Generate WaveEvent as a typed discriminated union with explicit null payloads for no-data events (#2899)

This updates WaveEvent typing to be event-aware instead of `data?: any`,
while keeping safe fallback behavior for unmapped events. It also
codifies known no-payload events as `null` payloads and documents event
payload expectations alongside the Go event constants.

- **Event registry + payload documentation (Go)**
- Added `AllEvents` in `pkg/wps/wpstypes.go` as the canonical list of
Wave event names.
  - Added/updated inline payload annotations on `Event_*` constants.
- Marked confirmed no-payload events with `// type: none` (e.g.
`route:up`, `route:down`, `workspace:update`, `waveapp:appgoupdated`).

- **Dedicated WaveEvent TS generation path**
- Added `pkg/tsgen/tsgenevent.go` with `event -> reflect.Type` metadata
(`WaveEventDataTypes`).
  - Supports three cases:
    - mapped concrete type → strong TS payload type
    - mapped `nil` → `data?: null` (explicit no-data contract)
    - unmapped event → `data?: any` (non-breaking fallback)

- **Custom WaveEvent output and default suppression**
- Suppressed default struct-based `WaveEvent` emission in
`gotypes.d.ts`.
  - Added generated `frontend/types/waveevent.d.ts` containing:
    - `WaveEventName` string-literal union from `AllEvents`
    - discriminated `WaveEvent` union keyed by `event`.

- **Generator wiring + focused coverage**
- Hooked custom event generation into
`cmd/generatets/main-generatets.go`.
  - Added `pkg/tsgen/tsgenevent_test.go` assertions for:
    - typed mapped events
    - explicit `null` for known no-data events
    - `any` fallback for unmapped events.

```ts
type WaveEvent = {
  event: WaveEventName;
  scopes?: string[];
  sender?: string;
  persist?: number;
  data?: any;
} & (
  { event: "block:jobstatus"; data?: BlockJobStatusData } |
  { event: "route:up"; data?: null } |
  { event: "workspace:update"; data?: null } |
  { event: "some:future:event"; data?: any } // fallback if unmapped
);
```

<!-- START COPILOT CODING AGENT TIPS -->
---

💬 We'd love your input! Share your thoughts on Copilot coding agent in
our [2 minute survey](https://gh.io/copilot-coding-agent-survey).

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
Co-authored-by: sawka <mike@commandline.dev>
This commit is contained in:
Copilot 2026-02-20 17:04:03 -08:00 committed by GitHub
parent f3b1c16882
commit 195277de45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 417 additions and 162 deletions

23
.github/copilot-instructions.md vendored Normal file
View file

@ -0,0 +1,23 @@
# Wave Terminal — Copilot Instructions
## Project Rules
Read and follow all guidelines in [`.roo/rules/rules.md`](./.roo/rules/rules.md).
---
## Skill Guides
This project uses a set of "skill" guides — focused how-to documents for common implementation tasks. When your task matches one of the descriptions below, **read the linked SKILL.md file before proceeding** and follow its instructions precisely.
| Skill | Description |
|-------|-------------|
| [add-config](./.kilocode/skills/add-config/SKILL.md) | Guide for adding new configuration settings to Wave Terminal. Use when adding a new setting to the configuration system, implementing a new config key, or adding user-customizable settings. |
| [add-rpc](./.kilocode/skills/add-rpc/SKILL.md) | Guide for adding new RPC calls to Wave Terminal. Use when implementing new RPC commands, adding server-client communication methods, or extending the RPC interface with new functionality. |
| [add-wshcmd](./.kilocode/skills/add-wshcmd/SKILL.md) | Guide for adding new wsh commands to Wave Terminal. Use when implementing new CLI commands, adding command-line functionality, or extending the wsh command interface. |
| [context-menu](./.kilocode/skills/context-menu/SKILL.md) | Guide for creating and displaying context menus in Wave Terminal. Use when implementing right-click menus, adding context menu items, creating submenus, or handling menu interactions with checkboxes and separators. |
| [create-view](./.kilocode/skills/create-view/SKILL.md) | Guide for implementing a new view type in Wave Terminal. Use when creating a new view component, implementing the ViewModel interface, registering a new view type in BlockRegistry, or adding a new content type to display within blocks. |
| [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. |
| [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. |
> **How skills work:** Each skill is a self-contained guide covering the exact files to edit, patterns to follow, and steps to take for a specific type of task in this codebase. If your task matches a skill's description, open that SKILL.md and treat it as your primary reference for the implementation.

View file

@ -40,7 +40,7 @@ const (
Event_BlockClose = "blockclose"
Event_ConnChange = "connchange"
// ... other events ...
Event_YourNewEvent = "your:newevent" // Use colon notation for namespacing
Event_YourNewEvent = "your:newevent" // type: YourEventData (or "none" if no data)
)
```
@ -49,8 +49,37 @@ const (
- Use descriptive PascalCase for the constant name with `Event_` prefix
- Use lowercase with colons for the string value (e.g., "namespace:eventname")
- Group related events with the same namespace prefix
- Always add a `// type: <TypeName>` comment; use `// type: none` if no data is sent
### Step 2: Define Event Data Structure (Optional)
### Step 2: Add to AllEvents
Add your new constant to the `AllEvents` slice in `pkg/wps/wpstypes.go`:
```go
var AllEvents []string = []string{
// ... existing events ...
Event_YourNewEvent,
}
```
### Step 3: Register in WaveEventDataTypes (REQUIRED)
You **must** add an entry to `WaveEventDataTypes` in `pkg/tsgen/tsgenevent.go`. This drives TypeScript type generation for the event's `data` field:
```go
var WaveEventDataTypes = map[string]reflect.Type{
// ... existing entries ...
wps.Event_YourNewEvent: reflect.TypeOf(YourEventData{}), // value type
// wps.Event_YourNewEvent: reflect.TypeOf((*YourEventData)(nil)), // pointer type
// wps.Event_YourNewEvent: nil, // no data (type: none)
}
```
- Use `reflect.TypeOf(YourType{})` for value types
- Use `reflect.TypeOf((*YourType)(nil))` for pointer types
- Use `nil` if no data is sent for the event
### Step 4: Define Event Data Structure (Optional)
If your event carries structured data, define a type for it:
@ -61,7 +90,7 @@ type YourEventData struct {
}
```
### Step 3: Expose Type to Frontend (If Needed)
### Step 5: Expose Type to Frontend (If Needed)
If your event data type isn't already exposed via an RPC call, you need to add it to `pkg/tsgen/tsgen.go` so TypeScript types are generated:
@ -299,9 +328,11 @@ To debug event flow:
When adding a new event:
- [ ] Add event constant to `pkg/wps/wpstypes.go`
- [ ] Add event constant to [`pkg/wps/wpstypes.go`](pkg/wps/wpstypes.go) with a `// type: <TypeName>` comment (use `none` if no data)
- [ ] Add the constant to `AllEvents` in [`pkg/wps/wpstypes.go`](pkg/wps/wpstypes.go)
- [ ] **REQUIRED**: Add an entry to `WaveEventDataTypes` in [`pkg/tsgen/tsgenevent.go`](pkg/tsgen/tsgenevent.go) — use `nil` for events with no data
- [ ] Define event data structure (if needed)
- [ ] Add data type to `pkg/tsgen/tsgen.go` for frontend use
- [ ] Add data type to `pkg/tsgen/tsgen.go` for frontend use (if not already exposed via RPC)
- [ ] Run `task generate` to update TypeScript types
- [ ] Publish events using `wps.Broker.Publish()`
- [ ] Use goroutines for non-blocking publish when appropriate

View file

@ -92,7 +92,7 @@ The full API is defined in custom.d.ts as type ElectronApi.
- **Match response length to question complexity** - For simple, direct questions in Ask mode (especially those that can be answered in 1-2 sentences), provide equally brief answers. Save detailed explanations for complex topics or when explicitly requested.
- **CRITICAL** - useAtomValue and useAtom are React HOOKS. They cannot be used inline in JSX code, they must appear at the top of a component in the hooks area of the react code.
- for simple functions, we prefer `if (!cond) { return }; functionality;` pattern overn `if (cond) { functionality }` because it produces less indentation and is easier to follow.
- It is now 2026, so if you write new files use 2026 for the copyright year
- It is now 2026, so if you write new files, or update files use 2026 for the copyright year
### Strict Comment Rules

View file

@ -21,6 +21,7 @@ func generateTypesFile(tsTypesMap map[reflect.Type]string) error {
fileName := "frontend/types/gotypes.d.ts"
fmt.Fprintf(os.Stderr, "generating types file to %s\n", fileName)
tsgen.GenerateWaveObjTypes(tsTypesMap)
tsgen.GenerateWaveEventTypes(tsTypesMap)
err := tsgen.GenerateServiceTypes(tsTypesMap)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating service types: %v\n", err)
@ -31,7 +32,7 @@ func generateTypesFile(tsTypesMap map[reflect.Type]string) error {
return fmt.Errorf("error generating wsh server types: %w", err)
}
var buf bytes.Buffer
fmt.Fprintf(&buf, "// Copyright 2025, Command Line Inc.\n")
fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n")
fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n")
fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n")
fmt.Fprintf(&buf, "declare global {\n\n")
@ -62,11 +63,29 @@ func generateTypesFile(tsTypesMap map[reflect.Type]string) error {
return err
}
func generateWaveEventFile(tsTypesMap map[reflect.Type]string) error {
fileName := "frontend/types/waveevent.d.ts"
fmt.Fprintf(os.Stderr, "generating waveevent file to %s\n", fileName)
var buf bytes.Buffer
fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n")
fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n")
fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n")
fmt.Fprintf(&buf, "declare global {\n\n")
fmt.Fprint(&buf, utilfn.IndentString(" ", tsgen.GenerateWaveEventTypes(tsTypesMap)))
fmt.Fprintf(&buf, "}\n\n")
fmt.Fprintf(&buf, "export {}\n")
written, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes())
if !written {
fmt.Fprintf(os.Stderr, "no changes to %s\n", fileName)
}
return err
}
func generateServicesFile(tsTypesMap map[reflect.Type]string) error {
fileName := "frontend/app/store/services.ts"
var buf bytes.Buffer
fmt.Fprintf(os.Stderr, "generating services file to %s\n", fileName)
fmt.Fprintf(&buf, "// Copyright 2025, Command Line Inc.\n")
fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n")
fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n")
fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n")
fmt.Fprintf(&buf, "import * as WOS from \"./wos\";\n\n")
@ -89,7 +108,7 @@ func generateWshClientApiFile(tsTypeMap map[reflect.Type]string) error {
var buf bytes.Buffer
declMap := wshrpc.GenerateWshCommandDeclMap()
fmt.Fprintf(os.Stderr, "generating wshclientapi file to %s\n", fileName)
fmt.Fprintf(&buf, "// Copyright 2025, Command Line Inc.\n")
fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n")
fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n")
fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n")
fmt.Fprintf(&buf, "import { WshClient } from \"./wshclient\";\n\n")
@ -128,6 +147,11 @@ func main() {
fmt.Fprintf(os.Stderr, "Error generating services file: %v\n", err)
os.Exit(1)
}
err = generateWaveEventFile(tsTypesMap)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating wave event file: %v\n", err)
os.Exit(1)
}
err = generateWshClientApiFile(tsTypesMap)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating wshserver file: %v\n", err)

View file

@ -1,7 +1,7 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { waveEventSubscribe } from "@/app/store/wps";
import { waveEventSubscribeSingle } from "@/app/store/wps";
import { RpcApi } from "@/app/store/wshclientapi";
import * as electron from "electron";
import { fireAndForget } from "../frontend/util/util";
@ -385,7 +385,7 @@ export function makeAndSetAppMenu() {
}
function initMenuEventSubscriptions() {
waveEventSubscribe({
waveEventSubscribeSingle({
eventType: "workspace:update",
handler: makeAndSetAppMenu,
});

View file

@ -31,7 +31,7 @@ import { globalStore } from "./jotaiStore";
import { modalsModel } from "./modalmodel";
import { ClientService, ObjectService } from "./services";
import * as WOS from "./wos";
import { getFileSubject, waveEventSubscribe } from "./wps";
import { getFileSubject, waveEventSubscribeSingle } from "./wps";
let atoms: GlobalAtomsType;
let globalEnvironment: "electron" | "renderer";
@ -198,65 +198,56 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
}
function initGlobalWaveEventSubs(initOpts: WaveInitOpts) {
waveEventSubscribe(
{
eventType: "waveobj:update",
handler: (event) => {
// console.log("waveobj:update wave event handler", event);
const update: WaveObjUpdate = event.data;
WOS.updateWaveObject(update);
},
waveEventSubscribeSingle({
eventType: "waveobj:update",
handler: (event) => {
// console.log("waveobj:update wave event handler", event);
WOS.updateWaveObject(event.data);
},
{
eventType: "config",
handler: (event) => {
// console.log("config wave event handler", event);
const fullConfig = (event.data as WatcherUpdate).fullconfig;
globalStore.set(atoms.fullConfigAtom, fullConfig);
},
});
waveEventSubscribeSingle({
eventType: "config",
handler: (event) => {
// console.log("config wave event handler", event);
globalStore.set(atoms.fullConfigAtom, event.data.fullconfig);
},
{
eventType: "waveai:modeconfig",
handler: (event) => {
const modeConfigs = (event.data as AIModeConfigUpdate).configs;
globalStore.set(atoms.waveaiModeConfigAtom, modeConfigs);
},
});
waveEventSubscribeSingle({
eventType: "waveai:modeconfig",
handler: (event) => {
globalStore.set(atoms.waveaiModeConfigAtom, event.data.configs);
},
{
eventType: "userinput",
handler: (event) => {
// console.log("userinput event handler", event);
const data: UserInputRequest = event.data;
modalsModel.pushModal("UserInputModal", { ...data });
},
scope: initOpts.windowId,
});
waveEventSubscribeSingle({
eventType: "userinput",
handler: (event) => {
// console.log("userinput event handler", event);
modalsModel.pushModal("UserInputModal", { ...event.data });
},
{
eventType: "blockfile",
handler: (event) => {
// console.log("blockfile event update", event);
const fileData: WSFileEventData = event.data;
const fileSubject = getFileSubject(fileData.zoneid, fileData.filename);
if (fileSubject != null) {
fileSubject.next(fileData);
}
},
scope: initOpts.windowId,
});
waveEventSubscribeSingle({
eventType: "blockfile",
handler: (event) => {
// console.log("blockfile event update", event);
const fileSubject = getFileSubject(event.data.zoneid, event.data.filename);
if (fileSubject != null) {
fileSubject.next(event.data);
}
},
{
eventType: "waveai:ratelimit",
handler: (event) => {
const rateLimitInfo: RateLimitInfo = event.data;
globalStore.set(atoms.waveAIRateLimitInfoAtom, rateLimitInfo);
},
});
waveEventSubscribeSingle({
eventType: "waveai:ratelimit",
handler: (event) => {
globalStore.set(atoms.waveAIRateLimitInfoAtom, event.data);
},
{
eventType: "tab:indicator",
handler: (event) => {
const data: TabIndicatorEventData = event.data;
setTabIndicatorInternal(data.tabid, data.indicator);
},
}
);
});
waveEventSubscribeSingle({
eventType: "tab:indicator",
handler: (event) => {
setTabIndicatorInternal(event.data.tabid, event.data.indicator);
},
});
}
const blockCache = new Map<string, Map<string, any>>();
@ -762,11 +753,11 @@ async function loadTabIndicators() {
}
function subscribeToConnEvents() {
waveEventSubscribe({
waveEventSubscribeSingle({
eventType: "connchange",
handler: (event: WaveEvent) => {
handler: (event) => {
try {
const connStatus = event.data as ConnStatus;
const connStatus = event.data;
if (connStatus == null || isBlank(connStatus.connection)) {
return;
}
@ -852,7 +843,7 @@ function setTabIndicator(tabId: string, indicator: TabIndicator) {
data: {
tabid: tabId,
indicator: indicator,
} as TabIndicatorEventData,
},
};
fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData));
}

View file

@ -1,4 +1,4 @@
// Copyright 2025, Command Line Inc.
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
// generated by cmd/generate/main-generatets.go

View file

@ -3,7 +3,7 @@
// WaveObjectStore
import { waveEventSubscribe } from "@/app/store/wps";
import { waveEventSubscribeSingle } from "@/app/store/wps";
import { getWebServerEndpoint } from "@/util/endpoints";
import { fetch } from "@/util/fetchutil";
import { fireAndForget } from "@/util/util";
@ -79,7 +79,7 @@ function debugLogBackendCall(methodName: string, durationStr: string, args: any[
}
function wpsSubscribeToObject(oref: string): () => void {
return waveEventSubscribe({
return waveEventSubscribeSingle({
eventType: "waveobj:update",
scope: oref,
handler: (event) => {

View file

@ -12,17 +12,19 @@ function setWpsRpcClient(client: WshClient) {
WpsRpcClient = client;
}
type WaveEventSubject = {
handler: (event: WaveEvent) => void;
type WaveEventSubject<T extends WaveEventName = WaveEventName> = {
handler: (event: Extract<WaveEvent, { event: T }>) => void;
scope?: string;
};
type WaveEventSubjectContainer = WaveEventSubject & {
type WaveEventSubjectContainer = {
handler: (event: WaveEvent) => void;
scope?: string;
id: string;
};
type WaveEventSubscription = WaveEventSubject & {
eventType: string;
type WaveEventSubscription<T extends WaveEventName = WaveEventName> = WaveEventSubject<T> & {
eventType: T;
};
type WaveEventUnsubscribe = {
@ -58,29 +60,25 @@ function updateWaveEventSub(eventType: string) {
RpcApi.EventSubCommand(WpsRpcClient, subreq, { noresponse: true });
}
function waveEventSubscribe(...subscriptions: WaveEventSubscription[]): () => void {
const unsubs: WaveEventUnsubscribe[] = [];
const eventTypeSet = new Set<string>();
for (const subscription of subscriptions) {
// console.log("waveEventSubscribe", subscription);
if (subscription.handler == null) {
return;
}
const id: string = crypto.randomUUID();
let subjects = waveEventSubjects.get(subscription.eventType);
if (subjects == null) {
subjects = [];
waveEventSubjects.set(subscription.eventType, subjects);
}
const subcont: WaveEventSubjectContainer = { id, handler: subscription.handler, scope: subscription.scope };
subjects.push(subcont);
unsubs.push({ id, eventType: subscription.eventType });
eventTypeSet.add(subscription.eventType);
function waveEventSubscribeSingle<T extends WaveEventName>(subscription: WaveEventSubscription<T>): () => void {
// console.log("waveEventSubscribeSingle", subscription);
if (subscription.handler == null) {
return () => {};
}
for (const eventType of eventTypeSet) {
updateWaveEventSub(eventType);
const id: string = crypto.randomUUID();
let subjects = waveEventSubjects.get(subscription.eventType);
if (subjects == null) {
subjects = [];
waveEventSubjects.set(subscription.eventType, subjects);
}
return () => waveEventUnsubscribe(...unsubs);
const subcont: WaveEventSubjectContainer = {
id,
handler: subscription.handler as (event: WaveEvent) => void,
scope: subscription.scope,
};
subjects.push(subcont);
updateWaveEventSub(subscription.eventType);
return () => waveEventUnsubscribe({ id, eventType: subscription.eventType });
}
function waveEventUnsubscribe(...unsubscribes: WaveEventUnsubscribe[]) {
@ -149,7 +147,7 @@ export {
getFileSubject,
handleWaveEvent,
setWpsRpcClient,
waveEventSubscribe,
waveEventSubscribeSingle,
waveEventUnsubscribe,
wpsReconnectHandler,
};

View file

@ -1,4 +1,4 @@
// Copyright 2025, Command Line Inc.
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
// generated by cmd/generate/main-generatets.go

View file

@ -21,7 +21,7 @@ import { IconButton } from "../element/iconbutton";
import { atoms, getApi } from "../store/global";
import { WorkspaceService } from "../store/services";
import { getObjectValue, makeORef } from "../store/wos";
import { waveEventSubscribe } from "../store/wps";
import { waveEventSubscribeSingle } from "../store/wps";
import { WorkspaceEditor } from "./workspaceeditor";
import "./workspaceswitcher.scss";
@ -59,7 +59,7 @@ const WorkspaceSwitcher = forwardRef<HTMLDivElement>((_, ref) => {
useEffect(
() =>
waveEventSubscribe({
waveEventSubscribeSingle({
eventType: "workspace:update",
handler: () => fireAndForget(updateWorkspaceList),
}),

View file

@ -13,7 +13,7 @@ import * as jotai from "jotai";
import * as React from "react";
import { useDimensionsWithExistingRef } from "@/app/hook/useDimensions";
import { waveEventSubscribe } from "@/app/store/wps";
import { waveEventSubscribeSingle } from "@/app/store/wps";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { atoms } from "@/store/global";
@ -80,8 +80,8 @@ for (let i = 0; i < 32; i++) {
DefaultPlotMeta[`cpu:${i}`] = defaultCpuMeta(`Core ${i}`);
}
function convertWaveEventToDataItem(event: WaveEvent): DataItem {
const eventData: TimeSeriesData = event.data;
function convertWaveEventToDataItem(event: Extract<WaveEvent, { event: "sysinfo" }>): DataItem {
const eventData = event.data;
if (eventData == null || eventData.ts == null || eventData.values == null) {
return null;
}
@ -360,7 +360,7 @@ function SysinfoView({ model, blockId }: SysinfoViewProps) {
}
}, [connStatus.status, connName]);
React.useEffect(() => {
const unsubFn = waveEventSubscribe({
const unsubFn = waveEventSubscribeSingle({
eventType: "sysinfo",
scope: connName,
handler: (event) => {

View file

@ -6,7 +6,7 @@ import { BlockNodeModel } from "@/app/block/blocktypes";
import { appHandleKeyDown } from "@/app/store/keymodel";
import { modalsModel } from "@/app/store/modalmodel";
import type { TabModel } from "@/app/store/tab-model";
import { waveEventSubscribe } from "@/app/store/wps";
import { waveEventSubscribeSingle } from "@/app/store/wps";
import { RpcApi } from "@/app/store/wshclientapi";
import { makeFeBlockRouteId } from "@/app/store/wshrouter";
import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil";
@ -330,12 +330,11 @@ export class TermViewModel implements ViewModel {
initialShellProcStatus.then((rts) => {
this.updateShellProcStatus(rts);
});
this.shellProcStatusUnsubFn = waveEventSubscribe({
this.shellProcStatusUnsubFn = waveEventSubscribeSingle({
eventType: "controllerstatus",
scope: WOS.makeORef("block", blockId),
handler: (event) => {
let bcRTS: BlockControllerRuntimeStatus = event.data;
this.updateShellProcStatus(bcRTS);
this.updateShellProcStatus(event.data);
},
});
this.shellProcStatus = jotai.atom((get) => {
@ -364,7 +363,7 @@ export class TermViewModel implements ViewModel {
.catch((error) => {
console.log("error getting initial block job status", error);
});
this.blockJobStatusUnsubFn = waveEventSubscribe({
this.blockJobStatusUnsubFn = waveEventSubscribeSingle({
eventType: "block:jobstatus",
scope: `block:${blockId}`,
handler: (event) => {

View file

@ -6,7 +6,7 @@ import type { BlockNodeModel } from "@/app/block/blocktypes";
import { Search, useSearch } from "@/app/element/search";
import { ContextMenuModel } from "@/app/store/contextmenu";
import { useTabModel } from "@/app/store/tab-model";
import { waveEventSubscribe } from "@/app/store/wps";
import { waveEventSubscribeSingle } from "@/app/store/wps";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import type { TermViewModel } from "@/app/view/term/term-model";
@ -55,7 +55,7 @@ const TermResyncHandler = React.memo(({ blockId, model }: TerminalViewProps) =>
const TermVDomToolbarNode = ({ vdomBlockId, blockId, model }: TerminalViewProps & { vdomBlockId: string }) => {
React.useEffect(() => {
const unsub = waveEventSubscribe({
const unsub = waveEventSubscribeSingle({
eventType: "blockclose",
scope: WOS.makeORef("block", vdomBlockId),
handler: (event) => {
@ -98,7 +98,7 @@ const TermVDomToolbarNode = ({ vdomBlockId, blockId, model }: TerminalViewProps
const TermVDomNodeSingleId = ({ vdomBlockId, blockId, model }: TerminalViewProps & { vdomBlockId: string }) => {
React.useEffect(() => {
const unsub = waveEventSubscribe({
const unsub = waveEventSubscribeSingle({
eventType: "blockclose",
scope: WOS.makeORef("block", vdomBlockId),
handler: (event) => {

View file

@ -4,7 +4,7 @@
import { BlockNodeModel } from "@/app/block/blocktypes";
import { getApi, globalStore, WOS } from "@/app/store/global";
import type { TabModel } from "@/app/store/tab-model";
import { waveEventSubscribe } from "@/app/store/wps";
import { waveEventSubscribeSingle } from "@/app/store/wps";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { WebView, WebViewModel } from "@/app/view/webview/webview";
@ -37,12 +37,11 @@ class TsunamiViewModel extends WebViewModel {
initialShellProcStatus.then((rts) => {
this.updateShellProcStatus(rts);
});
this.shellProcStatusUnsubFn = waveEventSubscribe({
this.shellProcStatusUnsubFn = waveEventSubscribeSingle({
eventType: "controllerstatus",
scope: WOS.makeORef("block", blockId),
handler: (event) => {
let bcRTS: BlockControllerRuntimeStatus = event.data;
this.updateShellProcStatus(bcRTS);
this.updateShellProcStatus(event.data);
},
});
@ -69,12 +68,11 @@ class TsunamiViewModel extends WebViewModel {
globalStore.set(this.appMeta, rtInfo["tsunami:appmeta"]);
}
});
this.appMetaUnsubFn = waveEventSubscribe({
this.appMetaUnsubFn = waveEventSubscribeSingle({
eventType: "tsunami:updatemeta",
scope: WOS.makeORef("block", blockId),
handler: (event) => {
const meta: AppMeta = event.data;
globalStore.set(this.appMeta, meta);
globalStore.set(this.appMeta, event.data);
},
});
}

View file

@ -5,7 +5,7 @@ import { BlockNodeModel } from "@/app/block/blocktypes";
import type { TabModel } from "@/app/store/tab-model";
import { getBlockMetaKeyAtom, globalStore, WOS } from "@/app/store/global";
import { makeORef } from "@/app/store/wos";
import { waveEventSubscribe } from "@/app/store/wps";
import { waveEventSubscribeSingle } from "@/app/store/wps";
import { RpcResponseHelper, WshClient } from "@/app/store/wshclient";
import { RpcApi } from "@/app/store/wshclientapi";
import { makeFeBlockRouteId } from "@/app/store/wshrouter";
@ -161,10 +161,10 @@ export class VDomModel {
if (curBackendRoute) {
this.queueUpdate(true);
}
this.routeGoneUnsub = waveEventSubscribe({
this.routeGoneUnsub = waveEventSubscribeSingle({
eventType: "route:down",
scope: curBackendRoute,
handler: (event: WaveEvent) => {
handler: (_event) => {
this.disposed = true;
const shouldPersist = globalStore.get(this.persist);
if (!shouldPersist) {

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import { globalStore } from "@/app/store/jotaiStore";
import { waveEventSubscribe } from "@/app/store/wps";
import { waveEventSubscribeSingle } from "@/app/store/wps";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { atoms, getApi, WOS } from "@/store/global";
@ -79,11 +79,11 @@ export class BuilderAppPanelModel {
this.statusUnsubFn();
}
this.statusUnsubFn = waveEventSubscribe({
this.statusUnsubFn = waveEventSubscribeSingle({
eventType: "builderstatus",
scope: WOS.makeORef("builder", builderId),
handler: (event) => {
const status: BuilderStatusData = event.data;
const status = event.data;
const currentStatus = globalStore.get(this.builderStatusAtom);
if (!currentStatus || !currentStatus.version || status.version > currentStatus.version) {
globalStore.set(this.builderStatusAtom, status);
@ -105,7 +105,7 @@ export class BuilderAppPanelModel {
await this.loadAppFile(appId);
await this.loadEnvVars(builderId);
this.appGoUpdateUnsubFn = waveEventSubscribe({
this.appGoUpdateUnsubFn = waveEventSubscribeSingle({
eventType: "waveapp:appgoupdated",
scope: appId,
handler: () => {

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import { globalStore } from "@/app/store/jotaiStore";
import { waveEventSubscribe } from "@/app/store/wps";
import { waveEventSubscribeSingle } from "@/app/store/wps";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { atoms, WOS } from "@/store/global";
@ -36,7 +36,7 @@ export class BuilderBuildPanelModel {
this.outputUnsubFn();
}
this.outputUnsubFn = waveEventSubscribe({
this.outputUnsubFn = waveEventSubscribeSingle({
eventType: "builderoutput",
scope: WOS.makeORef("builder", builderId),
handler: (event) => {

View file

@ -1,4 +1,4 @@
// Copyright 2025, Command Line Inc.
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
// generated by cmd/generate/main-generatets.go
@ -1931,14 +1931,6 @@ declare global {
total_tokens?: number;
};
// wps.WaveEvent
type WaveEvent = {
event: string;
scopes?: string[];
sender?: string;
persist?: number;
data?: any;
};
// filestore.WaveFile
type WaveFile = {

41
frontend/types/waveevent.d.ts vendored Normal file
View file

@ -0,0 +1,41 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
// generated by cmd/generate/main-generatets.go
declare global {
// wps.WaveEvent
type WaveEventName = "blockclose" | "connchange" | "sysinfo" | "controllerstatus" | "builderstatus" | "builderoutput" | "waveobj:update" | "blockfile" | "config" | "userinput" | "route:down" | "route:up" | "workspace:update" | "waveai:ratelimit" | "waveapp:appgoupdated" | "tsunami:updatemeta" | "waveai:modeconfig" | "tab:indicator" | "block:jobstatus";
type WaveEvent = {
event: WaveEventName;
scopes?: string[];
sender?: string;
persist?: number;
data?: unknown;
} & (
{ event: "blockclose"; data?: string; } |
{ event: "connchange"; data?: ConnStatus; } |
{ event: "sysinfo"; data?: TimeSeriesData; } |
{ event: "controllerstatus"; data?: BlockControllerRuntimeStatus; } |
{ event: "builderstatus"; data?: BuilderStatusData; } |
{ event: "builderoutput"; data?: {[key: string]: any}; } |
{ event: "waveobj:update"; data?: WaveObjUpdate; } |
{ event: "blockfile"; data?: WSFileEventData; } |
{ event: "config"; data?: WatcherUpdate; } |
{ event: "userinput"; data?: UserInputRequest; } |
{ event: "route:down"; data?: null; } |
{ event: "route:up"; data?: null; } |
{ event: "workspace:update"; data?: null; } |
{ event: "waveai:ratelimit"; data?: RateLimitInfo; } |
{ event: "waveapp:appgoupdated"; data?: null; } |
{ event: "tsunami:updatemeta"; data?: AppMeta; } |
{ event: "waveai:modeconfig"; data?: AIModeConfigUpdate; } |
{ event: "tab:indicator"; data?: TabIndicatorEventData; } |
{ event: "block:jobstatus"; data?: BlockJobStatusData; }
);
}
export {}

View file

@ -13,7 +13,7 @@ import (
)
func GenerateBoilerplate(buf *strings.Builder, pkgName string, imports []string) {
buf.WriteString("// Copyright 2025, Command Line Inc.\n")
buf.WriteString("// Copyright 2026, Command Line Inc.\n")
buf.WriteString("// SPDX-License-Identifier: Apache-2.0\n")
buf.WriteString("\n// Generated Code. DO NOT EDIT.\n\n")
buf.WriteString(fmt.Sprintf("package %s\n\n", pkgName))

92
pkg/tsgen/tsgenevent.go Normal file
View file

@ -0,0 +1,92 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package tsgen
import (
"bytes"
"fmt"
"reflect"
"strconv"
"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes"
"github.com/wavetermdev/waveterm/pkg/blockcontroller"
"github.com/wavetermdev/waveterm/pkg/userinput"
"github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wconfig"
"github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
)
var waveEventRType = reflect.TypeOf(wps.WaveEvent{})
var WaveEventDataTypes = map[string]reflect.Type{
wps.Event_BlockClose: reflect.TypeOf(""),
wps.Event_ConnChange: reflect.TypeOf(wshrpc.ConnStatus{}),
wps.Event_SysInfo: reflect.TypeOf(wshrpc.TimeSeriesData{}),
wps.Event_ControllerStatus: reflect.TypeOf((*blockcontroller.BlockControllerRuntimeStatus)(nil)),
wps.Event_BuilderStatus: reflect.TypeOf(wshrpc.BuilderStatusData{}),
wps.Event_BuilderOutput: reflect.TypeOf(map[string]any{}),
wps.Event_WaveObjUpdate: reflect.TypeOf(waveobj.WaveObjUpdate{}),
wps.Event_BlockFile: reflect.TypeOf((*wps.WSFileEventData)(nil)),
wps.Event_Config: reflect.TypeOf(wconfig.WatcherUpdate{}),
wps.Event_UserInput: reflect.TypeOf((*userinput.UserInputRequest)(nil)),
wps.Event_RouteDown: nil,
wps.Event_RouteUp: nil,
wps.Event_WorkspaceUpdate: nil,
wps.Event_WaveAIRateLimit: reflect.TypeOf((*uctypes.RateLimitInfo)(nil)),
wps.Event_WaveAppAppGoUpdated: nil,
wps.Event_TsunamiUpdateMeta: reflect.TypeOf(wshrpc.AppMeta{}),
wps.Event_AIModeConfig: reflect.TypeOf(wconfig.AIModeConfigUpdate{}),
wps.Event_TabIndicator: reflect.TypeOf(wshrpc.TabIndicatorEventData{}),
wps.Event_BlockJobStatus: reflect.TypeOf(wshrpc.BlockJobStatusData{}),
}
func getWaveEventDataTSType(eventName string, tsTypesMap map[reflect.Type]string) string {
rtype, found := WaveEventDataTypes[eventName]
if !found {
return "any"
}
if rtype == nil {
return "null"
}
tsType, _ := TypeToTSType(rtype, tsTypesMap)
if tsType == "" {
return "any"
}
return tsType
}
func GenerateWaveEventTypes(tsTypesMap map[reflect.Type]string) string {
for _, rtype := range WaveEventDataTypes {
GenerateTSType(rtype, tsTypesMap)
}
// suppress default struct generation, this type is custom generated
tsTypesMap[waveEventRType] = ""
var buf bytes.Buffer
buf.WriteString("// wps.WaveEvent\n")
buf.WriteString("type WaveEventName = ")
for idx, eventName := range wps.AllEvents {
if idx > 0 {
buf.WriteString(" | ")
}
buf.WriteString(strconv.Quote(eventName))
}
buf.WriteString(";\n\n")
buf.WriteString("type WaveEvent = {\n")
buf.WriteString(" event: WaveEventName;\n")
buf.WriteString(" scopes?: string[];\n")
buf.WriteString(" sender?: string;\n")
buf.WriteString(" persist?: number;\n")
buf.WriteString(" data?: unknown;\n")
buf.WriteString("} & (\n")
for idx, eventName := range wps.AllEvents {
if idx > 0 {
buf.WriteString(" | \n")
}
buf.WriteString(fmt.Sprintf(" { event: %s; data?: %s; }", strconv.Quote(eventName), getWaveEventDataTSType(eventName, tsTypesMap)))
}
buf.WriteString("\n);\n")
return buf.String()
}

View file

@ -0,0 +1,37 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package tsgen
import (
"reflect"
"strings"
"testing"
"github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
)
func TestGenerateWaveEventTypes(t *testing.T) {
tsTypesMap := make(map[reflect.Type]string)
waveEventTypeDecl := GenerateWaveEventTypes(tsTypesMap)
if !strings.Contains(waveEventTypeDecl, `type WaveEventName = "blockclose"`) {
t.Fatalf("expected WaveEventName declaration, got:\n%s", waveEventTypeDecl)
}
if !strings.Contains(waveEventTypeDecl, `{ event: "block:jobstatus"; data?: BlockJobStatusData; }`) {
t.Fatalf("expected typed block:jobstatus event, got:\n%s", waveEventTypeDecl)
}
if !strings.Contains(waveEventTypeDecl, `{ event: "route:up"; data?: null; }`) {
t.Fatalf("expected null for known no-data event, got:\n%s", waveEventTypeDecl)
}
if got := getWaveEventDataTSType("unmapped:event", tsTypesMap); got != "any" {
t.Fatalf("expected any for unmapped event fallback, got: %q", got)
}
if _, found := tsTypesMap[reflect.TypeOf(wps.WaveEvent{})]; !found {
t.Fatalf("expected WaveEvent type to be seeded in tsTypesMap")
}
if _, found := tsTypesMap[reflect.TypeOf(wshrpc.BlockJobStatusData{})]; !found {
t.Fatalf("expected mapped data types to be generated into tsTypesMap")
}
}

View file

@ -1,4 +1,4 @@
// Copyright 2025, Command Line Inc.
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
// Generated Code. DO NOT EDIT.

View file

@ -1,4 +1,4 @@
// Copyright 2025, Command Line Inc.
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
// Generated Code. DO NOT EDIT.

View file

@ -7,28 +7,57 @@ import (
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
)
// IMPORTANT: When adding a new event constant, you MUST also:
// 1. Add a "// type: <TypeName>" comment (use "none" if no data is sent)
// 2. Add the constant to AllEvents below
// 3. Add an entry to WaveEventDataTypes in pkg/tsgen/tsgenevent.go
// - Use reflect.TypeOf(YourType{}) for value types
// - Use reflect.TypeOf((*YourType)(nil)) for pointer types
// - Use nil if no data is sent for the event
const (
Event_BlockClose = "blockclose"
Event_ConnChange = "connchange"
Event_SysInfo = "sysinfo"
Event_ControllerStatus = "controllerstatus"
Event_BuilderStatus = "builderstatus"
Event_BuilderOutput = "builderoutput"
Event_WaveObjUpdate = "waveobj:update"
Event_BlockFile = "blockfile"
Event_Config = "config"
Event_UserInput = "userinput"
Event_RouteDown = "route:down"
Event_RouteUp = "route:up"
Event_WorkspaceUpdate = "workspace:update"
Event_WaveAIRateLimit = "waveai:ratelimit"
Event_WaveAppAppGoUpdated = "waveapp:appgoupdated"
Event_TsunamiUpdateMeta = "tsunami:updatemeta"
Event_AIModeConfig = "waveai:modeconfig"
Event_TabIndicator = "tab:indicator"
Event_BlockJobStatus = "block:jobstatus" // type: BlockJobStatusData
Event_BlockClose = "blockclose" // type: string
Event_ConnChange = "connchange" // type: wshrpc.ConnStatus
Event_SysInfo = "sysinfo" // type: wshrpc.TimeSeriesData
Event_ControllerStatus = "controllerstatus" // type: *blockcontroller.BlockControllerRuntimeStatus
Event_BuilderStatus = "builderstatus" // type: wshrpc.BuilderStatusData
Event_BuilderOutput = "builderoutput" // type: map[string]any
Event_WaveObjUpdate = "waveobj:update" // type: waveobj.WaveObjUpdate
Event_BlockFile = "blockfile" // type: *WSFileEventData
Event_Config = "config" // type: wconfig.WatcherUpdate
Event_UserInput = "userinput" // type: *userinput.UserInputRequest
Event_RouteDown = "route:down" // type: none
Event_RouteUp = "route:up" // type: none
Event_WorkspaceUpdate = "workspace:update" // type: none
Event_WaveAIRateLimit = "waveai:ratelimit" // type: *uctypes.RateLimitInfo
Event_WaveAppAppGoUpdated = "waveapp:appgoupdated" // type: none
Event_TsunamiUpdateMeta = "tsunami:updatemeta" // type: wshrpc.AppMeta
Event_AIModeConfig = "waveai:modeconfig" // type: wconfig.AIModeConfigUpdate
Event_TabIndicator = "tab:indicator" // type: wshrpc.TabIndicatorEventData
Event_BlockJobStatus = "block:jobstatus" // type: wshrpc.BlockJobStatusData
)
var AllEvents []string = []string{
Event_BlockClose,
Event_ConnChange,
Event_SysInfo,
Event_ControllerStatus,
Event_BuilderStatus,
Event_BuilderOutput,
Event_WaveObjUpdate,
Event_BlockFile,
Event_Config,
Event_UserInput,
Event_RouteDown,
Event_RouteUp,
Event_WorkspaceUpdate,
Event_WaveAIRateLimit,
Event_WaveAppAppGoUpdated,
Event_TsunamiUpdateMeta,
Event_AIModeConfig,
Event_TabIndicator,
Event_BlockJobStatus,
}
type WaveEvent struct {
Event string `json:"event"`
Scopes []string `json:"scopes,omitempty"`

View file

@ -1,4 +1,4 @@
// Copyright 2025, Command Line Inc.
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
// Generated Code. DO NOT EDIT.