diff --git a/Taskfile.yml b/Taskfile.yml index bf37a83e45..d939902317 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -23,7 +23,6 @@ tasks: deps: - npm:install - build:backend - - build:tsunamiscaffold env: WAVETERM_ENVFILE: "{{.ROOT_DIR}}/.env" WCLOUD_PING_ENDPOINT: "https://ping-dev.waveterm.dev/central" @@ -129,7 +128,6 @@ tasks: - clean - npm:install - build:backend - - build:tsunamiscaffold build:frontend:dev: desc: Build the frontend in development mode. @@ -153,9 +151,6 @@ tasks: - pkg/**/*.go - pkg/**/*.sh - cmd/**/*.go - - tsunami/go.mod - - tsunami/go.sum - - tsunami/**/*.go - package.json build:schema: @@ -185,7 +180,6 @@ tasks: - "pkg/**/*.go" - "pkg/**/*.json" - "pkg/**/*.sh" - - tsunami/**/*.go - package.json generates: - dist/bin/wavesrv.* @@ -214,7 +208,6 @@ tasks: - "pkg/**/*.go" - "pkg/**/*.json" - "pkg/**/*.sh" - - "tsunami/**/*.go" generates: - dist/bin/wavesrv.* @@ -233,7 +226,6 @@ tasks: - "pkg/**/*.go" - "pkg/**/*.json" - "pkg/**/*.sh" - - "tsunami/**/*.go" generates: - dist/bin/wavesrv.x64.exe @@ -345,21 +337,6 @@ tasks: cmd: (CGO_ENABLED=0 GOOS={{.GOOS}} GOARCH={{.GOARCH}} go build -ldflags="-s -w -X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.WaveVersion={{.VERSION}}" -o dist/bin/wsh-{{.VERSION}}-{{.GOOS}}.{{.NORMALIZEDARCH}}{{.EXT}} cmd/wsh/main-wsh.go) internal: true - build:tsunamiscaffold: - desc: Build and copy tsunami scaffold to dist directory. - cmds: - - cmd: "{{.RMRF}} dist/tsunamiscaffold" - ignore_error: true - - task: copyfiles:'tsunami/frontend/scaffold':'dist/tsunamiscaffold' - - cmd: '{{if eq OS "windows"}}powershell -NoProfile -NonInteractive Copy-Item -Path tsunami/templates/empty-gomod.tmpl -Destination dist/tsunamiscaffold/go.mod{{else}}cp tsunami/templates/empty-gomod.tmpl dist/tsunamiscaffold/go.mod{{end}}' - deps: - - tsunami:scaffold - sources: - - "tsunami/frontend/dist/**/*" - - "tsunami/templates/**/*" - generates: - - "dist/tsunamiscaffold/**/*" - generate: desc: Generate Typescript bindings for the Go backend. cmds: @@ -520,140 +497,6 @@ tasks: - cmd: '{{.RMRF}} "dist"' ignore_error: true - tsunami:demo:todo: - desc: Run the tsunami todo demo application - cmd: go run demo/todo/*.go - dir: tsunami - env: - TSUNAMI_LISTENADDR: "localhost:12026" - - tsunami:frontend:dev: - desc: Run the tsunami frontend vite dev server - cmd: npm run dev - dir: tsunami/frontend - - tsunami:frontend:build: - desc: Build the tsunami frontend - cmd: npm run build - dir: tsunami/frontend - - tsunami:frontend:devbuild: - desc: Build the tsunami frontend in development mode (with source maps and symbols) - cmd: npm run build:dev - dir: tsunami/frontend - - tsunami:scaffold: - desc: Build scaffold for tsunami frontend development - deps: - - tsunami:frontend:build - cmds: - - task: tsunami:scaffold:internal - - tsunami:devscaffold: - desc: Build scaffold for tsunami frontend development (with source maps and symbols) - deps: - - tsunami:frontend:devbuild - cmds: - - task: tsunami:scaffold:internal - - tsunami:scaffold:packagejson: - desc: Create package.json for tsunami scaffold using npm commands - dir: tsunami/frontend/scaffold - cmds: - - cmd: rm -f package.json - platforms: [darwin, linux] - ignore_error: true - - cmd: powershell -NoProfile -NonInteractive -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path package.json" - platforms: [windows] - ignore_error: true - - npm --no-workspaces init -y --init-license Apache-2.0 - - npm pkg set name=tsunami-scaffold - - npm pkg delete author - - npm pkg set author.name="Command Line Inc" - - npm pkg set author.email="info@commandline.dev" - - npm --no-workspaces install tailwindcss@4.1.13 @tailwindcss/cli@4.1.13 - - tsunami:scaffold:internal: - desc: Internal task to create scaffold directory structure - internal: true - cmds: - - task: tsunami:scaffold:internal:unix - - task: tsunami:scaffold:internal:windows - - tsunami:scaffold:internal:unix: - desc: Internal task to create scaffold directory structure (Unix) - dir: tsunami/frontend - internal: true - platforms: [darwin, linux] - cmds: - - cmd: "{{.RMRF}} scaffold" - ignore_error: true - - mkdir -p scaffold - - cp ../templates/package.json.tmpl scaffold/package.json - - cd scaffold && npm install - - mv scaffold/node_modules scaffold/nm - - cp -r dist scaffold/ - - mkdir -p scaffold/dist/tw - - cp ../templates/*.go.tmpl scaffold/ - - cp ../templates/tailwind.css scaffold/ - - cp ../templates/gitignore.tmpl scaffold/.gitignore - - cp src/element/*.tsx scaffold/dist/tw/ - - cp ../ui/*.go scaffold/dist/tw/ - - cp ../engine/errcomponent.go scaffold/dist/tw/ - - tsunami:scaffold:internal:windows: - desc: Internal task to create scaffold directory structure (Windows) - dir: tsunami/frontend - internal: true - platforms: [windows] - cmds: - - cmd: "{{.RMRF}} scaffold" - ignore_error: true - - powershell -NoProfile -NonInteractive New-Item -ItemType Directory -Force -Path scaffold - - powershell -NoProfile -NonInteractive Copy-Item -Path ../templates/package.json.tmpl -Destination scaffold/package.json - - powershell -NoProfile -NonInteractive -Command "Set-Location scaffold; npm install" - - powershell -NoProfile -NonInteractive Move-Item -Path scaffold/node_modules -Destination scaffold/nm - - powershell -NoProfile -NonInteractive Copy-Item -Recurse -Force -Path dist -Destination scaffold/ - - powershell -NoProfile -NonInteractive New-Item -ItemType Directory -Force -Path scaffold/dist/tw - - powershell -NoProfile -NonInteractive Copy-Item -Path '../templates/*.go.tmpl' -Destination scaffold/ - - powershell -NoProfile -NonInteractive Copy-Item -Path ../templates/tailwind.css -Destination scaffold/ - - powershell -NoProfile -NonInteractive Copy-Item -Path ../templates/gitignore.tmpl -Destination scaffold/.gitignore - - powershell -NoProfile -NonInteractive Copy-Item -Path 'src/element/*.tsx' -Destination scaffold/dist/tw/ - - powershell -NoProfile -NonInteractive Copy-Item -Path '../ui/*.go' -Destination scaffold/dist/tw/ - - powershell -NoProfile -NonInteractive Copy-Item -Path ../engine/errcomponent.go -Destination scaffold/dist/tw/ - - tsunami:build: - desc: Build the tsunami binary. - cmds: - - cmd: rm -f bin/tsunami* - platforms: [darwin, linux] - ignore_error: true - - cmd: powershell -NoProfile -NonInteractive -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path bin/tsunami*" - platforms: [windows] - ignore_error: true - - mkdir -p bin - - cd tsunami && go build -ldflags "-X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.TsunamiVersion={{.VERSION}}" -o ../bin/tsunami{{exeExt}} cmd/main-tsunami.go - sources: - - "tsunami/**/*.go" - - "tsunami/go.mod" - - "tsunami/go.sum" - generates: - - "bin/tsunami{{exeExt}}" - - tsunami:clean: - desc: Clean tsunami frontend build artifacts - dir: tsunami/frontend - cmds: - - cmd: "{{.RMRF}} dist" - ignore_error: true - - cmd: "{{.RMRF}} scaffold" - ignore_error: true - godoc: desc: Start the Go documentation server for the root module cmd: $(go env GOPATH)/bin/pkgsite -http=:6060 - - tsunami:godoc: - desc: Start the Go documentation server for the tsunami module - cmd: $(go env GOPATH)/bin/pkgsite -http=:6060 - dir: tsunami diff --git a/aiprompts/tsunami-builder.md b/aiprompts/tsunami-builder.md deleted file mode 100644 index eb84289563..0000000000 --- a/aiprompts/tsunami-builder.md +++ /dev/null @@ -1,261 +0,0 @@ -# Tsunami AI Builder - V1 Architecture - -## Overview - -A split-screen builder for creating Tsunami applications: chat interface on left, tabbed preview/code/files on right. Users describe what they want, AI edits the code iteratively. - -## UI Layout - -### Left Panel - -- **💬 Chat** - Conversation with AI - -### Right Panel - -**Top Section - Tabs:** -- **👁️ Preview** (default) - Live preview of running Tsunami app, updates automatically after successful compilation -- **📝 Code** - Monaco editor for manual edits to app.go -- **📁 Files** - Static assets browser (images, etc) - -**Bottom Section - Build Panel (closable):** -- Shows compilation status and output (like VSCode's terminal panel) -- Displays success messages or errors with line numbers -- Auto-runs after AI edits -- For manual Code tab edits: auto-reruns or user clicks build button -- Can be manually closed/reopened by user - -### Top Bar - -- Current AppTitle (extracted from app.go) -- **Publish** button - Moves draft → published version -- **Revert** button - Copies published → draft (discards draft changes) - -## Version Management - -**Draft mode**: Auto-saved on every edit, persists when builder closes -**Published version**: What runs in main Wave Terminal, only updates on explicit "Publish" - -Flow: - -1. Edit in builder (always editing draft) -2. Click "Publish" when ready (copies draft → published) -3. Continue editing draft OR click "Revert" to abandon changes - -## Context Structure - -Every AI request includes: - -``` -[System Instructions] - - General system prompt - - Full system.md (Tsunami framework guide) - -[Conversation History] - - Recent messages (with prompt caching) - -[Current Context] (injected fresh each turn, removed from previous turns) - - Current app.go content - - Compilation results (success or errors with line numbers) - - Static files listing (e.g., "/static/logo.png") -``` - -**Context cleanup**: Old "current context" blocks are removed from previous messages and replaced with "[OLD CONTEXT REMOVED]" to save tokens. Only the latest app.go + compile results stay in context. - -## AI Tools - -### edit_appgo (str_replace) - -**Primary editing tool** - -- `old_str` - Unique string to find in app.go -- `new_str` - Replacement string -- `description` - What this change does - -**Backend behavior**: - -1. Apply string replacement to app.go -2. Immediately run `go build` -3. Return tool result: - - ✓ Success: "Edit applied, compilation successful" - - ✗ Failure: "Edit applied, compilation failed: [error details]" - -AI can make multiple edits in one response, getting compile feedback after each. - -### create_appgo - -**Bootstrap new apps** - -- `content` - Full app.go file content -- Only used for initial app creation or total rewrites - -Same compilation behavior as str_replace. - -### web_search - -**Look up APIs, docs, examples** - -- Implemented via provider backend (OpenAI/Anthropic) -- AI can research before making edits - -### read_file - -**Read user-provided documentation** - -- `path` - Path to file (e.g., "/docs/api-spec.md") -- User can upload docs/examples for AI to reference - -## User Actions (Not AI Tools) - -### Manage Static Assets - -- Upload via drag & drop into Files tab or file picker -- Delete files from Files tab -- Rename files from Files tab -- Appear in `/static/` directory -- Auto-injected into AI context as available files - -### Share Screenshot - -- User clicks "📷 Share preview with AI" button -- Captures current preview state -- Attaches to user's next message -- Useful for debugging layout/visual issues - -### Manual Code Editing - -- User can switch to Code tab -- Edit app.go directly in Monaco editor -- Changes auto-compile -- AI sees manual edits in next chat turn - -## Compilation Pipeline - -After every code change (AI or user): - -``` -1. Write app.go to disk -2. Run: go build app.go -3. Show build output in build panel -4. If success: - - Start/restart app process - - Update preview iframe - - Show success message in build panel -5. If failure: - - Parse error output (line numbers, messages) - - Show error in build panel (bottom of right side) - - Inject into AI context for next turn -``` - -**Auto-retry**: AI can fix its own compilation errors within the same response (up to 3 attempts). - -## Error Handling - -### Compilation Errors - -Shown in build panel at bottom of right side. - -Format for AI: - -``` -COMPILATION FAILED - -Error at line 45: - 43 | func(props TodoProps) any { - 44 | return vdom.H("div", nil -> 45 | vdom.H("span", nil, "test") - | ^ missing closing parenthesis - 46 | ) - -Message: expected ')', found 'vdom' -``` - -### Runtime Errors - -- Shown in preview tab (not errors panel) -- User can screenshot and report to AI -- Not auto-injected (v1 simplification) - -### Linting (Future) - -- Could add custom Tsunami-specific linting -- Would inject warnings alongside compile results -- Not required for v1 - -## Secrets/Configuration - -Apps can declare secrets using Tsunami's ConfigAtom: - -```go -var apiKeyAtom = app.ConfigAtom("api_key", "", &app.AtomMeta{ - Desc: "OpenAI API Key", - Secret: true, -}) -``` - -Builder detects these and shows input fields in UI for user to fill in. - -## Conversation Limits - -**V1 approach**: No summarization, no smart handling. - -When context limit hit: Show message "You've hit the conversation limit. Click 'Start Fresh' to continue editing this app in a new chat." - -Starting fresh uses current app.go as the beginning state. - -## Token Optimization - -- System.md + early messages benefit from prompt caching -- Only pay per-turn for: current app.go + new messages -- Old context blocks removed to prevent bloat -- Estimated: 10-20k tokens per turn (very manageable) - -## Example Flow - -``` -User: "Create a counter app" -AI: [calls create_appgo with full counter app] -Backend: ✓ Compiled successfully -Preview: Shows counter app - -User: "Add a reset button" -AI: [calls str_replace to add reset button] -Backend: ✓ Compiled successfully -Preview: Updates with reset button - -User: "Make buttons bigger" -AI: [calls str_replace to update button classes] -Backend: ✓ Compiled successfully -Preview: Updates with larger buttons - -User: [switches to Code tab, tweaks color manually] -Backend: ✓ Compiled successfully -Preview: Updates - -User: "Add a chart showing count over time" -AI: [calls web_search for "go charting library"] -AI: [calls str_replace to add chart] -Backend: ✗ Compilation failed - missing import -AI: [calls str_replace to add import] -Backend: ✓ Compiled successfully -Preview: Shows chart -``` - -## Out of Scope (V1) - -- Version history / snapshots -- Multiple files / project structure -- Collaboration / sharing -- Advanced linting -- Runtime error auto-injection -- Conversation summarization -- Component-specific editing tools - -These can be added in v2+ based on user feedback. - -## Success Criteria - -- User can create functional Tsunami app through chat in <5 minutes -- AI successfully fixes its own compilation errors 80%+ of the time -- Iteration cycle (message → edit → preview) takes <10 seconds -- Users can publish working apps to Wave Terminal -- Draft state persists across sessions diff --git a/cmd/generatego/main-generatego.go b/cmd/generatego/main-generatego.go index ab7e338439..49cab8010f 100644 --- a/cmd/generatego/main-generatego.go +++ b/cmd/generatego/main-generatego.go @@ -26,7 +26,6 @@ func GenerateWshClient() error { gogen.GenerateBoilerplate(&buf, "wshclient", []string{ "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes", "github.com/wavetermdev/waveterm/pkg/baseds", - "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata", "github.com/wavetermdev/waveterm/pkg/vdom", "github.com/wavetermdev/waveterm/pkg/waveobj", "github.com/wavetermdev/waveterm/pkg/wconfig", diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index b204643ee8..ab80e5f01b 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -22,19 +22,13 @@ import ( "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/jobcontroller" "github.com/wavetermdev/waveterm/pkg/panichandler" - "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/remote/fileshare/wshfs" - "github.com/wavetermdev/waveterm/pkg/secretstore" "github.com/wavetermdev/waveterm/pkg/service" - "github.com/wavetermdev/waveterm/pkg/telemetry" - "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" "github.com/wavetermdev/waveterm/pkg/util/envutil" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/util/sigutil" - "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wcloud" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wcore" "github.com/wavetermdev/waveterm/pkg/web" @@ -44,7 +38,6 @@ import ( "github.com/wavetermdev/waveterm/pkg/wshrpc/wshremote" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshserver" "github.com/wavetermdev/waveterm/pkg/wshutil" - "github.com/wavetermdev/waveterm/pkg/wslconn" "github.com/wavetermdev/waveterm/pkg/wstore" "net/http" @@ -55,15 +48,8 @@ import ( var WaveVersion = "0.0.0" var BuildTime = "0" -const InitialTelemetryWait = 10 * time.Second -const TelemetryTick = 2 * time.Minute -const TelemetryInterval = 4 * time.Hour -const TelemetryInitialCountsWait = 5 * time.Second -const TelemetryCountsInterval = 1 * time.Hour const BackupCleanupTick = 2 * time.Minute const BackupCleanupInterval = 4 * time.Hour -const InitialDiagnosticWait = 5 * time.Minute -const DiagnosticTick = 10 * time.Minute var shutdownOnce sync.Once @@ -81,8 +67,6 @@ func doShutdown(reason string) { ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() go blockcontroller.StopAllBlockControllersForShutdown() - shutdownActivityUpdate() - sendTelemetryWrapper() // TODO deal with flush in progress clearTempFiles() filestore.WFS.FlushCache(ctx) @@ -118,74 +102,6 @@ func startConfigWatcher() { } } -func telemetryLoop() { - defer func() { - panichandler.PanicHandler("telemetryLoop", recover()) - }() - var nextSend int64 - time.Sleep(InitialTelemetryWait) - for { - if time.Now().Unix() > nextSend { - nextSend = time.Now().Add(TelemetryInterval).Unix() - sendTelemetryWrapper() - } - time.Sleep(TelemetryTick) - } -} - -func diagnosticLoop() { - defer func() { - panichandler.PanicHandler("diagnosticLoop", recover()) - }() - if os.Getenv("WAVETERM_NOPING") != "" { - log.Printf("WAVETERM_NOPING set, disabling diagnostic ping\n") - return - } - var lastSentDate string - time.Sleep(InitialDiagnosticWait) - for { - currentDate := time.Now().Format("2006-01-02") - if lastSentDate == "" || lastSentDate != currentDate { - if sendDiagnosticPing() { - lastSentDate = currentDate - } - } - time.Sleep(DiagnosticTick) - } -} - -func sendDiagnosticPing() bool { - ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) - defer cancelFn() - - rpcClient := wshclient.GetBareRpcClient() - isOnline, err := wshclient.NetworkOnlineCommand(rpcClient, &wshrpc.RpcOpts{Route: "electron", Timeout: 2000}) - if err != nil || !isOnline { - return false - } - clientId := wstore.GetClientId() - usageTelemetry := telemetry.IsTelemetryEnabled() - wcloud.SendDiagnosticPing(ctx, clientId, usageTelemetry) - return true -} - -func setupTelemetryConfigHandler() { - watcher := wconfig.GetWatcher() - if watcher == nil { - return - } - currentConfig := watcher.GetFullConfig() - currentTelemetryEnabled := currentConfig.Settings.TelemetryEnabled - - watcher.RegisterUpdateHandler(func(newConfig wconfig.FullConfigType) { - newTelemetryEnabled := newConfig.Settings.TelemetryEnabled - if newTelemetryEnabled != currentTelemetryEnabled { - currentTelemetryEnabled = newTelemetryEnabled - wcore.GoSendNoTelemetryUpdate(newTelemetryEnabled) - } - }) -} - func backupCleanupLoop() { defer func() { panichandler.PanicHandler("backupCleanupLoop", recover()) @@ -203,185 +119,6 @@ func backupCleanupLoop() { } } -func panicTelemetryHandler(panicName string) { - activity := wshrpc.ActivityUpdate{NumPanics: 1} - err := telemetry.UpdateActivity(context.Background(), activity) - if err != nil { - log.Printf("error updating activity (panicTelemetryHandler): %v\n", err) - } - telemetry.RecordTEvent(context.Background(), telemetrydata.MakeTEvent("debug:panic", telemetrydata.TEventProps{ - PanicType: panicName, - })) -} - -func sendTelemetryWrapper() { - defer func() { - panichandler.PanicHandler("sendTelemetryWrapper", recover()) - }() - ctx, cancelFn := context.WithTimeout(context.Background(), 15*time.Second) - defer cancelFn() - beforeSendActivityUpdate(ctx) - clientId := wstore.GetClientId() - err := wcloud.SendAllTelemetry(clientId) - if err != nil { - log.Printf("[error] sending telemetry: %v\n", err) - } -} - -func updateTelemetryCounts(lastCounts telemetrydata.TEventProps) telemetrydata.TEventProps { - ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) - defer cancelFn() - var props telemetrydata.TEventProps - props.CountBlocks, _ = wstore.DBGetCount[*waveobj.Block](ctx) - props.CountTabs, _ = wstore.DBGetCount[*waveobj.Tab](ctx) - props.CountWindows, _ = wstore.DBGetCount[*waveobj.Window](ctx) - props.CountWorkspaces, _, _ = wstore.DBGetWSCounts(ctx) - props.CountSSHConn = conncontroller.GetNumSSHHasConnected() - props.CountWSLConn = wslconn.GetNumWSLHasConnected() - props.CountJobs = jobcontroller.GetNumJobsRunning() - props.CountJobsConnected = jobcontroller.GetNumJobsConnected() - props.CountViews, _ = wstore.DBGetBlockViewCounts(ctx) - - fullConfig := wconfig.GetWatcher().GetFullConfig() - customWidgets := fullConfig.CountCustomWidgets() - customAIPresets := fullConfig.CountCustomAIPresets() - customSettings := wconfig.CountCustomSettings() - customAIModes := fullConfig.CountCustomAIModes() - - props.UserSet = &telemetrydata.TEventUserProps{ - SettingsCustomWidgets: customWidgets, - SettingsCustomAIPresets: customAIPresets, - SettingsCustomSettings: customSettings, - SettingsCustomAIModes: customAIModes, - } - - secretsCount, err := secretstore.CountSecrets() - if err == nil { - props.UserSet.SettingsSecretsCount = secretsCount - } - - if utilfn.CompareAsMarshaledJson(props, lastCounts) { - return lastCounts - } - tevent := telemetrydata.MakeTEvent("app:counts", props) - err = telemetry.RecordTEvent(ctx, tevent) - if err != nil { - log.Printf("error recording counts tevent: %v\n", err) - } - return props -} - -func updateTelemetryCountsLoop() { - defer func() { - panichandler.PanicHandler("updateTelemetryCountsLoop", recover()) - }() - var nextSend int64 - var lastCounts telemetrydata.TEventProps - time.Sleep(TelemetryInitialCountsWait) - for { - if time.Now().Unix() > nextSend { - nextSend = time.Now().Add(TelemetryCountsInterval).Unix() - lastCounts = updateTelemetryCounts(lastCounts) - } - time.Sleep(TelemetryTick) - } -} - -func beforeSendActivityUpdate(ctx context.Context) { - activity := wshrpc.ActivityUpdate{} - activity.NumTabs, _ = wstore.DBGetCount[*waveobj.Tab](ctx) - activity.NumBlocks, _ = wstore.DBGetCount[*waveobj.Block](ctx) - activity.Blocks, _ = wstore.DBGetBlockViewCounts(ctx) - activity.NumWindows, _ = wstore.DBGetCount[*waveobj.Window](ctx) - activity.NumSSHConn = conncontroller.GetNumSSHHasConnected() - activity.NumWSLConn = wslconn.GetNumWSLHasConnected() - activity.NumWSNamed, activity.NumWS, _ = wstore.DBGetWSCounts(ctx) - err := telemetry.UpdateActivity(ctx, activity) - if err != nil { - log.Printf("error updating before activity: %v\n", err) - } -} - -func startupActivityUpdate(firstLaunch bool) { - defer func() { - panichandler.PanicHandler("startupActivityUpdate", recover()) - }() - ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) - defer cancelFn() - activity := wshrpc.ActivityUpdate{Startup: 1} - err := telemetry.UpdateActivity(ctx, activity) // set at least one record into activity (don't use go routine wrap here) - if err != nil { - log.Printf("error updating startup activity: %v\n", err) - } - autoUpdateChannel := telemetry.AutoUpdateChannel() - autoUpdateEnabled := telemetry.IsAutoUpdateEnabled() - shellType, shellVersion, shellErr := shellutil.DetectShellTypeAndVersion() - if shellErr != nil { - shellType = "error" - shellVersion = "" - } - userSetOnce := &telemetrydata.TEventUserProps{ - ClientInitialVersion: "v" + WaveVersion, - } - tosTs := telemetry.GetTosAgreedTs() - var cohortTime time.Time - if tosTs > 0 { - cohortTime = time.UnixMilli(tosTs) - } else { - cohortTime = time.Now() - } - cohortMonth := cohortTime.Format("2006-01") - year, week := cohortTime.ISOWeek() - cohortISOWeek := fmt.Sprintf("%04d-W%02d", year, week) - userSetOnce.CohortMonth = cohortMonth - userSetOnce.CohortISOWeek = cohortISOWeek - fullConfig := wconfig.GetWatcher().GetFullConfig() - props := telemetrydata.TEventProps{ - UserSet: &telemetrydata.TEventUserProps{ - ClientVersion: "v" + wavebase.WaveVersion, - ClientBuildTime: wavebase.BuildTime, - ClientArch: wavebase.ClientArch(), - ClientOSRelease: wavebase.UnameKernelRelease(), - ClientIsDev: wavebase.IsDevMode(), - ClientPackageType: wavebase.ClientPackageType(), - ClientMacOSVersion: wavebase.ClientMacOSVersion(), - AutoUpdateChannel: autoUpdateChannel, - AutoUpdateEnabled: autoUpdateEnabled, - LocalShellType: shellType, - LocalShellVersion: shellVersion, - SettingsTransparent: fullConfig.Settings.WindowTransparent, - }, - UserSetOnce: userSetOnce, - } - if firstLaunch { - props.AppFirstLaunch = true - } - tevent := telemetrydata.MakeTEvent("app:startup", props) - err = telemetry.RecordTEvent(ctx, tevent) - if err != nil { - log.Printf("error recording startup event: %v\n", err) - } -} - -func shutdownActivityUpdate() { - ctx, cancelFn := context.WithTimeout(context.Background(), 1*time.Second) - defer cancelFn() - activity := wshrpc.ActivityUpdate{Shutdown: 1} - err := telemetry.UpdateActivity(ctx, activity) // do NOT use the go routine wrap here (this needs to be synchronous) - if err != nil { - log.Printf("error updating shutdown activity: %v\n", err) - } - err = telemetry.TruncateActivityTEventForShutdown(ctx) - if err != nil { - log.Printf("error truncating activity t-event for shutdown: %v\n", err) - } - tevent := telemetrydata.MakeTEvent("app:shutdown", telemetrydata.TEventProps{}) - err = telemetry.RecordTEvent(ctx, tevent) - if err != nil { - log.Printf("error recording shutdown event: %v\n", err) - } -} - func createMainWshClient() { rpc := wshserver.GetMainRpcClient() wshutil.DefaultRouter.RegisterTrustedLeaf(rpc, wshutil.DefaultRoute) @@ -390,7 +127,6 @@ func createMainWshClient() { sockName := wavebase.GetDomainSocketName() remoteImpl := wshremote.MakeRemoteRpcServerImpl(nil, wshutil.DefaultRouter, wshclient.GetBareRpcClient(), true, localInitialEnv, sockName) localConnWsh := wshutil.MakeWshRpc(wshrpc.RpcContext{Conn: wshrpc.LocalConnName}, remoteImpl, "conn:local") - go wshremote.RunSysInfoLoop(localConnWsh, wshrpc.LocalConnName) wshutil.DefaultRouter.RegisterTrustedLeaf(localConnWsh, wshutil.MakeConnectionRouteId(wshrpc.LocalConnName)) wshfs.RpcClient = localConnWsh wshfs.RpcClientRouteId = wshutil.MakeConnectionRouteId(wshrpc.LocalConnName) @@ -405,10 +141,6 @@ func grabAndRemoveEnvVars() error { if err != nil { return err } - err = wcloud.CacheAndRemoveEnvVars() - if err != nil { - return err - } // Remove WAVETERM env vars that leak from prod => dev os.Unsetenv("WAVETERM_CLIENTID") @@ -525,7 +257,6 @@ func main() { log.Printf("error initializing wstore: %v\n", err) return } - panichandler.PanicTelemetryHandler = panicTelemetryHandler go func() { defer func() { panichandler.PanicHandler("InitCustomShellStartupFiles", recover()) @@ -566,12 +297,7 @@ func main() { aiusechat.InitAIModeConfigWatcher() maybeStartPprofServer() go stdinReadWatch() - go telemetryLoop() - go diagnosticLoop() - setupTelemetryConfigHandler() - go updateTelemetryCountsLoop() go backupCleanupLoop() - go startupActivityUpdate(firstLaunch) // must be after startConfigWatcher() blocklogger.InitBlockLogger() jobcontroller.InitJobController() blockcontroller.InitBlockController() diff --git a/cmd/wsh/cmd/wshcmd-blocks.go b/cmd/wsh/cmd/wshcmd-blocks.go index 7e4b935ee3..e499eba812 100644 --- a/cmd/wsh/cmd/wshcmd-blocks.go +++ b/cmd/wsh/cmd/wshcmd-blocks.go @@ -21,7 +21,7 @@ var ( blocksWindowId string // Window ID to filter blocks by blocksWorkspaceId string // Workspace ID to filter blocks by blocksTabId string // Tab ID to filter blocks by - blocksView string // View type to filter blocks by (term, web, etc.) + blocksView string // View type to filter blocks by (term, preview, etc.) blocksJSON bool // Whether to output as JSON blocksTimeout int // Timeout in milliseconds for RPC calls ) @@ -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, preview, edit, waveai) Meta waveobj.MetaMapType `json:"meta"` // Block metadata including view type } @@ -40,7 +40,7 @@ var blocksListCmd = &cobra.Command{ Use: "list", Aliases: []string{"ls", "get"}, Short: "List blocks in workspaces/windows", - Long: `List blocks with optional filtering by workspace, window, tab, or view type. + Long: `List blocks with optional filtering by workspace, window, tab, or view type. Examples: # List blocks from all workspaces @@ -63,8 +63,8 @@ Examples: # Set a different timeout (in milliseconds) wsh blocks list --timeout=10000`, - RunE: blocksListRun, - PreRunE: preRunSetupRpcClient, + RunE: blocksListRun, + PreRunE: preRunSetupRpcClient, SilenceUsage: true, } @@ -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, preview/edit, waveai)") blocksListCmd.Flags().BoolVar(&blocksJSON, "json", false, "output as JSON") blocksListCmd.Flags().IntVar(&blocksTimeout, "timeout", 5000, "timeout in milliseconds for RPC calls (default: 5000)") @@ -86,9 +86,9 @@ func init() { } blocksCmd := &cobra.Command{ - Use: "blocks", - Short: "Manage blocks", - Long: "Commands for working with blocks", + Use: "blocks", + Short: "Manage blocks", + Long: "Commands for working with blocks", } blocksCmd.AddCommand(blocksListCmd) @@ -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, preview, edit, waveai", v) } } @@ -231,8 +231,6 @@ func blocksListRun(cmd *cobra.Command, args []string) error { switch view { case "preview", "edit": content = b.Meta.GetString(waveobj.MetaKey_File, "") - case "web": - content = b.Meta.GetString(waveobj.MetaKey_Url, "") case "term": content = b.Meta.GetString(waveobj.MetaKey_CmdCwd, "") default: @@ -268,12 +266,8 @@ func matchesViewType(actual, filter string) bool { return strings.EqualFold(actual, "preview") || strings.EqualFold(actual, "edit") case "terminal", "term", "shell", "console": return strings.EqualFold(actual, "term") - case "web", "browser", "url": - return strings.EqualFold(actual, "web") case "ai", "waveai", "assistant": return strings.EqualFold(actual, "waveai") - case "sys", "sysinfo", "system": - return strings.EqualFold(actual, "sysinfo") } return false @@ -283,9 +277,7 @@ func matchesViewType(actual, filter string) bool { func isKnownViewFilter(f string) bool { switch strings.ToLower(strings.TrimSpace(f)) { case "term", "terminal", "shell", "console", - "web", "browser", "url", "preview", "edit", - "sysinfo", "sys", "system", "waveai", "ai", "assistant": return true default: diff --git a/cmd/wsh/cmd/wshcmd-connserver.go b/cmd/wsh/cmd/wshcmd-connserver.go index 1f892a24ce..5f5ac60f6e 100644 --- a/cmd/wsh/cmd/wshcmd-connserver.go +++ b/cmd/wsh/cmd/wshcmd-connserver.go @@ -273,13 +273,6 @@ func serverRunRouter() error { }() runListener(unixListener, router) }() - // run the sysinfo loop - go func() { - defer func() { - panichandler.PanicHandler("serverRunRouter:RunSysInfoLoop", recover()) - }() - wshremote.RunSysInfoLoop(client, connServerConnName) - }() startJobLogCleanup() log.Printf("running server, successfully started") select {} @@ -381,13 +374,6 @@ func serverRunRouterDomainSocket(jwtToken string) error { runListener(unixListener, router) }() - // run the sysinfo loop - go func() { - defer func() { - panichandler.PanicHandler("serverRunRouterDomainSocket:RunSysInfoLoop", recover()) - }() - wshremote.RunSysInfoLoop(client, connServerConnName) - }() startJobLogCleanup() log.Printf("running server (router-domainsocket mode), successfully started") @@ -406,12 +392,6 @@ func serverRunNormal(jwtToken string) error { wshfs.RpcClient = RpcClient wshfs.RpcClientRouteId = RpcClientRouteId WriteStdout("running wsh connserver (%s)\n", RpcContext.Conn) - go func() { - defer func() { - panichandler.PanicHandler("serverRunNormal:RunSysInfoLoop", recover()) - }() - wshremote.RunSysInfoLoop(RpcClient, RpcContext.Conn) - }() startJobLogCleanup() select {} // run forever } diff --git a/cmd/wsh/cmd/wshcmd-debug.go b/cmd/wsh/cmd/wshcmd-debug.go index 9efac0ff87..29a1857689 100644 --- a/cmd/wsh/cmd/wshcmd-debug.go +++ b/cmd/wsh/cmd/wshcmd-debug.go @@ -24,24 +24,11 @@ var debugBlockIdsCmd = &cobra.Command{ Hidden: true, } -var debugSendTelemetryCmd = &cobra.Command{ - Use: "send-telemetry", - Short: "send telemetry", - RunE: debugSendTelemetryRun, - Hidden: true, -} - func init() { debugCmd.AddCommand(debugBlockIdsCmd) - debugCmd.AddCommand(debugSendTelemetryCmd) rootCmd.AddCommand(debugCmd) } -func debugSendTelemetryRun(cmd *cobra.Command, args []string) error { - err := wshclient.SendTelemetryCommand(RpcClient, nil) - return err -} - func debugBlockIdsRun(cmd *cobra.Command, args []string) error { oref, err := resolveBlockArg() if err != nil { diff --git a/cmd/wsh/cmd/wshcmd-file.go b/cmd/wsh/cmd/wshcmd-file.go index e40eb324d2..7c668ef2dd 100644 --- a/cmd/wsh/cmd/wshcmd-file.go +++ b/cmd/wsh/cmd/wshcmd-file.go @@ -90,7 +90,7 @@ var fileListCmd = &cobra.Command{ Short: "list files", Long: "List files in a directory. By default, lists files in the current directory." + UriHelpText, Example: " wsh file ls wsh://user@ec2/home/user/", - RunE: activityWrap("file", fileListRun), + RunE: fileListRun, PreRunE: preRunSetupRpcClient, } @@ -100,7 +100,7 @@ var fileCatCmd = &cobra.Command{ Long: "Display the contents of a file." + UriHelpText, Example: " wsh file cat wsh://user@ec2/home/user/config.txt", Args: cobra.ExactArgs(1), - RunE: activityWrap("file", fileCatRun), + RunE: fileCatRun, PreRunE: preRunSetupRpcClient, } @@ -110,7 +110,7 @@ var fileInfoCmd = &cobra.Command{ Long: "Show information about a file." + UriHelpText, Example: " wsh file info wsh://user@ec2/home/user/config.txt", Args: cobra.ExactArgs(1), - RunE: activityWrap("file", fileInfoRun), + RunE: fileInfoRun, PreRunE: preRunSetupRpcClient, } @@ -120,7 +120,7 @@ var fileRmCmd = &cobra.Command{ Long: "Remove a file." + UriHelpText, Example: " wsh file rm wsh://user@ec2/home/user/config.txt", Args: cobra.ExactArgs(1), - RunE: activityWrap("file", fileRmRun), + RunE: fileRmRun, PreRunE: preRunSetupRpcClient, } @@ -130,7 +130,7 @@ var fileWriteCmd = &cobra.Command{ Long: "Write stdin into a file, buffering input (10MB total file size limit)." + UriHelpText, Example: " echo 'hello' | wsh file write ./greeting.txt", Args: cobra.ExactArgs(1), - RunE: activityWrap("file", fileWriteRun), + RunE: fileWriteRun, PreRunE: preRunSetupRpcClient, } @@ -140,7 +140,7 @@ var fileAppendCmd = &cobra.Command{ Long: "Append stdin to a file, buffering input (10MB total file size limit)." + UriHelpText, Example: " tail -f log.txt | wsh file append ./app.log", Args: cobra.ExactArgs(1), - RunE: activityWrap("file", fileAppendRun), + RunE: fileAppendRun, PreRunE: preRunSetupRpcClient, } @@ -151,7 +151,7 @@ var fileCpCmd = &cobra.Command{ Long: "Copy files between different storage systems." + UriHelpText, Example: " wsh file cp wsh://user@ec2/home/user/config.txt ./local-config.txt\n wsh file cp ./local-config.txt wsh://user@ec2/home/user/config.txt", Args: cobra.ExactArgs(2), - RunE: activityWrap("file", fileCpRun), + RunE: fileCpRun, PreRunE: preRunSetupRpcClient, } @@ -162,7 +162,7 @@ var fileMvCmd = &cobra.Command{ Long: "Move files between different storage systems. The source file will be deleted once the operation completes successfully." + UriHelpText, Example: " wsh file mv wsh://user@ec2/home/user/config.txt ./local-config.txt\n wsh file mv ./local-config.txt wsh://user@ec2/home/user/config.txt", Args: cobra.ExactArgs(2), - RunE: activityWrap("file", fileMvRun), + RunE: fileMvRun, PreRunE: preRunSetupRpcClient, } diff --git a/cmd/wsh/cmd/wshcmd-root.go b/cmd/wsh/cmd/wshcmd-root.go index 9534d2e5f5..bc4d5ced90 100644 --- a/cmd/wsh/cmd/wshcmd-root.go +++ b/cmd/wsh/cmd/wshcmd-root.go @@ -213,22 +213,7 @@ func getTabIdFromEnv() string { return os.Getenv("WAVETERM_TABID") } -// this will send wsh activity to the client running on *your* local machine (it does not contact any wave cloud infrastructure) -// if you've turned off telemetry in your local client, this data never gets sent to us -// no parameters or timestamps are sent, as you can see below, it just sends the name of the command (and if there was an error) -// (e.g. "wsh ai ..." would send "ai") -// this helps us understand which commands are actually being used so we know where to concentrate our effort -func sendActivity(wshCmdName string, success bool) { - if RpcClient == nil || wshCmdName == "" { - return - } - dataMap := make(map[string]int) - dataMap[wshCmdName] = 1 - if !success { - dataMap[wshCmdName+"#"+"error"] = 1 - } - wshclient.WshActivityCommand(RpcClient, dataMap, nil) -} +func sendActivity(wshCmdName string, success bool) {} // Execute executes the root command. func Execute() { diff --git a/cmd/wsh/cmd/wshcmd-view.go b/cmd/wsh/cmd/wshcmd-view.go index 1ba84b516f..473528ed5b 100644 --- a/cmd/wsh/cmd/wshcmd-view.go +++ b/cmd/wsh/cmd/wshcmd-view.go @@ -59,54 +59,42 @@ func viewRun(cmd *cobra.Command, args []string) (rtnErr error) { } fileArg := args[0] conn := RpcContext.Conn - var wshCmd *wshrpc.CommandCreateBlockData if strings.HasPrefix(fileArg, "http://") || strings.HasPrefix(fileArg, "https://") { - wshCmd = &wshrpc.CommandCreateBlockData{ - TabId: tabId, - BlockDef: &waveobj.BlockDef{ - Meta: map[string]any{ - waveobj.MetaKey_View: "web", - waveobj.MetaKey_Url: fileArg, - }, - }, - Magnified: viewMagnified, - Focused: true, - } - } else { - absFile, err := filepath.Abs(fileArg) - if err != nil { - return fmt.Errorf("getting absolute path: %w", err) - } - absParent, err := filepath.Abs(filepath.Dir(fileArg)) - if err != nil { - return fmt.Errorf("getting absolute path of parent dir: %w", err) - } - _, err = os.Stat(absParent) - if err == fs.ErrNotExist { - return fmt.Errorf("parent directory does not exist: %q", absParent) - } - if err != nil { - return fmt.Errorf("getting file info: %w", err) - } - wshCmd = &wshrpc.CommandCreateBlockData{ - TabId: tabId, - BlockDef: &waveobj.BlockDef{ - Meta: map[string]interface{}{ - waveobj.MetaKey_View: "preview", - waveobj.MetaKey_File: absFile, - }, + return fmt.Errorf("URL viewing is not supported; the in-app web browser has been removed") + } + absFile, err := filepath.Abs(fileArg) + if err != nil { + return fmt.Errorf("getting absolute path: %w", err) + } + absParent, err := filepath.Abs(filepath.Dir(fileArg)) + if err != nil { + return fmt.Errorf("getting absolute path of parent dir: %w", err) + } + _, err = os.Stat(absParent) + if err == fs.ErrNotExist { + return fmt.Errorf("parent directory does not exist: %q", absParent) + } + if err != nil { + return fmt.Errorf("getting file info: %w", err) + } + wshCmd := &wshrpc.CommandCreateBlockData{ + TabId: tabId, + BlockDef: &waveobj.BlockDef{ + Meta: map[string]interface{}{ + waveobj.MetaKey_View: "preview", + waveobj.MetaKey_File: absFile, }, - Magnified: viewMagnified, - Focused: true, - } - if cmdName == "edit" { - wshCmd.BlockDef.Meta[waveobj.MetaKey_Edit] = true - } - if conn != "" { - wshCmd.BlockDef.Meta[waveobj.MetaKey_Connection] = conn - } + }, + Magnified: viewMagnified, + Focused: true, + } + if cmdName == "edit" { + wshCmd.BlockDef.Meta[waveobj.MetaKey_Edit] = true + } + if conn != "" { + wshCmd.BlockDef.Meta[waveobj.MetaKey_Connection] = conn } - _, err := wshclient.CreateBlockCommand(RpcClient, *wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) + _, err = wshclient.CreateBlockCommand(RpcClient, *wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("running view command: %w", err) } diff --git a/cmd/wsh/cmd/wshcmd-web.go b/cmd/wsh/cmd/wshcmd-web.go deleted file mode 100644 index bfda76b82c..0000000000 --- a/cmd/wsh/cmd/wshcmd-web.go +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "encoding/json" - "fmt" - - "github.com/spf13/cobra" - "github.com/wavetermdev/waveterm/pkg/waveobj" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" - "github.com/wavetermdev/waveterm/pkg/wshutil" -) - -var webCmd = &cobra.Command{ - Use: "web [open|get|set]", - Short: "web commands", - PersistentPreRunE: preRunSetupRpcClient, -} - -var webOpenCmd = &cobra.Command{ - Use: "open url", - Short: "open a url a web widget", - Args: cobra.ExactArgs(1), - RunE: webOpenRun, -} - -var webGetCmd = &cobra.Command{ - Use: "get [--inner] [--all] [--json] css-selector", - Short: "get the html for a css selector", - Args: cobra.ExactArgs(1), - Hidden: true, - RunE: webGetRun, -} - -var webGetInner bool -var webGetAll bool -var webGetJson bool -var webOpenMagnified bool -var webOpenReplaceBlock string - -func init() { - webOpenCmd.Flags().BoolVarP(&webOpenMagnified, "magnified", "m", false, "open view in magnified mode") - webOpenCmd.Flags().StringVarP(&webOpenReplaceBlock, "replace", "r", "", "replace block") - webCmd.AddCommand(webOpenCmd) - webGetCmd.Flags().BoolVarP(&webGetInner, "inner", "", false, "get inner html (instead of outer)") - webGetCmd.Flags().BoolVarP(&webGetAll, "all", "", false, "get all matches (querySelectorAll)") - webGetCmd.Flags().BoolVarP(&webGetJson, "json", "", false, "output as json") - webCmd.AddCommand(webGetCmd) - rootCmd.AddCommand(webCmd) -} - -func webGetRun(cmd *cobra.Command, args []string) error { - fullORef, err := resolveBlockArg() - if err != nil { - return fmt.Errorf("resolving blockid: %w", err) - } - blockInfo, err := wshclient.BlockInfoCommand(RpcClient, fullORef.OID, nil) - if err != nil { - return fmt.Errorf("getting block info: %w", err) - } - if blockInfo.Block.Meta.GetString(waveobj.MetaKey_View, "") != "web" { - return fmt.Errorf("block %s is not a web block", fullORef.OID) - } - data := wshrpc.CommandWebSelectorData{ - WorkspaceId: blockInfo.WorkspaceId, - BlockId: fullORef.OID, - TabId: blockInfo.TabId, - Selector: args[0], - Opts: &wshrpc.WebSelectorOpts{ - Inner: webGetInner, - All: webGetAll, - }, - } - output, err := wshclient.WebSelectorCommand(RpcClient, data, &wshrpc.RpcOpts{ - Route: wshutil.ElectronRoute, - Timeout: 5000, - }) - if err != nil { - return err - } - if webGetJson { - barr, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("json encoding: %w", err) - } - WriteStdout("%s\n", string(barr)) - } else { - for _, item := range output { - WriteStdout("%s\n", item) - } - } - return nil -} - -func webOpenRun(cmd *cobra.Command, args []string) (rtnErr error) { - defer func() { - sendActivity("web", rtnErr == nil) - }() - - var replaceBlockORef *waveobj.ORef - if webOpenReplaceBlock != "" { - var err error - replaceBlockORef, err = resolveSimpleId(webOpenReplaceBlock) - if err != nil { - return fmt.Errorf("resolving -r blockid: %w", err) - } - } - if replaceBlockORef != nil && webOpenMagnified { - return fmt.Errorf("cannot use --replace and --magnified together") - } - - tabId := getTabIdFromEnv() - if tabId == "" { - return fmt.Errorf("no WAVETERM_TABID env var set") - } - - wshCmd := wshrpc.CommandCreateBlockData{ - TabId: tabId, - BlockDef: &waveobj.BlockDef{ - Meta: map[string]any{ - waveobj.MetaKey_View: "web", - waveobj.MetaKey_Url: args[0], - }, - }, - Magnified: webOpenMagnified, - Focused: true, - } - if replaceBlockORef != nil { - wshCmd.TargetBlockId = replaceBlockORef.OID - wshCmd.TargetAction = wshrpc.CreateBlockAction_Replace - } - oref, err := wshclient.CreateBlockCommand(RpcClient, wshCmd, nil) - if err != nil { - return fmt.Errorf("creating block: %w", err) - } - WriteStdout("created block %s\n", oref) - return nil -} diff --git a/db/migrations-wstore/000012_drop_telemetry.down.sql b/db/migrations-wstore/000012_drop_telemetry.down.sql new file mode 100644 index 0000000000..830ac8e47c --- /dev/null +++ b/db/migrations-wstore/000012_drop_telemetry.down.sql @@ -0,0 +1,2 @@ +-- This migration cannot be reversed +-- Telemetry tables have been permanently removed diff --git a/db/migrations-wstore/000012_drop_telemetry.up.sql b/db/migrations-wstore/000012_drop_telemetry.up.sql new file mode 100644 index 0000000000..fe9609c5f6 --- /dev/null +++ b/db/migrations-wstore/000012_drop_telemetry.up.sql @@ -0,0 +1,3 @@ +-- Drop telemetry tables +DROP TABLE IF EXISTS db_tevent; +DROP TABLE IF EXISTS db_activity; diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 05389e99ef..0715159d6e 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -115,7 +115,6 @@ wsh editconfig | window:savelastwindow | bool | when `true`, the last window that is closed is preserved and is reopened the next time the app is launched (defaults to `true`) | | window:confirmonclose | bool | when `true`, a prompt will ask a user to confirm that they want to close a window if it has an unsaved workspace with more than one tab (defaults to `true`) | | window:dimensions | string | set the default dimensions for new windows using the format "WIDTHxHEIGHT" (e.g. "1920x1080"). when a new window is created, these dimensions will be automatically applied. The width and height values should be specified in pixels. | -| telemetry:enabled | bool | set to enable/disable telemetry | For reference, this is the current default configuration (v0.14.0): @@ -149,7 +148,6 @@ For reference, this is the current default configuration (v0.14.0): "window:magnifiedblockblursecondarypx": 2, "window:confirmclose": true, "window:savelastwindow": true, - "telemetry:enabled": true, "term:bellsound": false, "term:bellindicator": false, "term:osc52": "always", diff --git a/docs/docs/faq.mdx b/docs/docs/faq.mdx index 61dc80beb4..69dc81cb7f 100644 --- a/docs/docs/faq.mdx +++ b/docs/docs/faq.mdx @@ -56,15 +56,3 @@ If you've installed via Snap, you can use the following command: ```sh sudo snap install waveterm --classic --beta ``` - -## Can I use Wave AI without enabling telemetry? - - - -Yes! Wave AI is normally disabled when telemetry is not enabled. However, you can enable Wave AI features without telemetry by configuring your own custom AI model (either a local model or using your own API key). - -To enable Wave AI without telemetry: -1. Configure a custom AI mode (see [Wave AI documentation](./waveai-modes)) -2. Set `waveai:defaultmode` to your custom mode's key in your Wave settings - -Once you've completed both steps, Wave AI will be enabled and you can use it completely privately without telemetry. This allows you to use local models like Ollama or your own API keys with providers like OpenAI, OpenRouter, or others. diff --git a/docs/docs/index.mdx b/docs/docs/index.mdx index f1665faae8..ef1af63d6a 100644 --- a/docs/docs/index.mdx +++ b/docs/docs/index.mdx @@ -78,7 +78,6 @@ Other References: - [Configuration](./config) - [Custom Widgets](./customwidgets) - [Full wsh reference](./wsh-reference) -- [Telemetry](./telemetry) - [FAQ](./faq) - [Release Notes](./releasenotes) diff --git a/docs/docs/releasenotes.mdx b/docs/docs/releasenotes.mdx index 987be81534..e7c2aaab45 100644 --- a/docs/docs/releasenotes.mdx +++ b/docs/docs/releasenotes.mdx @@ -175,7 +175,7 @@ This release focuses on significant Windows platform improvements, Wave AI visua **Wave AI Updates:** - **Refreshed Visual Design** - Complete UI refresh removing blue accents and adding transparency support for better integration with custom backgrounds -- **BYOK Without Telemetry** - Wave AI now works with bring-your-own-key and local models without requiring telemetry to be enabled +- **BYOK Support** - Wave AI now works with bring-your-own-key and local models - [bugfix] Fixed tool type "function" compatibility with providers like Mistral **Terminal Improvements:** @@ -376,7 +376,6 @@ Lots of other features and bug fixes as well: - New block splitting support -- Use Cmd-D and Cmd-Shift-D to split horizontally and vertically. For more control you can use Ctrl-Shift-S and then Up/Down/Left/Right to split in the given direction. - Delete block (without removing it from the layout). You can use Ctrl-Shift-D to remove a block, while keeping it in the layout. you can then launch a new widget in its place. - `wsh file` now supports copying files between your local machine, remote machines, and to/from S3 -- New analytics framework (event based as opposed to counter based). See Telemetry Docs for more information. - Web bookmarks! Edit in your bookmarks.json file, can open them in the web widget using Cmd+O - Edits to your ai.json presets file will now take effect _immediately_ in AI widgets - Much better error handling and messaging when errors occur in the preview or editor widget @@ -502,7 +501,6 @@ New minor release that introduces Wave's connected computing extensions. We've i - `wsh file` operations (cat, write, append, rm, info, cp, and ls) -- [Docs](https://docs.waveterm.dev/wsh-reference#file) - Improved golang panic handling to prevent backend crashes - Improved SSH config logging and fixes a reused connection bug -- Updated telemetry to track additional counters - New configuration settings (under "window:magnifiedblock") to control magnified block margins and display - New block/zone aliases (client, global, block, workspace, temp) - `wsh ai` file attachments are now rendered with special handling in the AI block @@ -687,7 +685,6 @@ Minor cleanup release. - fix number parsing for certain config file values - add link to docs site - add new back button for directory view -- telemetry fixes ### v0.8.0 — Sep 20, 2024 diff --git a/docs/docs/telemetry-old.mdx b/docs/docs/telemetry-old.mdx deleted file mode 100644 index dba263dacb..0000000000 --- a/docs/docs/telemetry-old.mdx +++ /dev/null @@ -1,130 +0,0 @@ ---- -id: "telemetry-old" -title: "Legacy Telemetry" -sidebar_class_name: hidden ---- - -Wave Terminal collects telemetry data to help us track feature use, direct future product efforts, and generate aggregate metrics on Wave's popularity and usage. We do not collect or store any PII (personal identifiable information) and all metric data is only associated with and aggregated using your randomly generated _ClientId_. You may opt out of collection at any time. - -If you would like to turn telemetry on or off, the first opportunity is a button on the initial welcome page. After this, it can be turned off by adding `"telemetry:enabled": false` to the `config/settings.json` file. It can alternatively be turned on by adding `"telemetry:enabled": true` to the `config/settings.json` file. - -:::info - -You can also change your telemetry setting by running the wsh command: - -``` -wsh setconfig telemetry:enabled=true -``` - -::: - ---- - -## Sending Telemetry - -Provided that telemetry is enabled, it is sent 10 seconds after Waveterm is first booted and then again every 4 hours thereafter. It can also be sent in response to a few special cases listed below. When telemetry is sent, it is grouped into individual days as determined by your time zone. Any data from a previous day is marked as `Uploaded` so it will not need to be sent again. - -### Sending Once Telemetry is Enabled - -As soon as telemetry is enabled, a telemetry update is sent regardless of how long it has been since the last send. This does not reset the usual timer for telemetry sends. - -### Notifying that Telemetry is Disabled - -As soon as telemetry is disabled, Waveterm sends a special update that notifies us of this change. See [When Telemetry is Turned Off](#when-telemetry-is-turned-off) for more info. The timer still runs in the background but no data is sent. - -### When Waveterm is Closed - -Provided that telemetry is enabled, it will be sent when Waveterm is closed. - ---- - -## Telemetry Data - -When telemetry is active, we collect the following data. It is stored in the `telemetry.TelemetryData` type in the source code. - -| Name | Description | -| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ActiveMinutes | The number of minutes that the user has actively used Waveterm on a given day. This requires the terminal window to be in focus while the user is actively interacting with it. | -| FgMinutes | The number of minutes that Waveterm has been in the foreground on a given day. This requires the terminal window to be in focus regardless of user interaction. | -| OpenMinutes | The number of minutes that Waveterm has been open on a given day. This only requires that the terminal is open, even if the window is out of focus. | -| NumBlocks | The number of existing blocks open on a given day | -| NumTabs | The number of existing tabs open on a given day. | -| NewTab | The number of new tabs created on a given day | -| NumWindows | The number of existing windows open on a given day. | -| NumWS | The number of existing workspaces on a given day. | -| NumWSNamed | The number of named workspaces on a give day. | -| NewTab | The number of new tabs opened on a given day. | -| NumStartup | The number of times waveterm has been started on a given day. | -| NumShutdown | The number of times waveterm has been shut down on a given day. | -| SetTabTheme | The number of times the tab theme is changed from the context menu | -| NumMagnify | The number of times any block is magnified | -| NumPanics | The number of backend (golang) panics caught in the current day | -| NumAIReqs | The number of AI requests made in the current day | -| NumSSHConn | The number of distinct SSH connections that have been made to distinct hosts | -| NumWSLConns | The number of distinct WSL connections that have been made to distinct distros | -| Renderers | The number of new block views of each type are open on a given day. | -| WshCmds | The number of wsh commands of each type run on a given day | -| Blocks | The number of blocks of different view types open on a given day | -| Conn | The number of successful remote connections made (and errors) on a given day | - -## Associated Data - -In addition to the telemetry data collected, the following is also reported. It is stored in the `telemetry.ActivityType` type in the source code. - -| Name | Description | -| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Day | The date the telemetry is associated with. It does not include the time. | -| Uploaded | A boolean that indicates if the telemetry for this day is finalized. It is false during the day the telemetry is associated with, but gets set true at the first telemetry upload after that. Once it is true, the data for that particular day will not be sent up with the telemetry any more. | -| TzName | The code for the timezone the user's OS is reporting (e.g. PST, GMT, JST) | -| TzOffset | The offset for the timezone the user's OS is reporting (e.g. -08:00, +00:00, +09:00) | -| ClientVersion | Which version of Waveterm is installed. | -| ClientArch | This includes the user's operating system (e.g. linux or darwin) and architecture (e.g. x86_64 or arm64). It does not include data for any Connections at this time. | -| BuildTime | This serves as a more accurate version number that keeps track of when we built the version. It has no bearing on when that version was installed by you. | -| OSRelease | This lists the version of the operating system the user has installed. | -| Displays | Display resolutions (added in v0.9.3 to help us understand what screen resolutions to optimize for) | - -## Telemetry Metadata - -Lastly, some data is sent along with the telemetry that describes how to classify it. It is stored in the `wcloud.TelemetryInputType` in the source code. - -| Name | Description | -| ----------------- | --------------------------------------------------------------------------------------------------------------------------- | -| UserId | Currently Unused. This is an anonymous UUID intended for use in future features. | -| ClientId | This is an anonymous UUID created when Waveterm is first launched. It is used for telemetry and sending prompts to Open AI. | -| AppType | This is used to differentiate the current version of waveterm from the legacy app. | -| AutoUpdateEnabled | Whether or not auto update is turned on. | -| AutoUpdateChannel | The type of auto update in use. This specifically refers to whether a latest or beta channel is selected. | -| CurDay | The current day (in your time zone) when telemetry is sent. It does not include the time of day. | - -## Geo Data - -We do not store IP addresses in our telemetry table. However, CloudFlare passes us Geo-Location headers. We store these two header values: - -| Name | Description | -| ------------ | ----------------------------------------------------------------- | -| CFCountry | 2-letter country code (e.g. "US", "FR", or "JP") | -| CFRegionCode | region code (often a provence, region, or state within a country) | - ---- - -## When Telemetry is Turned Off - -When a user disables telemetry, Waveterm sends a notification that their anonymous _ClientId_ has had its telemetry disabled. This is done with the `wcloud.NoTelemetryInputType` type in the source code. Beyond that, no further information is sent unless telemetry is turned on again. If it is turned on again, the previous 30 days of telemetry will be sent. - ---- - -## A Note on IP Addresses - -Telemetry is uploaded via https, which means your IP address is known to the telemetry server. We **do not** store your IP address in our telemetry table and **do not** associate it with your _ClientId_. - ---- - -## Previously Collected Telemetry Data - -While we believe the data we collect with telemetry is fairly minimal, we cannot make that decision for every user. If you ever change your mind about what has been collected previously, you may request that your data be deleted by emailing us at [support@waveterm.dev](mailto:support@waveterm.dev). If you do, we will need your _ClientId_ to remove it. - ---- - -## Privacy Policy - -For a summary of the above, you can take a look at our [Privacy Policy](https://www.waveterm.dev/privacy). diff --git a/docs/docs/telemetry.mdx b/docs/docs/telemetry.mdx deleted file mode 100644 index 2f9132276d..0000000000 --- a/docs/docs/telemetry.mdx +++ /dev/null @@ -1,71 +0,0 @@ ---- -sidebar_position: 100 -title: Telemetry -id: "telemetry" ---- - -## tl;dr - -Wave Terminal collects telemetry data to help us track feature use, direct future product efforts, and generate aggregate metrics on Wave's popularity and usage. We do NOT collect personal information (PII), keystrokes, file contents, AI prompts, IP addresses, hostnames, or commands. We attach all information to an anonymous, randomly generated _ClientId_ (UUID). You may opt out of collection at any time. - -Here's a quick summary of what is collected: - -- Basic App/System Info - OS, architecture, app version, update settings -- Usage Metrics - App start/shutdown, active minutes, foreground time, tab/block counts/usage -- Feature Interactions - When you create tabs, run commands, change settings, etc. -- Display Info - Monitor resolution, number of displays -- Connection Events - SSH/WSL connection attempts (but NOT hostnames/IPs) -- Wave AI Usage - Model/provider selection, token counts, request metrics, latency (but NOT prompts or responses) -- Error Reports - Crash/panic events with minimal debugging info, but no stack traces or detailed errors - -Telemetry can be disabled at any time in settings. If not disabled it is sent on startup, on shutdown, and every 4-hours. - -## How to Disable Telemetry - -Telemetry can be enabled or disabled on the initial welcome screen when Wave first starts. After setup, telemetry can be disabled by setting the `telemetry:enabled` key to `false` in Wave’s general configuration file. It can also be disabled using the CLI command `wsh setconfig telemetry:enabled=false`. - -:::info - -This document outlines the current telemetry system as of v0.11.1. As of v0.12.5, Wave Terminal no longer sends legacy telemetry. The previous telemetry documentation can be found in our [Legacy Telemetry Documentation](./telemetry-old.mdx) for historical reference. - -::: - -## Diagnostics Ping - -Wave sends a small, anonymous diagnostics ping after the app has been running for a short time and at most once per day thereafter. This is used to estimate active installs and understand which versions are still in use, so we can make informed decisions about ongoing support and deprecations. - -The ping includes only: your Wave version, OS/CPU arch, local date (yyyy-mm-dd, no timezone or clock time), your randomly generated anonymous client ID, and whether usage telemetry is enabled or disabled. - -It does not include usage data, commands, files, or any telemetry events. - -This ping is intentionally separate from telemetry so Wave can count active installs. If you'd like to disable it, set the WAVETERM_NOPING environment variable. - -## Sending Telemetry - -Provided that telemetry is enabled, it is sent shortly after Wave is first launched and then again every 4 hours thereafter. It can also be sent in response to a few special cases listed below. When telemetry is sent, events are marked as sent to prevent duplicate transmissions. - -### Sending Once Telemetry is Enabled - -As soon as telemetry is enabled, a telemetry update is sent regardless of how long it has been since the last send. This does not reset the usual timer for telemetry sends. - -### When Wave is Closed - -Provided that telemetry is enabled, it will be sent when Waveterm is closed. - -## Event Types and Properties - -Wave collects the event types and properties described in the summary above. As we add features, new events and properties may be added to track their usage. - -For the complete, current list of all telemetry events and properties, see the source code: [telemetrydata.go](https://github.com/wavetermdev/waveterm/blob/main/pkg/telemetry/telemetrydata/telemetrydata.go) - -## GDPR Opt-Out Compliance - -When telemetry is disabled, Wave sends a single minimal opt-out record associated with the anonymous client ID, recording that telemetry was turned off and when it occurred. This record is retained for compliance purposes. After that, no telemetry or usage data is sent. - -## Deleting Your Data - -If you want your previously collected telemetry data deleted, email us at support (at) waveterm.dev with your _ClientId_ and we'll remove it. - -## Privacy Policy - -For a summary of the above, you can take a look at our [Privacy Policy](https://www.waveterm.dev/privacy). diff --git a/docs/docs/waveai-modes.mdx b/docs/docs/waveai-modes.mdx index 62045b86a9..0f83ad954f 100644 --- a/docs/docs/waveai-modes.mdx +++ b/docs/docs/waveai-modes.mdx @@ -76,10 +76,6 @@ wsh setconfig 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`: diff --git a/docs/docs/waveai.mdx b/docs/docs/waveai.mdx index 5189bc6792..75426c1096 100644 --- a/docs/docs/waveai.mdx +++ b/docs/docs/waveai.mdx @@ -90,7 +90,6 @@ See the [**Local Models & BYOK guide**](./waveai-modes.mdx) for complete configu **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:** diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs index d49f2da616..cc657600c9 100644 --- a/electron-builder.config.cjs +++ b/electron-builder.config.cjs @@ -22,7 +22,7 @@ const config = { { from: "./dist", to: "./dist", - filter: ["**/*", "!bin/*", "bin/wavesrv.${arch}*", "bin/wsh*", "!tsunamiscaffold/**/*"], + filter: ["**/*", "!bin/*", "bin/wavesrv.${arch}*", "bin/wsh*"], }, { from: ".", @@ -31,12 +31,7 @@ const config = { }, "!node_modules", // We don't need electron-builder to package in Node modules as Vite has already bundled any code that our program is using. ], - extraResources: [ - { - from: "dist/tsunamiscaffold", - to: "tsunamiscaffold", - }, - ], + extraResources: [], directories: { output: "make", }, diff --git a/electron.vite.config.ts b/electron.vite.config.ts index d94a166659..3bb936f4c3 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -107,7 +107,6 @@ export default defineConfig({ rollupOptions: { input: { index: "emain/preload.ts", - "preload-webview": "emain/preload-webview.ts", }, output: { format: "cjs", diff --git a/emain/emain-activity.ts b/emain/emain-activity.ts deleted file mode 100644 index 17dde466ae..0000000000 --- a/emain/emain-activity.ts +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -// for activity updates -let wasActive = true; -let wasInFg = true; -let globalIsQuitting = false; -let globalIsStarting = true; -let globalIsRelaunching = false; -let forceQuit = false; -let userConfirmedQuit = false; -let termCommandsRun = 0; -let termCommandsRemote = 0; -let termCommandsWsl = 0; -let termCommandsDurable = 0; - -export function setWasActive(val: boolean) { - wasActive = val; -} - -export function setWasInFg(val: boolean) { - wasInFg = val; -} - -export function getActivityState(): { wasActive: boolean; wasInFg: boolean } { - return { wasActive, wasInFg }; -} - -export function setGlobalIsQuitting(val: boolean) { - globalIsQuitting = val; -} - -export function getGlobalIsQuitting(): boolean { - return globalIsQuitting; -} - -export function setGlobalIsStarting(val: boolean) { - globalIsStarting = val; -} - -export function getGlobalIsStarting(): boolean { - return globalIsStarting; -} - -export function setGlobalIsRelaunching(val: boolean) { - globalIsRelaunching = val; -} - -export function getGlobalIsRelaunching(): boolean { - return globalIsRelaunching; -} - -export function setForceQuit(val: boolean) { - forceQuit = val; -} - -export function getForceQuit(): boolean { - return forceQuit; -} - -export function setUserConfirmedQuit(val: boolean) { - userConfirmedQuit = val; -} - -export function getUserConfirmedQuit(): boolean { - return userConfirmedQuit; -} - -export function incrementTermCommandsRun() { - termCommandsRun++; -} - -export function getAndClearTermCommandsRun(): number { - const count = termCommandsRun; - termCommandsRun = 0; - return count; -} - -export function incrementTermCommandsRemote() { - termCommandsRemote++; -} - -export function getAndClearTermCommandsRemote(): number { - const count = termCommandsRemote; - termCommandsRemote = 0; - return count; -} - -export function incrementTermCommandsWsl() { - termCommandsWsl++; -} - -export function getAndClearTermCommandsWsl(): number { - const count = termCommandsWsl; - termCommandsWsl = 0; - return count; -} - -export function incrementTermCommandsDurable() { - termCommandsDurable++; -} - -export function getAndClearTermCommandsDurable(): number { - const count = termCommandsDurable; - termCommandsDurable = 0; - return count; -} diff --git a/emain/emain-builder.ts b/emain/emain-builder.ts deleted file mode 100644 index 8b223c0f9c..0000000000 --- a/emain/emain-builder.ts +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { ClientService } from "@/app/store/services"; -import { RpcApi } from "@/app/store/wshclientapi"; -import { randomUUID } from "crypto"; -import { BrowserWindow, webContents } from "electron"; -import { globalEvents } from "emain/emain-events"; -import path from "path"; -import { getElectronAppBasePath, isDevVite, unamePlatform } from "./emain-platform"; -import { calculateWindowBounds, MinWindowHeight, MinWindowWidth } from "./emain-window"; -import { ElectronWshClient } from "./emain-wsh"; - -export type BuilderWindowType = BrowserWindow & { - builderId: string; - builderAppId?: string; - savedInitOpts: BuilderInitOpts; -}; - -const builderWindows: BuilderWindowType[] = []; -export let focusedBuilderWindow: BuilderWindowType = null; - -export function getBuilderWindowById(builderId: string): BuilderWindowType { - return builderWindows.find((win) => win.builderId === builderId); -} - -export function getBuilderWindowByWebContentsId(webContentsId: number): BuilderWindowType { - return builderWindows.find((win) => win.webContents.id === webContentsId); -} - -export function getAllBuilderWindows(): BuilderWindowType[] { - return builderWindows; -} - -export async function createBuilderWindow(appId: string): Promise { - const builderId = randomUUID(); - - const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); - const clientData = await ClientService.GetClientData(); - const clientId = clientData?.oid; - const windowId = randomUUID(); - - if (appId) { - const oref = `builder:${builderId}`; - await RpcApi.SetRTInfoCommand(ElectronWshClient, { - oref, - data: { "builder:appid": appId }, - }); - } - - const winBounds = calculateWindowBounds(undefined, undefined, fullConfig.settings); - - const builderWindow = new BrowserWindow({ - x: winBounds.x, - y: winBounds.y, - width: winBounds.width, - height: winBounds.height, - minWidth: MinWindowWidth, - minHeight: MinWindowHeight, - titleBarStyle: unamePlatform === "darwin" ? "hiddenInset" : "default", - icon: - unamePlatform === "linux" - ? path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png") - : undefined, - show: false, - backgroundColor: "#222222", - webPreferences: { - preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"), - webviewTag: true, - }, - }); - - if (isDevVite) { - await builderWindow.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html`); - } else { - await builderWindow.loadFile(path.join(getElectronAppBasePath(), "frontend", "index.html")); - } - - const initOpts: BuilderInitOpts = { - builderId, - clientId, - windowId, - }; - - const typedBuilderWindow = builderWindow as BuilderWindowType; - typedBuilderWindow.builderId = builderId; - typedBuilderWindow.builderAppId = appId; - typedBuilderWindow.savedInitOpts = initOpts; - - typedBuilderWindow.on("close", () => { - const wc = typedBuilderWindow.webContents; - if (wc.isDevToolsOpened()) { - wc.closeDevTools(); - } - for (const guest of webContents.getAllWebContents()) { - if (guest.getType() === "webview" && guest.hostWebContents?.id === wc.id) { - if (guest.isDevToolsOpened()) { - guest.closeDevTools(); - } - } - } - }); - - typedBuilderWindow.on("focus", () => { - focusedBuilderWindow = typedBuilderWindow; - console.log("builder window focused", builderId); - setTimeout(() => globalEvents.emit("windows-updated"), 50); - }); - - typedBuilderWindow.on("blur", () => { - if (focusedBuilderWindow === typedBuilderWindow) { - focusedBuilderWindow = null; - } - setTimeout(() => globalEvents.emit("windows-updated"), 50); - }); - - typedBuilderWindow.on("closed", () => { - console.log("builder window closed", builderId); - const index = builderWindows.indexOf(typedBuilderWindow); - if (index !== -1) { - builderWindows.splice(index, 1); - } - if (focusedBuilderWindow === typedBuilderWindow) { - focusedBuilderWindow = null; - } - RpcApi.DeleteBuilderCommand(ElectronWshClient, builderId, { noresponse: true }); - setTimeout(() => globalEvents.emit("windows-updated"), 50); - }); - - builderWindows.push(typedBuilderWindow); - typedBuilderWindow.show(); - - console.log("created builder window", builderId, appId); - return typedBuilderWindow; -} diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index 5e5f15b302..0a3c41fcfe 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -12,14 +12,6 @@ import { RpcApi } from "../frontend/app/store/wshclientapi"; import { getWebServerEndpoint } from "../frontend/util/endpoints"; import * as keyutil from "../frontend/util/keyutil"; import { fireAndForget, parseDataUrl } from "../frontend/util/util"; -import { - incrementTermCommandsDurable, - incrementTermCommandsRemote, - incrementTermCommandsRun, - incrementTermCommandsWsl, - setWasActive, -} from "./emain-activity"; -import { createBuilderWindow, getAllBuilderWindows, getBuilderWindowByWebContentsId } from "./emain-builder"; import { callWithOriginalXdgCurrentDesktopAsync, unamePlatform } from "./emain-platform"; import { getWaveTabViewByWebContentsId } from "./emain-tabview"; import { handleCtrlShiftState } from "./emain-util"; @@ -29,20 +21,6 @@ import { ElectronWshClient } from "./emain-wsh"; const electronApp = electron.app; -let webviewFocusId: number = null; -let webviewKeys: string[] = []; - -export function openBuilderWindow(appId?: string) { - const normalizedAppId = appId || ""; - const existingBuilderWindows = getAllBuilderWindows(); - const existingWindow = existingBuilderWindows.find((win) => win.builderAppId === normalizedAppId); - if (existingWindow) { - existingWindow.focus(); - return; - } - fireAndForget(() => createBuilderWindow(normalizedAppId)); -} - type UrlInSessionResult = { stream: Readable; mimeType: string; @@ -207,43 +185,6 @@ export function initIpcHandlers() { } }); - electron.ipcMain.on("webview-image-contextmenu", (event: electron.IpcMainEvent, payload: { src: string }) => { - const menu = new electron.Menu(); - const win = getWaveWindowByWebContentsId(event.sender.hostWebContents?.id); - if (win == null) { - return; - } - menu.append( - new electron.MenuItem({ - label: "Save Image", - click: () => { - const resultP = getUrlInSession(event.sender.session, payload.src); - resultP - .then((result) => { - saveImageFileWithNativeDialog( - event.sender.hostWebContents, - result.fileName, - result.mimeType, - result.stream - ); - }) - .catch((e) => { - console.log("error getting image", e); - }); - }, - }) - ); - menu.popup(); - }); - - electron.ipcMain.on("webview-mouse-navigate", (event: electron.IpcMainEvent, direction: string) => { - if (direction === "back") { - event.sender.navigationHistory.goBack(); - } else if (direction === "forward") { - event.sender.navigationHistory.goForward(); - } - }); - electron.ipcMain.on("download", (event, payload) => { const baseName = encodeURIComponent(path.basename(payload.filePath)); const streamingUrl = @@ -288,60 +229,12 @@ export function initIpcHandlers() { event.returnValue = event.sender.getZoomFactor(); }); - const hasBeforeInputRegisteredMap = new Map(); - - electron.ipcMain.on("webview-focus", (event: Electron.IpcMainEvent, focusedId: number) => { - webviewFocusId = focusedId; - console.log("webview-focus", focusedId); - if (focusedId == null) { - return; - } - const parentWc = event.sender; - const webviewWc = electron.webContents.fromId(focusedId); - if (webviewWc == null) { - webviewFocusId = null; - return; - } - if (!hasBeforeInputRegisteredMap.get(focusedId)) { - hasBeforeInputRegisteredMap.set(focusedId, true); - webviewWc.on("before-input-event", (e, input) => { - let waveEvent = keyutil.adaptFromElectronKeyEvent(input); - handleCtrlShiftState(parentWc, waveEvent); - if (webviewFocusId != focusedId) { - return; - } - if (input.type != "keyDown") { - return; - } - for (let keyDesc of webviewKeys) { - if (keyutil.checkKeyPressed(waveEvent, keyDesc)) { - e.preventDefault(); - parentWc.send("reinject-key", waveEvent); - console.log("webview reinject-key", keyDesc); - return; - } - } - }); - webviewWc.on("destroyed", () => { - hasBeforeInputRegisteredMap.delete(focusedId); - }); - } - }); - - electron.ipcMain.on("register-global-webview-keys", (event, keys: string[]) => { - webviewKeys = keys ?? []; - }); - electron.ipcMain.on("set-keyboard-chord-mode", (event) => { event.returnValue = null; const tabView = getWaveTabViewByWebContentsId(event.sender.id); tabView?.setKeyboardChordMode(true); }); - electron.ipcMain.handle("set-is-active", () => { - setWasActive(true); - }); - const fac = new FastAverageColor(); electron.ipcMain.on("update-window-controls-overlay", async (event, rect: Dimensions) => { if (unamePlatform === "darwin") return; @@ -380,19 +273,6 @@ export function initIpcHandlers() { }); }); - electron.ipcMain.handle("clear-webview-storage", async (event, webContentsId: number) => { - try { - const wc = electron.webContents.fromId(webContentsId); - if (wc && wc.session) { - await wc.session.clearStorageData(); - console.log("Cleared cookies and storage for webContentsId:", webContentsId); - } - } catch (e) { - console.error("Failed to clear cookies and storage:", e); - throw e; - } - }); - electron.ipcMain.on("open-native-path", (event, filePath: string) => { console.log("open-native-path", filePath); filePath = filePath.replace("~", electronApp.getPath("home")); @@ -420,17 +300,6 @@ export function initIpcHandlers() { return; } - const builderWindow = getBuilderWindowByWebContentsId(event.sender.id); - if (builderWindow != null) { - if (status === "ready") { - if (builderWindow.savedInitOpts) { - console.log("savedInitOpts calling builder-init", builderWindow.savedInitOpts.builderId); - builderWindow.webContents.send("builder-init", builderWindow.savedInitOpts); - } - } - return; - } - console.log("set-window-init-status: no window found for webContentsId", event.sender.id); }); @@ -438,72 +307,12 @@ export function initIpcHandlers() { console.log("fe-log", logStr); }); - electron.ipcMain.on( - "increment-term-commands", - (event, opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) => { - incrementTermCommandsRun(); - if (opts?.isRemote) { - incrementTermCommandsRemote(); - } - if (opts?.isWsl) { - incrementTermCommandsWsl(); - } - if (opts?.isDurable) { - incrementTermCommandsDurable(); - } - } - ); - electron.ipcMain.on("native-paste", (event) => { event.sender.paste(); }); - electron.ipcMain.on("open-builder", (event, appId?: string) => { - openBuilderWindow(appId); - }); - - electron.ipcMain.on("set-builder-window-appid", (event, appId: string) => { - const bw = getBuilderWindowByWebContentsId(event.sender.id); - if (bw == null) { - return; - } - bw.builderAppId = appId; - console.log("set-builder-window-appid", bw.builderId, appId); - }); - electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); - electron.ipcMain.on("close-builder-window", async (event) => { - const bw = getBuilderWindowByWebContentsId(event.sender.id); - if (bw == null) { - return; - } - const builderId = bw.builderId; - if (builderId) { - try { - await RpcApi.SetRTInfoCommand(ElectronWshClient, { - oref: `builder:${builderId}`, - data: {} as ObjRTInfo, - delete: true, - }); - } catch (e) { - console.error("Error deleting builder rtinfo:", e); - } - } - const wc = bw.webContents; - if (wc.isDevToolsOpened()) { - wc.closeDevTools(); - } - for (const guest of electron.webContents.getAllWebContents()) { - if (guest.getType() === "webview" && guest.hostWebContents?.id === wc.id) { - if (guest.isDevToolsOpened()) { - guest.closeDevTools(); - } - } - } - bw.destroy(); - }); - electron.ipcMain.on("do-refresh", (event) => { event.sender.reloadIgnoringCache(); }); diff --git a/emain/emain-menu.ts b/emain/emain-menu.ts index 1bdf6a7139..ddb54fac06 100644 --- a/emain/emain-menu.ts +++ b/emain/emain-menu.ts @@ -5,9 +5,7 @@ import { waveEventSubscribeSingle } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import * as electron from "electron"; import { fireAndForget } from "../frontend/util/util"; -import { focusedBuilderWindow, getBuilderWindowById } from "./emain-builder"; -import { openBuilderWindow } from "./emain-ipc"; -import { isDev, unamePlatform } from "./emain-platform"; +import { unamePlatform } from "./emain-platform"; import { clearTabCache } from "./emain-tabview"; import { decreaseZoomLevel, increaseZoomLevel, resetZoomLevel } from "./emain-util"; import { @@ -31,10 +29,6 @@ function getWindowWebContents(window: electron.BaseWindow): electron.WebContents if (window == null) { return null; } - // Check BrowserWindow first (for Tsunami Builder windows) - if (window instanceof electron.BrowserWindow) { - return window.webContents; - } // Check WaveBrowserWindow (for main Wave windows with tab views) if (window instanceof WaveBrowserWindow) { if (window.activeTabView) { @@ -144,14 +138,6 @@ function makeFileMenu( }, }, ]; - const featureWaveAppBuilder = fullConfig?.settings?.["feature:waveappbuilder"]; - if (isDev || featureWaveAppBuilder) { - fileMenu.splice(1, 0, { - label: "New WaveApp Builder Window", - accelerator: unamePlatform === "darwin" ? "Command+Shift+B" : "Alt+Shift+B", - click: () => openBuilderWindow(""), - }); - } if (numWaveWindows == 0) { fileMenu.push({ label: "New Window (hidden-1)", @@ -203,13 +189,12 @@ function makeAppMenuItems(webContents: electron.WebContents): Electron.MenuItemC function makeViewMenu( webContents: electron.WebContents, callbacks: AppMenuCallbacks, - isBuilderWindowFocused: boolean, fullscreenOnLaunch: boolean ): Electron.MenuItemConstructorOptions[] { const devToolsAccel = unamePlatform === "darwin" ? "Option+Command+I" : "Alt+Shift+I"; return [ { - label: isBuilderWindowFocused ? "Reload Window" : "Reload Tab", + label: "Reload Tab", accelerator: "Shift+CommandOrControl+R", click: (_, window) => { (getWindowWebContents(window) ?? webContents)?.reloadIgnoringCache(); @@ -328,12 +313,11 @@ function makeViewMenu( ]; } -async function makeFullAppMenu(callbacks: AppMenuCallbacks, workspaceOrBuilderId?: string): Promise { +async function makeFullAppMenu(callbacks: AppMenuCallbacks, workspaceId?: string): Promise { const numWaveWindows = getAllWaveWindows().length; - const webContents = workspaceOrBuilderId && getWebContentsByWorkspaceOrBuilderId(workspaceOrBuilderId); + const webContents = workspaceId && getWebContentsByWorkspaceId(workspaceId); const appMenuItems = makeAppMenuItems(webContents); - const isBuilderWindowFocused = focusedBuilderWindow != null; let fullscreenOnLaunch = false; let fullConfig: FullConfigType = null; try { @@ -344,7 +328,7 @@ async function makeFullAppMenu(callbacks: AppMenuCallbacks, workspaceOrBuilderId } const editMenu = makeEditMenu(fullConfig); const fileMenu = makeFileMenu(numWaveWindows, callbacks, fullConfig); - const viewMenu = makeViewMenu(webContents, callbacks, isBuilderWindowFocused, fullscreenOnLaunch); + const viewMenu = makeViewMenu(webContents, callbacks, fullscreenOnLaunch); let workspaceMenu: Electron.MenuItemConstructorOptions[] = null; try { workspaceMenu = await getWorkspaceMenu(); @@ -363,7 +347,7 @@ async function makeFullAppMenu(callbacks: AppMenuCallbacks, workspaceOrBuilderId { role: "editMenu", submenu: editMenu }, { role: "viewMenu", submenu: viewMenu }, ]; - if (workspaceMenu != null && !isBuilderWindowFocused) { + if (workspaceMenu != null) { menuTemplate.push({ label: "Workspace", id: "workspace-menu", @@ -377,13 +361,13 @@ async function makeFullAppMenu(callbacks: AppMenuCallbacks, workspaceOrBuilderId return electron.Menu.buildFromTemplate(menuTemplate); } -export function instantiateAppMenu(workspaceOrBuilderId?: string): Promise { +export function instantiateAppMenu(workspaceId?: string): Promise { return makeFullAppMenu( { createNewWaveWindow, relaunchBrowserWindows, }, - workspaceOrBuilderId + workspaceId ); } @@ -405,17 +389,12 @@ function initMenuEventSubscriptions() { }); } -function getWebContentsByWorkspaceOrBuilderId(workspaceOrBuilderId: string): electron.WebContents { - const ww = getWaveWindowByWorkspaceId(workspaceOrBuilderId); +function getWebContentsByWorkspaceId(workspaceId: string): electron.WebContents { + const ww = getWaveWindowByWorkspaceId(workspaceId); if (ww) { return ww.activeTabView?.webContents; } - const bw = getBuilderWindowById(workspaceOrBuilderId); - if (bw) { - return bw.webContents; - } - return null; } @@ -448,10 +427,10 @@ function convertMenuDefArrToMenu( electron.ipcMain.on( "contextmenu-show", - (event, workspaceOrBuilderId: string, menuDefArr: ElectronContextMenuItem[]) => { - const webContents = getWebContentsByWorkspaceOrBuilderId(workspaceOrBuilderId); + (event, workspaceId: string, menuDefArr: ElectronContextMenuItem[]) => { + const webContents = getWebContentsByWorkspaceId(workspaceId); if (!webContents) { - console.error("invalid window for context menu:", workspaceOrBuilderId); + console.error("invalid window for context menu:", workspaceId); event.returnValue = true; return; } @@ -477,7 +456,7 @@ electron.ipcMain.on( electron.ipcMain.on("workspace-appmenu-show", (event, workspaceId: string) => { fireAndForget(async () => { - const webContents = getWebContentsByWorkspaceOrBuilderId(workspaceId); + const webContents = getWebContentsByWorkspaceId(workspaceId); if (!webContents) { console.error("invalid window for workspace app menu:", workspaceId); return; @@ -488,19 +467,6 @@ electron.ipcMain.on("workspace-appmenu-show", (event, workspaceId: string) => { event.returnValue = true; }); -electron.ipcMain.on("builder-appmenu-show", (event, builderId: string) => { - fireAndForget(async () => { - const webContents = getWebContentsByWorkspaceOrBuilderId(builderId); - if (!webContents) { - console.error("invalid window for builder app menu:", builderId); - return; - } - const menu = await instantiateAppMenu(builderId); - menu.popup(); - }); - event.returnValue = true; -}); - const dockMenu = electron.Menu.buildFromTemplate([ { label: "New Window", diff --git a/emain/emain-platform.ts b/emain/emain-platform.ts index 32320e4eb4..77e2dbc4d6 100644 --- a/emain/emain-platform.ts +++ b/emain/emain-platform.ts @@ -193,9 +193,6 @@ ipcMain.on("get-user-name", (event) => { ipcMain.on("get-host-name", (event) => { event.returnValue = os.hostname(); }); -ipcMain.on("get-webview-preload", (event) => { - event.returnValue = path.join(getElectronAppBasePath(), "preload", "preload-webview.cjs"); -}); ipcMain.on("get-data-dir", (event) => { event.returnValue = getWaveDataDir(); }); diff --git a/emain/emain-tabview.ts b/emain/emain-tabview.ts index 753a53adec..e94afe57a7 100644 --- a/emain/emain-tabview.ts +++ b/emain/emain-tabview.ts @@ -8,7 +8,6 @@ import { Rectangle, shell, WebContentsView } from "electron"; import { createNewWaveWindow, getWaveWindowById } from "emain/emain-window"; import path from "path"; import { configureAuthKeyRequestInjection } from "./authkey"; -import { setWasActive } from "./emain-activity"; import { getElectronAppBasePath, isDevVite, unamePlatform } from "./emain-platform"; import { decreaseZoomLevel, @@ -138,7 +137,6 @@ export class WaveTabView extends WebContentsView { super({ webPreferences: { preload: path.join(getElectronAppBasePath(), "preload", "index.cjs"), - webviewTag: true, }, }); this.createdTs = Date.now(); @@ -327,7 +325,6 @@ export async function getOrCreateWebViewForTab(waveWindowId: string, tabId: stri const waveEvent = adaptFromElectronKeyEvent(input); // console.log("WIN bie", tabView.waveTabId.substring(0, 8), waveEvent.type, waveEvent.code); handleCtrlShiftState(tabView.webContents, waveEvent); - setWasActive(true); if (input.type == "keyDown" && tabView.keyboardChordMode) { e.preventDefault(); tabView.setKeyboardChordMode(false); diff --git a/emain/emain-wavesrv.ts b/emain/emain-wavesrv.ts index f58d214a7e..f8d5b8dd8d 100644 --- a/emain/emain-wavesrv.ts +++ b/emain/emain-wavesrv.ts @@ -6,7 +6,7 @@ import * as child_process from "node:child_process"; import * as readline from "readline"; import { WebServerEndpointVarName, WSServerEndpointVarName } from "../frontend/util/endpoints"; import { AuthKey, WaveAuthKeyEnv } from "./authkey"; -import { setForceQuit, setUserConfirmedQuit } from "./emain-activity"; +import { setForceQuit, setUserConfirmedQuit } from "./emain"; import { getElectronAppResourcesPath, getElectronAppUnpackedBasePath, diff --git a/emain/emain-window.ts b/emain/emain-window.ts index e3bfa87751..63f5741dff 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -13,9 +13,7 @@ import { getGlobalIsQuitting, getGlobalIsRelaunching, setGlobalIsRelaunching, - setWasActive, - setWasInFg, -} from "./emain-activity"; +} from "./emain"; import { log } from "./emain-log"; import { getElectronAppBasePath, isDev, unamePlatform } from "./emain-platform"; import { getOrCreateWebViewForTab, getWaveTabViewByWebContentsId, WaveTabView } from "./emain-tabview"; @@ -285,8 +283,6 @@ export class WaveBrowserWindow extends BaseWindow { focusedWaveWindow = this; // eslint-disable-line @typescript-eslint/no-this-alias console.log("focus win", this.waveWindowId); fireAndForget(() => ClientService.FocusWindow(this.waveWindowId)); - setWasInFg(true); - setWasActive(true); setTimeout(() => globalEvents.emit("windows-updated"), 50); }); this.on("blur", () => { diff --git a/emain/emain.ts b/emain/emain.ts index 8b08178aec..a1c7f15ef6 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -3,29 +3,12 @@ import { RpcApi } from "@/app/store/wshclientapi"; import * as electron from "electron"; -import { focusedBuilderWindow, getAllBuilderWindows } from "emain/emain-builder"; import { globalEvents } from "emain/emain-events"; import { sprintf } from "sprintf-js"; import * as services from "../frontend/app/store/services"; import { initElectronWshrpc, shutdownWshrpc } from "../frontend/app/store/wshrpcutil-base"; import { fireAndForget, sleep } from "../frontend/util/util"; import { AuthKey, configureAuthKeyRequestInjection } from "./authkey"; -import { - getActivityState, - getAndClearTermCommandsDurable, - getAndClearTermCommandsRemote, - getAndClearTermCommandsRun, - getAndClearTermCommandsWsl, - getForceQuit, - getGlobalIsRelaunching, - getUserConfirmedQuit, - setForceQuit, - setGlobalIsQuitting, - setGlobalIsStarting, - setUserConfirmedQuit, - setWasActive, - setWasInFg, -} from "./emain-activity"; import { initIpcHandlers } from "./emain-ipc"; import { log } from "./emain-log"; import { initMenuEventSubscriptions, makeAndSetAppMenu, makeDockTaskbar } from "./emain-menu"; @@ -62,6 +45,53 @@ const electronApp = electron.app; let confirmQuit = true; +// Lifecycle state management +let globalIsQuitting = false; +let globalIsStarting = true; +let globalIsRelaunching = false; +let forceQuit = false; +let userConfirmedQuit = false; + +export function setGlobalIsQuitting(val: boolean) { + globalIsQuitting = val; +} + +export function getGlobalIsQuitting(): boolean { + return globalIsQuitting; +} + +export function setGlobalIsStarting(val: boolean) { + globalIsStarting = val; +} + +export function getGlobalIsStarting(): boolean { + return globalIsStarting; +} + +export function setGlobalIsRelaunching(val: boolean) { + globalIsRelaunching = val; +} + +export function getGlobalIsRelaunching(): boolean { + return globalIsRelaunching; +} + +export function setForceQuit(val: boolean) { + forceQuit = val; +} + +export function getForceQuit(): boolean { + return forceQuit; +} + +export function setUserConfirmedQuit(val: boolean) { + userConfirmedQuit = val; +} + +export function getUserConfirmedQuit(): boolean { + return userConfirmedQuit; +} + const waveDataDir = getWaveDataDir(); const waveConfigDir = getWaveConfigDir(); @@ -121,125 +151,6 @@ function handleWSEvent(evtMsg: WSEventType) { }); } -// we try to set the primary display as index [0] -function getActivityDisplays(): ActivityDisplayType[] { - const displays = electron.screen.getAllDisplays(); - const primaryDisplay = electron.screen.getPrimaryDisplay(); - const rtn: ActivityDisplayType[] = []; - for (const display of displays) { - const adt = { - width: display.size.width, - height: display.size.height, - dpr: display.scaleFactor, - internal: display.internal, - }; - if (display.id === primaryDisplay?.id) { - rtn.unshift(adt); - } else { - rtn.push(adt); - } - } - return rtn; -} - -async function sendDisplaysTDataEvent() { - const displays = getActivityDisplays(); - if (displays.length === 0) { - return; - } - const props: TEventProps = {}; - props["display:count"] = displays.length; - props["display:height"] = displays[0].height; - props["display:width"] = displays[0].width; - props["display:dpr"] = displays[0].dpr; - props["display:all"] = displays; - try { - await RpcApi.RecordTEventCommand( - ElectronWshClient, - { - event: "app:display", - props, - }, - { noresponse: true } - ); - } catch (e) { - console.log("error sending display tdata event", e); - } -} - -function logActiveState() { - fireAndForget(async () => { - const astate = getActivityState(); - const activity: ActivityUpdate = { openminutes: 1 }; - const ww = focusedWaveWindow; - const activeTabView = ww?.activeTabView; - const isWaveAIOpen = activeTabView?.isWaveAIOpen ?? false; - - if (astate.wasInFg) { - activity.fgminutes = 1; - } - if (astate.wasActive) { - activity.activeminutes = 1; - } - activity.displays = getActivityDisplays(); - - const termCmdCount = getAndClearTermCommandsRun(); - if (termCmdCount > 0) { - activity.termcommandsrun = termCmdCount; - } - const termCmdRemoteCount = getAndClearTermCommandsRemote(); - const termCmdWslCount = getAndClearTermCommandsWsl(); - const termCmdDurableCount = getAndClearTermCommandsDurable(); - - const props: TEventProps = { - "activity:activeminutes": activity.activeminutes, - "activity:fgminutes": activity.fgminutes, - "activity:openminutes": activity.openminutes, - }; - if (termCmdCount > 0) { - props["activity:termcommandsrun"] = termCmdCount; - } - if (termCmdRemoteCount > 0) { - props["activity:termcommands:remote"] = termCmdRemoteCount; - } - if (termCmdWslCount > 0) { - props["activity:termcommands:wsl"] = termCmdWslCount; - } - if (termCmdDurableCount > 0) { - props["activity:termcommands:durable"] = termCmdDurableCount; - } - if (astate.wasActive && isWaveAIOpen) { - props["activity:waveaiactiveminutes"] = 1; - } - if (astate.wasInFg && isWaveAIOpen) { - props["activity:waveaifgminutes"] = 1; - } - - try { - await RpcApi.ActivityCommand(ElectronWshClient, activity, { noresponse: true }); - await RpcApi.RecordTEventCommand( - ElectronWshClient, - { - event: "app:activity", - props, - }, - { noresponse: true } - ); - } catch (e) { - console.log("error logging active state", e); - } finally { - setWasInFg(ww?.isFocused() ?? false); - setWasActive(false); - } - }); -} - -// this isn't perfect, but gets the job done without being complicated -function runActiveTimer() { - logActiveState(); - setTimeout(runActiveTimer, 60000); -} - function hideWindowWithCatch(window: WaveBrowserWindow) { if (window == null) { return; @@ -265,12 +176,11 @@ electronApp.on("window-all-closed", () => { }); electronApp.on("before-quit", (e) => { const allWindows = getAllWaveWindows(); - const allBuilders = getAllBuilderWindows(); if ( confirmQuit && !getForceQuit() && !getUserConfirmedQuit() && - (allWindows.length > 0 || allBuilders.length > 0) && + allWindows.length > 0 && !getIsWaveSrvDead() && !process.env.WAVETERM_NOCONFIRMQUIT ) { @@ -306,9 +216,6 @@ electronApp.on("before-quit", (e) => { for (const window of allWindows) { hideWindowWithCatch(window); } - for (const builder of allBuilders) { - builder.hide(); - } if (getIsWaveSrvDead()) { console.log("wavesrv is dead, quitting immediately"); setForceQuit(true); @@ -358,16 +265,13 @@ process.on("uncaughtException", (error) => { }); let lastWaveWindowCount = 0; -let lastIsBuilderWindowActive = false; globalEvents.on("windows-updated", () => { const wwCount = getAllWaveWindows().length; - const isBuilderActive = focusedBuilderWindow != null; - if (wwCount == lastWaveWindowCount && isBuilderActive == lastIsBuilderWindowActive) { + if (wwCount == lastWaveWindowCount) { return; } lastWaveWindowCount = wwCount; - lastIsBuilderWindowActive = isBuilderActive; - console.log("windows-updated", wwCount, "builder-active:", isBuilderActive); + console.log("windows-updated", wwCount); makeAndSetAppMenu(); }); @@ -416,8 +320,6 @@ async function appMain() { } ensureHotSpareTab(fullConfig); await relaunchBrowserWindows(); - setTimeout(runActiveTimer, 5000); // start active timer, wait 5s just to be safe - setTimeout(sendDisplaysTDataEvent, 5000); makeAndSetAppMenu(); makeDockTaskbar(); diff --git a/emain/preload-webview.ts b/emain/preload-webview.ts deleted file mode 100644 index e2a39a3b4e..0000000000 --- a/emain/preload-webview.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { ipcRenderer } from "electron"; - -document.addEventListener("contextmenu", (event) => { - console.log("contextmenu event", event); - if (event.target == null) { - return; - } - const targetElement = event.target as HTMLElement; - // Check if the right-click is on an image - if (targetElement.tagName === "IMG") { - setTimeout(() => { - if (event.defaultPrevented) { - return; - } - event.preventDefault(); - const imgElem = targetElement as HTMLImageElement; - const imageUrl = imgElem.src; - ipcRenderer.send("webview-image-contextmenu", { src: imageUrl }); - }, 50); - return; - } - // do nothing -}); - -document.addEventListener("mouseup", (event) => { - // Mouse button 3 = back, button 4 = forward - if (!event.isTrusted) { - return; - } - if (event.button === 3 || event.button === 4) { - event.preventDefault(); - ipcRenderer.send("webview-mouse-navigate", event.button === 3 ? "back" : "forward"); - } -}); - -console.log("loaded wave preload-webview.ts"); diff --git a/emain/preload.ts b/emain/preload.ts index 8d2b18a308..f5ebc93ffe 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -1,7 +1,7 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { contextBridge, ipcRenderer, Rectangle, webUtils, WebviewTag } from "electron"; +import { contextBridge, ipcRenderer, Rectangle, webUtils } from "electron"; // update type in custom.d.ts (ElectronApi type) contextBridge.exposeInMainWorld("api", { @@ -15,11 +15,9 @@ contextBridge.exposeInMainWorld("api", { getConfigDir: () => ipcRenderer.sendSync("get-config-dir"), getHomeDir: () => ipcRenderer.sendSync("get-home-dir"), getAboutModalDetails: () => ipcRenderer.sendSync("get-about-modal-details"), - getWebviewPreload: () => ipcRenderer.sendSync("get-webview-preload"), getZoomFactor: () => ipcRenderer.sendSync("get-zoom-factor"), openNewWindow: () => ipcRenderer.send("open-new-window"), showWorkspaceAppMenu: (workspaceId) => ipcRenderer.send("workspace-appmenu-show", workspaceId), - showBuilderAppMenu: (builderId) => ipcRenderer.send("builder-appmenu-show", builderId), showContextMenu: (workspaceId, menu) => ipcRenderer.send("contextmenu-show", workspaceId, menu), onContextMenuClick: (callback: (id: string | null) => void) => ipcRenderer.on("contextmenu-click", (_event, id: string | null) => callback(id)), @@ -43,8 +41,6 @@ contextBridge.exposeInMainWorld("api", { onMenuItemAbout: (callback) => ipcRenderer.on("menu-item-about", callback), updateWindowControlsOverlay: (rect) => ipcRenderer.send("update-window-controls-overlay", rect), onReinjectKey: (callback) => ipcRenderer.on("reinject-key", (_event, waveEvent) => callback(waveEvent)), - setWebviewFocus: (focused: number) => ipcRenderer.send("webview-focus", focused), - registerGlobalWebviewKeys: (keys) => ipcRenderer.send("register-global-webview-keys", keys), onControlShiftStateUpdate: (callback) => ipcRenderer.on("control-shift-state-update", (_event, state) => callback(state)), createWorkspace: () => ipcRenderer.send("create-workspace"), @@ -55,34 +51,15 @@ contextBridge.exposeInMainWorld("api", { closeTab: (workspaceId, tabId, confirmClose) => ipcRenderer.invoke("close-tab", workspaceId, tabId, confirmClose), setWindowInitStatus: (status) => ipcRenderer.send("set-window-init-status", status), onWaveInit: (callback) => ipcRenderer.on("wave-init", (_event, initOpts) => callback(initOpts)), - onBuilderInit: (callback) => ipcRenderer.on("builder-init", (_event, initOpts) => callback(initOpts)), sendLog: (log) => ipcRenderer.send("fe-log", log), onQuicklook: (filePath: string) => ipcRenderer.send("quicklook", filePath), openNativePath: (filePath: string) => ipcRenderer.send("open-native-path", filePath), captureScreenshot: (rect: Rectangle) => ipcRenderer.invoke("capture-screenshot", rect), setKeyboardChordMode: () => ipcRenderer.send("set-keyboard-chord-mode"), - clearWebviewStorage: (webContentsId: number) => ipcRenderer.invoke("clear-webview-storage", webContentsId), setWaveAIOpen: (isOpen: boolean) => ipcRenderer.send("set-waveai-open", isOpen), - closeBuilderWindow: () => ipcRenderer.send("close-builder-window"), - incrementTermCommands: (opts?: { isRemote?: boolean; isWsl?: boolean; isDurable?: boolean }) => - ipcRenderer.send("increment-term-commands", opts), nativePaste: () => ipcRenderer.send("native-paste"), - openBuilder: (appId?: string) => ipcRenderer.send("open-builder", appId), - setBuilderWindowAppId: (appId: string) => ipcRenderer.send("set-builder-window-appid", appId), doRefresh: () => ipcRenderer.send("do-refresh"), getPathForFile: (file: File): string => webUtils.getPathForFile(file), saveTextFile: (fileName: string, content: string) => ipcRenderer.invoke("save-text-file", fileName, content), - setIsActive: () => ipcRenderer.invoke("set-is-active"), }); -// Custom event for "new-window" -ipcRenderer.on("webview-new-window", (e, webContentsId, details) => { - const event = new CustomEvent("new-window", { detail: details }); - document.getElementById("webview").dispatchEvent(event); -}); - -ipcRenderer.on("webcontentsid-from-blockid", (e, blockId, responseCh) => { - const webviewElem: WebviewTag = document.querySelector("div[data-blockid='" + blockId + "'] webview"); - const wcId = webviewElem?.dataset?.webcontentsid; - ipcRenderer.send(responseCh, wcId); -}); diff --git a/emain/updater.ts b/emain/updater.ts index 8f06e6bec7..c1c654f017 100644 --- a/emain/updater.ts +++ b/emain/updater.ts @@ -9,7 +9,7 @@ import YAML from "yaml"; import { RpcApi } from "../frontend/app/store/wshclientapi"; import { isDev } from "../frontend/util/isdev"; import { fireAndForget } from "../frontend/util/util"; -import { setUserConfirmedQuit } from "./emain-activity"; +import { setUserConfirmedQuit } from "./emain"; import { delay } from "./emain-util"; import { focusedWaveWindow, getAllWaveWindows } from "./emain-window"; import { ElectronWshClient } from "./emain-wsh"; diff --git a/frontend/app/aipanel/ai-token-utils.ts b/frontend/app/aipanel/ai-token-utils.ts new file mode 100644 index 0000000000..250bdfdc39 --- /dev/null +++ b/frontend/app/aipanel/ai-token-utils.ts @@ -0,0 +1,29 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { WaveUIMessage } from "./aitypes"; + +export function estimateTokensFromMessages(messages: WaveUIMessage[]): number { + let totalChars = 0; + for (const msg of messages) { + if (!msg.parts) continue; + for (const part of msg.parts) { + if (part.type === "text" && part.text) { + totalChars += part.text.length; + } else if (part.type === "reasoning" && part.text) { + totalChars += part.text.length; + } else if (part.type?.startsWith("tool-")) { + const toolPart = part as any; + const toolText = JSON.stringify(toolPart.input ?? toolPart.output ?? ""); + totalChars += toolText.length; + } else if (part.type === "data-tooluse") { + const toolUseData = (part as any).data; + if (toolUseData?.output) { + totalChars += toolUseData.output.length; + } + } + } + } + // Rough estimate: ~4 characters per token (conservative for mixed content) + return Math.ceil(totalChars / 4); +} diff --git a/frontend/app/aipanel/ai-utils.ts b/frontend/app/aipanel/ai-utils.ts index 8bfd67bdc0..1477db6af5 100644 --- a/frontend/app/aipanel/ai-utils.ts +++ b/frontend/app/aipanel/ai-utils.ts @@ -1,8 +1,6 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { sortByDisplayOrder } from "@/util/util"; - const TextFileLimit = 200 * 1024; // 200KB const PdfLimit = 5 * 1024 * 1024; // 5MB const ImageLimit = 10 * 1024 * 1024; // 10MB @@ -531,68 +529,3 @@ export const createImagePreview = async (file: File): Promise => img.src = url; }); }; - - -/** - * Filter and organize AI mode configs into Wave and custom provider groups - * Returns organized configs that should be displayed based on settings and premium status - */ -export interface FilteredAIModeConfigs { - waveProviderConfigs: Array<{ mode: string } & AIModeConfigType>; - otherProviderConfigs: Array<{ mode: string } & AIModeConfigType>; - shouldShowCloudModes: boolean; -} - -export const getFilteredAIModeConfigs = ( - aiModeConfigs: Record, - showCloudModes: boolean, - inBuilder: boolean, - hasPremium: boolean, - currentMode?: string -): FilteredAIModeConfigs => { - const hideQuick = inBuilder && hasPremium; - - const allConfigs = Object.entries(aiModeConfigs) - .map(([mode, config]) => ({ mode, ...config })) - .filter((config) => !(hideQuick && config.mode === "waveai@quick")); - - const otherProviderConfigs = allConfigs - .filter((config) => config["ai:provider"] !== "wave") - .sort(sortByDisplayOrder); - - const hasCustomModels = otherProviderConfigs.length > 0; - const isCurrentModeCloud = currentMode?.startsWith("waveai@") ?? false; - const shouldShowCloudModes = showCloudModes || !hasCustomModels || isCurrentModeCloud; - - const waveProviderConfigs = shouldShowCloudModes - ? allConfigs.filter((config) => config["ai:provider"] === "wave").sort(sortByDisplayOrder) - : []; - - return { - waveProviderConfigs, - otherProviderConfigs, - shouldShowCloudModes, - }; -}; - -/** - * Get the display name for an AI mode configuration. - * If display:name is set, use that. Otherwise, construct from model/provider. - * For azure-legacy, show "azureresourcename (azure)". - * For other providers, show "model (provider)". - */ -export function getModeDisplayName(config: AIModeConfigType): string { - if (config["display:name"]) { - return config["display:name"]; - } - - const provider = config["ai:provider"]; - const model = config["ai:model"]; - const azureResourceName = config["ai:azureresourcename"]; - - if (provider === "azure-legacy") { - return `${azureResourceName || "unknown"} (azure)`; - } - - return `${model || "unknown"} (${provider || "custom"})`; -} diff --git a/frontend/app/aipanel/aicontextwindowindicator.tsx b/frontend/app/aipanel/aicontextwindowindicator.tsx new file mode 100644 index 0000000000..ba5862bf97 --- /dev/null +++ b/frontend/app/aipanel/aicontextwindowindicator.tsx @@ -0,0 +1,72 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { useAtomValue } from "jotai"; +import { memo, useMemo } from "react"; +import { estimateTokensFromMessages } from "./ai-token-utils"; +import { WaveUIMessage } from "./aitypes"; +import { WaveAIModel } from "./waveai-model"; + +interface AIContextWindowIndicatorProps { + messages: WaveUIMessage[]; +} + +const DefaultContextWindow = 128_000; + +function formatTokenCount(count: number): string { + if (count >= 1_000_000) { + return `${(count / 1_000_000).toFixed(1)}M`; + } + if (count >= 1_000) { + return `${(count / 1_000).toFixed(1)}k`; + } + return String(count); +} + +export const AIContextWindowIndicator = memo(({ messages }: AIContextWindowIndicatorProps) => { + const model = WaveAIModel.getInstance(); + const aiModelConfigs = useAtomValue(model.aiModelConfigs); + const currentModel = useAtomValue(model.currentAIModel); + + const { usedTokens, maxTokens, percentage } = useMemo(() => { + const used = estimateTokensFromMessages(messages); + const config = aiModelConfigs?.[currentModel]; + const max = config?.["ai:contextwindow"] && config["ai:contextwindow"] > 0 + ? config["ai:contextwindow"] + : DefaultContextWindow; + const pct = Math.min((used / max) * 100, 100); + return { usedTokens: used, maxTokens: max, percentage: pct }; + }, [messages, aiModelConfigs, currentModel]); + + const barColor = useMemo(() => { + if (percentage >= 90) return "bg-red-500"; + if (percentage >= 70) return "bg-yellow-500"; + return "bg-accent"; + }, [percentage]); + + const textColor = useMemo(() => { + if (percentage >= 90) return "text-red-400"; + if (percentage >= 70) return "text-yellow-400"; + return "text-gray-400"; + }, [percentage]); + + if (messages.length === 0) { + return null; + } + + return ( +
+
+
+
+ + {formatTokenCount(usedTokens)} / {formatTokenCount(maxTokens)} + +
+ ); +}); + +AIContextWindowIndicator.displayName = "AIContextWindowIndicator"; diff --git a/frontend/app/aipanel/aifeedbackbuttons.tsx b/frontend/app/aipanel/aifeedbackbuttons.tsx index 30d9accc07..ff6719e70f 100644 --- a/frontend/app/aipanel/aifeedbackbuttons.tsx +++ b/frontend/app/aipanel/aifeedbackbuttons.tsx @@ -3,7 +3,6 @@ import { cn, makeIconClass } from "@/util/util"; import { memo, useState } from "react"; -import { WaveAIModel } from "./waveai-model"; interface AIFeedbackButtonsProps { messageText: string; @@ -19,9 +18,6 @@ export const AIFeedbackButtons = memo(({ messageText }: AIFeedbackButtonsProps) if (thumbsDownClicked) { setThumbsDownClicked(false); } - if (!thumbsUpClicked) { - WaveAIModel.getInstance().handleAIFeedback("good"); - } }; const handleThumbsDown = () => { @@ -29,9 +25,6 @@ export const AIFeedbackButtons = memo(({ messageText }: AIFeedbackButtonsProps) if (thumbsUpClicked) { setThumbsUpClicked(false); } - if (!thumbsDownClicked) { - WaveAIModel.getInstance().handleAIFeedback("bad"); - } }; const handleCopy = () => { diff --git a/frontend/app/aipanel/aimode.tsx b/frontend/app/aipanel/aimode.tsx index 3602cdd360..4ec747c599 100644 --- a/frontend/app/aipanel/aimode.tsx +++ b/frontend/app/aipanel/aimode.tsx @@ -2,183 +2,65 @@ // SPDX-License-Identifier: Apache-2.0 import { Tooltip } from "@/app/element/tooltip"; -import { atoms, getSettingsKeyAtom } from "@/app/store/global"; -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { cn, fireAndForget, makeIconClass } from "@/util/util"; +import { cn, fireAndForget, makeIconClass, sortByDisplayOrder } from "@/util/util"; import { useAtomValue } from "jotai"; -import { memo, useRef, useState } from "react"; -import { getFilteredAIModeConfigs, getModeDisplayName } from "./ai-utils"; +import { memo, useMemo, useRef, useState } from "react"; import { WaveAIModel } from "./waveai-model"; interface AIModeMenuItemProps { - config: AIModeConfigWithMode; + modeKey: string; + config: AIModeConfigType; isSelected: boolean; - isDisabled: boolean; - isPremiumDisabled: boolean; onClick: () => void; - isFirst?: boolean; - isLast?: boolean; } -const AIModeMenuItem = memo(({ config, isSelected, isDisabled, isPremiumDisabled, onClick, isFirst, isLast }: AIModeMenuItemProps) => { +const AIModeMenuItem = memo(({ modeKey, config, isSelected, onClick }: AIModeMenuItemProps) => { + const color = config["display:color"]; return ( ); }); - AIModeMenuItem.displayName = "AIModeMenuItem"; -interface ConfigSection { - sectionName: string; - configs: AIModeConfigWithMode[]; - isIncompatible?: boolean; - noTelemetry?: boolean; -} - -function computeCompatibleSections( - currentMode: string, - aiModeConfigs: Record, - waveProviderConfigs: AIModeConfigWithMode[], - otherProviderConfigs: AIModeConfigWithMode[] -): ConfigSection[] { - const currentConfig = aiModeConfigs[currentMode]; - const allConfigs = [...waveProviderConfigs, ...otherProviderConfigs]; - - if (!currentConfig) { - return [{ sectionName: "Incompatible Modes", configs: allConfigs, isIncompatible: true }]; - } - - const currentSwitchCompat = currentConfig["ai:switchcompat"] || []; - const compatibleConfigs: AIModeConfigWithMode[] = [{ ...currentConfig, mode: currentMode }]; - const incompatibleConfigs: AIModeConfigWithMode[] = []; - - if (currentSwitchCompat.length === 0) { - allConfigs.forEach((config) => { - if (config.mode !== currentMode) { - incompatibleConfigs.push(config); - } - }); - } else { - allConfigs.forEach((config) => { - if (config.mode === currentMode) return; - - const configSwitchCompat = config["ai:switchcompat"] || []; - const hasMatch = currentSwitchCompat.some((currentTag: string) => configSwitchCompat.includes(currentTag)); - - if (hasMatch) { - compatibleConfigs.push(config); - } else { - incompatibleConfigs.push(config); - } - }); - } - - const sections: ConfigSection[] = []; - const compatibleSectionName = compatibleConfigs.length === 1 ? "Current" : "Compatible Modes"; - sections.push({ sectionName: compatibleSectionName, configs: compatibleConfigs }); - - if (incompatibleConfigs.length > 0) { - sections.push({ sectionName: "Incompatible Modes", configs: incompatibleConfigs, isIncompatible: true }); - } - - return sections; -} - -function computeWaveCloudSections( - waveProviderConfigs: AIModeConfigWithMode[], - otherProviderConfigs: AIModeConfigWithMode[], - telemetryEnabled: boolean -): ConfigSection[] { - const sections: ConfigSection[] = []; - - if (waveProviderConfigs.length > 0) { - sections.push({ - sectionName: "Wave AI Cloud", - configs: waveProviderConfigs, - noTelemetry: !telemetryEnabled, - }); - } - if (otherProviderConfigs.length > 0) { - sections.push({ sectionName: "Custom", configs: otherProviderConfigs }); - } - - return sections; -} - -interface AIModeDropdownProps { - compatibilityMode?: boolean; -} - -export const AIModeDropdown = memo(({ compatibilityMode = false }: AIModeDropdownProps) => { +export const AIModeDropdown = memo(() => { const model = WaveAIModel.getInstance(); const currentMode = useAtomValue(model.currentAIMode); const aiModeConfigs = useAtomValue(model.aiModeConfigs); - const waveaiModeConfigs = useAtomValue(atoms.waveaiModeConfigAtom); - const widgetContextEnabled = useAtomValue(model.widgetAccessAtom); - const hasPremium = useAtomValue(model.hasPremiumAtom); - const showCloudModes = useAtomValue(getSettingsKeyAtom("waveai:showcloudmodes")); - const telemetryEnabled = useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false; const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); - const { waveProviderConfigs, otherProviderConfigs } = getFilteredAIModeConfigs( - aiModeConfigs, - showCloudModes, - model.inBuilder, - hasPremium, - currentMode - ); - - const sections: ConfigSection[] = compatibilityMode - ? computeCompatibleSections(currentMode, aiModeConfigs, waveProviderConfigs, otherProviderConfigs) - : computeWaveCloudSections(waveProviderConfigs, otherProviderConfigs, telemetryEnabled); - - const showSectionHeaders = compatibilityMode || sections.length > 1; + const modeEntries = useMemo(() => { + if (aiModeConfigs == null) return []; + return Object.entries(aiModeConfigs) + .map(([key, cfg]) => ({ key, ...cfg })) + .sort(sortByDisplayOrder); + }, [aiModeConfigs]); - const handleSelect = (mode: string) => { - const config = aiModeConfigs[mode]; - if (!config) return; - if (!hasPremium && config["waveai:premium"]) { - return; - } - model.setAIMode(mode); + const handleSelect = (key: string) => { + model.setAIMode(key); setIsOpen(false); }; - const displayConfig = aiModeConfigs[currentMode]; - const displayName = displayConfig ? getModeDisplayName(displayConfig) : `Invalid (${currentMode})`; - const displayIcon = displayConfig ? displayConfig["display:icon"] || "sparkles" : "question"; - const resolvedConfig = waveaiModeConfigs[currentMode]; - const hasToolsSupport = resolvedConfig && resolvedConfig["ai:capabilities"]?.includes("tools"); - const showNoToolsWarning = widgetContextEnabled && resolvedConfig && !hasToolsSupport; - const handleNewChatClick = () => { model.clearChat(); setIsOpen(false); @@ -186,124 +68,55 @@ export const AIModeDropdown = memo(({ compatibilityMode = false }: AIModeDropdow const handleConfigureClick = () => { fireAndForget(async () => { - RpcApi.RecordTEventCommand( - TabRpcClient, - { - event: "action:other", - props: { - "action:type": "waveai:configuremodes:contextmenu", - }, - }, - { noresponse: true } - ); await model.openWaveAIConfig(); setIsOpen(false); }); }; - const handleEnableTelemetry = () => { - fireAndForget(async () => { - await RpcApi.WaveAIEnableTelemetryCommand(TabRpcClient); - setTimeout(() => { - model.focusInput(); - }, 100); - }); - }; + const displayConfig = aiModeConfigs?.[currentMode]; + const displayName = displayConfig?.["display:name"] || `Invalid (${currentMode})`; + const displayIcon = displayConfig?.["display:icon"] || "question"; + const displayColor = displayConfig?.["display:color"]; return (
- - - {showNoToolsWarning && ( - - Warning: This custom mode was configured without the "tools" capability in the - "ai:capabilities" array. Without tool support, Wave AI will not be able to interact with - widgets or files. -
- } - placement="bottom" + + + {isOpen && ( <>
setIsOpen(false)} /> -
- {sections.map((section, sectionIndex) => { - const isFirstSection = sectionIndex === 0; - const isLastSection = sectionIndex === sections.length - 1; - - return ( -
- {!isFirstSection &&
} - {showSectionHeaders && ( - <> -
- {section.sectionName} -
- {section.isIncompatible && ( -
- (Start a New Chat to Switch) -
- )} - {section.noTelemetry && ( - - )} - - )} - {section.configs.map((config, index) => { - const isFirst = index === 0 && isFirstSection && !showSectionHeaders; - const isLast = index === section.configs.length - 1 && isLastSection; - const isPremiumDisabled = !hasPremium && config["waveai:premium"]; - const isIncompatibleDisabled = section.isIncompatible || false; - const isTelemetryDisabled = section.noTelemetry || false; - const isDisabled = - isPremiumDisabled || isIncompatibleDisabled || isTelemetryDisabled; - const isSelected = currentMode === config.mode; - return ( - handleSelect(config.mode)} - isFirst={isFirst} - isLast={isLast} - /> - ); - })} -
- ); - })} +
+
+ Mode +
+ {modeEntries.length === 0 && ( +
No modes configured
+ )} + {modeEntries.map(({ key, ...cfg }) => ( + handleSelect(key)} + /> + ))}
+ ); + } +); +AIModelMenuItem.displayName = "AIModelMenuItem"; + +export const AIModelDropdown = memo(() => { + const model = WaveAIModel.getInstance(); + const currentModel = useAtomValue(model.currentAIModel); + const aiModelConfigs = useAtomValue(model.aiModelConfigs); + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const modelEntries = useMemo(() => { + if (aiModelConfigs == null) return []; + return Object.entries(aiModelConfigs) + .map(([key, cfg]) => ({ key, ...cfg })) + .sort(sortByDisplayOrder); + }, [aiModelConfigs]); + + const currentCfg = aiModelConfigs?.[currentModel]; + const displayName = currentCfg?.["display:name"] || currentModel || "Model"; + const displayIcon = currentCfg?.["display:icon"] || "sparkles"; + + const handleSelect = (key: string) => { + model.setAIModel(key); + setIsOpen(false); + }; + + return ( +
+ + + + + {isOpen && ( + <> +
setIsOpen(false)} /> +
+
+ Model +
+ {modelEntries.length === 0 && ( +
No models configured
+ )} + {modelEntries.map(({ key, ...cfg }) => ( + handleSelect(key)} + /> + ))} +
+ + )} +
+ ); +}); +AIModelDropdown.displayName = "AIModelDropdown"; diff --git a/frontend/app/aipanel/aipanel-contextmenu.ts b/frontend/app/aipanel/aipanel-contextmenu.ts index 4e78389198..4954ac67bf 100644 --- a/frontend/app/aipanel/aipanel-contextmenu.ts +++ b/frontend/app/aipanel/aipanel-contextmenu.ts @@ -39,86 +39,58 @@ export async function handleWaveAIContextMenu(e: React.MouseEvent, showCopy: boo oref: model.orefContext, }); - const defaultTokens = model.inBuilder ? 24576 : 4096; - const currentMaxTokens = rtInfo?.["waveai:maxoutputtokens"] ?? defaultTokens; + const currentMaxTokens = rtInfo?.["waveai:maxoutputtokens"] ?? 4096; const maxTokensSubmenu: ContextMenuItem[] = []; - if (model.inBuilder) { - maxTokensSubmenu.push( - { - label: "24k", - type: "checkbox", - checked: currentMaxTokens === 24576, - click: () => { - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: model.orefContext, - data: { "waveai:maxoutputtokens": 24576 }, - }); - }, + if (isDev()) { + maxTokensSubmenu.push({ + label: "1k (Dev Testing)", + type: "checkbox", + checked: currentMaxTokens === 1024, + click: () => { + RpcApi.SetRTInfoCommand(TabRpcClient, { + oref: model.orefContext, + data: { "waveai:maxoutputtokens": 1024 }, + }); }, - { - label: "64k (Pro)", - type: "checkbox", - checked: currentMaxTokens === 65536, - click: () => { - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: model.orefContext, - data: { "waveai:maxoutputtokens": 65536 }, - }); - }, - } - ); - } else { - if (isDev()) { - maxTokensSubmenu.push({ - label: "1k (Dev Testing)", - type: "checkbox", - checked: currentMaxTokens === 1024, - click: () => { - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: model.orefContext, - data: { "waveai:maxoutputtokens": 1024 }, - }); - }, - }); - } - maxTokensSubmenu.push( - { - label: "4k", - type: "checkbox", - checked: currentMaxTokens === 4096, - click: () => { - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: model.orefContext, - data: { "waveai:maxoutputtokens": 4096 }, - }); - }, + }); + } + maxTokensSubmenu.push( + { + label: "4k", + type: "checkbox", + checked: currentMaxTokens === 4096, + click: () => { + RpcApi.SetRTInfoCommand(TabRpcClient, { + oref: model.orefContext, + data: { "waveai:maxoutputtokens": 4096 }, + }); }, - { - label: "16k (Pro)", - type: "checkbox", - checked: currentMaxTokens === 16384, - click: () => { - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: model.orefContext, - data: { "waveai:maxoutputtokens": 16384 }, - }); - }, + }, + { + label: "16k (Pro)", + type: "checkbox", + checked: currentMaxTokens === 16384, + click: () => { + RpcApi.SetRTInfoCommand(TabRpcClient, { + oref: model.orefContext, + data: { "waveai:maxoutputtokens": 16384 }, + }); }, - { - label: "64k (Pro)", - type: "checkbox", - checked: currentMaxTokens === 65536, - click: () => { - RpcApi.SetRTInfoCommand(TabRpcClient, { - oref: model.orefContext, - data: { "waveai:maxoutputtokens": 65536 }, - }); - }, - } - ); - } + }, + { + label: "64k (Pro)", + type: "checkbox", + checked: currentMaxTokens === 65536, + click: () => { + RpcApi.SetRTInfoCommand(TabRpcClient, { + oref: model.orefContext, + data: { "waveai:maxoutputtokens": 65536 }, + }); + }, + } + ); menu.push({ label: "Max Output Tokens", @@ -127,28 +99,11 @@ export async function handleWaveAIContextMenu(e: React.MouseEvent, showCopy: boo menu.push({ type: "separator" }); - menu.push({ - label: "Configure Modes", - click: () => { - RpcApi.RecordTEventCommand( - TabRpcClient, - { - event: "action:other", - props: { - "action:type": "waveai:configuremodes:contextmenu", - }, - }, - { noresponse: true } - ); - model.openWaveAIConfig(); - }, - }); - if (model.canCloseWaveAIPanel()) { menu.push({ type: "separator" }); menu.push({ - label: "Hide Wave AI", + label: "Hide Assistant", click: () => { model.closeWaveAIPanel(); }, diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 32b8582141..e8d00eabab 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -8,7 +8,6 @@ import { ErrorBoundary } from "@/app/element/errorboundary"; import { atoms, getSettingsKeyAtom } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; import { useTabModelMaybe } from "@/app/store/tab-model"; -import { isBuilderWindow } from "@/app/store/windowtype"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import { checkKeyPressed, keydownWrapper } from "@/util/keyutil"; import { isMacOS, isWindows } from "@/util/platformutil"; @@ -21,13 +20,14 @@ import { useDrop } from "react-dnd"; import { formatFileSizeError, isAcceptableFile, validateFileSize } from "./ai-utils"; import { AIDroppedFiles } from "./aidroppedfiles"; import { AIModeDropdown } from "./aimode"; +import { AIModelDropdown } from "./aimodel-dropdown"; import { AIPanelHeader } from "./aipanelheader"; import { AIPanelInput } from "./aipanelinput"; import { AIPanelMessages } from "./aipanelmessages"; +import { AIQueuedMessage } from "./aiqueuedmessage"; import { AIRateLimitStrip } from "./airatelimitstrip"; import { WaveUIMessage } from "./aitypes"; import { BYOKAnnouncement } from "./byokannouncement"; -import { TelemetryRequiredMessage } from "./telemetryrequired"; import { WaveAIModel } from "./waveai-model"; const AIBlockMask = memo(() => { @@ -88,32 +88,22 @@ KeyCap.displayName = "KeyCap"; const AIWelcomeMessage = memo(() => { const modKey = isMacOS() ? "⌘" : "Alt"; - const aiModeConfigs = jotai.useAtomValue(atoms.waveaiModeConfigAtom); - const hasCustomModes = Object.keys(aiModeConfigs).some((key) => !key.startsWith("waveai@")); + const aiModelConfigs = jotai.useAtomValue(atoms.waveaiModelConfigAtom); + const hasCustomModels = aiModelConfigs != null && Object.keys(aiModelConfigs).length > 0; return (
-

Welcome to Wave AI

+

Welcome to Assistant

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

Getting Started:
-
-
- -
-
- Widget Context -
When ON, I can read your terminal and analyze widgets.
-
When OFF, I'm sandboxed with no system access.
-
-
@@ -154,28 +144,9 @@ const AIWelcomeMessage = memo(() => {
-
-
- -
-
- Questions or feedback?{" "} - - Join our Discord - -
-
- {!hasCustomModes && } -
- BETA: Free to use. Daily limits keep our costs in check. -
+ {!hasCustomModels && }
); @@ -183,24 +154,6 @@ const AIWelcomeMessage = memo(() => { AIWelcomeMessage.displayName = "AIWelcomeMessage"; -const AIBuilderWelcomeMessage = memo(() => { - return ( -
-
- -

WaveApp Builder

-
-
-

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

-
-
- ); -}); - -AIBuilderWelcomeMessage.displayName = "AIBuilderWelcomeMessage"; - const AIErrorMessage = memo(() => { const model = WaveAIModel.getInstance(); const errorMessage = jotai.useAtomValue(model.errorMessage); @@ -235,12 +188,11 @@ AIErrorMessage.displayName = "AIErrorMessage"; const ConfigChangeModeFixer = memo(() => { const model = WaveAIModel.getInstance(); - const telemetryEnabled = jotai.useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false; const aiModeConfigs = jotai.useAtomValue(model.aiModeConfigs); useEffect(() => { model.fixModeAfterConfigChange(); - }, [telemetryEnabled, aiModeConfigs, model]); + }, [aiModeConfigs, model]); return null; }); @@ -262,16 +214,13 @@ const AIPanelComponentInner = memo(({ roundTopLeft }: AIPanelComponentInnerProps const showOverlayBlockNums = jotai.useAtomValue(getSettingsKeyAtom("app:showoverlayblocknums")) ?? true; const isFocused = jotai.useAtomValue(model.isWaveAIFocusedAtom); const focusFollowsCursorMode = jotai.useAtomValue(getSettingsKeyAtom("app:focusfollowscursor")) ?? "off"; - const telemetryEnabled = jotai.useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false; const isPanelVisible = jotai.useAtomValue(model.getPanelVisibleAtom()); const tabModel = useTabModelMaybe(); const [tabBorderColor, tabActiveBorderColor] = useTabBackground(waveEnv, tabModel?.tabId); - const defaultMode = jotai.useAtomValue(getSettingsKeyAtom("waveai:defaultmode")) ?? "waveai@balanced"; - const aiModeConfigs = jotai.useAtomValue(model.aiModeConfigs); + const aiModelConfigs = jotai.useAtomValue(model.aiModelConfigs); - const hasCustomModes = Object.keys(aiModeConfigs).some((key) => !key.startsWith("waveai@")); - const isUsingCustomMode = !defaultMode.startsWith("waveai@"); - const allowAccess = telemetryEnabled || (hasCustomModes && isUsingCustomMode); + const hasCustomModels = aiModelConfigs != null && Object.keys(aiModelConfigs).length > 0; + const allowAccess = true; const { messages, sendMessage, status, setMessages, error, stop } = useChat({ transport: new DefaultChatTransport({ @@ -283,19 +232,22 @@ const AIPanelComponentInner = memo(({ roundTopLeft }: AIPanelComponentInnerProps chatid: globalStore.get(model.chatId), widgetaccess: globalStore.get(model.widgetAccessAtom), aimode: globalStore.get(model.currentAIMode), + aimodel: globalStore.get(model.currentAIModel), }; - if (isBuilderWindow()) { - body.builderid = globalStore.get(atoms.builderId); - body.builderappid = globalStore.get(atoms.builderAppId); - } else { - body.tabid = tabModel.tabId; - } + body.tabid = tabModel.tabId; return { body }; }, }), onError: (error) => { + const msg = error.message || ""; + // Suppress spurious "already running" when a queued message is being + // retried — the backend now waits gracefully for the lock. + if (msg.toLowerCase().includes("already running") && globalStore.get(model.hasQueuedMessageAtom)) { + console.log("AI Chat suppressed 'already running' — retrying via queued message"); + return; + } console.error("AI Chat error:", error); - model.setError(error.message || "An error occurred"); + model.setError(msg || "An error occurred"); }, }); @@ -317,6 +269,14 @@ const AIPanelComponentInner = memo(({ roundTopLeft }: AIPanelComponentInnerProps globalStore.set(model.isAIStreaming, status === "streaming" || status === "submitted"); }, [status]); + useEffect(() => { + if (status === "ready" || status === "error") { + if (globalStore.get(model.hasQueuedMessageAtom)) { + model.sendQueuedMessage(); + } + } + }, [status, model]); + useEffect(() => { const keyHandler = keydownWrapper(handleKeyDown); document.addEventListener("keydown", keyHandler); @@ -358,7 +318,7 @@ const AIPanelComponentInner = memo(({ roundTopLeft }: AIPanelComponentInnerProps const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - await model.handleSubmit(); + await model.handleSubmit(status); setTimeout(() => { model.focusInput(); }, 100); @@ -557,15 +517,14 @@ const AIPanelComponentInner = memo(({ roundTopLeft }: AIPanelComponentInnerProps ref={containerRef} data-waveai-panel="true" className={cn( - "@container bg-zinc-900/70 flex flex-col relative", - model.inBuilder ? "mt-0 h-full" : "mt-1 h-[calc(100%-4px)]", + "@container bg-zinc-900/70 flex flex-col relative mt-1 h-[calc(100%-4px)]", (isDragOver || isReactDndDragOver) && "bg-zinc-800 border-accent", isFocused && !borderColor ? "border-2 border-accent" : "border-2 border-transparent" )} style={{ borderTopLeftRadius: roundTopLeft ? 10 : 0, - borderTopRightRadius: model.inBuilder ? 0 : 10, - borderBottomRightRadius: model.inBuilder ? 0 : 10, + borderTopRightRadius: 10, + borderBottomRightRadius: 10, borderBottomLeftRadius: 10, borderColor: borderColor ?? undefined, }} @@ -586,19 +545,17 @@ const AIPanelComponentInner = memo(({ roundTopLeft }: AIPanelComponentInnerProps
- {!allowAccess ? ( - - ) : ( - <> + <> {messages.length === 0 && initialLoadDone ? (
handleWaveAIContextMenu(e, true)} > -
+
+
- {model.inBuilder ? : } +
) : ( - + + - )}
); diff --git a/frontend/app/aipanel/aipanelheader.tsx b/frontend/app/aipanel/aipanelheader.tsx index da54f6c9e9..d93ca7926e 100644 --- a/frontend/app/aipanel/aipanelheader.tsx +++ b/frontend/app/aipanel/aipanelheader.tsx @@ -2,15 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { handleWaveAIContextMenu } from "@/app/aipanel/aipanel-contextmenu"; -import { useAtomValue } from "jotai"; import { memo } from "react"; -import { WaveAIModel } from "./waveai-model"; export const AIPanelHeader = memo(() => { - const model = WaveAIModel.getInstance(); - const widgetAccess = useAtomValue(model.widgetAccessAtom); - const inBuilder = model.inBuilder; - const handleKebabClick = (e: React.MouseEvent) => { handleWaveAIContextMenu(e, false); }; @@ -21,47 +15,15 @@ export const AIPanelHeader = memo(() => { return (
-

+

- Wave AI + Assistant

- {!inBuilder && ( -
- Context - Widget Context - -
- )} - ) : ( - +
+
); diff --git a/frontend/app/aipanel/aipanelmessages.tsx b/frontend/app/aipanel/aipanelmessages.tsx index 478b20e658..27c33d7bdb 100644 --- a/frontend/app/aipanel/aipanelmessages.tsx +++ b/frontend/app/aipanel/aipanelmessages.tsx @@ -5,6 +5,7 @@ import { useAtomValue } from "jotai"; import { memo, useEffect, useRef, useState } from "react"; import { AIMessage } from "./aimessage"; import { AIModeDropdown } from "./aimode"; +import { AIModelDropdown } from "./aimodel-dropdown"; import { type WaveUIMessage } from "./aitypes"; import { WaveAIModel } from "./waveai-model"; @@ -83,9 +84,10 @@ export const AIPanelMessages = memo(({ messages, status, onContextMenu }: AIPane }, [status]); return ( -
-
- +
+
+ +
{messages.map((message, index) => { const isLastMessage = index === messages.length - 1; diff --git a/frontend/app/aipanel/aiqueuedmessage.tsx b/frontend/app/aipanel/aiqueuedmessage.tsx new file mode 100644 index 0000000000..427a7d2d74 --- /dev/null +++ b/frontend/app/aipanel/aiqueuedmessage.tsx @@ -0,0 +1,78 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Tooltip } from "@/element/tooltip"; +import { cn } from "@/util/util"; +import { useAtomValue } from "jotai"; +import { memo } from "react"; +import { WaveAIModel } from "./waveai-model"; + +interface AIQueuedMessageProps { + model: WaveAIModel; +} + +export const AIQueuedMessage = memo(({ model }: AIQueuedMessageProps) => { + const queued = useAtomValue(model.queuedMessageAtom); + + if (queued == null) { + return null; + } + + const text = queued.text || ""; + const fileCount = queued.files.length; + const displayText = text.length > 80 ? text.slice(0, 80) + "…" : text; + + return ( +
+
+
+
+ + + + {displayText} +
+ {fileCount > 0 && ( +
+ {fileCount} file{fileCount > 1 ? "s" : ""} attached +
+ )} +
+ +
+ + + + + + + + + +
+
+
+ ); +}); + +AIQueuedMessage.displayName = "AIQueuedMessage"; diff --git a/frontend/app/aipanel/aitooluse.tsx b/frontend/app/aipanel/aitooluse.tsx index 7868c188e9..b35b3718e7 100644 --- a/frontend/app/aipanel/aitooluse.tsx +++ b/frontend/app/aipanel/aitooluse.tsx @@ -3,7 +3,6 @@ import { BlockModel } from "@/app/block/block-model"; import { Modal } from "@/app/modals/modal"; -import { recordTEvent } from "@/app/store/global"; import { cn, fireAndForget } from "@/util/util"; import { useAtomValue } from "jotai"; import { memo, useEffect, useRef, useState } from "react"; @@ -51,7 +50,7 @@ const ToolDescLine = memo(({ text }: ToolDescLineProps) => { parts.push(displayText.slice(lastIndex)); } - return
{parts.length > 0 ? parts : displayText}
; + return
{parts.length > 0 ? parts : displayText}
; }); ToolDescLine.displayName = "ToolDescLine"; @@ -67,7 +66,7 @@ const ToolDesc = memo(({ text, className }: ToolDescProps) => { if (lines.length === 0) return null; return ( -
+
{lines.map((line, idx) => ( ))} @@ -77,6 +76,75 @@ const ToolDesc = memo(({ text, className }: ToolDescProps) => { ToolDesc.displayName = "ToolDesc"; +const TERM_SEND_INPUT_TOOLNAME = "term_send_input"; +const TERM_SEND_INPUT_DESC_REGEX = /^send to (\S+):\s([\s\S]*?)(\s)?$/; + +interface ParsedTermSendInput { + widgetId: string; + command: string; + pressEnter: boolean; +} + +function parseTermSendInputDesc(desc: string): ParsedTermSendInput | null { + const match = desc.match(TERM_SEND_INPUT_DESC_REGEX); + if (!match) return null; + return { + widgetId: match[1], + command: match[2], + pressEnter: match[3] != null, + }; +} + +function splitCommandByOperators(cmd: string): string[] { + const parts: string[] = []; + const regex = /&&|\|\|/g; + let lastIndex = 0; + let match; + while ((match = regex.exec(cmd)) !== null) { + parts.push(cmd.slice(lastIndex, match.index + match[0].length).trim()); + lastIndex = regex.lastIndex; + } + const remainder = cmd.slice(lastIndex).trim(); + if (remainder) { + parts.push(remainder); + } + return parts; +} + +interface TermSendInputBodyProps { + parsed: ParsedTermSendInput; +} + +const TermSendInputBody = memo(({ parsed }: TermSendInputBodyProps) => { + const parts = splitCommandByOperators(parsed.command); + return ( +
+
+
+                    $ 
+                    {parts.map((part, idx) => (
+                        
+                            {idx > 0 &&   }
+                            {part}
+                            {idx < parts.length - 1 && "\n"}
+                        
+                    ))}
+                
+ {parsed.pressEnter && ( + + ⏎ Enter + + )} +
+
+ ); +}); + +TermSendInputBody.displayName = "TermSendInputBody"; + function getEffectiveApprovalStatus(baseApproval: string, isStreaming: boolean): string { return !isStreaming && baseApproval === "needs-approval" ? "timeout" : baseApproval; } @@ -129,8 +197,8 @@ const AIToolUseBatchItem = memo(({ part, effectiveApproval }: AIToolUseBatchItem return (
{statusIcon} -
- {part.data.tooldesc} +
+ {part.data.tooldesc} {effectiveErrorMessage &&
{effectiveErrorMessage}
}
@@ -206,6 +274,10 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => { const effectiveApproval = getEffectiveApprovalStatus(baseApproval, isStreaming); const isFileWriteTool = toolData.toolname === "write_text_file" || toolData.toolname === "edit_text_file"; + const termSendInputParsed = + toolData.toolname === TERM_SEND_INPUT_TOOLNAME && toolData.tooldesc + ? parseTermSendInputDesc(toolData.tooldesc) + : null; useEffect(() => { return () => { @@ -261,7 +333,6 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => { }; const handleOpenDiff = () => { - recordTEvent("waveai:showdiff"); fireAndForget(() => WaveAIModel.getInstance().openDiff(toolData.inputfilename, toolData.toolcallid)); }; @@ -273,7 +344,16 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => { >
{statusIcon} -
{toolData.toolname}
+
+ {toolData.toolname === TERM_SEND_INPUT_TOOLNAME && termSendInputParsed ? ( + <> + Run in terminal + · {termSendInputParsed.widgetId} + + ) : ( + toolData.toolname + )} +
{isFileWriteTool && toolData.inputfilename && @@ -282,7 +362,6 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => { Date.now() - toolData.runts < BackupRetentionDays * 24 * 60 * 60 * 1000 && ( )}
- {toolData.tooldesc && } + {toolData.toolname === TERM_SEND_INPUT_TOOLNAME && termSendInputParsed ? ( + + ) : ( + toolData.tooldesc && + )} {(toolData.errormessage || effectiveApproval === "timeout") && (
{toolData.errormessage || "Not approved"}
)} diff --git a/frontend/app/aipanel/aitypes.ts b/frontend/app/aipanel/aitypes.ts index fbce463a73..ff41ac8311 100644 --- a/frontend/app/aipanel/aitypes.ts +++ b/frontend/app/aipanel/aitypes.ts @@ -23,6 +23,7 @@ type WaveUIDataTypes = { blockid?: string; writebackupfilename?: string; inputfilename?: string; + output?: string; }; toolprogress: { diff --git a/frontend/app/aipanel/byokannouncement.tsx b/frontend/app/aipanel/byokannouncement.tsx index 935cc4a3b0..d777fd11c6 100644 --- a/frontend/app/aipanel/byokannouncement.tsx +++ b/frontend/app/aipanel/byokannouncement.tsx @@ -1,40 +1,15 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WaveAIModel } from "./waveai-model"; const BYOKAnnouncement = () => { const model = WaveAIModel.getInstance(); const handleOpenConfig = async () => { - RpcApi.RecordTEventCommand( - TabRpcClient, - { - event: "action:other", - props: { - "action:type": "waveai:configuremodes:panel", - }, - }, - { noresponse: true } - ); await model.openWaveAIConfig(); }; - const handleViewDocs = () => { - RpcApi.RecordTEventCommand( - TabRpcClient, - { - event: "action:other", - props: { - "action:type": "waveai:viewdocs:panel", - }, - }, - { noresponse: true } - ); - }; - return (
@@ -42,7 +17,7 @@ const BYOKAnnouncement = () => {
New: BYOK & Local AI Support
- Wave AI now supports bring-your-own-key (BYOK) with OpenAI, Google Gemini, Azure, and + Assistant 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.
@@ -56,7 +31,6 @@ const BYOKAnnouncement = () => { href="https://docs.waveterm.dev/waveai-modes" target="_blank" rel="noopener noreferrer" - onClick={handleViewDocs} className="text-blue-400! hover:text-blue-300! hover:underline text-sm cursor-pointer transition-colors flex items-center gap-1" > View Docs diff --git a/frontend/app/aipanel/restorebackupmodal.tsx b/frontend/app/aipanel/restorebackupmodal.tsx index 36b4be5b5d..2388b5f811 100644 --- a/frontend/app/aipanel/restorebackupmodal.tsx +++ b/frontend/app/aipanel/restorebackupmodal.tsx @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import { Modal } from "@/app/modals/modal"; -import { recordTEvent } from "@/app/store/global"; import { useAtomValue } from "jotai"; import { memo } from "react"; import { WaveUIMessagePart } from "./aitypes"; @@ -25,12 +24,10 @@ export const RestoreBackupModal = memo(({ part }: RestoreBackupModalProps) => { }; const handleConfirm = () => { - recordTEvent("waveai:revertfile", { "waveai:action": "revertfile:confirm" }); model.restoreBackup(toolData.toolcallid, toolData.writebackupfilename, toolData.inputfilename); }; const handleCancel = () => { - recordTEvent("waveai:revertfile", { "waveai:action": "revertfile:cancel" }); model.closeRestoreBackupModal(); }; diff --git a/frontend/app/aipanel/telemetryrequired.tsx b/frontend/app/aipanel/telemetryrequired.tsx deleted file mode 100644 index 692dec73d5..0000000000 --- a/frontend/app/aipanel/telemetryrequired.tsx +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { cn } from "@/util/util"; -import { useState } from "react"; -import { WaveAIModel } from "./waveai-model"; - -interface TelemetryRequiredMessageProps { - className?: string; -} - -const TelemetryRequiredMessage = ({ className }: TelemetryRequiredMessageProps) => { - const [isEnabling, setIsEnabling] = useState(false); - - const handleEnableTelemetry = async () => { - setIsEnabling(true); - try { - await RpcApi.WaveAIEnableTelemetryCommand(TabRpcClient); - setTimeout(() => { - WaveAIModel.getInstance().focusInput(); - }, 100); - } catch (error) { - console.error("Failed to enable telemetry:", error); - setIsEnabling(false); - } - }; - - return ( -
-
-
-
-
- -

Wave AI

-

- Wave AI is free to use and provides integrated AI chat that can interact with your widgets, - help you with code, analyze files, and assist with your terminal workflows. -

-
- -
-
- -
-
Telemetry keeps Wave AI free
-
-

- To keep Wave AI free for everyone, we require a small amount of anonymous{" "} - usage data (app version, feature usage, system info). -

-

- This helps us block abuse by automated systems and ensure it's used by real - people like you. -

-

- We never collect your files, prompts, keystrokes, hostnames, or personally - identifying information. Wave AI is powered by OpenAI's APIs, please refer to - OpenAI's privacy policy for details on how they handle your data. -

-

- For information about BYOK and local model support, see{" "} - - https://docs.waveterm.dev/waveai-modes - - . -

-
- -
-
-
- - -
-
-
-
- ); -}; - -TelemetryRequiredMessage.displayName = "TelemetryRequiredMessage"; - -export { TelemetryRequiredMessage }; diff --git a/frontend/app/aipanel/waveai-model.tsx b/frontend/app/aipanel/waveai-model.tsx index 194005adc6..964401012d 100644 --- a/frontend/app/aipanel/waveai-model.tsx +++ b/frontend/app/aipanel/waveai-model.tsx @@ -8,14 +8,12 @@ import { WaveUIMessagePart, } from "@/app/aipanel/aitypes"; import { FocusManager } from "@/app/store/focusManager"; -import { atoms, createBlock, getOrefMetaKeyAtom, getSettingsKeyAtom } from "@/app/store/global"; +import { atoms, createBlock, getSettingsKeyAtom } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; -import { isBuilderWindow } from "@/app/store/windowtype"; import * as WOS from "@/app/store/wos"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; -import { BuilderFocusManager } from "@/builder/store/builder-focusmanager"; import { getWebServerEndpoint } from "@/util/endpoints"; import { base64ToArrayBuffer } from "@/util/util"; import { ChatStatus } from "ai"; @@ -41,26 +39,12 @@ export interface DroppedFile { previewUrl?: string; } -const BuilderAIModeConfigs: Record = { - "waveaibuilder@default": { - "display:name": "Builder Default", - "display:order": -2, - "display:icon": "sparkles", - "display:description": "Good mix of speed and accuracy\n(gpt-5.4 with minimal thinking)", - "ai:provider": "wave", - "ai:switchcompat": ["wavecloud"], - "waveai:premium": true, - }, - "waveaibuilder@deep": { - "display:name": "Builder Deep", - "display:order": -1, - "display:icon": "lightbulb", - "display:description": "Slower but most capable\n(gpt-5.4 with full reasoning)", - "ai:provider": "wave", - "ai:switchcompat": ["wavecloud"], - "waveai:premium": true, - }, -}; +export interface QueuedMessage { + text: string; + files: DroppedFile[]; + uiParts: WaveUIMessagePart[]; + aiParts: AIMessagePart[]; +} export class WaveAIModel { private static instance: WaveAIModel | null = null; @@ -73,7 +57,7 @@ export class WaveAIModel { // Used for injecting Wave-specific message data into DefaultChatTransport's prepareSendMessagesRequest realMessage: AIMessage | null = null; orefContext: ORef; - inBuilder: boolean = false; + private isSendingQueued = false; isAIStreaming = jotai.atom(false); widgetAccessAtom!: jotai.Atom; @@ -81,8 +65,10 @@ export class WaveAIModel { chatId!: jotai.PrimitiveAtom; currentAIMode!: jotai.PrimitiveAtom; aiModeConfigs!: jotai.Atom>; - hasPremiumAtom!: jotai.Atom; + currentAIModel!: jotai.PrimitiveAtom; + aiModelConfigs!: jotai.Atom>; defaultModeAtom!: jotai.Atom; + defaultModelAtom!: jotai.Atom; errorMessage: jotai.PrimitiveAtom = jotai.atom(null) as jotai.PrimitiveAtom; containerWidth: jotai.PrimitiveAtom = jotai.atom(0); codeBlockMaxWidth!: jotai.Atom; @@ -96,30 +82,16 @@ export class WaveAIModel { >; restoreBackupStatus: jotai.PrimitiveAtom<"idle" | "processing" | "success" | "error"> = jotai.atom("idle"); restoreBackupError: jotai.PrimitiveAtom = jotai.atom(null) as jotai.PrimitiveAtom; + queuedMessageAtom: jotai.PrimitiveAtom = jotai.atom(null) as jotai.PrimitiveAtom; + hasQueuedMessageAtom: jotai.Atom = jotai.atom((get) => get(this.queuedMessageAtom) != null); - private constructor(orefContext: ORef, inBuilder: boolean) { + private constructor(orefContext: ORef) { this.orefContext = orefContext; - this.inBuilder = inBuilder; this.chatId = jotai.atom(null) as jotai.PrimitiveAtom; - if (inBuilder) { - this.aiModeConfigs = jotai.atom(BuilderAIModeConfigs) as jotai.Atom>; - } else { - this.aiModeConfigs = atoms.waveaiModeConfigAtom; - } + this.aiModeConfigs = atoms.waveaiModeConfigAtom; + this.aiModelConfigs = atoms.waveaiModelConfigAtom; - this.hasPremiumAtom = jotai.atom((get) => { - const rateLimitInfo = get(atoms.waveAIRateLimitInfoAtom); - return !rateLimitInfo || rateLimitInfo.unknown || rateLimitInfo.preq > 0; - }); - - this.widgetAccessAtom = jotai.atom((get) => { - if (this.inBuilder) { - return true; - } - const widgetAccessMetaAtom = getOrefMetaKeyAtom(this.orefContext, "waveai:widgetcontext"); - const value = get(widgetAccessMetaAtom); - return value ?? true; - }); + this.widgetAccessAtom = jotai.atom(() => true); this.codeBlockMaxWidth = jotai.atom((get) => { const width = get(this.containerWidth); @@ -127,38 +99,17 @@ export class WaveAIModel { }); this.isWaveAIFocusedAtom = jotai.atom((get) => { - if (this.inBuilder) { - return get(BuilderFocusManager.getInstance().focusType) === "waveai"; - } return get(FocusManager.getInstance().focusType) === "waveai"; }); this.panelVisibleAtom = jotai.atom((get) => { - if (this.inBuilder) { - return true; - } return get(WorkspaceLayoutModel.getInstance().panelVisibleAtom); }); this.defaultModeAtom = jotai.atom((get) => { - const telemetryEnabled = get(getSettingsKeyAtom("telemetry:enabled")) ?? false; - if (this.inBuilder) { - return telemetryEnabled ? "waveaibuilder@default" : "invalid"; - } const aiModeConfigs = get(this.aiModeConfigs); - if (!telemetryEnabled) { - let mode = get(getSettingsKeyAtom("waveai:defaultmode")); - if (mode == null || mode.startsWith("waveai@")) { - return "unknown"; - } - return mode; - } - const hasPremium = get(this.hasPremiumAtom); - const waveFallback = hasPremium ? "waveai@balanced" : "waveai@quick"; + const waveFallback = "waveai@ask"; let mode = get(getSettingsKeyAtom("waveai:defaultmode")) ?? waveFallback; - if (!hasPremium && mode.startsWith("waveai@")) { - mode = "waveai@quick"; - } const modeExists = aiModeConfigs != null && mode in aiModeConfigs; if (!modeExists) { mode = waveFallback; @@ -166,8 +117,27 @@ export class WaveAIModel { return mode; }); + this.defaultModelAtom = jotai.atom((get) => { + const aiModelConfigs = get(this.aiModelConfigs); + const settingDefault = get(getSettingsKeyAtom("waveai:defaultmodel")); + if (settingDefault != null && aiModelConfigs?.[settingDefault]) { + return settingDefault; + } + const entries = Object.entries(aiModelConfigs ?? {}); + const score = (cfg: AIModelConfigType) => cfg["display:order"] ?? 0; + const sorted = entries.sort(([ak, ac], [bk, bc]) => { + const sa = score(ac); + const sb = score(bc); + if (sa !== sb) return sa - sb; + return ak.localeCompare(bk); + }); + return sorted.length > 0 ? sorted[0][0] : ""; + }); + const defaultMode = globalStore.get(this.defaultModeAtom); this.currentAIMode = jotai.atom(defaultMode); + const defaultModel = globalStore.get(this.defaultModelAtom); + this.currentAIModel = jotai.atom(defaultModel); } getPanelVisibleAtom(): jotai.Atom { @@ -176,15 +146,9 @@ export class WaveAIModel { static getInstance(): WaveAIModel { if (!WaveAIModel.instance) { - let orefContext: ORef; - if (isBuilderWindow()) { - const builderId = globalStore.get(atoms.builderId); - orefContext = WOS.makeORef("builder", builderId); - } else { - const tabId = globalStore.get(atoms.staticTabId); - orefContext = WOS.makeORef("tab", tabId); - } - WaveAIModel.instance = new WaveAIModel(orefContext, isBuilderWindow()); + const tabId = globalStore.get(atoms.staticTabId); + const orefContext = WOS.makeORef("tab", tabId); + WaveAIModel.instance = new WaveAIModel(orefContext); (window as any).WaveAIModel = WaveAIModel.instance; } return WaveAIModel.instance; @@ -226,7 +190,7 @@ export class WaveAIModel { async addFileFromRemoteUri(draggedFile: DraggedFile): Promise { if (draggedFile.isDir) { - this.setError("Cannot add directories to Wave AI. Please select a file."); + this.setError("Cannot add directories to Assistant. Please select a file."); return; } @@ -237,7 +201,7 @@ export class WaveAIModel { return; } if (fileInfo.isdir) { - this.setError("Cannot add directories to Wave AI. Please select a file."); + this.setError("Cannot add directories to Assistant. Please select a file."); return; } @@ -295,6 +259,7 @@ export class WaveAIModel { this.useChatStop?.(); this.clearFiles(); this.clearError(); + this.deleteQueuedMessage(); globalStore.set(this.isChatEmptyAtom, true); const newChatId = crypto.randomUUID(); globalStore.set(this.chatId, newChatId); @@ -340,7 +305,7 @@ export class WaveAIModel { } focusInput() { - if (!this.inBuilder && !WorkspaceLayoutModel.getInstance().getAIPanelVisible()) { + if (!WorkspaceLayoutModel.getInstance().getAIPanelVisible()) { WorkspaceLayoutModel.getInstance().setAIPanelVisible(true); } if (this.inputRef?.current) { @@ -357,18 +322,6 @@ export class WaveAIModel { async stopResponse() { this.useChatStop?.(); - await new Promise((resolve) => setTimeout(resolve, 500)); - - const chatIdValue = globalStore.get(this.chatId); - if (!chatIdValue) { - return; - } - try { - const messages = await this.reloadChatFromBackend(chatIdValue); - this.useChatSetMessages?.(messages); - } catch (error) { - console.error("Failed to reload chat after stop:", error); - } } getAndClearMessage(): AIMessage | null { @@ -411,19 +364,11 @@ export class WaveAIModel { }); } - setWidgetAccess(enabled: boolean) { - RpcApi.SetMetaCommand(TabRpcClient, { - oref: this.orefContext, - meta: { "waveai:widgetcontext": enabled }, - }); + setWidgetAccess(_enabled: boolean) { + // no-op: widget context is always enabled } isValidMode(mode: string): boolean { - const telemetryEnabled = globalStore.get(getSettingsKeyAtom("telemetry:enabled")) ?? false; - if (mode.startsWith("waveai@") && !telemetryEnabled) { - return false; - } - const aiModeConfigs = globalStore.get(this.aiModeConfigs); if (aiModeConfigs == null || !(mode in aiModeConfigs)) { return false; @@ -461,6 +406,36 @@ export class WaveAIModel { if (mode == null || !this.isValidMode(mode)) { this.setAIModeToDefault(); } + const model = rtInfo?.["waveai:model"]; + if (model == null || !this.isValidModel(model)) { + this.setAIModelToDefault(); + } + } + + isValidModel(model: string): boolean { + const aiModelConfigs = globalStore.get(this.aiModelConfigs); + return aiModelConfigs != null && model in aiModelConfigs; + } + + setAIModel(model: string) { + if (!this.isValidModel(model)) { + this.setAIModelToDefault(); + return; + } + globalStore.set(this.currentAIModel, model); + RpcApi.SetRTInfoCommand(TabRpcClient, { + oref: this.orefContext, + data: { "waveai:model": model }, + }); + } + + setAIModelToDefault() { + const defaultModel = globalStore.get(this.defaultModelAtom); + globalStore.set(this.currentAIModel, defaultModel); + RpcApi.SetRTInfoCommand(TabRpcClient, { + oref: this.orefContext, + data: { "waveai:model": null }, + }); } async getRTInfo(): Promise> { @@ -484,6 +459,14 @@ export class WaveAIModel { } globalStore.set(this.chatId, chatIdValue); + const aiModelValue = rtInfo?.["waveai:model"]; + if (aiModelValue != null && this.isValidModel(aiModelValue)) { + globalStore.set(this.currentAIModel, aiModelValue); + } else { + const defaultModel = globalStore.get(this.defaultModelAtom); + globalStore.set(this.currentAIModel, defaultModel); + } + const aiModeValue = rtInfo?.["waveai:mode"]; if (aiModeValue == null) { const defaultMode = globalStore.get(this.defaultModeAtom); @@ -505,7 +488,7 @@ export class WaveAIModel { } } - async handleSubmit() { + async handleSubmit(currentStatus?: ChatStatus) { const input = globalStore.get(this.inputAtom); const droppedFiles = globalStore.get(this.droppedFiles); @@ -515,11 +498,12 @@ export class WaveAIModel { return; } - if ( - (!input.trim() && droppedFiles.length === 0) || - (this.useChatStatus !== "ready" && this.useChatStatus !== "error") || - globalStore.get(this.isLoadingChatAtom) - ) { + if ((!input.trim() && droppedFiles.length === 0) || globalStore.get(this.isLoadingChatAtom)) { + return; + } + + // If already queued, ignore + if (globalStore.get(this.queuedMessageAtom) != null) { return; } @@ -557,14 +541,27 @@ export class WaveAIModel { }); } + const status = currentStatus ?? this.useChatStatus; + if (status !== "ready" && status !== "error") { + // Queue the message instead of sending immediately + const queued: QueuedMessage = { + text: input.trim(), + files: droppedFiles, + uiParts: uiMessageParts, + aiParts: aiMessageParts, + }; + globalStore.set(this.queuedMessageAtom, queued); + globalStore.set(this.inputAtom, ""); + this.clearFiles(); + return; + } + const realMessage: AIMessage = { messageid: crypto.randomUUID(), parts: aiMessageParts, }; this.realMessage = realMessage; - // console.log("SUBMIT MESSAGE", realMessage); - this.useChatSendMessage?.({ parts: uiMessageParts }); globalStore.set(this.isChatEmptyAtom, false); @@ -572,6 +569,63 @@ export class WaveAIModel { this.clearFiles(); } + async sendQueuedMessage() { + if (this.isSendingQueued) { + return; + } + const queued = globalStore.get(this.queuedMessageAtom); + if (queued == null) { + return; + } + this.isSendingQueued = true; + globalStore.set(this.queuedMessageAtom, null); + const realMessage: AIMessage = { + messageid: crypto.randomUUID(), + parts: queued.aiParts, + }; + this.realMessage = realMessage; + try { + await this.useChatSendMessage?.({ parts: queued.uiParts }); + globalStore.set(this.isChatEmptyAtom, false); + } catch (error) { + console.error("Failed to send queued message:", error); + } finally { + this.isSendingQueued = false; + } + } + + async sendQueuedMessageNow() { + const queued = globalStore.get(this.queuedMessageAtom); + if (queued == null) { + return; + } + await this.stopResponse(); + await this.sendQueuedMessage(); + } + + editQueuedMessage() { + const queued = globalStore.get(this.queuedMessageAtom); + if (queued == null) { + return; + } + globalStore.set(this.inputAtom, queued.text); + globalStore.set(this.droppedFiles, queued.files); + globalStore.set(this.queuedMessageAtom, null); + } + + deleteQueuedMessage() { + const queued = globalStore.get(this.queuedMessageAtom); + if (queued == null) { + return; + } + queued.files.forEach((file) => { + if (file.previewUrl) { + URL.revokeObjectURL(file.previewUrl); + } + }); + globalStore.set(this.queuedMessageAtom, null); + } + async uiLoadInitialChat() { globalStore.set(this.isLoadingChatAtom, true); const messages = await this.loadInitialChat(); @@ -597,33 +651,12 @@ export class WaveAIModel { } } - handleAIFeedback(feedback: "good" | "bad") { - RpcApi.RecordTEventCommand( - TabRpcClient, - { - event: "waveai:feedback", - props: { - "waveai:feedback": feedback, - }, - }, - { noresponse: true } - ); - } - requestWaveAIFocus() { - if (this.inBuilder) { - BuilderFocusManager.getInstance().setWaveAIFocused(); - } else { - FocusManager.getInstance().requestWaveAIFocus(); - } + FocusManager.getInstance().requestWaveAIFocus(); } requestNodeFocus() { - if (this.inBuilder) { - BuilderFocusManager.getInstance().setAppFocused(); - } else { - FocusManager.getInstance().requestNodeFocus(); - } + FocusManager.getInstance().requestNodeFocus(); } getChatId(): string { @@ -695,16 +728,10 @@ export class WaveAIModel { } canCloseWaveAIPanel(): boolean { - if (this.inBuilder) { - return false; - } return true; } closeWaveAIPanel() { - if (this.inBuilder) { - return; - } WorkspaceLayoutModel.getInstance().setAIPanelVisible(false); } } diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index e9c70a35df..8a85294df2 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -286,7 +286,6 @@ const AppKeyHandlers = () => { const staticKeyDownHandler = keyutil.keydownWrapper(appHandleKeyDown); const staticMouseDownHandler = (e: MouseEvent) => { keyboardMouseDownHandler(e); - GlobalModel.getInstance().setIsActive(); }; document.addEventListener("keydown", staticKeyDownHandler); document.addEventListener("mousedown", staticMouseDownHandler); diff --git a/frontend/app/block/blockenv.ts b/frontend/app/block/blockenv.ts index 8a529be11b..93c95e2e63 100644 --- a/frontend/app/block/blockenv.ts +++ b/frontend/app/block/blockenv.ts @@ -26,7 +26,6 @@ export type BlockEnv = WaveEnvSubset<{ openExternal: WaveEnv["electron"]["openExternal"]; }; rpc: { - ActivityCommand: WaveEnv["rpc"]["ActivityCommand"]; ConnEnsureCommand: WaveEnv["rpc"]["ConnEnsureCommand"]; ConnDisconnectCommand: WaveEnv["rpc"]["ConnDisconnectCommand"]; ConnConnectCommand: WaveEnv["rpc"]["ConnConnectCommand"]; diff --git a/frontend/app/block/blockframe-header.tsx b/frontend/app/block/blockframe-header.tsx index a70f323e71..89eb7d3f48 100644 --- a/frontend/app/block/blockframe-header.tsx +++ b/frontend/app/block/blockframe-header.tsx @@ -14,13 +14,11 @@ import { getBlockBadgeAtom } from "@/app/store/badge"; import { createBlockSplitHorizontally, createBlockSplitVertically, - recordTEvent, refocusNode, WOS, } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; import { uxCloseBlock } from "@/app/store/keymodel"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import { IconButton } from "@/element/iconbutton"; import { NodeModel } from "@/layout/index"; @@ -237,10 +235,6 @@ const BlockFrame_Header = ({ viewIconUnion = metaFrameIcon ?? viewIconUnion; React.useEffect(() => { - if (magnified && !preview && !prevMagifiedState.current) { - waveEnv.rpc.ActivityCommand(TabRpcClient, { nummagnify: 1 }); - recordTEvent("action:magnify", { "block:view": viewName }); - } prevMagifiedState.current = magnified; }, [magnified]); diff --git a/frontend/app/block/blockregistry.ts b/frontend/app/block/blockregistry.ts index 5de7e05bd3..501c37b74c 100644 --- a/frontend/app/block/blockregistry.ts +++ b/frontend/app/block/blockregistry.ts @@ -7,31 +7,22 @@ import { AiFileDiffViewModel } from "@/app/view/aifilediff/aifilediff"; import { LauncherViewModel } from "@/app/view/launcher/launcher"; import { PreviewModel } from "@/app/view/preview/preview-model"; import { ProcessViewerViewModel } from "@/app/view/processviewer/processviewer"; -import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo"; -import { TsunamiViewModel } from "@/app/view/tsunami/tsunami"; import { VDomModel } from "@/app/view/vdom/vdom-model"; import { WaveEnv } from "@/app/waveenv/waveenv"; import { atom } from "jotai"; import { QuickTipsViewModel } from "../view/quicktipsview/quicktipsview"; import { WaveConfigViewModel } from "../view/waveconfig/waveconfig-model"; import { blockViewToIcon, blockViewToName } from "./blockutil"; -import { HelpViewModel } from "@/view/helpview/helpview"; import { TermViewModel } from "@/view/term/term-model"; import { WaveAiModel } from "@/view/waveai/waveai"; -import { WebViewModel } from "@/view/webview/webview"; const BlockRegistry: Map = new Map(); BlockRegistry.set("term", TermViewModel); BlockRegistry.set("preview", PreviewModel); -BlockRegistry.set("web", WebViewModel); BlockRegistry.set("waveai", WaveAiModel); -BlockRegistry.set("cpuplot", SysinfoViewModel); -BlockRegistry.set("sysinfo", SysinfoViewModel); BlockRegistry.set("vdom", VDomModel); BlockRegistry.set("tips", QuickTipsViewModel); -BlockRegistry.set("help", HelpViewModel); BlockRegistry.set("launcher", LauncherViewModel); -BlockRegistry.set("tsunami", TsunamiViewModel); BlockRegistry.set("aifilediff", AiFileDiffViewModel); BlockRegistry.set("waveconfig", WaveConfigViewModel); BlockRegistry.set("processviewer", ProcessViewerViewModel); diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx index 3ef4d39821..1f9b1fe62b 100644 --- a/frontend/app/block/blockutil.tsx +++ b/frontend/app/block/blockutil.tsx @@ -30,15 +30,9 @@ export function blockViewToIcon(view: string): string { if (view == "preview") { return "file"; } - if (view == "web") { - return "globe"; - } if (view == "waveai") { return "sparkles"; } - if (view == "help") { - return "circle-question"; - } if (view == "tips") { return "lightbulb"; } @@ -58,15 +52,9 @@ export function blockViewToName(view: string): string { if (view == "preview") { return "Preview"; } - if (view == "web") { - return "Web"; - } if (view == "waveai") { return "WaveAI"; } - if (view == "help") { - return "Help"; - } if (view == "tips") { return "Tips"; } diff --git a/frontend/app/block/connectionbutton.tsx b/frontend/app/block/connectionbutton.tsx index c5a9b635c3..7ad8869ea5 100644 --- a/frontend/app/block/connectionbutton.tsx +++ b/frontend/app/block/connectionbutton.tsx @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import { computeConnColorNum } from "@/app/block/blockutil"; -import { recordTEvent } from "@/app/store/global"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import { IconButton } from "@/element/iconbutton"; import * as util from "@/util/util"; @@ -30,7 +29,6 @@ export const ConnectionButton = React.memo( const connColorNum = computeConnColorNum(connStatus); let color = `var(--conn-icon-color-${connColorNum})`; const clickHandler = function () { - recordTEvent("action:other", { "action:type": "conndropdown", "action:initiator": "mouse" }); setConnModalOpen(true); }; let titleText = null; diff --git a/frontend/app/block/durable-session-flyover.tsx b/frontend/app/block/durable-session-flyover.tsx index 7ab7fa0b10..ecd02e0e2d 100644 --- a/frontend/app/block/durable-session-flyover.tsx +++ b/frontend/app/block/durable-session-flyover.tsx @@ -1,7 +1,6 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { recordTEvent } from "@/app/store/global"; import { TermViewModel } from "@/app/view/term/term-model"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import * as util from "@/util/util"; @@ -44,7 +43,6 @@ interface StandardSessionContentProps { function StandardSessionContent({ viewModel, onClose }: StandardSessionContentProps) { const handleRestartAsDurable = () => { - recordTEvent("action:termdurable", { "action:type": "restartdurable" }); onClose(); util.fireAndForget(() => viewModel.restartSessionWithDurability(true)); }; diff --git a/frontend/app/modals/about.tsx b/frontend/app/modals/about.tsx index d3a43d386d..be431d1321 100644 --- a/frontend/app/modals/about.tsx +++ b/frontend/app/modals/about.tsx @@ -5,12 +5,9 @@ import Logo from "@/app/asset/logo.svg"; import { OnboardingGradientBg } from "@/app/onboarding/onboarding-common"; import { atoms } from "@/app/store/global"; import { modalsModel } from "@/app/store/modalmodel"; -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; import { isDev } from "@/util/isdev"; import { fireAndForget } from "@/util/util"; import { useAtomValue } from "jotai"; -import { useEffect } from "react"; import { Modal } from "./modal"; interface AboutModalVProps { @@ -89,16 +86,6 @@ const AboutModal = () => { const versionString = `${fullConfig?.version ?? ""} (${isDev() ? "dev-" : ""}${fullConfig?.buildtime ?? ""})`; const updaterChannel = fullConfig?.settings?.["autoupdate:channel"] ?? "latest"; - useEffect(() => { - fireAndForget(async () => { - RpcApi.RecordTEventCommand( - TabRpcClient, - { event: "action:other", props: { "action:type": "about" } }, - { noresponse: true } - ); - }); - }, []); - return ( } = { [UserInputModal.displayName || "UserInputModal"]: UserInputModal, [AboutModal.displayName || "AboutModal"]: AboutModal, [MessageModal.displayName || "MessageModal"]: MessageModal, - [PublishAppModal.displayName || "PublishAppModal"]: PublishAppModal, - [RenameFileModal.displayName || "RenameFileModal"]: RenameFileModal, - [DeleteFileModal.displayName || "DeleteFileModal"]: DeleteFileModal, - [SetSecretDialog.displayName || "SetSecretDialog"]: SetSecretDialog, }; export const getModalComponent = (key: string): React.ComponentType | undefined => { diff --git a/frontend/app/onboarding/fakechat.tsx b/frontend/app/onboarding/fakechat.tsx index 00eac0598c..e9b5047b08 100644 --- a/frontend/app/onboarding/fakechat.tsx +++ b/frontend/app/onboarding/fakechat.tsx @@ -25,7 +25,7 @@ const chatConfigs: ChatConfig[] = [ ## Architecture at a glance - **Electron main process:** \`emain/*.ts\` configures windows, menus, preload scripts, updater, and ties into the Go backend via local RPC. (\`emain/\`) - **Renderer UI:** React/TS built with Vite, Tailwind. (\`frontend/\`, \`index.html\`, \`electron.vite.config.ts\`) -- **Go backend ("wavesrv"):** starts services, web and websocket listeners, telemetry loops, config watcher, local RPC, filestore and SQLite-backed object store. (\`cmd/server/main-server.go\`, \`pkg/*\`) +- **Go backend ("wavesrv"):** starts services, web and websocket listeners, config watcher, local RPC, filestore and SQLite-backed object store. (\`cmd/server/main-server.go\`, \`pkg/*\`) - **CLI/helper ("wsh"):** built for multiple OS/arch; used for shell integration and remote operations. (\`cmd/wsh/\`, \`Taskfile.yml build:wsh\`) ## Key directories diff --git a/frontend/app/onboarding/onboarding-durable.tsx b/frontend/app/onboarding/onboarding-durable.tsx index b716b3d7da..38a7808591 100644 --- a/frontend/app/onboarding/onboarding-durable.tsx +++ b/frontend/app/onboarding/onboarding-durable.tsx @@ -3,8 +3,6 @@ import Logo from "@/app/asset/logo.svg"; import { EmojiButton } from "@/app/element/emojibutton"; -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; import { useState } from "react"; import { CurrentOnboardingVersion } from "./onboarding-common"; import { OnboardingFooter } from "./onboarding-features-footer"; @@ -23,15 +21,6 @@ export const DurableSessionPage = ({ const handleFireClick = () => { setFireClicked(!fireClicked); - if (!fireClicked) { - RpcApi.RecordTEventCommand(TabRpcClient, { - event: "onboarding:fire", - props: { - "onboarding:feature": "durable", - "onboarding:version": CurrentOnboardingVersion, - }, - }); - } }; return ( diff --git a/frontend/app/onboarding/onboarding-features.tsx b/frontend/app/onboarding/onboarding-features.tsx index b47bbc4e1a..518d8960da 100644 --- a/frontend/app/onboarding/onboarding-features.tsx +++ b/frontend/app/onboarding/onboarding-features.tsx @@ -26,15 +26,6 @@ export const WaveAIPage = ({ onNext, onSkip }: { onNext: () => void; onSkip: () const handleFireClick = () => { setFireClicked(!fireClicked); - if (!fireClicked) { - RpcApi.RecordTEventCommand(TabRpcClient, { - event: "onboarding:fire", - props: { - "onboarding:feature": "waveai", - "onboarding:version": CurrentOnboardingVersion, - }, - }); - } }; return ( @@ -121,15 +112,6 @@ export const MagnifyBlocksPage = ({ const handleFireClick = () => { setFireClicked(!fireClicked); - if (!fireClicked) { - RpcApi.RecordTEventCommand(TabRpcClient, { - event: "onboarding:fire", - props: { - "onboarding:feature": "magnify", - "onboarding:version": CurrentOnboardingVersion, - }, - }); - } }; return ( @@ -179,15 +161,6 @@ export const FilesPage = ({ onFinish, onPrev }: { onFinish: () => void; onPrev?: const handleFireClick = () => { setFireClicked(!fireClicked); - if (!fireClicked) { - RpcApi.RecordTEventCommand(TabRpcClient, { - event: "onboarding:fire", - props: { - "onboarding:feature": "wsh", - "onboarding:version": CurrentOnboardingVersion, - }, - }); - } }; const commands = [ @@ -273,12 +246,6 @@ export const OnboardingFeatures = ({ onComplete }: { onComplete: () => void }) = oref: WOS.makeORef("client", clientId), meta: { "onboarding:lastversion": CurrentOnboardingVersion }, }); - RpcApi.RecordTEventCommand(TabRpcClient, { - event: "onboarding:start", - props: { - "onboarding:version": CurrentOnboardingVersion, - }, - }); }, []); const handleNext = () => { @@ -302,10 +269,6 @@ export const OnboardingFeatures = ({ onComplete }: { onComplete: () => void }) = }; const handleSkip = () => { - RpcApi.RecordTEventCommand(TabRpcClient, { - event: "onboarding:skip", - props: {}, - }); onComplete(); }; diff --git a/frontend/app/onboarding/onboarding-starask.tsx b/frontend/app/onboarding/onboarding-starask.tsx index bb7678ab2a..3f6b6b272a 100644 --- a/frontend/app/onboarding/onboarding-starask.tsx +++ b/frontend/app/onboarding/onboarding-starask.tsx @@ -15,14 +15,6 @@ type StarAskPageProps = { export function StarAskPage({ onClose, page = "upgrade" }: StarAskPageProps) { const handleStarClick = async () => { - RpcApi.RecordTEventCommand( - TabRpcClient, - { - event: "onboarding:githubstar", - props: { "onboarding:githubstar": "star", "onboarding:page": page }, - }, - { noresponse: true } - ); const clientId = ClientModel.getInstance().clientId; await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("client", clientId), @@ -33,14 +25,6 @@ export function StarAskPage({ onClose, page = "upgrade" }: StarAskPageProps) { }; const handleAlreadyStarred = async () => { - RpcApi.RecordTEventCommand( - TabRpcClient, - { - event: "onboarding:githubstar", - props: { "onboarding:githubstar": "already", "onboarding:page": page }, - }, - { noresponse: true } - ); const clientId = ClientModel.getInstance().clientId; await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("client", clientId), @@ -50,26 +34,10 @@ export function StarAskPage({ onClose, page = "upgrade" }: StarAskPageProps) { }; const handleRepoLinkClick = () => { - RpcApi.RecordTEventCommand( - TabRpcClient, - { - event: "action:link", - props: { "action:type": "githubrepo", "onboarding:page": page }, - }, - { noresponse: true } - ); window.open("https://github.com/wavetermdev/waveterm", "_blank"); }; const handleMaybeLater = async () => { - RpcApi.RecordTEventCommand( - TabRpcClient, - { - event: "onboarding:githubstar", - props: { "onboarding:githubstar": "later", "onboarding:page": page }, - }, - { noresponse: true } - ); const clientId = ClientModel.getInstance().clientId; await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("client", clientId), diff --git a/frontend/app/onboarding/onboarding-upgrade-minor.tsx b/frontend/app/onboarding/onboarding-upgrade-minor.tsx index b165c0ce7f..edf6ea082c 100644 --- a/frontend/app/onboarding/onboarding-upgrade-minor.tsx +++ b/frontend/app/onboarding/onboarding-upgrade-minor.tsx @@ -131,14 +131,6 @@ const UpgradeOnboardingMinor = () => { }, []); const handleStarClick = async () => { - RpcApi.RecordTEventCommand( - TabRpcClient, - { - event: "onboarding:githubstar", - props: { "onboarding:githubstar": "star", "onboarding:page": "minorupgrade" }, - }, - { noresponse: true } - ); const clientId = ClientModel.getInstance().clientId; await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("client", clientId), @@ -149,14 +141,6 @@ const UpgradeOnboardingMinor = () => { }; const handleAlreadyStarred = async () => { - RpcApi.RecordTEventCommand( - TabRpcClient, - { - event: "onboarding:githubstar", - props: { "onboarding:githubstar": "already", "onboarding:page": "minorupgrade" }, - }, - { noresponse: true } - ); const clientId = ClientModel.getInstance().clientId; await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("client", clientId), @@ -166,14 +150,6 @@ const UpgradeOnboardingMinor = () => { }; const handleMaybeLater = async () => { - RpcApi.RecordTEventCommand( - TabRpcClient, - { - event: "onboarding:githubstar", - props: { "onboarding:githubstar": "later", "onboarding:page": "minorupgrade" }, - }, - { noresponse: true } - ); const clientId = ClientModel.getInstance().clientId; await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("client", clientId), diff --git a/frontend/app/onboarding/onboarding-upgrade-v0131.tsx b/frontend/app/onboarding/onboarding-upgrade-v0131.tsx index 7584607384..d30553edf3 100644 --- a/frontend/app/onboarding/onboarding-upgrade-v0131.tsx +++ b/frontend/app/onboarding/onboarding-upgrade-v0131.tsx @@ -53,8 +53,7 @@ const UpgradeOnboardingModal_v0_13_1_Content = () => { support for custom backgrounds
  • - BYOK Without Telemetry - Wave AI now works with bring-your-own-key and - local models without requiring telemetry + BYOK Support - Wave AI now works with bring-your-own-key and local models
  • diff --git a/frontend/app/onboarding/onboarding.tsx b/frontend/app/onboarding/onboarding.tsx index ba139e81df..14807dd3df 100644 --- a/frontend/app/onboarding/onboarding.tsx +++ b/frontend/app/onboarding/onboarding.tsx @@ -22,34 +22,21 @@ import { useEffect, useRef, useState } from "react"; import { debounce } from "throttle-debounce"; // Page flow: -// init -> (telemetry enabled) -> features -// init -> (telemetry disabled) -> notelemetrystar -> features +// init -> features -type PageName = "init" | "notelemetrystar" | "features"; +type PageName = "init" | "features"; const pageNameAtom: PrimitiveAtom = atom("init"); const InitPage = ({ isCompact, - telemetryUpdateFn, }: { isCompact: boolean; - telemetryUpdateFn: (value: boolean) => Promise; }) => { - const telemetrySetting = useSettingsKeyAtom("telemetry:enabled"); const clientData = useAtomValue(ClientModel.getInstance().clientAtom); - const [telemetryEnabled, setTelemetryEnabled] = useState(!!telemetrySetting); const setPageName = useSetAtom(pageNameAtom); const handleStarClick = async () => { - RpcApi.RecordTEventCommand( - TabRpcClient, - { - event: "onboarding:githubstar", - props: { "onboarding:githubstar": "star", "onboarding:page": "init" }, - }, - { noresponse: true } - ); const clientId = ClientModel.getInstance().clientId; await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("client", clientId), @@ -61,22 +48,10 @@ const InitPage = ({ if (!clientData?.tosagreed) { fireAndForget(() => services.ClientService.AgreeTos()); } - if (telemetryEnabled) { - WorkspaceLayoutModel.getInstance().setAIPanelVisible(true); - } - setPageName(telemetryEnabled ? "features" : "notelemetrystar"); - }; - - const setTelemetry = (value: boolean) => { - fireAndForget(() => - telemetryUpdateFn(value).then(() => { - setTelemetryEnabled(value); - }) - ); + WorkspaceLayoutModel.getInstance().setAIPanelVisible(true); + setPageName("features"); }; - const label = telemetryEnabled ? "Enabled" : "Disabled"; - return (
    -
    -
    - -
    -
    -
    - Anonymous usage data helps us improve features you use. -
    - - Privacy Policy - -
    - -
    -