diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..ac71ce785f --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: wavetermdev diff --git a/README.md b/README.md index c2d4a661d3..2b8e0637ae 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,14 @@ Find more information in our [Contributions Guide](CONTRIBUTING.md), which inclu - [Ways to contribute](CONTRIBUTING.md#contributing-to-wave-terminal) - [Contribution guidelines](CONTRIBUTING.md#before-you-start) +### Sponsoring Wave ❤️ + +If Wave Terminal is useful to you or your company, consider sponsoring development. + +Sponsorship helps support the time spent building and maintaining the project. + +- https://github.com/sponsors/wavetermdev + ## License Wave Terminal is licensed under the Apache-2.0 License. For more information on our dependencies, see [here](./ACKNOWLEDGEMENTS.md). diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 6cdd59cdfa..cd29f8a7f4 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -73,6 +73,7 @@ wsh editconfig | term:cursorblink | bool | when enabled, terminal cursor blinks (default false) | | term:bellsound | bool | when enabled, plays the system beep sound when the terminal bell (BEL character) is received (default false) | | term:bellindicator | bool | when enabled, shows a visual indicator in the tab when the terminal bell is received (default false) | +| term:osc52 | string | controls OSC 52 clipboard behavior: `always` (default, allows OSC 52 at any time) or `focus` (requires focused window and focused block) | | term:durable | bool | makes remote terminal sessions durable across network disconnects (defaults to false) | | editor:minimapenabled | bool | set to false to disable editor minimap | | editor:stickyscrollenabled | bool | enables monaco editor's stickyScroll feature (pinning headers of current context, e.g. class names, method names, etc.), defaults to false | @@ -147,6 +148,7 @@ For reference, this is the current default configuration (v0.14.0): "telemetry:enabled": true, "term:bellsound": false, "term:bellindicator": false, + "term:osc52": "always", "term:cursor": "block", "term:cursorblink": false, "term:copyonselect": true, diff --git a/docs/docs/wsh-reference.mdx b/docs/docs/wsh-reference.mdx index 9dd8bc0b3e..90c8d82147 100644 --- a/docs/docs/wsh-reference.mdx +++ b/docs/docs/wsh-reference.mdx @@ -23,6 +23,7 @@ You can open a preview block with the contents of any file or directory by runni ```sh wsh view [path] +wsh view -m [path] # opens in magnified block ``` You can use this command to easily preview images, markdown files, and directories. For code/text files this will open @@ -34,9 +35,29 @@ a codeedit block which you can use to quickly edit the file using Wave's embedde ```sh wsh edit [path] +wsh edit -m [path] # opens in magnified block ``` -This will open up codeedit for the specified file. This is useful for quickly editing files on a local or remote machine in our graphical editor. This command will wait until the file is closed before exiting (unlike `view`) so you can set your `$EDITOR` to `wsh editor` for a seamless experience. You can combine this with a `-m` flag to open the editor in magnified mode. +This will open up a codeedit block for the specified file. This is useful for quickly editing files on a local or remote machine in Wave's graphical editor. This command returns immediately after opening the block. + +For `$EDITOR` integration (e.g. with `git commit`), see [`wsh editor`](#editor) which blocks until the editor is closed. + +--- + +## editor + +```sh +wsh editor [path] +wsh editor -m [path] # opens in magnified block +``` + +This opens a codeedit block for the specified file and **blocks until the editor is closed**. This is useful for setting your `$EDITOR` environment variable so that CLI tools (e.g. `git commit`, `crontab -e`) open files in Wave's graphical editor: + +```sh +export EDITOR="wsh editor" +``` + +The file must already exist. Use `-m` to open the editor in magnified mode. --- diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index aaf3736431..1d1ec2108a 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -17,13 +17,14 @@ import { incrementTermCommandsRemote, incrementTermCommandsRun, incrementTermCommandsWsl, + setWasActive, } from "./emain-activity"; import { createBuilderWindow, getAllBuilderWindows, getBuilderWindowByWebContentsId } from "./emain-builder"; import { callWithOriginalXdgCurrentDesktopAsync, unamePlatform } from "./emain-platform"; import { getWaveTabViewByWebContentsId } from "./emain-tabview"; import { handleCtrlShiftState } from "./emain-util"; import { getWaveVersion } from "./emain-wavesrv"; -import { createNewWaveWindow, focusedWaveWindow, getWaveWindowByWebContentsId } from "./emain-window"; +import { createNewWaveWindow, getWaveWindowByWebContentsId } from "./emain-window"; import { ElectronWshClient } from "./emain-wsh"; const electronApp = electron.app; @@ -129,12 +130,18 @@ function getUrlInSession(session: Electron.Session, url: string): Promise { if (file.canceled) { + readStream.destroy(); return; } const writeStream = fs.createWriteStream(file.filePath); @@ -212,7 +220,12 @@ export function initIpcHandlers() { const resultP = getUrlInSession(event.sender.session, payload.src); resultP .then((result) => { - saveImageFileWithNativeDialog(result.fileName, result.mimeType, result.stream); + saveImageFileWithNativeDialog( + event.sender.hostWebContents, + result.fileName, + result.mimeType, + result.stream + ); }) .catch((e) => { console.log("error getting image", e); @@ -317,6 +330,10 @@ export function initIpcHandlers() { tabView?.setKeyboardChordMode(true); }); + electron.ipcMain.handle("set-is-active", () => { + setWasActive(true); + }); + const fac = new FastAverageColor(); electron.ipcMain.on("update-window-controls-overlay", async (event, rect: Dimensions) => { if (unamePlatform === "darwin") return; @@ -472,7 +489,7 @@ export function initIpcHandlers() { }); electron.ipcMain.handle("save-text-file", async (event, fileName: string, content: string) => { - const ww = focusedWaveWindow; + const ww = electron.BrowserWindow.fromWebContents(event.sender); if (ww == null) { return false; } diff --git a/emain/emain-menu.ts b/emain/emain-menu.ts index 6685d79087..e3de818f80 100644 --- a/emain/emain-menu.ts +++ b/emain/emain-menu.ts @@ -9,7 +9,7 @@ import { focusedBuilderWindow, getBuilderWindowById } from "./emain-builder"; import { openBuilderWindow } from "./emain-ipc"; import { isDev, unamePlatform } from "./emain-platform"; import { clearTabCache } from "./emain-tabview"; -import { decreaseZoomLevel, increaseZoomLevel } from "./emain-util"; +import { decreaseZoomLevel, increaseZoomLevel, resetZoomLevel } from "./emain-util"; import { createNewWaveWindow, createWorkspace, @@ -238,8 +238,7 @@ function makeViewMenu( click: (_, window) => { const wc = getWindowWebContents(window) ?? webContents; if (wc) { - wc.setZoomFactor(1); - wc.send("zoom-factor-change", 1); + resetZoomLevel(wc); } }, }, diff --git a/emain/emain-tabview.ts b/emain/emain-tabview.ts index feb0f029c2..7bf4cc23f3 100644 --- a/emain/emain-tabview.ts +++ b/emain/emain-tabview.ts @@ -15,6 +15,7 @@ import { handleCtrlShiftFocus, handleCtrlShiftState, increaseZoomLevel, + resetZoomLevel, shFrameNavHandler, shNavHandler, } from "./emain-util"; @@ -48,8 +49,7 @@ function handleWindowsMenuAccelerators( } if (checkKeyPressed(waveEvent, "Ctrl:0")) { - tabView.webContents.setZoomFactor(1); - tabView.webContents.send("zoom-factor-change", 1); + resetZoomLevel(tabView.webContents); return true; } @@ -165,9 +165,6 @@ export class WaveTabView extends WebContentsView { removeWaveTabView(this.waveTabId); this.isDestroyed = true; }); - this.webContents.on("zoom-changed", (_event, zoomDirection) => { - this.webContents.send("zoom-factor-change", this.webContents.getZoomFactor()); - }); this.setBackgroundColor(computeBgColor(fullConfig)); } @@ -339,9 +336,6 @@ export async function getOrCreateWebViewForTab(waveWindowId: string, tabId: stri } } }); - tabView.webContents.on("zoom-changed", (e) => { - tabView.webContents.send("zoom-changed"); - }); tabView.webContents.setWindowOpenHandler(({ url, frameName }) => { if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) { console.log("openExternal fallback", url); diff --git a/emain/emain-util.ts b/emain/emain-util.ts index b04fda0dfa..08f9c3413a 100644 --- a/emain/emain-util.ts +++ b/emain/emain-util.ts @@ -12,16 +12,36 @@ const MinZoomLevel = 0.4; const MaxZoomLevel = 2.6; const ZoomDelta = 0.2; +// Note: Chromium automatically syncs zoom factor across all WebContents +// sharing the same origin/session, so we only need to notify renderers +// to update their CSS/state — not call setZoomFactor on each one. +// We broadcast to all WebContents (including devtools, webviews, etc.) but +// that is safe because "zoom-factor-change" is a custom app-defined event +// that only our renderers listen to; unrecognized IPC messages are ignored. +function broadcastZoomFactorChanged(newZoomFactor: number): void { + for (const wc of electron.webContents.getAllWebContents()) { + if (wc.isDestroyed()) { + continue; + } + wc.send("zoom-factor-change", newZoomFactor); + } +} + export function increaseZoomLevel(webContents: electron.WebContents): void { const newZoom = Math.min(MaxZoomLevel, webContents.getZoomFactor() + ZoomDelta); webContents.setZoomFactor(newZoom); - webContents.send("zoom-factor-change", newZoom); + broadcastZoomFactorChanged(newZoom); } export function decreaseZoomLevel(webContents: electron.WebContents): void { const newZoom = Math.max(MinZoomLevel, webContents.getZoomFactor() - ZoomDelta); webContents.setZoomFactor(newZoom); - webContents.send("zoom-factor-change", newZoom); + broadcastZoomFactorChanged(newZoom); +} + +export function resetZoomLevel(webContents: electron.WebContents): void { + webContents.setZoomFactor(1); + broadcastZoomFactorChanged(1); } export function getElectronExecPath(): string { diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 07c0c08a6c..2c34d3a39c 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -273,6 +273,7 @@ export class WaveBrowserWindow extends BaseWindow { if (getGlobalIsRelaunching()) { return; } + focusedWaveWindow = this; // eslint-disable-line @typescript-eslint/no-this-alias console.log("focus win", this.waveWindowId); fireAndForget(() => ClientService.FocusWindow(this.waveWindowId)); setWasInFg(true); diff --git a/emain/emain.ts b/emain/emain.ts index 79b0c2d0ff..7a2b0a0710 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -384,6 +384,10 @@ async function appMain() { electronApp.quit(); return; } + electronApp.on("second-instance", (_event, argv, workingDirectory) => { + console.log("second-instance event, argv:", argv, "workingDirectory:", workingDirectory); + fireAndForget(createNewWaveWindow); + }); try { await runWaveSrv(handleWSEvent); } catch (e) { diff --git a/emain/preload.ts b/emain/preload.ts index 7acdf2e73a..823f99c4cd 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -71,6 +71,7 @@ contextBridge.exposeInMainWorld("api", { setBuilderWindowAppId: (appId: string) => ipcRenderer.send("set-builder-window-appid", appId), doRefresh: () => ipcRenderer.send("do-refresh"), saveTextFile: (fileName: string, content: string) => ipcRenderer.invoke("save-text-file", fileName, content), + setIsActive: () => ipcRenderer.invoke("set-is-active"), }); // Custom event for "new-window" diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 46780455c2..dded015f85 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -306,7 +306,7 @@ const AIPanelComponentInner = memo(() => { }; useEffect(() => { - globalStore.set(model.isAIStreaming, status == "streaming"); + globalStore.set(model.isAIStreaming, status === "streaming" || status === "submitted"); }, [status]); useEffect(() => { diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index 76ad557516..0970b476a1 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -200,12 +200,16 @@ function AppFocusHandler() { const AppKeyHandlers = () => { useEffect(() => { const staticKeyDownHandler = keyutil.keydownWrapper(appHandleKeyDown); + const staticMouseDownHandler = (e: MouseEvent) => { + keyboardMouseDownHandler(e); + GlobalModel.getInstance().setIsActive(); + }; document.addEventListener("keydown", staticKeyDownHandler); - document.addEventListener("mousedown", keyboardMouseDownHandler); + document.addEventListener("mousedown", staticMouseDownHandler); return () => { document.removeEventListener("keydown", staticKeyDownHandler); - document.removeEventListener("mousedown", keyboardMouseDownHandler); + document.removeEventListener("mousedown", staticMouseDownHandler); }; }, []); return null; diff --git a/frontend/app/store/global-model.ts b/frontend/app/store/global-model.ts index 804e3a18f6..05e84e3774 100644 --- a/frontend/app/store/global-model.ts +++ b/frontend/app/store/global-model.ts @@ -3,14 +3,18 @@ import * as WOS from "@/app/store/wos"; import { ClientModel } from "@/app/store/client-model"; +import { getApi } from "@/store/global"; +import * as util from "@/util/util"; import { atom, Atom } from "jotai"; class GlobalModel { private static instance: GlobalModel; + static readonly IsActiveThrottleMs = 5000; windowId: string; builderId: string; platform: NodeJS.Platform; + lastSetIsActiveTs = 0; windowDataAtom!: Atom; workspaceAtom!: Atom; @@ -47,6 +51,15 @@ class GlobalModel { return WOS.getObjectValue(WOS.makeORef("workspace", windowData.workspaceid), get); }); } + + setIsActive(): void { + const now = Date.now(); + if (now - this.lastSetIsActiveTs < GlobalModel.IsActiveThrottleMs) { + return; + } + this.lastSetIsActiveTs = now; + util.fireAndForget(() => getApi().setIsActive()); + } } -export { GlobalModel }; \ No newline at end of file +export { GlobalModel }; diff --git a/frontend/app/treeview/treeview.test.ts b/frontend/app/treeview/treeview.test.ts new file mode 100644 index 0000000000..c286be7a49 --- /dev/null +++ b/frontend/app/treeview/treeview.test.ts @@ -0,0 +1,46 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { buildVisibleRows, TreeNodeData } from "@/app/treeview/treeview"; +import { describe, expect, it } from "vitest"; + +function makeNodes(entries: TreeNodeData[]): Map { + return new Map(entries.map((entry) => [entry.id, entry])); +} + +describe("treeview visible rows", () => { + it("sorts directories before files and alphabetically", () => { + const nodes = makeNodes([ + { + id: "root", + isDirectory: true, + childrenStatus: "loaded", + childrenIds: ["c", "a", "b"], + }, + { id: "a", parentId: "root", isDirectory: false, label: "z-last.txt" }, + { id: "b", parentId: "root", isDirectory: true, label: "docs", childrenStatus: "loaded", childrenIds: [] }, + { id: "c", parentId: "root", isDirectory: false, label: "a-first.txt" }, + ]); + const rows = buildVisibleRows(nodes, ["root"], new Set(["root"])); + expect(rows.map((row) => row.id)).toEqual(["root", "b", "c", "a"]); + }); + + it("renders loading and capped synthetic rows", () => { + const nodes = makeNodes([ + { id: "root", isDirectory: true, childrenStatus: "loading" }, + { + id: "dir", + isDirectory: true, + childrenStatus: "capped", + childrenIds: ["f1"], + capInfo: { max: 1 }, + }, + { id: "f1", parentId: "dir", isDirectory: false, label: "one.txt" }, + ]); + const loadingRows = buildVisibleRows(nodes, ["root"], new Set(["root"])); + expect(loadingRows.map((row) => row.kind)).toEqual(["node", "loading"]); + + const cappedRows = buildVisibleRows(nodes, ["dir"], new Set(["dir"])); + expect(cappedRows.map((row) => row.kind)).toEqual(["node", "node", "capped"]); + }); +}); diff --git a/frontend/app/treeview/treeview.tsx b/frontend/app/treeview/treeview.tsx new file mode 100644 index 0000000000..4481d2c68f --- /dev/null +++ b/frontend/app/treeview/treeview.tsx @@ -0,0 +1,522 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { makeIconClass } from "@/util/util"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import clsx from "clsx"; +import React, { + CSSProperties, + KeyboardEvent, + MouseEvent, + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; + +type TreeNodeChildrenStatus = "unloaded" | "loading" | "loaded" | "error" | "capped"; + +export interface TreeNodeData { + id: string; + parentId?: string; + label?: string; + path?: string; + isDirectory: boolean; + mimeType?: string; + icon?: string; + isReadonly?: boolean; + notfound?: boolean; + staterror?: string; + childrenStatus?: TreeNodeChildrenStatus; + childrenIds?: string[]; + capInfo?: { max: number; totalKnown?: number }; +} + +interface FetchDirResult { + nodes: TreeNodeData[]; + capped?: boolean; + totalKnown?: number; +} + +export interface TreeViewVisibleRow { + id: string; + parentId?: string; + depth: number; + kind: "node" | "loading" | "error" | "capped"; + label: string; + isDirectory?: boolean; + isExpanded?: boolean; + hasChildren?: boolean; + icon?: string; + node?: TreeNodeData; +} + +export interface TreeViewProps { + rootIds: string[]; + initialNodes: Record; + fetchDir?: (id: string, limit: number) => Promise; + maxDirEntries?: number; + rowHeight?: number; + indentWidth?: number; + overscan?: number; + minWidth?: number; + maxWidth?: number; + width?: number | string; + height?: number | string; + className?: string; + onOpenFile?: (id: string, node: TreeNodeData) => void; + onSelectionChange?: (id: string, node: TreeNodeData) => void; +} + +export interface TreeViewRef { + scrollToId: (id: string) => void; +} + +const DefaultRowHeight = 24; +const DefaultIndentWidth = 16; +const DefaultOverscan = 10; +const ChevronWidth = 16; + +function normalizeLabel(node: TreeNodeData): string { + if (node.label?.trim()) { + return node.label; + } + const path = node.path ?? node.id; + const chunks = path.split("/").filter(Boolean); + return chunks[chunks.length - 1] ?? path; +} + +function sortIdsByNode(nodesById: Map, ids: string[]): string[] { + return [...ids].sort((leftId, rightId) => { + const left = nodesById.get(leftId); + const right = nodesById.get(rightId); + const leftDir = left?.isDirectory ? 0 : 1; + const rightDir = right?.isDirectory ? 0 : 1; + if (leftDir !== rightDir) { + return leftDir - rightDir; + } + const leftLabel = normalizeLabel(left ?? { id: leftId, isDirectory: false }).toLocaleLowerCase(); + const rightLabel = normalizeLabel(right ?? { id: rightId, isDirectory: false }).toLocaleLowerCase(); + if (leftLabel !== rightLabel) { + return leftLabel.localeCompare(rightLabel); + } + return leftId.localeCompare(rightId); + }); +} + +export function buildVisibleRows( + nodesById: Map, + rootIds: string[], + expandedIds: Set +): TreeViewVisibleRow[] { + const rows: TreeViewVisibleRow[] = []; + + const appendNode = (id: string, depth: number) => { + const node = nodesById.get(id); + if (node == null) { + return; + } + const childIds = node.childrenIds ?? []; + const hasChildren = node.isDirectory && (childIds.length > 0 || node.childrenStatus !== "loaded"); + const isExpanded = expandedIds.has(id); + rows.push({ + id, + parentId: node.parentId, + depth, + kind: "node", + label: normalizeLabel(node), + isDirectory: node.isDirectory, + isExpanded, + hasChildren, + icon: node.icon, + node, + }); + if (!isExpanded || !node.isDirectory) { + return; + } + const status = node.childrenStatus ?? "unloaded"; + if (status === "loading") { + rows.push({ + id: `${id}::__loading`, + parentId: id, + depth: depth + 1, + kind: "loading", + label: "Loading…", + }); + return; + } + if (status === "error") { + rows.push({ + id: `${id}::__error`, + parentId: id, + depth: depth + 1, + kind: "error", + label: node.staterror ? `Error: ${node.staterror}` : "Unable to load directory", + }); + return; + } + + const sortedChildren = sortIdsByNode(nodesById, childIds); + sortedChildren.forEach((childId) => appendNode(childId, depth + 1)); + if (status === "capped") { + const capMax = node.capInfo?.max ?? childIds.length; + rows.push({ + id: `${id}::__capped`, + parentId: id, + depth: depth + 1, + kind: "capped", + label: `Showing first ${capMax} entries`, + }); + } + }; + + sortIdsByNode(nodesById, rootIds).forEach((id) => appendNode(id, 0)); + return rows; +} + +function getNodeIcon(node: TreeNodeData, isExpanded: boolean): string { + if (node.notfound || node.staterror) { + return "triangle-exclamation"; + } + if (node.icon) { + return node.icon; + } + if (node.isDirectory) { + return isExpanded ? "folder-open" : "folder"; + } + const mime = node.mimeType ?? ""; + if (mime.startsWith("image/")) { + return "image"; + } + if (mime === "application/pdf") { + return "file-pdf"; + } + const extension = normalizeLabel(node).split(".").pop()?.toLocaleLowerCase(); + if (["js", "jsx", "ts", "tsx", "go", "py", "java", "c", "cpp", "h", "hpp", "json", "yaml", "yml"].includes(extension)) { + return "file-code"; + } + if (["md", "txt", "log"].includes(extension)) { + return "file-lines"; + } + return "file"; +} + +export const TreeView = forwardRef((props, ref) => { + const { + rootIds, + initialNodes, + fetchDir, + maxDirEntries = 500, + rowHeight = DefaultRowHeight, + indentWidth = DefaultIndentWidth, + overscan = DefaultOverscan, + minWidth = 100, + maxWidth = 400, + width = "100%", + height = 360, + className, + onOpenFile, + onSelectionChange, + } = props; + const [nodesById, setNodesById] = useState>( + () => + new Map( + Object.entries(initialNodes).map(([id, node]) => [id, { ...node, childrenStatus: node.childrenStatus ?? "unloaded" }]) + ) + ); + const [expandedIds, setExpandedIds] = useState>(new Set()); + const [selectedId, setSelectedId] = useState(rootIds[0]); + const scrollRef = useRef(null); + + useEffect(() => { + setNodesById( + new Map( + Object.entries(initialNodes).map(([id, node]) => [ + id, + { + ...node, + childrenStatus: node.childrenStatus ?? "unloaded", + }, + ]) + ) + ); + }, [initialNodes]); + + const visibleRows = useMemo(() => buildVisibleRows(nodesById, rootIds, expandedIds), [nodesById, rootIds, expandedIds]); + const idToIndex = useMemo( + () => new Map(visibleRows.map((row, index) => [row.id, index])), + [visibleRows] + ); + const virtualizer = useVirtualizer({ + count: visibleRows.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => rowHeight, + overscan, + }); + + const commitSelection = (id: string) => { + const node = nodesById.get(id); + if (node == null) { + return; + } + setSelectedId(id); + onSelectionChange?.(id, node); + }; + + const scrollToId = (id: string) => { + const index = idToIndex.get(id); + if (index == null) { + return; + } + virtualizer.scrollToIndex(index, { align: "auto" }); + }; + + useImperativeHandle( + ref, + () => ({ + scrollToId, + }), + [idToIndex, virtualizer] + ); + + const loadChildren = async (id: string) => { + const currentNode = nodesById.get(id); + if (currentNode == null || !currentNode.isDirectory || currentNode.notfound || currentNode.staterror || fetchDir == null) { + return; + } + const status = currentNode.childrenStatus ?? "unloaded"; + if (status !== "unloaded") { + return; + } + setNodesById((prev) => { + const next = new Map(prev); + next.set(id, { ...currentNode, childrenStatus: "loading" }); + return next; + }); + try { + const result = await fetchDir(id, maxDirEntries); + setNodesById((prev) => { + const next = new Map(prev); + result.nodes.forEach((node) => { + const merged: TreeNodeData = { + ...node, + parentId: node.parentId ?? id, + childrenStatus: node.childrenStatus ?? (node.isDirectory ? "unloaded" : "loaded"), + }; + next.set(merged.id, merged); + }); + const childrenIds = sortIdsByNode( + next, + result.nodes.map((entry) => entry.id) + ); + const source = next.get(id) ?? currentNode; + next.set(id, { + ...source, + childrenIds, + childrenStatus: result.capped ? "capped" : "loaded", + capInfo: result.capped ? { max: maxDirEntries, totalKnown: result.totalKnown } : undefined, + }); + return next; + }); + } catch (error) { + setNodesById((prev) => { + const next = new Map(prev); + const source = next.get(id) ?? currentNode; + next.set(id, { + ...source, + childrenStatus: "error", + staterror: error instanceof Error ? error.message : "Unknown error", + }); + return next; + }); + } + }; + + const toggleExpand = (id: string) => { + const node = nodesById.get(id); + if (node == null || !node.isDirectory || node.notfound || node.staterror) { + return; + } + const expanded = expandedIds.has(id); + if (!expanded) { + loadChildren(id); + } + setExpandedIds((prev) => { + const next = new Set(prev); + if (expanded) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + scrollToId(id); + }; + + const selectVisibleNodeAt = (index: number) => { + if (index < 0 || index >= visibleRows.length) { + return; + } + const row = visibleRows[index]; + if (row.kind !== "node") { + return; + } + commitSelection(row.id); + scrollToId(row.id); + }; + + const onKeyDown = (event: KeyboardEvent) => { + const selectedIndex = selectedId != null ? idToIndex.get(selectedId) : undefined; + if (event.key === "ArrowDown") { + event.preventDefault(); + const nextIndex = (selectedIndex ?? -1) + 1; + for (let idx = nextIndex; idx < visibleRows.length; idx++) { + if (visibleRows[idx].kind === "node") { + selectVisibleNodeAt(idx); + break; + } + } + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + const previousIndex = (selectedIndex ?? visibleRows.length) - 1; + for (let idx = previousIndex; idx >= 0; idx--) { + if (visibleRows[idx].kind === "node") { + selectVisibleNodeAt(idx); + break; + } + } + return; + } + const node = selectedId ? nodesById.get(selectedId) : null; + if (node == null) { + return; + } + if (event.key === "ArrowLeft") { + event.preventDefault(); + if (node.isDirectory && expandedIds.has(node.id)) { + toggleExpand(node.id); + return; + } + if (node.parentId != null) { + commitSelection(node.parentId); + scrollToId(node.parentId); + } + return; + } + if (event.key === "ArrowRight") { + event.preventDefault(); + if (node.isDirectory && !expandedIds.has(node.id)) { + toggleExpand(node.id); + return; + } + if (node.isDirectory && expandedIds.has(node.id) && node.childrenIds?.[0]) { + commitSelection(node.childrenIds[0]); + scrollToId(node.childrenIds[0]); + } + } + }; + + const containerStyle: CSSProperties = { + width, + minWidth, + maxWidth, + height, + }; + + return ( +
+
+
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const row = visibleRows[virtualRow.index]; + if (row.kind === "node" && row.node == null) { + return null; + } + const selected = row.id === selectedId; + return ( +
row.kind === "node" && commitSelection(row.id)} + onDoubleClick={() => { + if (row.kind !== "node") { + return; + } + if (row.isDirectory) { + toggleExpand(row.id); + return; + } + if (row.node != null) { + onOpenFile?.(row.id, row.node); + } + }} + > +
+ {row.kind === "node" && row.isDirectory && row.hasChildren ? ( + + ) : ( + + )} +
+ {row.kind === "node" ? ( + <> + + + {row.label} + + + ) : ( + {row.label} + )} +
+ ); + })} +
+
+
+ ); +}); + +TreeView.displayName = "TreeView"; diff --git a/frontend/app/view/term/osc-handlers.ts b/frontend/app/view/term/osc-handlers.ts index dd8021ae1d..25fdf0e89b 100644 --- a/frontend/app/view/term/osc-handlers.ts +++ b/frontend/app/view/term/osc-handlers.ts @@ -3,7 +3,15 @@ import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { getApi, getBlockMetaKeyAtom, getBlockTermDurableAtom, globalStore, recordTEvent, WOS } from "@/store/global"; +import { + getApi, + getBlockMetaKeyAtom, + getBlockTermDurableAtom, + getOverrideConfigAtom, + globalStore, + recordTEvent, + WOS, +} from "@/store/global"; import * as services from "@/store/services"; import { base64ToString, fireAndForget, isSshConnName, isWslConnName } from "@/util/util"; import debug from "debug"; @@ -114,10 +122,13 @@ export function handleOsc52Command(data: string, blockId: string, loaded: boolea if (!loaded) { return true; } - const isBlockFocused = termWrap.nodeModel ? globalStore.get(termWrap.nodeModel.isFocused) : false; - if (!document.hasFocus() || !isBlockFocused) { - console.log("OSC 52: rejected, window or block not focused"); - return true; + const osc52Mode = globalStore.get(getOverrideConfigAtom(blockId, "term:osc52")) ?? "always"; + if (osc52Mode === "focus") { + const isBlockFocused = termWrap.nodeModel ? globalStore.get(termWrap.nodeModel.isFocused) : false; + if (!document.hasFocus() || !isBlockFocused) { + console.log("OSC 52: rejected, window or block not focused"); + return true; + } } if (!data || data.length === 0) { console.log("OSC 52: empty data received"); diff --git a/frontend/app/workspace/widgets.tsx b/frontend/app/workspace/widgets.tsx index 11067edcda..940fb4e96c 100644 --- a/frontend/app/workspace/widgets.tsx +++ b/frontend/app/workspace/widgets.tsx @@ -6,7 +6,7 @@ import { ContextMenuModel } from "@/app/store/contextmenu"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { shouldIncludeWidgetForWorkspace } from "@/app/workspace/widgetfilter"; -import { atoms, createBlock, isDev } from "@/store/global"; +import { atoms, createBlock, getApi, isDev } from "@/store/global"; import { fireAndForget, isBlank, makeIconClass } from "@/util/util"; import { FloatingPortal, @@ -111,6 +111,10 @@ const AppsFloatingWindow = memo( const dismiss = useDismiss(context); const { getFloatingProps } = useInteractions([dismiss]); + const handleOpenBuilder = useCallback(() => { + getApi().openBuilder(null); + onClose(); + }, [onClose]); useEffect(() => { if (!isOpen) return; @@ -148,55 +152,65 @@ const AppsFloatingWindow = memo( ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()} - className="bg-modalbg border border-border rounded-lg shadow-xl p-4 z-50" + className="bg-modalbg border border-border rounded-lg shadow-xl z-50 overflow-hidden" > - {loading ? ( -
- -
- ) : apps.length === 0 ? ( -
No local apps found
- ) : ( -
- {apps.map((app) => { - const appMeta = app.manifest?.appmeta; - const displayName = app.appid.replace(/^local\//, ""); - const icon = appMeta?.icon || "cube"; - const iconColor = appMeta?.iconcolor || "white"; - - return ( -
{ - const blockDef: BlockDef = { - meta: { - view: "tsunami", - controller: "tsunami", - "tsunami:appid": app.appid, - }, - }; - createBlock(blockDef); - onClose(); - }} - > -
- -
-
- {displayName} +
+ {loading ? ( +
+ +
+ ) : apps.length === 0 ? ( +
No local apps found
+ ) : ( +
+ {apps.map((app) => { + const appMeta = app.manifest?.appmeta; + const displayName = app.appid.replace(/^local\//, ""); + const icon = appMeta?.icon || "cube"; + const iconColor = appMeta?.iconcolor || "white"; + + return ( +
{ + const blockDef: BlockDef = { + meta: { + view: "tsunami", + controller: "tsunami", + "tsunami:appid": app.appid, + }, + }; + createBlock(blockDef); + onClose(); + }} + > +
+ +
+
+ {displayName} +
-
- ); - })} -
- )} + ); + })} +
+ )} +
+
); diff --git a/frontend/preview/previews/treeview.preview.tsx b/frontend/preview/previews/treeview.preview.tsx new file mode 100644 index 0000000000..65043ddda4 --- /dev/null +++ b/frontend/preview/previews/treeview.preview.tsx @@ -0,0 +1,97 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { TreeNodeData, TreeView } from "@/app/treeview/treeview"; +import { useMemo, useState } from "react"; + +const RootId = "workspace:/"; +const RootNode: TreeNodeData = { + id: RootId, + path: RootId, + label: "workspace", + isDirectory: true, + childrenStatus: "unloaded", +}; + +const DirectoryData: Record = { + [RootId]: [ + { id: "workspace:/src", path: "workspace:/src", label: "src", parentId: RootId, isDirectory: true }, + { id: "workspace:/docs", path: "workspace:/docs", label: "docs", parentId: RootId, isDirectory: true }, + { id: "workspace:/README.md", path: "workspace:/README.md", label: "README.md", parentId: RootId, isDirectory: false, mimeType: "text/markdown" }, + { id: "workspace:/package.json", path: "workspace:/package.json", label: "package.json", parentId: RootId, isDirectory: false, mimeType: "application/json" }, + ], + "workspace:/src": [ + { id: "workspace:/src/app", path: "workspace:/src/app", label: "app", parentId: "workspace:/src", isDirectory: true }, + { id: "workspace:/src/styles", path: "workspace:/src/styles", label: "styles", parentId: "workspace:/src", isDirectory: true }, + ...Array.from({ length: 200 }).map((_, idx) => ({ + id: `workspace:/src/file-${idx.toString().padStart(3, "0")}.tsx`, + path: `workspace:/src/file-${idx.toString().padStart(3, "0")}.tsx`, + label: `file-${idx.toString().padStart(3, "0")}.tsx`, + parentId: "workspace:/src", + isDirectory: false, + mimeType: "text/typescript", + })), + ], + "workspace:/src/app": [ + { id: "workspace:/src/app/main.tsx", path: "workspace:/src/app/main.tsx", label: "main.tsx", parentId: "workspace:/src/app", isDirectory: false, mimeType: "text/typescript" }, + { id: "workspace:/src/app/router.ts", path: "workspace:/src/app/router.ts", label: "router.ts", parentId: "workspace:/src/app", isDirectory: false, mimeType: "text/typescript" }, + ], + "workspace:/src/styles": [ + { id: "workspace:/src/styles/app.css", path: "workspace:/src/styles/app.css", label: "app.css", parentId: "workspace:/src/styles", isDirectory: false, mimeType: "text/css" }, + ], + "workspace:/docs": Array.from({ length: 25 }).map((_, idx) => ({ + id: `workspace:/docs/page-${idx + 1}.md`, + path: `workspace:/docs/page-${idx + 1}.md`, + label: `page-${idx + 1}.md`, + parentId: "workspace:/docs", + isDirectory: false, + mimeType: "text/markdown", + })), +}; + +export function TreeViewPreview() { + const [width, setWidth] = useState(260); + const [selection, setSelection] = useState(RootId); + const initialNodes = useMemo(() => ({ [RootId]: RootNode }), []); + + return ( +
+
+
Tree width: {width}px
+ setWidth(Number(event.target.value))} + className="mt-2 w-full cursor-pointer" + /> +
Selection: {selection}
+
+ { + await new Promise((resolve) => setTimeout(resolve, 220)); + const entries = DirectoryData[id] ?? []; + return { + nodes: entries.slice(0, limit), + capped: entries.length > limit, + totalKnown: entries.length, + }; + }} + onOpenFile={(id) => { + setSelection(`open:${id}`); + }} + onSelectionChange={(id) => { + setSelection(id); + }} + /> +
+ ); +} diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 74180391cc..25c40eefef 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -132,6 +132,7 @@ declare global { setBuilderWindowAppId: (appId: string) => void; // set-builder-window-appid doRefresh: () => void; // do-refresh saveTextFile: (fileName: string, content: string) => Promise; // save-text-file + setIsActive: () => Promise; // set-is-active }; type ElectronContextMenuItem = { diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 2d155a919c..6070f46bef 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1119,6 +1119,7 @@ declare global { "term:conndebug"?: string; "term:bellsound"?: boolean; "term:bellindicator"?: boolean; + "term:osc52"?: string; "term:durable"?: boolean; "web:zoom"?: number; "web:hidenav"?: boolean; @@ -1313,6 +1314,7 @@ declare global { "term:cursorblink"?: boolean; "term:bellsound"?: boolean; "term:bellindicator"?: boolean; + "term:osc52"?: string; "term:durable"?: boolean; "editor:minimapenabled"?: boolean; "editor:stickyscrollenabled"?: boolean; diff --git a/go.mod b/go.mod index b07b58b3f8..7615a351ee 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( github.com/mitchellh/mapstructure v1.5.0 github.com/sashabaranov/go-openai v1.41.2 github.com/sawka/txwrap v0.2.0 - github.com/shirou/gopsutil/v4 v4.26.1 + github.com/shirou/gopsutil/v4 v4.26.2 github.com/skeema/knownhosts v1.3.1 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/spf13/cobra v1.10.2 @@ -49,7 +49,7 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/ebitengine/purego v0.9.1 // indirect + github.com/ebitengine/purego v0.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect diff --git a/go.sum b/go.sum index 6d213d65d7..03a89cd1d2 100644 --- a/go.sum +++ b/go.sum @@ -31,8 +31,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= -github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= @@ -126,8 +126,8 @@ github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TV github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/sawka/txwrap v0.2.0 h1:V3LfvKVLULxcYSxdMguLwFyQFMEU9nFDJopg0ZkL+94= github.com/sawka/txwrap v0.2.0/go.mod h1:wwQ2SQiN4U+6DU/iVPhbvr7OzXAtgZlQCIGuvOswEfA= -github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo= -github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc= +github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= +github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= diff --git a/package-lock.json b/package-lock.json index 269181d32a..86e9b5f581 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.14.1-beta.1", + "version": "0.14.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.14.1-beta.1", + "version": "0.14.1", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -21,6 +21,7 @@ "@table-nav/core": "^0.0.7", "@table-nav/react": "^0.0.7", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.19", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.15.0", "@xterm/addon-serialize": "^0.13.0", @@ -110,9 +111,9 @@ "electron-vite": "^5.0", "eslint": "^9.39", "eslint-config-prettier": "^10.1.8", - "globals": "^17.3.0", + "globals": "^17.4.0", "node-abi": "^4.26.0", - "postcss": "^8.5.6", + "postcss": "^8.5.8", "prettier": "^3.8.1", "prettier-plugin-jsdoc": "^1.8.0", "prettier-plugin-organize-imports": "^4.3.0", @@ -9047,6 +9048,23 @@ "react-dom": ">=16.8" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.19", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.19.tgz", + "integrity": "sha512-KzwmU1IbE0IvCZSm6OXkS+kRdrgW2c2P3Ho3NC+zZXWK6oObv/L+lcV/2VuJ+snVESRlMJ+w/fg4WXI/JzoNGQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tanstack/table-core": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", @@ -9060,6 +9078,16 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.19", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.19.tgz", + "integrity": "sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -16981,9 +17009,9 @@ } }, "node_modules/globals": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", - "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", "dev": true, "license": "MIT", "engines": { @@ -18088,9 +18116,9 @@ } }, "node_modules/immutable": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz", - "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", "license": "MIT" }, "node_modules/import-fresh": { @@ -24209,9 +24237,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "funding": [ { "type": "opencollective", @@ -29967,9 +29995,9 @@ } }, "node_modules/tar": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", - "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz", + "integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { diff --git a/package.json b/package.json index b21f8eee1a..5db786f6c6 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "productName": "Wave", "description": "Open-Source AI-Native Terminal Built for Seamless Workflows", "license": "Apache-2.0", - "version": "0.14.1", + "version": "0.14.2-beta.0", "homepage": "https://waveterm.dev", "build": { "appId": "dev.commandline.waveterm" @@ -53,9 +53,9 @@ "electron-vite": "^5.0", "eslint": "^9.39", "eslint-config-prettier": "^10.1.8", - "globals": "^17.3.0", + "globals": "^17.4.0", "node-abi": "^4.26.0", - "postcss": "^8.5.6", + "postcss": "^8.5.8", "prettier": "^3.8.1", "prettier-plugin-jsdoc": "^1.8.0", "prettier-plugin-organize-imports": "^4.3.0", @@ -81,6 +81,7 @@ "@table-nav/core": "^0.0.7", "@table-nav/react": "^0.0.7", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.19", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.15.0", "@xterm/addon-serialize": "^0.13.0", diff --git a/pkg/aiusechat/anthropic/anthropic-backend.go b/pkg/aiusechat/anthropic/anthropic-backend.go index b52b4a6797..02070b1bf8 100644 --- a/pkg/aiusechat/anthropic/anthropic-backend.go +++ b/pkg/aiusechat/anthropic/anthropic-backend.go @@ -10,8 +10,9 @@ import ( "errors" "fmt" "io" - "log" "net/http" + "net/url" + "sort" "strings" "time" @@ -20,6 +21,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil" "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" + "github.com/wavetermdev/waveterm/pkg/util/logutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/web/sse" ) @@ -56,10 +58,11 @@ func (m *anthropicChatMessage) GetUsage() *uctypes.AIUsage { } return &uctypes.AIUsage{ - APIType: uctypes.APIType_AnthropicMessages, - Model: m.Usage.Model, - InputTokens: m.Usage.InputTokens, - OutputTokens: m.Usage.OutputTokens, + APIType: uctypes.APIType_AnthropicMessages, + Model: m.Usage.Model, + InputTokens: m.Usage.InputTokens, + OutputTokens: m.Usage.OutputTokens, + NativeWebSearchCount: m.Usage.NativeWebSearchCount, } } @@ -95,8 +98,9 @@ type anthropicMessageContentBlock struct { Name string `json:"name,omitempty"` Input interface{} `json:"input,omitempty"` - ToolUseDisplayName string `json:"toolusedisplayname,omitempty"` // internal field (cannot marshal to API, must be stripped) - ToolUseShortDescription string `json:"tooluseshortdescription,omitempty"` // internal field (cannot marshal to API, must be stripped) + ToolUseDisplayName string `json:"toolusedisplayname,omitempty"` // internal field (cannot marshal to API, must be stripped) + ToolUseShortDescription string `json:"tooluseshortdescription,omitempty"` // internal field (cannot marshal to API, must be stripped) + ToolUseData *uctypes.UIMessageDataToolUse `json:"toolusedata,omitempty"` // internal field (cannot marshal to API, must be stripped) // Tool result content ToolUseID string `json:"tool_use_id,omitempty"` @@ -154,6 +158,7 @@ func (b *anthropicMessageContentBlock) Clean() *anthropicMessageContentBlock { rtn.SourcePreviewUrl = "" rtn.ToolUseDisplayName = "" rtn.ToolUseShortDescription = "" + rtn.ToolUseData = nil if rtn.Source != nil { rtn.Source = rtn.Source.Clean() } @@ -177,10 +182,15 @@ type anthropicStreamRequest struct { Stream bool `json:"stream"` System []anthropicMessageContentBlock `json:"system,omitempty"` ToolChoice any `json:"tool_choice,omitempty"` - Tools []uctypes.ToolDefinition `json:"tools,omitempty"` + Tools []any `json:"tools,omitempty"` // *uctypes.ToolDefinition or *anthropicWebSearchTool Thinking *anthropicThinkingOpts `json:"thinking,omitempty"` } +type anthropicWebSearchTool struct { + Type string `json:"type"` // "web_search_20250305" + Name string `json:"name"` // "web_search" +} + type anthropicCacheControl struct { Type string `json:"type"` // "ephemeral" TTL string `json:"ttl"` // "5m" or "1h" @@ -228,8 +238,9 @@ type anthropicUsageType struct { CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` - // internal field for Wave use (not sent to API) - Model string `json:"model,omitempty"` + // internal fields for Wave use (not sent to API) + Model string `json:"model,omitempty"` + NativeWebSearchCount int `json:"nativewebsearchcount,omitempty"` // for reference, but we dont keep thsese up to date or track them CacheCreation *anthropicCacheCreationType `json:"cache_creation,omitempty"` // breakdown of cached tokens by TTL @@ -290,14 +301,16 @@ type partialJSON struct { } type streamingState struct { - blockMap map[int]*blockState - toolCalls []uctypes.WaveToolCall - stopFromDelta string - msgID string - model string - stepStarted bool - rtnMessage *anthropicChatMessage - usage *anthropicUsageType + blockMap map[int]*blockState + toolCalls []uctypes.WaveToolCall + stopFromDelta string + msgID string + model string + stepStarted bool + rtnMessage *anthropicChatMessage + usage *anthropicUsageType + chatOpts uctypes.WaveChatOpts + webSearchCount int } func (p *partialJSON) Write(s string) { @@ -330,6 +343,20 @@ func (p *partialJSON) FinalObject() (json.RawMessage, error) { } } +// sanitizeHostnameInError removes the Wave cloud hostname from error messages +func sanitizeHostnameInError(err error) error { + if err == nil { + return nil + } + errStr := err.Error() + parsedURL, parseErr := url.Parse(uctypes.DefaultAIEndpoint) + if parseErr == nil && parsedURL.Host != "" && strings.Contains(errStr, parsedURL.Host) { + errStr = strings.ReplaceAll(errStr, uctypes.DefaultAIEndpoint, "AI service") + errStr = strings.ReplaceAll(errStr, parsedURL.Host, "host") + } + return fmt.Errorf("%s", errStr) +} + // makeThinkingOpts creates thinking options based on level and max tokens func makeThinkingOpts(thinkingLevel string, maxTokens int) *anthropicThinkingOpts { if thinkingLevel != uctypes.ThinkingLevelMedium && thinkingLevel != uctypes.ThinkingLevelHigh { @@ -373,13 +400,13 @@ func parseAnthropicHTTPError(resp *http.Response) error { // Try to parse as Anthropic error format first var eresp anthropicHTTPErrorResponse if err := json.Unmarshal(slurp, &eresp); err == nil && eresp.Error.Message != "" { - return fmt.Errorf("anthropic %s: %s", resp.Status, eresp.Error.Message) + return sanitizeHostnameInError(fmt.Errorf("anthropic %s: %s", resp.Status, eresp.Error.Message)) } // Try to parse as proxy error format var proxyErr uctypes.ProxyErrorResponse if err := json.Unmarshal(slurp, &proxyErr); err == nil && !proxyErr.Success && proxyErr.Error != "" { - return fmt.Errorf("anthropic %s: %s", resp.Status, proxyErr.Error) + return sanitizeHostnameInError(fmt.Errorf("anthropic %s: %s", resp.Status, proxyErr.Error)) } // Fall back to truncated raw response @@ -387,7 +414,7 @@ func parseAnthropicHTTPError(resp *http.Response) error { if msg == "" { msg = "unknown error" } - return fmt.Errorf("anthropic %s: %s", resp.Status, msg) + return sanitizeHostnameInError(fmt.Errorf("anthropic %s: %s", resp.Status, msg)) } func RunAnthropicChatStep( @@ -426,7 +453,7 @@ func RunAnthropicChatStep( // Validate continuation if provided if cont != nil { - if chatOpts.Config.Model != cont.Model { + if !uctypes.AreModelsCompatible(chat.APIType, chatOpts.Config.Model, cont.Model) { return nil, nil, nil, fmt.Errorf("cannot continue with a different model, model:%q, cont-model:%q", chatOpts.Config.Model, cont.Model) } } @@ -461,7 +488,7 @@ func RunAnthropicChatStep( resp, err := httpClient.Do(req) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, sanitizeHostnameInError(err) } defer resp.Body.Close() @@ -499,7 +526,7 @@ func RunAnthropicChatStep( // Use eventsource decoder for proper SSE parsing decoder := eventsource.NewDecoder(resp.Body) - stopReason, rtnMessage := handleAnthropicStreamingResp(ctx, sse, decoder, cont) + stopReason, rtnMessage := handleAnthropicStreamingResp(ctx, sse, decoder, cont, chatOpts) return stopReason, rtnMessage, rateLimitInfo, nil } @@ -509,6 +536,7 @@ func handleAnthropicStreamingResp( sse *sse.SSEHandlerCh, decoder *eventsource.Decoder, cont *uctypes.WaveContinueResponse, + chatOpts uctypes.WaveChatOpts, ) (*uctypes.WaveStopReason, *anthropicChatMessage) { // Per-response state state := &streamingState{ @@ -518,6 +546,7 @@ func handleAnthropicStreamingResp( Role: "assistant", Content: []anthropicMessageContentBlock{}, }, + chatOpts: chatOpts, } var rtnStopReason *uctypes.WaveStopReason @@ -526,8 +555,10 @@ func handleAnthropicStreamingResp( defer func() { // Set usage in the returned message if state.usage != nil { - // Set model in usage for internal use state.usage.Model = state.model + if state.webSearchCount > 0 { + state.usage.NativeWebSearchCount = state.webSearchCount + } state.rtnMessage.Usage = state.usage } @@ -558,6 +589,13 @@ func handleAnthropicStreamingResp( // Normal end of stream break } + if sse.Err() != nil { + return &uctypes.WaveStopReason{ + Kind: uctypes.StopKindCanceled, + ErrorType: "client_disconnect", + ErrorText: "client disconnected", + }, extractPartialTextFromState(state) + } // transport error mid-stream _ = sse.AiMsgError(err.Error()) return &uctypes.WaveStopReason{ @@ -587,6 +625,37 @@ func handleAnthropicStreamingResp( return rtnStopReason, state.rtnMessage } +func extractPartialTextFromState(state *streamingState) *anthropicChatMessage { + var content []anthropicMessageContentBlock + for _, block := range state.rtnMessage.Content { + if block.Type == "text" && block.Text != "" { + content = append(content, block) + } + } + var partialIdx []int + for idx, st := range state.blockMap { + if st.kind == blockText && st.contentBlock != nil && st.contentBlock.Text != "" { + partialIdx = append(partialIdx, idx) + } + } + sort.Ints(partialIdx) + for _, idx := range partialIdx { + st := state.blockMap[idx] + if st.kind == blockText && st.contentBlock != nil && st.contentBlock.Text != "" { + content = append(content, *st.contentBlock) + } + } + if len(content) == 0 { + return nil + } + return &anthropicChatMessage{ + MessageId: state.rtnMessage.MessageId, + Role: "assistant", + Content: content, + Usage: state.rtnMessage.Usage, + } +} + // handleAnthropicEvent processes one SSE event block. It may emit SSE parts // and/or return a StopReason when the stream is complete. // @@ -601,6 +670,13 @@ func handleAnthropicEvent( state *streamingState, cont *uctypes.WaveContinueResponse, ) (stopFromDelta *string, final *uctypes.WaveStopReason) { + if err := sse.Err(); err != nil { + return nil, &uctypes.WaveStopReason{ + Kind: uctypes.StopKindCanceled, + ErrorType: "client_disconnect", + ErrorText: "client disconnected", + } + } eventName := event.Event() data := event.Data() switch eventName { @@ -693,6 +769,10 @@ func handleAnthropicEvent( } state.blockMap[idx] = st _ = sse.AiMsgToolInputStart(tcID, tName) + case "server_tool_use": + if ev.ContentBlock.Name == "web_search" { + state.webSearchCount++ + } default: // ignore other block types gracefully per Anthropic guidance :contentReference[oaicite:18]{index=18} } @@ -732,6 +812,7 @@ func handleAnthropicEvent( if st.kind == blockToolUse { st.accumJSON.Write(ev.Delta.PartialJSON) _ = sse.AiMsgToolInputDelta(st.toolCallID, ev.Delta.PartialJSON) + aiutil.SendToolProgress(st.toolCallID, st.toolName, st.accumJSON.Bytes(), state.chatOpts, sse, true) } case "signature_delta": // Accumulate signature for thinking blocks @@ -784,6 +865,7 @@ func handleAnthropicEvent( } } _ = sse.AiMsgToolInputAvailable(st.toolCallID, st.toolName, raw) + aiutil.SendToolProgress(st.toolCallID, st.toolName, raw, state.chatOpts, sse, false) state.toolCalls = append(state.toolCalls, uctypes.WaveToolCall{ ID: st.toolCallID, Name: st.toolName, @@ -798,6 +880,9 @@ func handleAnthropicEvent( } state.rtnMessage.Content = append(state.rtnMessage.Content, toolUseBlock) } + // extractPartialTextFromState reads blockMap for still-in-flight content, so remove completed blocks + // once they have been appended to rtnMessage.Content to avoid duplicate text on disconnect. + delete(state.blockMap, *ev.Index) return nil, nil case "message_delta": @@ -868,7 +953,7 @@ func handleAnthropicEvent( } default: - log.Printf("unknown anthropic event type: %s", eventName) + logutil.DevPrintf("unknown anthropic event type: %s", eventName) return nil, nil } } diff --git a/pkg/aiusechat/anthropic/anthropic-backend_test.go b/pkg/aiusechat/anthropic/anthropic-backend_test.go index 8d9acb78e2..71e89bfb2f 100644 --- a/pkg/aiusechat/anthropic/anthropic-backend_test.go +++ b/pkg/aiusechat/anthropic/anthropic-backend_test.go @@ -6,6 +6,7 @@ package anthropic import ( "testing" + "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" ) @@ -69,3 +70,97 @@ func TestConvertPartsToAnthropicBlocks_SkipsUnknownTypes(t *testing.T) { t.Errorf("expected second text 'Another valid text', got %v", block2.Text) } } + +func TestGetFunctionCallInputByToolCallId(t *testing.T) { + toolData := &uctypes.UIMessageDataToolUse{ToolCallId: "call-1", ToolName: "read_file", Status: uctypes.ToolUseStatusPending} + chat := uctypes.AIChat{ + NativeMessages: []uctypes.GenAIMessage{ + &anthropicChatMessage{ + MessageId: "m1", + Role: "assistant", + Content: []anthropicMessageContentBlock{ + {Type: "tool_use", ID: "call-1", Name: "read_file", Input: map[string]interface{}{"path": "/tmp/a"}, ToolUseData: toolData}, + }, + }, + }, + } + fnCall := GetFunctionCallInputByToolCallId(chat, "call-1") + if fnCall == nil { + t.Fatalf("expected function call input") + } + if fnCall.CallId != "call-1" || fnCall.Name != "read_file" { + t.Fatalf("unexpected function call input: %#v", fnCall) + } + if fnCall.Arguments != "{\"path\":\"/tmp/a\"}" { + t.Fatalf("unexpected arguments: %s", fnCall.Arguments) + } + if fnCall.ToolUseData == nil || fnCall.ToolUseData.ToolCallId != "call-1" { + t.Fatalf("expected tool use data") + } +} + +func TestUpdateAndRemoveToolUseCall(t *testing.T) { + chatID := "anthropic-test-tooluse" + chatstore.DefaultChatStore.Delete(chatID) + defer chatstore.DefaultChatStore.Delete(chatID) + + aiOpts := &uctypes.AIOptsType{ + APIType: uctypes.APIType_AnthropicMessages, + Model: "claude-sonnet-4-5", + APIVersion: AnthropicDefaultAPIVersion, + } + msg := &anthropicChatMessage{ + MessageId: "m1", + Role: "assistant", + Content: []anthropicMessageContentBlock{ + {Type: "text", Text: "start"}, + {Type: "tool_use", ID: "call-1", Name: "read_file", Input: map[string]interface{}{"path": "/tmp/a"}}, + }, + } + if err := chatstore.DefaultChatStore.PostMessage(chatID, aiOpts, msg); err != nil { + t.Fatalf("failed to seed chat: %v", err) + } + + newData := uctypes.UIMessageDataToolUse{ToolCallId: "call-1", ToolName: "read_file", Status: uctypes.ToolUseStatusCompleted} + if err := UpdateToolUseData(chatID, "call-1", newData); err != nil { + t.Fatalf("update failed: %v", err) + } + + chat := chatstore.DefaultChatStore.Get(chatID) + updated := chat.NativeMessages[0].(*anthropicChatMessage) + if updated.Content[1].ToolUseData == nil || updated.Content[1].ToolUseData.Status != uctypes.ToolUseStatusCompleted { + t.Fatalf("tool use data not updated") + } + + if err := RemoveToolUseCall(chatID, "call-1"); err != nil { + t.Fatalf("remove failed: %v", err) + } + chat = chatstore.DefaultChatStore.Get(chatID) + updated = chat.NativeMessages[0].(*anthropicChatMessage) + if len(updated.Content) != 1 || updated.Content[0].Type != "text" { + t.Fatalf("expected tool_use block removed, got %#v", updated.Content) + } +} + +func TestConvertToUIMessageIncludesToolUseData(t *testing.T) { + msg := &anthropicChatMessage{ + MessageId: "m1", + Role: "assistant", + Content: []anthropicMessageContentBlock{ + { + Type: "tool_use", + ID: "call-1", + Name: "read_file", + Input: map[string]interface{}{"path": "/tmp/a"}, + ToolUseData: &uctypes.UIMessageDataToolUse{ToolCallId: "call-1", ToolName: "read_file", Status: uctypes.ToolUseStatusPending}, + }, + }, + } + ui := msg.ConvertToUIMessage() + if ui == nil || len(ui.Parts) != 2 { + t.Fatalf("expected tool and data-tooluse parts, got %#v", ui) + } + if ui.Parts[0].Type != "tool-read_file" || ui.Parts[1].Type != "data-tooluse" { + t.Fatalf("unexpected part types: %#v", ui.Parts) + } +} diff --git a/pkg/aiusechat/anthropic/anthropic-convertmessage.go b/pkg/aiusechat/anthropic/anthropic-convertmessage.go index 7fec54b1ab..552cc8080c 100644 --- a/pkg/aiusechat/anthropic/anthropic-convertmessage.go +++ b/pkg/aiusechat/anthropic/anthropic-convertmessage.go @@ -13,10 +13,13 @@ import ( "log" "net/http" "regexp" + "slices" "strings" "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" + "github.com/wavetermdev/waveterm/pkg/util/logutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavebase" ) @@ -119,24 +122,23 @@ func buildAnthropicHTTPRequest(ctx context.Context, msgs []anthropicInputMessage reqBody.System = systemBlocks } - if len(chatOpts.Tools) > 0 { - cleanedTools := make([]uctypes.ToolDefinition, len(chatOpts.Tools)) - for i, tool := range chatOpts.Tools { - cleanedTools[i] = *tool.Clean() - } - reqBody.Tools = cleanedTools + for _, tool := range chatOpts.Tools { + cleanedTool := tool.Clean() + reqBody.Tools = append(reqBody.Tools, cleanedTool) } for _, tool := range chatOpts.TabTools { - cleanedTool := *tool.Clean() + cleanedTool := tool.Clean() reqBody.Tools = append(reqBody.Tools, cleanedTool) } + if chatOpts.AllowNativeWebSearch { + reqBody.Tools = append(reqBody.Tools, &anthropicWebSearchTool{Type: "web_search_20250305", Name: "web_search"}) + } // Enable extended thinking based on level reqBody.Thinking = makeThinkingOpts(opts.ThinkingLevel, maxTokens) // pretty print json of anthropicMsgs if jsonStr, err := utilfn.MarshalIndentNoHTMLString(convertedMsgs, "", " "); err == nil { - log.Printf("system-prompt: %v\n", chatOpts.SystemPrompt) var toolNames []string for _, tool := range chatOpts.Tools { toolNames = append(toolNames, tool.Name) @@ -144,9 +146,12 @@ func buildAnthropicHTTPRequest(ctx context.Context, msgs []anthropicInputMessage for _, tool := range chatOpts.TabTools { toolNames = append(toolNames, tool.Name) } - log.Printf("tools: %s\n", strings.Join(toolNames, ", ")) - log.Printf("anthropicMsgs JSON:\n%s", jsonStr) - log.Printf("has-api-key: %v\n", opts.APIToken != "") + if chatOpts.AllowNativeWebSearch { + toolNames = append(toolNames, "web_search[server]") + } + logutil.DevPrintf("tools: %s\n", strings.Join(toolNames, ", ")) + logutil.DevPrintf("anthropicMsgs JSON:\n%s", jsonStr) + logutil.DevPrintf("has-api-key: %v\n", opts.APIToken != "") } var buf bytes.Buffer @@ -698,6 +703,13 @@ func (m *anthropicChatMessage) ConvertToUIMessage() *uctypes.UIMessage { ToolCallID: block.ID, Input: block.Input, }) + if block.ToolUseData != nil { + parts = append(parts, uctypes.UIMessagePart{ + Type: "data-tooluse", + ID: block.ID, + Data: *block.ToolUseData, + }) + } } default: // For now, skip all other types (will implement later) @@ -827,3 +839,102 @@ func ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) { Messages: uiMessages, }, nil } + +func GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput { + for _, genMsg := range aiChat.NativeMessages { + chatMsg, ok := genMsg.(*anthropicChatMessage) + if !ok { + continue + } + for _, block := range chatMsg.Content { + if block.Type != "tool_use" || block.ID != toolCallId { + continue + } + argsInput := block.Input + if argsInput == nil { + argsInput = map[string]interface{}{} + } + argsBytes, err := json.Marshal(argsInput) + if err != nil { + continue + } + return &uctypes.AIFunctionCallInput{ + CallId: block.ID, + Name: block.Name, + Arguments: string(argsBytes), + ToolUseData: block.ToolUseData, + } + } + } + return nil +} + +func UpdateToolUseData(chatId string, toolCallId string, toolUseData uctypes.UIMessageDataToolUse) error { + chat := chatstore.DefaultChatStore.Get(chatId) + if chat == nil { + return fmt.Errorf("chat not found: %s", chatId) + } + for _, genMsg := range chat.NativeMessages { + chatMsg, ok := genMsg.(*anthropicChatMessage) + if !ok { + continue + } + for i, block := range chatMsg.Content { + if block.Type != "tool_use" || block.ID != toolCallId { + continue + } + updatedMsg := &anthropicChatMessage{ + MessageId: chatMsg.MessageId, + Usage: chatMsg.Usage, + Role: chatMsg.Role, + Content: slices.Clone(chatMsg.Content), + } + updatedMsg.Content[i].ToolUseData = &toolUseData + aiOpts := &uctypes.AIOptsType{ + APIType: chat.APIType, + Model: chat.Model, + APIVersion: chat.APIVersion, + } + return chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, updatedMsg) + } + } + return fmt.Errorf("tool call with ID %s not found in chat %s", toolCallId, chatId) +} + +func RemoveToolUseCall(chatId string, toolCallId string) error { + chat := chatstore.DefaultChatStore.Get(chatId) + if chat == nil { + return fmt.Errorf("chat not found: %s", chatId) + } + for _, genMsg := range chat.NativeMessages { + chatMsg, ok := genMsg.(*anthropicChatMessage) + if !ok { + continue + } + for i, block := range chatMsg.Content { + if block.Type != "tool_use" || block.ID != toolCallId { + continue + } + updatedMsg := &anthropicChatMessage{ + MessageId: chatMsg.MessageId, + Usage: chatMsg.Usage, + Role: chatMsg.Role, + Content: slices.Delete(slices.Clone(chatMsg.Content), i, i+1), + } + if len(updatedMsg.Content) == 0 { + chatstore.DefaultChatStore.RemoveMessage(chatId, chatMsg.MessageId) + } else { + aiOpts := &uctypes.AIOptsType{ + APIType: chat.APIType, + Model: chat.Model, + APIVersion: chat.APIVersion, + } + if err := chatstore.DefaultChatStore.PostMessage(chatId, aiOpts, updatedMsg); err != nil { + return err + } + } + return nil + } + } + return nil +} diff --git a/pkg/aiusechat/usechat-backend.go b/pkg/aiusechat/usechat-backend.go index cb380a457c..37e2f432ec 100644 --- a/pkg/aiusechat/usechat-backend.go +++ b/pkg/aiusechat/usechat-backend.go @@ -186,15 +186,18 @@ func (b *anthropicBackend) RunChatStep( cont *uctypes.WaveContinueResponse, ) (*uctypes.WaveStopReason, []uctypes.GenAIMessage, *uctypes.RateLimitInfo, error) { stopReason, msg, rateLimitInfo, err := anthropic.RunAnthropicChatStep(ctx, sseHandler, chatOpts, cont) + if msg == nil { + return stopReason, nil, rateLimitInfo, err + } return stopReason, []uctypes.GenAIMessage{msg}, rateLimitInfo, err } func (b *anthropicBackend) UpdateToolUseData(chatId string, toolCallId string, toolUseData uctypes.UIMessageDataToolUse) error { - return fmt.Errorf("UpdateToolUseData not implemented for anthropic backend") + return anthropic.UpdateToolUseData(chatId, toolCallId, toolUseData) } func (b *anthropicBackend) RemoveToolUseCall(chatId string, toolCallId string) error { - return fmt.Errorf("RemoveToolUseCall not implemented for anthropic backend") + return anthropic.RemoveToolUseCall(chatId, toolCallId) } func (b *anthropicBackend) ConvertToolResultsToNativeChatMessage(toolResults []uctypes.AIToolResult) ([]uctypes.GenAIMessage, error) { @@ -210,7 +213,7 @@ func (b *anthropicBackend) ConvertAIMessageToNativeChatMessage(message uctypes.A } func (b *anthropicBackend) GetFunctionCallInputByToolCallId(aiChat uctypes.AIChat, toolCallId string) *uctypes.AIFunctionCallInput { - return nil + return anthropic.GetFunctionCallInputByToolCallId(aiChat, toolCallId) } func (b *anthropicBackend) ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) { diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index 8399340cfe..873732de9d 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -121,6 +121,7 @@ const ( MetaKey_TermConnDebug = "term:conndebug" MetaKey_TermBellSound = "term:bellsound" MetaKey_TermBellIndicator = "term:bellindicator" + MetaKey_TermOsc52 = "term:osc52" MetaKey_TermDurable = "term:durable" MetaKey_WebZoom = "web:zoom" diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index da73892365..8cd5ed1d9b 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -125,6 +125,7 @@ type MetaTSType struct { TermConnDebug string `json:"term:conndebug,omitempty"` // null, info, debug TermBellSound *bool `json:"term:bellsound,omitempty"` TermBellIndicator *bool `json:"term:bellindicator,omitempty"` + TermOsc52 string `json:"term:osc52,omitempty"` TermDurable *bool `json:"term:durable,omitempty"` WebZoom float64 `json:"web:zoom,omitempty"` diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index aead19efbe..2de1974716 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -30,6 +30,7 @@ "telemetry:enabled": true, "term:bellsound": false, "term:bellindicator": false, + "term:osc52": "always", "term:cursor": "block", "term:cursorblink": false, "term:copyonselect": true, diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 52dfa4514c..e031a493ea 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -56,6 +56,7 @@ const ( ConfigKey_TermCursorBlink = "term:cursorblink" ConfigKey_TermBellSound = "term:bellsound" ConfigKey_TermBellIndicator = "term:bellindicator" + ConfigKey_TermOsc52 = "term:osc52" ConfigKey_TermDurable = "term:durable" ConfigKey_EditorMinimapEnabled = "editor:minimapenabled" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 387598e899..69c531eb77 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -107,6 +107,7 @@ type SettingsType struct { TermCursorBlink *bool `json:"term:cursorblink,omitempty"` TermBellSound *bool `json:"term:bellsound,omitempty"` TermBellIndicator *bool `json:"term:bellindicator,omitempty"` + TermOsc52 string `json:"term:osc52,omitempty" jsonschema:"enum=focus,enum=always"` TermDurable *bool `json:"term:durable,omitempty"` EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"` diff --git a/schema/settings.json b/schema/settings.json index ad4cd83155..d60367bea3 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -151,6 +151,13 @@ "term:bellindicator": { "type": "boolean" }, + "term:osc52": { + "type": "string", + "enum": [ + "focus", + "always" + ] + }, "term:durable": { "type": "boolean" }, diff --git a/tsunami/app/defaultclient.go b/tsunami/app/defaultclient.go index e033bfd2a6..1359f0f58b 100644 --- a/tsunami/app/defaultclient.go +++ b/tsunami/app/defaultclient.go @@ -64,6 +64,13 @@ func SendAsyncInitiation() error { return engine.GetDefaultClient().SendAsyncInitiation() } +func TermWrite(ref *vdom.VDomRef, data string) error { + if ref == nil || !ref.HasCurrent.Load() { + return nil + } + return engine.GetDefaultClient().SendTermWrite(ref.RefId, data) +} + func ConfigAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] { fullName := "$config." + name client := engine.GetDefaultClient() @@ -155,7 +162,7 @@ func DeepCopy[T any](v T) T { // If the ref is nil or not current, the operation is ignored. // This function must be called within a component context. func QueueRefOp(ref *vdom.VDomRef, op vdom.VDomRefOperation) { - if ref == nil || !ref.HasCurrent { + if ref == nil || !ref.HasCurrent.Load() { return } if op.RefId == "" { diff --git a/tsunami/app/hooks.go b/tsunami/app/hooks.go index 6b6ebbd36c..54418a00e0 100644 --- a/tsunami/app/hooks.go +++ b/tsunami/app/hooks.go @@ -31,6 +31,38 @@ func UseVDomRef() *vdom.VDomRef { return refVal } +// TermRef wraps a VDomRef and implements io.Writer by forwarding writes to the terminal. +type TermRef struct { + *vdom.VDomRef +} + +// Write implements io.Writer by sending data to the terminal via TermWrite. +func (tr *TermRef) Write(p []byte) (n int, err error) { + if tr.VDomRef == nil || !tr.VDomRef.HasCurrent.Load() { + return 0, fmt.Errorf("TermRef not current") + } + err = TermWrite(tr.VDomRef, string(p)) + if err != nil { + return 0, err + } + return len(p), nil +} + +// TermSize returns the current terminal size, or nil if not yet set. +func (tr *TermRef) TermSize() *vdom.VDomTermSize { + if tr.VDomRef == nil { + return nil + } + return tr.VDomRef.TermSize +} + +// UseTermRef returns a TermRef that can be passed as a ref to "wave:term" elements +// and also implements io.Writer for writing directly to the terminal. +func UseTermRef() *TermRef { + ref := UseVDomRef() + return &TermRef{VDomRef: ref} +} + // UseRef is the tsunami analog to React's useRef hook. // It provides a mutable ref object that persists across re-renders. // Unlike UseVDomRef, this is not tied to DOM elements but holds arbitrary values. diff --git a/tsunami/cmd/main-tsunami.go b/tsunami/cmd/main-tsunami.go index 6399aa6d52..f8b85f3e46 100644 --- a/tsunami/cmd/main-tsunami.go +++ b/tsunami/cmd/main-tsunami.go @@ -41,14 +41,22 @@ func validateEnvironmentVars(opts *build.BuildOpts) error { if scaffoldPath == "" { return fmt.Errorf("%s environment variable must be set", EnvTsunamiScaffoldPath) } + absScaffoldPath, err := filepath.Abs(scaffoldPath) + if err != nil { + return fmt.Errorf("failed to resolve %s to absolute path: %w", EnvTsunamiScaffoldPath, err) + } sdkReplacePath := os.Getenv(EnvTsunamiSdkReplacePath) if sdkReplacePath == "" { return fmt.Errorf("%s environment variable must be set", EnvTsunamiSdkReplacePath) } + absSdkReplacePath, err := filepath.Abs(sdkReplacePath) + if err != nil { + return fmt.Errorf("failed to resolve %s to absolute path: %w", EnvTsunamiSdkReplacePath, err) + } - opts.ScaffoldPath = scaffoldPath - opts.SdkReplacePath = sdkReplacePath + opts.ScaffoldPath = absScaffoldPath + opts.SdkReplacePath = absSdkReplacePath // NodePath is optional if nodePath := os.Getenv(EnvTsunamiNodePath); nodePath != "" { diff --git a/tsunami/engine/clientimpl.go b/tsunami/engine/clientimpl.go index 79c760e98b..ac9cb29109 100644 --- a/tsunami/engine/clientimpl.go +++ b/tsunami/engine/clientimpl.go @@ -5,6 +5,7 @@ package engine import ( "context" + "encoding/base64" "encoding/json" "fmt" "io/fs" @@ -304,6 +305,18 @@ func (c *ClientImpl) SendAsyncInitiation() error { return c.SendSSEvent(ssEvent{Event: "asyncinitiation", Data: nil}) } +func (c *ClientImpl) SendTermWrite(refId string, data string) error { + payload := rpctypes.TermWritePacket{ + RefId: refId, + Data64: base64.StdEncoding.EncodeToString([]byte(data)), + } + jsonData, err := json.Marshal(payload) + if err != nil { + return err + } + return c.SendSSEvent(ssEvent{Event: "termwrite", Data: jsonData}) +} + func makeNullRendered() *rpctypes.RenderedElem { return &rpctypes.RenderedElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag} } diff --git a/tsunami/engine/render.go b/tsunami/engine/render.go index 29c39c9cb7..87750f8740 100644 --- a/tsunami/engine/render.go +++ b/tsunami/engine/render.go @@ -5,6 +5,7 @@ package engine import ( "fmt" + "log" "reflect" "unicode" @@ -247,12 +248,6 @@ func convertPropsToVDom(props map[string]any) map[string]any { vdomProps[k] = vdomFuncPtr continue } - if vdomRef, ok := v.(vdom.VDomRef); ok { - // ensure Type is set on all VDomRefs - vdomRef.Type = vdom.ObjectType_Ref - vdomProps[k] = vdomRef - continue - } if vdomRefPtr, ok := v.(*vdom.VDomRef); ok { if vdomRefPtr == nil { continue // handle typed-nil @@ -263,6 +258,10 @@ func convertPropsToVDom(props map[string]any) map[string]any { continue } val := reflect.ValueOf(v) + if val.Type() == reflect.TypeOf(vdom.VDomRef{}) { + log.Printf("warning: VDomRef passed as non-pointer for prop %q (VDomRef contains atomics and must be passed as *VDomRef); dropping prop\n", k) + continue + } if val.Kind() == reflect.Func { // convert go functions passed to event handlers to VDomFuncs vdomProps[k] = vdom.VDomFunc{Type: vdom.ObjectType_Func} diff --git a/tsunami/engine/rootelem.go b/tsunami/engine/rootelem.go index 1d8b93808e..787be044e0 100644 --- a/tsunami/engine/rootelem.go +++ b/tsunami/engine/rootelem.go @@ -443,9 +443,11 @@ func (r *RootElem) UpdateRef(updateRef rpctypes.VDomRefUpdate) { if !ok { return } - ref.HasCurrent = updateRef.HasCurrent + ref.HasCurrent.Store(updateRef.HasCurrent) ref.Position = updateRef.Position - r.addRenderWork(waveId) + if updateRef.TermSize != nil { + ref.TermSize = updateRef.TermSize + } } func (r *RootElem) QueueRefOp(op vdom.VDomRefOperation) { diff --git a/tsunami/engine/serverhandlers.go b/tsunami/engine/serverhandlers.go index 5c5325610c..1e7bc94fda 100644 --- a/tsunami/engine/serverhandlers.go +++ b/tsunami/engine/serverhandlers.go @@ -18,6 +18,7 @@ import ( "github.com/wavetermdev/waveterm/tsunami/rpctypes" "github.com/wavetermdev/waveterm/tsunami/util" + "github.com/wavetermdev/waveterm/tsunami/vdom" ) const SSEKeepAliveDuration = 5 * time.Second @@ -83,6 +84,7 @@ func (h *httpHandlers) registerHandlers(mux *http.ServeMux, opts handlerOpts) { mux.HandleFunc("/api/schemas", h.handleSchemas) mux.HandleFunc("/api/manifest", h.handleManifest(opts.ManifestFile)) mux.HandleFunc("/api/modalresult", h.handleModalResult) + mux.HandleFunc("/api/terminput", h.handleTermInput) mux.HandleFunc("/dyn/", h.handleDynContent) // Add handler for static files at /static/ path @@ -392,6 +394,48 @@ func (h *httpHandlers) handleModalResult(w http.ResponseWriter, r *http.Request) json.NewEncoder(w).Encode(map[string]any{"success": true}) } +func (h *httpHandlers) handleTermInput(w http.ResponseWriter, r *http.Request) { + defer func() { + panicErr := util.PanicHandler("handleTermInput", recover()) + if panicErr != nil { + http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) + } + }() + + setNoCacheHeaders(w) + + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest) + return + } + + var event vdom.VDomEvent + if err := json.Unmarshal(body, &event); err != nil { + http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest) + return + } + if strings.TrimSpace(event.WaveId) == "" { + http.Error(w, "waveid is required", http.StatusBadRequest) + return + } + if event.TermInput == nil { + http.Error(w, "terminput is required", http.StatusBadRequest) + return + } + + h.renderLock.Lock() + h.Client.Root.Event(event, h.Client.GlobalEventHandler) + h.renderLock.Unlock() + + w.WriteHeader(http.StatusNoContent) +} + func (h *httpHandlers) handleDynContent(w http.ResponseWriter, r *http.Request) { defer func() { panicErr := util.PanicHandler("handleDynContent", recover()) diff --git a/tsunami/frontend/src/element/tsunamiterm.tsx b/tsunami/frontend/src/element/tsunamiterm.tsx new file mode 100644 index 0000000000..603d4b1889 --- /dev/null +++ b/tsunami/frontend/src/element/tsunamiterm.tsx @@ -0,0 +1,157 @@ +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal } from "@xterm/xterm"; +import "@xterm/xterm/css/xterm.css"; +import * as React from "react"; + +import { base64ToArray } from "@/util/base64"; + +export type TsunamiTermElem = HTMLDivElement & { + __termWrite: (data64: string) => void; + __termFocus: () => void; + __termSize: () => VDomTermSize | null; +}; + +type TsunamiTermProps = React.HTMLAttributes & { + onData?: (data: string | null, termsize: VDomTermSize | null) => void; + termFontSize?: number; + termFontFamily?: string; + termScrollback?: number; +}; + +const TsunamiTerm = React.forwardRef(function TsunamiTerm(props, ref) { + const { onData, termFontSize, termFontFamily, termScrollback, ...outerProps } = props; + const outerRef = React.useRef(null); + const termRef = React.useRef(null); + const terminalRef = React.useRef(null); + const onDataRef = React.useRef(onData); + onDataRef.current = onData; + + const setOuterRef = React.useCallback( + (elem: TsunamiTermElem) => { + outerRef.current = elem; + if (elem != null) { + elem.__termWrite = (data64: string) => { + if (data64 == null || data64 === "") { + return; + } + try { + terminalRef.current?.write(base64ToArray(data64)); + } catch (error) { + console.error("Failed to write to terminal:", error); + } + }; + elem.__termFocus = () => { + terminalRef.current?.focus(); + }; + elem.__termSize = () => { + const terminal = terminalRef.current; + if (terminal == null) { + return null; + } + return { rows: terminal.rows, cols: terminal.cols }; + }; + } + if (typeof ref === "function") { + ref(elem); + return; + } + if (ref != null) { + ref.current = elem; + } + }, + [ref] + ); + + React.useEffect(() => { + if (termRef.current == null) { + return; + } + const terminal = new Terminal({ + convertEol: false, + ...(termFontSize != null ? { fontSize: termFontSize } : {}), + ...(termFontFamily != null ? { fontFamily: termFontFamily } : {}), + ...(termScrollback != null ? { scrollback: termScrollback } : {}), + }); + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + terminal.open(termRef.current); + fitAddon.fit(); + terminalRef.current = terminal; + + const onDataDisposable = terminal.onData((data) => { + if (onDataRef.current == null) { + return; + } + onDataRef.current(data, null); + }); + const onResizeDisposable = terminal.onResize((size) => { + if (onDataRef.current == null) { + return; + } + onDataRef.current(null, { rows: size.rows, cols: size.cols }); + }); + if (onDataRef.current != null) { + onDataRef.current(null, { rows: terminal.rows, cols: terminal.cols }); + } + + const resizeObserver = new ResizeObserver(() => { + fitAddon.fit(); + }); + if (outerRef.current != null) { + resizeObserver.observe(outerRef.current); + } + + return () => { + resizeObserver.disconnect(); + onResizeDisposable.dispose(); + onDataDisposable.dispose(); + terminal.dispose(); + terminalRef.current = null; + }; + }, []); + + React.useEffect(() => { + const terminal = terminalRef.current; + if (terminal == null) { + return; + } + if (termFontSize != null) { + terminal.options.fontSize = termFontSize; + } + if (termFontFamily != null) { + terminal.options.fontFamily = termFontFamily; + } + if (termScrollback != null) { + terminal.options.scrollback = termScrollback; + } + }, [termFontSize, termFontFamily, termScrollback]); + + const handleFocus = React.useCallback( + (e: React.FocusEvent) => { + terminalRef.current?.focus(); + outerProps.onFocus?.(e); + }, + [outerProps.onFocus] + ); + + const handleBlur = React.useCallback( + (e: React.FocusEvent) => { + terminalRef.current?.blur(); + outerProps.onBlur?.(e); + }, + [outerProps.onBlur] + ); + + return ( +
} + onFocus={handleFocus} + onBlur={handleBlur} + > +
+
+ ); +}); + +export { TsunamiTerm }; diff --git a/tsunami/frontend/src/model/model-utils.ts b/tsunami/frontend/src/model/model-utils.ts index 9ea4d92982..da14252a6e 100644 --- a/tsunami/frontend/src/model/model-utils.ts +++ b/tsunami/frontend/src/model/model-utils.ts @@ -1,6 +1,8 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import type { TsunamiTermElem } from "@/element/tsunamiterm"; + const TextTag = "#text"; // TODO support binding @@ -79,6 +81,22 @@ export function restoreVDomElems(backendUpdate: VDomBackendUpdate) { }); } +export function isTsunamiTermElem(elem: HTMLElement): elem is TsunamiTermElem { + return elem != null && typeof (elem as TsunamiTermElem).__termWrite === "function"; +} + +export function applyTermOp(elem: TsunamiTermElem, termOp: VDomRefOperation) { + const { op, params } = termOp; + if (op === "termwrite") { + const data64 = params?.[0]; + if (typeof data64 === "string" && data64 !== "") { + elem.__termWrite(data64); + } + } else if (op === "focus") { + elem.__termFocus(); + } +} + export function applyCanvasOp(canvas: HTMLCanvasElement, canvasOp: VDomRefOperation, refStore: Map) { const ctx = canvas.getContext("2d"); if (!ctx) { diff --git a/tsunami/frontend/src/model/tsunami-model.tsx b/tsunami/frontend/src/model/tsunami-model.tsx index 61857dbebe..cc83104b81 100644 --- a/tsunami/frontend/src/model/tsunami-model.tsx +++ b/tsunami/frontend/src/model/tsunami-model.tsx @@ -9,7 +9,7 @@ import { getOrCreateClientId } from "@/util/clientid"; import { adaptFromReactOrNativeKeyEvent } from "@/util/keyutil"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; import { getDefaultStore } from "jotai"; -import { applyCanvasOp, restoreVDomElems } from "./model-utils"; +import { applyCanvasOp, applyTermOp, isTsunamiTermElem, restoreVDomElems } from "./model-utils"; const dlog = debug("wave:vdom"); @@ -236,6 +236,25 @@ export class TsunamiModel { } }); + this.serverEventSource.addEventListener("termwrite", (event: MessageEvent) => { + try { + const packet = JSON.parse(event.data); + if (packet?.refid == null || packet?.data64 == null) { + return; + } + const refOp: VDomRefOperation = { refid: packet.refid, op: "termwrite", params: [packet.data64] }; + const elem = this.getRefElem(refOp.refid); + if (elem == null) { + return; + } + if (isTsunamiTermElem(elem)) { + applyTermOp(elem, refOp); + } + } catch (e) { + console.error("Failed to parse termwrite event:", e); + } + }); + this.serverEventSource.addEventListener("error", (event) => { console.error("SSE connection error:", event); }); @@ -319,6 +338,12 @@ export class TsunamiModel { boundingclientrect: ref.elem.getBoundingClientRect(), }; } + if (isTsunamiTermElem(ref.elem)) { + const termsize = ref.elem.__termSize(); + if (termsize != null) { + ru.termsize = termsize; + } + } updates.push(ru); ref.updated = false; } @@ -606,6 +631,10 @@ export class TsunamiModel { applyCanvasOp(elem, refOp, this.refOutputStore); continue; } + if (isTsunamiTermElem(elem)) { + applyTermOp(elem, refOp); + continue; + } if (refOp.op == "focus") { if (elem == null) { this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: elem is null`); @@ -718,8 +747,7 @@ export class TsunamiModel { vdomEvent.globaleventtype = fnDecl.globalevent; } const needsAsync = - propName == "onSubmit" || - (propName == "onChange" && (e.target as HTMLInputElement)?.type === "file"); + propName == "onSubmit" || (propName == "onChange" && (e.target as HTMLInputElement)?.type === "file"); if (needsAsync) { asyncAnnotateEvent(vdomEvent, propName, e) .then(() => { diff --git a/tsunami/frontend/src/types/vdom.d.ts b/tsunami/frontend/src/types/vdom.d.ts index 485ada680b..2ca0f73867 100644 --- a/tsunami/frontend/src/types/vdom.d.ts +++ b/tsunami/frontend/src/types/vdom.d.ts @@ -33,6 +33,18 @@ type VDomElem = { text?: string; }; +// vdom.VDomTermSize +type VDomTermSize = { + rows: number; + cols: number; +}; + +// vdom.VDomTermInputData +type VDomTermInputData = { + termsize?: VDomTermSize; + data?: string; +}; + // vdom.VDomEvent type VDomEvent = { waveid: string; @@ -46,6 +58,7 @@ type VDomEvent = { keydata?: VDomKeyboardEvent; mousedata?: VDomPointerData; formdata?: VDomFormData; + terminput?: VDomTermInputData; }; // vdom.VDomFrontendUpdate @@ -103,7 +116,6 @@ type VDomRef = { type: "ref"; refid: string; trackposition?: boolean; - position?: VDomRefPosition; hascurrent?: boolean; }; @@ -130,6 +142,7 @@ type VDomRefUpdate = { refid: string; hascurrent: boolean; position?: VDomRefPosition; + termsize?: VDomTermSize; }; // rpctypes.VDomRenderContext diff --git a/tsunami/frontend/src/vdom.tsx b/tsunami/frontend/src/vdom.tsx index 37de4c0f1c..a51e119193 100644 --- a/tsunami/frontend/src/vdom.tsx +++ b/tsunami/frontend/src/vdom.tsx @@ -7,8 +7,9 @@ import * as jotai from "jotai"; import * as React from "react"; import { twMerge } from "tailwind-merge"; -import { AlertModal, ConfirmModal } from "@/element/modals"; import { Markdown } from "@/element/markdown"; +import { AlertModal, ConfirmModal } from "@/element/modals"; +import { TsunamiTerm } from "@/element/tsunamiterm"; import { getTextChildren } from "@/model/model-utils"; import type { TsunamiModel } from "@/model/tsunami-model"; import { RechartsTag } from "@/recharts/recharts"; @@ -30,6 +31,7 @@ type VDomReactTagType = (props: { elem: VDomElem; model: TsunamiModel }) => Reac const WaveTagMap: Record = { "wave:markdown": WaveMarkdown, + "wave:term": WaveTerm, }; const AllowedSimpleTags: { [tagName: string]: boolean } = { @@ -278,6 +280,46 @@ function WaveMarkdown({ elem, model }: { elem: VDomElem; model: TsunamiModel }) ); } +async function sendTermInputEvent(event: VDomEvent) { + const response = await fetch("/api/terminput", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(event), + }); + if (!response.ok) { + throw new Error(`terminal input request failed: ${response.status} ${response.statusText}`); + } +} + +function WaveTerm({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { + const props = useVDom(model, elem); + const hasOnData = props.onData != null; + const onData = React.useCallback( + (data: string | null, termsize: VDomTermSize | null) => { + const terminput: VDomTermInputData = {}; + if (data != null) { + terminput.data = data; + } + if (termsize != null) { + terminput.termsize = termsize; + } + const event: VDomEvent = { + waveid: elem.waveid, + eventtype: "onData", + terminput: terminput, + }; + sendTermInputEvent(event).catch((error) => { + console.error("Failed to send terminal input:", error); + }); + }, + [elem.waveid] + ); + const termProps = { ...props, onData: hasOnData ? onData : undefined }; + return ; +} + function StyleTag({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { const styleText = getTextChildren(elem); if (styleText == null) { diff --git a/tsunami/rpctypes/protocoltypes.go b/tsunami/rpctypes/protocoltypes.go index f2728f0bb6..bad88a8745 100644 --- a/tsunami/rpctypes/protocoltypes.go +++ b/tsunami/rpctypes/protocoltypes.go @@ -166,6 +166,7 @@ type VDomRefUpdate struct { RefId string `json:"refid"` HasCurrent bool `json:"hascurrent"` Position *vdom.VDomRefPosition `json:"position,omitempty"` + TermSize *vdom.VDomTermSize `json:"termsize,omitempty"` } type VDomBackendOpts struct { @@ -206,3 +207,8 @@ type ModalResult struct { ModalId string `json:"modalid"` // ID of the modal Confirm bool `json:"confirm"` // true = confirmed/ok, false = cancelled } + +type TermWritePacket struct { + RefId string `json:"refid"` + Data64 string `json:"data64"` +} diff --git a/tsunami/templates/package.json.tmpl b/tsunami/templates/package.json.tmpl index a214510649..c8d88dae83 100644 --- a/tsunami/templates/package.json.tmpl +++ b/tsunami/templates/package.json.tmpl @@ -10,7 +10,7 @@ "email": "info@commandline.dev" }, "dependencies": { - "@tailwindcss/cli": "^4.1.13", - "tailwindcss": "^4.1.13" + "@tailwindcss/cli": "^4.2.1", + "tailwindcss": "^4.2.1" } } diff --git a/tsunami/vdom/vdom_types.go b/tsunami/vdom/vdom_types.go index d20a02ac3d..58725e4010 100644 --- a/tsunami/vdom/vdom_types.go +++ b/tsunami/vdom/vdom_types.go @@ -1,8 +1,13 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package vdom +import ( + "encoding/json" + "sync/atomic" +) + const TextTag = "#text" const WaveTextTag = "wave:text" const WaveNullTag = "wave:null" @@ -36,8 +41,42 @@ type VDomRef struct { Type string `json:"type" tstype:"\"ref\""` RefId string `json:"refid"` TrackPosition bool `json:"trackposition,omitempty"` - Position *VDomRefPosition `json:"position,omitempty"` - HasCurrent bool `json:"hascurrent,omitempty"` + Position *VDomRefPosition `json:"-"` + HasCurrent atomic.Bool `json:"-"` + TermSize *VDomTermSize `json:"-"` +} + +func (r *VDomRef) MarshalJSON() ([]byte, error) { + type vdomRefAlias struct { + Type string `json:"type"` + RefId string `json:"refid"` + TrackPosition bool `json:"trackposition,omitempty"` + HasCurrent bool `json:"hascurrent,omitempty"` + } + return json.Marshal(vdomRefAlias{ + Type: r.Type, + RefId: r.RefId, + TrackPosition: r.TrackPosition, + HasCurrent: r.HasCurrent.Load(), + }) +} + +func (r *VDomRef) UnmarshalJSON(data []byte) error { + type vdomRefAlias struct { + Type string `json:"type"` + RefId string `json:"refid"` + TrackPosition bool `json:"trackposition,omitempty"` + HasCurrent bool `json:"hascurrent,omitempty"` + } + var alias vdomRefAlias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + r.Type = alias.Type + r.RefId = alias.RefId + r.TrackPosition = alias.TrackPosition + r.HasCurrent.Store(alias.HasCurrent) + return nil } type VDomSimpleRef[T any] struct { @@ -62,18 +101,29 @@ type VDomRefPosition struct { BoundingClientRect DomRect `json:"boundingclientrect"` } +type VDomTermInputData struct { + TermSize *VDomTermSize `json:"termsize,omitempty"` + Data string `json:"data,omitempty"` +} + +type VDomTermSize struct { + Rows int `json:"rows"` + Cols int `json:"cols"` +} + type VDomEvent struct { WaveId string `json:"waveid"` EventType string `json:"eventtype"` // usually the prop name (e.g. onClick, onKeyDown) GlobalEventType string `json:"globaleventtype,omitempty"` - TargetValue string `json:"targetvalue,omitempty"` // set for onChange events on input/textarea/select + TargetValue string `json:"targetvalue,omitempty"` // set for onChange events on input/textarea/select TargetChecked bool `json:"targetchecked,omitempty"` // set for onChange events on checkbox/radio inputs - TargetName string `json:"targetname,omitempty"` // target element's name attribute - TargetId string `json:"targetid,omitempty"` // target element's id attribute - TargetFiles []VDomFileData `json:"targetfiles,omitempty"` // set for onChange events on file inputs - KeyData *VDomKeyboardEvent `json:"keydata,omitempty"` // set for onKeyDown events - MouseData *VDomPointerData `json:"mousedata,omitempty"` // set for onClick, onMouseDown, onMouseUp, onDoubleClick events - FormData *VDomFormData `json:"formdata,omitempty"` // set for onSubmit events on forms + TargetName string `json:"targetname,omitempty"` // target element's name attribute + TargetId string `json:"targetid,omitempty"` // target element's id attribute + TargetFiles []VDomFileData `json:"targetfiles,omitempty"` // set for onChange events on file inputs + KeyData *VDomKeyboardEvent `json:"keydata,omitempty"` // set for onKeyDown events + MouseData *VDomPointerData `json:"mousedata,omitempty"` // set for onClick, onMouseDown, onMouseUp, onDoubleClick events + FormData *VDomFormData `json:"formdata,omitempty"` // set for onSubmit events on forms + TermInput *VDomTermInputData `json:"terminput,omitempty"` // set for onData events on wave:term elements } type VDomKeyboardEvent struct { @@ -115,13 +165,13 @@ type VDomPointerData struct { } type VDomFormData struct { - Action string `json:"action,omitempty"` - Method string `json:"method"` - Enctype string `json:"enctype"` - FormId string `json:"formid,omitempty"` - FormName string `json:"formname,omitempty"` - Fields map[string][]string `json:"fields"` - Files map[string][]VDomFileData `json:"files"` + Action string `json:"action,omitempty"` + Method string `json:"method"` + Enctype string `json:"enctype"` + FormId string `json:"formid,omitempty"` + FormName string `json:"formname,omitempty"` + Fields map[string][]string `json:"fields"` + Files map[string][]VDomFileData `json:"files"` } func (f *VDomFormData) GetField(fieldName string) string {