Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

@.kilocode/rules/rules.md

---
Expand All @@ -16,3 +18,114 @@ This project uses a set of "skill" guides — focused how-to documents for commo
| electron-api | `.kilocode/skills/electron-api/SKILL.md` | Guide for adding new Electron APIs to Wave Terminal. Use when implementing new frontend-to-electron communications via preload/IPC. |
| waveenv | `.kilocode/skills/waveenv/SKILL.md` | Guide for creating WaveEnv narrowings in Wave Terminal. Use when writing a named subset type of WaveEnv for a component tree, documenting environmental dependencies, or enabling mock environments for preview/test server usage. |
| wps-events | `.kilocode/skills/wps-events/SKILL.md` | Guide for working with Wave Terminal's WPS (Wave PubSub) event system. Use when implementing new event types, publishing events, subscribing to events, or adding asynchronous communication between components. |

---

## Common Commands

All build commands use [Task](https://taskfile.dev/) (`task`). Run from the repo root.

### Development

```sh
task init # First-time setup: npm install + go mod tidy + docs npm install
task dev # Build backend + run via Vite dev server (hot reload)
task start # Build backend + run standalone (no hot reload)
task quickdev # macOS arm64 only: faster dev loop, skips wsh build and generate
task preview # Run standalone component preview server (no Electron, no backend) at http://localhost:7007
```

### Building

```sh
task build:backend # Build wavesrv + wsh for all targets
task generate # Re-generate TypeScript bindings and Go code from pkg/wshrpc/wshrpctypes.go
task check:ts # TypeScript type-check (npx tsc --noEmit)
task package # Production build + package for current platform (artifacts in make/)
```

### Testing

```sh
npm test # Run all Vitest frontend tests
npm run coverage # Run tests with coverage
go test ./pkg/... # Run all Go tests from repo root
go test ./pkg/somepackage/... # Run a single Go package's tests
```

### Debugging

- Frontend DevTools: `Cmd+Option+I` (macOS) or `Ctrl+Option+I` (Linux/Windows)
- Backend logs (dev): `~/.waveterm-dev/waveapp.log`

---

## Architecture

Wave Terminal is an Electron app with a React/TypeScript renderer process and a Go backend server. The two communicate via **wshrpc** — a custom RPC protocol over WebSocket and Unix domain sockets.

### Top-level layout

| Directory | Purpose |
|-----------|---------|
| `emain/` | Electron main process (window management, IPC, auto-update) |
| `frontend/` | React renderer process (UI, state, views) |
| `cmd/` | Go entry points: `server/`, `wsh/`, `generatets/`, `generatego/`, `generateschema/` |
| `pkg/` | Go packages (see below) |
| `tsunami/` | Tsunami builder subsystem (separate Go module + frontend) |
| `db/` | SQL migration files for wstore and filestore |
| `docs/` | Docusaurus documentation site |

### Frontend (`frontend/`)

- **`app/store/`** — Jotai atoms and global state; `wshclientapi.ts` is generated (do not edit manually)
- **`app/block/`** — Block container components and `BlockRegistry` (maps view type strings to components)
- **`app/view/`** — All view implementations: `term/`, `codeeditor/`, `preview/`, `webview/`, `waveai/`, `sysinfo/`, `tsunami/`, etc.
- **`app/tab/` / `app/workspace/`** — Tab and workspace UI
- **`layout/`** — Drag/drop layout engine
- **`types/gotypes.d.ts`** — Generated from Go types; do not edit manually
- **`preview/`** — Standalone Vite app for component preview (no backend needed); run with `task preview`

### Go backend (`pkg/`)

| Package | Purpose |
|---------|---------|
| `wshrpc/` | RPC type definitions (`wshrpctypes.go`) and generated client; source of truth for all RPC commands |
| `wshrpc/wshserver/` | Server-side RPC handler implementations |
| `wstore/` | Database and object storage layer |
| `wconfig/` | Configuration system (`settingsconfig.go`) |
| `wcore/` | Core business logic |
| `wps/` | Wave PubSub event system |
| `blockcontroller/` | Block execution and lifecycle management |
| `remote/` | SSH and remote connection handling |
| `filestore/` | File storage |
| `web/` | HTTP/WebSocket server |
| `waveobj/` | Core Wave data object types |
| `service/` | Service layer |
| `waveai/` | AI integration |
| `shellexec/` | Shell execution |

### Electron main (`emain/`)

Key files: `emain.ts` (app lifecycle), `emain-window.ts` (window), `emain-tabview.ts` (tabs), `emain-ipc.ts` (IPC handlers), `emain-wavesrv.ts` (Go backend process management), `preload.ts` (renderer ↔ main bridge).

Frontend accesses Electron APIs via `getApi()` from `@/store/global` — the full API type is `ElectronApi` in `custom.d.ts`.

### WSH RPC Communication

All IPC (frontend ↔ backend, main ↔ backend, backend ↔ remote SSH/WSL) goes through the WSH RPC system:

1. **Define** the RPC command in `pkg/wshrpc/wshrpctypes.go`
2. **Run** `task generate` to regenerate `frontend/app/store/wshclientapi.ts` and related Go files
3. **Implement** server-side handler in `pkg/wshrpc/wshserver/wshserver.go`

Callers use _routes_ (block ID, connection name, or `"waveapp"`) — the RPC layer picks the right transport automatically.

### Code Generation

Run `task generate` after modifying any of these files:
- `pkg/wshrpc/wshrpctypes.go` — RPC types → TypeScript client + Go boilerplate
- `pkg/wconfig/settingsconfig.go` — Config types → schema + TypeScript types
- `pkg/waveobj/wtypemeta.go` — Wave object types → TypeScript types

Never manually edit `frontend/types/gotypes.d.ts` or `frontend/app/store/wshclientapi.ts`.
32 changes: 32 additions & 0 deletions cmd/server/main-server.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import (
"github.com/wavetermdev/waveterm/pkg/wslconn"
"github.com/wavetermdev/waveterm/pkg/wstore"

"net"
"net/http"
_ "net/http/pprof"
)
Expand Down Expand Up @@ -610,6 +611,37 @@ func main() {
// use fmt instead of log here to make sure it goes directly to stderr
fmt.Fprintf(os.Stderr, "WAVESRV-ESTART ws:%s web:%s version:%s buildtime:%s\n", wsListener.Addr(), webListener.Addr(), WaveVersion, BuildTime)
}()
cfgSettings := wconfig.GetWatcher().GetFullConfig().Settings
if cfgSettings.RemotePassword != "" {
bindAddr := cfgSettings.RemoteBindAddr
if bindAddr == "" {
bindAddr = "127.0.0.1"
}
port := cfgSettings.RemoteListenPort
if port == 0 {
port = 31577
}
entryAddr := fmt.Sprintf("%s:%d", bindAddr, port)
entryLn, err := net.Listen("tcp", entryAddr)
if err != nil {
log.Printf("error creating remote-entry listener at %s: %v\n", entryAddr, err)
return
}
log.Printf("Server [remote-entry] listening on %s\n", entryLn.Addr())
entry := web.NewRemoteEntry(
cfgSettings.RemotePassword,
webListener.Addr().String(),
wsListener.Addr().String(),
authkey.GetAuthKey(),
)
go func() {
if err := entry.Serve(entryLn); err != nil {
log.Printf("remote-entry serve error: %v\n", err)
}
}()
} else {
log.Printf("remote-entry disabled (no remote:password)\n")
}
go wshutil.RunWshRpcOverListener(unixListener, nil)
web.RunWebServer(webListener) // blocking
runtime.KeepAlive(waveLock)
Expand Down
41 changes: 41 additions & 0 deletions cmd/wsh/cmd/wshcmd-attach.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

package cmd

import (
"github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/waveattach"
)

var attachCmd = &cobra.Command{
Use: "attach [blockid]",
Short: "attach to a Wave Terminal block from an external terminal",
Long: "Attach to a running term block in Wave Terminal. Press Ctrl+A D to detach.",
Args: cobra.MaximumNArgs(1),
RunE: attachRun,
DisableFlagsInUseLine: true,
}

func init() {
rootCmd.AddCommand(attachCmd)
}
Comment on lines +11 to +22
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add the advertised --list entrypoint.

This command never registers or handles a --list flag, so wsh attach --list will currently fail as an unknown option even though it is one of the PR’s documented usage modes. Since pkg/waveattach/selector.go already exposes ListTermBlocks, this looks like a missing CLI branch rather than a missing backend capability.

Also applies to: 24-40

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/wsh/cmd/wshcmd-attach.go` around lines 11 - 22, The attach command
advertises a --list mode but never registers or handles that flag; add a boolean
flag to attachCmd (e.g., Flags().Bool("list", false, "...")) and update
attachRun to check that flag early: when true, call the existing
pkg/waveattach.ListTermBlocks selector function to fetch the blocks, print or
format the list to stdout, and return nil (skip normal attach flow). Ensure the
flag is declared on the attachCmd variable (not root) and that attachRun uses
cmd.Flags().GetBool("list") to branch before attempting to parse a block id or
perform the attach logic.


func attachRun(cmd *cobra.Command, args []string) error {
rpcClient, _, err := waveattach.Connect()
if err != nil {
return err
}

var blockId string
if len(args) == 1 {
blockId = args[0]
} else {
blockId, err = waveattach.SelectBlock(rpcClient)
if err != nil {
return err
}
}

return waveattach.Attach(rpcClient, blockId)
}
21 changes: 16 additions & 5 deletions emain/emain-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,29 @@ import fs from "fs";
import path from "path";
import { format } from "util";
import winston from "winston";
import { getWaveDataDir, isDev } from "./emain-platform";
import { getRemoteState, getWaveDataDir, isDev } from "./emain-platform";

const oldConsoleLog = console.log;

function getLogBaseName(): string {
const remote = getRemoteState();
if (remote.isRemote && remote.safeSuffix) {
return `waveapp-remote-${remote.safeSuffix}`;
}
return "waveapp";
}

const LogBaseName = getLogBaseName();

function findHighestLogNumber(logsDir: string): number {
if (!fs.existsSync(logsDir)) {
return 0;
}
const files = fs.readdirSync(logsDir);
let maxNum = 0;
const pattern = new RegExp(`^${LogBaseName}\\.(\\d+)\\.log$`);
for (const file of files) {
const match = file.match(/^waveapp\.(\d+)\.log$/);
const match = file.match(pattern);
if (match) {
const num = parseInt(match[1], 10);
if (num > maxNum) {
Expand Down Expand Up @@ -67,7 +78,7 @@ function pruneOldLogs(logsDir: string): { pruned: string[]; error: any } {

function rotateLogIfNeeded(): string | null {
const waveDataDir = getWaveDataDir();
const logFile = path.join(waveDataDir, "waveapp.log");
const logFile = path.join(waveDataDir, `${LogBaseName}.log`);
const logsDir = path.join(waveDataDir, "logs");

if (!fs.existsSync(logsDir)) {
Expand All @@ -81,7 +92,7 @@ function rotateLogIfNeeded(): string | null {
const stats = fs.statSync(logFile);
if (stats.size > 10 * 1024 * 1024) {
const nextNum = findHighestLogNumber(logsDir) + 1;
const rotatedPath = path.join(logsDir, `waveapp.${nextNum}.log`);
const rotatedPath = path.join(logsDir, `${LogBaseName}.${nextNum}.log`);
fs.renameSync(logFile, rotatedPath);
return rotatedPath;
}
Expand All @@ -103,7 +114,7 @@ try {
}

const loggerTransports: winston.transport[] = [
new winston.transports.File({ filename: path.join(getWaveDataDir(), "waveapp.log"), level: "info" }),
new winston.transports.File({ filename: path.join(getWaveDataDir(), `${LogBaseName}.log`), level: "info" }),
];
if (isDev) {
loggerTransports.push(new winston.transports.Console());
Expand Down
40 changes: 37 additions & 3 deletions emain/emain-platform.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// Copyright 2025, Command Line Inc.
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { fireAndForget } from "@/util/util";
import { app, dialog, ipcMain, shell } from "electron";
import { RemoteModeState, resolveRemoteMode } from "./remotemode";
import envPaths from "env-paths";
import { existsSync, mkdirSync } from "fs";
import os from "os";
Expand Down Expand Up @@ -30,6 +31,39 @@ const waveDirNamePrefix = "waveterm";
const waveDirNameSuffix = isDev ? "dev" : "";
const waveDirName = `${waveDirNamePrefix}${waveDirNameSuffix ? `-${waveDirNameSuffix}` : ""}`;

function computeLocalConfigDir(): string {
const xdgConfigHome = process.env.XDG_CONFIG_HOME;
if (xdgConfigHome) return path.join(xdgConfigHome, waveDirName);
return path.join(app.getPath("home"), ".config", waveDirName);
}

let remoteState: RemoteModeState;
try {
remoteState = resolveRemoteMode(process.argv, computeLocalConfigDir());
} catch (e) {
console.error("[remote-mode] failed to parse --remote-host:", (e as Error).message);
process.exit(1);
remoteState = { isRemote: false, target: null, password: null, safeSuffix: null };
}

if (remoteState.isRemote) {
if (!remoteState.password) {
console.error("[remote-mode] remote:password missing from local settings.json");
process.exit(1);
}
const baseUserData = app.getPath("userData");
const remoteUserData = path.join(
path.dirname(baseUserData),
`waveterm-remote-${remoteState.safeSuffix}`,
);
app.setPath("userData", remoteUserData);
app.setPath("sessionData", path.join(remoteUserData, "Session"));
}

export function getRemoteState(): RemoteModeState {
return remoteState;
}

const paths = envPaths("waveterm", { suffix: waveDirNameSuffix });

app.setName(isDev ? "Wave (Dev)" : "Wave");
Expand Down Expand Up @@ -197,10 +231,10 @@ ipcMain.on("get-webview-preload", (event) => {
event.returnValue = path.join(getElectronAppBasePath(), "preload", "preload-webview.cjs");
});
ipcMain.on("get-data-dir", (event) => {
event.returnValue = getWaveDataDir();
event.returnValue = remoteState.isRemote ? null : getWaveDataDir();
});
ipcMain.on("get-config-dir", (event) => {
event.returnValue = getWaveConfigDir();
event.returnValue = remoteState.isRemote ? null : getWaveConfigDir();
});
ipcMain.on("get-home-dir", (event) => {
event.returnValue = app.getPath("home");
Expand Down
10 changes: 8 additions & 2 deletions emain/emain-tabview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { createNewWaveWindow, getWaveWindowById } from "emain/emain-window";
import path from "path";
import { configureAuthKeyRequestInjection } from "./authkey";
import { setWasActive } from "./emain-activity";
import { getElectronAppBasePath, isDevVite, unamePlatform } from "./emain-platform";
import { getElectronAppBasePath, getRemoteState, isDevVite, unamePlatform } from "./emain-platform";
import { configureRemotePasswordInjection } from "./remoteauth";
import {
decreaseZoomLevel,
handleCtrlShiftFocus,
Expand Down Expand Up @@ -353,7 +354,12 @@ export async function getOrCreateWebViewForTab(waveWindowId: string, tabId: stri
tabView.webContents.on("blur", () => {
handleCtrlShiftFocus(tabView.webContents, false);
});
configureAuthKeyRequestInjection(tabView.webContents.session);
const remote = getRemoteState();
if (remote.isRemote) {
configureRemotePasswordInjection(tabView.webContents.session);
} else {
configureAuthKeyRequestInjection(tabView.webContents.session);
}
return [tabView, false];
}

Expand Down
9 changes: 9 additions & 0 deletions emain/emain-wavesrv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { setForceQuit, setUserConfirmedQuit } from "./emain-activity";
import {
getElectronAppResourcesPath,
getElectronAppUnpackedBasePath,
getRemoteState,
getWaveConfigDir,
getWaveDataDir,
getWaveSrvCwd,
Expand Down Expand Up @@ -53,6 +54,14 @@ export function getIsWaveSrvDead(): boolean {
}

export function runWaveSrv(handleWSEvent: (evtMsg: WSEventType) => void): Promise<boolean> {
const remote = getRemoteState();
if (remote.isRemote && remote.target != null) {
const endpoint = `${remote.target.host}:${remote.target.port}`;
process.env[WSServerEndpointVarName] = endpoint;
process.env[WebServerEndpointVarName] = endpoint;
waveSrvReadyResolve(true);
return Promise.resolve(true);
}
let pResolve: (value: boolean) => void;
let pReject: (reason?: any) => void;
const rtnPromise = new Promise<boolean>((argResolve, argReject) => {
Expand Down
Loading