From f4acfc945664478fb6b5a4edbb754a89b85d2634 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 18:25:42 -0800 Subject: [PATCH 01/18] Add virtualized flat-list TreeView component and preview sandbox (#2972) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces a new frontend TreeView widget intended for VSCode-style explorer use cases, without backend wiring yet. It provides a reusable, backend-agnostic API with virtualization, flat visible-row projection, and preview coverage under `frontend/preview`. - **What this adds** - New `TreeView` component in `frontend/app/treeview/treeview.tsx` designed around: - flat `visibleRows` projection with `depth` (not recursive render) - TanStack Virtual row virtualization - responsive width constraints + horizontal/vertical scrolling - single-selection, expand/collapse, and basic keyboard navigation - New preview: `frontend/preview/previews/treeview.preview.tsx` with async mocked directory loading and width controls. - Focused tests: `frontend/app/treeview/treeview.test.ts` for projection/sorting/synthetic-row behavior. - **Tree model + projection behavior** - Defines a canonical `TreeNodeData` wrapper (separate from direct `FileInfo` coupling) with: - `id`, `parentId`, `isDirectory`, `mimeType`, flags, `childrenStatus`, `childrenIds`, `capInfo` - Builds `visibleRows` from `nodesById + expandedIds` and injects synthetic rows for: - `loading` - `error` - `capped` (“Showing first N entries”) - **Interaction model implemented** - Click: select row - Double-click directory (or chevron click): expand/collapse - Double-click file: emits `onOpenFile` - Keyboard: - Up/Down: move visible selection - Left: collapse selected dir or move to parent - Right: expand selected dir or move to first child - **Sorting + icon strategy** - Child sorting is deterministic and stable: - directories first - case-insensitive label order - id/path tie-breaker - Icon resolution supports directory/file/error states and simple mimetype/extension fallbacks. - **Example usage** ```tsx ({ nodes: data[id].slice(0, limit), capped: data[id].length > limit })} maxDirEntries={120} minWidth={100} maxWidth={400} height={420} onSelectionChange={(id) => setSelection(id)} /> ``` - **** - https://github.com/user-attachments/assets/6f8b8a2a-f9a1-454d-bf4f-1d4a97b6e123 --- 💬 We'd love your input! Share your thoughts on Copilot coding agent in our [2 minute survey](https://gh.io/copilot-coding-agent-survey). --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- frontend/app/treeview/treeview.test.ts | 46 ++ frontend/app/treeview/treeview.tsx | 522 ++++++++++++++++++ .../preview/previews/treeview.preview.tsx | 97 ++++ package-lock.json | 32 +- package.json | 1 + 5 files changed, 696 insertions(+), 2 deletions(-) create mode 100644 frontend/app/treeview/treeview.test.ts create mode 100644 frontend/app/treeview/treeview.tsx create mode 100644 frontend/preview/previews/treeview.preview.tsx 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/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/package-lock.json b/package-lock.json index 269181d32a..2c6a94218c 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", @@ -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", diff --git a/package.json b/package.json index b21f8eee1a..cd75ae81d2 100644 --- a/package.json +++ b/package.json @@ -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", From ceca94bc3c1b8719d3d7ca56e86e925528f67e0f Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 4 Mar 2026 13:05:40 -0800 Subject: [PATCH 02/18] add funding.yml file (#2977) --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml 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 From c65147da1b249194fb0b8ac379872a85f263174e Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 4 Mar 2026 13:12:55 -0800 Subject: [PATCH 03/18] add sponsor section to readme (#2978) --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) 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). From 73f77152454c1cc0d1c0ee4caabd86d7aa303c6d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:17:35 -0800 Subject: [PATCH 04/18] Bump immutable from 5.1.3 to 5.1.5 (#2980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [immutable](https://github.com/immutable-js/immutable-js) from 5.1.3 to 5.1.5.
Release notes

Sourced from immutable's releases.

v5.1.5

What's Changed

Full Changelog: https://github.com/immutable-js/immutable-js/compare/v5.1.4...v5.1.5

v5.1.4

What's Changed

Documentation

Internal

New Contributors

Full Changelog: https://github.com/immutable-js/immutable-js/compare/v5.1.3...v5.1.4

Changelog

Sourced from immutable's changelog.

5.1.5

  • Fix Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution') in immutable

5.1.4

Documentation

Internal

Commits
  • b37b855 5.1.5
  • 16b3313 Merge commit from fork
  • fd2ef49 fix new proto key injection
  • 6734b7b fix Prototype Pollution in mergeDeep, toJS, etc.
  • 6f772de Merge pull request #2175 from immutable-js/dependabot/npm_and_yarn/rollup-4.59.0
  • 5f3dc61 Bump rollup from 4.34.8 to 4.59.0
  • 049a594 Merge pull request #2173 from immutable-js/dependabot/npm_and_yarn/lodash-4.1...
  • 2481a77 Merge pull request #2172 from mrazauskas/update-tstyche
  • eb04779 Bump lodash from 4.17.21 to 4.17.23
  • b973bf3 format
  • Additional commits viewable in compare view
Maintainer changes

This version was pushed to npm by [GitHub Actions](https://www.npmjs.com/~GitHub Actions), a new releaser for immutable since your current version.


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=immutable&package-manager=npm_and_yarn&previous-version=5.1.3&new-version=5.1.5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/wavetermdev/waveterm/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2c6a94218c..bdee147e59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18116,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": { From 9d4acb7aad873947e6a3c482a777ea68d85be7fe Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:18:29 -0800 Subject: [PATCH 05/18] Add builder-launch strip to AppsFloatingWindow (#2979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppsFloatingWindow currently lists local apps but does not provide an in-context way to open the app builder picker. This change adds a bottom ghost-style action strip (`+ Build/Edit Apps`) that launches the builder via Electron preload with `openBuilder(null)`. - **What changed** - Added a new bottom strip action inside `AppsFloatingWindow`: - Label: `+ Build/Edit Apps` - Visual style: ghost-like footer strip with top border, hover state, full-width click target - Wired the strip to call the preload API and close the floating window: - `getApi().openBuilder(null)` (`null` app id opens the app picker) - Kept the change scoped to `frontend/app/workspace/widgets.tsx` with no behavior changes to app-grid item launching. - **Implementation detail** - Imported `getApi` from `@/store/global` - Added a memoized handler for builder launch: ```tsx const handleOpenBuilder = useCallback(() => { getApi().openBuilder(null); onClose(); }, [onClose]); ``` - **UI preview** - https://github.com/user-attachments/assets/1448588f-ff1d-41b5-af72-2849135ca1f3 --- ✨ Let Copilot coding agent [set things up for you](https://github.com/wavetermdev/waveterm/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> Co-authored-by: sawka --- frontend/app/workspace/widgets.tsx | 110 ++++++++++++++++------------- 1 file changed, 62 insertions(+), 48 deletions(-) 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} +
-
- ); - })} -
- )} + ); + })} +
+ )} +
+
); From edc20f7ec0fa2a26a35b73b92078b1cff9933c85 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:20:50 -0800 Subject: [PATCH 06/18] Bring Anthropic usechat backend to OpenAI-level tool-use parity and stream robustness (#2971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This updates `pkg/aiusechat/anthropic` from partial implementation to full backend parity for core tool-use orchestration and stream behavior. The main gaps were unimplemented tool lifecycle methods, missing persisted tool-use UI state, and weaker disconnect/error handling versus the OpenAI backend. - **Tool-use lifecycle parity (critical path)** - Implemented Anthropic backend support for: - `UpdateToolUseData` - `RemoveToolUseCall` - `GetFunctionCallInputByToolCallId` - Wired `pkg/aiusechat/usechat-backend.go` to call Anthropic implementations instead of stubs. - Added Anthropic run-step nil-message guard so `nil` responses are not wrapped into `[]GenAIMessage{nil}`. - **Persisted tool-use state in Anthropic native messages** - Added internal `ToolUseData` storage on Anthropic `tool_use` blocks. - Ensured internal-only fields are stripped before API requests via `Clean()`. - **UI conversion parity for reloaded history** - Extended `ConvertToUIMessage()` to emit `data-tooluse` parts when tool-use metadata exists, in addition to `tool-{name}` parts. - **Streaming UX parity for tool argument deltas** - Added `aiutil.SendToolProgress(...)` calls during: - `input_json_delta` (incremental updates) - `content_block_stop` for `tool_use` (final update) - **Disconnect/stream robustness** - Added `sse.Err()` checks in event handling and decode-error path. - Added partial-text extraction on client disconnect and deterministic ordering of partial blocks. - Cleans up completed blocks from in-flight state to avoid duplicate partial extraction. - **Correctness + hygiene alignment** - Continuation model checks now use `AreModelsCompatible(...)` (instead of strict string equality). - Added hostname sanitization in Anthropic error paths (HTTP error parsing and `httpClient.Do` failures). - Replaced unconditional Anthropic debug `log.Printf` calls with `logutil.DevPrintf`. - **Targeted coverage additions** - Added Anthropic tests for: - function-call lookup by tool call id - tool-use data update + removal - `data-tooluse` UI conversion behavior ```go // usechat-backend.go func (b *anthropicBackend) RunChatStep(...) (..., []uctypes.GenAIMessage, ...) { 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 } ``` --- ✨ Let Copilot coding agent [set things up for you](https://github.com/wavetermdev/waveterm/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> Co-authored-by: sawka --- frontend/app/aipanel/aipanel.tsx | 2 +- pkg/aiusechat/anthropic/anthropic-backend.go | 137 ++++++++++++++---- .../anthropic/anthropic-backend_test.go | 95 ++++++++++++ .../anthropic/anthropic-convertmessage.go | 133 +++++++++++++++-- pkg/aiusechat/usechat-backend.go | 9 +- 5 files changed, 335 insertions(+), 41 deletions(-) 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/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) { From 7f5487d9accfdfd02a168797350c5c799e55820c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:24:42 -0800 Subject: [PATCH 07/18] Remove mock-heavy OSC52 Vitest coverage (#2975) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue called out that the OSC52 unit tests were mostly validating mock setup rather than meaningful behavior. This PR trims that low-signal coverage by removing the Vitest suite for OSC52. - **Scope** - Deleted `frontend/app/view/term/osc-handlers.test.ts`. - No production/runtime code changes. - **Rationale reflected in changes** - Removes brittle, mock-dominant tests for logic considered too simple for this unit-test shape. - Keeps the codebase focused on higher-value test coverage. ```diff - frontend/app/view/term/osc-handlers.test.ts ``` --- ✨ Let Copilot coding agent [set things up for you](https://github.com/wavetermdev/waveterm/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- docs/docs/config.mdx | 2 ++ frontend/app/view/term/osc-handlers.ts | 21 ++++++++++++++++----- frontend/types/gotypes.d.ts | 2 ++ pkg/waveobj/metaconsts.go | 1 + pkg/waveobj/wtypemeta.go | 1 + pkg/wconfig/defaultconfig/settings.json | 1 + pkg/wconfig/metaconsts.go | 1 + pkg/wconfig/settingsconfig.go | 1 + schema/settings.json | 7 +++++++ 9 files changed, 32 insertions(+), 5 deletions(-) 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/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/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/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" }, From 0ab26ef947358b0d720fa17b03ed21ab2fef7904 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:25:53 -0800 Subject: [PATCH 08/18] Add a mousedown handler to also signal user activity in the app (#2976) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mousedown` activity signaling was structured such that async telemetry concerns leaked into event handling. This change moves fire-and-forget behavior to the model boundary and keeps telemetry failures non-fatal. - **`mousedown` handler path** - `AppKeyHandlers` now calls `GlobalModel.getInstance().setIsActive()` directly (no async wrapper in the handler). - **`GlobalModel.setIsActive` structure** - `setIsActive()` is now synchronous (`void`). - Throttle logic remains unchanged. - Electron telemetry call is executed via `util.fireAndForget(...)` inside `setIsActive()`. - **Telemetry error containment** - `getApi().setIsActive()` is wrapped in `try/catch` inside the fire-and-forget callback. - Errors are logged with `console.log("setIsActive error", e)` and do not bubble. - **Focused coverage** - Added `frontend/app/store/global-model.test.ts` for: - fire-and-forget invocation + throttling behavior - error logging/swallowing on rejected telemetry call ```ts setIsActive(): void { const now = Date.now(); if (now - this.lastSetIsActiveTs < GlobalModel.IsActiveThrottleMs) { return; } this.lastSetIsActiveTs = now; util.fireAndForget(async () => { try { await getApi().setIsActive(); } catch (e) { console.log("setIsActive error", e); } }); } ``` --- 🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. [Learn more about Advanced Security.](https://gh.io/cca-advanced-security) --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> Co-authored-by: sawka --- emain/emain-ipc.ts | 5 +++++ emain/preload.ts | 1 + frontend/app/app.tsx | 8 ++++++-- frontend/app/store/global-model.ts | 15 ++++++++++++++- frontend/types/custom.d.ts | 1 + 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index aaf3736431..72498f15c1 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -17,6 +17,7 @@ import { incrementTermCommandsRemote, incrementTermCommandsRun, incrementTermCommandsWsl, + setWasActive, } from "./emain-activity"; import { createBuilderWindow, getAllBuilderWindows, getBuilderWindowByWebContentsId } from "./emain-builder"; import { callWithOriginalXdgCurrentDesktopAsync, unamePlatform } from "./emain-platform"; @@ -317,6 +318,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; 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/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/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 = { From 1a1cd853f888c85287fd8627d2ea2f2ce6af681a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:32:01 -0800 Subject: [PATCH 09/18] Add `wave:term` component with direct SSE output + `/api/terminput` input path (#2974) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces a standalone Tsunami terminal element (`wave:term`) and routes terminal IO outside the normal render/event loop for lower-latency streaming. It adds imperative terminal output (`TermWrite`) over SSE and terminal input/resize delivery over a dedicated `/api/terminput` endpoint. - **Frontend: new `wave:term` element** - Added `tsunami/frontend/src/element/tsunamiterm.tsx`. - Uses `@xterm/xterm` with `@xterm/addon-fit`. - Renders as an outer `
` (style/class/ref target), with xterm auto-fit to that container. - Supports ref passthrough on the outer element. - **Frontend: terminal transport wiring** - Registered `wave:term` in `tsunami/frontend/src/vdom.tsx`. - Added SSE listener handling for `termwrite` in `tsunami/frontend/src/model/tsunami-model.tsx`, dispatched to the terminal component via a local custom event. - `onData` and `onResize` now POST directly to `/api/terminput` as JSON payloads: - `id` - `data64` (base64 terminal input) - `termsize` (`rows`, `cols`) for resize updates - **Backend: new terminal IO APIs** - Added `/api/terminput` handler in `tsunami/engine/serverhandlers.go`. - Added protocol types in `tsunami/rpctypes/protocoltypes.go`: - `TermInputPacket`, `TermWritePacket`, `TermSize` - Added engine/client support in `tsunami/engine/clientimpl.go`: - `SendTermWrite(id, data64)` -> emits SSE event `termwrite` - `SetTermInputHandler(...)` and `HandleTermInput(...)` - Exposed app-level APIs in `tsunami/app/defaultclient.go`: - `TermWrite(id, data64) error` - `SetTermInputHandler(func(TermInputPacket))` - **Example usage** ```go app.SetTermInputHandler(func(input app.TermInputPacket) { // input.Id, input.Data64, input.TermSize.Rows/Cols }) _ = app.TermWrite("term1", "SGVsbG8gZnJvbSB0aGUgYmFja2VuZA0K") ``` - **** - Provided screenshot URL: https://github.com/user-attachments/assets/58c92ebb-0a52-43d2-b577-17c9cf92a19c --- 💬 We'd love your input! Share your thoughts on Copilot coding agent in our [2 minute survey](https://gh.io/copilot-coding-agent-survey). --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> Co-authored-by: sawka --- tsunami/app/defaultclient.go | 9 +- tsunami/app/hooks.go | 32 ++++ tsunami/cmd/main-tsunami.go | 12 +- tsunami/engine/clientimpl.go | 13 ++ tsunami/engine/render.go | 11 +- tsunami/engine/rootelem.go | 6 +- tsunami/engine/serverhandlers.go | 44 ++++++ tsunami/frontend/src/element/tsunamiterm.tsx | 157 +++++++++++++++++++ tsunami/frontend/src/model/model-utils.ts | 18 +++ tsunami/frontend/src/model/tsunami-model.tsx | 34 +++- tsunami/frontend/src/types/vdom.d.ts | 15 +- tsunami/frontend/src/vdom.tsx | 44 +++++- tsunami/rpctypes/protocoltypes.go | 6 + tsunami/templates/package.json.tmpl | 4 +- tsunami/vdom/vdom_types.go | 84 ++++++++-- 15 files changed, 454 insertions(+), 35 deletions(-) create mode 100644 tsunami/frontend/src/element/tsunamiterm.tsx 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 { From f59fdb55db98ce70d3303eb7668c3dd6c98ac801 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Thu, 5 Mar 2026 11:33:56 -0800 Subject: [PATCH 10/18] Fix focusedWaveWindow (also fixes bug w/ save session as....) (#2987) --- emain/emain-ipc.ts | 22 +++++++++++++++++----- emain/emain-window.ts | 1 + 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index 72498f15c1..1d1ec2108a 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -24,7 +24,7 @@ import { callWithOriginalXdgCurrentDesktopAsync, unamePlatform } from "./emain-p 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; @@ -130,12 +130,18 @@ function getUrlInSession(session: Electron.Session, url: string): Promise { if (file.canceled) { + readStream.destroy(); return; } const writeStream = fs.createWriteStream(file.filePath); @@ -213,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); @@ -477,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-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); From aefd4dacc592b3a4eab8f5caee6fd083a582b6fb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:18:33 -0800 Subject: [PATCH 11/18] Bump globals from 17.3.0 to 17.4.0 in the dev-dependencies-minor group (#2996) Bumps the dev-dependencies-minor group with 1 update: [globals](https://github.com/sindresorhus/globals). Updates `globals` from 17.3.0 to 17.4.0
Release notes

Sourced from globals's releases.

v17.4.0

  • Update globals (2026-03-01) (#338) d43a051

https://github.com/sindresorhus/globals/compare/v17.3.0...v17.4.0

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=globals&package-manager=npm_and_yarn&previous-version=17.3.0&new-version=17.4.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index bdee147e59..508e47c88d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -111,7 +111,7 @@ "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", "prettier": "^3.8.1", @@ -17009,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": { diff --git a/package.json b/package.json index cd75ae81d2..1bc28c5f63 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "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", "prettier": "^3.8.1", From 73b2fb6be809b3c7238574e6184395589bb3aa00 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:01:18 -0800 Subject: [PATCH 12/18] Bump postcss from 8.5.6 to 8.5.8 in the dev-dependencies-patch group (#2995) Bumps the dev-dependencies-patch group with 1 update: [postcss](https://github.com/postcss/postcss). Updates `postcss` from 8.5.6 to 8.5.8
Release notes

Sourced from postcss's releases.

8.5.8

  • Fixed Processor#version.

8.5.7

  • Improved source map annotation cleaning performance (by CodeAnt AI).
Changelog

Sourced from postcss's changelog.

8.5.8

  • Fixed Processor#version.

8.5.7

  • Improved source map annotation cleaning performance (by CodeAnt AI).
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=postcss&package-manager=npm_and_yarn&previous-version=8.5.6&new-version=8.5.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 508e47c88d..cd9ece2708 100644 --- a/package-lock.json +++ b/package-lock.json @@ -113,7 +113,7 @@ "eslint-config-prettier": "^10.1.8", "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", @@ -24237,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", diff --git a/package.json b/package.json index 1bc28c5f63..2c9145db29 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "eslint-config-prettier": "^10.1.8", "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", From 06bac333aeadda8c7cb625b145742d014ae51e03 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 6 Mar 2026 10:06:14 -0800 Subject: [PATCH 13/18] handle second-instance events (auto-generated new window action on gnome) (#2998) --- emain/emain.ts | 4 ++++ 1 file changed, 4 insertions(+) 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) { From 5e6d33d10d5d9e171545a0bf155ba6707204b727 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:06:57 -0800 Subject: [PATCH 14/18] Bump github.com/shirou/gopsutil/v4 from 4.26.1 to 4.26.2 (#2993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) from 4.26.1 to 4.26.2.
Release notes

Sourced from github.com/shirou/gopsutil/v4's releases.

v4.26.2

What's Changed

disk

host

process

Other Changes

New Contributors

Full Changelog: https://github.com/shirou/gopsutil/compare/v4.26.1...v4.26.2

Commits
  • 33fab7d Merge pull request #2009 from shirou/dependabot/github_actions/actions/setup-...
  • 6320f9e Merge pull request #2007 from shirou/fix/fix_disk_mountinfo_parsing_bugs
  • a69a8e2 chore(deps): bump actions/setup-go from 6.2.0 to 6.3.0
  • 6a6e215 Merge pull request #2008 from shirou/dependabot/go_modules/github.com/ebiteng...
  • e140450 chore(deps): bump github.com/ebitengine/purego from 0.9.1 to 0.10.0
  • 2ef7eb7 [linux][disk]: fix disk mountinfo parsing bug
  • 962bfd8 Merge pull request #2000 from shirou/dependabot/go_modules/golang.org/x/sys-0...
  • 8de66e7 Merge pull request #2006 from shirou/fix/fix_gosec_lint
  • a927423 fix: ignore s390x golangci-lint due to becoming always fail
  • 4e9a8de [common][process]: fix gosec lint
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/shirou/gopsutil/v4&package-manager=go_modules&previous-version=4.26.1&new-version=4.26.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) 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= From 1b3c7c708bf85e913e699cd45f16a04e6a68b2a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:07:38 -0800 Subject: [PATCH 15/18] Bump tar from 7.5.9 to 7.5.10 (#2992) Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.9 to 7.5.10.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=tar&package-manager=npm_and_yarn&previous-version=7.5.9&new-version=7.5.10)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/wavetermdev/waveterm/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index cd9ece2708..86e9b5f581 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29995,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": { From 0f60c706661a8beb771df0432071de2e020ba06f Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 6 Mar 2026 10:19:14 -0800 Subject: [PATCH 16/18] broadcast zoom changed notifications (#2989) --- emain/emain-menu.ts | 5 ++--- emain/emain-tabview.ts | 10 ++-------- emain/emain-util.ts | 24 ++++++++++++++++++++++-- 3 files changed, 26 insertions(+), 13 deletions(-) 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 { From ff673e15004503b30d4035b55eba638272cbbac6 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 6 Mar 2026 10:21:22 -0800 Subject: [PATCH 17/18] break out `wsh editor` and clarify behavior. also document -m flag... (#2988) fix documentation issue #2948 --- docs/docs/wsh-reference.mdx | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) 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. --- From f82845b5b69b37eee0284754368e6b54cc7d4a90 Mon Sep 17 00:00:00 2001 From: "wave-builder[bot]" <181805596+wave-builder[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:23:50 +0000 Subject: [PATCH 18/18] chore: bump package version to 0.14.2-beta.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2c9145db29..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"