- Anonymous usage data helps us improve features you use.
-
-
- Privacy Policy
-
-
-
-
-
-Wave is an open-source, AI-integrated terminal for macOS, Linux, and Windows. It works with any AI model. Bring your own API keys for OpenAI, Claude, or Gemini, or run local models via Ollama and LM Studio. No accounts required.
+Wave is an open-source terminal for macOS, Linux, and Windows. No accounts required.
-Wave also supports durable SSH sessions that survive network interruptions and restarts, with automatic reconnection. Edit remote files with a built-in graphical editor and preview files inline without leaving the terminal.
+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
@@ -30,19 +30,17 @@ This fork is optimized for remote development workflows with a focus on macOS.
- **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)
-- **Planned changes** — SSH port forwarding, reduced AI features, MOSH support, vertical tabs, SSH config as source of truth for connections
+- **Planned changes** — SSH port forwarding, remove unnecessary AI features, MOSH support, vertical tabs, SSH config as source of truth for connections

## 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
@@ -50,17 +48,6 @@ This fork is optimized for remote development workflows with a focus on macOS.
- 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
-- **Coming Soon**: Command execution (with approval)
-
## Installation
Wave Terminal works on macOS, Linux, and Windows.
From 0cd6489b3302ad73179918ccae80e2d55ebfda6c Mon Sep 17 00:00:00 2001
From: Jeremy Lam
Date: Thu, 14 May 2026 02:37:57 +0000
Subject: [PATCH 09/19] Fix crash on tab close after SSH session exit
Root cause: CloseTab launched an explicit goroutine calling
DestroyBlockController while DeleteTab -> DeleteBlock ->
BlockCloseEvent triggered the same destruction again,
causing concurrent double-Stop on ShellController and
DurableShellController.
Fixes:
- Remove redundant DestroyBlockController goroutine from CloseTab
- Add sync.Once to ShellProc.Close() as defense-in-depth
- Add trace logging for interactive diagnosis
- Add 14 unit tests covering the race conditions
Resolves: .pi/specs/bug-tabclose-crash.md
---
pkg/blockcontroller/blockcontroller.go | 4 +
pkg/blockcontroller/blockcontroller_test.go | 761 ++++++++++++++++++
pkg/blockcontroller/durableshellcontroller.go | 1 +
pkg/blockcontroller/shellcontroller.go | 3 +
.../workspaceservice/workspaceservice.go | 15 +-
pkg/shellexec/shellexec.go | 31 +-
6 files changed, 792 insertions(+), 23 deletions(-)
create mode 100644 pkg/blockcontroller/blockcontroller_test.go
diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go
index 75f1938e12..5adc178362 100644
--- a/pkg/blockcontroller/blockcontroller.go
+++ b/pkg/blockcontroller/blockcontroller.go
@@ -143,6 +143,7 @@ func handleBlockCloseEvent(event *wps.WaveEvent) {
log.Printf("[blockclose] invalid event data type")
return
}
+ log.Printf("[blockclose] block=%s: launching DestroyBlockController goroutine from event handler", blockId)
go DestroyBlockController(blockId)
}
@@ -295,11 +296,14 @@ func GetBlockControllerRuntimeStatus(blockId string) *BlockControllerRuntimeStat
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)
}
func sendConnMonitorInputNotification(controller Controller) {
diff --git a/pkg/blockcontroller/blockcontroller_test.go b/pkg/blockcontroller/blockcontroller_test.go
new file mode 100644
index 0000000000..cc73cecaa1
--- /dev/null
+++ b/pkg/blockcontroller/blockcontroller_test.go
@@ -0,0 +1,761 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package blockcontroller
+
+import (
+ "errors"
+ "io"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/wavetermdev/waveterm/pkg/shellexec"
+ "github.com/wavetermdev/waveterm/pkg/utilds"
+
+)
+
+// mockConnInterface implements shellexec.ConnInterface for testing.
+// It tracks calls to Kill, KillGraceful, Wait, Close to detect double-calls.
+type mockConnInterface struct {
+ mu sync.Mutex
+ killCount int
+ killGracefulCount int
+ waitCount int
+ closeCount int
+ waitErr error
+ waitCh chan struct{} // closed when Wait is called
+ fdVal uintptr
+ nameVal string
+}
+
+func (m *mockConnInterface) Fd() uintptr { return m.fdVal }
+func (m *mockConnInterface) Name() string { return m.nameVal }
+func (m *mockConnInterface) Read(p []byte) (int, error) { return 0, io.EOF }
+func (m *mockConnInterface) Write(p []byte) (int, error) { return len(p), nil }
+func (m *mockConnInterface) Close() error {
+ m.mu.Lock()
+ m.closeCount++
+ m.mu.Unlock()
+ return nil
+}
+func (m *mockConnInterface) WriteString(s string) (int, error) { return len(s), nil }
+
+func (m *mockConnInterface) Kill() {
+ m.mu.Lock()
+ m.killCount++
+ m.mu.Unlock()
+}
+
+func (m *mockConnInterface) KillGraceful(timeout time.Duration) {
+ m.mu.Lock()
+ m.killGracefulCount++
+ m.mu.Unlock()
+}
+
+func (m *mockConnInterface) Wait() error {
+ m.mu.Lock()
+ m.waitCount++
+ m.mu.Unlock()
+ if m.waitCh != nil {
+ close(m.waitCh)
+ m.waitCh = nil
+ }
+ return m.waitErr
+}
+
+func (m *mockConnInterface) Start() error { return nil }
+func (m *mockConnInterface) ExitCode() int { return 0 }
+func (m *mockConnInterface) ExitSignal() string { return "" }
+func (m *mockConnInterface) StdinPipe() (io.WriteCloser, error) { return nil, nil }
+func (m *mockConnInterface) StdoutPipe() (io.ReadCloser, error) { return nil, nil }
+func (m *mockConnInterface) StderrPipe() (io.ReadCloser, error) { return nil, nil }
+func (m *mockConnInterface) SetSize(w int, h int) error { return nil }
+
+// slowMockConnInterface is like mockConnInterface but Wait() blocks for a
+// configurable duration, simulating a real SSH session that takes time to exit.
+// This is essential for exposing the Lock/Unlock/Relock race in ShellController.Stop.
+type slowMockConnInterface struct {
+ mu sync.Mutex
+ killCount int
+ killGracefulCount int
+ waitCount int
+ closeCount int
+ waitDone chan struct{} // signals when Wait() should return
+ waitStarted chan struct{} // signals when Wait() has been entered
+}
+
+func (m *slowMockConnInterface) Fd() uintptr { return 0 }
+func (m *slowMockConnInterface) Name() string { return "slow-mock" }
+func (m *slowMockConnInterface) Read(p []byte) (int, error) { return 0, io.EOF }
+func (m *slowMockConnInterface) Write(p []byte) (int, error) { return len(p), nil }
+func (m *slowMockConnInterface) Close() error {
+ m.mu.Lock()
+ m.closeCount++
+ m.mu.Unlock()
+ return nil
+}
+func (m *slowMockConnInterface) WriteString(s string) (int, error) { return len(s), nil }
+
+func (m *slowMockConnInterface) Kill() {
+ m.mu.Lock()
+ m.killCount++
+ m.mu.Unlock()
+}
+
+func (m *slowMockConnInterface) KillGraceful(timeout time.Duration) {
+ m.mu.Lock()
+ m.killGracefulCount++
+ m.mu.Unlock()
+ // Simulate the real KillGraceful: signal Wait to complete.
+ // Use select to avoid panic on double-close (the real SSH session
+ // Close does not panic on double-call, but our mock channel does).
+ select {
+ case <-m.waitDone:
+ // already closed
+ default:
+ close(m.waitDone)
+ }
+}
+
+func (m *slowMockConnInterface) Wait() error {
+ m.mu.Lock()
+ m.waitCount++
+ m.mu.Unlock()
+ if m.waitStarted != nil {
+ close(m.waitStarted)
+ m.waitStarted = nil
+ }
+ <-m.waitDone
+ return nil
+}
+
+func (m *slowMockConnInterface) Start() error { return nil }
+func (m *slowMockConnInterface) ExitCode() int { return 0 }
+func (m *slowMockConnInterface) ExitSignal() string { return "" }
+func (m *slowMockConnInterface) StdinPipe() (io.WriteCloser, error) { return nil, nil }
+func (m *slowMockConnInterface) StdoutPipe() (io.ReadCloser, error) { return nil, nil }
+func (m *slowMockConnInterface) StderrPipe() (io.ReadCloser, error) { return nil, nil }
+func (m *slowMockConnInterface) SetSize(w int, h int) error { return nil }
+
+func makeSlowMockShellProc() *shellexec.ShellProc {
+ mockCmd := &slowMockConnInterface{
+ waitDone: make(chan struct{}),
+ waitStarted: make(chan struct{}),
+ }
+ return &shellexec.ShellProc{
+ ConnName: "test",
+ Cmd: mockCmd,
+ CloseOnce: &sync.Once{},
+ DoneCh: make(chan any),
+ }
+}
+
+func makeMockShellProc() *shellexec.ShellProc {
+ mockCmd := &mockConnInterface{
+ waitCh: make(chan struct{}),
+ }
+ return &shellexec.ShellProc{
+ ConnName: "test",
+ Cmd: mockCmd,
+ CloseOnce: &sync.Once{},
+ DoneCh: make(chan any),
+ }
+}
+
+// TestShellControllerStopConcurrent tests that two concurrent Stop calls
+// on a ShellController do not cause a double-Close on the underlying ShellProc.
+//
+// This tests the race condition identified in the tab-close-after-SSH-exit bug:
+// CloseTab launches DestroyBlockController in a goroutine, and DeleteTab also
+// triggers DestroyBlockController via sendBlockCloseEvent. Both call Stop concurrently.
+//
+// The Lock/Unlock/Relock pattern in Stop creates a window where a second Stop
+// can enter and call ShellProc.Close() again while the first is waiting on DoneCh.
+func TestShellControllerStopConcurrent(t *testing.T) {
+ t.Parallel()
+
+ t.Run("double_stop_does_not_double_kill", func(t *testing.T) {
+ t.Parallel()
+
+ // Use the slow mock so Wait() blocks, exposing the Lock/Unlock/Relock race.
+ // With the fast mock, Wait() returns instantly so the race window is too small.
+ mockProc := makeSlowMockShellProc()
+ mockCmd := mockProc.Cmd.(*slowMockConnInterface)
+
+ sc := &ShellController{
+ Lock: &sync.Mutex{},
+ ControllerType: BlockController_Shell,
+ TabId: "test-tab",
+ BlockId: "test-block",
+ ConnName: "ssh:test",
+ RunLock: &atomic.Bool{},
+ ProcStatus: Status_Running,
+ ShellProc: mockProc,
+ VersionTs: utilds.VersionTs{},
+ }
+
+ var wg sync.WaitGroup
+ wg.Add(2)
+
+ // Launch two concurrent Stop calls (simulating the double-destroy race)
+ go func() {
+ defer wg.Done()
+ sc.Stop(true, Status_Done, true)
+ }()
+ go func() {
+ defer wg.Done()
+ // Small delay to let the first Stop acquire the lock and enter the graceful wait
+ time.Sleep(10 * time.Millisecond)
+ sc.Stop(true, Status_Done, true)
+ }()
+
+ wg.Wait()
+
+ // Verify that KillGraceful was called at most once.
+ // A double-call indicates the race condition is NOT protected.
+ mockCmd.mu.Lock()
+ killGracefulCount := mockCmd.killGracefulCount
+ closeCount := mockCmd.closeCount
+ mockCmd.mu.Unlock()
+
+ if killGracefulCount > 1 {
+ t.Errorf("KillGraceful was called %d times; expected at most 1. This indicates a race condition where concurrent Stop calls both close the ShellProc.", killGracefulCount)
+ }
+ if closeCount > 1 {
+ t.Errorf("Close was called %d times; expected at most 1. Double-close on SSH sessions can cause errors or panics.", closeCount)
+ }
+ })
+
+ t.Run("stop_after_proc_done_is_noop", func(t *testing.T) {
+ t.Parallel()
+
+ mockProc := makeMockShellProc()
+ mockCmd := mockProc.Cmd.(*mockConnInterface)
+
+ sc := &ShellController{
+ Lock: &sync.Mutex{},
+ ControllerType: BlockController_Shell,
+ TabId: "test-tab",
+ BlockId: "test-block-done",
+ ConnName: "ssh:test",
+ RunLock: &atomic.Bool{},
+ ProcStatus: Status_Done,
+ ShellProc: mockProc,
+ VersionTs: utilds.VersionTs{},
+ }
+
+ sc.Stop(true, Status_Done, true)
+
+ mockCmd.mu.Lock()
+ killCount := mockCmd.killCount
+ mockCmd.mu.Unlock()
+
+ if killCount != 0 {
+ t.Errorf("Kill was called %d times on a Done proc; expected 0 (should be a no-op)", killCount)
+ }
+ })
+
+ t.Run("stop_sets_status_done", func(t *testing.T) {
+ t.Parallel()
+
+ mockProc := makeMockShellProc()
+
+ sc := &ShellController{
+ Lock: &sync.Mutex{},
+ ControllerType: BlockController_Shell,
+ TabId: "test-tab",
+ BlockId: "test-block-status",
+ ConnName: "ssh:test",
+ RunLock: &atomic.Bool{},
+ ProcStatus: Status_Running,
+ ShellProc: mockProc,
+ VersionTs: utilds.VersionTs{},
+ }
+
+ sc.Stop(true, Status_Done, true)
+
+ if sc.ProcStatus != Status_Done {
+ t.Errorf("ProcStatus = %q; expected %q", sc.ProcStatus, Status_Done)
+ }
+ })
+}
+
+// TestDestroyBlockControllerDoubleCall tests that concurrent calls to
+// DestroyBlockController for the same blockId are safe and do not
+// cause double-Stops or other side effects.
+//
+// This directly tests the race in CloseTab where both the explicit goroutine
+// and the sendBlockCloseEvent handler call DestroyBlockController for the same block.
+func TestDestroyBlockControllerDoubleCall(t *testing.T) {
+ t.Parallel()
+
+ // Register a test controller
+ testBlockId := "test-block-destroy-double"
+ testController := &ShellController{
+ Lock: &sync.Mutex{},
+ ControllerType: BlockController_Shell,
+ TabId: "test-tab",
+ BlockId: testBlockId,
+ ConnName: "ssh:test",
+ RunLock: &atomic.Bool{},
+ ProcStatus: Status_Running,
+ ShellProc: makeMockShellProc(),
+ VersionTs: utilds.VersionTs{},
+ }
+
+ registerController(testBlockId, testController)
+
+ // Clean up registry after test
+ defer deleteController(testBlockId)
+
+ var wg sync.WaitGroup
+ wg.Add(2)
+
+ // Simulate the double-destroy race from CloseTab
+ go func() {
+ defer wg.Done()
+ DestroyBlockController(testBlockId)
+ }()
+ go func() {
+ defer wg.Done()
+ time.Sleep(5 * time.Millisecond) // slight delay to increase race likelihood
+ DestroyBlockController(testBlockId)
+ }()
+
+ wg.Wait()
+
+ // After both calls, the controller should be removed from the registry
+ controller := getController(testBlockId)
+ if controller != nil {
+ t.Errorf("controller still in registry after DestroyBlockController; expected nil")
+ }
+}
+
+// TestDestroyBlockControllerDoubleCallDurable tests the same double-destroy
+// race but with a DurableShellController, which uses jobcontroller.TerminateAndDetachJob.
+func TestDestroyBlockControllerDoubleCallDurable(t *testing.T) {
+ t.Parallel()
+
+ testBlockId := "test-block-destroy-durable"
+ testController := &DurableShellController{
+ Lock: &sync.Mutex{},
+ ControllerType: BlockController_Shell,
+ TabId: "test-tab",
+ BlockId: testBlockId,
+ ConnName: "ssh:test",
+ LastKnownStatus: Status_Init,
+ InputSessionId: "test-session",
+ }
+
+ registerController(testBlockId, testController)
+
+ defer deleteController(testBlockId)
+
+ var wg sync.WaitGroup
+ wg.Add(2)
+
+ go func() {
+ defer wg.Done()
+ DestroyBlockController(testBlockId)
+ }()
+ go func() {
+ defer wg.Done()
+ time.Sleep(5 * time.Millisecond)
+ DestroyBlockController(testBlockId)
+ }()
+
+ wg.Wait()
+
+ controller := getController(testBlockId)
+ if controller != nil {
+ t.Errorf("controller still in registry after DestroyBlockController; expected nil")
+ }
+}
+
+// TestDurableShellControllerStopConcurrent tests that two concurrent Stop calls
+// on a DurableShellController do not cause issues.
+// DurableShellController.Stop calls TerminateAndDetachJob without any lock,
+// so concurrent calls could race on the jobId field.
+func TestDurableShellControllerStopConcurrent(t *testing.T) {
+ t.Parallel()
+
+ t.Run("stop_with_empty_jobid_is_noop", func(t *testing.T) {
+ t.Parallel()
+
+ dsc := &DurableShellController{
+ Lock: &sync.Mutex{},
+ ControllerType: BlockController_Shell,
+ TabId: "test-tab",
+ BlockId: "test-block-durable-stop",
+ ConnName: "ssh:test",
+ LastKnownStatus: Status_Init,
+ InputSessionId: "test-session",
+ }
+
+ // Stop with no jobId should be a no-op
+ dsc.Stop(true, Status_Done, true)
+ // No panic = pass
+ })
+
+ t.Run("stop_without_destroy_is_noop", func(t *testing.T) {
+ t.Parallel()
+
+ dsc := &DurableShellController{
+ Lock: &sync.Mutex{},
+ ControllerType: BlockController_Shell,
+ TabId: "test-tab",
+ BlockId: "test-block-durable-stop-2",
+ ConnName: "ssh:test",
+ JobId: "test-job",
+ LastKnownStatus: Status_Running,
+ InputSessionId: "test-session",
+ }
+
+ // Stop with destroy=false should be a no-op
+ dsc.Stop(true, Status_Done, false)
+ // No panic = pass
+ })
+}
+
+// TestShellControllerStopNilShellProc tests that Stop handles the case
+// where ShellProc is nil (e.g., shell exited and was already cleaned up).
+func TestShellControllerStopNilShellProc(t *testing.T) {
+ t.Parallel()
+
+ t.Run("nil_proc_updates_status", func(t *testing.T) {
+ t.Parallel()
+
+ sc := &ShellController{
+ Lock: &sync.Mutex{},
+ ControllerType: BlockController_Shell,
+ TabId: "test-tab",
+ BlockId: "test-block-nil-proc",
+ ConnName: "ssh:test",
+ RunLock: &atomic.Bool{},
+ ProcStatus: Status_Running,
+ ShellProc: nil,
+ VersionTs: utilds.VersionTs{},
+ }
+
+ // When ShellProc is nil, Stop still updates status if newStatus differs
+ sc.Stop(true, Status_Done, true)
+ if sc.ProcStatus != Status_Done {
+ t.Errorf("ProcStatus = %q; expected %q", sc.ProcStatus, Status_Done)
+ }
+ })
+
+ t.Run("nil_proc_already_done_noop", func(t *testing.T) {
+ t.Parallel()
+
+ sc := &ShellController{
+ Lock: &sync.Mutex{},
+ ControllerType: BlockController_Shell,
+ TabId: "test-tab",
+ BlockId: "test-block-nil-proc-done",
+ ConnName: "ssh:test",
+ RunLock: &atomic.Bool{},
+ ProcStatus: Status_Done,
+ ShellProc: nil,
+ VersionTs: utilds.VersionTs{},
+ }
+
+ // When ProcStatus is already Done and ShellProc is nil, Stop is a no-op
+ sc.Stop(true, Status_Done, true)
+ // No panic = pass
+ })
+
+ t.Run("nil_proc_init_status", func(t *testing.T) {
+ t.Parallel()
+
+ sc := &ShellController{
+ Lock: &sync.Mutex{},
+ ControllerType: BlockController_Shell,
+ TabId: "test-tab",
+ BlockId: "test-block-nil-proc-init",
+ ConnName: "ssh:test",
+ RunLock: &atomic.Bool{},
+ ProcStatus: Status_Init,
+ ShellProc: nil,
+ VersionTs: utilds.VersionTs{},
+ }
+
+ // When ProcStatus is Init and ShellProc is nil, Stop is a no-op
+ sc.Stop(true, Status_Done, true)
+ // No panic = pass
+ })
+}
+
+// TestShellProcDoubleClose tests that calling ShellProc.Close() twice
+// does not panic. This simulates the actual race condition where
+// two concurrent Stop calls both close the ShellProc.
+func TestShellProcDoubleClose(t *testing.T) {
+ t.Parallel()
+
+ t.Run("double_close_on_running_proc", func(t *testing.T) {
+ t.Parallel()
+
+ mockProc := makeSlowMockShellProc()
+ mockCmd := mockProc.Cmd.(*slowMockConnInterface)
+
+ var wg sync.WaitGroup
+ wg.Add(2)
+
+ // Call Close concurrently (simulating the race)
+ go func() {
+ defer wg.Done()
+ mockProc.Close()
+ }()
+ go func() {
+ defer wg.Done()
+ time.Sleep(5 * time.Millisecond)
+ mockProc.Close()
+ }()
+
+ // The slowMockConnInterface.KillGraceful() already closes waitDone,
+ // so Wait() returns immediately. No manual close needed.
+ wg.Wait()
+
+ // Wait for the DoneCh to be closed
+ <-mockProc.DoneCh
+
+ mockCmd.mu.Lock()
+ killGracefulCount := mockCmd.killGracefulCount
+ closeCount := mockCmd.closeCount
+ mockCmd.mu.Unlock()
+
+ // KillGraceful may be called multiple times (once per Close call),
+ // but should not panic. Log if it happens more than once.
+ if killGracefulCount > 1 {
+ t.Logf("WARNING: KillGraceful was called %d times (expected 1). This indicates ShellProc.Close is not idempotent and the race condition allows double-kills.", killGracefulCount)
+ }
+ if closeCount > 1 {
+ t.Logf("WARNING: Close was called %d times (expected 1). Double-close on SSH sessions can cause errors.", closeCount)
+ }
+ })
+
+ t.Run("close_then_wait", func(t *testing.T) {
+ t.Parallel()
+
+ mockProc := makeMockShellProc()
+ mockCmd := mockProc.Cmd.(*mockConnInterface)
+
+ // Simulate the real flow: Close is called, then Wait is called
+ mockProc.Close()
+
+ // Wait for DoneCh
+ <-mockProc.DoneCh
+
+ // Second Close should not panic even after Wait
+ mockProc.Close()
+
+ mockCmd.mu.Lock()
+ waitCount := mockCmd.waitCount
+ mockCmd.mu.Unlock()
+
+ // Wait should only execute once (protected by sync.Once in ShellProc)
+ if waitCount > 1 {
+ t.Errorf("Wait was called %d times; expected at most 1 (should be protected by sync.Once)", waitCount)
+ }
+ })
+}
+
+// TestShellControllerStopRaceWithDoneStatus tests the specific race where
+// one goroutine sees ProcStatus as Running and enters Close, while another
+// goroutine updates ProcStatus to Done concurrently. This is the exact
+// scenario from the bug: the shell exits (setting Done) while the tab
+// close triggers Stop (seeing Running).
+func TestShellControllerStopRaceWithDoneStatus(t *testing.T) {
+ t.Parallel()
+
+ // This test simulates the real-world scenario:
+ // 1. Shell process exits (manageRunningShellProcess wait loop sets ProcStatus=Done)
+ // 2. Tab close triggers Stop (sees Running, calls Close)
+ // Both happen concurrently.
+
+ mockProc := makeSlowMockShellProc()
+ mockCmd := mockProc.Cmd.(*slowMockConnInterface)
+
+ sc := &ShellController{
+ Lock: &sync.Mutex{},
+ ControllerType: BlockController_Shell,
+ TabId: "test-tab",
+ BlockId: "test-block-race-done",
+ ConnName: "ssh:test",
+ RunLock: &atomic.Bool{},
+ ProcStatus: Status_Running,
+ ShellProc: mockProc,
+ VersionTs: utilds.VersionTs{},
+ }
+
+ var wg sync.WaitGroup
+ wg.Add(2)
+
+ // Goroutine 1: simulates tab close calling Stop
+ go func() {
+ defer wg.Done()
+ sc.Stop(true, Status_Done, true)
+ }()
+
+ // Goroutine 2: simulates the shell exiting (sets Done status)
+ // This mimics what manageRunningShellProcess does via UpdateControllerAndSendUpdate
+ go func() {
+ defer wg.Done()
+ time.Sleep(5 * time.Millisecond)
+ sc.UpdateControllerAndSendUpdate(func() bool {
+ if sc.ProcStatus == Status_Running {
+ sc.ProcStatus = Status_Done
+ }
+ return true
+ })
+ }()
+
+ wg.Wait()
+
+ // Final status should be Done
+ if sc.ProcStatus != Status_Done {
+ t.Errorf("ProcStatus = %q; expected %q", sc.ProcStatus, Status_Done)
+ }
+
+ mockCmd.mu.Lock()
+ killGracefulCount := mockCmd.killGracefulCount
+ mockCmd.mu.Unlock()
+
+ // The key question: was KillGraceful called even though the shell was exiting?
+ // In the current implementation, Stop checks ProcStatus BEFORE calling Close,
+ // but the Lock/Unlock/Relock pattern creates a window where the status may
+ // change between the check and the Close call.
+ if killGracefulCount > 1 {
+ t.Logf("KillGraceful was called %d times. This may indicate the race between shell exit and tab close causes redundant close operations.", killGracefulCount)
+ }
+}
+
+// TestShellControllerStopDoesNotPanicOnClosedSession tests that calling Stop
+// on a ShellController whose underlying SSH session has already been closed
+// (e.g., after user typed exit) does not panic.
+func TestShellControllerStopDoesNotPanicOnClosedSession(t *testing.T) {
+ t.Parallel()
+
+ t.Run("closed_session_stop", func(t *testing.T) {
+ t.Parallel()
+
+ // mockClosedConn simulates an SSH session that has already been closed.
+ // KillGraceful and Close return errors (simulating a closed channel).
+ mockCmd := &mockClosedConnInterface{}
+
+ mockProc := &shellexec.ShellProc{
+ ConnName: "ssh:test",
+ Cmd: mockCmd,
+ CloseOnce: &sync.Once{},
+ DoneCh: make(chan any),
+ }
+
+ sc := &ShellController{
+ Lock: &sync.Mutex{},
+ ControllerType: BlockController_Shell,
+ TabId: "test-tab",
+ BlockId: "test-block-closed-session",
+ ConnName: "ssh:test",
+ RunLock: &atomic.Bool{},
+ ProcStatus: Status_Running,
+ ShellProc: mockProc,
+ VersionTs: utilds.VersionTs{},
+ }
+
+ // Stop should not panic even when the underlying session is closed
+ sc.Stop(true, Status_Done, true)
+ if sc.ProcStatus != Status_Done {
+ t.Errorf("ProcStatus = %q; expected %q", sc.ProcStatus, Status_Done)
+ }
+ })
+
+ t.Run("concurrent_stop_on_closing_session", func(t *testing.T) {
+ t.Parallel()
+
+ // Test concurrent Stop calls on a session that is closing (simulating
+ // the real scenario where the SSH shell has exited and tab close happens).
+ mockProc := makeSlowMockShellProc()
+ mockCmd := mockProc.Cmd.(*slowMockConnInterface)
+
+ sc := &ShellController{
+ Lock: &sync.Mutex{},
+ ControllerType: BlockController_Shell,
+ TabId: "test-tab",
+ BlockId: "test-block-concurrent-close",
+ ConnName: "ssh:test",
+ RunLock: &atomic.Bool{},
+ ProcStatus: Status_Running,
+ ShellProc: mockProc,
+ VersionTs: utilds.VersionTs{},
+ }
+
+ var wg sync.WaitGroup
+ wg.Add(3)
+
+ // Goroutine 1: First Stop call (simulates CloseTab goroutine)
+ go func() {
+ defer wg.Done()
+ sc.Stop(true, Status_Done, true)
+ }()
+
+ // Goroutine 2: Second Stop call (simulates sendBlockCloseEvent handler)
+ go func() {
+ defer wg.Done()
+ time.Sleep(10 * time.Millisecond)
+ sc.Stop(true, Status_Done, true)
+ }()
+
+ // Goroutine 3: Simulate the shell exiting concurrently.
+ // In this mock, KillGraceful already signals Wait to complete,
+ // so no manual channel close is needed.
+ go func() {
+ defer wg.Done()
+ time.Sleep(20 * time.Millisecond)
+ }()
+
+ wg.Wait()
+
+ mockCmd.mu.Lock()
+ killGracefulCount := mockCmd.killGracefulCount
+ closeCount := mockCmd.closeCount
+ mockCmd.mu.Unlock()
+
+ if killGracefulCount > 1 {
+ t.Errorf("KillGraceful was called %d times on a closing session; expected at most 1. Double-kill on a closing SSH session can cause errors or panics in the ssh package.", killGracefulCount)
+ }
+ if closeCount > 1 {
+ t.Errorf("Close was called %d times on a closing session; expected at most 1.", closeCount)
+ }
+ })
+}
+
+// mockClosedConnInterface simulates a closed SSH session where operations
+// return errors (as would happen after the remote side has exited/closed).
+type mockClosedConnInterface struct {
+ mu sync.Mutex
+ closeCount int
+}
+
+func (m *mockClosedConnInterface) Fd() uintptr { return 0 }
+func (m *mockClosedConnInterface) Name() string { return "closed-session" }
+func (m *mockClosedConnInterface) Read(p []byte) (int, error) { return 0, io.EOF }
+func (m *mockClosedConnInterface) Write(p []byte) (int, error) { return 0, errors.New("session closed") }
+func (m *mockClosedConnInterface) Close() error {
+ m.mu.Lock()
+ m.closeCount++
+ m.mu.Unlock()
+ return errors.New("session already closed")
+}
+func (m *mockClosedConnInterface) WriteString(s string) (int, error) { return 0, errors.New("session closed") }
+
+func (m *mockClosedConnInterface) Kill() {}
+func (m *mockClosedConnInterface) KillGraceful(timeout time.Duration) {}
+func (m *mockClosedConnInterface) Wait() error { return errors.New("session exited") }
+func (m *mockClosedConnInterface) Start() error { return errors.New("session closed") }
+func (m *mockClosedConnInterface) ExitCode() int { return 0 }
+func (m *mockClosedConnInterface) ExitSignal() string { return "" }
+func (m *mockClosedConnInterface) StdinPipe() (io.WriteCloser, error) { return nil, errors.New("session closed") }
+func (m *mockClosedConnInterface) StdoutPipe() (io.ReadCloser, error) { return nil, errors.New("session closed") }
+func (m *mockClosedConnInterface) StderrPipe() (io.ReadCloser, error) { return nil, errors.New("session closed") }
+func (m *mockClosedConnInterface) SetSize(w int, h int) error { return errors.New("session closed") }
\ No newline at end of file
diff --git a/pkg/blockcontroller/durableshellcontroller.go b/pkg/blockcontroller/durableshellcontroller.go
index a21dac153b..b164eb3631 100644
--- a/pkg/blockcontroller/durableshellcontroller.go
+++ b/pkg/blockcontroller/durableshellcontroller.go
@@ -194,6 +194,7 @@ func (dsc *DurableShellController) Stop(graceful bool, newStatus string, destroy
return
}
jobId := dsc.getJobId()
+ log.Printf("[durableshellcontroller] Stop block=%s jobId=%s destroy=%v", dsc.BlockId, jobId, destroy)
if jobId == "" {
return
}
diff --git a/pkg/blockcontroller/shellcontroller.go b/pkg/blockcontroller/shellcontroller.go
index a410225394..99cc68ad07 100644
--- a/pkg/blockcontroller/shellcontroller.go
+++ b/pkg/blockcontroller/shellcontroller.go
@@ -97,6 +97,7 @@ func (sc *ShellController) Start(ctx context.Context, blockMeta waveobj.MetaMapT
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 {
@@ -110,8 +111,10 @@ func (sc *ShellController) Stop(graceful bool, newStatus string, destroy bool) {
if graceful {
doneCh := sc.ShellProc.DoneCh
sc.Lock.Unlock() // Unlock before waiting
+ log.Printf("[shellcontroller] Stop block=%s waiting on DoneCh (lock released)", sc.BlockId)
<-doneCh
sc.Lock.Lock() // Re-lock after waiting
+ log.Printf("[shellcontroller] Stop block=%s DoneCh closed (lock reacquired)", sc.BlockId)
}
// Update status
diff --git a/pkg/service/workspaceservice/workspaceservice.go b/pkg/service/workspaceservice/workspaceservice.go
index 1d7b116bdc..0b8bee7147 100644
--- a/pkg/service/workspaceservice/workspaceservice.go
+++ b/pkg/service/workspaceservice/workspaceservice.go
@@ -6,9 +6,9 @@ package workspaceservice
import (
"context"
"fmt"
+ "log"
"time"
- "github.com/wavetermdev/waveterm/pkg/blockcontroller"
"github.com/wavetermdev/waveterm/pkg/panichandler"
"github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta"
"github.com/wavetermdev/waveterm/pkg/waveobj"
@@ -217,14 +217,11 @@ func (svc *WorkspaceService) CloseTab_Meta() tsgenmeta.MethodMeta {
// returns the new active tabid
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 {
- go func() {
- for _, blockId := range tab.BlockIds {
- blockcontroller.DestroyBlockController(blockId)
- }
- }()
- }
+ log.Printf("[closetab] tab=%s: starting close via DeleteTab (blocks will be destroyed by BlockCloseEvent)", tabId)
+ // DeleteTab iterates blocks and calls DeleteBlock, which fires
+ // BlockCloseEvent -> handleBlockCloseEvent -> DestroyBlockController.
+ // Do NOT call DestroyBlockController here; doing so creates a race
+ // where the controller is destroyed twice concurrently.
newActiveTabId, err := wcore.DeleteTab(ctx, workspaceId, tabId, true)
if err != nil {
return nil, nil, fmt.Errorf("error closing tab: %w", err)
diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go
index 35af5446a3..209c858643 100644
--- a/pkg/shellexec/shellexec.go
+++ b/pkg/shellexec/shellexec.go
@@ -52,24 +52,27 @@ type ShellProc struct {
CloseOnce *sync.Once
DoneCh chan any // closed after proc.Wait() returns
WaitErr error // WaitErr is synchronized by DoneCh (written before DoneCh is closed) and CloseOnce
+ closeOnce sync.Once // ensures Close() is idempotent; defends against double-close races
}
func (sp *ShellProc) Close() {
- sp.Cmd.KillGraceful(DefaultGracefulKillWait)
- go func() {
- defer func() {
- panichandler.PanicHandler("ShellProc.Close", recover())
+ sp.closeOnce.Do(func() {
+ sp.Cmd.KillGraceful(DefaultGracefulKillWait)
+ go func() {
+ defer func() {
+ panichandler.PanicHandler("ShellProc.Close", recover())
+ }()
+ waitErr := sp.Cmd.Wait()
+ sp.SetWaitErrorAndSignalDone(waitErr)
+
+ // windows cannot handle the pty being
+ // closed twice, so we let the pty
+ // close itself instead
+ if runtime.GOOS != "windows" {
+ sp.Cmd.Close()
+ }
}()
- waitErr := sp.Cmd.Wait()
- sp.SetWaitErrorAndSignalDone(waitErr)
-
- // windows cannot handle the pty being
- // closed twice, so we let the pty
- // close itself instead
- if runtime.GOOS != "windows" {
- sp.Cmd.Close()
- }
- }()
+ })
}
func (sp *ShellProc) SetWaitErrorAndSignalDone(waitErr error) {
From b2836c467fe14f8eaa5b087ae1dfdd9bd898e37e Mon Sep 17 00:00:00 2001
From: Jeremy Lam
Date: Thu, 14 May 2026 12:01:53 +0000
Subject: [PATCH 10/19] Remove trace logging added for tab-close crash
diagnosis
Logging was added to verify the fix; crash confirmed resolved
interactively. Stripping to avoid polluting production logs.
---
emain/emain-tabview.ts | 2 -
emain/emain-window.ts | 7 -
emain/preload.ts | 1 -
frontend/app/app.tsx | 15 --
frontend/app/block/blockframe.tsx | 3 +-
frontend/app/block/blockregistry.ts | 4 -
frontend/app/block/blockutil.tsx | 6 -
frontend/app/monaco/schemaendpoints.ts | 12 -
.../app/onboarding/onboarding-features.tsx | 100 +-------
frontend/app/store/focusManager.ts | 49 +---
frontend/app/store/global-atoms.ts | 16 --
frontend/app/store/global.ts | 12 -
frontend/app/store/keymodel.ts | 107 +--------
frontend/app/store/tabrpcclient.ts | 31 ---
frontend/app/tab/tabbar.tsx | 33 +--
frontend/app/tab/vtabbar.tsx | 30 ---
frontend/app/view/term/term-model.ts | 26 +-
frontend/app/view/waveai/waveai.tsx | 10 +-
.../app/view/waveconfig/waveconfig-model.ts | 23 --
frontend/app/workspace/widgets.tsx | 1 -
.../app/workspace/workspace-layout-model.ts | 224 +-----------------
frontend/app/workspace/workspace.tsx | 63 +----
frontend/layout/lib/layoutModel.ts | 12 +-
frontend/preview/mock/defaultconfig.ts | 2 -
frontend/preview/mock/mockwaveenv.ts | 3 -
.../preview/previews/onboarding.preview.tsx | 5 +-
frontend/types/custom.d.ts | 3 -
frontend/wave.ts | 4 -
pkg/blockcontroller/blockcontroller.go | 4 -
pkg/blockcontroller/durableshellcontroller.go | 1 -
pkg/blockcontroller/shellcontroller.go | 3 -
.../workspaceservice/workspaceservice.go | 2 -
32 files changed, 34 insertions(+), 780 deletions(-)
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-window.ts b/emain/emain-window.ts
index e3bfa87751..96bcade6a2 100644
--- a/emain/emain-window.ts
+++ b/emain/emain-window.ts
@@ -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/preload.ts b/emain/preload.ts
index d9cfa4230f..e23dfec447 100644
--- a/emain/preload.ts
+++ b/emain/preload.ts
@@ -62,7 +62,6 @@ 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"),
nativePaste: () => ipcRenderer.send("native-paste"),
openBuilder: (appId?: string) => ipcRenderer.send("open-builder", appId),
diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx
index e9c70a35df..297e9879e1 100644
--- a/frontend/app/app.tsx
+++ b/frontend/app/app.tsx
@@ -225,16 +225,6 @@ const MacOSFirstClickHandler = () => {
}
return null;
};
- const isAIPanelTarget = (target: EventTarget): boolean => {
- let elem = target as HTMLElement;
- while (elem != null) {
- if (elem.dataset?.aipanel) {
- return true;
- }
- elem = elem.parentElement;
- }
- return false;
- };
const handleMouseDown = (e: MouseEvent) => {
const timeDiff = Date.now() - windowFocusTime;
if (windowFocusTime != null && timeDiff < 50) {
@@ -248,11 +238,6 @@ const MacOSFirstClickHandler = () => {
console.log("macos first-click, focusing block", blockId);
refocusNode(blockId);
}, 10);
- } else if (isAIPanelTarget(e.target)) {
- setTimeout(() => {
- console.log("macos first-click, focusing AI panel");
- FocusManager.getInstance().setWaveAIFocused(true);
- }, 10);
}
console.log("macos first-click detected, canceled", timeDiff + "ms");
return;
diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx
index 8ff2e2d0a7..0f7d231ed2 100644
--- a/frontend/app/block/blockframe.tsx
+++ b/frontend/app/block/blockframe.tsx
@@ -95,7 +95,6 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => {
const waveEnv = useWaveEnv();
const { nodeModel, viewModel, blockModel, preview, numBlocksInTab, children } = props;
const isFocused = jotai.useAtomValue(nodeModel.isFocused);
- const aiPanelVisible = jotai.useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom);
const metaView = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view"));
const viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(metaView);
const customBg = util.useAtomValueSafe(viewModel?.blockBg);
@@ -170,7 +169,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => {
className={clsx("block", "block-frame-default", "block-" + nodeModel.blockId, {
"block-focused": isFocused || preview,
"block-preview": preview,
- "block-no-highlight": numBlocksInTab === 1 && !aiPanelVisible,
+ "block-no-highlight": numBlocksInTab === 1,
ephemeral: isEphemeral,
magnified: isMagnified,
})}
diff --git a/frontend/app/block/blockregistry.ts b/frontend/app/block/blockregistry.ts
index 5de7e05bd3..e68d0fadc2 100644
--- a/frontend/app/block/blockregistry.ts
+++ b/frontend/app/block/blockregistry.ts
@@ -3,7 +3,6 @@
import { BlockNodeModel } from "@/app/block/blocktypes";
import type { TabModel } from "@/app/store/tab-model";
-import { AiFileDiffViewModel } from "@/app/view/aifilediff/aifilediff";
import { LauncherViewModel } from "@/app/view/launcher/launcher";
import { PreviewModel } from "@/app/view/preview/preview-model";
import { ProcessViewerViewModel } from "@/app/view/processviewer/processviewer";
@@ -17,14 +16,12 @@ import { WaveConfigViewModel } from "../view/waveconfig/waveconfig-model";
import { blockViewToIcon, blockViewToName } from "./blockutil";
import { HelpViewModel } from "@/view/helpview/helpview";
import { TermViewModel } from "@/view/term/term-model";
-import { WaveAiModel } from "@/view/waveai/waveai";
import { WebViewModel } from "@/view/webview/webview";
const BlockRegistry: Map = new Map();
BlockRegistry.set("term", TermViewModel);
BlockRegistry.set("preview", PreviewModel);
BlockRegistry.set("web", WebViewModel);
-BlockRegistry.set("waveai", WaveAiModel);
BlockRegistry.set("cpuplot", SysinfoViewModel);
BlockRegistry.set("sysinfo", SysinfoViewModel);
BlockRegistry.set("vdom", VDomModel);
@@ -32,7 +29,6 @@ BlockRegistry.set("tips", QuickTipsViewModel);
BlockRegistry.set("help", HelpViewModel);
BlockRegistry.set("launcher", LauncherViewModel);
BlockRegistry.set("tsunami", TsunamiViewModel);
-BlockRegistry.set("aifilediff", AiFileDiffViewModel);
BlockRegistry.set("waveconfig", WaveConfigViewModel);
BlockRegistry.set("processviewer", ProcessViewerViewModel);
diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx
index 3ef4d39821..be725496ad 100644
--- a/frontend/app/block/blockutil.tsx
+++ b/frontend/app/block/blockutil.tsx
@@ -33,9 +33,6 @@ export function blockViewToIcon(view: string): string {
if (view == "web") {
return "globe";
}
- if (view == "waveai") {
- return "sparkles";
- }
if (view == "help") {
return "circle-question";
}
@@ -61,9 +58,6 @@ export function blockViewToName(view: string): string {
if (view == "web") {
return "Web";
}
- if (view == "waveai") {
- return "WaveAI";
- }
if (view == "help") {
return "Help";
}
diff --git a/frontend/app/monaco/schemaendpoints.ts b/frontend/app/monaco/schemaendpoints.ts
index 5365d1c739..a272ca120f 100644
--- a/frontend/app/monaco/schemaendpoints.ts
+++ b/frontend/app/monaco/schemaendpoints.ts
@@ -1,11 +1,9 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
-import aipresetsSchema from "../../../schema/aipresets.json";
import backgroundsSchema from "../../../schema/backgrounds.json";
import connectionsSchema from "../../../schema/connections.json";
import settingsSchema from "../../../schema/settings.json";
-import waveaiSchema from "../../../schema/waveai.json";
import widgetsSchema from "../../../schema/widgets.json";
type SchemaInfo = {
@@ -25,21 +23,11 @@ const MonacoSchemas: SchemaInfo[] = [
fileMatch: ["*/WAVECONFIGPATH/connections.json"],
schema: connectionsSchema,
},
- {
- uri: "wave://schema/aipresets.json",
- fileMatch: ["*/WAVECONFIGPATH/presets/ai.json"],
- schema: aipresetsSchema,
- },
{
uri: "wave://schema/backgrounds.json",
fileMatch: ["*/WAVECONFIGPATH/backgrounds.json"],
schema: backgroundsSchema,
},
- {
- uri: "wave://schema/waveai.json",
- fileMatch: ["*/WAVECONFIGPATH/waveai.json"],
- schema: waveaiSchema,
- },
{
uri: "wave://schema/widgets.json",
fileMatch: ["*/WAVECONFIGPATH/widgets.json"],
diff --git a/frontend/app/onboarding/onboarding-features.tsx b/frontend/app/onboarding/onboarding-features.tsx
index 97836d2bc3..e47dadd9a4 100644
--- a/frontend/app/onboarding/onboarding-features.tsx
+++ b/frontend/app/onboarding/onboarding-features.tsx
@@ -10,94 +10,13 @@ import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { isMacOS } from "@/util/platformutil";
import { useEffect, useState } from "react";
-import { FakeChat } from "./fakechat";
import { EditBashrcCommand, ViewLogoCommand, ViewShortcutsCommand } from "./onboarding-command";
import { CurrentOnboardingVersion } from "./onboarding-common";
import { DurableSessionPage } from "./onboarding-durable";
import { OnboardingFooter } from "./onboarding-features-footer";
import { FakeLayout } from "./onboarding-layout";
-type FeaturePageName = "waveai" | "durable" | "magnify" | "files";
-
-export const WaveAIPage = ({ onNext, onSkip }: { onNext: () => void; onSkip: () => void }) => {
- const isMac = isMacOS();
- const shortcutKey = isMac ? "⌘-Shift-A" : "Alt-Shift-A";
- const [fireClicked, setFireClicked] = useState(false);
-
- const handleFireClick = () => {
- setFireClicked(!fireClicked);
- if (!fireClicked) {
- }
- };
-
- return (
-
-
-
-
-
-
Wave AI
-
-
-
-
-
-
- AI
-
-
-
-
- Wave AI is your terminal assistant with context. I can read your terminal output,
- analyze widgets, read/write files, and help you solve problems faster.
-
-
-
-
-
- Toggle the Wave AI panel with the{" "}
-
-
- AI
- {" "}
- button in the header (top left)
-
-
-
-
-
-
- Or use the keyboard shortcut{" "}
-
- {shortcutKey}
- {" "}
- to quickly toggle
-
-
-
-
-
-
- Bring your own API keys or run local models with Ollama, LM Studio, and other
- OpenAI-compatible providers
-
- This older AI widget has been retired. Please use the modern Wave AI panel for AI chats, terminal
- context, tools, and uploads going forward.
+ This older AI widget has been retired.
From 4cce99c0c2178408594107af2826181a34f4c232 Mon Sep 17 00:00:00 2001
From: Jeremy Lam
Date: Sat, 16 May 2026 00:19:05 +0000
Subject: [PATCH 13/19] fix(term): remove AI sparkle icon from terminal block
header
Remove getShellIntegrationIconButton() implementation (sparkle/Claude logo
and Wave AI tooltips) and replace with no-op stub returning null.
- Remove TermClaudeIcon import from term-model.ts
- Underlying shell integration protocol (OSC 16162) left intact; could be
reused later for pi coding agent support (documented in .pi/decisions.md)
Docs: Add .pi/decisions.md with Claude Code integration analysis
---
.pi/decisions.md | 49 +++++++++++++++++++++++++++
frontend/app/view/term/term-model.ts | 50 ++--------------------------
2 files changed, 51 insertions(+), 48 deletions(-)
create mode 100644 .pi/decisions.md
diff --git a/.pi/decisions.md b/.pi/decisions.md
new file mode 100644
index 0000000000..02c190abab
--- /dev/null
+++ b/.pi/decisions.md
@@ -0,0 +1,49 @@
+# Architecture Decisions — waveterm-remote Fork
+
+## 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.
diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts
index b6b2a0e3b8..7ffe07d747 100644
--- a/frontend/app/view/term/term-model.ts
+++ b/frontend/app/view/term/term-model.ts
@@ -9,7 +9,7 @@ import { waveEventSubscribeSingle } from "@/app/store/wps";
import { RpcApi } from "@/app/store/wshclientapi";
import { makeFeBlockRouteId } from "@/app/store/wshrouter";
import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil";
-import { TermClaudeIcon, TerminalView } from "@/app/view/term/term";
+import { TerminalView } from "@/app/view/term/term";
import { TermWshClient } from "@/app/view/term/term-wsh";
import { VDomModel } from "@/app/view/vdom/vdom-model";
import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
@@ -393,53 +393,7 @@ export class TermViewModel implements ViewModel {
});
}
- getShellIntegrationIconButton(get: jotai.Getter): IconButtonDecl | null {
- if (!this.termRef.current?.shellIntegrationStatusAtom) {
- return null;
- }
- const shellIntegrationStatus = get(this.termRef.current.shellIntegrationStatusAtom);
- const claudeCodeActive = get(this.termRef.current.claudeCodeActiveAtom);
- const icon = claudeCodeActive ? React.createElement(TermClaudeIcon) : "sparkles";
- if (shellIntegrationStatus == null) {
- return {
- elemtype: "iconbutton",
- icon,
- className: "text-muted",
- title: "No shell integration — Wave AI unable to run commands.",
- noAction: true,
- };
- }
- if (shellIntegrationStatus === "ready") {
- return {
- elemtype: "iconbutton",
- icon,
- className: "text-accent",
- title: "Shell ready — Wave AI can run commands in this terminal.",
- noAction: true,
- };
- }
- if (shellIntegrationStatus === "running-command") {
- let title = claudeCodeActive
- ? "Claude Code Detected"
- : "Shell busy — Wave AI unable to run commands while another command is running.";
-
- if (this.termRef.current) {
- const inAltBuffer = this.termRef.current.terminal?.buffer?.active?.type === "alternate";
- const lastCommand = get(this.termRef.current.lastCommandAtom);
- const blockingCmd = getBlockingCommand(lastCommand, inAltBuffer);
- if (blockingCmd) {
- title = `Wave AI integration disabled while you're inside ${blockingCmd}.`;
- }
- }
-
- return {
- elemtype: "iconbutton",
- icon,
- className: "text-warning",
- title: title,
- noAction: true,
- };
- }
+ getShellIntegrationIconButton(_get: jotai.Getter): IconButtonDecl | null {
return null;
}
From 234b301ff797c69357c0d84c85ccf140253e8ebc Mon Sep 17 00:00:00 2001
From: Jeremy Lam
Date: Sat, 16 May 2026 05:31:21 +0000
Subject: [PATCH 14/19] docs: merge .pi/ project memory from waveterm to
waveterm-remote
Move all fork planning docs into the active working directory:
- Copy specs (remove-waveai, remove-telemetry, portforwarding, bug-tabclose-crash)
- Copy context.md, index.md, todos.md
- Merge decisions.md: prepend original ADRs (fork purpose, .pi/ hub, port forwarding
config-first approach, tab-close crash fix, secret store keep) with today's
Claude Code integration analysis for future pi agent support
---
.pi/context.md | 50 +++
.pi/decisions.md | 77 ++++-
.pi/index.md | 28 ++
.pi/specs/bug-tabclose-crash.md | 442 ++++++++++++++++++++++++
.pi/specs/portforwarding.md | 417 +++++++++++++++++++++++
.pi/specs/remove-telemetry.md | 575 ++++++++++++++++++++++++++++++++
.pi/specs/remove-waveai.md | 439 ++++++++++++++++++++++++
.pi/todos.md | 101 ++++++
8 files changed, 2127 insertions(+), 2 deletions(-)
create mode 100644 .pi/context.md
create mode 100644 .pi/index.md
create mode 100644 .pi/specs/bug-tabclose-crash.md
create mode 100644 .pi/specs/portforwarding.md
create mode 100644 .pi/specs/remove-telemetry.md
create mode 100644 .pi/specs/remove-waveai.md
create mode 100644 .pi/todos.md
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
index 02c190abab..853224ab51 100644
--- a/.pi/decisions.md
+++ b/.pi/decisions.md
@@ -1,4 +1,77 @@
-# Architecture Decisions — waveterm-remote Fork
+# 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
@@ -23,7 +96,7 @@
### 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
+- `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
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/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/portforwarding.md b/.pi/specs/portforwarding.md
new file mode 100644
index 0000000000..4855522b10
--- /dev/null
+++ b/.pi/specs/portforwarding.md
@@ -0,0 +1,417 @@
+# 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. 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
+ }
+ bindAddr, dest := bindAddr, dest // capture for goroutine
+ go func() {
+ defer panichandler.PanicHandler("conncontroller:localforward-tunnel", recover())
+ remoteConn, err := client.Dial("tcp", dest)
+ if err != nil {
+ localConn.Close()
+ return
+ }
+ conn.MonitorUpdate(localConn, remoteConn)
+ io.CopyBoth(localConn, remoteConn)
+ localConn.Close()
+ remoteConn.Close()
+ }()
+ }
+ }()
+ }
+
+ // 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
+ }
+ bindAddr, dest := bindAddr, dest // capture for goroutine
+ go func() {
+ defer panichandler.PanicHandler("conncontroller:remoteforward-tunnel", recover())
+ localConn, err := net.Dial("tcp", dest)
+ if err != nil {
+ remoteConn.Close()
+ return
+ }
+ conn.MonitorUpdate(remoteConn, localConn)
+ io.CopyBoth(localConn, remoteConn)
+ localConn.Close()
+ remoteConn.Close()
+ }()
+ }
+ }()
+ }
+}
+```
+
+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)
+- `io.CopyBoth` for bidirectional tunneling (standard Go pattern)
+- Variable capture (`bindAddr, dest := bindAddr, dest`) prevents goroutine closure bugs
+
+#### 3c. `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.
+
+#### 3d. `closeInternal_withlifecyclelock()` — Cleanup
+
+Add listener cleanup before the existing `client.Close()` call:
+
+```go
+// Close local forward listeners
+conn.WithLock(func() {
+ for _, l := range conn.LocalForwardListeners {
+ l.Close()
+ }
+ conn.LocalForwardListeners = nil
+ for _, l := range conn.RemoteForwardListeners {
+ l.Close()
+ }
+ conn.RemoteForwardListeners = nil
+})
+```
+
+This runs inside `lifecycleLock`, before `client.Close()`, ensuring no new connections are accepted during teardown.
+
+### 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`
+
+Check for any direct calls:
+
+```bash
+grep -rn "ConnectToClient" /home/jeremy/projects/waveterm/
+```
+
+Update each call site to capture (or ignore with `_`) the new `*wconfig.ConnKeywords` return value.
+
+### 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 `io.CopyBoth`; monitor tracks activity |
+| 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/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-waveai.md b/.pi/specs/remove-waveai.md
new file mode 100644
index 0000000000..75cf02f10d
--- /dev/null
+++ b/.pi/specs/remove-waveai.md
@@ -0,0 +1,439 @@
+# 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
+
+## 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/todos.md b/.pi/todos.md
new file mode 100644
index 0000000000..38df02a655
--- /dev/null
+++ b/.pi/todos.md
@@ -0,0 +1,101 @@
+# 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
+
+## Phase 3: Implementation
+
+### High Priority — Bugfix
+
+- [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)
+ - [ ] Minor: update misleading AI text in `builder-previewtab.tsx` EmptyStateView ("AI chat interface" → general message) — still has AI references
+ - [x] Phase B: Remove backend wiring (Go) — 2026-05-15
+ - [ ] Phase C: Clean up docs & schemas
+ - [ ] Phase D: Delete unused code (optional, later)
+ - [x] Document Claude Code shell integration analysis for future pi agent reuse (`.pi/decisions.md`)
+- [ ] **ACTIVE:** SSH port forwarding (`LocalForward` / `RemoteForward`) (spec: [[.pi/specs/portforwarding.md]])
+ - [ ] Modify `pkg/wconfig/settingsconfig.go`
+ - [ ] Modify `pkg/remote/sshclient.go` (parse + return merged keywords)
+ - [ ] Modify `pkg/remote/conncontroller/conncontroller.go` (runtime forwarding)
+ - [ ] Update call sites for new `ConnectToClient` signature
+ - [ ] Add tests
+ - [ ] Update documentation (`docs/docs/connections.mdx`)
+- [ ] MOSH (Mobile Shell) support
+ - [ ] Research mosh client/server architecture and integration points
+ - [ ] Design ConnKeywords for MOSH connections (host, port, mosh-server path, etc.)
+ - [ ] Implement MOSH protocol handler (UDP session, mosh-client process management)
+ - [ ] Integrate with connection controller lifecycle
+ - [ ] Add UI support for MOSH connection type
+ - [ ] Handle coexistence with SSH port forwarding (MOSH doesn't support tunnels)
+ - [ ] Add tests
+ - [ ] Update documentation
+- [ ] Paste screenshots into terminal
+ - [ ] Drag and drop images → SCP to remote, type fully-qualified filename into terminal
+ - [ ] Cmd+V paste clipboard image → upload as PNG, insert filename into terminal
+ - [ ] 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** — Mobile shell for robust mobile/wifi connections. Note: MOSH doesn't support port forwarding, so SSH tunnels must coexist cleverly
+- **Vertical tabs** — Tab layout optimized for remote host switching
+
+
+
+### 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
+
+### 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
+
+- **Paste screenshots into terminal** — Uploads the image to the remote server and pastes the filename/path into the terminal window
+- **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
From f180e5d3cbd67f0a88e1a87fa365b0e3404c1fb3 Mon Sep 17 00:00:00 2001
From: Jeremy Lam
Date: Sat, 16 May 2026 08:29:02 +0000
Subject: [PATCH 15/19] Phase C: remove AI docs, schemas, and clean generator
- Delete docs/docs/waveai.mdx, waveai-modes.mdx, ai-presets.mdx
- Delete schema/waveai.json, schema/aipresets.json
- Remove AI config rows from docs/docs/config.mdx (ai:*, waveai:*, app:hideaibutton)
- Clean AI references from gettingstarted.mdx, index.mdx, wsh-reference.mdx
- Truncate releasenotes.mdx to v0.14.x, strip all AI mentions
- Fix misleading AI text in builder-previewtab.tsx EmptyStateView
- Remove AI schema generation from generateschema (Phase B.12 fix)
- Add sharp dependency for vite image optimizer
---
.pi/specs/remove-waveai.md | 21 +
.pi/todos.md | 2 +-
cmd/generateschema/main-generateschema.go | 14 +-
docs/docs/ai-presets.mdx | 253 --------
docs/docs/config.mdx | 25 +-
docs/docs/gettingstarted.mdx | 6 +-
docs/docs/index.mdx | 10 +-
docs/docs/releasenotes.mdx | 553 +-----------------
docs/docs/waveai-modes.mdx | 565 ------------------
docs/docs/waveai.mdx | 110 ----
docs/docs/wsh-reference.mdx | 5 +-
frontend/builder/tabs/builder-previewtab.tsx | 3 +-
package-lock.json | 575 ++++++++++++++++++-
package.json | 3 +-
schema/aipresets.json | 63 --
schema/waveai.json | 116 ----
16 files changed, 608 insertions(+), 1716 deletions(-)
delete mode 100644 docs/docs/ai-presets.mdx
delete mode 100644 docs/docs/waveai-modes.mdx
delete mode 100644 docs/docs/waveai.mdx
delete mode 100644 schema/aipresets.json
delete mode 100644 schema/waveai.json
diff --git a/.pi/specs/remove-waveai.md b/.pi/specs/remove-waveai.md
index 75cf02f10d..60ae210435 100644
--- a/.pi/specs/remove-waveai.md
+++ b/.pi/specs/remove-waveai.md
@@ -428,6 +428,27 @@ All Phase B items implemented and verified. `task build:backend` completes witho
- 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 |
diff --git a/.pi/todos.md b/.pi/todos.md
index 38df02a655..aa709f39b2 100644
--- a/.pi/todos.md
+++ b/.pi/todos.md
@@ -46,7 +46,7 @@
- [x] Remove sparkle/Claude icon from terminal block header (`getShellIntegrationIconButton` → no-op stub)
- [ ] Minor: update misleading AI text in `builder-previewtab.tsx` EmptyStateView ("AI chat interface" → general message) — still has AI references
- [x] Phase B: Remove backend wiring (Go) — 2026-05-15
- - [ ] Phase C: Clean up docs & schemas
+ - [x] Phase C: Clean up docs & schemas — 2026-05-16
- [ ] Phase D: Delete unused code (optional, later)
- [x] Document Claude Code shell integration analysis for future pi agent reuse (`.pi/decisions.md`)
- [ ] **ACTIVE:** SSH port forwarding (`LocalForward` / `RemoteForward`) (spec: [[.pi/specs/portforwarding.md]])
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/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.
-:::
-
-
-
-
-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 57c379fd20..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 |
@@ -120,13 +108,8 @@ 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",
@@ -154,8 +137,6 @@ For reference, this is the current default configuration (v0.14.0):
"term:cursorblink": false,
"term:copyonselect": true,
"term:durable": false,
- "waveai:showcloudmodes": true,
- "waveai:defaultmode": "waveai@balanced",
"preview:defaultsort": "name"
}
```
@@ -170,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/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 ef1af63d6a..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 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 Pro",
- "ai:provider": "google",
- "ai:model": "gemini-3-pro-preview"
- }
-}
-```
-
-The provider automatically sets:
-- `ai:endpoint` to `https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent`
-- `ai:apitype` to `google-gemini`
-- `ai:apitokensecretname` to `GOOGLE_AI_KEY` (store your Google AI API key with this name)
-- `ai:capabilities` to `["tools", "images", "pdfs"]` (automatically configured)
-
-### Azure OpenAI (Modern API)
-
-For the modern Azure OpenAI API, use the `azure` provider:
-
-```json
-{
- "azure-gpt4": {
- "display:name": "Azure GPT-4",
- "ai:provider": "azure",
- "ai:model": "gpt-4",
- "ai:azureresourcename": "your-resource-name"
- }
-}
-```
-
-The provider automatically sets:
-- `ai:endpoint` to `https://your-resource-name.openai.azure.com/openai/v1/chat/completions` (or `/responses` for newer models)
-- `ai:apitype` based on the model
-- `ai:apitokensecretname` to `AZURE_OPENAI_KEY` (store your Azure OpenAI key with this name)
-
-:::note
-For Azure providers, you must manually specify `ai:capabilities` based on your model's features. Example:
-```json
-{
- "azure-gpt4": {
- "display:name": "Azure GPT-4",
- "ai:provider": "azure",
- "ai:model": "gpt-4",
- "ai:azureresourcename": "your-resource-name",
- "ai:capabilities": ["tools", "images"]
- }
-}
-```
-:::
-
-### Azure OpenAI (Legacy Deployment API)
-
-For legacy Azure deployments, use the `azure-legacy` provider:
-
-```json
-{
- "azure-legacy-gpt4": {
- "display:name": "Azure GPT-4 (Legacy)",
- "ai:provider": "azure-legacy",
- "ai:azureresourcename": "your-resource-name",
- "ai:azuredeployment": "your-deployment-name"
- }
-}
-```
-
-The provider automatically constructs the full endpoint URL and sets the API version (defaults to `2025-04-01-preview`). You can override the API version with `ai:azureapiversion` if needed.
-
-:::note
-For Azure Legacy provider, you must manually specify `ai:capabilities` based on your model's features.
-:::
-
-## Using Secrets for API Keys
-
-Instead of storing API keys directly in the configuration, you should use Wave's secret store to keep your credentials secure. Secrets are stored encrypted using your system's native keychain.
-
-### Storing an API Key
-
-**Using the Secrets UI (recommended):**
-1. Click the settings (gear) icon in the widget bar
-2. Select "Secrets" from the menu
-3. Click "Add New Secret"
-4. Enter the secret name (e.g., `OPENAI_API_KEY`) and your API key
-5. Click "Save"
-
-**Or from the command line:**
-```bash
-wsh secret set OPENAI_KEY=sk-xxxxxxxxxxxxxxxx
-wsh secret set OPENROUTER_KEY=sk-xxxxxxxxxxxxxxxx
-```
-
-### Referencing the Secret
-
-When using providers like `openai` or `openrouter`, the secret name is automatically set. Just ensure the secret exists with the correct name:
-
-```json
-{
- "my-openai-mode": {
- "display:name": "OpenAI GPT-4o",
- "ai:provider": "openai",
- "ai:model": "gpt-4o"
- }
-}
-```
-
-The `openai` provider automatically looks for the `OPENAI_KEY` secret. See the [Secrets documentation](./secrets.mdx) for more information on managing secrets securely in Wave.
-
-## Multiple Modes Example
-
-You can define multiple AI modes and switch between them easily:
-
-```json
-{
- "ollama-llama": {
- "display:name": "Ollama - Llama 3.3",
- "display:order": 1,
- "ai:model": "llama3.3:70b",
- "ai:endpoint": "http://localhost:11434/v1/chat/completions",
- "ai:apitoken": "ollama"
- },
- "ollama-codellama": {
- "display:name": "Ollama - CodeLlama",
- "display:order": 2,
- "ai:model": "codellama:34b",
- "ai:endpoint": "http://localhost:11434/v1/chat/completions",
- "ai:apitoken": "ollama"
- },
- "openai-gpt4o": {
- "display:name": "GPT-4o",
- "display:order": 10,
- "ai:provider": "openai",
- "ai:model": "gpt-4o"
- }
-}
-```
-
-## Troubleshooting
-
-### Connection Issues
-
-If Wave can't connect to your model server:
-
-1. **For cloud providers with `ai:provider` set**: Ensure you have the correct secret stored (e.g., `OPENAI_KEY`, `OPENROUTER_KEY`)
-2. **For local/custom endpoints**: Verify the server is running (`curl http://localhost:11434/v1/models` for Ollama)
-3. Check the `ai:endpoint` is the complete endpoint URL including the path (e.g., `http://localhost:11434/v1/chat/completions`)
-4. Verify the `ai:apitype` matches your server's API (defaults are usually correct when using providers)
-5. Check firewall settings if using a non-localhost address
-
-### Model Not Found
-
-If you get "model not found" errors:
-
-1. Verify the model name matches exactly what your server expects
-2. For Ollama, use `ollama list` to see available models
-3. Some servers require prefixes or specific naming formats
-
-### API Type Selection
-
-- The API type defaults to `openai-chat` if not specified, which works for most providers
-- Use `openai-chat` for Ollama, LM Studio, custom endpoints, and most cloud providers
-- Use `openai-responses` for newer OpenAI models (GPT-5+) or when your provider specifically requires it
-- Provider presets automatically set the correct API type when needed
-
-## Configuration Reference
-
-### Minimal Configuration (with Provider)
-
-```json
-{
- "mode-key": {
- "display:name": "Qwen (OpenRouter)",
- "ai:provider": "openrouter",
- "ai:model": "qwen/qwen-2.5-coder-32b-instruct"
- }
-}
-```
-
-### Full Configuration (all fields)
-
-```json
-{
- "mode-key": {
- "display:name": "Display Name",
- "display:order": 1,
- "display:icon": "icon-name",
- "display:description": "Full description",
- "ai:provider": "custom",
- "ai:apitype": "openai-chat",
- "ai:model": "model-name",
- "ai:thinkinglevel": "medium",
- "ai:endpoint": "http://localhost:11434/v1/chat/completions",
- "ai:azureapiversion": "v1",
- "ai:apitoken": "your-token",
- "ai:apitokensecretname": "PROVIDER_KEY",
- "ai:azureresourcename": "your-resource",
- "ai:azuredeployment": "your-deployment",
- "ai:capabilities": ["tools", "images", "pdfs"]
- }
-}
-```
-
-### Field Reference
-
-| Field | Required | Description |
-|-------|----------|-------------|
-| `display:name` | Yes | Name shown in the AI mode selector |
-| `display:order` | No | Sort order in the selector (lower numbers first) |
-| `display:icon` | No | Icon identifier for the mode (can use any [FontAwesome icon](https://fontawesome.com/search), use the name without the "fa-" prefix). Default is "sparkles" |
-| `display:description` | No | Full description of the mode |
-| `ai:provider` | No | Provider preset: `openai`, `openrouter`, `nanogpt`, `groq`, `google`, `azure`, `azure-legacy`, `custom` |
-| `ai:apitype` | No | API type: `openai-chat`, `openai-responses`, or `google-gemini` (defaults to `openai-chat` if not specified) |
-| `ai:model` | No | Model identifier (required for most providers) |
-| `ai:thinkinglevel` | No | Thinking level: `low`, `medium`, or `high` |
-| `ai:endpoint` | No | *Full* API endpoint URL (auto-set by provider when available) |
-| `ai:azureapiversion` | No | Azure API version (for `azure-legacy` provider, defaults to `2025-04-01-preview`) |
-| `ai:apitoken` | No | API key/token (not recommended - use secrets instead) |
-| `ai:apitokensecretname` | No | Name of secret containing API token (auto-set by provider) |
-| `ai:azureresourcename` | No | Azure resource name (for Azure providers) |
-| `ai:azuredeployment` | No | Azure deployment name (for `azure-legacy` provider) |
-| `ai:capabilities` | No | Array of supported capabilities: `"tools"`, `"images"`, `"pdfs"` |
-| `waveai:cloud` | No | Internal - for Wave Cloud AI configuration only |
-| `waveai:premium` | No | Internal - for Wave Cloud AI configuration only |
-
-### AI Capabilities
-
-The `ai:capabilities` field specifies what features the AI mode supports:
-
-- **`tools`** - Enables AI tool usage for file reading/writing, shell integration, and widget interaction
-- **`images`** - Allows image attachments in chat (model can view uploaded images)
-- **`pdfs`** - Allows PDF file attachments in chat (model can read PDF content)
-
-**Provider-specific behavior:**
-- **OpenAI and Google providers**: Capabilities are automatically configured based on the model. You don't need to specify them.
-- **OpenRouter, NanoGPT, Groq, Azure, Azure-Legacy, and Custom providers**: You must manually specify capabilities based on your model's features.
-
-:::warning
-If you don't include `"tools"` in the `ai:capabilities` array, the AI model will not be able to interact with your Wave terminal widgets, read/write files, or execute commands. Most AI modes should include `"tools"` for the best Wave experience.
-:::
-
-Most models support `tools` and can benefit from it. Vision-capable models should include `images`. Not all models support PDFs, so only include `pdfs` if your model can process them.
diff --git a/docs/docs/waveai.mdx b/docs/docs/waveai.mdx
deleted file mode 100644
index 5189bc6792..0000000000
--- a/docs/docs/waveai.mdx
+++ /dev/null
@@ -1,110 +0,0 @@
----
-sidebar_position: 1.5
-id: "waveai"
-title: "Wave AI"
----
-
-import { Kbd } from "@site/src/components/kbd";
-import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext";
-
-
-
-
-
-
-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/frontend/builder/tabs/builder-previewtab.tsx b/frontend/builder/tabs/builder-previewtab.tsx
index 98575b1077..9fb87244f5 100644
--- a/frontend/builder/tabs/builder-previewtab.tsx
+++ b/frontend/builder/tabs/builder-previewtab.tsx
@@ -14,8 +14,7 @@ const EmptyStateView = memo(() => {
No App to Preview
- Get started by using the AI chat interface on the left to create your WaveApp. Describe what you
- want to build, and the AI will help you generate the code.
+ Create an app.go file to get started.
diff --git a/package-lock.json b/package-lock.json
index b4995a359b..cf1045384f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,7 +16,6 @@
"dependencies": {
"@ai-sdk/react": "^2.0.104",
"@floating-ui/react": "^0.27.16",
- "@go-task/cli": "^3.50.0",
"@observablehq/plot": "^0.6.17",
"@react-hook/resize-observer": "^2.0.2",
"@table-nav/core": "^0.0.7",
@@ -84,6 +83,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39",
+ "@go-task/cli": "^3.50.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"@tailwindcss/vite": "^4.2.1",
"@types/css-tree": "^2",
@@ -111,6 +111,7 @@
"prettier-plugin-jsdoc": "^1.8.0",
"prettier-plugin-organize-imports": "^4.3.0",
"sass": "1.91.0",
+ "sharp": "^0.34.5",
"tailwindcss": "^4.2.1",
"ts-node": "^10.9.2",
"tsx": "^4.21.0",
@@ -4849,6 +4850,16 @@
"node": ">=14.14"
}
},
+ "node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -5468,6 +5479,7 @@
"version": "3.50.0",
"resolved": "https://registry.npmjs.org/@go-task/cli/-/cli-3.50.0.tgz",
"integrity": "sha512-xERwU5ul6fpv5yVzAOBW8feqcQSFSHNHOZVbOwcZdIz3BSmbXrAJlDM1E2pVdIi7SoZj2L2XZwrlsvWZiF5q/w==",
+ "dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -5580,6 +5592,472 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "devOptional": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -5602,6 +6080,7 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
+ "dev": true,
"license": "ISC",
"dependencies": {
"minipass": "^7.0.4"
@@ -10854,6 +11333,7 @@
"version": "0.13.4",
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
"integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.0.1"
@@ -11172,6 +11652,7 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
"integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -11939,6 +12420,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
+ "dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
@@ -13763,6 +14245,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-7.0.0.tgz",
"integrity": "sha512-CuRUx0TXGSbbWdEci3VK/XOZGP3n0P4pIKpsqpVtBqaIIuj3GKK8H45oAqA4Rg8FHipc+CzRdUzmD4YQXxv66Q==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
@@ -13975,6 +14458,7 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/degenerator/-/degenerator-6.0.0.tgz",
"integrity": "sha512-j5MdXdefrecJeSqTpUrgZd4fBsD2IxZx0JlJD+n1Q7+aTf7/HcyXSfHsicPW6ekPurX159v1ZYla6OJgSPh2Dw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"ast-types": "^0.13.4",
@@ -15017,6 +15501,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
"integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
+ "dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"esprima": "^4.0.1",
@@ -15038,6 +15523,7 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
"license": "BSD-3-Clause",
"optional": true,
"engines": {
@@ -16426,6 +16912,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/get-uri/-/get-uri-7.0.0.tgz",
"integrity": "sha512-ZsC7KQxm1Hra8yO0RvMZ4lGJT7vnBtSNpEHKq39MPN7vjuvCiu1aQ8rkXUaIXG1y/TSDez97Gmv04ibnYqCp/A==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"basic-ftp": "^5.0.2",
@@ -17673,6 +18160,7 @@
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "dev": true,
"license": "MIT"
},
"node_modules/immer": {
@@ -17815,6 +18303,7 @@
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -18531,6 +19020,7 @@
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+ "dev": true,
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
@@ -18543,12 +19033,14 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
"license": "MIT"
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
@@ -18564,12 +19056,14 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "dev": true,
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
@@ -18725,6 +19219,7 @@
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
@@ -22137,6 +22632,7 @@
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
+ "dev": true,
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -22276,6 +22772,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"minipass": "^7.1.2"
@@ -22515,6 +23012,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz",
"integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
@@ -23244,6 +23742,7 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-8.0.0.tgz",
"integrity": "sha512-HyCoVbyQ/nbVlQ/R6wBu0YXhbG2oAnEK5BQ3xMyj1OffQmU5NoOnpLzgPlKHaobUzz5NK0+AZHby4TdydAEBUA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "8.0.0",
@@ -23263,6 +23762,7 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-8.0.0.tgz",
"integrity": "sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
@@ -23272,6 +23772,7 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-8.0.0.tgz",
"integrity": "sha512-7pose0uGgrCJeH2Qh4JcNhWZp3u/oNrWjNYDK4ydOLxOpTw8V8ogHFAmkz0VWq96JBFj4umVJpvmQi287rSYLg==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "8.0.0",
@@ -23285,6 +23786,7 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-8.0.0.tgz",
"integrity": "sha512-YYeW+iCnAS3xhvj2dvVoWgsbca3RfQy/IlaNHHOtDmU0jMqPI9euIq3Y9BJETdxk16h9NHHCKqp/KB9nIMStCQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "8.0.0",
@@ -23298,6 +23800,7 @@
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-9.0.0.tgz",
"integrity": "sha512-fFlbMlfsXhK02ZB8aZY7Hwxh/IHBV9b1Oq9bvBk6tkFWXvdAxUgA0wbw/NYR5liU3Y5+KI6U4FH3kYJt9QYv0w==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "8.0.0",
@@ -23312,6 +23815,7 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-8.0.0.tgz",
"integrity": "sha512-SVNzOxVq2zuTew3WAt7U8UghwzJzuWYuJryd3y8FxyLTZdjVoCzY8kLP39PpEqQCDvlMWdQXwViu0sYT3eiU2w==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"degenerator": "6.0.0",
@@ -23508,6 +24012,7 @@
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "dev": true,
"license": "(MIT AND Zlib)"
},
"node_modules/papaparse": {
@@ -25816,6 +26321,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-7.0.0.tgz",
"integrity": "sha512-okTgt79rHTvMHkr/Ney5rZpgCHh3g1g3tI5uhkgN5b7OeI3n0Q/ui1uv9OdrnZNJM9WIZJqZPh/UJs+YtO/TMQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "8.0.0",
@@ -25835,6 +26341,7 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-8.0.0.tgz",
"integrity": "sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
@@ -25844,6 +26351,7 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-8.0.0.tgz",
"integrity": "sha512-7pose0uGgrCJeH2Qh4JcNhWZp3u/oNrWjNYDK4ydOLxOpTw8V8ogHFAmkz0VWq96JBFj4umVJpvmQi287rSYLg==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "8.0.0",
@@ -25857,6 +26365,7 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-8.0.0.tgz",
"integrity": "sha512-YYeW+iCnAS3xhvj2dvVoWgsbca3RfQy/IlaNHHOtDmU0jMqPI9euIq3Y9BJETdxk16h9NHHCKqp/KB9nIMStCQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "8.0.0",
@@ -25870,6 +26379,7 @@
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
"integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
+ "dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
@@ -25879,6 +26389,7 @@
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-9.0.0.tgz",
"integrity": "sha512-fFlbMlfsXhK02ZB8aZY7Hwxh/IHBV9b1Oq9bvBk6tkFWXvdAxUgA0wbw/NYR5liU3Y5+KI6U4FH3kYJt9QYv0w==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "8.0.0",
@@ -25893,6 +26404,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "dev": true,
"license": "MIT"
},
"node_modules/pump": {
@@ -25981,6 +26493,7 @@
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-0.0.1.tgz",
"integrity": "sha512-fBWNLTBkxkLAhe1AzF1hyXEvuA+N+vV1WMP2D6iiMUblvmOt8Pp5t8zUcgvz7aYA1ldUdxDlgUse15dmcKjkNg==",
+ "dev": true,
"license": "MIT"
},
"node_modules/randombytes": {
@@ -28734,6 +29247,7 @@
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "dev": true,
"license": "MIT"
},
"node_modules/setprototypeof": {
@@ -28760,6 +29274,61 @@
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
"license": "MIT"
},
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "devOptional": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/sharp/node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -29053,6 +29622,7 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
@@ -29093,6 +29663,7 @@
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"ip-address": "^10.0.1",
@@ -29703,6 +30274,7 @@
"version": "7.5.11",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz",
"integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==",
+ "dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
@@ -29744,6 +30316,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
+ "dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
diff --git a/package.json b/package.json
index 3379025e8f..645d5842fa 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39",
+ "@go-task/cli": "^3.50.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"@tailwindcss/vite": "^4.2.1",
"@types/css-tree": "^2",
@@ -56,6 +57,7 @@
"prettier-plugin-jsdoc": "^1.8.0",
"prettier-plugin-organize-imports": "^4.3.0",
"sass": "1.91.0",
+ "sharp": "^0.34.5",
"tailwindcss": "^4.2.1",
"ts-node": "^10.9.2",
"tsx": "^4.21.0",
@@ -70,7 +72,6 @@
"dependencies": {
"@ai-sdk/react": "^2.0.104",
"@floating-ui/react": "^0.27.16",
- "@go-task/cli": "^3.50.0",
"@observablehq/plot": "^0.6.17",
"@react-hook/resize-observer": "^2.0.2",
"@table-nav/core": "^0.0.7",
diff --git a/schema/aipresets.json b/schema/aipresets.json
deleted file mode 100644
index c932a5c777..0000000000
--- a/schema/aipresets.json
+++ /dev/null
@@ -1,63 +0,0 @@
-{
- "$schema": "https://json-schema.org/draft/2020-12/schema",
- "$defs": {
- "AiSettingsType": {
- "properties": {
- "ai:*": {
- "type": "boolean"
- },
- "ai:preset": {
- "type": "string"
- },
- "ai:apitype": {
- "type": "string"
- },
- "ai:baseurl": {
- "type": "string"
- },
- "ai:apitoken": {
- "type": "string"
- },
- "ai:name": {
- "type": "string"
- },
- "ai:model": {
- "type": "string"
- },
- "ai:orgid": {
- "type": "string"
- },
- "ai:apiversion": {
- "type": "string"
- },
- "ai:maxtokens": {
- "type": "number"
- },
- "ai:timeoutms": {
- "type": "number"
- },
- "ai:proxyurl": {
- "type": "string"
- },
- "ai:fontsize": {
- "type": "number"
- },
- "ai:fixedfontsize": {
- "type": "number"
- },
- "display:name": {
- "type": "string"
- },
- "display:order": {
- "type": "number"
- }
- },
- "additionalProperties": false,
- "type": "object"
- }
- },
- "additionalProperties": {
- "$ref": "#/$defs/AiSettingsType"
- },
- "type": "object"
-}
\ No newline at end of file
diff --git a/schema/waveai.json b/schema/waveai.json
deleted file mode 100644
index 7279777a4c..0000000000
--- a/schema/waveai.json
+++ /dev/null
@@ -1,116 +0,0 @@
-{
- "$schema": "https://json-schema.org/draft/2020-12/schema",
- "$defs": {
- "AIModeConfigType": {
- "properties": {
- "display:name": {
- "type": "string"
- },
- "display:order": {
- "type": "number"
- },
- "display:icon": {
- "type": "string"
- },
- "display:description": {
- "type": "string"
- },
- "ai:provider": {
- "type": "string",
- "enum": [
- "wave",
- "google",
- "groq",
- "openrouter",
- "nanogpt",
- "openai",
- "azure",
- "azure-legacy",
- "custom"
- ]
- },
- "ai:apitype": {
- "type": "string",
- "enum": [
- "google-gemini",
- "openai-responses",
- "openai-chat"
- ]
- },
- "ai:model": {
- "type": "string"
- },
- "ai:thinkinglevel": {
- "type": "string",
- "enum": [
- "low",
- "medium",
- "high"
- ]
- },
- "ai:verbosity": {
- "type": "string",
- "enum": [
- "low",
- "medium",
- "high"
- ],
- "description": "Text verbosity level (OpenAI Responses API only)"
- },
- "ai:endpoint": {
- "type": "string"
- },
- "ai:proxyurl": {
- "type": "string"
- },
- "ai:azureapiversion": {
- "type": "string"
- },
- "ai:apitoken": {
- "type": "string"
- },
- "ai:apitokensecretname": {
- "type": "string"
- },
- "ai:azureresourcename": {
- "type": "string"
- },
- "ai:azuredeployment": {
- "type": "string"
- },
- "ai:capabilities": {
- "items": {
- "type": "string",
- "enum": [
- "pdfs",
- "images",
- "tools"
- ]
- },
- "type": "array"
- },
- "ai:switchcompat": {
- "items": {
- "type": "string"
- },
- "type": "array"
- },
- "waveai:cloud": {
- "type": "boolean"
- },
- "waveai:premium": {
- "type": "boolean"
- }
- },
- "additionalProperties": false,
- "type": "object",
- "required": [
- "display:name"
- ]
- }
- },
- "additionalProperties": {
- "$ref": "#/$defs/AIModeConfigType"
- },
- "type": "object"
-}
\ No newline at end of file
From bd84d56c40b24b9afa58228cc228fcabe4c7eac2 Mon Sep 17 00:00:00 2001
From: Jeremy Lam
Date: Sat, 16 May 2026 08:30:27 +0000
Subject: [PATCH 16/19] Mark builder-previewtab.tsx fix complete in todos
---
.pi/todos.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.pi/todos.md b/.pi/todos.md
index aa709f39b2..ae4d7583a5 100644
--- a/.pi/todos.md
+++ b/.pi/todos.md
@@ -44,7 +44,7 @@
- [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)
- - [ ] Minor: update misleading AI text in `builder-previewtab.tsx` EmptyStateView ("AI chat interface" → general message) — still has AI references
+ - [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
- [ ] Phase D: Delete unused code (optional, later)
From 06284b9dd6a569a0598b5a639f0bcfdb8acd4393 Mon Sep 17 00:00:00 2001
From: Jeremy Lam
Date: Sat, 16 May 2026 13:28:24 +0000
Subject: [PATCH 17/19] =?UTF-8?q?fix(gap):=20complete=20Phase=20B=20?=
=?UTF-8?q?=E2=80=94=20remove=20all=20remaining=20Go=20backend=20AI=20wiri?=
=?UTF-8?q?ng?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Removes AI RPC handlers, client helpers, web endpoints, and dead packages
that were missed in the original Phase B implementation.
Specific changes:
- wshrpctypes.go: remove 7 AI interface methods + 6 AI data types
- wshserver.go: remove 5 AI RPC handlers + aiusechat/chatstore/uctypes imports
- wshclient.go: remove 8 AI client helper functions + uctypes import
- web.go: remove /api/post-chat-message and /wave/aichat endpoints
- main-server.go: remove aiusechat.InitAIModeConfigWatcher() call
- wshcmd-blocks.go: remove waveai from view filter help/validation
- filebackup.go: rename waveai-backups -> file-backups
- wpstypes.go: remove Event_WaveAIRateLimit and Event_AIModeConfig
- tsgenevent.go: remove WaveAI event data mappings
- tsgen.go: remove uctypes/AIModeConfigUpdate from ExtraTypes
- Delete pkg/aiusechat/ (entire directory, ~8.5K lines)
- Delete cmd/testai/, cmd/testopenai/, cmd/testsummarize/
- Delete cmd/wsh/cmd/wshcmd-ai.go (entire wsh ai command)
Also updates .pi/todos.md to reflect Phase D as next active task.
---
.pi/todos.md | 22 +-
cmd/generatego/main-generatego.go | 1 -
cmd/server/main-server.go | 2 -
cmd/testai/main-testai.go | 548 ---------
cmd/testai/testschema.json | 104 --
cmd/testopenai/main-testopenai.go | 164 ---
cmd/testsummarize/main-testsummarize.go | 104 --
cmd/wsh/cmd/wshcmd-ai.go | 210 ----
cmd/wsh/cmd/wshcmd-blocks.go | 11 +-
frontend/app/store/wshclientapi.ts | 42 -
frontend/types/gotypes.d.ts | 96 --
frontend/types/waveevent.d.ts | 4 -
pkg/aiusechat/aiutil/aiutil.go | 314 -----
pkg/aiusechat/anthropic/anthropic-backend.go | 959 ---------------
.../anthropic/anthropic-backend_test.go | 166 ---
.../anthropic/anthropic-convertmessage.go | 940 ---------------
pkg/aiusechat/chatstore/chatstore.go | 129 --
pkg/aiusechat/gemini/doc.go | 99 --
pkg/aiusechat/gemini/gemini-backend.go | 500 --------
pkg/aiusechat/gemini/gemini-convertmessage.go | 508 --------
pkg/aiusechat/gemini/gemini-types.go | 232 ----
pkg/aiusechat/google/doc.go | 41 -
pkg/aiusechat/google/google-summarize.go | 283 -----
pkg/aiusechat/google/google-summarize_test.go | 130 --
pkg/aiusechat/openai/openai-backend.go | 1067 -----------------
pkg/aiusechat/openai/openai-convertmessage.go | 588 ---------
pkg/aiusechat/openai/openai-util.go | 44 -
pkg/aiusechat/openai/stream-sample.txt | 161 ---
pkg/aiusechat/openai/tool-sample.txt | 44 -
.../openaichat/openaichat-backend.go | 275 -----
.../openaichat/openaichat-convertmessage.go | 529 --------
pkg/aiusechat/openaichat/openaichat-types.go | 271 -----
pkg/aiusechat/toolapproval.go | 119 --
pkg/aiusechat/tools.go | 318 -----
pkg/aiusechat/tools_builder.go | 306 -----
pkg/aiusechat/tools_readdir.go | 173 ---
pkg/aiusechat/tools_readdir_test.go | 297 -----
pkg/aiusechat/tools_readfile.go | 410 -------
pkg/aiusechat/tools_screenshot.go | 84 --
pkg/aiusechat/tools_term.go | 301 -----
pkg/aiusechat/tools_tsunami.go | 203 ----
pkg/aiusechat/tools_web.go | 108 --
pkg/aiusechat/tools_writefile.go | 526 --------
pkg/aiusechat/uctypes/uctypes.go | 632 ----------
pkg/aiusechat/usechat-backend.go | 265 ----
pkg/aiusechat/usechat-mode.go | 330 -----
pkg/aiusechat/usechat-prompts.go | 91 --
pkg/aiusechat/usechat-utils.go | 85 --
pkg/aiusechat/usechat.go | 846 -------------
pkg/aiusechat/usechat_mode_test.go | 39 -
pkg/filebackup/filebackup.go | 4 +-
pkg/tsgen/tsgen.go | 3 -
pkg/tsgen/tsgenevent.go | 3 -
pkg/web/web.go | 5 -
pkg/wps/wpstypes.go | 4 -
pkg/wshrpc/wshclient/wshclient.go | 43 -
pkg/wshrpc/wshrpctypes.go | 48 -
pkg/wshrpc/wshserver/wshserver.go | 41 -
58 files changed, 20 insertions(+), 13852 deletions(-)
delete mode 100644 cmd/testai/main-testai.go
delete mode 100644 cmd/testai/testschema.json
delete mode 100644 cmd/testopenai/main-testopenai.go
delete mode 100644 cmd/testsummarize/main-testsummarize.go
delete mode 100644 cmd/wsh/cmd/wshcmd-ai.go
delete mode 100644 pkg/aiusechat/aiutil/aiutil.go
delete mode 100644 pkg/aiusechat/anthropic/anthropic-backend.go
delete mode 100644 pkg/aiusechat/anthropic/anthropic-backend_test.go
delete mode 100644 pkg/aiusechat/anthropic/anthropic-convertmessage.go
delete mode 100644 pkg/aiusechat/chatstore/chatstore.go
delete mode 100644 pkg/aiusechat/gemini/doc.go
delete mode 100644 pkg/aiusechat/gemini/gemini-backend.go
delete mode 100644 pkg/aiusechat/gemini/gemini-convertmessage.go
delete mode 100644 pkg/aiusechat/gemini/gemini-types.go
delete mode 100644 pkg/aiusechat/google/doc.go
delete mode 100644 pkg/aiusechat/google/google-summarize.go
delete mode 100644 pkg/aiusechat/google/google-summarize_test.go
delete mode 100644 pkg/aiusechat/openai/openai-backend.go
delete mode 100644 pkg/aiusechat/openai/openai-convertmessage.go
delete mode 100644 pkg/aiusechat/openai/openai-util.go
delete mode 100644 pkg/aiusechat/openai/stream-sample.txt
delete mode 100644 pkg/aiusechat/openai/tool-sample.txt
delete mode 100644 pkg/aiusechat/openaichat/openaichat-backend.go
delete mode 100644 pkg/aiusechat/openaichat/openaichat-convertmessage.go
delete mode 100644 pkg/aiusechat/openaichat/openaichat-types.go
delete mode 100644 pkg/aiusechat/toolapproval.go
delete mode 100644 pkg/aiusechat/tools.go
delete mode 100644 pkg/aiusechat/tools_builder.go
delete mode 100644 pkg/aiusechat/tools_readdir.go
delete mode 100644 pkg/aiusechat/tools_readdir_test.go
delete mode 100644 pkg/aiusechat/tools_readfile.go
delete mode 100644 pkg/aiusechat/tools_screenshot.go
delete mode 100644 pkg/aiusechat/tools_term.go
delete mode 100644 pkg/aiusechat/tools_tsunami.go
delete mode 100644 pkg/aiusechat/tools_web.go
delete mode 100644 pkg/aiusechat/tools_writefile.go
delete mode 100644 pkg/aiusechat/uctypes/uctypes.go
delete mode 100644 pkg/aiusechat/usechat-backend.go
delete mode 100644 pkg/aiusechat/usechat-mode.go
delete mode 100644 pkg/aiusechat/usechat-prompts.go
delete mode 100644 pkg/aiusechat/usechat-utils.go
delete mode 100644 pkg/aiusechat/usechat.go
delete mode 100644 pkg/aiusechat/usechat_mode_test.go
diff --git a/.pi/todos.md b/.pi/todos.md
index ae4d7583a5..ba0c4a04ac 100644
--- a/.pi/todos.md
+++ b/.pi/todos.md
@@ -47,15 +47,14 @@
- [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
- - [ ] Phase D: Delete unused code (optional, later)
+ - [ ] **ACTIVE:** Phase D: Delete unused code
+ - [ ] Remove builder AI dependencies (A.15: `AIPanel`, `WaveAIModel`, `formatFileSize`, `builder-focusmanager.ts`)
+ - [ ] Move `formatFileSize` to shared utility (currently trapped in `aipanel/ai-utils`)
+ - [ ] Delete `pkg/aiusechat/` (entire directory, ~12K lines, dead package)
+ - [ ] Delete `frontend/app/aipanel/` (17 files, orphaned after builder deps removed)
+ - [ ] Delete `frontend/app/view/waveai/`, `frontend/app/view/aifilediff/`, `frontend/app/view/waveconfig/waveaivisual.tsx`
+ - [ ] Regenerate auto-generated TS types (`gotypes.d.ts`, `waveevent.d.ts`, `wshclientapi.ts`) to remove stale AI definitions
- [x] Document Claude Code shell integration analysis for future pi agent reuse (`.pi/decisions.md`)
-- [ ] **ACTIVE:** SSH port forwarding (`LocalForward` / `RemoteForward`) (spec: [[.pi/specs/portforwarding.md]])
- - [ ] Modify `pkg/wconfig/settingsconfig.go`
- - [ ] Modify `pkg/remote/sshclient.go` (parse + return merged keywords)
- - [ ] Modify `pkg/remote/conncontroller/conncontroller.go` (runtime forwarding)
- - [ ] Update call sites for new `ConnectToClient` signature
- - [ ] Add tests
- - [ ] Update documentation (`docs/docs/connections.mdx`)
- [ ] MOSH (Mobile Shell) support
- [ ] Research mosh client/server architecture and integration points
- [ ] Design ConnKeywords for MOSH connections (host, port, mosh-server path, etc.)
@@ -65,6 +64,13 @@
- [ ] Handle coexistence with SSH port forwarding (MOSH doesn't support tunnels)
- [ ] Add tests
- [ ] Update documentation
+- [ ] SSH port forwarding (`LocalForward` / `RemoteForward`) (spec: [[.pi/specs/portforwarding.md]])
+ - [ ] Modify `pkg/wconfig/settingsconfig.go`
+ - [ ] Modify `pkg/remote/sshclient.go` (parse + return merged keywords)
+ - [ ] Modify `pkg/remote/conncontroller/conncontroller.go` (runtime forwarding)
+ - [ ] Update call sites for new `ConnectToClient` signature
+ - [ ] Add tests
+ - [ ] Update documentation (`docs/docs/connections.mdx`)
- [ ] Paste screenshots into terminal
- [ ] Drag and drop images → SCP to remote, type fully-qualified filename into terminal
- [ ] Cmd+V paste clipboard image → upload as PNG, insert filename into terminal
diff --git a/cmd/generatego/main-generatego.go b/cmd/generatego/main-generatego.go
index 49cab8010f..99e268ad85 100644
--- a/cmd/generatego/main-generatego.go
+++ b/cmd/generatego/main-generatego.go
@@ -24,7 +24,6 @@ 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/vdom",
"github.com/wavetermdev/waveterm/pkg/waveobj",
diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go
index 323deaca87..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"
@@ -294,7 +293,6 @@ func main() {
sigutil.InstallSIGUSR1Handler()
wconfig.MigratePresetsBackgrounds()
startConfigWatcher()
- aiusechat.InitAIModeConfigWatcher()
maybeStartPprofServer()
go stdinReadWatch()
go backupCleanupLoop()
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 79293c634e..0000000000
--- a/cmd/wsh/cmd/wshcmd-ai.go
+++ /dev/null
@@ -1,210 +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() {
- }()
-
- 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-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/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts
index 3829f4af68..95715ac633 100644
--- a/frontend/app/store/wshclientapi.ts
+++ b/frontend/app/store/wshclientapi.ts
@@ -18,12 +18,6 @@ export class RpcApiType {
this.mockClient = client;
}
- // command "aisendmessage" [call]
- AiSendMessageCommand(client: WshClient, data: AiMessageData, opts?: RpcOpts): Promise {
- if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "aisendmessage", data, opts);
- return client.wshRpcCall("aisendmessage", data, opts);
- }
-
// command "authenticate" [call]
AuthenticateCommand(client: WshClient, data: string, opts?: RpcOpts): Promise {
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "authenticate", data, opts);
@@ -486,24 +480,6 @@ export class RpcApiType {
return client.wshRpcCall("getvar", data, opts);
}
- // command "getwaveaichat" [call]
- GetWaveAIChatCommand(client: WshClient, data: CommandGetWaveAIChatData, opts?: RpcOpts): Promise {
- if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getwaveaichat", data, opts);
- return client.wshRpcCall("getwaveaichat", data, opts);
- }
-
- // command "getwaveaimodeconfig" [call]
- GetWaveAIModeConfigCommand(client: WshClient, opts?: RpcOpts): Promise {
- if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getwaveaimodeconfig", null, opts);
- return client.wshRpcCall("getwaveaimodeconfig", null, opts);
- }
-
- // command "getwaveairatelimit" [call]
- GetWaveAIRateLimitCommand(client: WshClient, opts?: RpcOpts): Promise {
- if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getwaveairatelimit", null, opts);
- return client.wshRpcCall("getwaveairatelimit", null, opts);
- }
-
// command "jobcmdexited" [call]
JobCmdExitedCommand(client: WshClient, data: CommandJobCmdExitedData, opts?: RpcOpts): Promise {
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "jobcmdexited", data, opts);
@@ -966,24 +942,6 @@ export class RpcApiType {
return client.wshRpcCall("waitforroute", data, opts);
}
- // command "waveaiaddcontext" [call]
- WaveAIAddContextCommand(client: WshClient, data: CommandWaveAIAddContextData, opts?: RpcOpts): Promise {
- if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "waveaiaddcontext", data, opts);
- return client.wshRpcCall("waveaiaddcontext", data, opts);
- }
-
- // command "waveaigettooldiff" [call]
- WaveAIGetToolDiffCommand(client: WshClient, data: CommandWaveAIGetToolDiffData, opts?: RpcOpts): Promise {
- if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "waveaigettooldiff", data, opts);
- return client.wshRpcCall("waveaigettooldiff", data, opts);
- }
-
- // command "waveaitoolapprove" [call]
- WaveAIToolApproveCommand(client: WshClient, data: CommandWaveAIToolApproveData, opts?: RpcOpts): Promise {
- if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "waveaitoolapprove", data, opts);
- return client.wshRpcCall("waveaitoolapprove", data, opts);
- }
-
// command "wavefilereadstream" [call]
WaveFileReadStreamCommand(client: WshClient, data: CommandWaveFileReadStreamData, opts?: RpcOpts): Promise {
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "wavefilereadstream", data, opts);
diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts
index 5679d0f003..1e4417d293 100644
--- a/frontend/types/gotypes.d.ts
+++ b/frontend/types/gotypes.d.ts
@@ -5,14 +5,6 @@
declare global {
- // wshrpc.AIAttachedFile
- type AIAttachedFile = {
- name: string;
- type: string;
- size: number;
- data64: string;
- };
-
// wconfig.AIModeConfigType
type AIModeConfigType = {
"display:name": string;
@@ -37,16 +29,6 @@ declare global {
"waveai:premium"?: boolean;
};
- // wconfig.AIModeConfigUpdate
- type AIModeConfigUpdate = {
- configs: {[key: string]: AIModeConfigType};
- };
-
- // wshrpc.AiMessageData
- type AiMessageData = {
- message?: string;
- };
-
// wshrpc.AppInfo
type AppInfo = {
appid: string;
@@ -375,11 +357,6 @@ declare global {
filename?: string;
};
- // wshrpc.CommandGetWaveAIChatData
- type CommandGetWaveAIChatData = {
- chatid: string;
- };
-
// wshrpc.CommandJobCmdExitedData
type CommandJobCmdExitedData = {
jobid: string;
@@ -689,32 +666,6 @@ declare global {
waitms: number;
};
- // wshrpc.CommandWaveAIAddContextData
- type CommandWaveAIAddContextData = {
- files?: AIAttachedFile[];
- text?: string;
- submit?: boolean;
- newchat?: boolean;
- };
-
- // wshrpc.CommandWaveAIGetToolDiffData
- type CommandWaveAIGetToolDiffData = {
- chatid: string;
- toolcallid: string;
- };
-
- // wshrpc.CommandWaveAIGetToolDiffRtnData
- type CommandWaveAIGetToolDiffRtnData = {
- originalcontents64: string;
- modifiedcontents64: string;
- };
-
- // wshrpc.CommandWaveAIToolApproveData
- type CommandWaveAIToolApproveData = {
- toolcallid: string;
- approval?: string;
- };
-
// wshrpc.CommandWaveFileReadStreamData
type CommandWaveFileReadStreamData = {
zoneid: string;
@@ -1265,16 +1216,6 @@ declare global {
cpusum?: number;
};
- // uctypes.RateLimitInfo
- type RateLimitInfo = {
- req: number;
- reqlimit: number;
- preq: number;
- preqlimit: number;
- resetepoch: number;
- unknown?: boolean;
- };
-
// wshrpc.RemoteInfo
type RemoteInfo = {
clientarch: string;
@@ -1547,49 +1488,12 @@ declare global {
values: {[key: string]: number};
};
- // uctypes.UIChat
- type UIChat = {
- chatid: string;
- apitype: string;
- model: string;
- apiversion: string;
- messages: UIMessage[];
- };
-
// waveobj.UIContext
type UIContext = {
windowid: string;
activetabid: string;
};
- // uctypes.UIMessage
- type UIMessage = {
- id: string;
- role: string;
- metadata?: any;
- parts?: UIMessagePart[];
- };
-
- // uctypes.UIMessagePart
- type UIMessagePart = {
- type: string;
- text?: string;
- state?: string;
- toolCallId?: string;
- input?: any;
- output?: any;
- errorText?: string;
- providerExecuted?: boolean;
- sourceId?: string;
- url?: string;
- title?: string;
- filename?: string;
- mediaType?: string;
- id?: string;
- data?: any;
- providerMetadata?: {[key: string]: any};
- };
-
// userinput.UserInputRequest
type UserInputRequest = {
requestid: string;
diff --git a/frontend/types/waveevent.d.ts b/frontend/types/waveevent.d.ts
index c3e5bd7822..c4e23f922a 100644
--- a/frontend/types/waveevent.d.ts
+++ b/frontend/types/waveevent.d.ts
@@ -20,10 +20,8 @@ declare global {
| "route:down"
| "route:up"
| "workspace:update"
- | "waveai:ratelimit"
| "waveapp:appgoupdated"
| "tsunami:updatemeta"
- | "waveai:modeconfig"
| "block:jobstatus"
| "badge"
;
@@ -48,10 +46,8 @@ declare global {
{ event: "route:down"; data?: null; } |
{ event: "route:up"; data?: null; } |
{ event: "workspace:update"; data?: null; } |
- { event: "waveai:ratelimit"; data?: RateLimitInfo; } |
{ event: "waveapp:appgoupdated"; data?: null; } |
{ event: "tsunami:updatemeta"; data?: AppMeta; } |
- { event: "waveai:modeconfig"; data?: AIModeConfigUpdate; } |
{ event: "block:jobstatus"; data?: BlockJobStatusData; } |
{ event: "badge"; data?: BadgeEvent; }
);
diff --git a/pkg/aiusechat/aiutil/aiutil.go b/pkg/aiusechat/aiutil/aiutil.go
deleted file mode 100644
index 075dd58e7b..0000000000
--- a/pkg/aiusechat/aiutil/aiutil.go
+++ /dev/null
@@ -1,314 +0,0 @@
-// Copyright 2025, Command Line Inc.
-// SPDX-License-Identifier: Apache-2.0
-
-package aiutil
-
-import (
- "bytes"
- "context"
- "crypto/sha256"
- "encoding/base64"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "net/http"
- "net/url"
- "strconv"
- "strings"
- "time"
-
- "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes"
- "github.com/wavetermdev/waveterm/pkg/util/utilfn"
- "github.com/wavetermdev/waveterm/pkg/wcore"
- "github.com/wavetermdev/waveterm/pkg/web/sse"
-)
-
-// ExtractXmlAttribute extracts an attribute value from an XML-like tag.
-// Expects double-quoted strings where internal quotes are encoded as ".
-// Returns the unquoted value and true if found, or empty string and false if not found or invalid.
-func ExtractXmlAttribute(tag, attrName string) (string, bool) {
- attrStart := strings.Index(tag, attrName+"=")
- if attrStart == -1 {
- return "", false
- }
-
- pos := attrStart + len(attrName+"=")
- start := strings.Index(tag[pos:], `"`)
- if start == -1 {
- return "", false
- }
- start += pos
-
- end := strings.Index(tag[start+1:], `"`)
- if end == -1 {
- return "", false
- }
- end += start + 1
-
- quotedValue := tag[start : end+1]
- value, err := strconv.Unquote(quotedValue)
- if err != nil {
- return "", false
- }
-
- value = strings.ReplaceAll(value, """, `"`)
- return value, true
-}
-
-// GenerateDeterministicSuffix creates an 8-character hash from input strings
-func GenerateDeterministicSuffix(inputs ...string) string {
- hasher := sha256.New()
- for _, input := range inputs {
- hasher.Write([]byte(input))
- }
- hash := hasher.Sum(nil)
- return hex.EncodeToString(hash)[:8]
-}
-
-// ExtractImageUrl extracts an image URL from either URL field (http/https/data) or raw Data
-func ExtractImageUrl(data []byte, url, mimeType string) (string, error) {
- if url != "" {
- if !strings.HasPrefix(url, "data:") &&
- !strings.HasPrefix(url, "http://") &&
- !strings.HasPrefix(url, "https://") {
- return "", fmt.Errorf("unsupported URL protocol in file part: %s", url)
- }
- return url, nil
- }
- if len(data) > 0 {
- base64Data := base64.StdEncoding.EncodeToString(data)
- return fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data), nil
- }
- return "", fmt.Errorf("file part missing both url and data")
-}
-
-// ExtractTextData extracts text data from either Data field or URL field (data: URLs only)
-func ExtractTextData(data []byte, url string) ([]byte, error) {
- if len(data) > 0 {
- return data, nil
- }
- if url != "" {
- if strings.HasPrefix(url, "data:") {
- _, decodedData, err := utilfn.DecodeDataURL(url)
- if err != nil {
- return nil, fmt.Errorf("failed to decode data URL for text/plain file: %w", err)
- }
- return decodedData, nil
- }
- return nil, fmt.Errorf("dropping text/plain file with URL (must be fetched and converted to data)")
- }
- return nil, fmt.Errorf("text/plain file part missing data")
-}
-
-// FormatAttachedTextFile formats a text file attachment with proper encoding and deterministic suffix
-func FormatAttachedTextFile(fileName string, textContent []byte) string {
- if fileName == "" {
- fileName = "untitled.txt"
- }
-
- encodedFileName := strings.ReplaceAll(fileName, `"`, """)
- quotedFileName := strconv.Quote(encodedFileName)
-
- textStr := string(textContent)
- deterministicSuffix := GenerateDeterministicSuffix(textStr, fileName)
- return fmt.Sprintf("\n%s\n", deterministicSuffix, quotedFileName, textStr, deterministicSuffix)
-}
-
-// FormatAttachedDirectoryListing formats a directory listing attachment with proper encoding and deterministic suffix
-func FormatAttachedDirectoryListing(directoryName, jsonContent string) string {
- if directoryName == "" {
- directoryName = "unnamed-directory"
- }
-
- encodedDirName := strings.ReplaceAll(directoryName, `"`, """)
- quotedDirName := strconv.Quote(encodedDirName)
-
- deterministicSuffix := GenerateDeterministicSuffix(jsonContent, directoryName)
- return fmt.Sprintf("\n%s\n", deterministicSuffix, quotedDirName, jsonContent, deterministicSuffix)
-}
-
-// ConvertDataUserFile converts OpenAI attached file/directory blocks to UIMessagePart
-// Returns (found, part) where found indicates if the prefix was matched,
-// and part is the converted UIMessagePart (can be nil if parsing failed)
-func ConvertDataUserFile(blockText string) (bool, *uctypes.UIMessagePart) {
- if strings.HasPrefix(blockText, "' {
- return true, nil
- }
-
- openTag := blockText[:openTagEnd]
- fileName, ok := ExtractXmlAttribute(openTag, "file_name")
- if !ok {
- return true, nil
- }
-
- return true, &uctypes.UIMessagePart{
- Type: "data-userfile",
- Data: uctypes.UIMessageDataUserFile{
- FileName: fileName,
- MimeType: "text/plain",
- },
- }
- }
-
- if strings.HasPrefix(blockText, "' {
- return true, nil
- }
-
- openTag := blockText[:openTagEnd]
- directoryName, ok := ExtractXmlAttribute(openTag, "directory_name")
- if !ok {
- return true, nil
- }
-
- return true, &uctypes.UIMessagePart{
- Type: "data-userfile",
- Data: uctypes.UIMessageDataUserFile{
- FileName: directoryName,
- MimeType: "directory",
- },
- }
- }
-
- return false, nil
-}
-
-func JsonEncodeRequestBody(reqBody any) (bytes.Buffer, error) {
- var buf bytes.Buffer
- encoder := json.NewEncoder(&buf)
- encoder.SetEscapeHTML(false)
- err := encoder.Encode(reqBody)
- if err != nil {
- return buf, err
- }
- return buf, nil
-}
-
-func MakeHTTPClient(proxyURL string) (*http.Client, error) {
- client := &http.Client{
- Timeout: 0, // rely on ctx; streaming can be long
- }
- if proxyURL == "" {
- return client, nil
- }
-
- pURL, err := url.Parse(proxyURL)
- if err != nil {
- return nil, fmt.Errorf("invalid proxy URL: %w", err)
- }
- client.Transport = &http.Transport{
- Proxy: http.ProxyURL(pURL),
- }
- return client, nil
-}
-
-func IsOpenAIReasoningModel(model string) bool {
- m := strings.ToLower(model)
- return CheckModelPrefix(m, "o1") ||
- CheckModelPrefix(m, "o3") ||
- CheckModelPrefix(m, "o4") ||
- CheckModelPrefix(m, "gpt-5") ||
- CheckModelSubPrefix(m, "gpt-5.") ||
- CheckModelPrefix(m, "gpt-6") ||
- CheckModelSubPrefix(m, "gpt-6.")
-}
-
-func CheckModelPrefix(model string, prefix string) bool {
- return model == prefix || strings.HasPrefix(model, prefix+"-")
-}
-
-func CheckModelSubPrefix(model string, prefix string) bool {
- if strings.HasPrefix(model, prefix) && len(model) > len(prefix) {
- if model[len(prefix)] >= '0' && model[len(prefix)] <= '9' {
- return true
- }
- }
- return false
-}
-
-// GeminiSupportsImageToolResults returns true if the model supports multimodal function responses (images in tool results)
-// This is only supported by Gemini 3 Pro and later models
-func GeminiSupportsImageToolResults(model string) bool {
- m := strings.ToLower(model)
- return strings.Contains(m, "gemini-3") || strings.Contains(m, "gemini-4")
-}
-
-// CreateToolUseData creates a UIMessageDataToolUse from tool call information
-func CreateToolUseData(toolCallID, toolName string, arguments string, chatOpts uctypes.WaveChatOpts) uctypes.UIMessageDataToolUse {
- toolUseData := uctypes.UIMessageDataToolUse{
- ToolCallId: toolCallID,
- ToolName: toolName,
- Status: uctypes.ToolUseStatusPending,
- }
-
- toolDef := chatOpts.GetToolDefinition(toolName)
- if toolDef == nil {
- toolUseData.Status = uctypes.ToolUseStatusError
- toolUseData.ErrorMessage = "tool not found"
- return toolUseData
- }
-
- var parsedArgs any
- if err := json.Unmarshal([]byte(arguments), &parsedArgs); err != nil {
- toolUseData.Status = uctypes.ToolUseStatusError
- toolUseData.ErrorMessage = fmt.Sprintf("failed to parse tool arguments: %v", err)
- return toolUseData
- }
-
- if toolDef.ToolCallDesc != nil {
- toolUseData.ToolDesc = toolDef.ToolCallDesc(parsedArgs, nil, nil)
- }
-
- if toolDef.ToolApproval != nil {
- toolUseData.Approval = toolDef.ToolApproval(parsedArgs)
- }
-
- if chatOpts.TabId != "" {
- if argsMap, ok := parsedArgs.(map[string]any); ok {
- if widgetId, ok := argsMap["widget_id"].(string); ok && widgetId != "" {
- ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)
- defer cancelFn()
- fullBlockId, err := wcore.ResolveBlockIdFromPrefix(ctx, chatOpts.TabId, widgetId)
- if err == nil {
- toolUseData.BlockId = fullBlockId
- }
- }
- }
- }
-
- return toolUseData
-}
-
-// SendToolProgress sends tool progress updates via SSE if the tool has a progress descriptor
-func SendToolProgress(toolCallID, toolName string, jsonData []byte, chatOpts uctypes.WaveChatOpts, sseHandler *sse.SSEHandlerCh, usePartialParse bool) {
- toolDef := chatOpts.GetToolDefinition(toolName)
- if toolDef == nil || toolDef.ToolProgressDesc == nil {
- return
- }
-
- var parsedJSON any
- var err error
- if usePartialParse {
- parsedJSON, err = utilfn.ParsePartialJson(jsonData)
- } else {
- err = json.Unmarshal(jsonData, &parsedJSON)
- }
- if err != nil {
- return
- }
-
- statusLines, err := toolDef.ToolProgressDesc(parsedJSON)
- if err != nil {
- return
- }
-
- progressData := &uctypes.UIMessageDataToolProgress{
- ToolCallId: toolCallID,
- ToolName: toolName,
- StatusLines: statusLines,
- }
- _ = sseHandler.AiMsgData("data-toolprogress", "progress-"+toolCallID, progressData)
-}
diff --git a/pkg/aiusechat/anthropic/anthropic-backend.go b/pkg/aiusechat/anthropic/anthropic-backend.go
deleted file mode 100644
index 02070b1bf8..0000000000
--- a/pkg/aiusechat/anthropic/anthropic-backend.go
+++ /dev/null
@@ -1,959 +0,0 @@
-// Copyright 2025, Command Line Inc.
-// SPDX-License-Identifier: Apache-2.0
-
-package anthropic
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "sort"
- "strings"
- "time"
-
- "github.com/google/uuid"
- "github.com/launchdarkly/eventsource"
- "github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil"
- "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore"
- "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes"
- "github.com/wavetermdev/waveterm/pkg/util/logutil"
- "github.com/wavetermdev/waveterm/pkg/util/utilfn"
- "github.com/wavetermdev/waveterm/pkg/web/sse"
-)
-
-const (
- AnthropicDefaultAPIVersion = "2023-06-01"
- AnthropicDefaultMaxTokens = 4096
- AnthropicThinkingBudget = 1024
- AnthropicMinThinkingBudget = 1024
- ProviderMetadataThinkingSignatureKey = "anthropic:signature"
-)
-
-// ---------- Anthropic wire types (subset) ----------
-// Derived from anthropic-messages-api.md and anthropic-streaming.md. :contentReference[oaicite:6]{index=6} :contentReference[oaicite:7]{index=7}
-
-type anthropicChatMessage struct {
- MessageId string `json:"messageid"` // internal field for idempotency (cannot send to anthropic)
- Usage *anthropicUsageType `json:"usage,omitempty"` // internal field (cannot send to anthropic)
- Role string `json:"role"`
- Content []anthropicMessageContentBlock `json:"content"`
-}
-
-func (m *anthropicChatMessage) GetMessageId() string {
- return m.MessageId
-}
-
-func (m *anthropicChatMessage) GetRole() string {
- return m.Role
-}
-
-func (m *anthropicChatMessage) GetUsage() *uctypes.AIUsage {
- if m.Usage == nil {
- return nil
- }
-
- return &uctypes.AIUsage{
- APIType: uctypes.APIType_AnthropicMessages,
- Model: m.Usage.Model,
- InputTokens: m.Usage.InputTokens,
- OutputTokens: m.Usage.OutputTokens,
- NativeWebSearchCount: m.Usage.NativeWebSearchCount,
- }
-}
-
-type anthropicInputMessage struct {
- Role string `json:"role"`
- Content []anthropicMessageContentBlock `json:"content"`
-}
-
-type anthropicMessageContentBlock struct {
- // text, image, document, tool_use, tool_result, thinking, redacted_thinking,
- // server_tool_use, web_search_tool_result, code_execution_tool_result,
- // mcp_tool_use, mcp_tool_result, container_upload, search_result, web_search_result
- Type string `json:"type"`
-
- CacheControl *anthropicCacheControl `json:"cache_control,omitempty"`
-
- // Text content
- Text string `json:"text,omitempty"`
-
- // not going to support citations now
- // Citations []anthropicCitation `json:"citations,omitempty"`
-
- // Image+File content
- Source *anthropicSource `json:"source,omitempty"`
- SourcePreviewUrl string `json:"sourcepreviewurl,omitempty"` // internal field (cannot marshal to API, must be stripped)
-
- // Document content
- Title string `json:"title,omitempty"`
- Context string `json:"context,omitempty"`
-
- // Tool use content
- ID string `json:"id,omitempty"`
- Name string `json:"name,omitempty"`
- Input interface{} `json:"input,omitempty"`
-
- ToolUseDisplayName string `json:"toolusedisplayname,omitempty"` // internal field (cannot marshal to API, must be stripped)
- ToolUseShortDescription string `json:"tooluseshortdescription,omitempty"` // internal field (cannot marshal to API, must be stripped)
- ToolUseData *uctypes.UIMessageDataToolUse `json:"toolusedata,omitempty"` // internal field (cannot marshal to API, must be stripped)
-
- // Tool result content
- ToolUseID string `json:"tool_use_id,omitempty"`
- IsError bool `json:"is_error,omitempty"`
- Content interface{} `json:"content,omitempty"` // string or []blocks for tool results
-
- // Thinking content (extended thinking feature)
- Thinking string `json:"thinking,omitempty"`
- Signature string `json:"signature,omitempty"`
-
- // Server tool use/MCP (web search, code execution, MCP tools)
- ServerName string `json:"server_name,omitempty"`
-
- // Container upload
- FileID string `json:"file_id,omitempty"`
-
- // Web search result (for responses)
- URL string `json:"url,omitempty"`
- EncryptedContent string `json:"encrypted_content,omitempty"`
- PageAge string `json:"page_age,omitempty"`
-
- // Code execution results
- ReturnCode int `json:"return_code,omitempty"`
- Stdout string `json:"stdout,omitempty"`
- Stderr string `json:"stderr,omitempty"`
-}
-
-type anthropicSource struct {
- Type string `json:"type"` // "base64", "url", "file", "text", "content"
- Data string `json:"data,omitempty"`
- MediaType string `json:"media_type,omitempty"` // MIME type
- URL string `json:"url,omitempty"` // URL reference
- FileID string `json:"file_id,omitempty"` // file upload ID
- Text string `json:"text,omitempty"` // plain text (documents only)
- Content interface{} `json:"content,omitempty"` // content blocks (documents only)
- FileName string `json:"filename,omitempty"` // internal field (cannot marshal to API, must be stripped)
- Size int `json:"size,omitempty"` // internal field (cannot marshal to API, must be stripped)
-}
-
-func (s *anthropicSource) Clean() *anthropicSource {
- if s == nil {
- return nil
- }
- rtn := *s
- rtn.FileName = ""
- rtn.Size = 0
- return &rtn
-}
-
-func (b *anthropicMessageContentBlock) Clean() *anthropicMessageContentBlock {
- if b == nil {
- return nil
- }
- rtn := *b
- rtn.SourcePreviewUrl = ""
- rtn.ToolUseDisplayName = ""
- rtn.ToolUseShortDescription = ""
- rtn.ToolUseData = nil
- if rtn.Source != nil {
- rtn.Source = rtn.Source.Clean()
- }
- return &rtn
-}
-
-type anthropicCitation struct {
- Type string `json:"type"`
- CitedText string `json:"cited_text"`
- DocumentIndex int `json:"document_index,omitempty"`
- DocumentTitle string `json:"document_title,omitempty"`
- StartCharIndex int `json:"start_char_index,omitempty"`
- EndCharIndex int `json:"end_char_index,omitempty"`
- // ... other citation type fields
-}
-
-type anthropicStreamRequest struct {
- Model string `json:"model"`
- Messages []anthropicInputMessage `json:"messages"`
- MaxTokens int `json:"max_tokens"`
- Stream bool `json:"stream"`
- System []anthropicMessageContentBlock `json:"system,omitempty"`
- ToolChoice any `json:"tool_choice,omitempty"`
- Tools []any `json:"tools,omitempty"` // *uctypes.ToolDefinition or *anthropicWebSearchTool
- Thinking *anthropicThinkingOpts `json:"thinking,omitempty"`
-}
-
-type anthropicWebSearchTool struct {
- Type string `json:"type"` // "web_search_20250305"
- Name string `json:"name"` // "web_search"
-}
-
-type anthropicCacheControl struct {
- Type string `json:"type"` // "ephemeral"
- TTL string `json:"ttl"` // "5m" or "1h"
-}
-
-type anthropicMessageObj struct {
- ID string `json:"id"`
- Model string `json:"model"`
- StopReason *string `json:"stop_reason"`
- StopSequence *string `json:"stop_sequence"`
-}
-
-type anthropicContentBlockType struct {
- Type string `json:"type"`
- Text string `json:"text,omitempty"`
- Thinking string `json:"thinking,omitempty"`
- ID string `json:"id,omitempty"`
- Name string `json:"name,omitempty"`
- Input json.RawMessage `json:"input,omitempty"`
-}
-
-type anthropicDeltaType struct {
- Type string `json:"type"`
- Text string `json:"text,omitempty"` // text_delta.text
- Thinking string `json:"thinking,omitempty"` // thinking_delta.thinking
- PartialJSON string `json:"partial_json,omitempty"`
- Signature string `json:"signature,omitempty"`
- StopReason *string `json:"stop_reason,omitempty"` // message_delta.delta.stop_reason
- StopSeq *string `json:"stop_sequence,omitempty"` // message_delta.delta.stop_sequence
-}
-
-type anthropicCacheCreationType struct {
- Ephemeral1hInputTokens int `json:"ephemeral_1h_input_tokens,omitempty"` // default: 0
- Ephemeral5mInputTokens int `json:"ephemeral_5m_input_tokens,omitempty"` // default: 0
-}
-
-type anthropicServerToolUseType struct {
- WebFetchRequests int `json:"web_fetch_requests,omitempty"` // default: 0
- WebSearchRequests int `json:"web_search_requests,omitempty"` // default: 0
-}
-
-type anthropicUsageType struct {
- InputTokens int `json:"input_tokens,omitempty"` // cumulative
- OutputTokens int `json:"output_tokens,omitempty"` // cumulative
- CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"`
- CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"`
-
- // internal fields for Wave use (not sent to API)
- Model string `json:"model,omitempty"`
- NativeWebSearchCount int `json:"nativewebsearchcount,omitempty"`
-
- // for reference, but we dont keep thsese up to date or track them
- CacheCreation *anthropicCacheCreationType `json:"cache_creation,omitempty"` // breakdown of cached tokens by TTL
- ServerToolUse *anthropicServerToolUseType `json:"server_tool_use,omitempty"` // server tool requests
- ServiceTier *string `json:"service_tier,omitempty"` // standard, priority, or batch
-}
-
-type anthropicErrorType struct {
- Type string `json:"type"`
- Message string `json:"message"`
-}
-
-type anthropicHTTPErrorResponse struct {
- Type string `json:"type"`
- Error anthropicErrorType `json:"error"`
-}
-
-type anthropicFullStreamEvent struct {
- Type string `json:"type"`
- Message *anthropicMessageObj `json:"message,omitempty"`
- Index *int `json:"index,omitempty"`
- ContentBlock *anthropicContentBlockType `json:"content_block,omitempty"`
- Delta *anthropicDeltaType `json:"delta,omitempty"`
- Usage *anthropicUsageType `json:"usage,omitempty"`
- Error *anthropicErrorType `json:"error,omitempty"`
-}
-
-type anthropicThinkingOpts struct {
- Type string `json:"type"`
- BudgetTokens int `json:"budget_tokens"`
-}
-
-// ---------- per-index content block bookkeeping ----------
-type blockKind int
-
-const (
- blockText blockKind = iota
- blockThinking
- blockToolUse
-)
-
-type blockState struct {
- kind blockKind
- // For text/reasoning: local SSE id
- localID string
- // Content block being built for rtnMessage
- contentBlock *anthropicMessageContentBlock
- // For tool_use:
- toolCallID string // Anthropic tool_use.id
- toolName string
- accumJSON *partialJSON // accumulator for input_json_delta
-}
-
-// partialJSON is a minimal, allocation-friendly accumulator for Anthropic
-// input_json_delta (concat, then parse once on content_block_stop). :contentReference[oaicite:8]{index=8}
-type partialJSON struct {
- buf bytes.Buffer
-}
-
-type streamingState struct {
- blockMap map[int]*blockState
- toolCalls []uctypes.WaveToolCall
- stopFromDelta string
- msgID string
- model string
- stepStarted bool
- rtnMessage *anthropicChatMessage
- usage *anthropicUsageType
- chatOpts uctypes.WaveChatOpts
- webSearchCount int
-}
-
-func (p *partialJSON) Write(s string) {
- // The stream may send empty "" chunks; ignore if zero-length
- if s == "" {
- return
- }
- p.buf.WriteString(s)
-}
-
-func (p *partialJSON) Bytes() []byte { return p.buf.Bytes() }
-
-func (p *partialJSON) FinalObject() (json.RawMessage, error) {
- raw := p.buf.Bytes()
- // If empty, treat as "{}"
- if len(bytes.TrimSpace(raw)) == 0 {
- return json.RawMessage(`{}`), nil
- }
- // The accumulated content should be a valid JSON object string; parse it.
- var v interface{}
- if err := json.Unmarshal(raw, &v); err != nil {
- return nil, fmt.Errorf("invalid accumulated tool input JSON: %w", err)
- }
- // Ensure it's an object per Anthropic contract
- switch v.(type) {
- case map[string]interface{}:
- return json.RawMessage(raw), nil
- default:
- return nil, fmt.Errorf("tool input is not an object")
- }
-}
-
-// sanitizeHostnameInError removes the Wave cloud hostname from error messages
-func sanitizeHostnameInError(err error) error {
- if err == nil {
- return nil
- }
- errStr := err.Error()
- parsedURL, parseErr := url.Parse(uctypes.DefaultAIEndpoint)
- if parseErr == nil && parsedURL.Host != "" && strings.Contains(errStr, parsedURL.Host) {
- errStr = strings.ReplaceAll(errStr, uctypes.DefaultAIEndpoint, "AI service")
- errStr = strings.ReplaceAll(errStr, parsedURL.Host, "host")
- }
- return fmt.Errorf("%s", errStr)
-}
-
-// makeThinkingOpts creates thinking options based on level and max tokens
-func makeThinkingOpts(thinkingLevel string, maxTokens int) *anthropicThinkingOpts {
- if thinkingLevel != uctypes.ThinkingLevelMedium && thinkingLevel != uctypes.ThinkingLevelHigh {
- return nil
- }
-
- maxThinkingBudget := int(float64(maxTokens) * 0.75)
-
- // If 75% of maxTokens is less than minimum, disable thinking
- if maxThinkingBudget < AnthropicMinThinkingBudget {
- return nil
- }
-
- // Use the smaller of our default budget or 75% of maxTokens
- thinkingBudget := AnthropicThinkingBudget
- if thinkingBudget > maxThinkingBudget {
- thinkingBudget = maxThinkingBudget
- }
-
- return &anthropicThinkingOpts{
- Type: "enabled",
- BudgetTokens: thinkingBudget,
- }
-}
-
-// ---------- Public entrypoint ----------
-//
-// Mapping rules recap (Anthropic → AI‑SDK):
-// - message_start → AiMsgStart + AiMsgStartStep
-// - content_block_start(type=text) → AiMsgTextStart; text_delta → AiMsgTextDelta; content_block_stop → AiMsgTextEnd
-// - content_block_start(type=thinking) → AiMsgReasoningStart; thinking_delta → AiMsgReasoningDelta; stop → AiMsgReasoningEnd
-// - content_block_start(type=tool_use) → AiMsgToolInputStart; input_json_delta → AiMsgToolInputDelta; stop → AiMsgToolInputAvailable
-// - If final stop_reason == "tool_use": emit AiMsgFinishStep and return StopReason{Kind:ToolUse, ...} WITHOUT AiMsgFinish
-// - If message_stop with stop_reason == "end_turn" or nil: emit AiMsgFinish then [DONE]
-// - On Anthropic error event: AiMsgError and return StopKindError. :contentReference[oaicite:9]{index=9} :contentReference[oaicite:10]{index=10}
-
-// parseAnthropicHTTPError parses Anthropic API HTTP error responses
-func parseAnthropicHTTPError(resp *http.Response) error {
- slurp, _ := io.ReadAll(resp.Body)
-
- // Try to parse as Anthropic error format first
- var eresp anthropicHTTPErrorResponse
- if err := json.Unmarshal(slurp, &eresp); err == nil && eresp.Error.Message != "" {
- return sanitizeHostnameInError(fmt.Errorf("anthropic %s: %s", resp.Status, eresp.Error.Message))
- }
-
- // Try to parse as proxy error format
- var proxyErr uctypes.ProxyErrorResponse
- if err := json.Unmarshal(slurp, &proxyErr); err == nil && !proxyErr.Success && proxyErr.Error != "" {
- return sanitizeHostnameInError(fmt.Errorf("anthropic %s: %s", resp.Status, proxyErr.Error))
- }
-
- // Fall back to truncated raw response
- msg := utilfn.TruncateString(strings.TrimSpace(string(slurp)), 120)
- if msg == "" {
- msg = "unknown error"
- }
- return sanitizeHostnameInError(fmt.Errorf("anthropic %s: %s", resp.Status, msg))
-}
-
-func RunAnthropicChatStep(
- ctx context.Context,
- sse *sse.SSEHandlerCh,
- chatOpts uctypes.WaveChatOpts,
- cont *uctypes.WaveContinueResponse,
-) (*uctypes.WaveStopReason, *anthropicChatMessage, *uctypes.RateLimitInfo, error) {
- if sse == nil {
- return nil, nil, nil, errors.New("sse handler is nil")
- }
-
- // Get chat from store
- chat := chatstore.DefaultChatStore.Get(chatOpts.ChatId)
- if chat == nil {
- return nil, nil, nil, fmt.Errorf("chat not found: %s", chatOpts.ChatId)
- }
-
- // Validate that chatOpts.Config match the chat's stored configuration
- if chat.APIType != chatOpts.Config.APIType {
- return nil, nil, nil, fmt.Errorf("API type mismatch: chat has %s, chatOpts has %s", chat.APIType, chatOpts.Config.APIType)
- }
- if !uctypes.AreModelsCompatible(chat.APIType, chat.Model, chatOpts.Config.Model) {
- return nil, nil, nil, fmt.Errorf("model mismatch: chat has %s, chatOpts has %s", chat.Model, chatOpts.Config.Model)
- }
- if chat.APIVersion != chatOpts.Config.APIVersion {
- return nil, nil, nil, fmt.Errorf("API version mismatch: chat has %s, chatOpts has %s", chat.APIVersion, chatOpts.Config.APIVersion)
- }
-
- // Context with timeout if provided.
- if chatOpts.Config.TimeoutMs > 0 {
- var cancel context.CancelFunc
- ctx, cancel = context.WithTimeout(ctx, time.Duration(chatOpts.Config.TimeoutMs)*time.Millisecond)
- defer cancel()
- }
-
- // Validate continuation if provided
- if cont != nil {
- if !uctypes.AreModelsCompatible(chat.APIType, chatOpts.Config.Model, cont.Model) {
- return nil, nil, nil, fmt.Errorf("cannot continue with a different model, model:%q, cont-model:%q", chatOpts.Config.Model, cont.Model)
- }
- }
-
- // Convert GenAIMessages to anthropicInputMessages
- var anthropicMsgs []anthropicInputMessage
- for _, genMsg := range chat.NativeMessages {
- // Cast to anthropicChatMessage
- chatMsg, ok := genMsg.(*anthropicChatMessage)
- if !ok {
- return nil, nil, nil, fmt.Errorf("expected anthropicChatMessage, got %T", genMsg)
- }
- // Convert to anthropicInputMessage with copied content
- contentCopy := make([]anthropicMessageContentBlock, len(chatMsg.Content))
- copy(contentCopy, chatMsg.Content)
- inputMsg := anthropicInputMessage{
- Role: chatMsg.Role,
- Content: contentCopy,
- }
- anthropicMsgs = append(anthropicMsgs, inputMsg)
- }
-
- req, err := buildAnthropicHTTPRequest(ctx, anthropicMsgs, chatOpts)
- if err != nil {
- return nil, nil, nil, err
- }
-
- httpClient, err := aiutil.MakeHTTPClient(chatOpts.Config.ProxyURL)
- if err != nil {
- return nil, nil, nil, err
- }
-
- resp, err := httpClient.Do(req)
- if err != nil {
- return nil, nil, nil, sanitizeHostnameInError(err)
- }
- defer resp.Body.Close()
-
- // Parse rate limit info from header if present (do this before error check)
- rateLimitInfo := uctypes.ParseRateLimitHeader(resp.Header.Get("X-Wave-RateLimit"))
-
- ct := resp.Header.Get("Content-Type")
- if resp.StatusCode != http.StatusOK || !strings.HasPrefix(ct, "text/event-stream") {
- // Handle 429 rate limit with special logic
- if resp.StatusCode == http.StatusTooManyRequests && rateLimitInfo != nil {
- if rateLimitInfo.PReq == 0 && rateLimitInfo.Req > 0 {
- // Premium requests exhausted, but regular requests available
- stopReason := &uctypes.WaveStopReason{
- Kind: uctypes.StopKindPremiumRateLimit,
- }
- return stopReason, nil, rateLimitInfo, nil
- }
- if rateLimitInfo.Req == 0 {
- // All requests exhausted
- stopReason := &uctypes.WaveStopReason{
- Kind: uctypes.StopKindRateLimit,
- }
- return stopReason, nil, rateLimitInfo, nil
- }
- }
- return nil, nil, rateLimitInfo, parseAnthropicHTTPError(resp)
- }
-
- // At this point we have a valid SSE stream, so setup SSE handling
- // From here on, errors must be returned through the SSE stream
- if cont == nil {
- sse.SetupSSE()
- }
-
- // Use eventsource decoder for proper SSE parsing
- decoder := eventsource.NewDecoder(resp.Body)
-
- stopReason, rtnMessage := handleAnthropicStreamingResp(ctx, sse, decoder, cont, chatOpts)
- return stopReason, rtnMessage, rateLimitInfo, nil
-}
-
-// handleAnthropicStreamingResp processes the SSE stream after HTTP setup is complete
-func handleAnthropicStreamingResp(
- ctx context.Context,
- sse *sse.SSEHandlerCh,
- decoder *eventsource.Decoder,
- cont *uctypes.WaveContinueResponse,
- chatOpts uctypes.WaveChatOpts,
-) (*uctypes.WaveStopReason, *anthropicChatMessage) {
- // Per-response state
- state := &streamingState{
- blockMap: map[int]*blockState{},
- rtnMessage: &anthropicChatMessage{
- MessageId: uuid.New().String(),
- Role: "assistant",
- Content: []anthropicMessageContentBlock{},
- },
- chatOpts: chatOpts,
- }
-
- var rtnStopReason *uctypes.WaveStopReason
-
- // Ensure step is closed on error/cancellation
- defer func() {
- // Set usage in the returned message
- if state.usage != nil {
- state.usage.Model = state.model
- if state.webSearchCount > 0 {
- state.usage.NativeWebSearchCount = state.webSearchCount
- }
- state.rtnMessage.Usage = state.usage
- }
-
- if !state.stepStarted {
- return
- }
- _ = sse.AiMsgFinishStep()
- if rtnStopReason == nil || rtnStopReason.Kind != uctypes.StopKindToolUse {
- _ = sse.AiMsgFinish("", nil)
- }
- }()
-
- // SSE event processing loop
- for {
- // Check for context cancellation
- if err := ctx.Err(); err != nil {
- _ = sse.AiMsgError("request cancelled")
- return &uctypes.WaveStopReason{
- Kind: uctypes.StopKindCanceled,
- ErrorType: "cancelled",
- ErrorText: "request cancelled",
- }, state.rtnMessage
- }
-
- event, err := decoder.Decode()
- if err != nil {
- if errors.Is(err, io.EOF) {
- // Normal end of stream
- break
- }
- if sse.Err() != nil {
- return &uctypes.WaveStopReason{
- Kind: uctypes.StopKindCanceled,
- ErrorType: "client_disconnect",
- ErrorText: "client disconnected",
- }, extractPartialTextFromState(state)
- }
- // transport error mid-stream
- _ = sse.AiMsgError(err.Error())
- return &uctypes.WaveStopReason{
- Kind: uctypes.StopKindError,
- ErrorType: "stream",
- ErrorText: err.Error(),
- }, state.rtnMessage
- }
-
- if stop, ret := handleAnthropicEvent(event, sse, state, cont); ret != nil {
- // Either error or message_stop triggered return
- rtnStopReason = ret
- return ret, state.rtnMessage
- } else {
- // maybe updated final stop reason (from message_delta)
- if stop != nil && *stop != "" {
- state.stopFromDelta = *stop
- }
- }
- }
-
- // EOF - let defer handle cleanup
- rtnStopReason = &uctypes.WaveStopReason{
- Kind: uctypes.StopKindDone,
- RawReason: state.stopFromDelta,
- }
- return rtnStopReason, state.rtnMessage
-}
-
-func extractPartialTextFromState(state *streamingState) *anthropicChatMessage {
- var content []anthropicMessageContentBlock
- for _, block := range state.rtnMessage.Content {
- if block.Type == "text" && block.Text != "" {
- content = append(content, block)
- }
- }
- var partialIdx []int
- for idx, st := range state.blockMap {
- if st.kind == blockText && st.contentBlock != nil && st.contentBlock.Text != "" {
- partialIdx = append(partialIdx, idx)
- }
- }
- sort.Ints(partialIdx)
- for _, idx := range partialIdx {
- st := state.blockMap[idx]
- if st.kind == blockText && st.contentBlock != nil && st.contentBlock.Text != "" {
- content = append(content, *st.contentBlock)
- }
- }
- if len(content) == 0 {
- return nil
- }
- return &anthropicChatMessage{
- MessageId: state.rtnMessage.MessageId,
- Role: "assistant",
- Content: content,
- Usage: state.rtnMessage.Usage,
- }
-}
-
-// handleAnthropicEvent processes one SSE event block. It may emit SSE parts
-// and/or return a StopReason when the stream is complete.
-//
-// Return tuple:
-// - stopFromDelta: a *string with stop reason when message_delta updates stop_reason
-// - final: a *StopReason to return immediately (e.g., after message_stop or error)
-//
-// Event model: anthropic-streaming.md. :contentReference[oaicite:16]{index=16}
-func handleAnthropicEvent(
- event eventsource.Event,
- sse *sse.SSEHandlerCh,
- state *streamingState,
- cont *uctypes.WaveContinueResponse,
-) (stopFromDelta *string, final *uctypes.WaveStopReason) {
- if err := sse.Err(); err != nil {
- return nil, &uctypes.WaveStopReason{
- Kind: uctypes.StopKindCanceled,
- ErrorType: "client_disconnect",
- ErrorText: "client disconnected",
- }
- }
- eventName := event.Event()
- data := event.Data()
- switch eventName {
- case "ping":
- return nil, nil // ignore
-
- case "error":
- // Example: data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}} :contentReference[oaicite:17]{index=17}
- var ev anthropicFullStreamEvent
- if jerr := json.Unmarshal([]byte(data), &ev); jerr != nil {
- err := fmt.Errorf("error event decode: %w", jerr)
- _ = sse.AiMsgError(err.Error())
- return nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}
- }
- msg := "unknown error"
- etype := "error"
- if ev.Error != nil {
- msg = ev.Error.Message
- etype = ev.Error.Type
- }
- _ = sse.AiMsgError(msg)
- return nil, &uctypes.WaveStopReason{
- Kind: uctypes.StopKindError,
- ErrorType: etype,
- ErrorText: msg,
- }
-
- case "message_start":
- var ev anthropicFullStreamEvent
- if err := json.Unmarshal([]byte(data), &ev); err != nil {
- _ = sse.AiMsgError(err.Error())
- return nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}
- }
- if ev.Message != nil {
- state.msgID = ev.Message.ID
- state.model = ev.Message.Model
- }
- // Initialize usage from message_start event
- if ev.Usage != nil {
- state.usage = ev.Usage
- }
- if cont == nil {
- _ = sse.AiMsgStart(state.msgID)
- }
- _ = sse.AiMsgStartStep()
- state.stepStarted = true
- return nil, nil
-
- case "content_block_start":
- var ev anthropicFullStreamEvent
- if err := json.Unmarshal([]byte(data), &ev); err != nil {
- _ = sse.AiMsgError(err.Error())
- return nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}
- }
- if ev.Index == nil || ev.ContentBlock == nil {
- return nil, nil
- }
- idx := *ev.Index
- switch ev.ContentBlock.Type {
- case "text":
- id := uuid.New().String()
- state.blockMap[idx] = &blockState{
- kind: blockText,
- localID: id,
- contentBlock: &anthropicMessageContentBlock{
- Type: "text",
- Text: "",
- },
- }
- _ = sse.AiMsgTextStart(id)
- case "thinking":
- id := uuid.New().String()
- state.blockMap[idx] = &blockState{
- kind: blockThinking,
- localID: id,
- contentBlock: &anthropicMessageContentBlock{
- Type: "thinking",
- Thinking: "",
- },
- }
- _ = sse.AiMsgReasoningStart(id)
- case "tool_use":
- tcID := ev.ContentBlock.ID
- tName := ev.ContentBlock.Name
- st := &blockState{
- kind: blockToolUse,
- toolCallID: tcID,
- toolName: tName,
- accumJSON: &partialJSON{},
- }
- state.blockMap[idx] = st
- _ = sse.AiMsgToolInputStart(tcID, tName)
- case "server_tool_use":
- if ev.ContentBlock.Name == "web_search" {
- state.webSearchCount++
- }
- default:
- // ignore other block types gracefully per Anthropic guidance :contentReference[oaicite:18]{index=18}
- }
- return nil, nil
-
- case "content_block_delta":
- var ev anthropicFullStreamEvent
- if err := json.Unmarshal([]byte(data), &ev); err != nil {
- _ = sse.AiMsgError(err.Error())
- return nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}
- }
- if ev.Index == nil || ev.Delta == nil {
- return nil, nil
- }
- st := state.blockMap[*ev.Index]
- if st == nil {
- return nil, nil
- }
- switch ev.Delta.Type {
- case "text_delta":
- if st.kind == blockText {
- _ = sse.AiMsgTextDelta(st.localID, ev.Delta.Text)
- // Accumulate text in the content block
- if st.contentBlock != nil {
- st.contentBlock.Text += ev.Delta.Text
- }
- }
- case "thinking_delta":
- if st.kind == blockThinking {
- _ = sse.AiMsgReasoningDelta(st.localID, ev.Delta.Thinking)
- // Accumulate thinking content in the content block
- if st.contentBlock != nil {
- st.contentBlock.Thinking += ev.Delta.Thinking
- }
- }
- case "input_json_delta":
- if st.kind == blockToolUse {
- st.accumJSON.Write(ev.Delta.PartialJSON)
- _ = sse.AiMsgToolInputDelta(st.toolCallID, ev.Delta.PartialJSON)
- aiutil.SendToolProgress(st.toolCallID, st.toolName, st.accumJSON.Bytes(), state.chatOpts, sse, true)
- }
- case "signature_delta":
- // Accumulate signature for thinking blocks
- if st.kind == blockThinking && st.contentBlock != nil {
- st.contentBlock.Signature += ev.Delta.Signature
- }
- default:
- // ignore unknown deltas gracefully. :contentReference[oaicite:20]{index=20}
- }
- return nil, nil
-
- case "content_block_stop":
- var ev anthropicFullStreamEvent
- if err := json.Unmarshal([]byte(data), &ev); err != nil {
- _ = sse.AiMsgError(err.Error())
- return nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}
- }
- if ev.Index == nil {
- return nil, nil
- }
- st := state.blockMap[*ev.Index]
- if st == nil {
- return nil, nil
- }
- switch st.kind {
- case blockText:
- _ = sse.AiMsgTextEnd(st.localID)
- // Add completed text block to rtnMessage
- if st.contentBlock != nil {
- state.rtnMessage.Content = append(state.rtnMessage.Content, *st.contentBlock)
- }
- case blockThinking:
- _ = sse.AiMsgReasoningEnd(st.localID)
- // Add completed thinking block to rtnMessage
- if st.contentBlock != nil {
- state.rtnMessage.Content = append(state.rtnMessage.Content, *st.contentBlock)
- }
- case blockToolUse:
- raw, jerr := st.accumJSON.FinalObject()
- if jerr != nil {
- _ = sse.AiMsgError(jerr.Error())
- return nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "parse", ErrorText: jerr.Error()}
- }
- var input any
- if len(raw) > 0 {
- jerr = json.Unmarshal(raw, &input)
- if jerr != nil {
- _ = sse.AiMsgError(jerr.Error())
- return nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "parse", ErrorText: jerr.Error()}
- }
- }
- _ = sse.AiMsgToolInputAvailable(st.toolCallID, st.toolName, raw)
- aiutil.SendToolProgress(st.toolCallID, st.toolName, raw, state.chatOpts, sse, false)
- state.toolCalls = append(state.toolCalls, uctypes.WaveToolCall{
- ID: st.toolCallID,
- Name: st.toolName,
- Input: input,
- })
- // Add completed tool_use block to rtnMessage
- toolUseBlock := anthropicMessageContentBlock{
- Type: "tool_use",
- ID: st.toolCallID,
- Name: st.toolName,
- Input: input,
- }
- state.rtnMessage.Content = append(state.rtnMessage.Content, toolUseBlock)
- }
- // extractPartialTextFromState reads blockMap for still-in-flight content, so remove completed blocks
- // once they have been appended to rtnMessage.Content to avoid duplicate text on disconnect.
- delete(state.blockMap, *ev.Index)
- return nil, nil
-
- case "message_delta":
- var ev anthropicFullStreamEvent
- if err := json.Unmarshal([]byte(data), &ev); err != nil {
- _ = sse.AiMsgError(err.Error())
- return nil, &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}
- }
- if ev.Delta != nil && ev.Delta.StopReason != nil {
- stopFromDelta = ev.Delta.StopReason
- }
- // Update cumulative usage from message_delta event
- if ev.Usage != nil {
- if state.usage == nil {
- state.usage = &anthropicUsageType{}
- }
- // Update the fields we track (cumulative values)
- if ev.Usage.InputTokens > 0 {
- state.usage.InputTokens = ev.Usage.InputTokens
- }
- if ev.Usage.OutputTokens > 0 {
- state.usage.OutputTokens = ev.Usage.OutputTokens
- }
- if ev.Usage.CacheCreationInputTokens > 0 {
- state.usage.CacheCreationInputTokens = ev.Usage.CacheCreationInputTokens
- }
- if ev.Usage.CacheReadInputTokens > 0 {
- state.usage.CacheReadInputTokens = ev.Usage.CacheReadInputTokens
- }
- }
- return stopFromDelta, nil
-
- case "message_stop":
- // Decide finalization based on last known stop_reason.
- // If we didn't capture it in message_delta, treat as end_turn.
- reason := "end_turn"
- if state.stopFromDelta != "" {
- reason = state.stopFromDelta
- }
- switch reason {
- case "tool_use":
- return nil, &uctypes.WaveStopReason{
- Kind: uctypes.StopKindToolUse,
- RawReason: reason,
- ToolCalls: state.toolCalls,
- }
- case "max_tokens":
- return nil, &uctypes.WaveStopReason{
- Kind: uctypes.StopKindMaxTokens,
- RawReason: reason,
- }
- case "refusal":
- return nil, &uctypes.WaveStopReason{
- Kind: uctypes.StopKindContent,
- RawReason: reason,
- }
- case "pause_turn":
- return nil, &uctypes.WaveStopReason{
- Kind: uctypes.StopKindPauseTurn,
- RawReason: reason,
- }
- default:
- // end_turn, stop_sequence (treat as end of this call)
- return nil, &uctypes.WaveStopReason{
- Kind: uctypes.StopKindDone,
- RawReason: reason,
- }
- }
-
- default:
- logutil.DevPrintf("unknown anthropic event type: %s", eventName)
- return nil, nil
- }
-}
diff --git a/pkg/aiusechat/anthropic/anthropic-backend_test.go b/pkg/aiusechat/anthropic/anthropic-backend_test.go
deleted file mode 100644
index 71e89bfb2f..0000000000
--- a/pkg/aiusechat/anthropic/anthropic-backend_test.go
+++ /dev/null
@@ -1,166 +0,0 @@
-// Copyright 2025, Command Line Inc.
-// SPDX-License-Identifier: Apache-2.0
-
-package anthropic
-
-import (
- "testing"
-
- "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore"
- "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes"
-)
-
-func TestConvertPartsToAnthropicBlocks_TextOnly(t *testing.T) {
- parts := []uctypes.UIMessagePart{
- {Type: "text", Text: "Hello world"},
- {Type: "text", Text: "Default text"},
- }
-
- blocks, err := convertPartsToAnthropicBlocks(parts, "user")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- if len(blocks) != 2 {
- t.Fatalf("expected 2 blocks, got %d", len(blocks))
- }
-
- // Check first block
- block1 := blocks[0]
- if block1.Type != "text" {
- t.Errorf("expected type 'text', got %v", block1.Type)
- }
- if block1.Text != "Hello world" {
- t.Errorf("expected text 'Hello world', got %v", block1.Text)
- }
-
- // Check second block (empty type defaults to text)
- block2 := blocks[1]
- if block2.Type != "text" {
- t.Errorf("expected type 'text', got %v", block2.Type)
- }
- if block2.Text != "Default text" {
- t.Errorf("expected text 'Default text', got %v", block2.Text)
- }
-}
-
-func TestConvertPartsToAnthropicBlocks_SkipsUnknownTypes(t *testing.T) {
- parts := []uctypes.UIMessagePart{
- {Type: "text", Text: "Valid text"},
- {Type: "unknown_type", Text: "Should be skipped"},
- {Type: "text", Text: "Another valid text"},
- }
-
- blocks, err := convertPartsToAnthropicBlocks(parts, "user")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- if len(blocks) != 2 {
- t.Fatalf("expected 2 blocks (unknown type skipped), got %d", len(blocks))
- }
-
- block1 := blocks[0]
- if block1.Text != "Valid text" {
- t.Errorf("expected first text 'Valid text', got %v", block1.Text)
- }
-
- block2 := blocks[1]
- if block2.Text != "Another valid text" {
- t.Errorf("expected second text 'Another valid text', got %v", block2.Text)
- }
-}
-
-func TestGetFunctionCallInputByToolCallId(t *testing.T) {
- toolData := &uctypes.UIMessageDataToolUse{ToolCallId: "call-1", ToolName: "read_file", Status: uctypes.ToolUseStatusPending}
- chat := uctypes.AIChat{
- NativeMessages: []uctypes.GenAIMessage{
- &anthropicChatMessage{
- MessageId: "m1",
- Role: "assistant",
- Content: []anthropicMessageContentBlock{
- {Type: "tool_use", ID: "call-1", Name: "read_file", Input: map[string]interface{}{"path": "/tmp/a"}, ToolUseData: toolData},
- },
- },
- },
- }
- fnCall := GetFunctionCallInputByToolCallId(chat, "call-1")
- if fnCall == nil {
- t.Fatalf("expected function call input")
- }
- if fnCall.CallId != "call-1" || fnCall.Name != "read_file" {
- t.Fatalf("unexpected function call input: %#v", fnCall)
- }
- if fnCall.Arguments != "{\"path\":\"/tmp/a\"}" {
- t.Fatalf("unexpected arguments: %s", fnCall.Arguments)
- }
- if fnCall.ToolUseData == nil || fnCall.ToolUseData.ToolCallId != "call-1" {
- t.Fatalf("expected tool use data")
- }
-}
-
-func TestUpdateAndRemoveToolUseCall(t *testing.T) {
- chatID := "anthropic-test-tooluse"
- chatstore.DefaultChatStore.Delete(chatID)
- defer chatstore.DefaultChatStore.Delete(chatID)
-
- aiOpts := &uctypes.AIOptsType{
- APIType: uctypes.APIType_AnthropicMessages,
- Model: "claude-sonnet-4-5",
- APIVersion: AnthropicDefaultAPIVersion,
- }
- msg := &anthropicChatMessage{
- MessageId: "m1",
- Role: "assistant",
- Content: []anthropicMessageContentBlock{
- {Type: "text", Text: "start"},
- {Type: "tool_use", ID: "call-1", Name: "read_file", Input: map[string]interface{}{"path": "/tmp/a"}},
- },
- }
- if err := chatstore.DefaultChatStore.PostMessage(chatID, aiOpts, msg); err != nil {
- t.Fatalf("failed to seed chat: %v", err)
- }
-
- newData := uctypes.UIMessageDataToolUse{ToolCallId: "call-1", ToolName: "read_file", Status: uctypes.ToolUseStatusCompleted}
- if err := UpdateToolUseData(chatID, "call-1", newData); err != nil {
- t.Fatalf("update failed: %v", err)
- }
-
- chat := chatstore.DefaultChatStore.Get(chatID)
- updated := chat.NativeMessages[0].(*anthropicChatMessage)
- if updated.Content[1].ToolUseData == nil || updated.Content[1].ToolUseData.Status != uctypes.ToolUseStatusCompleted {
- t.Fatalf("tool use data not updated")
- }
-
- if err := RemoveToolUseCall(chatID, "call-1"); err != nil {
- t.Fatalf("remove failed: %v", err)
- }
- chat = chatstore.DefaultChatStore.Get(chatID)
- updated = chat.NativeMessages[0].(*anthropicChatMessage)
- if len(updated.Content) != 1 || updated.Content[0].Type != "text" {
- t.Fatalf("expected tool_use block removed, got %#v", updated.Content)
- }
-}
-
-func TestConvertToUIMessageIncludesToolUseData(t *testing.T) {
- msg := &anthropicChatMessage{
- MessageId: "m1",
- Role: "assistant",
- Content: []anthropicMessageContentBlock{
- {
- Type: "tool_use",
- ID: "call-1",
- Name: "read_file",
- Input: map[string]interface{}{"path": "/tmp/a"},
- ToolUseData: &uctypes.UIMessageDataToolUse{ToolCallId: "call-1", ToolName: "read_file", Status: uctypes.ToolUseStatusPending},
- },
- },
- }
- ui := msg.ConvertToUIMessage()
- if ui == nil || len(ui.Parts) != 2 {
- t.Fatalf("expected tool and data-tooluse parts, got %#v", ui)
- }
- if ui.Parts[0].Type != "tool-read_file" || ui.Parts[1].Type != "data-tooluse" {
- t.Fatalf("unexpected part types: %#v", ui.Parts)
- }
-}
diff --git a/pkg/aiusechat/anthropic/anthropic-convertmessage.go b/pkg/aiusechat/anthropic/anthropic-convertmessage.go
deleted file mode 100644
index 552cc8080c..0000000000
--- a/pkg/aiusechat/anthropic/anthropic-convertmessage.go
+++ /dev/null
@@ -1,940 +0,0 @@
-// Copyright 2025, Command Line Inc.
-// SPDX-License-Identifier: Apache-2.0
-
-package anthropic
-
-import (
- "bytes"
- "context"
- "encoding/base64"
- "encoding/json"
- "errors"
- "fmt"
- "log"
- "net/http"
- "regexp"
- "slices"
- "strings"
-
- "github.com/google/uuid"
- "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore"
- "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes"
- "github.com/wavetermdev/waveterm/pkg/util/logutil"
- "github.com/wavetermdev/waveterm/pkg/util/utilfn"
- "github.com/wavetermdev/waveterm/pkg/wavebase"
-)
-
-// these conversions are based off the anthropic spec
-// and the aiprompts/aisdk-uimessage-type.md doc (v5)
-
-// buildAnthropicHTTPRequest creates a complete HTTP request for the Anthropic API
-func buildAnthropicHTTPRequest(ctx context.Context, msgs []anthropicInputMessage, chatOpts uctypes.WaveChatOpts) (*http.Request, error) {
- opts := chatOpts.Config
- if opts.Model == "" {
- return nil, errors.New("ai:model is required")
- }
- if chatOpts.ClientId == "" {
- return nil, errors.New("chatOpts.ClientId is required")
- }
-
- // Set defaults
- endpoint := opts.Endpoint
- if endpoint == "" {
- return nil, errors.New("ai:endpoint is required")
- }
-
- maxTokens := opts.MaxTokens
- if maxTokens <= 0 {
- maxTokens = AnthropicDefaultMaxTokens
- }
-
- // Convert messages to clear FileName fields from Source blocks
- convertedMsgs := make([]anthropicInputMessage, len(msgs))
- for i, msg := range msgs {
- convertedMsgs[i] = convertMessageForAPI(msg)
- }
-
- // inject chatOpts.TabState as a "text" block at the END of the LAST "user" message found (append to Content)
- if chatOpts.TabState != "" {
- // Find the last "user" message
- for i := len(convertedMsgs) - 1; i >= 0; i-- {
- if convertedMsgs[i].Role == "user" {
- // Create a text block with the TabState content
- tabStateBlock := anthropicMessageContentBlock{
- Type: "text",
- Text: chatOpts.TabState,
- }
- // Append to the Content of this message
- convertedMsgs[i].Content = append(convertedMsgs[i].Content, tabStateBlock)
- break
- }
- }
- }
-
- // inject chatOpts.PlatformInfo, AppStaticFiles, and AppGoFile as "text" blocks at the END of the LAST "user" message found (append to Content)
- if chatOpts.PlatformInfo != "" || chatOpts.AppStaticFiles != "" || chatOpts.AppGoFile != "" {
- // Find the last "user" message
- for i := len(convertedMsgs) - 1; i >= 0; i-- {
- if convertedMsgs[i].Role == "user" {
- if chatOpts.PlatformInfo != "" {
- platformInfoBlock := anthropicMessageContentBlock{
- Type: "text",
- Text: "\n" + chatOpts.PlatformInfo + "\n",
- }
- convertedMsgs[i].Content = append(convertedMsgs[i].Content, platformInfoBlock)
- }
- if chatOpts.AppStaticFiles != "" {
- appStaticFilesBlock := anthropicMessageContentBlock{
- Type: "text",
- Text: "\n" + chatOpts.AppStaticFiles + "\n",
- }
- convertedMsgs[i].Content = append(convertedMsgs[i].Content, appStaticFilesBlock)
- }
- if chatOpts.AppGoFile != "" {
- appGoFileBlock := anthropicMessageContentBlock{
- Type: "text",
- Text: "\n" + chatOpts.AppGoFile + "\n",
- }
- convertedMsgs[i].Content = append(convertedMsgs[i].Content, appGoFileBlock)
- }
- break
- }
- }
- }
-
- // Build request body
- reqBody := &anthropicStreamRequest{
- Model: opts.Model,
- MaxTokens: maxTokens,
- Stream: true,
- Messages: convertedMsgs,
- }
-
- // Add system prompt if provided
- if len(chatOpts.SystemPrompt) > 0 {
- systemBlocks := make([]anthropicMessageContentBlock, len(chatOpts.SystemPrompt))
- for i, prompt := range chatOpts.SystemPrompt {
- systemBlocks[i] = anthropicMessageContentBlock{
- Type: "text",
- Text: prompt,
- }
- }
- reqBody.System = systemBlocks
- }
-
- for _, tool := range chatOpts.Tools {
- cleanedTool := tool.Clean()
- reqBody.Tools = append(reqBody.Tools, cleanedTool)
- }
- for _, tool := range chatOpts.TabTools {
- cleanedTool := tool.Clean()
- reqBody.Tools = append(reqBody.Tools, cleanedTool)
- }
- if chatOpts.AllowNativeWebSearch {
- reqBody.Tools = append(reqBody.Tools, &anthropicWebSearchTool{Type: "web_search_20250305", Name: "web_search"})
- }
-
- // Enable extended thinking based on level
- reqBody.Thinking = makeThinkingOpts(opts.ThinkingLevel, maxTokens)
-
- // pretty print json of anthropicMsgs
- if jsonStr, err := utilfn.MarshalIndentNoHTMLString(convertedMsgs, "", " "); err == nil {
- var toolNames []string
- for _, tool := range chatOpts.Tools {
- toolNames = append(toolNames, tool.Name)
- }
- for _, tool := range chatOpts.TabTools {
- toolNames = append(toolNames, tool.Name)
- }
- if chatOpts.AllowNativeWebSearch {
- toolNames = append(toolNames, "web_search[server]")
- }
- logutil.DevPrintf("tools: %s\n", strings.Join(toolNames, ", "))
- logutil.DevPrintf("anthropicMsgs JSON:\n%s", jsonStr)
- logutil.DevPrintf("has-api-key: %v\n", opts.APIToken != "")
- }
-
- var buf bytes.Buffer
- encoder := json.NewEncoder(&buf)
- encoder.SetEscapeHTML(false)
- err := encoder.Encode(reqBody)
- if err != nil {
- return nil, err
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &buf)
- if err != nil {
- return nil, err
- }
- req.Header.Set("content-type", "application/json")
- if opts.APIToken != "" {
- req.Header.Set("x-api-key", opts.APIToken)
- }
- req.Header.Set("anthropic-version", AnthropicDefaultAPIVersion)
- req.Header.Set("accept", "text/event-stream")
- // Only send Wave-specific headers when using Wave provider
- if opts.Provider == uctypes.AIProvider_Wave {
- if chatOpts.ClientId != "" {
- req.Header.Set("X-Wave-ClientId", chatOpts.ClientId)
- }
- if chatOpts.ChatId != "" {
- req.Header.Set("X-Wave-ChatId", chatOpts.ChatId)
- }
- req.Header.Set("X-Wave-Version", wavebase.WaveVersion)
- req.Header.Set("X-Wave-APIType", uctypes.APIType_AnthropicMessages)
- req.Header.Set("X-Wave-RequestType", chatOpts.GetWaveRequestType())
- }
-
- return req, nil
-}
-
-// convertToolUsePart converts a tool-* type UIMessagePart to an Anthropic tool_use or tool_result block
-func convertToolUsePart(p uctypes.UIMessagePart) (*anthropicMessageContentBlock, error) {
- // Sanity check that this is actually a tool-* type
- if !strings.HasPrefix(p.Type, "tool-") {
- return nil, fmt.Errorf("convertToolUsePart expects 'tool-*' type, got '%s'", p.Type)
- }
-
- // Extract tool name from type field (format: "tool-{name}")
- toolName := strings.TrimPrefix(p.Type, "tool-")
- if toolName == "" {
- return nil, fmt.Errorf("tool name is empty (type was '%s')", p.Type)
- }
- if len(toolName) > 200 {
- return nil, fmt.Errorf("tool name exceeds 200 character limit: %d characters", len(toolName))
- }
- if p.ToolCallID == "" {
- return nil, fmt.Errorf("tool call ID is required but missing")
- }
-
- // Validate ToolCallID charset (must match ^[a-zA-Z0-9_-]+$)
- validIDPattern := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
- if !validIDPattern.MatchString(p.ToolCallID) {
- return nil, fmt.Errorf("tool call ID contains invalid characters (must be alphanumeric, underscore, or dash): %s", p.ToolCallID)
- }
-
- // Handle different states
- if p.State == "input-streaming" || p.State == "input-available" {
- // These states represent tool calls (tool_use blocks)
- // Anthropic expects an object for input, never nil
- input := p.Input
- if input == nil {
- input = map[string]interface{}{}
- } else {
- // Validate that input is an object (map), not string/array
- if _, ok := input.(map[string]interface{}); !ok {
- return nil, fmt.Errorf("tool input must be an object/map, got %T", input)
- }
- }
-
- return &anthropicMessageContentBlock{
- Type: "tool_use",
- ID: p.ToolCallID,
- Name: toolName,
- Input: input,
- }, nil
-
- } else if p.State == "output-available" {
- // This state represents successful tool execution result (tool_result block)
- var content interface{}
- if p.Output != nil {
- // Try to convert output to string if it's not already
- if outputStr, ok := p.Output.(string); ok {
- content = outputStr
- } else {
- // If it's not a string, marshal it to JSON
- outputBytes, err := json.Marshal(p.Output)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal tool output: %w", err)
- }
- content = string(outputBytes)
- }
- } else {
- content = ""
- }
-
- return &anthropicMessageContentBlock{
- Type: "tool_result",
- ToolUseID: p.ToolCallID,
- Content: content,
- }, nil
-
- } else if p.State == "output-error" {
- // This state represents failed tool execution (tool_result block with error)
- errorContent := p.ErrorText
- if errorContent == "" {
- errorContent = "Tool execution failed"
- }
-
- return &anthropicMessageContentBlock{
- Type: "tool_result",
- ToolUseID: p.ToolCallID,
- Content: errorContent,
- IsError: true,
- }, nil
-
- } else {
- return nil, fmt.Errorf("invalid tool part state '%s' (must be 'input-streaming', 'input-available', 'output-available', or 'output-error')", p.State)
- }
-}
-
-// convertPartToAnthropicBlocks converts a single UIMessagePart to one or more Anthropic content blocks
-func convertPartToAnthropicBlocks(p uctypes.UIMessagePart, role string, blockIndex int) ([]anthropicMessageContentBlock, error) {
- if p.Type == "text" {
- return []anthropicMessageContentBlock{{
- Type: "text",
- Text: p.Text,
- }}, nil
- } else if p.Type == "reasoning" {
- // Check if we have a signature in provider metadata
- signature, hasSignature := p.ProviderMetadata[ProviderMetadataThinkingSignatureKey]
- if !hasSignature {
- return nil, fmt.Errorf("reasoning part requires signature in provider metadata key '%s'", ProviderMetadataThinkingSignatureKey)
- }
-
- signatureStr, ok := signature.(string)
- if !ok {
- return nil, fmt.Errorf("reasoning part signature must be a string, got %T", signature)
- }
-
- return []anthropicMessageContentBlock{{
- Type: "thinking",
- Thinking: p.Text,
- Signature: signatureStr,
- }}, nil
- } else if p.Type == "source-url" || p.Type == "source-document" {
- // no longer convert citations
- return nil, nil
- } else if p.Type == "step-start" {
- // Omit step-start parts from Anthropic
- return nil, nil
- } else if strings.HasPrefix(p.Type, "data-") {
- // Omit data-* parts from Anthropic
- return nil, nil
- } else if p.Type == "file" {
- // Anthropic expects files in user messages
- if role != "user" {
- return nil, fmt.Errorf("dropping file part in %s message (files should be in user messages)", role)
- }
- block, err := convertFileUIMessagePart(p)
- if err != nil {
- return nil, err
- }
- return []anthropicMessageContentBlock{*block}, nil
- } else if strings.HasPrefix(p.Type, "tool-") {
- block, err := convertToolUsePart(p)
- if err != nil {
- return nil, err
- }
- return []anthropicMessageContentBlock{*block}, nil
- } else {
- // Skip unknown part types
- return nil, fmt.Errorf("dropping unknown part type '%s'", p.Type)
- }
-}
-
-// convertPartsToAnthropicBlocks converts UseChatMessagePart array to Anthropic content blocks with role-based validation
-func convertPartsToAnthropicBlocks(parts []uctypes.UIMessagePart, role string) ([]anthropicMessageContentBlock, error) {
- var blocks []anthropicMessageContentBlock
-
- for _, p := range parts {
- partBlocks, err := convertPartToAnthropicBlocks(p, role, len(blocks))
- if err != nil {
- log.Printf("anthropic: %v", err)
- continue
- }
- blocks = append(blocks, partBlocks...)
- }
-
- return blocks, nil
-}
-
-// convertFileUIMessagePart converts a file part to Anthropic image or document block format
-func convertFileUIMessagePart(p uctypes.UIMessagePart) (*anthropicMessageContentBlock, error) {
- if p.Type != "file" {
- return nil, fmt.Errorf("convertFileUIMessagePart expects 'file' type, got '%s'", p.Type)
- }
- if p.URL == "" {
- return nil, errors.New("file part missing url")
- }
- if p.MediaType == "" {
- return nil, errors.New("file part missing mediaType")
- }
-
- // Validate URL protocol - only allow data:, http:, https:
- if !strings.HasPrefix(p.URL, "data:") &&
- !strings.HasPrefix(p.URL, "http://") &&
- !strings.HasPrefix(p.URL, "https://") {
- return nil, fmt.Errorf("unsupported URL protocol in file part: %s", p.URL)
- }
-
- // Branch on mediaType first to determine block type and constraints
- switch {
- case strings.HasPrefix(p.MediaType, "image/"):
- // image/* (jpeg, png, gif, webp) → Anthropic image block
- if strings.HasPrefix(p.URL, "data:") {
- // Data URL → base64 source
- parts := strings.SplitN(p.URL, ",", 2)
- if len(parts) != 2 {
- return nil, errors.New("invalid data URL format")
- }
- return &anthropicMessageContentBlock{
- Type: "image",
- Source: &anthropicSource{
- Type: "base64",
- Data: parts[1],
- MediaType: p.MediaType,
- },
- }, nil
- } else {
- // HTTP/HTTPS URL → url source (no media_type for image URLs)
- return &anthropicMessageContentBlock{
- Type: "image",
- Source: &anthropicSource{
- Type: "url",
- URL: p.URL,
- },
- }, nil
- }
-
- case p.MediaType == "application/pdf":
- // application/pdf → Anthropic document block
- if strings.HasPrefix(p.URL, "data:") {
- // Data URL → base64 source
- parts := strings.SplitN(p.URL, ",", 2)
- if len(parts) != 2 {
- return nil, errors.New("invalid data URL format")
- }
- return &anthropicMessageContentBlock{
- Type: "document",
- Source: &anthropicSource{
- Type: "base64",
- Data: parts[1],
- MediaType: p.MediaType,
- },
- }, nil
- } else {
- // HTTP/HTTPS URL → url source (no media_type for URL sources)
- return &anthropicMessageContentBlock{
- Type: "document",
- Source: &anthropicSource{
- Type: "url",
- URL: p.URL,
- },
- }, nil
- }
-
- case p.MediaType == "text/plain":
- // text/plain → Anthropic document block, but NO URL form supported
- if strings.HasPrefix(p.URL, "data:") {
- // Data URL → decode base64 data and return as document with PlainTextSource
- parts := strings.SplitN(p.URL, ",", 2)
- if len(parts) != 2 {
- return nil, errors.New("invalid data URL format")
- }
- // Decode base64 data
- textData, err := base64.StdEncoding.DecodeString(parts[1])
- if err != nil {
- return nil, fmt.Errorf("failed to decode base64 data: %w", err)
- }
- return &anthropicMessageContentBlock{
- Type: "document",
- Source: &anthropicSource{
- Type: "text",
- Data: string(textData),
- MediaType: "text/plain",
- },
- }, nil
- } else {
- // HTTP/HTTPS URL → not supported inline, would need to fetch
- return nil, fmt.Errorf("dropping text/plain file with URL (must be fetched and converted to base64 or uploaded to Files API)")
- }
-
- default:
- // Other media types → not supported inline, must upload and use file_id
- return nil, fmt.Errorf("dropping file with unsupported media type '%s' (must be uploaded to Files API and sent as file_id)", p.MediaType)
- }
-
-}
-
-// convertAIMessageToAnthropicChatMessage converts an AIMessage to anthropicChatMessage
-// These messages are ALWAYS role "user"
-func ConvertAIMessageToAnthropicChatMessage(aiMsg uctypes.AIMessage) (*anthropicChatMessage, error) {
- if err := aiMsg.Validate(); err != nil {
- return nil, fmt.Errorf("invalid AIMessage: %w", err)
- }
-
- var contentBlocks []anthropicMessageContentBlock
-
- for i, part := range aiMsg.Parts {
- switch part.Type {
- case uctypes.AIMessagePartTypeText:
- if part.Text == "" {
- return nil, fmt.Errorf("part %d: text type requires non-empty text field", i)
- }
- contentBlocks = append(contentBlocks, anthropicMessageContentBlock{
- Type: "text",
- Text: part.Text,
- })
-
- case uctypes.AIMessagePartTypeFile:
- block, err := convertFileAIMessagePart(part)
- if err != nil {
- return nil, fmt.Errorf("part %d: %w", i, err)
- }
- contentBlocks = append(contentBlocks, *block)
-
- default:
- return nil, fmt.Errorf("part %d: unsupported part type '%s'", i, part.Type)
- }
- }
-
- return &anthropicChatMessage{
- MessageId: aiMsg.MessageId,
- Role: "user",
- Content: contentBlocks,
- }, nil
-}
-
-// hasInlineData checks if the part has data available for inline use (either Data field or data URL)
-func hasInlineData(part uctypes.AIMessagePart) bool {
- hasData := len(part.Data) > 0
- hasURL := part.URL != "" && strings.HasPrefix(part.URL, "data:")
- return hasData || hasURL
-}
-
-// extractBase64Data extracts base64 data from either the Data field or a data URL
-func extractBase64Data(part uctypes.AIMessagePart) (string, error) {
- hasData := len(part.Data) > 0
- hasURL := part.URL != ""
-
- if hasData {
- // Raw data → base64 encode
- return base64.StdEncoding.EncodeToString(part.Data), nil
- } else if hasURL && strings.HasPrefix(part.URL, "data:") {
- // Data URL → check format and extract/encode data appropriately
- parts := strings.SplitN(part.URL, ",", 2)
- if len(parts) != 2 {
- return "", errors.New("invalid data URL format")
- }
-
- header := parts[0]
- data := parts[1]
-
- // Check if it's already base64 encoded: data:mediatype;base64,
- if strings.Contains(header, ";base64") {
- // Already base64 encoded
- return data, nil
- } else {
- // Raw data that needs base64 encoding: data:mediatype,
- return base64.StdEncoding.EncodeToString([]byte(data)), nil
- }
- }
-
- return "", errors.New("no data available for base64 extraction")
-}
-
-// convertFileAIMessagePart converts a file AIMessagePart to anthropicMessageContentBlock
-func convertFileAIMessagePart(part uctypes.AIMessagePart) (*anthropicMessageContentBlock, error) {
- if part.Type != uctypes.AIMessagePartTypeFile {
- return nil, fmt.Errorf("convertFileAIMessagePart expects 'file' type, got '%s'", part.Type)
- }
-
- if err := part.Validate(); err != nil {
- return nil, err
- }
-
- // Validate URL protocol if URL is provided - only allow data:, http:, https:
- if part.URL != "" {
- if !strings.HasPrefix(part.URL, "data:") &&
- !strings.HasPrefix(part.URL, "http://") &&
- !strings.HasPrefix(part.URL, "https://") {
- return nil, fmt.Errorf("unsupported URL protocol in file part: %s", part.URL)
- }
- }
-
- // Branch on mimetype to determine block type and constraints
- switch {
- case strings.HasPrefix(part.MimeType, "image/"):
- // image/* (jpeg, png, gif, webp) → Anthropic image block
- if hasInlineData(part) {
- // Data available → use base64 source
- base64Data, err := extractBase64Data(part)
- if err != nil {
- return nil, err
- }
- return &anthropicMessageContentBlock{
- Type: "image",
- Source: &anthropicSource{
- Type: "base64",
- Data: base64Data,
- MediaType: part.MimeType,
- FileName: part.FileName,
- },
- SourcePreviewUrl: part.PreviewUrl,
- }, nil
- } else {
- // HTTP/HTTPS URL → url source (no media_type for image URLs)
- return &anthropicMessageContentBlock{
- Type: "image",
- Source: &anthropicSource{
- Type: "url",
- URL: part.URL,
- FileName: part.FileName,
- },
- SourcePreviewUrl: part.PreviewUrl,
- }, nil
- }
-
- case part.MimeType == "application/pdf":
- // application/pdf → Anthropic document block
- if hasInlineData(part) {
- // Data available → use base64 source
- base64Data, err := extractBase64Data(part)
- if err != nil {
- return nil, err
- }
- return &anthropicMessageContentBlock{
- Type: "document",
- Source: &anthropicSource{
- Type: "base64",
- Data: base64Data,
- MediaType: part.MimeType,
- FileName: part.FileName,
- },
- SourcePreviewUrl: part.PreviewUrl,
- }, nil
- } else {
- // HTTP/HTTPS URL → url source (no media_type for URL sources)
- return &anthropicMessageContentBlock{
- Type: "document",
- Source: &anthropicSource{
- Type: "url",
- URL: part.URL,
- FileName: part.FileName,
- },
- SourcePreviewUrl: part.PreviewUrl,
- }, nil
- }
-
- case part.MimeType == "text/plain":
- // text/plain → Anthropic document block, but NO URL form supported
- if hasInlineData(part) {
- var textData string
- if len(part.Data) > 0 {
- // Raw data → convert to string directly
- textData = string(part.Data)
- } else {
- // Data URL → extract base64 data and decode back to string
- base64Data, err := extractBase64Data(part)
- if err != nil {
- return nil, err
- }
- decoded, err := base64.StdEncoding.DecodeString(base64Data)
- if err != nil {
- return nil, fmt.Errorf("failed to decode base64 data: %w", err)
- }
- textData = string(decoded)
- }
- return &anthropicMessageContentBlock{
- Type: "document",
- Source: &anthropicSource{
- Type: "text",
- Data: textData,
- MediaType: part.MimeType,
- FileName: part.FileName,
- },
- }, nil
- } else {
- // HTTP/HTTPS URL → not supported inline, would need to fetch
- return nil, fmt.Errorf("text/plain file with URL not supported (must be fetched and converted to base64 or uploaded to Files API)")
- }
-
- default:
- // Other media types → not supported inline, must upload and use file_id
- return nil, fmt.Errorf("unsupported media type '%s' (must be uploaded to Files API and sent as file_id)", part.MimeType)
- }
-}
-
-// ConvertToUIMessage converts an anthropicChatMessage to a UIMessage
-func (m *anthropicChatMessage) ConvertToUIMessage() *uctypes.UIMessage {
- var parts []uctypes.UIMessagePart
-
- // Iterate over all content blocks
- for _, block := range m.Content {
- switch block.Type {
- case "text":
- // Convert text blocks to UIMessagePart
- parts = append(parts, uctypes.UIMessagePart{
- Type: "text",
- Text: block.Text,
- })
- case "image":
- // Convert image blocks to data-userfile UIMessagePart (only for user role)
- if m.Role == "user" && block.Source != nil {
- parts = append(parts, uctypes.UIMessagePart{
- Type: "data-userfile",
- Data: uctypes.UIMessageDataUserFile{
- FileName: block.Source.FileName,
- Size: block.Source.Size,
- MimeType: block.Source.MediaType,
- PreviewUrl: block.SourcePreviewUrl,
- },
- })
- }
- case "document":
- // Convert document blocks to data-userfile UIMessagePart (only for user role)
- if m.Role == "user" && block.Source != nil {
- parts = append(parts, uctypes.UIMessagePart{
- Type: "data-userfile",
- Data: uctypes.UIMessageDataUserFile{
- FileName: block.Source.FileName,
- Size: block.Source.Size,
- MimeType: block.Source.MediaType,
- PreviewUrl: block.SourcePreviewUrl,
- },
- })
- }
- case "tool_use":
- // Convert tool_use blocks to tool UIMessagePart with input-available state
- if block.Name != "" && block.ID != "" {
- parts = append(parts, uctypes.UIMessagePart{
- Type: "tool-" + block.Name,
- State: "input-available",
- ToolCallID: block.ID,
- Input: block.Input,
- })
- if block.ToolUseData != nil {
- parts = append(parts, uctypes.UIMessagePart{
- Type: "data-tooluse",
- ID: block.ID,
- Data: *block.ToolUseData,
- })
- }
- }
- default:
- // For now, skip all other types (will implement later)
- continue
- }
- }
-
- if len(parts) == 0 {
- return nil
- }
-
- return &uctypes.UIMessage{
- ID: m.MessageId,
- Role: m.Role,
- Parts: parts,
- }
-}
-
-// convertMessageForAPI creates a copy of the anthropicInputMessage with internal fields stripped from content blocks
-func convertMessageForAPI(msg anthropicInputMessage) anthropicInputMessage {
- // Create a copy of the message
- converted := anthropicInputMessage{
- Role: msg.Role,
- Content: make([]anthropicMessageContentBlock, len(msg.Content)),
- }
-
- // Copy each content block and clean it (strips internal fields)
- for i, block := range msg.Content {
- converted.Content[i] = *block.Clean()
- }
-
- return converted
-}
-
-// ConvertToolResultsToAnthropicChatMessage converts AIToolResult slice to anthropicChatMessage
-func ConvertToolResultsToAnthropicChatMessage(toolResults []uctypes.AIToolResult) (*anthropicChatMessage, error) {
- if len(toolResults) == 0 {
- return nil, errors.New("toolResults cannot be empty")
- }
-
- var contentBlocks []anthropicMessageContentBlock
-
- for _, result := range toolResults {
- if result.ToolUseID == "" {
- return nil, fmt.Errorf("tool result missing ToolUseID")
- }
-
- var content interface{}
- var isError bool
-
- if result.ErrorText != "" {
- content = result.ErrorText
- isError = true
- } else {
- // Check if text looks like an image data URL
- if strings.HasPrefix(result.Text, "data:image/") {
- // Parse the data URL to extract media type and base64 data
- parts := strings.SplitN(result.Text, ",", 2)
- if len(parts) == 2 {
- // Extract media type from "data:image/png;base64"
- mediaTypePart := strings.TrimPrefix(parts[0], "data:")
- mediaType := strings.Split(mediaTypePart, ";")[0]
-
- // Create content as array with image block
- content = []anthropicMessageContentBlock{
- {
- Type: "image",
- Source: &anthropicSource{
- Type: "base64",
- Data: parts[1],
- MediaType: mediaType,
- },
- },
- }
- isError = false
- } else {
- // Failed to parse data URL
- content = "failed to parse image data URL"
- isError = true
- }
- } else {
- content = result.Text
- isError = false
- }
- }
-
- contentBlocks = append(contentBlocks, anthropicMessageContentBlock{
- Type: "tool_result",
- ToolUseID: result.ToolUseID,
- Content: content,
- IsError: isError,
- })
- }
-
- return &anthropicChatMessage{
- MessageId: uuid.New().String(),
- Role: "user",
- Content: contentBlocks,
- }, nil
-}
-
-// ConvertAIChatToUIChat converts an AIChat to a UIChat for Anthropic
-func ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) {
- if aiChat.APIType != uctypes.APIType_AnthropicMessages {
- return nil, fmt.Errorf("APIType must be '%s', got '%s'", uctypes.APIType_AnthropicMessages, aiChat.APIType)
- }
-
- uiMessages := make([]uctypes.UIMessage, 0, len(aiChat.NativeMessages))
-
- for i, nativeMsg := range aiChat.NativeMessages {
- anthropicMsg, ok := nativeMsg.(*anthropicChatMessage)
- if !ok {
- return nil, fmt.Errorf("message %d: expected *anthropicChatMessage, got %T", i, nativeMsg)
- }
-
- uiMsg := anthropicMsg.ConvertToUIMessage()
- if uiMsg != nil {
- uiMessages = append(uiMessages, *uiMsg)
- }
- }
-
- return &uctypes.UIChat{
- ChatId: aiChat.ChatId,
- APIType: aiChat.APIType,
- Model: aiChat.Model,
- APIVersion: aiChat.APIVersion,
- Messages: uiMessages,
- }, nil
-}
-
-func GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput {
- for _, genMsg := range aiChat.NativeMessages {
- chatMsg, ok := genMsg.(*anthropicChatMessage)
- if !ok {
- continue
- }
- for _, block := range chatMsg.Content {
- if block.Type != "tool_use" || block.ID != toolCallId {
- continue
- }
- argsInput := block.Input
- if argsInput == nil {
- argsInput = map[string]interface{}{}
- }
- argsBytes, err := json.Marshal(argsInput)
- if err != nil {
- continue
- }
- return &uctypes.AIFunctionCallInput{
- CallId: block.ID,
- Name: block.Name,
- Arguments: string(argsBytes),
- ToolUseData: block.ToolUseData,
- }
- }
- }
- return nil
-}
-
-func UpdateToolUseData(chatId string, toolCallId string, toolUseData uctypes.UIMessageDataToolUse) error {
- chat := chatstore.DefaultChatStore.Get(chatId)
- if chat == nil {
- return fmt.Errorf("chat not found: %s", chatId)
- }
- for _, genMsg := range chat.NativeMessages {
- chatMsg, ok := genMsg.(*anthropicChatMessage)
- if !ok {
- continue
- }
- for i, block := range chatMsg.Content {
- if block.Type != "tool_use" || block.ID != toolCallId {
- continue
- }
- updatedMsg := &anthropicChatMessage{
- MessageId: chatMsg.MessageId,
- Usage: chatMsg.Usage,
- Role: chatMsg.Role,
- Content: slices.Clone(chatMsg.Content),
- }
- updatedMsg.Content[i].ToolUseData = &toolUseData
- aiOpts := &uctypes.AIOptsType{
- APIType: chat.APIType,
- Model: chat.Model,
- APIVersion: chat.APIVersion,
- }
- return chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, updatedMsg)
- }
- }
- return fmt.Errorf("tool call with ID %s not found in chat %s", toolCallId, chatId)
-}
-
-func RemoveToolUseCall(chatId string, toolCallId string) error {
- chat := chatstore.DefaultChatStore.Get(chatId)
- if chat == nil {
- return fmt.Errorf("chat not found: %s", chatId)
- }
- for _, genMsg := range chat.NativeMessages {
- chatMsg, ok := genMsg.(*anthropicChatMessage)
- if !ok {
- continue
- }
- for i, block := range chatMsg.Content {
- if block.Type != "tool_use" || block.ID != toolCallId {
- continue
- }
- updatedMsg := &anthropicChatMessage{
- MessageId: chatMsg.MessageId,
- Usage: chatMsg.Usage,
- Role: chatMsg.Role,
- Content: slices.Delete(slices.Clone(chatMsg.Content), i, i+1),
- }
- if len(updatedMsg.Content) == 0 {
- chatstore.DefaultChatStore.RemoveMessage(chatId, chatMsg.MessageId)
- } else {
- aiOpts := &uctypes.AIOptsType{
- APIType: chat.APIType,
- Model: chat.Model,
- APIVersion: chat.APIVersion,
- }
- if err := chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, updatedMsg); err != nil {
- return err
- }
- }
- return nil
- }
- }
- return nil
-}
diff --git a/pkg/aiusechat/chatstore/chatstore.go b/pkg/aiusechat/chatstore/chatstore.go
deleted file mode 100644
index 4abe26ba62..0000000000
--- a/pkg/aiusechat/chatstore/chatstore.go
+++ /dev/null
@@ -1,129 +0,0 @@
-// Copyright 2025, Command Line Inc.
-// SPDX-License-Identifier: Apache-2.0
-
-package chatstore
-
-import (
- "fmt"
- "slices"
- "sync"
-
- "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes"
-)
-
-type ChatStore struct {
- lock sync.Mutex
- chats map[string]*uctypes.AIChat
-}
-
-var DefaultChatStore = &ChatStore{
- chats: make(map[string]*uctypes.AIChat),
-}
-
-func (cs *ChatStore) Get(chatId string) *uctypes.AIChat {
- cs.lock.Lock()
- defer cs.lock.Unlock()
-
- chat := cs.chats[chatId]
- if chat == nil {
- return nil
- }
-
- // Copy the chat to prevent concurrent access issues
- copyChat := &uctypes.AIChat{
- ChatId: chat.ChatId,
- APIType: chat.APIType,
- Model: chat.Model,
- APIVersion: chat.APIVersion,
- NativeMessages: make([]uctypes.GenAIMessage, len(chat.NativeMessages)),
- }
- copy(copyChat.NativeMessages, chat.NativeMessages)
-
- return copyChat
-}
-
-func (cs *ChatStore) Delete(chatId string) {
- cs.lock.Lock()
- defer cs.lock.Unlock()
-
- delete(cs.chats, chatId)
-}
-
-func (cs *ChatStore) CountUserMessages(chatId string) int {
- cs.lock.Lock()
- defer cs.lock.Unlock()
-
- chat := cs.chats[chatId]
- if chat == nil {
- return 0
- }
-
- count := 0
- for _, msg := range chat.NativeMessages {
- if msg.GetRole() == "user" {
- count++
- }
- }
- return count
-}
-
-func (cs *ChatStore) PostMessage(chatId string, aiOpts *uctypes.AIOptsType, message uctypes.GenAIMessage) error {
- cs.lock.Lock()
- defer cs.lock.Unlock()
-
- chat := cs.chats[chatId]
- if chat == nil {
- // Create new chat
- chat = &uctypes.AIChat{
- ChatId: chatId,
- APIType: aiOpts.APIType,
- Model: aiOpts.Model,
- APIVersion: aiOpts.APIVersion,
- NativeMessages: make([]uctypes.GenAIMessage, 0),
- }
- cs.chats[chatId] = chat
- } else {
- // Verify that the AI options match
- if chat.APIType != aiOpts.APIType {
- return fmt.Errorf("API type mismatch: expected %s, got %s (must start a new chat)", chat.APIType, aiOpts.APIType)
- }
- if !uctypes.AreModelsCompatible(chat.APIType, chat.Model, aiOpts.Model) {
- return fmt.Errorf("model mismatch: expected %s, got %s (must start a new chat)", chat.Model, aiOpts.Model)
- }
- if chat.APIVersion != aiOpts.APIVersion {
- return fmt.Errorf("API version mismatch: expected %s, got %s (must start a new chat)", chat.APIVersion, aiOpts.APIVersion)
- }
- }
-
- // Check for existing message with same ID (idempotency)
- messageId := message.GetMessageId()
- for i, existingMessage := range chat.NativeMessages {
- if existingMessage.GetMessageId() == messageId {
- // Replace existing message with same ID
- chat.NativeMessages[i] = message
- return nil
- }
- }
-
- // Append the new message if no duplicate found
- chat.NativeMessages = append(chat.NativeMessages, message)
-
- return nil
-}
-
-func (cs *ChatStore) RemoveMessage(chatId string, messageId string) bool {
- cs.lock.Lock()
- defer cs.lock.Unlock()
-
- chat := cs.chats[chatId]
- if chat == nil {
- return false
- }
-
- initialLen := len(chat.NativeMessages)
- chat.NativeMessages = slices.DeleteFunc(chat.NativeMessages, func(msg uctypes.GenAIMessage) bool {
- return msg.GetMessageId() == messageId
- })
-
- return len(chat.NativeMessages) < initialLen
-}
diff --git a/pkg/aiusechat/gemini/doc.go b/pkg/aiusechat/gemini/doc.go
deleted file mode 100644
index 7fe0699c5e..0000000000
--- a/pkg/aiusechat/gemini/doc.go
+++ /dev/null
@@ -1,99 +0,0 @@
-// Copyright 2025, Command Line Inc.
-// SPDX-License-Identifier: Apache-2.0
-
-// Package gemini implements the Google Gemini backend for WaveTerm's AI chat system.
-//
-// This package provides a complete implementation of the UseChatBackend interface
-// for Google's Gemini API, including:
-// - Streaming chat responses via Server-Sent Events (SSE)
-// - Function calling (tool use) support
-// - Multi-modal input support (text, images, PDFs)
-// - Proper message conversion and state management
-//
-// # API Type
-//
-// The Gemini backend uses the API type constant:
-// uctypes.APIType_GoogleGemini = "google-gemini"
-//
-// # Supported Features
-//
-// - Text messages
-// - Image uploads (JPEG, PNG, etc.) - inline base64 encoding
-// - PDF document uploads - inline base64 encoding
-// - Text file attachments
-// - Directory listings
-// - Function/tool calling with structured arguments
-// - Streaming responses with real-time token delivery
-//
-// # Usage
-//
-// The backend is automatically registered and can be obtained via:
-//
-// backend, err := aiusechat.GetBackendByAPIType(uctypes.APIType_GoogleGemini)
-//
-// To use the Gemini API, you need:
-// 1. A Google AI API key
-// 2. Configure the chat with APIType_GoogleGemini
-// 3. Set the Model (e.g., "gemini-2.0-flash-exp")
-// 4. Provide the API key in the Config.APIToken field
-//
-// # Configuration Example
-//
-// chatOpts := uctypes.WaveChatOpts{
-// ChatId: "my-chat-id",
-// ClientId: "my-client-id",
-// Config: uctypes.AIOptsType{
-// APIType: uctypes.APIType_GoogleGemini,
-// Model: "gemini-2.0-flash-exp",
-// APIToken: "your-google-api-key",
-// MaxTokens: 8192,
-// Capabilities: []string{
-// uctypes.AICapabilityTools,
-// uctypes.AICapabilityImages,
-// uctypes.AICapabilityPdfs,
-// },
-// },
-// Tools: []uctypes.ToolDefinition{...},
-// SystemPrompt: []string{"You are a helpful assistant."},
-// }
-//
-// # Message Format
-//
-// The Gemini backend uses the GeminiChatMessage type internally, which stores:
-// - MessageId: Unique identifier for idempotency
-// - Role: "user" or "model" (model is Gemini's term for assistant)
-// - Parts: Array of message parts (text, inline data, function calls/responses)
-// - Usage: Token usage metadata
-//
-// # Function Calling
-//
-// Function calling is supported via Gemini's native function calling feature:
-// - Tools are converted to Gemini's FunctionDeclaration format
-// - Function calls are streamed with real-time argument updates
-// - Function responses are sent back as user messages with FunctionResponse parts
-//
-// # API Endpoint
-//
-// By default, the backend uses:
-// https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent
-//
-// You can override this by setting Config.BaseURL.
-//
-// # Error Handling
-//
-// The backend properly handles:
-// - Content blocking/safety filters
-// - Token limit errors
-// - Network errors
-// - Malformed responses
-// - Context cancellation
-//
-// All errors are properly propagated through the SSE stream.
-//
-// # Limitations
-//
-// - File uploads must be provided as base64-encoded inline data
-// - Images and PDFs use inline data, not file upload URIs
-// - Multi-turn conversations require proper role alternation (user/model)
-// - Some advanced Gemini features like caching are not yet implemented
-package gemini
diff --git a/pkg/aiusechat/gemini/gemini-backend.go b/pkg/aiusechat/gemini/gemini-backend.go
deleted file mode 100644
index 728df59a4f..0000000000
--- a/pkg/aiusechat/gemini/gemini-backend.go
+++ /dev/null
@@ -1,500 +0,0 @@
-// Copyright 2025, Command Line Inc.
-// SPDX-License-Identifier: Apache-2.0
-
-package gemini
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "log"
- "net/http"
- "net/url"
- "strings"
- "time"
-
- "github.com/google/uuid"
- "github.com/launchdarkly/eventsource"
- "github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil"
- "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore"
- "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes"
- "github.com/wavetermdev/waveterm/pkg/util/utilfn"
- "github.com/wavetermdev/waveterm/pkg/wavebase"
- "github.com/wavetermdev/waveterm/pkg/web/sse"
-)
-
-// ensureAltSse ensures the ?alt=sse query parameter is set on the endpoint
-func ensureAltSse(endpoint string) (string, error) {
- parsedURL, err := url.Parse(endpoint)
- if err != nil {
- return "", fmt.Errorf("invalid ai:endpoint URL: %w", err)
- }
-
- query := parsedURL.Query()
- if query.Get("alt") != "sse" {
- query.Set("alt", "sse")
- parsedURL.RawQuery = query.Encode()
- return parsedURL.String(), nil
- }
-
- return endpoint, nil
-}
-
-// appendPartToLastUserMessage appends a text part to the last user message in the contents slice
-func appendPartToLastUserMessage(contents []GeminiContent, text string) {
- for i := len(contents) - 1; i >= 0; i-- {
- if contents[i].Role == "user" {
- contents[i].Parts = append(contents[i].Parts, GeminiMessagePart{
- Text: text,
- })
- break
- }
- }
-}
-
-// buildGeminiHTTPRequest creates an HTTP request for the Gemini API
-func buildGeminiHTTPRequest(ctx context.Context, contents []GeminiContent, chatOpts uctypes.WaveChatOpts) (*http.Request, error) {
- opts := chatOpts.Config
-
- if opts.Model == "" {
- return nil, errors.New("ai:model is required")
- }
- if opts.APIToken == "" {
- return nil, errors.New("ai:apitoken is required")
- }
- if opts.Endpoint == "" {
- return nil, errors.New("ai:endpoint is required")
- }
-
- maxTokens := opts.MaxTokens
- if maxTokens <= 0 {
- maxTokens = GeminiDefaultMaxTokens
- }
-
- // Build request body
- reqBody := &GeminiRequest{
- Contents: contents,
- GenerationConfig: &GeminiGenerationConfig{
- MaxOutputTokens: int32(maxTokens),
- Temperature: 0.7, // Default temperature
- },
- }
-
- // Map thinking level for Gemini 3+ models
- if opts.ThinkingLevel != "" && strings.Contains(opts.Model, "gemini-3") {
- geminiThinkingLevel := "high"
- if opts.ThinkingLevel == uctypes.ThinkingLevelLow {
- geminiThinkingLevel = "low"
- }
- reqBody.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
- ThinkingLevel: geminiThinkingLevel,
- }
- }
-
- // Add system instruction if provided
- if len(chatOpts.SystemPrompt) > 0 {
- systemText := strings.Join(chatOpts.SystemPrompt, "\n\n")
- reqBody.SystemInstruction = &GeminiContent{
- Parts: []GeminiMessagePart{
- {Text: systemText},
- },
- }
- }
-
- // Add tools if provided
- var allTools []uctypes.ToolDefinition
- allTools = append(allTools, chatOpts.Tools...)
- allTools = append(allTools, chatOpts.TabTools...)
-
- if len(allTools) > 0 {
- var functionDeclarations []GeminiFunctionDeclaration
- for _, tool := range allTools {
- // Only include tools whose capabilities are met
- if !tool.HasRequiredCapabilities(opts.Capabilities) {
- continue
- }
- functionDeclarations = append(functionDeclarations, ConvertToolDefinitionToGemini(tool))
- }
- if len(functionDeclarations) > 0 {
- reqBody.Tools = []GeminiTool{
- {FunctionDeclarations: functionDeclarations},
- }
- reqBody.ToolConfig = &GeminiToolConfig{
- FunctionCallingConfig: &GeminiFunctionCallingConfig{
- Mode: "AUTO",
- },
- }
- }
- }
-
- // Injected data - append to last user message as separate parts
- if chatOpts.TabState != "" {
- appendPartToLastUserMessage(reqBody.Contents, chatOpts.TabState)
- }
- if chatOpts.PlatformInfo != "" {
- appendPartToLastUserMessage(reqBody.Contents, "\n"+chatOpts.PlatformInfo+"\n")
- }
- if chatOpts.AppStaticFiles != "" {
- appendPartToLastUserMessage(reqBody.Contents, "\n"+chatOpts.AppStaticFiles+"\n")
- }
- if chatOpts.AppGoFile != "" {
- appendPartToLastUserMessage(reqBody.Contents, "\n"+chatOpts.AppGoFile+"\n")
- }
-
- if wavebase.IsDevMode() {
- var toolNames []string
- for _, tool := range allTools {
- toolNames = append(toolNames, tool.Name)
- }
- log.Printf("gemini: model %s, messages: %d, tools: %s\n", opts.Model, len(contents), strings.Join(toolNames, ","))
- }
-
- // Encode request body
- buf, err := aiutil.JsonEncodeRequestBody(reqBody)
- if err != nil {
- return nil, err
- }
-
- // Build URL
- endpoint, err := ensureAltSse(opts.Endpoint)
- if err != nil {
- return nil, err
- }
-
- // Create HTTP request
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &buf)
- if err != nil {
- return nil, err
- }
-
- // Set headers
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("x-goog-api-key", opts.APIToken)
-
- return req, nil
-}
-
-// RunGeminiChatStep executes a chat step using the Gemini API
-func RunGeminiChatStep(
- ctx context.Context,
- sseHandler *sse.SSEHandlerCh,
- chatOpts uctypes.WaveChatOpts,
- cont *uctypes.WaveContinueResponse,
-) (*uctypes.WaveStopReason, *GeminiChatMessage, *uctypes.RateLimitInfo, error) {
- if sseHandler == nil {
- return nil, nil, nil, errors.New("sse handler is nil")
- }
-
- // Get chat from store
- chat := chatstore.DefaultChatStore.Get(chatOpts.ChatId)
- if chat == nil {
- return nil, nil, nil, fmt.Errorf("chat not found: %s", chatOpts.ChatId)
- }
-
- // Validate that chatOpts.Config match the chat's stored configuration
- if chat.APIType != chatOpts.Config.APIType {
- return nil, nil, nil, fmt.Errorf("API type mismatch: chat has %s, chatOpts has %s", chat.APIType, chatOpts.Config.APIType)
- }
- if chat.Model != chatOpts.Config.Model {
- return nil, nil, nil, fmt.Errorf("model mismatch: chat has %s, chatOpts has %s", chat.Model, chatOpts.Config.Model)
- }
-
- // Context with timeout if provided
- if chatOpts.Config.TimeoutMs > 0 {
- var cancel context.CancelFunc
- ctx, cancel = context.WithTimeout(ctx, time.Duration(chatOpts.Config.TimeoutMs)*time.Millisecond)
- defer cancel()
- }
-
- // Convert GenAIMessages to Gemini contents
- var contents []GeminiContent
- for _, genMsg := range chat.NativeMessages {
- chatMsg, ok := genMsg.(*GeminiChatMessage)
- if !ok {
- return nil, nil, nil, fmt.Errorf("expected GeminiChatMessage, got %T", genMsg)
- }
-
- content := GeminiContent{
- Role: chatMsg.Role,
- Parts: make([]GeminiMessagePart, len(chatMsg.Parts)),
- }
- for i, part := range chatMsg.Parts {
- content.Parts[i] = *part.Clean()
- }
- contents = append(contents, content)
- }
-
- req, err := buildGeminiHTTPRequest(ctx, contents, chatOpts)
- if err != nil {
- return nil, nil, nil, err
- }
-
- httpClient, err := aiutil.MakeHTTPClient(chatOpts.Config.ProxyURL)
- if err != nil {
- return nil, nil, nil, err
- }
-
- resp, err := httpClient.Do(req)
- if err != nil {
- return nil, nil, nil, fmt.Errorf("HTTP request failed: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- bodyBytes, _ := io.ReadAll(resp.Body)
-
- // Try to parse as Gemini error
- var geminiErr GeminiErrorResponse
- if err := json.Unmarshal(bodyBytes, &geminiErr); err == nil && geminiErr.Error != nil {
- return nil, nil, nil, fmt.Errorf("Gemini API error (%d): %s", geminiErr.Error.Code, geminiErr.Error.Message)
- }
-
- return nil, nil, nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, utilfn.TruncateString(string(bodyBytes), 120))
- }
-
- // Setup SSE if this is a new request (not a continuation)
- if cont == nil {
- if err := sseHandler.SetupSSE(); err != nil {
- return nil, nil, nil, fmt.Errorf("failed to setup SSE: %w", err)
- }
- }
-
- // Stream processing
- stopReason, assistantMsg, err := processGeminiStream(ctx, resp.Body, sseHandler, chatOpts, cont)
- if err != nil {
- return nil, nil, nil, err
- }
-
- return stopReason, assistantMsg, nil, nil
-}
-
-// processGeminiStream handles the streaming response from Gemini
-func processGeminiStream(
- ctx context.Context,
- body io.Reader,
- sseHandler *sse.SSEHandlerCh,
- chatOpts uctypes.WaveChatOpts,
- cont *uctypes.WaveContinueResponse,
-) (*uctypes.WaveStopReason, *GeminiChatMessage, error) {
- msgID := uuid.New().String()
- textID := uuid.New().String()
- textStarted := false
- var textBuilder strings.Builder
- var textThoughtSignature string
- var finishReason string
- var functionCalls []GeminiMessagePart
- var usageMetadata *GeminiUsageMetadata
-
- if cont == nil {
- _ = sseHandler.AiMsgStart(msgID)
- }
- _ = sseHandler.AiMsgStartStep()
-
- decoder := eventsource.NewDecoder(body)
-
- for {
- if err := ctx.Err(); err != nil {
- _ = sseHandler.AiMsgError("request cancelled")
- return &uctypes.WaveStopReason{
- Kind: uctypes.StopKindCanceled,
- ErrorType: "cancelled",
- ErrorText: "request cancelled",
- }, nil, err
- }
-
- event, err := decoder.Decode()
- if err != nil {
- if errors.Is(err, io.EOF) {
- break
- }
- if sseHandler.Err() != nil {
- partialMsg := extractPartialGeminiMessage(msgID, textBuilder.String())
- return &uctypes.WaveStopReason{
- Kind: uctypes.StopKindCanceled,
- ErrorType: "client_disconnect",
- ErrorText: "client disconnected",
- }, partialMsg, nil
- }
- _ = sseHandler.AiMsgError(fmt.Sprintf("stream decode error: %v", err))
- return &uctypes.WaveStopReason{
- Kind: uctypes.StopKindError,
- ErrorType: "stream",
- ErrorText: err.Error(),
- }, nil, fmt.Errorf("stream decode error: %w", err)
- }
-
- data := event.Data()
- if data == "" {
- continue
- }
-
- // Parse the JSON response
- var chunk GeminiStreamResponse
- if err := json.Unmarshal([]byte(data), &chunk); err != nil {
- log.Printf("gemini: failed to parse chunk: %v\n", err)
- continue
- }
-
- // Check for prompt feedback (blocking)
- if chunk.PromptFeedback != nil && chunk.PromptFeedback.BlockReason != "" {
- errorMsg := fmt.Sprintf("Content blocked: %s", chunk.PromptFeedback.BlockReason)
- _ = sseHandler.AiMsgError(errorMsg)
- return &uctypes.WaveStopReason{
- Kind: uctypes.StopKindContent,
- ErrorType: "blocked",
- ErrorText: errorMsg,
- }, nil, fmt.Errorf("%s", errorMsg)
- }
-
- // Store usage metadata if present
- if chunk.UsageMetadata != nil {
- usageMetadata = chunk.UsageMetadata
- }
-
- // Log grounding metadata (web search queries)
- if chunk.GroundingMetadata != nil && len(chunk.GroundingMetadata.WebSearchQueries) > 0 {
- if wavebase.IsDevMode() {
- log.Printf("gemini: web search queries executed: %v\n", chunk.GroundingMetadata.WebSearchQueries)
- }
- }
-
- // Process candidates
- if len(chunk.Candidates) == 0 {
- continue
- }
-
- candidate := chunk.Candidates[0]
-
- // Log candidate grounding metadata if present
- if candidate.GroundingMetadata != nil && len(candidate.GroundingMetadata.WebSearchQueries) > 0 {
- if wavebase.IsDevMode() {
- log.Printf("gemini: candidate web search queries: %v\n", candidate.GroundingMetadata.WebSearchQueries)
- }
- }
-
- // Store finish reason
- if candidate.FinishReason != "" {
- finishReason = candidate.FinishReason
- }
-
- if candidate.Content == nil {
- continue
- }
-
- // Process content parts
- for _, part := range candidate.Content.Parts {
- if part.Text != "" {
- if !textStarted {
- _ = sseHandler.AiMsgTextStart(textID)
- textStarted = true
- }
- textBuilder.WriteString(part.Text)
- _ = sseHandler.AiMsgTextDelta(textID, part.Text)
- if part.ThoughtSignature != "" {
- textThoughtSignature = part.ThoughtSignature
- }
- }
-
- if part.FunctionCall != nil {
- toolCallId := uuid.New().String()
-
- argsBytes, _ := json.Marshal(part.FunctionCall.Args)
- aiutil.SendToolProgress(toolCallId, part.FunctionCall.Name, argsBytes, chatOpts, sseHandler, false)
-
- // Preserve thought_signature exactly as received from API
- // It can be at part level, FunctionCall level, or both
- functionCalls = append(functionCalls, GeminiMessagePart{
- FunctionCall: part.FunctionCall,
- ThoughtSignature: part.ThoughtSignature,
- ToolUseData: &uctypes.UIMessageDataToolUse{
- ToolCallId: toolCallId,
- ToolName: part.FunctionCall.Name,
- },
- })
- }
- }
- }
-
- // Determine stop reason
- stopKind := uctypes.StopKindDone
- switch finishReason {
- case "MAX_TOKENS":
- stopKind = uctypes.StopKindMaxTokens
- case "SAFETY":
- stopKind = uctypes.StopKindContent
- case "RECITATION":
- stopKind = uctypes.StopKindContent
- }
-
- // Build assistant message
- var parts []GeminiMessagePart
- if textBuilder.Len() > 0 {
- parts = append(parts, GeminiMessagePart{
- Text: textBuilder.String(),
- ThoughtSignature: textThoughtSignature,
- })
- }
- parts = append(parts, functionCalls...)
-
- // Set usage metadata model
- if usageMetadata != nil {
- usageMetadata.Model = chatOpts.Config.Model
- }
-
- assistantMsg := &GeminiChatMessage{
- MessageId: msgID,
- Role: "model",
- Parts: parts,
- Usage: usageMetadata,
- }
-
- // Build tool calls for stop reason
- var waveToolCalls []uctypes.WaveToolCall
- if len(functionCalls) > 0 {
- stopKind = uctypes.StopKindToolUse
- for _, fcPart := range functionCalls {
- if fcPart.FunctionCall != nil && fcPart.ToolUseData != nil {
- waveToolCalls = append(waveToolCalls, uctypes.WaveToolCall{
- ID: fcPart.ToolUseData.ToolCallId,
- Name: fcPart.FunctionCall.Name,
- Input: fcPart.FunctionCall.Args,
- ToolUseData: fcPart.ToolUseData,
- })
- }
- }
- }
-
- stopReason := &uctypes.WaveStopReason{
- Kind: stopKind,
- RawReason: finishReason,
- ToolCalls: waveToolCalls,
- }
-
- if textStarted {
- _ = sseHandler.AiMsgTextEnd(textID)
- }
- _ = sseHandler.AiMsgFinishStep()
- if stopKind != uctypes.StopKindToolUse {
- _ = sseHandler.AiMsgFinish(finishReason, nil)
- }
-
- return stopReason, assistantMsg, nil
-}
-
-func extractPartialGeminiMessage(msgID string, text string) *GeminiChatMessage {
- if text == "" {
- return nil
- }
-
- return &GeminiChatMessage{
- MessageId: msgID,
- Role: "model",
- Parts: []GeminiMessagePart{
- {
- Text: text,
- },
- },
- }
-}
diff --git a/pkg/aiusechat/gemini/gemini-convertmessage.go b/pkg/aiusechat/gemini/gemini-convertmessage.go
deleted file mode 100644
index 02cd809c44..0000000000
--- a/pkg/aiusechat/gemini/gemini-convertmessage.go
+++ /dev/null
@@ -1,508 +0,0 @@
-// Copyright 2025, Command Line Inc.
-// SPDX-License-Identifier: Apache-2.0
-
-package gemini
-
-import (
- "encoding/base64"
- "encoding/json"
- "fmt"
- "log"
- "slices"
- "strings"
-
- "github.com/google/uuid"
- "github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil"
- "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore"
- "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes"
- "github.com/wavetermdev/waveterm/pkg/util/utilfn"
-)
-
-// cleanSchemaForGemini removes fields from JSON Schema that Gemini doesn't accept
-// Gemini uses a strict subset of JSON Schema and rejects fields like $schema, units, title, etc.
-func cleanSchemaForGemini(schema map[string]any) map[string]any {
- if schema == nil {
- return nil
- }
-
- cleaned := make(map[string]any)
-
- // Fields that Gemini accepts in the root schema
- allowedRootFields := map[string]bool{
- "type": true,
- "properties": true,
- "required": true,
- "description": true,
- "items": true,
- "enum": true,
- "format": true,
- "minimum": true,
- "maximum": true,
- "pattern": true,
- "default": true,
- }
-
- for key, value := range schema {
- if !allowedRootFields[key] {
- // Skip fields like $schema, title, units, definitions, $ref, etc.
- continue
- }
-
- // Recursively clean nested schemas
- switch key {
- case "properties":
- if props, ok := value.(map[string]any); ok {
- cleanedProps := make(map[string]any)
- for propName, propValue := range props {
- if propSchema, ok := propValue.(map[string]any); ok {
- cleanedProps[propName] = cleanSchemaForGemini(propSchema)
- } else {
- // Preserve non-map property values
- cleanedProps[propName] = propValue
- }
- }
- cleaned[key] = cleanedProps
- }
- case "items":
- if items, ok := value.(map[string]any); ok {
- cleaned[key] = cleanSchemaForGemini(items)
- } else {
- cleaned[key] = value
- }
- default:
- cleaned[key] = value
- }
- }
-
- return cleaned
-}
-
-// ConvertToolDefinitionToGemini converts a Wave ToolDefinition to Gemini format
-func ConvertToolDefinitionToGemini(tool uctypes.ToolDefinition) GeminiFunctionDeclaration {
- // Clean the schema to remove fields that Gemini doesn't accept
- cleanedSchema := cleanSchemaForGemini(tool.InputSchema)
-
- return GeminiFunctionDeclaration{
- Name: tool.Name,
- Description: tool.Description,
- Parameters: cleanedSchema,
- }
-}
-
-// convertFileAIMessagePart converts a file AIMessagePart to Gemini format
-func convertFileAIMessagePart(part uctypes.AIMessagePart) (*GeminiMessagePart, error) {
- if part.Type != uctypes.AIMessagePartTypeFile {
- return nil, fmt.Errorf("convertFileAIMessagePart expects 'file' type, got '%s'", part.Type)
- }
- if part.MimeType == "" {
- return nil, fmt.Errorf("file part missing mimetype")
- }
-
- // Handle different file types
- switch {
- case strings.HasPrefix(part.MimeType, "image/"):
- // For images, we need base64 data
- var base64Data string
- if len(part.Data) > 0 {
- base64Data = base64.StdEncoding.EncodeToString(part.Data)
- } else if part.URL != "" {
- // If URL is provided, it should be a data URL
- if strings.HasPrefix(part.URL, "data:") {
- // Extract base64 data from data URL
- parts := strings.SplitN(part.URL, ",", 2)
- if len(parts) == 2 {
- base64Data = parts[1]
- } else {
- return nil, fmt.Errorf("invalid data URL format")
- }
- } else {
- return nil, fmt.Errorf("dropping image with non-data URL (must be fetched and converted to base64)")
- }
- } else {
- return nil, fmt.Errorf("image file part missing data")
- }
-
- return &GeminiMessagePart{
- InlineData: &GeminiInlineData{
- MimeType: part.MimeType,
- Data: base64Data,
- },
- FileName: part.FileName,
- PreviewUrl: part.PreviewUrl,
- }, nil
-
- case part.MimeType == "application/pdf":
- // Handle PDFs - Gemini supports base64 data for PDFs
- if len(part.Data) == 0 {
- if part.URL != "" {
- return nil, fmt.Errorf("dropping PDF with URL (must be fetched and converted to base64 data)")
- }
- return nil, fmt.Errorf("PDF file part missing data")
- }
-
- // Convert raw data to base64
- base64Data := base64.StdEncoding.EncodeToString(part.Data)
-
- return &GeminiMessagePart{
- InlineData: &GeminiInlineData{
- MimeType: "application/pdf",
- Data: base64Data,
- },
- FileName: part.FileName,
- PreviewUrl: part.PreviewUrl,
- }, nil
-
- case part.MimeType == "text/plain":
- textData, err := aiutil.ExtractTextData(part.Data, part.URL)
- if err != nil {
- return nil, err
- }
- formattedText := aiutil.FormatAttachedTextFile(part.FileName, textData)
- return &GeminiMessagePart{
- Text: formattedText,
- }, nil
-
- case part.MimeType == "directory":
- var jsonContent string
- if len(part.Data) > 0 {
- jsonContent = string(part.Data)
- } else {
- return nil, fmt.Errorf("directory listing part missing data")
- }
-
- formattedText := aiutil.FormatAttachedDirectoryListing(part.FileName, jsonContent)
- return &GeminiMessagePart{
- Text: formattedText,
- }, nil
-
- default:
- return nil, fmt.Errorf("dropping file with unsupported mimetype '%s' (Gemini supports images, PDFs, text/plain, and directories)", part.MimeType)
- }
-}
-
-// ConvertAIMessageToGeminiChatMessage converts an AIMessage to GeminiChatMessage
-// These messages are ALWAYS role "user"
-func ConvertAIMessageToGeminiChatMessage(aiMsg uctypes.AIMessage) (*GeminiChatMessage, error) {
- if err := aiMsg.Validate(); err != nil {
- return nil, fmt.Errorf("invalid AIMessage: %w", err)
- }
-
- var parts []GeminiMessagePart
-
- for i, part := range aiMsg.Parts {
- switch part.Type {
- case uctypes.AIMessagePartTypeText:
- if part.Text == "" {
- return nil, fmt.Errorf("part %d: text type requires non-empty text field", i)
- }
- parts = append(parts, GeminiMessagePart{
- Text: part.Text,
- })
-
- case uctypes.AIMessagePartTypeFile:
- geminiPart, err := convertFileAIMessagePart(part)
- if err != nil {
- log.Printf("gemini: %v", err)
- continue
- }
- parts = append(parts, *geminiPart)
-
- default:
- // Drop unknown part types
- log.Printf("gemini: dropping unknown part type '%s'", part.Type)
- continue
- }
- }
-
- return &GeminiChatMessage{
- MessageId: aiMsg.MessageId,
- Role: "user",
- Parts: parts,
- }, nil
-}
-
-// ConvertToolResultsToGeminiChatMessage converts AIToolResult slice to GeminiChatMessage
-func ConvertToolResultsToGeminiChatMessage(toolResults []uctypes.AIToolResult) (*GeminiChatMessage, error) {
- if len(toolResults) == 0 {
- return nil, fmt.Errorf("toolResults cannot be empty")
- }
-
- var parts []GeminiMessagePart
-
- for _, result := range toolResults {
- if result.ToolUseID == "" {
- return nil, fmt.Errorf("tool result missing ToolUseID")
- }
-
- response := make(map[string]any)
- var nestedParts []GeminiMessagePart
-
- if result.ErrorText != "" {
- response["ok"] = false
- response["error"] = result.ErrorText
- } else if strings.HasPrefix(result.Text, "data:") {
- mimeType, base64Data, err := utilfn.DecodeDataURL(result.Text)
- if err != nil {
- log.Printf("gemini: failed to decode data URL in tool result: %v\n", err)
- response["ok"] = false
- response["error"] = fmt.Sprintf("failed to decode data URL: %v", err)
- } else if strings.HasPrefix(mimeType, "image/") {
- // For image data URLs, use multimodal function response (Gemini 3 Pro+)
- displayName := fmt.Sprintf("result_%s.%s", result.ToolUseID[:8], strings.TrimPrefix(mimeType, "image/"))
- response["ok"] = true
- response["image"] = map[string]string{"$ref": displayName}
-
- // Add the image data as a nested part
- nestedParts = append(nestedParts, GeminiMessagePart{
- InlineData: &GeminiInlineData{
- MimeType: mimeType,
- Data: base64.StdEncoding.EncodeToString(base64Data),
- DisplayName: displayName,
- },
- })
- } else {
- log.Printf("gemini: unsupported data URL mimetype in tool result: %s\n", mimeType)
- response["ok"] = false
- response["error"] = fmt.Sprintf("unsupported data URL mimetype: %s", mimeType)
- }
- } else {
- response["ok"] = true
- response["result"] = result.Text
- }
-
- parts = append(parts, GeminiMessagePart{
- FunctionResponse: &GeminiFunctionResponse{
- Name: result.ToolName,
- Response: response,
- Parts: nestedParts,
- },
- })
- }
-
- return &GeminiChatMessage{
- MessageId: uuid.New().String(),
- Role: "user", // Function responses are sent as user messages
- Parts: parts,
- }, nil
-}
-
-// convertContentPartToUIPart converts a Gemini content part to UIMessagePart
-func convertContentPartToUIPart(part GeminiMessagePart, role string) []uctypes.UIMessagePart {
- var uiParts []uctypes.UIMessagePart
-
- if part.Text != "" {
- if found, dataPart := aiutil.ConvertDataUserFile(part.Text); found {
- if dataPart != nil {
- uiParts = append(uiParts, *dataPart)
- }
- } else {
- uiParts = append(uiParts, uctypes.UIMessagePart{
- Type: "text",
- Text: part.Text,
- })
- }
- }
-
- if part.InlineData != nil && role == "user" {
- // Show uploaded files in user messages
- var mimeType string
- if strings.HasPrefix(part.InlineData.MimeType, "image/") {
- mimeType = "image/*"
- } else {
- mimeType = part.InlineData.MimeType
- }
-
- uiParts = append(uiParts, uctypes.UIMessagePart{
- Type: "data-userfile",
- Data: uctypes.UIMessageDataUserFile{
- FileName: part.FileName,
- MimeType: mimeType,
- PreviewUrl: part.PreviewUrl,
- },
- })
- }
-
- // Tool use parts are handled separately by the backend
- if part.ToolUseData != nil {
- uiParts = append(uiParts, uctypes.UIMessagePart{
- Type: "data-tooluse",
- ID: part.ToolUseData.ToolCallId,
- Data: *part.ToolUseData,
- })
- }
-
- return uiParts
-}
-
-// convertToUIMessage converts a GeminiChatMessage to a UIMessage
-func (m *GeminiChatMessage) convertToUIMessage() *uctypes.UIMessage {
- var parts []uctypes.UIMessagePart
-
- for _, part := range m.Parts {
- // Skip function responses - they're not shown in UI
- if part.FunctionResponse != nil {
- continue
- }
-
- partUIParts := convertContentPartToUIPart(part, m.Role)
- parts = append(parts, partUIParts...)
- }
-
- if len(parts) == 0 {
- return nil
- }
-
- // Convert Gemini role to standard role
- role := m.Role
- if role == "model" {
- role = "assistant"
- }
-
- return &uctypes.UIMessage{
- ID: m.MessageId,
- Role: role,
- Parts: parts,
- }
-}
-
-// ConvertAIChatToUIChat converts an AIChat to a UIChat for Gemini
-func ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) {
- if aiChat.APIType != uctypes.APIType_GoogleGemini {
- return nil, fmt.Errorf("APIType must be '%s', got '%s'", uctypes.APIType_GoogleGemini, aiChat.APIType)
- }
-
- uiMessages := make([]uctypes.UIMessage, 0, len(aiChat.NativeMessages))
- for i, nativeMsg := range aiChat.NativeMessages {
- geminiMsg, ok := nativeMsg.(*GeminiChatMessage)
- if !ok {
- return nil, fmt.Errorf("message %d: expected *GeminiChatMessage, got %T", i, nativeMsg)
- }
- uiMsg := geminiMsg.convertToUIMessage()
- if uiMsg != nil {
- uiMessages = append(uiMessages, *uiMsg)
- }
- }
-
- return &uctypes.UIChat{
- ChatId: aiChat.ChatId,
- APIType: aiChat.APIType,
- Model: aiChat.Model,
- APIVersion: aiChat.APIVersion,
- Messages: uiMessages,
- }, nil
-}
-
-// GetFunctionCallInputByToolCallId returns the function call input associated with the given tool call ID
-func GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput {
- for _, nativeMsg := range aiChat.NativeMessages {
- geminiMsg, ok := nativeMsg.(*GeminiChatMessage)
- if !ok {
- continue
- }
- for _, part := range geminiMsg.Parts {
- if part.FunctionCall != nil && part.ToolUseData != nil && part.ToolUseData.ToolCallId == toolCallId {
- // Convert args map to JSON string
- argsBytes, err := json.Marshal(part.FunctionCall.Args)
- if err != nil {
- log.Printf("gemini: error marshaling function call args: %v", err)
- continue
- }
- return &uctypes.AIFunctionCallInput{
- CallId: toolCallId,
- Name: part.FunctionCall.Name,
- Arguments: string(argsBytes),
- ToolUseData: part.ToolUseData,
- }
- }
- }
- }
- return nil
-}
-
-// UpdateToolUseData updates the tool use data for a specific tool call in the chat
-func UpdateToolUseData(chatId string, toolCallId string, toolUseData uctypes.UIMessageDataToolUse) error {
- chat := chatstore.DefaultChatStore.Get(chatId)
- if chat == nil {
- return fmt.Errorf("chat not found: %s", chatId)
- }
-
- for _, genMsg := range chat.NativeMessages {
- chatMsg, ok := genMsg.(*GeminiChatMessage)
- if !ok {
- continue
- }
-
- for i, part := range chatMsg.Parts {
- if part.FunctionCall != nil && part.ToolUseData != nil && part.ToolUseData.ToolCallId == toolCallId {
- // Update the message with new tool use data
- updatedMsg := &GeminiChatMessage{
- MessageId: chatMsg.MessageId,
- Role: chatMsg.Role,
- Parts: make([]GeminiMessagePart, len(chatMsg.Parts)),
- Usage: chatMsg.Usage,
- }
- copy(updatedMsg.Parts, chatMsg.Parts)
- updatedMsg.Parts[i].ToolUseData = &toolUseData
-
- aiOpts := &uctypes.AIOptsType{
- APIType: chat.APIType,
- Model: chat.Model,
- APIVersion: chat.APIVersion,
- }
-
- return chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, updatedMsg)
- }
- }
- }
-
- return fmt.Errorf("tool call with ID %s not found in chat %s", toolCallId, chatId)
-}
-
-func RemoveToolUseCall(chatId string, toolCallId string) error {
- chat := chatstore.DefaultChatStore.Get(chatId)
- if chat == nil {
- return fmt.Errorf("chat not found: %s", chatId)
- }
-
- for _, genMsg := range chat.NativeMessages {
- chatMsg, ok := genMsg.(*GeminiChatMessage)
- if !ok {
- continue
- }
-
- partIndex := -1
- for i, part := range chatMsg.Parts {
- if part.FunctionCall != nil && part.ToolUseData != nil && part.ToolUseData.ToolCallId == toolCallId {
- partIndex = i
- break
- }
- }
-
- if partIndex == -1 {
- continue
- }
-
- updatedMsg := &GeminiChatMessage{
- MessageId: chatMsg.MessageId,
- Role: chatMsg.Role,
- Parts: slices.Delete(slices.Clone(chatMsg.Parts), partIndex, partIndex+1),
- Usage: chatMsg.Usage,
- }
-
- if len(updatedMsg.Parts) == 0 {
- chatstore.DefaultChatStore.RemoveMessage(chatId, chatMsg.MessageId)
- } else {
- aiOpts := &uctypes.AIOptsType{
- APIType: chat.APIType,
- Model: chat.Model,
- APIVersion: chat.APIVersion,
- }
- if err := chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, updatedMsg); err != nil {
- return err
- }
- }
- return nil
- }
-
- return nil
-}
diff --git a/pkg/aiusechat/gemini/gemini-types.go b/pkg/aiusechat/gemini/gemini-types.go
deleted file mode 100644
index e873abbb4c..0000000000
--- a/pkg/aiusechat/gemini/gemini-types.go
+++ /dev/null
@@ -1,232 +0,0 @@
-// Copyright 2025, Command Line Inc.
-// SPDX-License-Identifier: Apache-2.0
-
-package gemini
-
-import (
- "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes"
-)
-
-const (
- GeminiDefaultMaxTokens = 8192
-)
-
-// GeminiChatMessage represents a stored chat message for Gemini backend
-type GeminiChatMessage struct {
- MessageId string `json:"messageid"`
- Role string `json:"role"` // "user", "model"
- Parts []GeminiMessagePart `json:"parts"`
- Usage *GeminiUsageMetadata `json:"usage,omitempty"`
-}
-
-func (m *GeminiChatMessage) GetMessageId() string {
- return m.MessageId
-}
-
-func (m *GeminiChatMessage) GetRole() string {
- return m.Role
-}
-
-func (m *GeminiChatMessage) GetUsage() *uctypes.AIUsage {
- if m.Usage == nil {
- return nil
- }
- return &uctypes.AIUsage{
- APIType: uctypes.APIType_GoogleGemini,
- Model: m.Usage.Model,
- InputTokens: m.Usage.PromptTokenCount,
- OutputTokens: m.Usage.CandidatesTokenCount,
- }
-}
-
-// GeminiMessagePart represents different types of content in a message
-type GeminiMessagePart struct {
- // Text part
- Text string `json:"text,omitempty"`
-
- // Inline data (images, PDFs, etc.)
- InlineData *GeminiInlineData `json:"inlineData,omitempty"`
-
- // File data (for uploaded files)
- FileData *GeminiFileData `json:"fileData,omitempty"`
-
- // Function call (assistant calling a tool)
- FunctionCall *GeminiFunctionCall `json:"functionCall,omitempty"`
-
- // Function response (result of tool execution)
- FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"`
-
- // Thought signature (for thinking models - applies to text and function calls)
- ThoughtSignature string `json:"thoughtSignature,omitempty"`
-
- // Internal fields (not sent to API)
- PreviewUrl string `json:"previewurl,omitempty"` // internal field
- FileName string `json:"filename,omitempty"` // internal field
- ToolUseData *uctypes.UIMessageDataToolUse `json:"toolusedata,omitempty"` // internal field
-}
-
-// Clean removes internal fields before sending to API
-func (p *GeminiMessagePart) Clean() *GeminiMessagePart {
- if p == nil {
- return nil
- }
- cleaned := *p
- cleaned.PreviewUrl = ""
- cleaned.FileName = ""
- cleaned.ToolUseData = nil
- return &cleaned
-}
-
-// GeminiInlineData represents inline binary data
-type GeminiInlineData struct {
- MimeType string `json:"mimeType"`
- Data string `json:"data"` // base64 encoded
- DisplayName string `json:"displayName,omitempty"` // for multimodal function responses
-}
-
-// GeminiFileData represents uploaded file reference
-type GeminiFileData struct {
- MimeType string `json:"mimeType"`
- FileUri string `json:"fileUri"` // gs:// URI from file upload
- DisplayName string `json:"displayName,omitempty"` // for multimodal function responses
-}
-
-// GeminiFunctionCall represents a function call from the model
-type GeminiFunctionCall struct {
- Name string `json:"name"`
- Args map[string]any `json:"args,omitempty"`
-}
-
-// GeminiFunctionResponse represents a function execution result
-type GeminiFunctionResponse struct {
- Name string `json:"name"`
- Response map[string]any `json:"response"`
- Parts []GeminiMessagePart `json:"parts,omitempty"` // nested parts for multimodal content (Gemini 3 Pro and later)
-}
-
-// GeminiUsageMetadata represents token usage
-type GeminiUsageMetadata struct {
- Model string `json:"model,omitempty"` // internal field
- PromptTokenCount int `json:"promptTokenCount"`
- CachedContentTokenCount int `json:"cachedContentTokenCount,omitempty"`
- CandidatesTokenCount int `json:"candidatesTokenCount"`
- TotalTokenCount int `json:"totalTokenCount"`
-}
-
-// GeminiThinkingConfig represents thinking configuration for Gemini 3+ models
-type GeminiThinkingConfig struct {
- ThinkingLevel string `json:"thinkingLevel,omitempty"` // "low" or "high"
-}
-
-// GeminiGenerationConfig represents generation parameters
-type GeminiGenerationConfig struct {
- Temperature float32 `json:"temperature,omitempty"`
- TopP float32 `json:"topP,omitempty"`
- TopK int32 `json:"topK,omitempty"`
- CandidateCount int32 `json:"candidateCount,omitempty"`
- MaxOutputTokens int32 `json:"maxOutputTokens,omitempty"`
- StopSequences []string `json:"stopSequences,omitempty"`
- ThinkingConfig *GeminiThinkingConfig `json:"thinkingConfig,omitempty"` // for Gemini 3+ models
-}
-
-// GeminiTool represents a function tool definition
-type GeminiTool struct {
- FunctionDeclarations []GeminiFunctionDeclaration `json:"functionDeclarations,omitempty"`
- GoogleSearch *GeminiGoogleSearch `json:"googleSearch,omitempty"`
-}
-
-// GeminiGoogleSearch represents Google Search configuration (empty for default)
-type GeminiGoogleSearch struct{}
-
-// GeminiFunctionDeclaration represents a function schema
-type GeminiFunctionDeclaration struct {
- Name string `json:"name"`
- Description string `json:"description"`
- Parameters map[string]any `json:"parameters,omitempty"`
-}
-
-// GeminiToolConfig represents tool choice configuration
-type GeminiToolConfig struct {
- FunctionCallingConfig *GeminiFunctionCallingConfig `json:"functionCallingConfig,omitempty"`
-}
-
-// GeminiFunctionCallingConfig represents function calling configuration
-type GeminiFunctionCallingConfig struct {
- Mode string `json:"mode,omitempty"` // "AUTO", "ANY", "NONE"
-}
-
-// GeminiContent represents a content message for the API
-type GeminiContent struct {
- Role string `json:"role,omitempty"`
- Parts []GeminiMessagePart `json:"parts"`
-}
-
-// Clean removes internal fields from all parts
-func (c *GeminiContent) Clean() *GeminiContent {
- if c == nil {
- return nil
- }
- cleaned := &GeminiContent{
- Role: c.Role,
- Parts: make([]GeminiMessagePart, len(c.Parts)),
- }
- for i, part := range c.Parts {
- cleaned.Parts[i] = *part.Clean()
- }
- return cleaned
-}
-
-// GeminiRequest represents a request to the Gemini API
-type GeminiRequest struct {
- Contents []GeminiContent `json:"contents"`
- SystemInstruction *GeminiContent `json:"systemInstruction,omitempty"`
- GenerationConfig *GeminiGenerationConfig `json:"generationConfig,omitempty"`
- Tools []GeminiTool `json:"tools,omitempty"`
- ToolConfig *GeminiToolConfig `json:"toolConfig,omitempty"`
-}
-
-// GeminiStreamResponse represents a streaming response chunk
-type GeminiStreamResponse struct {
- Candidates []GeminiCandidate `json:"candidates,omitempty"`
- PromptFeedback *GeminiPromptFeedback `json:"promptFeedback,omitempty"`
- UsageMetadata *GeminiUsageMetadata `json:"usageMetadata,omitempty"`
- GroundingMetadata *GeminiGroundingMetadata `json:"groundingMetadata,omitempty"`
-}
-
-// GeminiCandidate represents a candidate response
-type GeminiCandidate struct {
- Content *GeminiContent `json:"content,omitempty"`
- FinishReason string `json:"finishReason,omitempty"`
- Index int `json:"index,omitempty"`
- SafetyRatings []GeminiSafetyRating `json:"safetyRatings,omitempty"`
- GroundingMetadata *GeminiGroundingMetadata `json:"groundingMetadata,omitempty"`
-}
-
-// GeminiSafetyRating represents a safety rating
-type GeminiSafetyRating struct {
- Category string `json:"category"`
- Probability string `json:"probability"`
-}
-
-// GeminiPromptFeedback represents feedback about the prompt
-type GeminiPromptFeedback struct {
- BlockReason string `json:"blockReason,omitempty"`
- SafetyRatings []GeminiSafetyRating `json:"safetyRatings,omitempty"`
-}
-
-// GeminiErrorResponse represents an error response
-type GeminiErrorResponse struct {
- Error *GeminiError `json:"error,omitempty"`
-}
-
-// GeminiError represents an error
-type GeminiError struct {
- Code int `json:"code"`
- Message string `json:"message"`
- Status string `json:"status,omitempty"`
-}
-
-// GeminiGroundingMetadata represents grounding metadata with web search results
-type GeminiGroundingMetadata struct {
- WebSearchQueries []string `json:"webSearchQueries,omitempty"`
-}
diff --git a/pkg/aiusechat/google/doc.go b/pkg/aiusechat/google/doc.go
deleted file mode 100644
index caab8a4ecd..0000000000
--- a/pkg/aiusechat/google/doc.go
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright 2025, Command Line Inc.
-// SPDX-License-Identifier: Apache-2.0
-
-// Package google provides Google Generative AI integration for WaveTerm.
-//
-// This package implements file summarization using Google's Gemini models.
-// Unlike other AI provider implementations in the aiusechat package, this
-// package does NOT implement full SSE streaming. It uses a simple
-// request-response API for file summarization.
-//
-// # Supported File Types
-//
-// The package supports the same file types as defined in wshcmd-ai.go:
-// - Images (PNG, JPEG, etc.): up to 7MB
-// - PDFs: up to 5MB
-// - Text files: up to 200KB
-//
-// Binary files are rejected unless they are recognized as images or PDFs.
-//
-// # Usage
-//
-// To summarize a file:
-//
-// ctx := context.Background()
-// summary, usage, err := google.SummarizeFile(ctx, "/path/to/file.txt", google.SummarizeOpts{
-// APIKey: "YOUR_API_KEY",
-// Mode: google.ModeQuickSummary,
-// })
-// if err != nil {
-// log.Fatal(err)
-// }
-// fmt.Println("Summary:", summary)
-// fmt.Printf("Tokens used: %d\n", usage.TotalTokenCount)
-//
-// # Configuration
-//
-// The summarization behavior can be customized by modifying the constants:
-// - SummarizeModel: The Gemini model to use (default: "gemini-2.5-flash-lite")
-// - SummarizePrompt: The prompt sent to the model
-// - GoogleAPIURL: The base URL for the API (for reference, not currently used by the SDK)
-package google
diff --git a/pkg/aiusechat/google/google-summarize.go b/pkg/aiusechat/google/google-summarize.go
deleted file mode 100644
index 67d3cd3eb5..0000000000
--- a/pkg/aiusechat/google/google-summarize.go
+++ /dev/null
@@ -1,283 +0,0 @@
-// Copyright 2025, Command Line Inc.
-// SPDX-License-Identifier: Apache-2.0
-
-package google
-
-import (
- "context"
- "fmt"
- "net/http"
- "os"
- "strings"
-
- "github.com/google/generative-ai-go/genai"
- "github.com/wavetermdev/waveterm/pkg/util/utilfn"
- "google.golang.org/api/option"
-)
-
-const (
- // GoogleAPIURL is the base URL for the Google Generative AI API
- GoogleAPIURL = "https://generativelanguage.googleapis.com"
-
- // SummarizeModel is the model used for file summarization
- SummarizeModel = "gemini-2.5-flash-lite"
-
- // Mode constants
- ModeQuickSummary = "quick"
- ModeUseful = "useful"
- ModePublicCode = "publiccode"
- ModeHTMLContent = "htmlcontent"
- ModeHTMLFull = "htmlfull"
-
- // SummarizePrompt is the default prompt used for file summarization
- SummarizePrompt = "Please provide a concise summary of this file. Include the main topics, key points, and any notable information."
-
- // QuickSummaryPrompt is the prompt for quick file summaries
- QuickSummaryPrompt = `Summarize the following file for another AI agent that is deciding which files to read.
-
-If the content is HTML or web page markup, ignore layout elements such as headers, footers, sidebars, navigation menus, cookie banners, pop-ups, ads, and search boxes.
-Focus only on the visible main content that describes the page’s subject or purpose.
-
-Keep the summary extremely concise — one or two sentences at most.
-Explain what the file appears to be and its main purpose or contents.
-If it's code, mention the language and what it implements (e.g., a CLI, library, test, or config).
-Avoid speculation or verbose explanations.
-Do not include markdown, bullets, or formatting — just a plain text summary.`
-
- // UsefulSummaryPrompt is the prompt for useful file summaries with more detail
- UsefulSummaryPrompt = `You are summarizing a single file so that another AI agent can understand its purpose and structure.
-
-If the content is HTML or web page markup, ignore layout elements such as headers, footers, sidebars, navigation menus, cookie banners, pop-ups, ads, and search boxes.
-Focus only on the visible main content that describes the page’s subject or purpose.
-
-Start with a short overview (2–4 sentences) describing the overall purpose of the file.
-If the file is large (more than about 150 lines) or has multiple major sections or functions,
-then briefly summarize each major section (1–2 sentences per section) and include an approximate line range in parentheses like "(lines 80–220)".
-
-Keep section summaries extremely concise — only include the most important parts or entry points.
-If it's code, mention key functions or classes and what they do.
-If it's documentation, describe key topics or sections.
-If it's a data or config file, summarize the structure and purpose of the values.
-
-Never produce more text than would fit comfortably on one screen (roughly under 200 words total).
-Plain text only — no lists, no markdown, no JSON.`
-
- // PublicCodeSummaryPrompt is the prompt for public API summaries
- PublicCodeSummaryPrompt = `You are summarizing a SINGLE source file to expose its PUBLIC API to another AI client.
-
-GOAL
-Produce a compact, header-like listing of all PUBLIC symbols callers would use.
-
-OUTPUT FORMAT (plain text only; no bullets/markdown/JSON):
-1) Public data structures required by public functions (types/structs/interfaces/enums/const groups):
- (lines A–B)
-
-
-2) Public functions/methods in order of appearance:
- (lines A–B)
-
-
-RULES
-- PUBLIC means exported/externally visible for the language (Go: capitalized; Java/C#/TS: public; Rust: pub; Python: not underscore-prefixed, etc.).
-- Include ALL public functions/methods.
-- Include public data structures ONLY if referenced by any public function OR commonly constructed/consumed by callers.
-- For multi-line declarations, emit a single-line canonical form by collapsing internal whitespace while preserving tokens and order.
-- The one-line comment is either a compressed docstring or, if absent, a concise inferred purpose (≤ 20 words).
-- Include approximate line ranges as "(lines A–B)".
-- Skip private helpers, tests, examples, and internal-only constants.
-- Preserve generics/annotations/modifiers as they appear (e.g., type params, async, const, noexcept).
-- No preface or epilogue text—just the listing.
-
-EXAMPLE STYLE (illustrative; use the target language's comment syntax):
-// Configuration for the proxy (lines 10–42)
-type ProxyConfig struct { ... }
-
-// Creates and configures a new proxy instance (lines 60–92)
-func NewProxy(cfg ProxyConfig) (*Proxy, error)
-
-// Handles a single HTTP request (lines 95–168)
-func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request)`
-
- // HTMLContentPrompt is the prompt for converting HTML to content-focused Markdown
- HTMLContentPrompt = `Convert the following stripped HTML into clean Markdown for READING CONTENT ONLY.
-
-- Output Markdown ONLY (no explanations, no JSON, no code fences).
-- Keep document title as a single H1 if present (from or first
- Wave AI now supports bring-your-own-key (BYOK) with OpenAI, Google Gemini, Azure, and
- OpenRouter, plus local models via Ollama, LM Studio, and other OpenAI-compatible providers.
-
- This will restore {toolData.inputfilename}{" "}
- to its state before this edit was made
- {toolData.runts && ({formatTimestamp(toolData.runts)})}.
-
-
- Any changes made by this edit and subsequent edits will be lost.
-