From 539559c6037a08c24d56987400f8d48e1d6e6821 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Tue, 11 Feb 2025 21:58:03 -0800 Subject: [PATCH] launcher block (#1948) --- docs/docs/config.mdx | 89 +++++++- docs/docs/keybindings.mdx | 41 ++-- frontend/app/block/block.tsx | 2 + frontend/app/block/blockframe.tsx | 4 +- frontend/app/store/global.ts | 26 ++- frontend/app/store/keymodel.ts | 60 ++--- frontend/app/view/launcher/launcher.tsx | 281 ++++++++++++++++++++++++ frontend/app/view/view-prompt.md | 239 ++++++++++++++++++++ frontend/layout/lib/layoutTree.ts | 8 +- frontend/layout/lib/types.ts | 1 + frontend/types/custom.d.ts | 2 + frontend/types/gotypes.d.ts | 1 + pkg/wconfig/defaultconfig/settings.json | 1 + pkg/wconfig/metaconsts.go | 1 + pkg/wconfig/settingsconfig.go | 1 + schema/settings.json | 3 + 16 files changed, 692 insertions(+), 68 deletions(-) create mode 100644 frontend/app/view/launcher/launcher.tsx create mode 100644 frontend/app/view/view-prompt.md diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 6648d1d92..9a211c3cc 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -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"; + + + + +
+ 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 + +### 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` + +
diff --git a/docs/docs/keybindings.mdx b/docs/docs/keybindings.mdx index b73470d26..ee3fac107 100644 --- a/docs/docs/keybindings.mdx +++ b/docs/docs/keybindings.mdx @@ -20,25 +20,27 @@ replace "Cmd" with "Alt" (note that "Ctrl" is "Ctrl" on both Mac, Windows, and L
-| Key | Function | -| ---------------------------- | --------------------------------------------------------------------------------- | -| | Open a new tab | -| | Open a new terminal block (defaults to the same connection and working directory) | -| | Open a new window | -| | Close the current block | -| | Close the current tab | -| | Magnify / Un-Magnify the current block | -| | Open the "connection" switcher | -| | Refocus the current block (useful if the block has lost input focus) | -| | Show block numbers | -| | Switch to block number | -| | Move left, right, up, down between blocks | -| | Switch to tab number | -| | Switch tab left | -| | Switch tab right | -| | Switch to workspace number | -| | Refresh the UI | -| | Toggle terminal multi-input mode | +| Key | Function | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| | Open a new tab | +| | Open a new block (defaults to a terminal block with the same connection and working directory). Switch to launcher using `app:defaultnewblock` setting | +| | Split horizontally, open a new block to the right | +| | Split vertically, open a new block below | +| | Open a new window | +| | Close the current block | +| | Close the current tab | +| | Magnify / Un-Magnify the current block | +| | Open the "connection" switcher | +| | Refocus the current block (useful if the block has lost input focus) | +| | Show block numbers | +| | Switch to block number | +| | Move left, right, up, down between blocks | +| | Switch to tab number | +| | Switch tab left | +| | Switch tab right | +| | Switch to workspace number | +| | Refresh the UI | +| | 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 | | Back | | | Forward | | | Find in webpage | +| | Open a bookmark | ## WaveAI Keybindings diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index badead269..58e119927 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -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); diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 6a9e4185d..7d359993f 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -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(); + const noHeader = util.useAtomValueSafe(viewModel?.noHeader); + React.useEffect(() => { if (!manageConnection) { return; @@ -618,7 +620,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { /> )}
- {headerElem} + {noHeader || {headerElem}} {preview ? previewElem : children}
{preview || viewModel == null || !connModalOpen ? null : ( diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 8bfe03f01..0ee5ce87e 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -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 { + 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, diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 5a4cf9c58..6aa640147 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -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(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(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; diff --git a/frontend/app/view/launcher/launcher.tsx b/frontend/app/view/launcher/launcher.tsx new file mode 100644 index 000000000..b0c311f94 --- /dev/null +++ b/frontend/app/view/launcher/launcher.tsx @@ -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; + 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> = ({ 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(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 ( +
+ {/* Hidden input for search */} + setSearchTerm(e.target.value)} + className="sr-only" + aria-label="Search widgets" + /> + + {/* Logo */} + {showLogo && ( +
+ Logo +
+ )} + + {/* Grid of widgets */} +
+ {filteredWidgets.map((widget, index) => ( +
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, + }} + > +
+ +
+ {gridLayout.showLabel && !isBlank(widget.label) && ( +
+ {widget.label} +
+ )} +
+ ))} +
+ + {/* Search instructions */} +
+ {filteredWidgets.length === 0 ? ( + No widgets found. Press Escape to clear search. + ) : ( + + {searchTerm == "" ? "Type to Filter" : "Searching " + '"' + searchTerm + '"'}, Enter to Launch, + {searchTerm == "" ? "Arrow Keys to Navigate" : null} + + )} +
+
+ ); +}; + +export default LauncherView; diff --git a/frontend/app/view/view-prompt.md b/frontend/app/view/view-prompt.md new file mode 100644 index 000000000..4a7f2ad5a --- /dev/null +++ b/frontend/app/view/view-prompt.md @@ -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`. + - 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`, `PrimitiveAtom`, `WritableAtom` 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 = { + blockId: string; + blockRef: React.RefObject; + contentRef: React.RefObject; + model: T; +}; + +type ViewComponent = React.FC>; + +interface ViewModel { + viewType: string; + viewIcon?: jotai.Atom; + viewName?: jotai.Atom; + viewText?: jotai.Atom; + preIconButton?: jotai.Atom; + endIconButtons?: jotai.Atom; + blockBg?: jotai.Atom; + manageConnection?: jotai.Atom; + noPadding?: jotai.Atom; + searchAtoms?: SearchAtoms; + viewComponent: ViewComponent; + dispose?: () => void; + giveFocus?: () => boolean; + keyDownHandler?: (e: WaveKeyboardEvent) => boolean; +} + +interface IconButtonDecl { + elemtype: "iconbutton"; + icon: string | React.ReactNode; + click?: (e: React.MouseEvent) => 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) => void; + longClick?: (e: React.MouseEvent) => void; +}; + +type ToggleIconButtonDecl = IconButtonCommon & { + elemtype: "toggleiconbutton"; + active: jotai.WritableAtom; +}; + +type HeaderTextButton = { + elemtype: "textbutton"; + text: string; + className?: string; + title?: string; + onClick?: (e: React.MouseEvent) => void; +}; + +type HeaderText = { + elemtype: "text"; + text: string; + ref?: React.MutableRefObject; + className?: string; + noGrow?: boolean; + onClick?: (e: React.MouseEvent) => void; +}; + +type HeaderInput = { + elemtype: "input"; + value: string; + className?: string; + isDisabled?: boolean; + ref?: React.MutableRefObject; + onChange?: (e: React.ChangeEvent) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; + onFocus?: (e: React.FocusEvent) => void; + onBlur?: (e: React.FocusEvent) => void; +}; + +type HeaderDiv = { + elemtype: "div"; + className?: string; + children: HeaderElem[]; + onMouseOver?: (e: React.MouseEvent) => void; + onMouseOut?: (e: React.MouseEvent) => void; + onClick?: (e: React.MouseEvent) => void; +}; + +type ConnectionButton = { + elemtype: "connectionbutton"; + icon: string; + text: string; + iconColor: string; + onClick?: (e: React.MouseEvent) => void; + connected: boolean; +}; + +type MenuItem = { + label: string; + icon?: string | React.ReactNode; + subItems?: MenuItem[]; + onClick?: (e: React.MouseEvent) => 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 = ({ model }) => { + return
Hello, World!
; +}; + +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`. diff --git a/frontend/layout/lib/layoutTree.ts b/frontend/layout/lib/layoutTree.ts index e60771acf..74fcad5f3 100644 --- a/frontend/layout/lib/layoutTree.ts +++ b/frontend/layout/lib/layoutTree.ts @@ -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) { diff --git a/frontend/layout/lib/types.ts b/frontend/layout/lib/types.ts index 1c8fa1f1d..d250fa74e 100644 --- a/frontend/layout/lib/types.ts +++ b/frontend/layout/lib/types.ts @@ -195,6 +195,7 @@ export interface LayoutTreeReplaceNodeAction extends LayoutTreeAction { type: LayoutTreeActionType.ReplaceNode; targetNodeId: string; newNode: LayoutNode; + focused?: boolean; } // SplitHorizontal: split the current block horizontally. diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index d0e8fbc92..70a777761 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -282,6 +282,8 @@ declare global { // Background styling metadata for the block. blockBg?: jotai.Atom; + noHeader?: jotai.Atom; + // Whether the block manages its own connection (e.g., for remote access). manageConnection?: jotai.Atom; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index d7270ae90..b6e1ca2ba 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -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; diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index 3da7e6fd2..1cfbc1356 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -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, diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 4e95a3373..0185b3bc2 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -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" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 46d27a13c..ff4aae89a 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -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 diff --git a/schema/settings.json b/schema/settings.json index abe5caac4..395974b57 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -14,6 +14,9 @@ "app:dismissarchitecturewarning": { "type": "boolean" }, + "app:defaultnewblock": { + "type": "string" + }, "ai:*": { "type": "boolean" },