diff --git a/.claude/architecture-decisions/LESSON-0001-wave-terminal-is-host-environment.md b/.claude/architecture-decisions/LESSON-0001-wave-terminal-is-host-environment.md new file mode 100644 index 0000000000..2dac3e315a --- /dev/null +++ b/.claude/architecture-decisions/LESSON-0001-wave-terminal-is-host-environment.md @@ -0,0 +1,72 @@ +# LESSON-0001: Wave Terminal IS the Host Environment + +**Date:** 2026-01-22 +**Category:** Development Environment +**Severity:** CRITICAL + +--- + +## Problem + +When developing Wave Terminal, Claude Code runs **inside** Wave Terminal itself. This creates a unique situation where the application under development is also the host environment for the development agent. + +## Incident + +During xterm.js 6.1.0 migration testing: +- Agent checked for running processes with `tasklist | grep wave` +- Found multiple `Wave.exe` and `wavesrv.x64.exe` processes +- Incorrectly assumed these were the dev instance that was just started +- Nearly suggested actions that could have killed the host terminal + +## Key Understanding + +``` +┌─────────────────────────────────────────────────┐ +│ Wave Terminal (Production/User Instance) │ +│ ├── Wave.exe (main process) │ +│ ├── wavesrv.x64.exe (backend server) │ +│ └── Claude Code (running in this terminal) │ +│ └── Agent conversation (THIS SESSION) │ +└─────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────┐ +│ Wave Terminal (Dev Instance - separate) │ +│ ├── Wave.exe (dev main process) │ +│ ├── wavesrv.x64.exe (dev backend) │ +│ └── Started by `npm run dev` or `task dev` │ +└─────────────────────────────────────────────────┘ +``` + +## Rules + +### NEVER DO: +- ❌ Kill `Wave.exe` or `wavesrv.x64.exe` processes without explicit user instruction +- ❌ Assume Wave.exe processes are from your dev instance +- ❌ Run `taskkill` on any Wave-related processes +- ❌ Use the Electron MCP tools to interact with the production Wave instance + +### ALWAYS DO: +- ✅ Understand that Wave Terminal is the HOST, not the target +- ✅ The dev instance runs separately with its own processes +- ✅ Ask user before any process management +- ✅ Use `npm run dev` or `task dev` to start a SEPARATE dev instance + +## Impact + +- **If violated:** Kills Claude Code session, loses all conversation context +- **Recovery:** User must restart Wave Terminal and Claude Code from scratch +- **Data loss:** Any uncommitted work or in-progress analysis + +## Applies To + +- Any development work on Wave Terminal +- Any testing involving Electron processes +- Any process management commands +- Any use of Electron MCP tools + +--- + +## Related + +- xterm.js 6.1.0 upgrade task +- QA testing procedures for Electron apps diff --git a/.claude/architecture-decisions/README.md b/.claude/architecture-decisions/README.md new file mode 100644 index 0000000000..30b7222c39 --- /dev/null +++ b/.claude/architecture-decisions/README.md @@ -0,0 +1,34 @@ +# Wave Terminal Architecture Decisions Registry + +This directory contains architecture decision records (ADRs), lessons learned, and error solutions for the Wave Terminal project. + +## Index + +### Lessons Learned + +| ID | Title | Category | Severity | +|----|-------|----------|----------| +| [LESSON-0001](LESSON-0001-wave-terminal-is-host-environment.md) | Wave Terminal IS the Host Environment | Development Environment | CRITICAL | + +### Architecture Decision Records (ADRs) + +*None yet* + +### Error Solutions + +*None yet* + +--- + +## Usage + +### Before Development +Query this registry to check for relevant patterns, known pitfalls, and proven approaches. + +### After Development +Update this registry with new ADRs, lessons learned, or error solutions. + +--- + +## Last Updated +2026-01-22 diff --git a/.claude/worktrees/waveterm-experimental.json b/.claude/worktrees/waveterm-experimental.json new file mode 100644 index 0000000000..013275bf60 --- /dev/null +++ b/.claude/worktrees/waveterm-experimental.json @@ -0,0 +1,23 @@ +{ + "lastUpdated": "2026-01-23T12:18:55.2633571-03:00", + "sessions": [ + { + "sessionId": "aa5892ec-e359-44df-874e-b2d74860fcb7", + "ended": "2026-01-23T12:18:55.2697385-03:00" + }, + { + "sessionId": "1421db85-83a1-4848-8c73-2677e47c920b", + "ended": "2026-01-23T00:59:12.2164267-03:00" + } + ], + "created": "2026-01-23T00:59:12.2084213-03:00", + "path": "G:\\Code\\waveterm-experimental", + "branch": "feat/experimental-upstream-fixes", + "name": "waveterm-experimental", + "parentRepo": "G:\\Code\\waveterm", + "context": { + "purpose": "", + "notes": [], + "relatedIssues": [] + } +} diff --git a/BUILD.md b/BUILD.md index 15229bb091..0969de88eb 100644 --- a/BUILD.md +++ b/BUILD.md @@ -51,9 +51,16 @@ For packaging, the following additional packages are required: #### Windows -You will need the [Zig](https://ziglang.org/) compiler for statically linking CGO. +You will need the following: -You can find installation instructions for Zig on Windows [here](https://ziglang.org/learn/getting-started/#managers). +- **[Zig](https://ziglang.org/)** — Required for statically linking CGO. See [installation instructions](https://ziglang.org/learn/getting-started/#managers). +- **[PowerShell 7+](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows)** — Required for build scripts. Windows ships with PowerShell 5.1, but the build system requires PowerShell 7 (pwsh). Install via winget: + +```powershell +winget install Microsoft.PowerShell +``` + +Or download from the [PowerShell releases page](https://github.com/PowerShell/PowerShell/releases). ### Task diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000000..c25ae2a6fc --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,104 @@ +# Changes Summary: Race Condition Fixes with Optimistic Locking + +## Overview + +This implementation addresses race conditions in tab metadata updates (spec-004) by implementing optimistic locking with version checking. The changes prevent TOCTOU (Time-Of-Check-Time-Of-Use) vulnerabilities in concurrent metadata operations. + +## Files Modified + +### Backend (Go) + +#### `pkg/wstore/wstore_dbops.go` +- Added `ErrVersionMismatch` error variable for concurrent modification detection +- Added `ErrObjectLocked` error variable for lock state rejection + +#### `pkg/wstore/wstore.go` +- Added `UpdateObjectMetaWithVersion()` function: + - Performs optimistic locking update with version checking + - If `expectedVersion > 0` and doesn't match current version, returns `ErrVersionMismatch` + - If `expectedVersion == 0`, behaves like `UpdateObjectMeta` (no version check) + +- Added `UpdateObjectMetaIfNotLocked()` function: + - Atomically checks lock and updates metadata + - Lock is checked INSIDE the transaction, eliminating TOCTOU vulnerability + - Returns `ErrObjectLocked` (wrapped in `ErrVersionMismatch`) if locked + - Returns `ErrVersionMismatch` if version doesn't match + +#### `pkg/service/objectservice/objectservice.go` +- Added `UpdateObjectMetaWithVersion()` RPC service method +- Added `UpdateObjectMetaIfNotLocked()` RPC service method +- Both methods include proper metadata annotations for TypeScript binding generation + +### Frontend (TypeScript) + +#### `frontend/app/view/term/termwrap.ts` +- Added debounce map (`osc7DebounceMap`) for OSC 7 updates per tab +- Added `OSC7_DEBOUNCE_MS = 300` constant for debounce delay +- Added `clearOsc7Debounce()` helper function +- Added `cleanupOsc7DebounceForTab()` exported function for memory leak prevention +- Updated `handleOsc7Command()` to: + - Add null safety check for `tabData?.oid` + - Use debouncing to reduce race condition window + - Use atomic lock-aware update (`UpdateObjectMetaIfNotLocked`) instead of regular update + - Gracefully handle version mismatch and locked state errors + +#### `frontend/app/tab/tab.tsx` +- Added `getApi` to imports from `@/app/store/global` (fix for pre-existing missing import) + +### Generated Files + +#### `frontend/app/store/services.ts` +- Auto-generated new TypeScript methods: + - `UpdateObjectMetaWithVersion(oref, meta, expectedVersion)` + - `UpdateObjectMetaIfNotLocked(oref, meta, lockKey, expectedVersion)` + +## Key Features Implemented + +### 1. Optimistic Locking +- Uses existing `version` field in WaveObj types +- Version checked inside transaction to prevent TOCTOU +- Atomic increment of version on successful update (already implemented in `DBUpdate`) + +### 2. Error Types +- **ErrVersionMismatch**: Indicates concurrent modification detected +- **ErrObjectLocked**: Indicates update rejected due to lock state +- Both errors are wrapped appropriately for consistent error handling + +### 3. OSC 7 Debouncing +- 300ms debounce window for rapid directory changes +- Per-tab debounce timers in a Map +- Cleanup function to prevent memory leaks on tab close + +### 4. Atomic Lock Checking +- Lock state checked INSIDE database transaction +- Eliminates race condition between lock check and update +- If lock is toggled during update, the update is safely rejected + +## Acceptance Criteria Status + +- [x] `UpdateObjectMetaWithVersion` added to `wstore.go` +- [x] RPC endpoints added to `objectservice.go` +- [x] OSC 7 debounce map with cleanup function +- [x] Null safety guards in `termwrap.ts` +- [x] `ErrVersionMismatch` error type created +- [x] `ErrObjectLocked` error type created +- [x] TypeScript compilation passes (our files) +- [x] Go compilation passes +- [x] Changes committed + +## Testing Notes + +To test the implementation: + +1. **Version Mismatch Test**: Open two terminals in the same tab, rapidly change directories in both - the race condition should be handled gracefully + +2. **Lock Bypass Test**: Toggle the lock while an OSC 7 update is in flight - the update should be rejected if lock is set + +3. **Debounce Test**: Rapidly `cd` between directories - only the final directory should be set as basedir + +4. **Memory Leak Test**: Open and close multiple tabs - the debounce map should be cleaned up + +## Notes + +- The spec mentions retry logic for manual updates (handleSetBaseDir, handleToggleLock) - this was NOT implemented as the spec noted it as optional for Phase 4 and the core race condition fixes are functional without it +- Pre-existing TypeScript errors in unrelated files (streamdown.tsx, notificationpopover.tsx) remain unfixed as they are not related to this implementation diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..06b718bc46 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,395 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Wave Terminal is an open-source, AI-native terminal built with Electron. It combines traditional terminal features with graphical capabilities like file previews, web browsing, and AI assistance. The architecture consists of four main components: + +1. **Frontend** (React + TypeScript) - UI and user interactions +2. **emain** (Electron Main Process) - Window management, native OS integration +3. **wavesrv** (Go Backend) - Core business logic, database, remote connections +4. **wsh** (Go CLI/Server) - Command-line tool and remote multiplexing server + +## Fork-Specific Notes + +This is a personal fork with experimental features. Key differences from upstream: + +- **Telemetry Removed** - All telemetry collection is disabled. Wave AI works without requiring telemetry to be enabled. +- **WaveApp/Tsunami Removed** - The experimental WaveApp Builder and Tsunami framework have been removed. +- **xterm.js 6.1.0** - Upgraded from 5.5.0 to 6.1.0-beta.106, enabling DEC mode 2026 (Synchronized Output) for proper TUI animations. Uses `DomScrollableElement` scrollbar. +- **Font Ligatures** - Enable with `"term:ligatures": true` in settings. Works with ligature fonts (Fira Code, JetBrains Mono, etc.). +- **Tab Base Directory System** - Project-centric workflow with colored tabs, breadcrumb navigation, and smart OSC 7 auto-detection. See `docs/docs/tabs.mdx` for full documentation. +- **Backend Validation** - Comprehensive metadata validation in `pkg/waveobj/validators.go` (path traversal prevention, URL validation, optimistic locking). +- **PowerShell Profile Loading** - User's `$PROFILE` is now sourced automatically after Wave's shell integration. +- **Windows PowerShell 7** - Build scripts require `pwsh` (PowerShell 7+), not Windows PowerShell 5.1. + +## Build System + +The project uses **Task** (modern Make alternative) for build orchestration. See `Taskfile.yml` for all available tasks. + +### Common Commands + +```bash +# Install dependencies (run this first after cloning) +task init + +# Development server with hot reload +task dev + +# Run standalone without hot reload +task start + +# Production build and packaging +task package + +# TypeScript type checking +task check:ts + +# Run tests +npm test + +# Run tests with coverage +npm run coverage + +# Clean build artifacts +task clean + +# Run a single Go test +go test -run TestName ./pkg/packagename/ + +# Clear dev data (useful when debugging) +task dev:cleardata + +# Clear dev config +task dev:clearconfig +``` + +### Quick Development Shortcuts + +```bash +# Fast development mode (macOS ARM64 only, no docsite, no wsh) +task electron:quickdev + +# Fast development mode (Windows x64 only, no docsite, no wsh) +task electron:winquickdev + +# Rebuild and install wsh locally (macOS ARM64 only) +task dev:installwsh +``` + +### Code Generation + +The project uses code generators to maintain type safety between Go and TypeScript: + +```bash +# Generate TypeScript bindings from Go types +task generate + +# This runs: +# - cmd/generatets/main-generatets.go -> frontend/types/gotypes.d.ts +# - cmd/generatets/main-generatets.go -> frontend/app/store/services.ts +# - cmd/generatets/main-generatets.go -> frontend/app/store/wshclientapi.ts +# - cmd/generatego/main-generatego.go -> various Go files +``` + +**Always run `task generate` after modifying:** +- Go RPC types in `pkg/wshrpc/` +- Service definitions in `pkg/service/` +- Wave object types in `pkg/waveobj/` + +## Architecture Overview + +### Frontend Architecture + +**Entry Point:** `frontend/wave.ts` +- Initializes the Wave Terminal application +- Sets up Jotai store, WPS (WebSocket Pub/Sub), and Monaco editor +- Root React component: `frontend/app/app.tsx` + +**State Management:** Jotai (atom-based state) +- Global atoms defined in `frontend/app/store/global.ts` +- Store instance: `globalStore` (exported from `frontend/app/store/jotaiStore.ts`) +- Key models: `GlobalModel`, `TabModel`, `ConnectionsModel` + +**Key Frontend Directories:** +- `frontend/app/block/` - Terminal blocks and renderers +- `frontend/app/view/` - Different view types (terminal, preview, web, etc.) +- `frontend/app/workspace/` - Workspace and tab layout management +- `frontend/layout/` - Layout system using `react-resizable-panels` +- `frontend/app/store/` - State management, RPC clients, WOS (Wave Object Store) +- `frontend/app/element/` - Reusable UI components +- `frontend/app/monaco/` - Monaco editor integration + +**Hot Module Reloading:** +- Vite enables HMR for most changes +- State changes (Jotai atoms, layout) may require hard reload: `Cmd+Shift+R` / `Ctrl+Shift+R` + +### Electron Main Process (emain) + +**Entry Point:** `emain/emain.ts` +- Manages Electron app lifecycle and window creation +- Spawns and manages the `wavesrv` backend process +- Handles native menus, context menus, and OS integration + +**IPC Communication:** +- Functions exposed from emain to frontend are defined in two places: + 1. `emain/preload.ts` - Electron preload script + 2. `frontend/types/custom.d.ts` - TypeScript declarations +- Frontend calls: `getApi().()` + +**Key emain Files:** +- `emain/emain.ts` - Main entry point +- `emain/emain-window.ts` - Window management +- `emain/emain-menu.ts` - Menu bar and context menus +- `emain/emain-wavesrv.ts` - wavesrv process management +- `emain/emain-tabview.ts` - Tab view management +- `emain/preload.ts` - Preload script for renderer + +### Go Backend (wavesrv) + +**Entry Point:** `cmd/server/main-server.go` + +**Core Packages:** +- `pkg/wstore/` - Database operations and Wave object persistence +- `pkg/waveobj/` - Wave object type definitions (Client, Window, Tab, Block, etc.) +- `pkg/service/` - HTTP service endpoints +- `pkg/wshrpc/` - WebSocket RPC system (communication with frontend and wsh) +- `pkg/blockcontroller/` - Terminal block lifecycle management +- `pkg/remote/` - SSH and remote connection handling +- `pkg/wcloud/` - Cloud sync and authentication +- `pkg/waveai/` - AI integration (OpenAI, Claude, etc.) +- `pkg/filestore/` - File storage and management + +**Database:** +- SQLite databases in `db/migrations-wstore/` and `db/migrations-filestore/` +- Wave objects: `Client`, `Window`, `Workspace`, `Tab`, `Block`, `LayoutState` +- All Wave object types registered in `pkg/waveobj/waveobj.go` + +**RPC Communication:** +- Uses custom `wshrpc` protocol over WebSocket +- RPC types defined in `pkg/wshrpc/wshrpctypes.go` +- Commands implemented in `pkg/wshrpc/wshserver/` and `pkg/wshrpc/wshremote/` + +### wsh (Wave Shell) + +**Entry Point:** `cmd/wsh/main-wsh.go` + +**Dual Purpose:** +1. CLI tool for controlling Wave from the terminal +2. Remote server for multiplexing connections and file streaming + +**Communication:** +- Uses `wshrpc` protocol over domain socket or WebSocket +- Enables single-connection multiplexing for remote terminals + +## Development Guidelines + +### Frontend Development + +1. **Use existing patterns:** Before adding new components, search for similar features: + ```bash + # Find similar views + grep -r "registerView" frontend/app/view/ + + # Find block implementations + ls frontend/app/block/ + ``` + +2. **State management:** Use Jotai atoms for reactive state + - Global atoms in `frontend/app/store/global.ts` + - Component-local atoms using `atom()` from `jotai` + +3. **RPC calls:** Use the generated `RpcApi` from `frontend/app/store/wshclientapi.ts`: + ```typescript + import { RpcApi } from "@/app/store/wshclientapi"; + import { TabRpcClient } from "@/app/store/wshrpcutil"; + + const result = await RpcApi.SomeCommand(TabRpcClient, { param: "value" }); + ``` + +4. **Wave Objects:** Access via WOS (Wave Object Store): + ```typescript + import * as WOS from "@/store/wos"; + + const tab = WOS.getObjectValue(WOS.makeORef("tab", tabId)); + ``` + +### Backend Development + +1. **Database changes:** Add migrations to `db/migrations-wstore/` or `db/migrations-filestore/` + +2. **New RPC commands:** + - Define in `pkg/wshrpc/wshrpctypes.go` + - Implement handler in `pkg/wshrpc/wshserver/` + - Run `task generate` to update TypeScript bindings + +3. **New Wave object types:** + - Add to `pkg/waveobj/wtype.go` + - Register in `init()` function + - Run `task generate` + +4. **Testing:** Write tests in `*_test.go` files: + ```bash + # Run Go tests + go test ./pkg/... + + # Run specific package + go test ./pkg/wstore/ + ``` + +### Code Style + +- **TypeScript:** Prettier + ESLint (configured in `eslint.config.js`, `prettier.config.cjs`) +- **Go:** Standard `go fmt` + `staticcheck` (see `staticcheck.conf`) +- **Text files:** Must end with a newline (`.editorconfig`) + +## Testing & Debugging + +### Frontend Debugging + +- **DevTools:** `Cmd+Option+I` (macOS) or `Ctrl+Option+I` (Windows/Linux) +- **Console access to global state:** + ```javascript + globalStore + globalAtoms + WOS + RpcApi + ``` + +### Backend Debugging + +- **Logs:** `~/.waveterm-dev/waveapp.log` (development mode) +- Contains both NodeJS (emain) and Go (wavesrv) logs + +### Running Tests + +```bash +# TypeScript/React tests +npm test + +# With coverage +npm run coverage + +# Go tests +go test ./pkg/... +``` + +## File Organization Conventions + +- **Go files:** `packagename_descriptor.go` (e.g., `waveobj_wtype.go`) +- **TypeScript files:** `component-name.tsx`, `util-name.ts` +- **SCSS files:** `component-name.scss` +- **Test files:** `*_test.go`, `*.test.ts`, `*.test.tsx` + +## Platform-Specific Notes + +### Windows + +- Use Zig for CGO static linking +- Use `task electron:winquickdev` for fast iteration +- Backslashes in file paths for Edit/MultiEdit tools + +### Linux + +- Requires Zig for CGO static linking +- Platform-specific dependencies in `BUILD.md` +- Use `USE_SYSTEM_FPM=1 task package` on ARM64 + +### macOS + +- No special dependencies +- `task electron:quickdev` works on ARM64 only + +## Important Paths + +- **Frontend entry:** `frontend/wave.ts` +- **Main React app:** `frontend/app/app.tsx` +- **Electron main:** `emain/emain.ts` +- **Go backend entry:** `cmd/server/main-server.go` +- **wsh entry:** `cmd/wsh/main-wsh.go` +- **Generated types:** `frontend/types/gotypes.d.ts` +- **RPC API:** `frontend/app/store/wshclientapi.ts` +- **Dev logs:** `~/.waveterm-dev/waveapp.log` +- **Metadata validators:** `pkg/waveobj/validators.go` +- **Tab docs:** `docs/docs/tabs.mdx` + +## Common Gotchas + +1. **After changing Go types, always run `task generate`** - TypeScript bindings won't update automatically +2. **emain and wavesrv don't hot-reload** - Must restart `task dev` to see changes +3. **Jotai atom changes may break HMR** - Use hard reload (`Cmd+Shift+R`) +4. **Database schema changes require migrations** - Never modify schema directly +5. **Wave objects must be registered** - Add to `init()` in `pkg/waveobj/waveobj.go` +6. **Windows requires PowerShell 7** - Build scripts use `pwsh -NoProfile`, not Windows PowerShell 5.1 + +## Tab Base Directory Feature + +Wave Terminal supports per-tab base directories that provide a project-centric workflow where all terminals and widgets within a tab share the same working directory context. + +### Metadata Keys + +| Key | Type | Description | +|-----|------|-------------| +| `tab:basedir` | `string` | Absolute path to base directory | +| `tab:basedirlock` | `boolean` | When true, disables smart auto-detection | + +### Behavior Model + +``` +┌─────────────────────────────────────────────────────────────┐ +│ TAB │ +│ tab:basedir = "/home/user/project" │ +│ tab:basedirlock = false │ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐ │ +│ │ Terminal 1 │ │ Terminal 2 │ │ File View │ │ +│ │ cmd:cwd = ... │ │ cmd:cwd = ... │ │ file = ... │ │ +│ │ (inherits tab) │ │ (inherits tab) │ │ (inherits) │ │ +│ └─────────────────┘ └─────────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Smart Auto-Detection (OSC 7) + +When a terminal reports its working directory via OSC 7: + +1. **Always:** Updates block's `cmd:cwd` metadata +2. **Conditionally:** Updates tab's `tab:basedir` if: + - `tab:basedirlock` is false + - `tab:basedir` is empty OR equals "~" + +This allows the first terminal to "teach" the tab its project directory. + +### Lock Semantics + +| State | Behavior | +|-------|----------| +| Unlocked (default) | OSC 7 can update `tab:basedir` (under conditions) | +| Locked | Only manual setting changes `tab:basedir` | + +### File Locations + +| Purpose | File | +|---------|------| +| Tab context menu UI | `frontend/app/tab/tab.tsx` | +| OSC 7 handling | `frontend/app/view/term/termwrap.ts` | +| Terminal inheritance | `frontend/app/store/keymodel.ts` | +| Widget inheritance | `frontend/app/workspace/widgets.tsx` | +| Go type definitions | `pkg/waveobj/wtypemeta.go` | +| Metadata constants | `pkg/waveobj/metaconsts.go` | + +### Related Presets + +Tab variable presets can include base directory configuration: + +```json +// File: pkg/wconfig/defaultconfig/presets/tabvars.json +{ + "tabvar@my-project": { + "display:name": "My Project", + "tab:basedir": "/home/user/my-project", + "tab:basedirlock": true + } +} +``` diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000000..22b0ef9599 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,7 @@ +PERSONAL NOTES TO ADD TO GITIGNORE / DO NOT REMOVE! + +Need drag and drop from files to viewer +Need option to open file in default viewer (need a default viewer) +Need a treeview, not a file explorer like we have now, slimmer, easier to navigate, synced with the main folder of the tab +Need a Project Notes widget +Need a TODO Widget, sync to file, allow images (paste image, save in folder, similar to md view) diff --git a/README.md b/README.md index da8224ef5a..b978c52f1d 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,102 @@

-# Wave Terminal +# Wave Terminal (Sawka Fork) + +> **This is a personal fork of [Wave Terminal](https://github.com/wavetermdev/waveterm)** with experimental features and customizations. For the official version, visit the [upstream repository](https://github.com/wavetermdev/waveterm). + +--- + +## Fork Changes + +This fork includes the following modifications from upstream: + +### Tab Base Directory System (Major Feature) + +A complete project-centric workflow system for tabs: + +- **VS Code-Style Tab Bar** - Colored tab backgrounds based on directory context +- **Breadcrumb Navigation** - Full path breadcrumbs below tab bar for quick navigation +- **Smart Auto-Detection** - OSC 7 integration automatically detects working directory from terminal +- **Directory Locking** - Lock base directory to prevent auto-detection changes +- **Tab Presets** - Save and apply tab configurations via presets (`tabvar@project-name`) +- **Tab Color Picker** - 8-color palette for manual tab coloring via context menu +- **Terminal Status Indicators** - Visual status for running/finished/stopped commands + +**New Files:** +- `frontend/app/store/tab-model.ts` - Tab state management +- `frontend/app/store/tab-basedir-validator.ts` - Path validation +- `frontend/app/store/tab-basedir-validation-hook.ts` - React hook for validation +- `frontend/app/tab/tab-menu.ts` - Reusable preset menu builder +- `frontend/util/pathutil.ts` - Cross-platform path utilities +- `frontend/util/presetutil.ts` - Preset validation and sanitization +- `docs/docs/tabs.mdx` - Full documentation + +### Backend Security & Validation + +Comprehensive metadata validation to prevent injection attacks: + +- **Path Validation** - Validates all path fields (traversal attacks, length limits) +- **URL Validation** - Validates URL fields with scheme restrictions +- **String Sanitization** - Length limits and content validation +- **Optimistic Locking** - Version-based concurrency control for metadata updates +- **Race Condition Fixes** - TOCTOU vulnerability prevention in OSC 7 updates + +**New Files:** +- `pkg/waveobj/validators.go` - 935-line validation framework +- `pkg/wconfig/defaultconfig/presets/tabvars.json` - Default tab presets +- `schema/tabvarspresets.json` - JSON schema for presets + +### Terminal Improvements + +- **xterm.js 6.1.0 Upgrade** - Updated from 5.5.0 to 6.1.0-beta.106 + - Enables DEC mode 2026 (Synchronized Output) for proper TUI animations + - Fixes npm progress bars, htop, and spinner animations scrolling issues + - Uses public `terminal.dimensions` API (no more private API hacks) + - New DomScrollableElement scrollbar with custom styling +- **Font Ligatures Support** - Enable programming ligatures with `"term:ligatures": true` + - Works with ligature fonts like Fira Code, JetBrains Mono, Cascadia Code + - Uses `@xterm/addon-ligatures` for native font discovery in Electron + - See screenshot: `assets/ligatures-demo.png` +- **OSC 7 Debouncing** - 300ms debounce for rapid directory changes +- **Memory Leak Prevention** - Cleanup handlers for tab close events + +### Telemetry Removal + +- **No Telemetry Required** - Wave AI works without enabling telemetry +- **Telemetry Disabled by Default** - All telemetry collection is disabled +- **No Cloud Mode Restrictions** - Wave AI cloud modes accessible without telemetry opt-in +- **Simplified Onboarding** - Removed telemetry toggle from initial setup + +### PowerShell Improvements + +- **Profile Loading** - User's PowerShell profile (`$PROFILE`) is now sourced automatically + - Wave launches with `-NoProfile` for clean environment, then sources your profile + - Custom aliases, functions, and prompt customizations now work + +### Electron IPC Additions + +- `showOpenDialog` - Native directory picker for setting tab base directory +- `showWorkspaceAppMenu` - Workspace menu from breadcrumb bar + +### Windows Build & Runtime Fixes + +- **PowerShell 7 Requirement** - All build commands use `pwsh -NoProfile` +- **Shell Launch Fix** - Runtime shells use `-NoProfile` flag +- **Build Prerequisites** - Updated BUILD.md with PowerShell 7 requirement + +### Syncing with Upstream + +This fork is periodically rebased on upstream main: + +```bash +git fetch upstream +git checkout sawka-main +git rebase upstream/main +git push origin sawka-main --force-with-lease +``` + +--- [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm?ref=badge_shield) diff --git a/Taskfile.yml b/Taskfile.yml index 1e6947de6c..3b632463a5 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -8,8 +8,10 @@ vars: BIN_DIR: "bin" VERSION: sh: node version.cjs - RMRF: '{{if eq OS "windows"}}powershell Remove-Item -Force -Recurse -ErrorAction SilentlyContinue{{else}}rm -rf{{end}}' - DATE: '{{if eq OS "windows"}}powershell Get-Date -UFormat{{else}}date{{end}}' + RMRF: '{{if eq OS "windows"}}pwsh -NoProfile -Command Remove-Item -Force -Recurse -ErrorAction SilentlyContinue{{else}}rm -rf{{end}}' + DATE: '{{if eq OS "windows"}}pwsh -NoProfile -Command "Get-Date -UFormat"{{else}}date{{end}}' + BUILD_TIME: + sh: '{{if eq OS "windows"}}pwsh -NoProfile -Command "Get-Date -UFormat +%Y%m%d%H%M"{{else}}date +%Y%m%d%H%M{{end}}' ARTIFACTS_BUCKET: waveterm-github-artifacts/staging-w2 RELEASES_BUCKET: dl.waveterm.dev/releases-w2 WINGET_PACKAGE: CommandLine.Wave @@ -23,7 +25,6 @@ tasks: deps: - npm:install - build:backend - - build:tsunamiscaffold env: WAVETERM_ENVFILE: "{{.ROOT_DIR}}/.env" WCLOUD_PING_ENDPOINT: "https://ping-dev.waveterm.dev/central" @@ -113,7 +114,6 @@ tasks: - clean - npm:install - build:backend - - build:tsunamiscaffold build:frontend:dev: desc: Build the frontend in development mode. @@ -137,9 +137,6 @@ tasks: - pkg/**/*.go - pkg/**/*.sh - cmd/**/*.go - - tsunami/go.mod - - tsunami/go.sum - - tsunami/**/*.go build:schema: desc: Build the schema for configuration. @@ -168,7 +165,6 @@ tasks: - "pkg/**/*.go" - "pkg/**/*.json" - "pkg/**/*.sh" - - tsunami/**/*.go generates: - dist/bin/wavesrv.* @@ -196,7 +192,6 @@ tasks: - "pkg/**/*.go" - "pkg/**/*.json" - "pkg/**/*.sh" - - "tsunami/**/*.go" generates: - dist/bin/wavesrv.* @@ -215,7 +210,6 @@ tasks: - "pkg/**/*.go" - "pkg/**/*.json" - "pkg/**/*.sh" - - "tsunami/**/*.go" generates: - dist/bin/wavesrv.x64.exe @@ -223,7 +217,7 @@ tasks: desc: Build the wavesrv component for Windows platforms (only generates artifacts for the current architecture). platforms: [windows] cmds: - - cmd: powershell -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path dist/bin/wavesrv*" + - cmd: pwsh -NoProfile -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path dist/bin/wavesrv*" ignore_error: true - task: build:server:internal vars: @@ -250,7 +244,7 @@ tasks: vars: - ARCHS cmd: - cmd: CGO_ENABLED=1 GOARCH={{.GOARCH}} {{.GO_ENV_VARS}} go build -tags "osusergo,sqlite_omit_load_extension" -ldflags "{{.GO_LDFLAGS}} -X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.WaveVersion={{.VERSION}}" -o dist/bin/wavesrv.{{if eq .GOARCH "amd64"}}x64{{else}}{{.GOARCH}}{{end}}{{exeExt}} cmd/server/main-server.go + cmd: CGO_ENABLED=1 GOARCH={{.GOARCH}} {{.GO_ENV_VARS}} go build -tags "osusergo,sqlite_omit_load_extension" -ldflags "{{.GO_LDFLAGS}} -X main.BuildTime={{.BUILD_TIME}} -X main.WaveVersion={{.VERSION}}" -o dist/bin/wavesrv.{{if eq .GOARCH "amd64"}}x64{{else}}{{.GOARCH}}{{end}}{{exeExt}} cmd/server/main-server.go for: var: ARCHS split: "," @@ -263,7 +257,7 @@ tasks: - cmd: rm -f dist/bin/wsh* platforms: [darwin, linux] ignore_error: true - - cmd: powershell -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path dist/bin/wsh*" + - cmd: pwsh -NoProfile -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path dist/bin/wsh*" platforms: [windows] ignore_error: true - task: build:wsh:internal @@ -318,24 +312,9 @@ tasks: - GOOS - GOARCH - VERSION - cmd: (CGO_ENABLED=0 GOOS={{.GOOS}} GOARCH={{.GOARCH}} go build -ldflags="-s -w -X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.WaveVersion={{.VERSION}}" -o dist/bin/wsh-{{.VERSION}}-{{.GOOS}}.{{.NORMALIZEDARCH}}{{.EXT}} cmd/wsh/main-wsh.go) + cmd: (CGO_ENABLED=0 GOOS={{.GOOS}} GOARCH={{.GOARCH}} go build -ldflags="-s -w -X main.BuildTime={{.BUILD_TIME}} -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 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: @@ -486,7 +465,7 @@ tasks: copyfiles:*:*: desc: Recursively copy directory and its contents. internal: true - cmd: '{{if eq OS "windows"}}powershell Copy-Item -Recurse -Force -Path {{index .MATCH 0}} -Destination {{index .MATCH 1}}{{else}}mkdir -p "$(dirname {{index .MATCH 1}})" && cp -r {{index .MATCH 0}} {{index .MATCH 1}}{{end}}' + cmd: '{{if eq OS "windows"}}pwsh -NoProfile -Command Copy-Item -Recurse -Force -Path {{index .MATCH 0}} -Destination {{index .MATCH 1}}{{else}}mkdir -p "$(dirname {{index .MATCH 1}})" && cp -r {{index .MATCH 0}} {{index .MATCH 1}}{{end}}' clean: desc: clean make/dist directories @@ -496,140 +475,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 -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 New-Item -ItemType Directory -Force -Path scaffold - - powershell Copy-Item -Path ../templates/package.json.tmpl -Destination scaffold/package.json - - powershell -Command "Set-Location scaffold; npm install" - - powershell Move-Item -Path scaffold/node_modules -Destination scaffold/nm - - powershell Copy-Item -Recurse -Force -Path dist -Destination scaffold/ - - powershell New-Item -ItemType Directory -Force -Path scaffold/dist/tw - - powershell Copy-Item -Path '../templates/*.go.tmpl' -Destination scaffold/ - - powershell Copy-Item -Path ../templates/tailwind.css -Destination scaffold/ - - powershell Copy-Item -Path ../templates/gitignore.tmpl -Destination scaffold/.gitignore - - powershell Copy-Item -Path 'src/element/*.tsx' -Destination scaffold/dist/tw/ - - powershell Copy-Item -Path '../ui/*.go' -Destination scaffold/dist/tw/ - - powershell 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 -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/assets/ligatures-demo.png b/assets/ligatures-demo.png new file mode 100644 index 0000000000..de43189f0c Binary files /dev/null and b/assets/ligatures-demo.png differ diff --git a/cmd/generatego/main-generatego.go b/cmd/generatego/main-generatego.go index 05c3abea9f..b0b901a449 100644 --- a/cmd/generatego/main-generatego.go +++ b/cmd/generatego/main-generatego.go @@ -30,7 +30,6 @@ func GenerateWshClient() error { "github.com/wavetermdev/waveterm/pkg/wconfig", "github.com/wavetermdev/waveterm/pkg/waveobj", "github.com/wavetermdev/waveterm/pkg/wps", - "github.com/wavetermdev/waveterm/pkg/vdom", "github.com/wavetermdev/waveterm/pkg/util/iochan/iochantypes", "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes", }) diff --git a/docs/docs/tabs.mdx b/docs/docs/tabs.mdx index 354089be4c..11fe415952 100644 --- a/docs/docs/tabs.mdx +++ b/docs/docs/tabs.mdx @@ -45,6 +45,71 @@ You can switch to an existing tab by clicking on it in the tab bar. You can also Pinning a tab makes it harder to close accidentally. You can pin a tab by right-clicking on it and selecting "Pin Tab" from the context menu that appears. You can also pin a tab by dragging it to a lesser index than an existing pinned tab. When a tab is pinned, the button for the tab will be replaced with a button. Clicking this button will unpin the tab. You can also unpin a tab by dragging it to an index higher than an existing unpinned tab. +### Tab Base Directory + +You can set a base directory for each tab to create project-centric workflows. All terminals and file previews launched within the tab will use this directory as their starting point. + +#### Setting the Base Directory + +1. Right-click on a tab in the tab bar +2. Select "Base Directory" from the context menu +3. Click "Set Base Directory..." +4. Choose a directory using the file picker + +Once set, the base directory will be displayed in the tab header with a folder icon. All new terminals opened in that tab will start in this directory. + +#### Smart Auto-Detection + +Wave Terminal can automatically detect and set the base directory from your terminal's working directory. When you `cd` into a project directory, Wave uses OSC 7 escape sequences to learn this location. + +Auto-detection occurs when: +- The tab has no base directory set yet +- The base directory is not locked + +This means the first directory you navigate to in a new tab becomes the tab's base directory automatically. + +#### Locking the Base Directory + +To prevent auto-detection from changing your base directory: + +1. Right-click on the tab +2. Select "Base Directory" +3. Click "Lock (Disable Smart Detection)" + +A lock icon will appear next to the base directory indicator in the tab header. When locked, you can still manually change the base directory, but terminal navigation will not affect it. Click the lock icon directly to toggle the lock state. + +#### Clearing the Base Directory + +To remove the base directory and return to default behavior: + +1. Right-click on the tab +2. Select "Base Directory" +3. Click "Clear Base Directory" + +#### Use Cases + +- **Project-centric workflows:** Set tab base directory to project root; all terminals start there +- **Multi-project organization:** Different tabs for different projects, each with its own base directory +- **Stable context:** Lock the base directory when navigating to multiple subdirectories + +#### Configuration via Presets + +You can create presets that include base directory configuration. Add entries to your settings: + +```json +{ + "presets": { + "tabvar@my-project": { + "display:name": "My Project", + "tab:basedir": "/path/to/my-project", + "tab:basedirlock": true + } + } +} +``` + +Apply presets via the tab's right-click menu: "Tab Variables" > Select preset + ## Tab Layout System The tabs are comprised of tiled blocks. The contents of each block is a single widget. You can move blocks around and arrange them into layouts that best-suit your workflow. You can also magnify blocks to focus on a specific widget. diff --git a/docs/docs/telemetry.mdx b/docs/docs/telemetry.mdx index 2f9132276d..2f84649cc3 100644 --- a/docs/docs/telemetry.mdx +++ b/docs/docs/telemetry.mdx @@ -4,6 +4,10 @@ title: Telemetry id: "telemetry" --- +:::warning Fork Notice +**This fork has telemetry disabled.** All telemetry collection and reporting has been removed from this version of Wave Terminal. Wave AI cloud modes work without requiring telemetry to be enabled. The documentation below is retained for reference only and describes the upstream behavior. +::: + ## 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. diff --git a/docs/docs/waveai-modes.mdx b/docs/docs/waveai-modes.mdx index 437a6ba99d..58e1e2853e 100644 --- a/docs/docs/waveai-modes.mdx +++ b/docs/docs/waveai-modes.mdx @@ -74,9 +74,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 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/emain/emain-builder.ts b/emain/emain-builder.ts deleted file mode 100644 index 33ca244681..0000000000 --- a/emain/emain-builder.ts +++ /dev/null @@ -1,121 +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 } 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("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 09ba69d1eb..0a6c0543ce 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -13,7 +13,6 @@ import { getWebServerEndpoint } from "../frontend/util/endpoints"; import * as keyutil from "../frontend/util/keyutil"; import { fireAndForget, parseDataUrl } from "../frontend/util/util"; import { incrementTermCommandsRun } 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"; @@ -26,17 +25,6 @@ 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; @@ -389,17 +377,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); }); @@ -415,42 +392,95 @@ export function initIpcHandlers() { event.sender.paste(); }); - electron.ipcMain.on("open-builder", (event, appId?: string) => { - openBuilderWindow(appId); - }); + electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); - 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("do-refresh", (event) => { + event.sender.reloadIgnoringCache(); }); - electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); + electron.ipcMain.handle( + "show-open-dialog", + async ( + event: electron.IpcMainInvokeEvent, + options: { + title?: string; + defaultPath?: string; + properties?: Array<"openFile" | "openDirectory" | "multiSelections" | "showHiddenFiles">; + filters?: Array<{ name: string; extensions: string[] }>; + } + ): Promise => { + // SECURITY: Restrict to directory selection only for this feature + const allowedProperties = + options.properties?.filter((p) => ["openDirectory", "showHiddenFiles"].includes(p)) || + ["openDirectory"]; + + // SECURITY: Sanitize defaultPath + let sanitizedDefaultPath = options.defaultPath; + if (sanitizedDefaultPath) { + // CRITICAL SECURITY: Block UNC paths on Windows to prevent network attacks + // UNC paths like \\attacker.com\share can leak credentials or data + if (process.platform === "win32" && /^[\\/]{2}[^\\/]/.test(sanitizedDefaultPath)) { + console.warn("show-open-dialog: blocked UNC path in defaultPath:", sanitizedDefaultPath); + sanitizedDefaultPath = electronApp.getPath("home"); + } else { + // Expand home directory shorthand + if (sanitizedDefaultPath.startsWith("~")) { + sanitizedDefaultPath = sanitizedDefaultPath.replace(/^~/, electronApp.getPath("home")); + } + // Normalize path to resolve any .. components + sanitizedDefaultPath = path.normalize(sanitizedDefaultPath); + + // Validate the path exists and is accessible + try { + await fs.promises.access(sanitizedDefaultPath, fs.constants.R_OK); + } catch { + // Fall back to home directory if path doesn't exist or isn't readable + sanitizedDefaultPath = electronApp.getPath("home"); + } + } + } - 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); + // Get the appropriate parent window + const ww = getWaveWindowByWebContentsId(event.sender.id); + const parentWindow = ww ?? electron.BrowserWindow.getFocusedWindow(); + + const result = await electron.dialog.showOpenDialog(parentWindow, { + title: options.title ?? "Select Directory", + defaultPath: sanitizedDefaultPath, + properties: allowedProperties as electron.OpenDialogOptions["properties"], + filters: options.filters, + }); + + // Return empty array if canceled + if (result.canceled || !result.filePaths) { + return []; } - } - bw.destroy(); - }); - electron.ipcMain.on("do-refresh", (event) => { - event.sender.reloadIgnoringCache(); - }); + // SECURITY: Validate returned paths + const validPaths: string[] = []; + for (const filePath of result.filePaths) { + // CRITICAL SECURITY: Block UNC paths in returned values on Windows + if (process.platform === "win32" && /^[\\/]{2}[^\\/]/.test(filePath)) { + console.warn("show-open-dialog: blocked UNC path in result:", filePath); + continue; + } + + try { + const stats = await fs.promises.stat(filePath); + if (allowedProperties.includes("openDirectory")) { + if (stats.isDirectory()) { + validPaths.push(filePath); + } + } else { + validPaths.push(filePath); + } + } catch { + // Skip paths that can't be accessed + console.warn("show-open-dialog: skipping inaccessible path:", filePath); + } + } + + return validPaths; + } + ); } diff --git a/emain/emain-menu.ts b/emain/emain-menu.ts index f4d45f8639..93740c6944 100644 --- a/emain/emain-menu.ts +++ b/emain/emain-menu.ts @@ -5,9 +5,7 @@ import { waveEventSubscribe } 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 } from "./emain-util"; import { @@ -31,11 +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) { return window.activeTabView.webContents; @@ -126,8 +119,7 @@ function makeEditMenu(fullConfig?: FullConfigType): Electron.MenuItemConstructor function makeFileMenu( numWaveWindows: number, - callbacks: AppMenuCallbacks, - fullConfig: FullConfigType + callbacks: AppMenuCallbacks ): Electron.MenuItemConstructorOptions[] { const fileMenu: Electron.MenuItemConstructorOptions[] = [ { @@ -143,14 +135,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)", @@ -202,13 +186,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(); @@ -314,12 +297,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 { @@ -329,8 +311,8 @@ async function makeFullAppMenu(callbacks: AppMenuCallbacks, workspaceOrBuilderId console.error("Error fetching config:", e); } const editMenu = makeEditMenu(fullConfig); - const fileMenu = makeFileMenu(numWaveWindows, callbacks, fullConfig); - const viewMenu = makeViewMenu(webContents, callbacks, isBuilderWindowFocused, fullscreenOnLaunch); + const fileMenu = makeFileMenu(numWaveWindows, callbacks); + const viewMenu = makeViewMenu(webContents, callbacks, fullscreenOnLaunch); let workspaceMenu: Electron.MenuItemConstructorOptions[] = null; try { workspaceMenu = await getWorkspaceMenu(); @@ -349,7 +331,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", @@ -363,13 +345,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 ); } @@ -391,17 +373,11 @@ 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; } @@ -437,15 +413,15 @@ function convertMenuDefArrToMenu( electron.ipcMain.on( "contextmenu-show", - (event, workspaceOrBuilderId: string, menuDefArr: ElectronContextMenuItem[]) => { + (event, workspaceId: string, menuDefArr: ElectronContextMenuItem[]) => { if (menuDefArr.length === 0) { event.returnValue = true; return; } fireAndForget(async () => { - const webContents = getWebContentsByWorkspaceOrBuilderId(workspaceOrBuilderId); + const webContents = getWebContentsByWorkspaceId(workspaceId); if (!webContents) { - console.error("invalid window for context menu:", workspaceOrBuilderId); + console.error("invalid window for context menu:", workspaceId); return; } @@ -458,7 +434,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; @@ -469,19 +445,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.ts b/emain/emain.ts index c8b0cfee28..0eb289c427 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -3,7 +3,6 @@ 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"; @@ -259,10 +258,6 @@ electronApp.on("before-quit", (e) => { for (const window of allWindows) { hideWindowWithCatch(window); } - const allBuilders = getAllBuilderWindows(); - for (const builder of allBuilders) { - builder.hide(); - } if (getIsWaveSrvDead()) { console.log("wavesrv is dead, quitting immediately"); setForceQuit(true); @@ -308,16 +303,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(); }); diff --git a/emain/preload.ts b/emain/preload.ts index c6bdf14988..2ce0aa8ae2 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -19,7 +19,6 @@ contextBridge.exposeInMainWorld("api", { 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) => ipcRenderer.on("contextmenu-click", (_event, id) => callback(id)), downloadFile: (filePath) => ipcRenderer.send("download", { filePath }), @@ -54,7 +53,6 @@ contextBridge.exposeInMainWorld("api", { closeTab: (workspaceId, tabId) => ipcRenderer.send("close-tab", workspaceId, tabId), 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), @@ -62,12 +60,15 @@ contextBridge.exposeInMainWorld("api", { 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: () => ipcRenderer.send("increment-term-commands"), 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"), + showOpenDialog: (options: { + title?: string; + defaultPath?: string; + properties?: Array<"openFile" | "openDirectory" | "multiSelections" | "showHiddenFiles">; + filters?: Array<{ name: string; extensions: string[] }>; + }) => ipcRenderer.invoke("show-open-dialog", options), }); // Custom event for "new-window" diff --git a/frontend/app/aipanel/ai-utils.ts b/frontend/app/aipanel/ai-utils.ts index 8bfd67bdc0..1fa261ec57 100644 --- a/frontend/app/aipanel/ai-utils.ts +++ b/frontend/app/aipanel/ai-utils.ts @@ -546,15 +546,10 @@ export interface FilteredAIModeConfigs { 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 allConfigs = Object.entries(aiModeConfigs).map(([mode, config]) => ({ mode, ...config })); const otherProviderConfigs = allConfigs .filter((config) => config["ai:provider"] !== "wave") diff --git a/frontend/app/aipanel/aimode.tsx b/frontend/app/aipanel/aimode.tsx index 9848c2327d..7e7f466a11 100644 --- a/frontend/app/aipanel/aimode.tsx +++ b/frontend/app/aipanel/aimode.tsx @@ -3,8 +3,6 @@ import { Tooltip } from "@/app/element/tooltip"; import { atoms, getSettingsKeyAtom } from "@/app/store/global"; -import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; import { cn, fireAndForget, makeIconClass } from "@/util/util"; import { useAtomValue } from "jotai"; import { memo, useRef, useState } from "react"; @@ -58,7 +56,6 @@ interface ConfigSection { sectionName: string; configs: AIModeConfigWithMode[]; isIncompatible?: boolean; - noTelemetry?: boolean; } function computeCompatibleSections( @@ -112,8 +109,7 @@ function computeCompatibleSections( function computeWaveCloudSections( waveProviderConfigs: AIModeConfigWithMode[], - otherProviderConfigs: AIModeConfigWithMode[], - telemetryEnabled: boolean + otherProviderConfigs: AIModeConfigWithMode[] ): ConfigSection[] { const sections: ConfigSection[] = []; @@ -121,7 +117,6 @@ function computeWaveCloudSections( sections.push({ sectionName: "Wave AI Cloud", configs: waveProviderConfigs, - noTelemetry: !telemetryEnabled, }); } if (otherProviderConfigs.length > 0) { @@ -143,21 +138,19 @@ export const AIModeDropdown = memo(({ compatibilityMode = false }: AIModeDropdow 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); + : computeWaveCloudSections(waveProviderConfigs, otherProviderConfigs); const showSectionHeaders = compatibilityMode || sections.length > 1; @@ -185,30 +178,11 @@ 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); - }); - }; - return (
- )} )} {section.configs.map((config, index) => { @@ -283,9 +249,7 @@ export const AIModeDropdown = memo(({ compatibilityMode = false }: AIModeDropdow 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 isDisabled = isPremiumDisabled || isIncompatibleDisabled; const isSelected = currentMode === config.mode; return ( { - 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", @@ -130,16 +101,6 @@ export async function handleWaveAIContextMenu(e: React.MouseEvent, showCopy: boo menu.push({ label: "Configure Modes", click: () => { - RpcApi.RecordTEventCommand( - TabRpcClient, - { - event: "action:other", - props: { - "action:type": "waveai:configuremodes:contextmenu", - }, - }, - { noresponse: true } - ); model.openWaveAIConfig(); }, }); diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 504910a44c..ae7679d507 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -24,7 +24,6 @@ import { AIPanelMessages } from "./aipanelmessages"; import { AIRateLimitStrip } from "./airatelimitstrip"; import { WaveUIMessage } from "./aitypes"; import { BYOKAnnouncement } from "./byokannouncement"; -import { TelemetryRequiredMessage } from "./telemetryrequired"; import { WaveAIModel } from "./waveai-model"; const AIBlockMask = memo(() => { @@ -180,24 +179,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); @@ -232,12 +213,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; }); @@ -253,34 +233,21 @@ const AIPanelComponentInner = memo(() => { const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom); const showOverlayBlockNums = jotai.useAtomValue(getSettingsKeyAtom("app:showoverlayblocknums")) ?? true; const isFocused = jotai.useAtomValue(model.isWaveAIFocusedAtom); - const telemetryEnabled = jotai.useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false; const isPanelVisible = jotai.useAtomValue(model.getPanelVisibleAtom()); const tabModel = maybeUseTabModel(); - const defaultMode = jotai.useAtomValue(getSettingsKeyAtom("waveai:defaultmode")) ?? "waveai@balanced"; - const aiModeConfigs = jotai.useAtomValue(model.aiModeConfigs); - - const hasCustomModes = Object.keys(aiModeConfigs).some((key) => !key.startsWith("waveai@")); - const isUsingCustomMode = !defaultMode.startsWith("waveai@"); - const allowAccess = telemetryEnabled || (hasCustomModes && isUsingCustomMode); const { messages, sendMessage, status, setMessages, error, stop } = useChat({ transport: new DefaultChatTransport({ api: model.getUseChatEndpointUrl(), prepareSendMessagesRequest: (opts) => { const msg = model.getAndClearMessage(); - const windowType = globalStore.get(atoms.waveWindowType); const body: any = { msg, chatid: globalStore.get(model.chatId), widgetaccess: globalStore.get(model.widgetAccessAtom), aimode: globalStore.get(model.currentAIMode), + tabid: tabModel.tabId, }; - if (windowType === "builder") { - body.builderid = globalStore.get(atoms.builderId); - body.builderappid = globalStore.get(atoms.builderAppId); - } else { - body.tabid = tabModel.tabId; - } return { body }; }, }), @@ -361,10 +328,6 @@ const AIPanelComponentInner = memo(() => { }; const handleDragOver = (e: React.DragEvent) => { - if (!allowAccess) { - return; - } - const hasFiles = hasFilesDragged(e.dataTransfer); // Only handle native file drags here, let react-dnd handle FILE_ITEM drags @@ -381,10 +344,6 @@ const AIPanelComponentInner = memo(() => { }; const handleDragEnter = (e: React.DragEvent) => { - if (!allowAccess) { - return; - } - const hasFiles = hasFilesDragged(e.dataTransfer); // Only handle native file drags here, let react-dnd handle FILE_ITEM drags @@ -399,10 +358,6 @@ const AIPanelComponentInner = memo(() => { }; const handleDragLeave = (e: React.DragEvent) => { - if (!allowAccess) { - return; - } - const hasFiles = hasFilesDragged(e.dataTransfer); // Only handle native file drags here, let react-dnd handle FILE_ITEM drags @@ -424,13 +379,6 @@ const AIPanelComponentInner = memo(() => { }; const handleDrop = async (e: React.DragEvent) => { - if (!allowAccess) { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - return; - } - // Check if this is a FILE_ITEM drag from react-dnd // If so, let react-dnd handle it instead if (!e.dataTransfer.files.length) { @@ -465,12 +413,9 @@ const AIPanelComponentInner = memo(() => { const handleFileItemDrop = useCallback( (draggedFile: DraggedFile) => { - if (!allowAccess) { - return; - } model.addFileFromRemoteUri(draggedFile); }, - [model, allowAccess] + [model] ); const [{ isOver, canDrop }, drop] = useDrop( @@ -537,14 +482,13 @@ const AIPanelComponentInner = memo(() => { 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 ? "border-2 border-accent" : "border-2 border-transparent" )} style={{ - borderTopRightRadius: model.inBuilder ? 0 : 10, - borderBottomRightRadius: model.inBuilder ? 0 : 10, + borderTopRightRadius: 10, + borderBottomRightRadius: 10, borderBottomLeftRadius: 10, }} onFocusCapture={handleFocusCapture} @@ -556,38 +500,32 @@ const AIPanelComponentInner = memo(() => { inert={!isPanelVisible ? true : undefined} > - {(isDragOver || isReactDndDragOver) && allowAccess && } + {(isDragOver || isReactDndDragOver) && } {showBlockMask && }
- {!allowAccess ? ( - + {messages.length === 0 && initialLoadDone ? ( +
handleWaveAIContextMenu(e, true)} + > +
+ +
+ +
) : ( - <> - {messages.length === 0 && initialLoadDone ? ( -
handleWaveAIContextMenu(e, true)} - > -
- -
- {model.inBuilder ? : } -
- ) : ( - handleWaveAIContextMenu(e, true)} - /> - )} - - - - + handleWaveAIContextMenu(e, true)} + /> )} + + +
); diff --git a/frontend/app/aipanel/aipanelheader.tsx b/frontend/app/aipanel/aipanelheader.tsx index da54f6c9e9..f9715335ea 100644 --- a/frontend/app/aipanel/aipanelheader.tsx +++ b/frontend/app/aipanel/aipanelheader.tsx @@ -9,7 +9,6 @@ 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); @@ -30,37 +29,35 @@ export const AIPanelHeader = memo(() => {
- {!inBuilder && ( -
- Context - Widget Context - -
- )} + {widgetAccess ? "ON" : "OFF"} + + +
- - - - - - - -
- - ); -}; - -TelemetryRequiredMessage.displayName = "TelemetryRequiredMessage"; - -export { TelemetryRequiredMessage }; diff --git a/frontend/app/aipanel/waveai-model.tsx b/frontend/app/aipanel/waveai-model.tsx index a13bbf4f1c..c1a7952256 100644 --- a/frontend/app/aipanel/waveai-model.tsx +++ b/frontend/app/aipanel/waveai-model.tsx @@ -14,7 +14,6 @@ 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"; @@ -51,7 +50,6 @@ export class WaveAIModel { // Used for injecting Wave-specific message data into DefaultChatTransport's prepareSendMessagesRequest realMessage: AIMessage | null = null; orefContext: ORef; - inBuilder: boolean = false; isAIStreaming = jotai.atom(false); widgetAccessAtom!: jotai.Atom; @@ -75,9 +73,8 @@ export class WaveAIModel { restoreBackupStatus: jotai.PrimitiveAtom<"idle" | "processing" | "success" | "error"> = jotai.atom("idle"); restoreBackupError: jotai.PrimitiveAtom = jotai.atom(null) as jotai.PrimitiveAtom; - private constructor(orefContext: ORef, inBuilder: boolean) { + private constructor(orefContext: ORef) { this.orefContext = orefContext; - this.inBuilder = inBuilder; this.chatId = jotai.atom(null) as jotai.PrimitiveAtom; this.aiModeConfigs = atoms.waveaiModeConfigAtom; @@ -87,9 +84,6 @@ export class WaveAIModel { }); this.widgetAccessAtom = jotai.atom((get) => { - if (this.inBuilder) { - return true; - } const widgetAccessMetaAtom = getOrefMetaKeyAtom(this.orefContext, "waveai:widgetcontext"); const value = get(widgetAccessMetaAtom); return value ?? true; @@ -101,32 +95,15 @@ 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 ? "waveai@balanced" : "invalid"; - } const aiModeConfigs = get(this.aiModeConfigs); - if (!telemetryEnabled) { - let mode = get(getSettingsKeyAtom("waveai:defaultmode")); - if (mode == null || mode.startsWith("waveai@")) { - return "unknown"; - } - return mode; - } const hasPremium = get(this.hasPremiumAtom); const waveFallback = hasPremium ? "waveai@balanced" : "waveai@quick"; let mode = get(getSettingsKeyAtom("waveai:defaultmode")) ?? waveFallback; @@ -150,17 +127,9 @@ export class WaveAIModel { static getInstance(): WaveAIModel { if (!WaveAIModel.instance) { - const windowType = globalStore.get(atoms.waveWindowType); - let orefContext: ORef; - const inBuilder = windowType === "builder"; - if (inBuilder) { - 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, inBuilder); + 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; @@ -316,7 +285,7 @@ export class WaveAIModel { } focusInput() { - if (!this.inBuilder && !WorkspaceLayoutModel.getInstance().getAIPanelVisible()) { + if (!WorkspaceLayoutModel.getInstance().getAIPanelVisible()) { WorkspaceLayoutModel.getInstance().setAIPanelVisible(true); } if (this.inputRef?.current) { @@ -395,11 +364,6 @@ export class WaveAIModel { } 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; @@ -587,19 +551,11 @@ export class WaveAIModel { } 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 { @@ -671,16 +627,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/block/block.tsx b/frontend/app/block/block.tsx index 1d6da87248..46a2638d9e 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -13,8 +13,6 @@ import { AiFileDiffViewModel } from "@/app/view/aifilediff/aifilediff"; import { LauncherViewModel } from "@/app/view/launcher/launcher"; import { PreviewModel } from "@/app/view/preview/preview-model"; import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo"; -import { TsunamiViewModel } from "@/app/view/tsunami/tsunami"; -import { VDomModel } from "@/app/view/vdom/vdom-model"; import { ErrorBoundary } from "@/element/errorboundary"; import { CenteredDiv } from "@/element/quickelems"; import { useDebouncedNodeInnerRect } from "@/layout/index"; @@ -49,11 +47,9 @@ 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); diff --git a/frontend/app/element/settings/color-control.tsx b/frontend/app/element/settings/color-control.tsx new file mode 100644 index 0000000000..95dfc9ae32 --- /dev/null +++ b/frontend/app/element/settings/color-control.tsx @@ -0,0 +1,108 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { cn } from "@/util/util"; +import { memo, useCallback, useId, useRef, useState } from "react"; + +interface ColorControlProps { + value: string; + onChange: (value: string) => void; + disabled?: boolean; + className?: string; + /** Show text input alongside color picker */ + showInput?: boolean; +} + +const ColorControl = memo(({ value, onChange, disabled, className, showInput = true }: ColorControlProps) => { + const colorInputRef = useRef(null); + const inputId = useId(); + const [textValue, setTextValue] = useState(value ?? ""); + + const handleColorChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value; + setTextValue(newValue); + onChange(newValue); + }, + [onChange] + ); + + const handleTextChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value; + setTextValue(newValue); + + // Validate hex color format before updating + if (/^#[0-9A-Fa-f]{6}$/.test(newValue) || /^#[0-9A-Fa-f]{3}$/.test(newValue)) { + onChange(newValue); + } + }, + [onChange] + ); + + const handleTextBlur = useCallback(() => { + // On blur, reset to valid value if invalid + if (!/^#[0-9A-Fa-f]{6}$/.test(textValue) && !/^#[0-9A-Fa-f]{3}$/.test(textValue)) { + setTextValue(value ?? "#000000"); + } + }, [textValue, value]); + + const handleSwatchClick = useCallback(() => { + colorInputRef.current?.click(); + }, []); + + const handleSwatchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === " " || e.key === "Enter") { + e.preventDefault(); + colorInputRef.current?.click(); + } + }, + [] + ); + + // Normalize color for display (ensure it's a valid hex) + const displayColor = /^#[0-9A-Fa-f]{6}$/.test(value) || /^#[0-9A-Fa-f]{3}$/.test(value) ? value : "#000000"; + + return ( +
+
+ ); +}); + +ColorControl.displayName = "ColorControl"; + +export { ColorControl }; +export type { ColorControlProps }; diff --git a/frontend/app/element/settings/control-factory.tsx b/frontend/app/element/settings/control-factory.tsx new file mode 100644 index 0000000000..d56de7c96b --- /dev/null +++ b/frontend/app/element/settings/control-factory.tsx @@ -0,0 +1,284 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { memo } from "react"; + +import { ColorControl } from "./color-control"; +import { FontControl } from "./font-control"; +import { NumberControl } from "./number-control"; +import { PathControl } from "./path-control"; +import { SelectControl } from "./select-control"; +import { SettingControl } from "./setting-control"; +import { SliderControl } from "./slider-control"; +import { StringListControl } from "./stringlist-control"; +import { TextControl } from "./text-control"; +import { ToggleControl } from "./toggle-control"; + +interface ControlFactoryProps { + metadata: SettingMetadata; + value: boolean | number | string | string[] | null; + onChange: (value: boolean | number | string | string[] | null) => void; + disabled?: boolean; + /** Callback for path browser (for path controls) */ + onBrowsePath?: () => void; + /** Dynamic options for select controls (overrides metadata options) */ + dynamicOptions?: SelectOption[]; +} + +/** + * Renders the appropriate control for a setting based on its metadata. + */ +const ControlFactory = memo( + ({ metadata, value, onChange, disabled, onBrowsePath, dynamicOptions }: ControlFactoryProps) => { + const isModified = value !== metadata.defaultValue && value !== undefined && value !== null; + + const renderControl = () => { + switch (metadata.controlType) { + case "toggle": + return ( + void} + disabled={disabled} + /> + ); + + case "number": + return ( + void} + min={metadata.validation?.min} + max={metadata.validation?.max} + step={metadata.validation?.step} + disabled={disabled} + /> + ); + + case "slider": + return ( + void} + min={metadata.validation?.min} + max={metadata.validation?.max} + step={metadata.validation?.step} + disabled={disabled} + /> + ); + + case "text": + return ( + void} + pattern={metadata.validation?.pattern} + disabled={disabled} + /> + ); + + case "select": { + const options = dynamicOptions ?? metadata.validation?.options ?? []; + return ( + void} + options={options} + disabled={disabled} + /> + ); + } + + case "color": + return ( + void} + disabled={disabled} + /> + ); + + case "font": + return ( + void} + disabled={disabled} + /> + ); + + case "path": + return ( + void} + disabled={disabled} + onBrowse={onBrowsePath} + /> + ); + + case "stringlist": + return ( + void} + disabled={disabled} + /> + ); + + default: + // Fallback to text control for unknown types + return ( + void} + disabled={disabled} + /> + ); + } + }; + + return ( + + {renderControl()} + + ); + } +); + +ControlFactory.displayName = "ControlFactory"; + +/** + * Standalone function to render a setting control based on metadata. + * Useful when you don't need the wrapper component. + */ +function renderSettingControl( + controlType: SettingControlType, + value: boolean | number | string | string[] | null, + onChange: (value: boolean | number | string | string[] | null) => void, + options?: { + disabled?: boolean; + min?: number; + max?: number; + step?: number; + pattern?: string; + selectOptions?: SelectOption[]; + onBrowsePath?: () => void; + } +): JSX.Element { + const { disabled, min, max, step, pattern, selectOptions, onBrowsePath } = options ?? {}; + + switch (controlType) { + case "toggle": + return ( + void} + disabled={disabled} + /> + ); + + case "number": + return ( + void} + min={min} + max={max} + step={step} + disabled={disabled} + /> + ); + + case "slider": + return ( + void} + min={min} + max={max} + step={step} + disabled={disabled} + /> + ); + + case "text": + return ( + void} + pattern={pattern} + disabled={disabled} + /> + ); + + case "select": + return ( + void} + options={selectOptions ?? []} + disabled={disabled} + /> + ); + + case "color": + return ( + void} + disabled={disabled} + /> + ); + + case "font": + return ( + void} + disabled={disabled} + /> + ); + + case "path": + return ( + void} + disabled={disabled} + onBrowse={onBrowsePath} + /> + ); + + case "stringlist": + return ( + void} + disabled={disabled} + /> + ); + + default: + return ( + void} + disabled={disabled} + /> + ); + } +} + +export { ControlFactory, renderSettingControl }; +export type { ControlFactoryProps }; diff --git a/frontend/app/element/settings/font-control.tsx b/frontend/app/element/settings/font-control.tsx new file mode 100644 index 0000000000..d23711f6f9 --- /dev/null +++ b/frontend/app/element/settings/font-control.tsx @@ -0,0 +1,68 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { cn } from "@/util/util"; +import { memo, useCallback, useMemo } from "react"; + +interface FontControlProps { + value: string; + onChange: (value: string) => void; + disabled?: boolean; + className?: string; + placeholder?: string; + /** If true, shows a preview of the font */ + showPreview?: boolean; + /** Preview text to display */ + previewText?: string; +} + +const FontControl = memo( + ({ + value, + onChange, + disabled, + className, + placeholder = "Enter font family...", + showPreview = true, + previewText = "The quick brown fox jumps over the lazy dog", + }: FontControlProps) => { + const handleChange = useCallback( + (e: React.ChangeEvent) => { + onChange(e.target.value); + }, + [onChange] + ); + + const fontStyle = useMemo( + () => ({ + fontFamily: value || "inherit", + }), + [value] + ); + + return ( +
+
+ +
+ {showPreview && value && ( +
+ {previewText} +
+ )} +
+ ); + } +); + +FontControl.displayName = "FontControl"; + +export { FontControl }; +export type { FontControlProps }; diff --git a/frontend/app/element/settings/index.ts b/frontend/app/element/settings/index.ts new file mode 100644 index 0000000000..3bbe57085d --- /dev/null +++ b/frontend/app/element/settings/index.ts @@ -0,0 +1,45 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Settings Control Components + * + * A library of reusable React components for rendering setting controls + * based on the SettingMetadata type system. + */ + +// Main wrapper component +export { SettingControl } from "./setting-control"; +export type { SettingControlProps } from "./setting-control"; + +// Individual control components +export { ToggleControl } from "./toggle-control"; +export type { ToggleControlProps } from "./toggle-control"; + +export { NumberControl } from "./number-control"; +export type { NumberControlProps } from "./number-control"; + +export { SliderControl } from "./slider-control"; +export type { SliderControlProps } from "./slider-control"; + +export { TextControl } from "./text-control"; +export type { TextControlProps } from "./text-control"; + +export { SelectControl } from "./select-control"; +export type { SelectControlProps, SelectOption } from "./select-control"; + +export { ColorControl } from "./color-control"; +export type { ColorControlProps } from "./color-control"; + +export { FontControl } from "./font-control"; +export type { FontControlProps } from "./font-control"; + +export { PathControl } from "./path-control"; +export type { PathControlProps } from "./path-control"; + +export { StringListControl } from "./stringlist-control"; +export type { StringListControlProps } from "./stringlist-control"; + +// Factory for dynamic control rendering +export { ControlFactory, renderSettingControl } from "./control-factory"; +export type { ControlFactoryProps } from "./control-factory"; diff --git a/frontend/app/element/settings/number-control.tsx b/frontend/app/element/settings/number-control.tsx new file mode 100644 index 0000000000..fc92ef518c --- /dev/null +++ b/frontend/app/element/settings/number-control.tsx @@ -0,0 +1,139 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { cn } from "@/util/util"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; + +interface NumberControlProps { + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; + disabled?: boolean; + className?: string; + placeholder?: string; +} + +const NumberControl = memo( + ({ value, onChange, min, max, step = 1, disabled, className, placeholder }: NumberControlProps) => { + const [inputValue, setInputValue] = useState(value?.toString() ?? ""); + const inputRef = useRef(null); + + const clampValue = useCallback( + (val: number): number => { + let clamped = val; + if (min !== undefined && clamped < min) clamped = min; + if (max !== undefined && clamped > max) clamped = max; + return clamped; + }, + [min, max] + ); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const raw = e.target.value; + setInputValue(raw); + + // Allow empty or just a minus sign during typing + if (raw === "" || raw === "-") { + return; + } + + const parsed = parseFloat(raw); + if (!isNaN(parsed)) { + onChange(clampValue(parsed)); + } + }, + [onChange, clampValue] + ); + + const handleBlur = useCallback(() => { + const parsed = parseFloat(inputValue); + if (isNaN(parsed)) { + setInputValue(value?.toString() ?? ""); + } else { + const clamped = clampValue(parsed); + setInputValue(clamped.toString()); + onChange(clamped); + } + }, [inputValue, value, onChange, clampValue]); + + const handleIncrement = useCallback(() => { + if (disabled) return; + const newValue = clampValue((value ?? 0) + step); + setInputValue(newValue.toString()); + onChange(newValue); + }, [value, step, onChange, clampValue, disabled]); + + const handleDecrement = useCallback(() => { + if (disabled) return; + const newValue = clampValue((value ?? 0) - step); + setInputValue(newValue.toString()); + onChange(newValue); + }, [value, step, onChange, clampValue, disabled]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "ArrowUp") { + e.preventDefault(); + handleIncrement(); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + handleDecrement(); + } + }, + [handleIncrement, handleDecrement] + ); + + // Sync internal state when external value changes + useEffect(() => { + // Only sync if this input is not currently focused + if (inputRef.current && document.activeElement !== inputRef.current) { + setInputValue(value?.toString() ?? ""); + } + }, [value]); + + return ( +
+ + + +
+ ); + } +); + +NumberControl.displayName = "NumberControl"; + +export { NumberControl }; +export type { NumberControlProps }; diff --git a/frontend/app/element/settings/path-control.tsx b/frontend/app/element/settings/path-control.tsx new file mode 100644 index 0000000000..e72d59f8de --- /dev/null +++ b/frontend/app/element/settings/path-control.tsx @@ -0,0 +1,71 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { cn } from "@/util/util"; +import { memo, useCallback } from "react"; + +interface PathControlProps { + value: string; + onChange: (value: string) => void; + disabled?: boolean; + className?: string; + placeholder?: string; + /** Whether to show a browse button */ + showBrowse?: boolean; + /** Callback when browse button is clicked (should open file dialog via IPC) */ + onBrowse?: () => void; +} + +const PathControl = memo( + ({ + value, + onChange, + disabled, + className, + placeholder = "Enter file path...", + showBrowse = true, + onBrowse, + }: PathControlProps) => { + const handleChange = useCallback( + (e: React.ChangeEvent) => { + onChange(e.target.value); + }, + [onChange] + ); + + const handleBrowseClick = useCallback(() => { + if (onBrowse && !disabled) { + onBrowse(); + } + }, [onBrowse, disabled]); + + return ( +
+ + {showBrowse && ( + + )} +
+ ); + } +); + +PathControl.displayName = "PathControl"; + +export { PathControl }; +export type { PathControlProps }; diff --git a/frontend/app/element/settings/select-control.tsx b/frontend/app/element/settings/select-control.tsx new file mode 100644 index 0000000000..58a1f3a2da --- /dev/null +++ b/frontend/app/element/settings/select-control.tsx @@ -0,0 +1,58 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { cn } from "@/util/util"; +import { memo, useCallback } from "react"; + +interface SelectOption { + value: string; + label: string; +} + +interface SelectControlProps { + value: string; + onChange: (value: string) => void; + options: SelectOption[]; + disabled?: boolean; + className?: string; + placeholder?: string; +} + +const SelectControl = memo( + ({ value, onChange, options, disabled, className, placeholder }: SelectControlProps) => { + const handleChange = useCallback( + (e: React.ChangeEvent) => { + onChange(e.target.value); + }, + [onChange] + ); + + return ( +
+ + +
+ ); + } +); + +SelectControl.displayName = "SelectControl"; + +export { SelectControl }; +export type { SelectControlProps, SelectOption }; diff --git a/frontend/app/element/settings/setting-control.tsx b/frontend/app/element/settings/setting-control.tsx new file mode 100644 index 0000000000..d01f68e3c8 --- /dev/null +++ b/frontend/app/element/settings/setting-control.tsx @@ -0,0 +1,73 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { cn } from "@/util/util"; +import { memo, useCallback } from "react"; + +import "./settings-controls.scss"; + +interface SettingControlProps { + settingKey: string; + label: string; + description: string; + value: boolean | number | string | string[] | null; + defaultValue: boolean | number | string | string[] | null; + onChange: (value: boolean | number | string | string[] | null) => void; + isModified: boolean; + disabled?: boolean; + requiresRestart?: boolean; + children: React.ReactNode; +} + +const SettingControl = memo( + ({ + settingKey, + label, + description, + value, + defaultValue, + onChange, + isModified, + disabled, + requiresRestart, + children, + }: SettingControlProps) => { + const handleReset = useCallback(() => { + onChange(defaultValue); + }, [onChange, defaultValue]); + + return ( +
+
+
+ {label} + {requiresRestart && Requires restart} + {isModified && ( + + )} +
+
{children}
+
+ {description &&
{description}
} +
+ ); + } +); + +SettingControl.displayName = "SettingControl"; + +export { SettingControl }; +export type { SettingControlProps }; diff --git a/frontend/app/element/settings/settings-controls.scss b/frontend/app/element/settings/settings-controls.scss new file mode 100644 index 0000000000..6cb0863d05 --- /dev/null +++ b/frontend/app/element/settings/settings-controls.scss @@ -0,0 +1,682 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// ============================================ +// Settings Control Components - SCSS +// ============================================ + +// Common variables for settings controls +$setting-modified-color: #e2c08d; // VS Code modified indicator color +$setting-control-height: 28px; +$setting-border-radius: 4px; +$setting-input-padding: 6px 10px; +$setting-gap: 8px; + +// ============================================ +// Setting Row (Wrapper Component) +// ============================================ + +.setting-row { + display: flex; + flex-direction: column; + padding: 12px 16px; + border-bottom: 1px solid var(--border-color); + border-left: 3px solid transparent; + transition: background-color 0.15s ease, border-color 0.15s ease; + + &:hover { + background: var(--hover-bg-color); + } + + &.modified { + border-left-color: $setting-modified-color; + } + + &.disabled { + opacity: 0.5; + pointer-events: none; + } +} + +.setting-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} + +.setting-label-container { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.setting-label { + font-weight: 500; + font-size: 13px; + color: var(--main-text-color); +} + +.setting-restart-badge { + font-size: 10px; + padding: 2px 6px; + background: var(--warning-color); + color: #000; + border-radius: 3px; + font-weight: 500; +} + +.setting-reset-button { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: none; + background: transparent; + color: var(--secondary-text-color); + cursor: pointer; + border-radius: $setting-border-radius; + opacity: 0.7; + transition: opacity 0.15s ease, background-color 0.15s ease; + + &:hover { + opacity: 1; + background: var(--hover-bg-color); + } + + i { + font-size: 11px; + } +} + +.setting-control-container { + flex-shrink: 0; +} + +.setting-description { + color: var(--secondary-text-color); + font-size: 12px; + margin-top: 6px; + line-height: 1.4; +} + +// ============================================ +// Toggle Control +// ============================================ + +.setting-toggle { + position: relative; + display: inline-block; + width: 36px; + height: 20px; + cursor: pointer; + + &.disabled { + cursor: not-allowed; + opacity: 0.5; + } + + input { + opacity: 0; + width: 0; + height: 0; + position: absolute; + } + + .setting-toggle-slider { + position: absolute; + content: ""; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: var(--toggle-bg-color); + transition: background-color 0.2s ease; + border-radius: 10px; + + &::before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 2px; + bottom: 2px; + background-color: var(--toggle-thumb-color); + transition: transform 0.2s ease; + border-radius: 50%; + } + } + + input:checked + .setting-toggle-slider { + background-color: var(--toggle-checked-bg-color); + } + + input:checked + .setting-toggle-slider::before { + transform: translateX(16px); + } + + &:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: 2px; + border-radius: 10px; + } +} + +// ============================================ +// Number Control +// ============================================ + +.setting-number { + display: flex; + align-items: center; + background: var(--form-element-bg-color); + border: 1px solid var(--form-element-border-color); + border-radius: $setting-border-radius; + overflow: hidden; + + &.disabled { + opacity: 0.5; + pointer-events: none; + } +} + +.setting-number-button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: $setting-control-height; + padding: 0; + border: none; + background: transparent; + color: var(--secondary-text-color); + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; + + &:hover:not(:disabled) { + background: var(--hover-bg-color); + color: var(--main-text-color); + } + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } + + i { + font-size: 10px; + } +} + +.setting-number-input { + width: 60px; + height: $setting-control-height; + padding: 0 4px; + border: none; + background: transparent; + color: var(--form-element-text-color); + text-align: center; + font-size: 12px; + outline: none; + + &:focus { + background: var(--hover-bg-color); + } +} + +// ============================================ +// Slider Control +// ============================================ + +.setting-slider { + display: flex; + align-items: center; + gap: 12px; + min-width: 200px; + + &.disabled { + opacity: 0.5; + pointer-events: none; + } +} + +.setting-slider-input { + flex: 1; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: var(--form-element-border-color); + border-radius: 2px; + outline: none; + cursor: pointer; + + // Track gradient to show fill + background: linear-gradient( + to right, + var(--accent-color) 0%, + var(--accent-color) var(--slider-fill, 0%), + var(--form-element-border-color) var(--slider-fill, 0%), + var(--form-element-border-color) 100% + ); + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--main-text-color); + cursor: pointer; + border: none; + transition: transform 0.15s ease; + + &:hover { + transform: scale(1.15); + } + } + + &::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--main-text-color); + cursor: pointer; + border: none; + transition: transform 0.15s ease; + + &:hover { + transform: scale(1.15); + } + } + + &:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: 2px; + } +} + +.setting-slider-value { + min-width: 50px; + font-size: 12px; + color: var(--secondary-text-color); + text-align: right; + font-variant-numeric: tabular-nums; +} + +// ============================================ +// Text Control +// ============================================ + +.setting-text { + &.disabled { + opacity: 0.5; + pointer-events: none; + } +} + +.setting-text-input { + width: 200px; + height: $setting-control-height; + padding: $setting-input-padding; + border: 1px solid var(--form-element-border-color); + border-radius: $setting-border-radius; + background: var(--form-element-bg-color); + color: var(--form-element-text-color); + font-size: 12px; + outline: none; + transition: border-color 0.15s ease; + + &:focus { + border-color: var(--form-element-primary-color); + } + + &.invalid { + border-color: var(--form-element-error-color); + } +} + +// Textarea variant of setting-text-input +textarea.setting-text-input { + height: auto; + resize: vertical; + min-height: 60px; +} + +// ============================================ +// Select Control +// ============================================ + +.setting-select { + position: relative; + display: inline-flex; + + &.disabled { + opacity: 0.5; + pointer-events: none; + } +} + +.setting-select-input { + width: 180px; + height: $setting-control-height; + padding: 0 28px 0 10px; + border: 1px solid var(--form-element-border-color); + border-radius: $setting-border-radius; + background: var(--form-element-bg-color); + color: var(--form-element-text-color); + font-size: 12px; + outline: none; + cursor: pointer; + appearance: none; + -webkit-appearance: none; + transition: border-color 0.15s ease; + + &:focus { + border-color: var(--form-element-primary-color); + } + + option { + background: var(--form-element-bg-color); + color: var(--form-element-text-color); + } +} + +.setting-select-icon { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + font-size: 10px; + color: var(--secondary-text-color); + pointer-events: none; +} + +// ============================================ +// Color Control +// ============================================ + +.setting-color { + display: flex; + align-items: center; + gap: 8px; + + &.disabled { + opacity: 0.5; + pointer-events: none; + } +} + +.setting-color-swatch { + width: 28px; + height: 28px; + border: 2px solid var(--form-element-border-color); + border-radius: $setting-border-radius; + cursor: pointer; + padding: 0; + transition: border-color 0.15s ease; + + &:hover { + border-color: var(--form-element-primary-color); + } + + &:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: 2px; + } +} + +.setting-color-picker { + position: absolute; + opacity: 0; + width: 0; + height: 0; + pointer-events: none; +} + +.setting-color-input { + width: 80px; + height: $setting-control-height; + padding: $setting-input-padding; + border: 1px solid var(--form-element-border-color); + border-radius: $setting-border-radius; + background: var(--form-element-bg-color); + color: var(--form-element-text-color); + font-size: 12px; + font-family: monospace; + outline: none; + transition: border-color 0.15s ease; + + &:focus { + border-color: var(--form-element-primary-color); + } +} + +// ============================================ +// Font Control +// ============================================ + +.setting-font { + display: flex; + flex-direction: column; + gap: 8px; + + &.disabled { + opacity: 0.5; + pointer-events: none; + } +} + +.setting-font-input-row { + display: flex; + align-items: center; + gap: 8px; +} + +.setting-font-input { + width: 200px; + height: $setting-control-height; + padding: $setting-input-padding; + border: 1px solid var(--form-element-border-color); + border-radius: $setting-border-radius; + background: var(--form-element-bg-color); + color: var(--form-element-text-color); + font-size: 12px; + outline: none; + transition: border-color 0.15s ease; + + &:focus { + border-color: var(--form-element-primary-color); + } +} + +.setting-font-preview { + padding: 8px 12px; + background: var(--block-bg-color); + border-radius: $setting-border-radius; + font-size: 14px; + color: var(--secondary-text-color); + max-width: 400px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +// ============================================ +// Path Control +// ============================================ + +.setting-path { + display: flex; + align-items: center; + gap: 4px; + + &.disabled { + opacity: 0.5; + pointer-events: none; + } +} + +.setting-path-input { + flex: 1; + min-width: 200px; + max-width: 300px; + height: $setting-control-height; + padding: $setting-input-padding; + border: 1px solid var(--form-element-border-color); + border-radius: $setting-border-radius; + background: var(--form-element-bg-color); + color: var(--form-element-text-color); + font-size: 12px; + font-family: monospace; + outline: none; + transition: border-color 0.15s ease; + + &:focus { + border-color: var(--form-element-primary-color); + } +} + +.setting-path-browse { + display: flex; + align-items: center; + justify-content: center; + width: $setting-control-height; + height: $setting-control-height; + padding: 0; + border: 1px solid var(--form-element-border-color); + border-radius: $setting-border-radius; + background: var(--form-element-bg-color); + color: var(--secondary-text-color); + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; + + &:hover:not(:disabled) { + background: var(--hover-bg-color); + color: var(--main-text-color); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + i { + font-size: 12px; + } +} + +// ============================================ +// String List Control +// ============================================ + +.setting-stringlist { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 300px; + + &.disabled { + opacity: 0.5; + pointer-events: none; + } +} + +.setting-stringlist-items { + display: flex; + flex-direction: column; + gap: 4px; +} + +.setting-stringlist-item { + display: flex; + align-items: center; + gap: 4px; +} + +.setting-stringlist-input { + flex: 1; + height: $setting-control-height; + padding: $setting-input-padding; + border: 1px solid var(--form-element-border-color); + border-radius: $setting-border-radius; + background: var(--form-element-bg-color); + color: var(--form-element-text-color); + font-size: 12px; + outline: none; + transition: border-color 0.15s ease; + + &:focus { + border-color: var(--form-element-primary-color); + } +} + +.setting-stringlist-actions { + display: flex; + gap: 2px; +} + +.setting-stringlist-button { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + background: transparent; + color: var(--secondary-text-color); + cursor: pointer; + border-radius: $setting-border-radius; + transition: background-color 0.15s ease, color 0.15s ease; + + &:hover:not(:disabled) { + background: var(--hover-bg-color); + color: var(--main-text-color); + } + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } + + &.setting-stringlist-remove:hover:not(:disabled) { + color: var(--error-color); + } + + i { + font-size: 10px; + } +} + +.setting-stringlist-add { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + height: $setting-control-height; + padding: 0 12px; + border: 1px dashed var(--form-element-border-color); + border-radius: $setting-border-radius; + background: transparent; + color: var(--secondary-text-color); + font-size: 12px; + cursor: pointer; + transition: border-color 0.15s ease, color 0.15s ease; + + &:hover:not(:disabled) { + border-color: var(--form-element-primary-color); + color: var(--main-text-color); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + i { + font-size: 10px; + } +} diff --git a/frontend/app/element/settings/slider-control.tsx b/frontend/app/element/settings/slider-control.tsx new file mode 100644 index 0000000000..2da7c88e42 --- /dev/null +++ b/frontend/app/element/settings/slider-control.tsx @@ -0,0 +1,77 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { cn } from "@/util/util"; +import { memo, useCallback, useMemo } from "react"; + +interface SliderControlProps { + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; + disabled?: boolean; + className?: string; + /** Number of decimal places to show */ + precision?: number; + /** Optional unit label (e.g., "px", "%") */ + unit?: string; +} + +const SliderControl = memo( + ({ value, onChange, min = 0, max = 100, step = 1, disabled, className, precision, unit }: SliderControlProps) => { + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = parseFloat(e.target.value); + onChange(newValue); + }, + [onChange] + ); + + // Calculate precision based on step if not provided + const displayPrecision = useMemo(() => { + if (precision !== undefined) return precision; + const stepStr = step.toString(); + const decimalIndex = stepStr.indexOf("."); + return decimalIndex === -1 ? 0 : stepStr.length - decimalIndex - 1; + }, [step, precision]); + + const displayValue = useMemo(() => { + const formatted = value?.toFixed(displayPrecision) ?? min.toFixed(displayPrecision); + return unit ? `${formatted}${unit}` : formatted; + }, [value, displayPrecision, unit, min]); + + // Calculate fill percentage for visual feedback + const fillPercentage = useMemo(() => { + const range = max - min; + if (range === 0) return 0; + return ((value - min) / range) * 100; + }, [value, min, max]); + + return ( +
+ + {displayValue} +
+ ); + } +); + +SliderControl.displayName = "SliderControl"; + +export { SliderControl }; +export type { SliderControlProps }; diff --git a/frontend/app/element/settings/stringlist-control.tsx b/frontend/app/element/settings/stringlist-control.tsx new file mode 100644 index 0000000000..716f5aca10 --- /dev/null +++ b/frontend/app/element/settings/stringlist-control.tsx @@ -0,0 +1,130 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { cn } from "@/util/util"; +import { memo, useCallback } from "react"; + +interface StringListControlProps { + value: string[]; + onChange: (value: string[]) => void; + disabled?: boolean; + className?: string; + placeholder?: string; + /** Maximum number of items allowed */ + maxItems?: number; +} + +const StringListControl = memo( + ({ value, onChange, disabled, className, placeholder = "Enter value...", maxItems }: StringListControlProps) => { + const items = value ?? []; + + const handleItemChange = useCallback( + (index: number, newValue: string) => { + const updated = [...items]; + updated[index] = newValue; + onChange(updated); + }, + [items, onChange] + ); + + const handleAddItem = useCallback(() => { + if (maxItems !== undefined && items.length >= maxItems) return; + onChange([...items, ""]); + }, [items, onChange, maxItems]); + + const handleRemoveItem = useCallback( + (index: number) => { + const updated = items.filter((_, i) => i !== index); + onChange(updated); + }, + [items, onChange] + ); + + const handleMoveUp = useCallback( + (index: number) => { + if (index === 0) return; + const updated = [...items]; + [updated[index - 1], updated[index]] = [updated[index], updated[index - 1]]; + onChange(updated); + }, + [items, onChange] + ); + + const handleMoveDown = useCallback( + (index: number) => { + if (index === items.length - 1) return; + const updated = [...items]; + [updated[index], updated[index + 1]] = [updated[index + 1], updated[index]]; + onChange(updated); + }, + [items, onChange] + ); + + const canAdd = maxItems === undefined || items.length < maxItems; + + return ( +
+
+ {items.map((item, index) => ( +
+ handleItemChange(index, e.target.value)} + disabled={disabled} + placeholder={placeholder} + className="setting-stringlist-input" + /> +
+ + + +
+
+ ))} +
+ +
+ ); + } +); + +StringListControl.displayName = "StringListControl"; + +export { StringListControl }; +export type { StringListControlProps }; diff --git a/frontend/app/element/settings/text-control.tsx b/frontend/app/element/settings/text-control.tsx new file mode 100644 index 0000000000..84b4a508bd --- /dev/null +++ b/frontend/app/element/settings/text-control.tsx @@ -0,0 +1,77 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { cn } from "@/util/util"; +import { memo, useCallback, useState } from "react"; + +interface TextControlProps { + value: string; + onChange: (value: string) => void; + disabled?: boolean; + className?: string; + placeholder?: string; + pattern?: string; + maxLength?: number; + /** If true, shows a multiline textarea */ + multiline?: boolean; + rows?: number; +} + +const TextControl = memo( + ({ + value, + onChange, + disabled, + className, + placeholder, + pattern, + maxLength, + multiline, + rows = 3, + }: TextControlProps) => { + const [isValid, setIsValid] = useState(true); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value; + + // Validate against pattern if provided + if (pattern) { + const regex = new RegExp(pattern); + setIsValid(regex.test(newValue) || newValue === ""); + } + + onChange(newValue); + }, + [onChange, pattern] + ); + + const commonProps = { + value: value ?? "", + onChange: handleChange, + disabled, + placeholder, + maxLength, + className: cn("setting-text-input", { invalid: !isValid }), + }; + + if (multiline) { + return ( +
+