Waveterm create-view
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.
git clone https://github.com/wavetermdev/waveterm
T=$(mktemp -d) && git clone --depth=1 https://github.com/wavetermdev/waveterm "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.kilocode/skills/create-view" ~/.claude/skills/wavetermdev-waveterm-create-view && rm -rf "$T"
.kilocode/skills/create-view/SKILL.mdCreating a New View in Wave Terminal
This guide explains how to implement a new view type in Wave Terminal. Views are the core content components displayed within blocks in the terminal interface.
Architecture Overview
Wave Terminal uses a Model-View architecture where:
- ViewModel - Contains all state, logic, and UI configuration as Jotai atoms
- ViewComponent - Pure React component that renders the UI using the model
- BlockFrame - Wraps views with a header, connection management, and standard controls
The separation between model and component ensures:
- Models can update state without React hooks
- Components remain pure and testable
- State is centralized in Jotai atoms for easy access
ViewModel Interface
Every view must implement the
ViewModel interface defined in frontend/types/custom.d.ts:
interface ViewModel { // Required: The type identifier for this view (e.g., "term", "web", "preview") viewType: string; // Required: The React component that renders this view viewComponent: ViewComponent<ViewModel>; // Optional: Icon shown in block header (FontAwesome icon name or IconButtonDecl) viewIcon?: jotai.Atom<string | IconButtonDecl>; // Optional: Display name shown in block header (e.g., "Terminal", "Web", "Preview") viewName?: jotai.Atom<string>; // Optional: Additional header elements (text, buttons, inputs) shown after the name viewText?: jotai.Atom<string | HeaderElem[]>; // Optional: Icon button shown before the view name in header preIconButton?: jotai.Atom<IconButtonDecl>; // Optional: Icon buttons shown at the end of the header (before settings/close) endIconButtons?: jotai.Atom<IconButtonDecl[]>; // Optional: Custom background styling for the block blockBg?: jotai.Atom<MetaType>; // Optional: If true, completely hides the block header noHeader?: jotai.Atom<boolean>; // Optional: If true, shows connection picker in header for remote connections manageConnection?: jotai.Atom<boolean>; // Optional: If true, filters out 'nowsh' connections from connection picker filterOutNowsh?: jotai.Atom<boolean>; // Optional: If true, removes default padding from content area noPadding?: jotai.Atom<boolean>; // Optional: Atoms for managing in-block search functionality searchAtoms?: SearchAtoms; // Optional: Returns whether this is a basic terminal (for multi-input feature) isBasicTerm?: (getFn: jotai.Getter) => boolean; // Optional: Returns context menu items for the settings dropdown getSettingsMenuItems?: () => ContextMenuItem[]; // Optional: Focuses the view when called, returns true if successful giveFocus?: () => boolean; // Optional: Handles keyboard events, returns true if handled keyDownHandler?: (e: WaveKeyboardEvent) => boolean; // Optional: Cleanup when block is closed dispose?: () => void; }
Key Concepts
Atoms: All UI-related properties must be Jotai atoms. This enables:
- Reactive updates when state changes
- Access from anywhere via
/globalStore.get()globalStore.set() - Derived atoms that compute values from other atoms
ViewComponent: The React component receives these props:
type ViewComponentProps<T extends ViewModel> = { blockId: string; // Unique ID for this block blockRef: React.RefObject<HTMLDivElement>; // Ref to block container contentRef: React.RefObject<HTMLDivElement>; // Ref to content area model: T; // Your ViewModel instance };
Step-by-Step Guide
1. Create the View Model Class
Create a new file for your view model (e.g.,
frontend/app/view/myview/myview-model.ts):
import { BlockNodeModel } from "@/app/block/blocktypes"; import { globalStore } from "@/app/store/jotaiStore"; import { WOS, useBlockAtom } from "@/store/global"; import * as jotai from "jotai"; import { MyView } from "./myview"; export class MyViewModel implements ViewModel { viewType: string; blockId: string; nodeModel: BlockNodeModel; blockAtom: jotai.Atom<Block>; // Define your atoms (simple field initializers) viewIcon = jotai.atom<string>("circle"); viewName = jotai.atom<string>("My View"); noPadding = jotai.atom<boolean>(true); // Derived atom (created in constructor) viewText!: jotai.Atom<HeaderElem[]>; constructor(blockId: string, nodeModel: BlockNodeModel) { this.viewType = "myview"; this.blockId = blockId; this.nodeModel = nodeModel; this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`); // Create derived atoms that depend on block data or other atoms this.viewText = jotai.atom((get) => { const blockData = get(this.blockAtom); const rtn: HeaderElem[] = []; // Add header buttons/text based on state rtn.push({ elemtype: "iconbutton", icon: "refresh", title: "Refresh", click: () => this.refresh(), }); return rtn; }); } get viewComponent(): ViewComponent { return MyView; } refresh() { // Update state using globalStore // Never use React hooks in model methods console.log("refreshing..."); } giveFocus(): boolean { // Focus your view component return true; } dispose() { // Cleanup resources (unsubscribe from events, etc.) } }
2. Create the View Component
Create your React component (e.g.,
frontend/app/view/myview/myview.tsx):
import { ViewComponentProps } from "@/app/block/blocktypes"; import { MyViewModel } from "./myview-model"; import { useAtomValue } from "jotai"; import "./myview.scss"; export const MyView: React.FC<ViewComponentProps<MyViewModel>> = ({ blockId, model, contentRef }) => { // Use atoms from the model (these are React hooks - call at top level!) const blockData = useAtomValue(model.blockAtom); return ( <div className="myview-container" ref={contentRef}> <div>Block ID: {blockId}</div> <div>View: {model.viewType}</div> {/* Your view content here */} </div> ); };
3. Register the View
Add your view to the
BlockRegistry in frontend/app/block/blockregistry.ts:
import { MyViewModel } from "@/app/view/myview/myview-model"; const BlockRegistry: Map<string, ViewModelClass> = new Map(); BlockRegistry.set("term", TermViewModel); BlockRegistry.set("preview", PreviewModel); BlockRegistry.set("web", WebViewModel); // ... existing registrations ... BlockRegistry.set("myview", MyViewModel); // Add your view here
The registry key (e.g.,
"myview") becomes the view type used in block metadata.
4. Create Blocks with Your View
Users can create blocks with your view type:
- Via CLI:
wsh view myview - Via RPC: Use the block's
field set tometa.view"myview"
Real-World Examples
Example 1: Terminal View (term-model.ts
)
term-model.tsThe terminal view demonstrates:
- Connection management via
atommanageConnection - Dynamic header buttons showing shell status (play/restart)
- Mode switching between terminal and vdom views
- Custom keyboard handling for terminal-specific shortcuts
- Focus management to focus the xterm.js instance
- Shell integration status showing AI capability indicators
Key features:
this.manageConnection = jotai.atom((get) => { const termMode = get(this.termMode); if (termMode == "vdom") return false; return true; // Show connection picker for regular terminal mode }); this.endIconButtons = jotai.atom((get) => { const shellProcStatus = get(this.shellProcStatus); const buttons: IconButtonDecl[] = []; if (shellProcStatus == "running") { buttons.push({ elemtype: "iconbutton", icon: "refresh", title: "Restart Shell", click: this.forceRestartController.bind(this), }); } return buttons; });
Example 2: Web View (webview.tsx
)
webview.tsxThe web view shows:
- Complex header controls (back/forward/home/URL input)
- State management for loading, URL, and navigation
- Event handling for webview navigation events
- Custom styling with
for full-bleed contentnoPadding - Media controls showing play/pause/mute when media is active
Key features:
this.viewText = jotai.atom((get) => { const url = get(this.url); const rtn: HeaderElem[] = []; // Navigation buttons rtn.push({ elemtype: "iconbutton", icon: "chevron-left", click: this.handleBack.bind(this), disabled: this.shouldDisableBackButton(), }); // URL input with nested controls rtn.push({ elemtype: "div", className: "block-frame-div-url", children: [ { elemtype: "input", value: url, onChange: this.handleUrlChange.bind(this), onKeyDown: this.handleKeyDown.bind(this), }, { elemtype: "iconbutton", icon: "rotate-right", click: this.handleRefresh.bind(this), }, ], }); return rtn; });
Header Elements (HeaderElem
)
HeaderElemThe
viewText atom can return an array of these element types:
// Icon button { elemtype: "iconbutton", icon: "refresh", title: "Tooltip text", click: () => { /* handler */ }, disabled?: boolean, iconColor?: string, iconSpin?: boolean, noAction?: boolean, // Shows icon but no click action } // Text element { elemtype: "text", text: "Display text", className?: string, noGrow?: boolean, ref?: React.RefObject<HTMLElement>, onClick?: (e: React.MouseEvent) => void, } // Text button { elemtype: "textbutton", text: "Button text", className?: string, title: "Tooltip", onClick: (e: React.MouseEvent) => void, } // Input field { elemtype: "input", value: string, className?: string, onChange: (e: React.ChangeEvent<HTMLInputElement>) => void, onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void, onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void, onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void, ref?: React.RefObject<HTMLInputElement>, } // Container with children { elemtype: "div", className?: string, children: HeaderElem[], onMouseOver?: (e: React.MouseEvent) => void, onMouseOut?: (e: React.MouseEvent) => void, } // Menu button (dropdown) { elemtype: "menubutton", // ... MenuButtonProps ... }
Best Practices
Jotai Model Pattern
Follow these rules for Jotai atoms in models:
-
Simple atoms as field initializers:
viewIcon = jotai.atom<string>("circle"); noPadding = jotai.atom<boolean>(true); -
Derived atoms in constructor (need dependency on other atoms):
constructor(blockId: string, nodeModel: BlockNodeModel) { this.viewText = jotai.atom((get) => { const blockData = get(this.blockAtom); return [/* computed based on blockData */]; }); } -
Models never use React hooks - Use
/globalStore.get()
:set()refresh() { const currentData = globalStore.get(this.blockAtom); globalStore.set(this.dataAtom, newData); } -
Components use hooks for atoms:
const data = useAtomValue(model.dataAtom); const [value, setValue] = useAtom(model.valueAtom);
State Management
- All view state should live in atoms on the model
- Use
helper for block-scoped atoms that persistuseBlockAtom() - Use
for imperative access outside React componentsglobalStore - Subscribe to Wave events using
waveEventSubscribe()
Styling
- Create a
file for your view styles.scss - Use Tailwind utilities where possible (v4)
- Add
for full-bleed contentnoPadding: atom(true) - Use
atom to customize block backgroundblockBg
Focus Management
Implement
giveFocus() to focus your view when:
- Block gains focus via keyboard navigation
- User clicks the block
- Return
if successfully focused,true
otherwisefalse
Keyboard Handling
Implement
keyDownHandler(e: WaveKeyboardEvent) for:
- View-specific keyboard shortcuts
- Return
if event was handled (prevents propagation)true - Use
for shortcut checkskeyutil.checkKeyPressed(waveEvent, "Cmd:K")
Cleanup
Implement
dispose() to:
- Unsubscribe from Wave events
- Unregister routes/handlers
- Clear timers/intervals
- Release resources
Connection Management
For views that need remote connections:
this.manageConnection = jotai.atom(true); // Show connection picker this.filterOutNowsh = jotai.atom(true); // Hide nowsh connections
Access connection status:
const connStatus = jotai.atom((get) => { const blockData = get(this.blockAtom); const connName = blockData?.meta?.connection; return get(getConnStatusAtom(connName)); });
Common Patterns
Reading Block Metadata
import { getBlockMetaKeyAtom } from "@/store/global"; // In constructor: this.someFlag = getBlockMetaKeyAtom(blockId, "myview:flag"); // In component: const flag = useAtomValue(model.someFlag);
Configuration Overrides
Wave has a hierarchical config system (global → connection → block):
import { getOverrideConfigAtom } from "@/store/global"; this.settingAtom = jotai.atom((get) => { // Checks block meta, then connection config, then global settings return get(getOverrideConfigAtom(this.blockId, "myview:setting")) ?? defaultValue; });
Updating Block Metadata
import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WOS } from "@/store/global"; await RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), meta: { "myview:key": value }, });
Additional Resources
- Block header renderingfrontend/app/block/blockframe-header.tsx
- Complex view examplefrontend/app/view/term/term-model.ts
- Navigation UI examplefrontend/app/view/webview/webview.tsx
- Type definitionsfrontend/types/custom.d.ts