launcher block (#1948)

This commit is contained in:
Mike Sawka 2025-02-11 21:58:03 -08:00 committed by GitHub
parent af65c2cc8d
commit 539559c603
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 692 additions and 68 deletions

View File

@ -4,6 +4,14 @@ id: "config"
title: "Configuration"
---
import { Kbd } from "@site/src/components/kbd.tsx";
import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext.tsx";
<PlatformProvider>
<PlatformSelectorButton />
<div style={{ marginBottom: 20 }}></div>
Wave's configuration files are located at `~/.config/waveterm/`.
The main configuration file is `settings.json` (`~/.config/waveterm/settings.json`).
@ -27,6 +35,7 @@ wsh editconfig
| ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| app:globalhotkey | string | A systemwide keybinding to open your most recent wave window. This is a set of key names separated by `:`. For more info, see [Customizable Systemwide Global Hotkey](#customizable-systemwide-global-hotkey) |
| app:dismissarchitecturewarning | bool | Disable warnings on app start when you are using a non-native architecture for Wave. For more info, see [Why does Wave warn me about ARM64 translation when it launches?](./faq#why-does-wave-warn-me-about-arm64-translation-when-it-launches). |
| app:defaultnewblock | string | Sets the default new block (Cmd:n, Cmd:d). "term" for terminal block, "launcher" for launcher block (default = "term") |
| ai:preset | string | the default AI preset to use |
| ai:baseurl | string | Set the AI Base Url (must be OpenAI compatible) |
| ai:apitoken | string | your AI api token |
@ -91,6 +100,7 @@ For reference, this is the current default configuration (v0.10.4):
"ai:model": "gpt-4o-mini",
"ai:maxtokens": 2048,
"ai:timeoutms": 60000,
"app:defaultnewblock": "term",
"autoupdate:enabled": true,
"autoupdate:installonquit": true,
"autoupdate:intervalms": 3600000,
@ -121,7 +131,76 @@ files as well: `termthemes.json`, `presets.json`, and `widgets.json`.
:::
### Terminal Theming
## WebBookmarks Configuration
WebBookmarks allows you to store and manage web links with customizable display preferences. The bookmarks are stored in a JSON file (`bookmarks.json`) as a key-value map where the key (`id`) is an arbitrary identifier for the bookmark. By convention, you should start your ids with "bookmark@". In the web widget, you can pull up your bookmarks using <Kbd k="Cmd:o"/>
### Bookmark Structure
Each bookmark follows this structure (only `url` is required):
```json
{
"url": "https://example.com",
"title": "Example Site",
"iconurl": "https://example.com/custom-icon.png",
"display:order": 1
}
```
### Fields
| Field | Type | Description |
| ------------- | ------- | ----------------------------------------------------------------------------------------------------------------- |
| url | string | **Required.** The URL of the bookmark. |
| title | string | **Optional.** A display title for the bookmark. |
| icon | string | **Optional, rarely used.** Overrides the default favicon with an icon name. |
| iconcolor | string | **Optional, rarely used.** Sets a custom color for the specified icon. |
| iconurl | string | **Optional.** Provides a custom icon URL, useful if the favicon is incorrect (e.g., for dark mode compatibility). |
| display:order | float64 | **Optional.** Defines the order in which bookmarks appear. |
### Example `bookmarks.json`
```json
{
"bookmark@google": {
"url": "https://www.google.com",
"title": "Google"
},
"bookmark@claude": {
"url": "https://claude.ai",
"title": "Claude AI"
},
"bookmark@wave": {
"url": "https://waveterm.dev",
"title": "Wave Terminal",
"display:order": -1
},
"bookmark@wave-github": {
"url": "https://github.com/wavetermdev/waveterm",
"title": "Wave Github",
"iconurl": "https://github.githubassets.com/favicons/favicon-dark.png"
},
"bookmark@chatgpt": {
"url": "https://chatgpt.com",
"iconurl": "https://cdn.oaistatic.com/assets/favicon-miwirzcw.ico"
},
"bookmark@wave-pulls": {
"url": "https://github.com/wavetermdev/waveterm/pulls",
"title": "Wave Pull Requests",
"iconurl": "https://github.githubassets.com/favicons/favicon-dark.png"
}
}
```
### Behavior
- If `iconurl` is set, it fetches the icon from the specified URL instead of the site's default favicon.
- Bookmarks are sorted based on `display:order` (if provided), otherwise by id.
- `icon` and `iconcolor` are rarely needed since the default behavior fetches the site's favicon.
- favicons are refreshed every 24-hours
## Terminal Theming
User-defined terminal themes are located in `~/.config/waveterm/termthemes.json`.
@ -203,17 +282,17 @@ wsh editconfig termthemes.json
| cursorAccent | CSS color | | | color for cursor |
| selectionBackground | CSS color | | | background color for selected text |
### Customizable Systemwide Global Hotkey
## Customizable Systemwide Global Hotkey
Wave allows settings a custom global hotkey to open your most recent window from anywhere in your computer. This has the name `"app:globalhotkey"` in the `settings.json` file and takes the form of a series of key names separated by the `:` character.
#### Examples
### Examples
As a practical example, suppose you want a value of `F5` as your global hotkey. Then you can simply set the value of `"app:globalhotkey"` to `"F5"` and reboot Wave to make that your global hotkey.
As a less practical example, suppose you use the combination of the keys `Ctrl`, `Option`, and `e`. Then the value for this keybinding would be `"Ctrl:Option:e"`.
#### Allowed Key Names
### Allowed Key Names
We support the following key names:
@ -251,3 +330,5 @@ We support the following key names:
- The numpad minus/subtract represented by `Subtract`
- The numpad star/multiply represented by `Multiply`
- The numpad slash/divide represented by `Divide`
</PlatformProvider>

View File

@ -20,25 +20,27 @@ replace "Cmd" with "Alt" (note that "Ctrl" is "Ctrl" on both Mac, Windows, and L
<PlatformSelectorButton />
<div style={{ marginBottom: 20 }}></div>
| Key | Function |
| ---------------------------- | --------------------------------------------------------------------------------- |
| <Kbd k="Cmd:t"/> | Open a new tab |
| <Kbd k="Cmd:n"/> | Open a new terminal block (defaults to the same connection and working directory) |
| <Kbd k="Cmd:Shift:n"/> | Open a new window |
| <Kbd k="Cmd:w"/> | Close the current block |
| <Kbd k="Cmd:Shift:w"/> | Close the current tab |
| <Kbd k="Cmd:m"/> | Magnify / Un-Magnify the current block |
| <Kbd k="Cmd:g"/> | Open the "connection" switcher |
| <Kbd k="Cmd:i"/> | Refocus the current block (useful if the block has lost input focus) |
| <Kbd k="Ctrl:Shift"/> | Show block numbers |
| <Kbd k="Ctrl:Shift:1-9"/> | Switch to block number |
| <Kbd k="Ctrl:Shift:Arrows"/> | Move left, right, up, down between blocks |
| <Kbd k="Cmd:1-9"/> | Switch to tab number |
| <Kbd k="Cmd:["/> | Switch tab left |
| <Kbd k="Cmd:]"/> | Switch tab right |
| <Kbd k="Cmd:Ctrl:1-9"/> | Switch to workspace number |
| <Kbd k="Cmd:Shift:r"/> | Refresh the UI |
| <Kbd k="Ctrl:Shift:i"/> | Toggle terminal multi-input mode |
| Key | Function |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| <Kbd k="Cmd:t"/> | Open a new tab |
| <Kbd k="Cmd:n"/> | Open a new block (defaults to a terminal block with the same connection and working directory). Switch to launcher using `app:defaultnewblock` setting |
| <Kbd k="Cmd:d"/> | Split horizontally, open a new block to the right |
| <Kbd k="Cmd:Shift:d"/> | Split vertically, open a new block below |
| <Kbd k="Cmd:Shift:n"/> | Open a new window |
| <Kbd k="Cmd:w"/> | Close the current block |
| <Kbd k="Cmd:Shift:w"/> | Close the current tab |
| <Kbd k="Cmd:m"/> | Magnify / Un-Magnify the current block |
| <Kbd k="Cmd:g"/> | Open the "connection" switcher |
| <Kbd k="Cmd:i"/> | Refocus the current block (useful if the block has lost input focus) |
| <Kbd k="Ctrl:Shift"/> | Show block numbers |
| <Kbd k="Ctrl:Shift:1-9"/> | Switch to block number |
| <Kbd k="Ctrl:Shift:Arrows"/> | Move left, right, up, down between blocks |
| <Kbd k="Cmd:1-9"/> | Switch to tab number |
| <Kbd k="Cmd:["/> | Switch tab left |
| <Kbd k="Cmd:]"/> | Switch tab right |
| <Kbd k="Cmd:Ctrl:1-9"/> | Switch to workspace number |
| <Kbd k="Cmd:Shift:r"/> | Refresh the UI |
| <Kbd k="Ctrl:Shift:i"/> | Toggle terminal multi-input mode |
## File Preview Keybindings
@ -66,6 +68,7 @@ replace "Cmd" with "Alt" (note that "Ctrl" is "Ctrl" on both Mac, Windows, and L
| <Kbd k="Cmd:ArrowLeft"/> | Back |
| <Kbd k="Cmd:ArrowRight"/> | Forward |
| <Kbd k="Cmd:f"/> | Find in webpage |
| <Kbd k="Cmd:o"/> | Open a bookmark |
## WaveAI Keybindings

View File

@ -9,6 +9,7 @@ import {
FullSubBlockProps,
SubBlockProps,
} from "@/app/block/blocktypes";
import { LauncherViewModel } from "@/app/view/launcher/launcher";
import { PreviewModel } from "@/app/view/preview/preview";
import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo";
import { VDomModel } from "@/app/view/vdom/vdom-model";
@ -44,6 +45,7 @@ BlockRegistry.set("cpuplot", SysinfoViewModel);
BlockRegistry.set("sysinfo", SysinfoViewModel);
BlockRegistry.set("vdom", VDomModel);
BlockRegistry.set("help", HelpViewModel);
BlockRegistry.set("launcher", LauncherViewModel);
function makeViewModel(blockId: string, blockView: string, nodeModel: BlockNodeModel): ViewModel {
const ctor = BlockRegistry.get(blockView);

View File

@ -537,6 +537,8 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => {
const [magnifiedBlockOpacityAtom] = React.useState(() => getSettingsKeyAtom("window:magnifiedblockopacity"));
const magnifiedBlockOpacity = jotai.useAtomValue(magnifiedBlockOpacityAtom);
const connBtnRef = React.useRef<HTMLDivElement>();
const noHeader = util.useAtomValueSafe(viewModel?.noHeader);
React.useEffect(() => {
if (!manageConnection) {
return;
@ -618,7 +620,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => {
/>
)}
<div className="block-frame-default-inner" style={innerStyle}>
<ErrorBoundary fallback={headerElemNoView}>{headerElem}</ErrorBoundary>
{noHeader || <ErrorBoundary fallback={headerElemNoView}>{headerElem}</ErrorBoundary>}
{preview ? previewElem : children}
</div>
{preview || viewModel == null || !connModalOpen ? null : (

View File

@ -10,7 +10,11 @@ import {
newLayoutNode,
} from "@/layout/index";
import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks";
import { LayoutTreeSplitHorizontalAction, LayoutTreeSplitVerticalAction } from "@/layout/lib/types";
import {
LayoutTreeReplaceNodeAction,
LayoutTreeSplitHorizontalAction,
LayoutTreeSplitVerticalAction,
} from "@/layout/lib/types";
import { getWebServerEndpoint } from "@/util/endpoints";
import { fetch } from "@/util/fetchutil";
import { deepCompareReturnPrev, getPrefixedSettings, isBlank } from "@/util/util";
@ -447,6 +451,25 @@ async function createBlock(blockDef: BlockDef, magnified = false, ephemeral = fa
return blockId;
}
async function replaceBlock(blockId: string, blockDef: BlockDef): Promise<string> {
const tabId = globalStore.get(atoms.staticTabId);
const layoutModel = getLayoutModelForTabById(tabId);
const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } };
const newBlockId = await ObjectService.CreateBlock(blockDef, rtOpts);
const targetNodeId = layoutModel.getNodeByBlockId(blockId)?.id;
if (targetNodeId == null) {
throw new Error(`targetNodeId not found for blockId: ${blockId}`);
}
const replaceNodeAction: LayoutTreeReplaceNodeAction = {
type: LayoutTreeActionType.ReplaceNode,
targetNodeId: targetNodeId,
newNode: newLayoutNode(undefined, undefined, undefined, { blockId: newBlockId }),
focused: true,
};
layoutModel.treeReducer(replaceNodeAction);
return newBlockId;
}
// when file is not found, returns {data: null, fileInfo: null}
async function fetchWaveFile(
zoneId: string,
@ -761,6 +784,7 @@ export {
removeFlashError,
removeNotification,
removeNotificationById,
replaceBlock,
setActiveTab,
setNodeFocus,
setPlatform,

View File

@ -10,6 +10,7 @@ import {
getAllBlockComponentModels,
getApi,
getBlockComponentModel,
getSettingsKeyAtom,
globalStore,
refocusNode,
WOS,
@ -176,7 +177,17 @@ function globalRefocus() {
refocusNode(blockId);
}
async function handleCmdN() {
function getDefaultNewBlockDef(): BlockDef {
const adnbAtom = getSettingsKeyAtom("app:defaultnewblock");
const adnb = globalStore.get(adnbAtom) ?? "term";
if (adnb == "launcher") {
return {
meta: {
view: "launcher",
},
};
}
// "term", blank, anything else, fall back to terminal
const termBlockDef: BlockDef = {
meta: {
view: "term",
@ -197,59 +208,32 @@ async function handleCmdN() {
termBlockDef.meta.connection = blockData.meta.connection;
}
}
await createBlock(termBlockDef);
return termBlockDef;
}
async function handleCmdN() {
const blockDef = getDefaultNewBlockDef();
await createBlock(blockDef);
}
async function handleSplitHorizontal() {
// split horizontally
const termBlockDef: BlockDef = {
meta: {
view: "term",
controller: "shell",
},
};
const layoutModel = getLayoutModelForStaticTab();
const focusedNode = globalStore.get(layoutModel.focusedNode);
if (focusedNode == null) {
return;
}
const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef("block", focusedNode.data?.blockId));
const blockData = globalStore.get(blockAtom);
if (blockData?.meta?.view == "term") {
if (blockData?.meta?.["cmd:cwd"] != null) {
termBlockDef.meta["cmd:cwd"] = blockData.meta["cmd:cwd"];
}
}
if (blockData?.meta?.connection != null) {
termBlockDef.meta.connection = blockData.meta.connection;
}
await createBlockSplitHorizontally(termBlockDef, focusedNode.data.blockId, "after");
const blockDef = getDefaultNewBlockDef();
await createBlockSplitHorizontally(blockDef, focusedNode.data.blockId, "after");
}
async function handleSplitVertical() {
// split horizontally
const termBlockDef: BlockDef = {
meta: {
view: "term",
controller: "shell",
},
};
const layoutModel = getLayoutModelForStaticTab();
const focusedNode = globalStore.get(layoutModel.focusedNode);
if (focusedNode == null) {
return;
}
const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef("block", focusedNode.data?.blockId));
const blockData = globalStore.get(blockAtom);
if (blockData?.meta?.view == "term") {
if (blockData?.meta?.["cmd:cwd"] != null) {
termBlockDef.meta["cmd:cwd"] = blockData.meta["cmd:cwd"];
}
}
if (blockData?.meta?.connection != null) {
termBlockDef.meta.connection = blockData.meta.connection;
}
await createBlockSplitVertically(termBlockDef, focusedNode.data.blockId, "after");
const blockDef = getDefaultNewBlockDef();
await createBlockSplitVertically(blockDef, focusedNode.data.blockId, "after");
}
let lastHandledEvent: KeyboardEvent | null = null;

View File

@ -0,0 +1,281 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import logoUrl from "@/app/asset/logo.svg?url";
import { atoms, globalStore, replaceBlock } from "@/app/store/global";
import { checkKeyPressed, keydownWrapper } from "@/util/keyutil";
import { isBlank, makeIconClass } from "@/util/util";
import clsx from "clsx";
import { atom, useAtom, useAtomValue } from "jotai";
import React, { useEffect, useLayoutEffect, useRef } from "react";
function sortByDisplayOrder(wmap: { [key: string]: WidgetConfigType } | null | undefined): WidgetConfigType[] {
if (!wmap) return [];
const wlist = Object.values(wmap);
wlist.sort((a, b) => (a["display:order"] ?? 0) - (b["display:order"] ?? 0));
return wlist;
}
type GridLayoutType = { columns: number; tileWidth: number; tileHeight: number; showLabel: boolean };
export class LauncherViewModel implements ViewModel {
blockId: string;
viewType = "launcher";
viewIcon = atom("shapes");
viewName = atom("Widget Launcher");
viewComponent = LauncherView;
noHeader = atom(true);
inputRef = { current: null } as React.RefObject<HTMLInputElement>;
searchTerm = atom("");
selectedIndex = atom(0);
containerSize = atom({ width: 0, height: 0 });
gridLayout: GridLayoutType = null;
constructor(blockId: string) {
this.blockId = blockId;
}
filteredWidgetsAtom = atom((get) => {
const searchTerm = get(this.searchTerm);
const widgets = sortByDisplayOrder(get(atoms.fullConfigAtom)?.widgets || {});
return widgets.filter(
(widget) =>
!widget["display:hidden"] &&
(!searchTerm || widget.label?.toLowerCase().includes(searchTerm.toLowerCase()))
);
});
giveFocus(): boolean {
if (this.inputRef.current) {
this.inputRef.current.focus();
return true;
}
return false;
}
keyDownHandler(e: WaveKeyboardEvent): boolean {
if (this.gridLayout == null) {
return;
}
const gridLayout = this.gridLayout;
const filteredWidgets = globalStore.get(this.filteredWidgetsAtom);
const selectedIndex = globalStore.get(this.selectedIndex);
const rows = Math.ceil(filteredWidgets.length / gridLayout.columns);
const currentRow = Math.floor(selectedIndex / gridLayout.columns);
const currentCol = selectedIndex % gridLayout.columns;
if (checkKeyPressed(e, "ArrowUp")) {
if (filteredWidgets.length == 0) {
return true;
}
if (currentRow > 0) {
const newIndex = selectedIndex - gridLayout.columns;
if (newIndex >= 0) {
globalStore.set(this.selectedIndex, newIndex);
}
}
return true;
}
if (checkKeyPressed(e, "ArrowDown")) {
if (filteredWidgets.length == 0) {
return true;
}
if (currentRow < rows - 1) {
const newIndex = selectedIndex + gridLayout.columns;
if (newIndex < filteredWidgets.length) {
globalStore.set(this.selectedIndex, newIndex);
}
}
return true;
}
if (checkKeyPressed(e, "ArrowLeft")) {
if (filteredWidgets.length == 0) {
return true;
}
if (currentCol > 0) {
globalStore.set(this.selectedIndex, selectedIndex - 1);
}
return true;
}
if (checkKeyPressed(e, "ArrowRight")) {
if (filteredWidgets.length == 0) {
return true;
}
if (currentCol < gridLayout.columns - 1 && selectedIndex + 1 < filteredWidgets.length) {
globalStore.set(this.selectedIndex, selectedIndex + 1);
}
return true;
}
if (checkKeyPressed(e, "Enter")) {
if (filteredWidgets.length == 0) {
return true;
}
if (filteredWidgets[selectedIndex]) {
this.handleWidgetSelect(filteredWidgets[selectedIndex]);
}
return true;
}
if (checkKeyPressed(e, "Escape")) {
globalStore.set(this.searchTerm, "");
globalStore.set(this.selectedIndex, 0);
return true;
}
return false;
}
async handleWidgetSelect(widget: WidgetConfigType) {
try {
await replaceBlock(this.blockId, widget.blockdef);
} catch (error) {
console.error("Error replacing block:", error);
}
}
}
const LauncherView: React.FC<ViewComponentProps<LauncherViewModel>> = ({ blockId, model }) => {
// Search and selection state
const [searchTerm, setSearchTerm] = useAtom(model.searchTerm);
const [selectedIndex, setSelectedIndex] = useAtom(model.selectedIndex);
const filteredWidgets = useAtomValue(model.filteredWidgetsAtom);
// Container measurement
const containerRef = useRef<HTMLDivElement>(null);
const [containerSize, setContainerSize] = useAtom(model.containerSize);
useLayoutEffect(() => {
if (!containerRef.current) return;
const resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
setContainerSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
}
});
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
};
}, []);
// Layout constants
const GAP = 16;
const LABEL_THRESHOLD = 60;
const MARGIN_BOTTOM = 24;
const MAX_TILE_SIZE = 120;
const calculatedLogoWidth = containerSize.width * 0.3;
const logoWidth = containerSize.width >= 100 ? Math.min(Math.max(calculatedLogoWidth, 100), 300) : 0;
const showLogo = logoWidth >= 100;
const availableHeight = containerSize.height - (showLogo ? logoWidth + MARGIN_BOTTOM : 0);
// Determine optimal grid layout
const gridLayout: GridLayoutType = React.useMemo(() => {
if (containerSize.width === 0 || availableHeight <= 0 || filteredWidgets.length === 0) {
return { columns: 1, tileWidth: 90, tileHeight: 90, showLabel: true };
}
let bestColumns = 1;
let bestTileSize = 0;
let bestTileWidth = 90;
let bestTileHeight = 90;
let showLabel = true;
for (let cols = 1; cols <= filteredWidgets.length; cols++) {
const rows = Math.ceil(filteredWidgets.length / cols);
const tileWidth = (containerSize.width - (cols - 1) * GAP) / cols;
const tileHeight = (availableHeight - (rows - 1) * GAP) / rows;
const currentTileSize = Math.min(tileWidth, tileHeight);
if (currentTileSize > bestTileSize) {
bestTileSize = currentTileSize;
bestColumns = cols;
bestTileWidth = tileWidth;
bestTileHeight = tileHeight;
showLabel = tileHeight >= LABEL_THRESHOLD;
}
}
return { columns: bestColumns, tileWidth: bestTileWidth, tileHeight: bestTileHeight, showLabel };
}, [containerSize, availableHeight, filteredWidgets.length]);
model.gridLayout = gridLayout;
const finalTileWidth = Math.min(gridLayout.tileWidth, MAX_TILE_SIZE);
const finalTileHeight = gridLayout.showLabel ? Math.min(gridLayout.tileHeight, MAX_TILE_SIZE) : finalTileWidth;
// Reset selection when search term changes
useEffect(() => {
setSelectedIndex(0);
}, [searchTerm]);
return (
<div ref={containerRef} className="w-full h-full p-4 box-border flex flex-col items-center justify-center">
{/* Hidden input for search */}
<input
ref={model.inputRef}
type="text"
value={searchTerm}
onKeyDown={keydownWrapper(model.keyDownHandler.bind(model))}
onChange={(e) => setSearchTerm(e.target.value)}
className="sr-only"
aria-label="Search widgets"
/>
{/* Logo */}
{showLogo && (
<div className="mb-6" style={{ width: logoWidth, maxWidth: 300 }}>
<img src={logoUrl} className="w-full h-auto filter grayscale brightness-70 opacity-70" alt="Logo" />
</div>
)}
{/* Grid of widgets */}
<div
className="grid gap-4 justify-center"
style={{
gridTemplateColumns: `repeat(${gridLayout.columns}, ${finalTileWidth}px)`,
}}
>
{filteredWidgets.map((widget, index) => (
<div
key={index}
onClick={() => model.handleWidgetSelect(widget)}
title={widget.description || widget.label}
className={clsx(
"flex flex-col items-center justify-center cursor-pointer rounded-md p-2 text-center",
"transition-colors duration-150",
index === selectedIndex
? "bg-white/20 text-white"
: "bg-white/5 hover:bg-white/10 text-secondary hover:text-white"
)}
style={{
width: finalTileWidth,
height: finalTileHeight,
}}
>
<div style={{ color: widget.color }}>
<i
className={makeIconClass(widget.icon, true, {
defaultIcon: "browser",
})}
/>
</div>
{gridLayout.showLabel && !isBlank(widget.label) && (
<div className="mt-1 w-full text-[11px] leading-4 overflow-hidden text-ellipsis whitespace-nowrap">
{widget.label}
</div>
)}
</div>
))}
</div>
{/* Search instructions */}
<div className="mt-4 text-secondary text-xs">
{filteredWidgets.length === 0 ? (
<span>No widgets found. Press Escape to clear search.</span>
) : (
<span>
{searchTerm == "" ? "Type to Filter" : "Searching " + '"' + searchTerm + '"'}, Enter to Launch,
{searchTerm == "" ? "Arrow Keys to Navigate" : null}
</span>
)}
</div>
</div>
);
};
export default LauncherView;

View File

@ -0,0 +1,239 @@
# Wave Terminal ViewModel Guide
## Overview
Wave Terminal uses a modular ViewModel system to define interactive blocks. Each block has a **ViewModel**, which manages its metadata, configuration, and state using **Jotai atoms**. The ViewModel also specifies a **React component (ViewComponent)** that renders the block.
### Key Concepts
1. **ViewModel Structure**
- Implements the `ViewModel` interface.
- Defines:
- `viewType`: Unique block type identifier.
- `viewIcon`, `viewName`, `viewText`: Atoms for UI metadata.
- `preIconButton`, `endIconButtons`: Atoms for action buttons.
- `blockBg`: Atom for background styling.
- `manageConnection`, `noPadding`, `searchAtoms`.
- `viewComponent`: React component rendering the block.
- Lifecycle methods like `dispose()`, `giveFocus()`, `keyDownHandler()`.
2. **ViewComponent Structure**
- A **React function component** implementing `ViewComponentProps<T extends ViewModel>`.
- Uses `blockId`, `blockRef`, `contentRef`, and `model` as props.
- Retrieves ViewModel state using Jotai atoms.
- Returns JSX for rendering.
3. **Header Elements (`HeaderElem[]`)**
- Can include:
- **Icons (`IconButtonDecl`)**: Clickable buttons.
- **Text (`HeaderText`)**: Metadata or status.
- **Inputs (`HeaderInput`)**: Editable fields.
- **Menu Buttons (`MenuButton`)**: Dropdowns.
4. **Jotai Atoms for State Management**
- Use `atom<T>`, `PrimitiveAtom<T>`, `WritableAtom<T>` for dynamic properties.
- `splitAtom` for managing lists of atoms.
- Read settings from `globalStore` and override with block metadata.
5. **Metadata vs. Global Config**
- **Block Metadata (`SetMetaCommand`)**: Each block persists its **own configuration** in its metadata (`blockAtom.meta`).
- **Global Config (`SetConfigCommand`)**: Provides **default settings** for all blocks, stored in config files.
- **Cascading Behavior**:
- Blocks first check their **own metadata** for settings.
- If no override exists, they **fall back** to global config.
- Updating a block's setting is done via `SetMetaCommand` (persisted per block).
- Updating a global setting is done via `SetConfigCommand` (applies globally unless overridden).
6. **Useful Helper Functions**
- To avoid repetitive boilerplate, use these global utilities from `global.ts`:
- `useBlockMetaKeyAtom(blockId, key)`: Retrieves and updates block-specific metadata.
- `useOverrideConfigAtom(blockId, key)`: Reads from global config but allows per-block overrides.
- `useSettingsKeyAtom(key)`: Accesses global settings efficiently.
7. **Styling**
- Use TailWind CSS to style components
- Accent color is: text-accent, for a 50% transparent accent background use bg-accentbg
- Hover background is: bg-hoverbg
- Border color is "border", so use border-border
- Colors are also defined for error, warning, and success (text-error, text-warning, text-sucess)
## Relevant TypeScript Types
```typescript
type ViewComponentProps<T extends ViewModel> = {
blockId: string;
blockRef: React.RefObject<HTMLDivElement>;
contentRef: React.RefObject<HTMLDivElement>;
model: T;
};
type ViewComponent = React.FC<ViewComponentProps<any>>;
interface ViewModel {
viewType: string;
viewIcon?: jotai.Atom<string | IconButtonDecl>;
viewName?: jotai.Atom<string>;
viewText?: jotai.Atom<string | HeaderElem[]>;
preIconButton?: jotai.Atom<IconButtonDecl>;
endIconButtons?: jotai.Atom<IconButtonDecl[]>;
blockBg?: jotai.Atom<MetaType>;
manageConnection?: jotai.Atom<boolean>;
noPadding?: jotai.Atom<boolean>;
searchAtoms?: SearchAtoms;
viewComponent: ViewComponent;
dispose?: () => void;
giveFocus?: () => boolean;
keyDownHandler?: (e: WaveKeyboardEvent) => boolean;
}
interface IconButtonDecl {
elemtype: "iconbutton";
icon: string | React.ReactNode;
click?: (e: React.MouseEvent<any>) => void;
}
type HeaderElem =
| IconButtonDecl
| ToggleIconButtonDecl
| HeaderText
| HeaderInput
| HeaderDiv
| HeaderTextButton
| ConnectionButton
| MenuButton;
type IconButtonCommon = {
icon: string | React.ReactNode;
iconColor?: string;
iconSpin?: boolean;
className?: string;
title?: string;
disabled?: boolean;
noAction?: boolean;
};
type IconButtonDecl = IconButtonCommon & {
elemtype: "iconbutton";
click?: (e: React.MouseEvent<any>) => void;
longClick?: (e: React.MouseEvent<any>) => void;
};
type ToggleIconButtonDecl = IconButtonCommon & {
elemtype: "toggleiconbutton";
active: jotai.WritableAtom<boolean, [boolean], void>;
};
type HeaderTextButton = {
elemtype: "textbutton";
text: string;
className?: string;
title?: string;
onClick?: (e: React.MouseEvent<any>) => void;
};
type HeaderText = {
elemtype: "text";
text: string;
ref?: React.MutableRefObject<HTMLDivElement>;
className?: string;
noGrow?: boolean;
onClick?: (e: React.MouseEvent<any>) => void;
};
type HeaderInput = {
elemtype: "input";
value: string;
className?: string;
isDisabled?: boolean;
ref?: React.MutableRefObject<HTMLInputElement>;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
};
type HeaderDiv = {
elemtype: "div";
className?: string;
children: HeaderElem[];
onMouseOver?: (e: React.MouseEvent<any>) => void;
onMouseOut?: (e: React.MouseEvent<any>) => void;
onClick?: (e: React.MouseEvent<any>) => void;
};
type ConnectionButton = {
elemtype: "connectionbutton";
icon: string;
text: string;
iconColor: string;
onClick?: (e: React.MouseEvent<any>) => void;
connected: boolean;
};
type MenuItem = {
label: string;
icon?: string | React.ReactNode;
subItems?: MenuItem[];
onClick?: (e: React.MouseEvent<any>) => void;
};
type MenuButtonProps = {
items: MenuItem[];
className?: string;
text: string;
title?: string;
menuPlacement?: Placement;
};
type MenuButton = {
elemtype: "menubutton";
} & MenuButtonProps;
```
## Minimal "Hello World" Example
This example defines a simple ViewModel and ViewComponent for a block that displays "Hello, World!".
```typescript
import * as jotai from "jotai";
import React from "react";
class HelloWorldModel implements ViewModel {
viewType = "helloworld";
viewIcon = jotai.atom("smile");
viewName = jotai.atom("Hello World");
viewText = jotai.atom("A simple greeting block");
viewComponent = HelloWorldView;
}
const HelloWorldView: ViewComponent<HelloWorldModel> = ({ model }) => {
return <div style={{ padding: "10px" }}>Hello, World!</div>;
};
export { HelloWorldModel };
```
## Instructions to AI
1. Generate a new **ViewModel** class for a block, following the structure above.
2. Generate a corresponding **ViewComponent**.
3. Use **Jotai atoms** to store all dynamic state.
4. Ensure the ViewModel defines **header elements** (`viewText`, `viewIcon`, `endIconButtons`).
5. Export the view model (to be registered in the BlockRegistry)
6. Use existing metadata patterns for config and settings.
## Other Notes
- The types you see above don't need to be imported, they are global types (custom.d.ts)
**Output Format:**
- TypeScript code defining the **ViewModel**.
- TypeScript code defining the **ViewComponent**.
- Ensure alignment with the patterns in `waveai.tsx`, `preview.tsx`, `sysinfo.tsx`, and `term.tsx`.

View File

@ -450,6 +450,9 @@ export function replaceNode(layoutState: LayoutTreeState, action: LayoutTreeRepl
newNode.size = targetNode.size;
parent.children[index] = newNode;
}
if (action.focused) {
layoutState.focusedNodeId = newNode.id;
}
layoutState.generation++;
}
@ -473,8 +476,6 @@ export function splitHorizontal(layoutState: LayoutTreeState, action: LayoutTree
const insertIndex = position === "before" ? index : index + 1;
// Directly splice in the new node instead of calling addChildAt (which may flatten nodes)
parent.children.splice(insertIndex, 0, newNode);
// Rebalance sizes equally (or use your own logic)
parent.children.forEach((child) => (child.size = 1));
} else {
// Otherwise, if no parent or parent's flexDirection is not Row, we need to wrap
// Create a new group node with horizontal layout.
@ -482,7 +483,6 @@ export function splitHorizontal(layoutState: LayoutTreeState, action: LayoutTree
const groupNode = newLayoutNode(FlexDirection.Row, targetNode.size, [targetNode], undefined);
// Now decide the ordering based on the "position"
groupNode.children = position === "before" ? [newNode, targetNode] : [targetNode, newNode];
groupNode.children.forEach((child) => (child.size = 1));
if (parent) {
const index = parent.children.findIndex((child) => child.id === targetNodeId);
if (index === -1) {
@ -520,13 +520,11 @@ export function splitVertical(layoutState: LayoutTreeState, action: LayoutTreeSp
const insertIndex = position === "before" ? index : index + 1;
// For vertical splits in an already vertical parent, splice directly.
parent.children.splice(insertIndex, 0, newNode);
parent.children.forEach((child) => (child.size = 1));
} else {
// Wrap target node in a new vertical group.
// Create group node with an initial children array so that validation passes.
const groupNode = newLayoutNode(FlexDirection.Column, targetNode.size, [targetNode], undefined);
groupNode.children = position === "before" ? [newNode, targetNode] : [targetNode, newNode];
groupNode.children.forEach((child) => (child.size = 1));
if (parent) {
const index = parent.children.findIndex((child) => child.id === targetNodeId);
if (index === -1) {

View File

@ -195,6 +195,7 @@ export interface LayoutTreeReplaceNodeAction extends LayoutTreeAction {
type: LayoutTreeActionType.ReplaceNode;
targetNodeId: string;
newNode: LayoutNode;
focused?: boolean;
}
// SplitHorizontal: split the current block horizontally.

View File

@ -282,6 +282,8 @@ declare global {
// Background styling metadata for the block.
blockBg?: jotai.Atom<MetaType>;
noHeader?: jotai.Atom<boolean>;
// Whether the block manages its own connection (e.g., for remote access).
manageConnection?: jotai.Atom<boolean>;

View File

@ -681,6 +681,7 @@ declare global {
"app:*"?: boolean;
"app:globalhotkey"?: string;
"app:dismissarchitecturewarning"?: boolean;
"app:defaultnewblock"?: string;
"ai:*"?: boolean;
"ai:preset"?: string;
"ai:apitype"?: string;

View File

@ -3,6 +3,7 @@
"ai:model": "gpt-4o-mini",
"ai:maxtokens": 2048,
"ai:timeoutms": 60000,
"app:defaultnewblock": "term",
"autoupdate:enabled": true,
"autoupdate:installonquit": true,
"autoupdate:intervalms": 3600000,

View File

@ -9,6 +9,7 @@ const (
ConfigKey_AppClear = "app:*"
ConfigKey_AppGlobalHotkey = "app:globalhotkey"
ConfigKey_AppDismissArchitectureWarning = "app:dismissarchitecturewarning"
ConfigKey_AppDefaultNewBlock = "app:defaultnewblock"
ConfigKey_AiClear = "ai:*"
ConfigKey_AiPreset = "ai:preset"

View File

@ -52,6 +52,7 @@ type SettingsType struct {
AppClear bool `json:"app:*,omitempty"`
AppGlobalHotkey string `json:"app:globalhotkey,omitempty"`
AppDismissArchitectureWarning bool `json:"app:dismissarchitecturewarning,omitempty"`
AppDefaultNewBlock string `json:"app:defaultnewblock,omitempty"`
AiSettingsType

View File

@ -14,6 +14,9 @@
"app:dismissarchitecturewarning": {
"type": "boolean"
},
"app:defaultnewblock": {
"type": "string"
},
"ai:*": {
"type": "boolean"
},