mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-02-28 03:42:50 +01:00
launcher block (#1948)
This commit is contained in:
parent
af65c2cc8d
commit
539559c603
@ -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>
|
||||
|
@ -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
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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 : (
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
281
frontend/app/view/launcher/launcher.tsx
Normal file
281
frontend/app/view/launcher/launcher.tsx
Normal 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;
|
239
frontend/app/view/view-prompt.md
Normal file
239
frontend/app/view/view-prompt.md
Normal 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`.
|
@ -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) {
|
||||
|
@ -195,6 +195,7 @@ export interface LayoutTreeReplaceNodeAction extends LayoutTreeAction {
|
||||
type: LayoutTreeActionType.ReplaceNode;
|
||||
targetNodeId: string;
|
||||
newNode: LayoutNode;
|
||||
focused?: boolean;
|
||||
}
|
||||
|
||||
// SplitHorizontal: split the current block horizontally.
|
||||
|
2
frontend/types/custom.d.ts
vendored
2
frontend/types/custom.d.ts
vendored
@ -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>;
|
||||
|
||||
|
1
frontend/types/gotypes.d.ts
vendored
1
frontend/types/gotypes.d.ts
vendored
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
||||
|
@ -14,6 +14,9 @@
|
||||
"app:dismissarchitecturewarning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"app:defaultnewblock": {
|
||||
"type": "string"
|
||||
},
|
||||
"ai:*": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user