Skip to content

Commit 195277d

Browse files
Copilotsawka
andauthored
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>
1 parent f3b1c16 commit 195277d

27 files changed

Lines changed: 417 additions & 162 deletions

File tree

.github/copilot-instructions.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Wave Terminal — Copilot Instructions
2+
3+
## Project Rules
4+
5+
Read and follow all guidelines in [`.roo/rules/rules.md`](./.roo/rules/rules.md).
6+
7+
---
8+
9+
## Skill Guides
10+
11+
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.
12+
13+
| Skill | Description |
14+
|-------|-------------|
15+
| [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. |
16+
| [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. |
17+
| [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. |
18+
| [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. |
19+
| [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. |
20+
| [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. |
21+
| [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. |
22+
23+
> **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.

.kilocode/skills/wps-events/SKILL.md

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const (
4040
Event_BlockClose = "blockclose"
4141
Event_ConnChange = "connchange"
4242
// ... other events ...
43-
Event_YourNewEvent = "your:newevent" // Use colon notation for namespacing
43+
Event_YourNewEvent = "your:newevent" // type: YourEventData (or "none" if no data)
4444
)
4545
```
4646

@@ -49,8 +49,37 @@ const (
4949
- Use descriptive PascalCase for the constant name with `Event_` prefix
5050
- Use lowercase with colons for the string value (e.g., "namespace:eventname")
5151
- Group related events with the same namespace prefix
52+
- Always add a `// type: <TypeName>` comment; use `// type: none` if no data is sent
5253

53-
### Step 2: Define Event Data Structure (Optional)
54+
### Step 2: Add to AllEvents
55+
56+
Add your new constant to the `AllEvents` slice in `pkg/wps/wpstypes.go`:
57+
58+
```go
59+
var AllEvents []string = []string{
60+
// ... existing events ...
61+
Event_YourNewEvent,
62+
}
63+
```
64+
65+
### Step 3: Register in WaveEventDataTypes (REQUIRED)
66+
67+
You **must** add an entry to `WaveEventDataTypes` in `pkg/tsgen/tsgenevent.go`. This drives TypeScript type generation for the event's `data` field:
68+
69+
```go
70+
var WaveEventDataTypes = map[string]reflect.Type{
71+
// ... existing entries ...
72+
wps.Event_YourNewEvent: reflect.TypeOf(YourEventData{}), // value type
73+
// wps.Event_YourNewEvent: reflect.TypeOf((*YourEventData)(nil)), // pointer type
74+
// wps.Event_YourNewEvent: nil, // no data (type: none)
75+
}
76+
```
77+
78+
- Use `reflect.TypeOf(YourType{})` for value types
79+
- Use `reflect.TypeOf((*YourType)(nil))` for pointer types
80+
- Use `nil` if no data is sent for the event
81+
82+
### Step 4: Define Event Data Structure (Optional)
5483

5584
If your event carries structured data, define a type for it:
5685

@@ -61,7 +90,7 @@ type YourEventData struct {
6190
}
6291
```
6392

64-
### Step 3: Expose Type to Frontend (If Needed)
93+
### Step 5: Expose Type to Frontend (If Needed)
6594

6695
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:
6796

@@ -299,9 +328,11 @@ To debug event flow:
299328

300329
When adding a new event:
301330

302-
- [ ] Add event constant to `pkg/wps/wpstypes.go`
331+
- [ ] Add event constant to [`pkg/wps/wpstypes.go`](pkg/wps/wpstypes.go) with a `// type: <TypeName>` comment (use `none` if no data)
332+
- [ ] Add the constant to `AllEvents` in [`pkg/wps/wpstypes.go`](pkg/wps/wpstypes.go)
333+
- [ ] **REQUIRED**: Add an entry to `WaveEventDataTypes` in [`pkg/tsgen/tsgenevent.go`](pkg/tsgen/tsgenevent.go) — use `nil` for events with no data
303334
- [ ] Define event data structure (if needed)
304-
- [ ] Add data type to `pkg/tsgen/tsgen.go` for frontend use
335+
- [ ] Add data type to `pkg/tsgen/tsgen.go` for frontend use (if not already exposed via RPC)
305336
- [ ] Run `task generate` to update TypeScript types
306337
- [ ] Publish events using `wps.Broker.Publish()`
307338
- [ ] Use goroutines for non-blocking publish when appropriate

.roo/rules/rules.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ The full API is defined in custom.d.ts as type ElectronApi.
9292
- **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.
9393
- **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.
9494
- for simple functions, we prefer `if (!cond) { return }; functionality;` pattern overn `if (cond) { functionality }` because it produces less indentation and is easier to follow.
95-
- It is now 2026, so if you write new files use 2026 for the copyright year
95+
- It is now 2026, so if you write new files, or update files use 2026 for the copyright year
9696

9797
### Strict Comment Rules
9898

cmd/generatets/main-generatets.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ func generateTypesFile(tsTypesMap map[reflect.Type]string) error {
2121
fileName := "frontend/types/gotypes.d.ts"
2222
fmt.Fprintf(os.Stderr, "generating types file to %s\n", fileName)
2323
tsgen.GenerateWaveObjTypes(tsTypesMap)
24+
tsgen.GenerateWaveEventTypes(tsTypesMap)
2425
err := tsgen.GenerateServiceTypes(tsTypesMap)
2526
if err != nil {
2627
fmt.Fprintf(os.Stderr, "Error generating service types: %v\n", err)
@@ -31,7 +32,7 @@ func generateTypesFile(tsTypesMap map[reflect.Type]string) error {
3132
return fmt.Errorf("error generating wsh server types: %w", err)
3233
}
3334
var buf bytes.Buffer
34-
fmt.Fprintf(&buf, "// Copyright 2025, Command Line Inc.\n")
35+
fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n")
3536
fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n")
3637
fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n")
3738
fmt.Fprintf(&buf, "declare global {\n\n")
@@ -62,11 +63,29 @@ func generateTypesFile(tsTypesMap map[reflect.Type]string) error {
6263
return err
6364
}
6465

66+
func generateWaveEventFile(tsTypesMap map[reflect.Type]string) error {
67+
fileName := "frontend/types/waveevent.d.ts"
68+
fmt.Fprintf(os.Stderr, "generating waveevent file to %s\n", fileName)
69+
var buf bytes.Buffer
70+
fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n")
71+
fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n")
72+
fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n")
73+
fmt.Fprintf(&buf, "declare global {\n\n")
74+
fmt.Fprint(&buf, utilfn.IndentString(" ", tsgen.GenerateWaveEventTypes(tsTypesMap)))
75+
fmt.Fprintf(&buf, "}\n\n")
76+
fmt.Fprintf(&buf, "export {}\n")
77+
written, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes())
78+
if !written {
79+
fmt.Fprintf(os.Stderr, "no changes to %s\n", fileName)
80+
}
81+
return err
82+
}
83+
6584
func generateServicesFile(tsTypesMap map[reflect.Type]string) error {
6685
fileName := "frontend/app/store/services.ts"
6786
var buf bytes.Buffer
6887
fmt.Fprintf(os.Stderr, "generating services file to %s\n", fileName)
69-
fmt.Fprintf(&buf, "// Copyright 2025, Command Line Inc.\n")
88+
fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n")
7089
fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n")
7190
fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n")
7291
fmt.Fprintf(&buf, "import * as WOS from \"./wos\";\n\n")
@@ -89,7 +108,7 @@ func generateWshClientApiFile(tsTypeMap map[reflect.Type]string) error {
89108
var buf bytes.Buffer
90109
declMap := wshrpc.GenerateWshCommandDeclMap()
91110
fmt.Fprintf(os.Stderr, "generating wshclientapi file to %s\n", fileName)
92-
fmt.Fprintf(&buf, "// Copyright 2025, Command Line Inc.\n")
111+
fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n")
93112
fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n")
94113
fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n")
95114
fmt.Fprintf(&buf, "import { WshClient } from \"./wshclient\";\n\n")
@@ -128,6 +147,11 @@ func main() {
128147
fmt.Fprintf(os.Stderr, "Error generating services file: %v\n", err)
129148
os.Exit(1)
130149
}
150+
err = generateWaveEventFile(tsTypesMap)
151+
if err != nil {
152+
fmt.Fprintf(os.Stderr, "Error generating wave event file: %v\n", err)
153+
os.Exit(1)
154+
}
131155
err = generateWshClientApiFile(tsTypesMap)
132156
if err != nil {
133157
fmt.Fprintf(os.Stderr, "Error generating wshserver file: %v\n", err)

emain/emain-menu.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright 2025, Command Line Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import { waveEventSubscribe } from "@/app/store/wps";
4+
import { waveEventSubscribeSingle } from "@/app/store/wps";
55
import { RpcApi } from "@/app/store/wshclientapi";
66
import * as electron from "electron";
77
import { fireAndForget } from "../frontend/util/util";
@@ -385,7 +385,7 @@ export function makeAndSetAppMenu() {
385385
}
386386

387387
function initMenuEventSubscriptions() {
388-
waveEventSubscribe({
388+
waveEventSubscribeSingle({
389389
eventType: "workspace:update",
390390
handler: makeAndSetAppMenu,
391391
});

frontend/app/store/global.ts

Lines changed: 49 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { globalStore } from "./jotaiStore";
3131
import { modalsModel } from "./modalmodel";
3232
import { ClientService, ObjectService } from "./services";
3333
import * as WOS from "./wos";
34-
import { getFileSubject, waveEventSubscribe } from "./wps";
34+
import { getFileSubject, waveEventSubscribeSingle } from "./wps";
3535

3636
let atoms: GlobalAtomsType;
3737
let globalEnvironment: "electron" | "renderer";
@@ -198,65 +198,56 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
198198
}
199199

200200
function initGlobalWaveEventSubs(initOpts: WaveInitOpts) {
201-
waveEventSubscribe(
202-
{
203-
eventType: "waveobj:update",
204-
handler: (event) => {
205-
// console.log("waveobj:update wave event handler", event);
206-
const update: WaveObjUpdate = event.data;
207-
WOS.updateWaveObject(update);
208-
},
201+
waveEventSubscribeSingle({
202+
eventType: "waveobj:update",
203+
handler: (event) => {
204+
// console.log("waveobj:update wave event handler", event);
205+
WOS.updateWaveObject(event.data);
209206
},
210-
{
211-
eventType: "config",
212-
handler: (event) => {
213-
// console.log("config wave event handler", event);
214-
const fullConfig = (event.data as WatcherUpdate).fullconfig;
215-
globalStore.set(atoms.fullConfigAtom, fullConfig);
216-
},
207+
});
208+
waveEventSubscribeSingle({
209+
eventType: "config",
210+
handler: (event) => {
211+
// console.log("config wave event handler", event);
212+
globalStore.set(atoms.fullConfigAtom, event.data.fullconfig);
217213
},
218-
{
219-
eventType: "waveai:modeconfig",
220-
handler: (event) => {
221-
const modeConfigs = (event.data as AIModeConfigUpdate).configs;
222-
globalStore.set(atoms.waveaiModeConfigAtom, modeConfigs);
223-
},
214+
});
215+
waveEventSubscribeSingle({
216+
eventType: "waveai:modeconfig",
217+
handler: (event) => {
218+
globalStore.set(atoms.waveaiModeConfigAtom, event.data.configs);
224219
},
225-
{
226-
eventType: "userinput",
227-
handler: (event) => {
228-
// console.log("userinput event handler", event);
229-
const data: UserInputRequest = event.data;
230-
modalsModel.pushModal("UserInputModal", { ...data });
231-
},
232-
scope: initOpts.windowId,
220+
});
221+
waveEventSubscribeSingle({
222+
eventType: "userinput",
223+
handler: (event) => {
224+
// console.log("userinput event handler", event);
225+
modalsModel.pushModal("UserInputModal", { ...event.data });
233226
},
234-
{
235-
eventType: "blockfile",
236-
handler: (event) => {
237-
// console.log("blockfile event update", event);
238-
const fileData: WSFileEventData = event.data;
239-
const fileSubject = getFileSubject(fileData.zoneid, fileData.filename);
240-
if (fileSubject != null) {
241-
fileSubject.next(fileData);
242-
}
243-
},
227+
scope: initOpts.windowId,
228+
});
229+
waveEventSubscribeSingle({
230+
eventType: "blockfile",
231+
handler: (event) => {
232+
// console.log("blockfile event update", event);
233+
const fileSubject = getFileSubject(event.data.zoneid, event.data.filename);
234+
if (fileSubject != null) {
235+
fileSubject.next(event.data);
236+
}
244237
},
245-
{
246-
eventType: "waveai:ratelimit",
247-
handler: (event) => {
248-
const rateLimitInfo: RateLimitInfo = event.data;
249-
globalStore.set(atoms.waveAIRateLimitInfoAtom, rateLimitInfo);
250-
},
238+
});
239+
waveEventSubscribeSingle({
240+
eventType: "waveai:ratelimit",
241+
handler: (event) => {
242+
globalStore.set(atoms.waveAIRateLimitInfoAtom, event.data);
251243
},
252-
{
253-
eventType: "tab:indicator",
254-
handler: (event) => {
255-
const data: TabIndicatorEventData = event.data;
256-
setTabIndicatorInternal(data.tabid, data.indicator);
257-
},
258-
}
259-
);
244+
});
245+
waveEventSubscribeSingle({
246+
eventType: "tab:indicator",
247+
handler: (event) => {
248+
setTabIndicatorInternal(event.data.tabid, event.data.indicator);
249+
},
250+
});
260251
}
261252

262253
const blockCache = new Map<string, Map<string, any>>();
@@ -762,11 +753,11 @@ async function loadTabIndicators() {
762753
}
763754

764755
function subscribeToConnEvents() {
765-
waveEventSubscribe({
756+
waveEventSubscribeSingle({
766757
eventType: "connchange",
767-
handler: (event: WaveEvent) => {
758+
handler: (event) => {
768759
try {
769-
const connStatus = event.data as ConnStatus;
760+
const connStatus = event.data;
770761
if (connStatus == null || isBlank(connStatus.connection)) {
771762
return;
772763
}
@@ -852,7 +843,7 @@ function setTabIndicator(tabId: string, indicator: TabIndicator) {
852843
data: {
853844
tabid: tabId,
854845
indicator: indicator,
855-
} as TabIndicatorEventData,
846+
},
856847
};
857848
fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData));
858849
}

frontend/app/store/services.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2025, Command Line Inc.
1+
// Copyright 2026, Command Line Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

44
// generated by cmd/generate/main-generatets.go

frontend/app/store/wos.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
// WaveObjectStore
55

6-
import { waveEventSubscribe } from "@/app/store/wps";
6+
import { waveEventSubscribeSingle } from "@/app/store/wps";
77
import { getWebServerEndpoint } from "@/util/endpoints";
88
import { fetch } from "@/util/fetchutil";
99
import { fireAndForget } from "@/util/util";
@@ -79,7 +79,7 @@ function debugLogBackendCall(methodName: string, durationStr: string, args: any[
7979
}
8080

8181
function wpsSubscribeToObject(oref: string): () => void {
82-
return waveEventSubscribe({
82+
return waveEventSubscribeSingle({
8383
eventType: "waveobj:update",
8484
scope: oref,
8585
handler: (event) => {

0 commit comments

Comments
 (0)