From bd401e84917a49bde8f6e273e2a7d7cd6406af67 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Thu, 26 Mar 2026 16:26:24 -0700 Subject: [PATCH 01/47] add line to release notes about durable session fix (#3126) --- docs/docs/releasenotes.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs/releasenotes.mdx b/docs/docs/releasenotes.mdx index 421f212731..db141b5626 100644 --- a/docs/docs/releasenotes.mdx +++ b/docs/docs/releasenotes.mdx @@ -27,6 +27,7 @@ Wave v0.14.4 introduces vertical tabs, upgrades to xterm.js v6, and includes a c - **macOS First Click** - Improved first-click handling on macOS (cancel the click but properly set block/WaveAI focus) - Deprecated legacy AI widget has been removed - [bugfix] Fixed focus bug for newly created blocks +- [bugfix] Fixed an issue around starting a new durable session by splitting an old one - Electron upgraded to v41 - Package updates and dependency upgrades From e6d83d7e89a9c0253d4b6977aad8c4a5a4d3fde1 Mon Sep 17 00:00:00 2001 From: Salman Chishti Date: Thu, 26 Mar 2026 23:33:54 +0000 Subject: [PATCH 02/47] Upgrade GitHub Actions to latest versions (#2842) ## Summary Upgrade GitHub Actions to their latest versions for improved features, bug fixes, and security updates. ## Changes | Action | Old Version(s) | New Version | Release | Files | |--------|---------------|-------------|---------|-------| | `actions/upload-pages-artifact` | [`v3`](https://github.com/actions/upload-pages-artifact/releases/tag/v3) | [`v4`](https://github.com/actions/upload-pages-artifact/releases/tag/v4) | [Release](https://github.com/actions/upload-pages-artifact/releases/tag/v4) | deploy-docsite.yml | ## Why upgrade? Keeping GitHub Actions up to date ensures: - **Security**: Latest security patches and fixes - **Features**: Access to new functionality and improvements - **Compatibility**: Better support for current GitHub features - **Performance**: Optimizations and efficiency improvements ### Security Note Actions that were previously pinned to commit SHAs remain pinned to SHAs (updated to the latest release SHA) to maintain the security benefits of immutable references. ### Testing These changes only affect CI/CD workflow configurations and should not impact application functionality. The workflows should be tested by running them on a branch before merging. Signed-off-by: Salman Muin Kayser Chishti <13schishti@gmail.com> --- .github/workflows/deploy-docsite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-docsite.yml b/.github/workflows/deploy-docsite.yml index b09fa60a25..e9ba826c31 100644 --- a/.github/workflows/deploy-docsite.yml +++ b/.github/workflows/deploy-docsite.yml @@ -55,7 +55,7 @@ jobs: - name: Upload Build Artifact # Only upload the build artifact when pushed to the main branch if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v4 with: path: docs/build deploy: From e4e77e7555002a7a3fe41e1334125413dd846ee6 Mon Sep 17 00:00:00 2001 From: "wave-builder[bot]" <181805596+wave-builder[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:18:47 +0000 Subject: [PATCH 03/47] chore: bump package version to 0.14.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2b68fef8a7..bc39047e2d 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.4-beta.2", + "version": "0.14.4", "homepage": "https://waveterm.dev", "build": { "appId": "dev.commandline.waveterm" From 96c2526f2aed0cf80c81aec11269e35b8cb956c8 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 27 Mar 2026 13:59:25 -0700 Subject: [PATCH 04/47] First Cut at a new ProcessViewer Widget (#3137) --- .kilocode/skills/create-view/SKILL.md | 4 +- frontend/app/block/block.tsx | 64 +- frontend/app/block/blockregistry.ts | 65 ++ frontend/app/block/blockutil.tsx | 6 + frontend/app/store/wshclientapi.ts | 12 + .../app/view/processviewer/processviewer.tsx | 852 ++++++++++++++++++ frontend/preview/mock/mockwaveenv.ts | 11 +- .../previews/processviewer.preview.tsx | 96 ++ frontend/types/gotypes.d.ts | 53 ++ go.mod | 2 +- package-lock.json | 4 +- pkg/util/procinfo/procinfo.go | 40 + pkg/util/procinfo/procinfo_darwin.go | 160 ++++ pkg/util/procinfo/procinfo_linux.go | 144 +++ pkg/util/procinfo/procinfo_windows.go | 140 +++ pkg/util/unixutil/unixutil_unix.go | 12 + pkg/util/unixutil/unixutil_windows.go | 4 + pkg/wshrpc/wshclient/wshclient.go | 12 + pkg/wshrpc/wshremote/processviewer.go | 470 ++++++++++ pkg/wshrpc/wshrpctypes.go | 51 ++ 20 files changed, 2137 insertions(+), 65 deletions(-) create mode 100644 frontend/app/block/blockregistry.ts create mode 100644 frontend/app/view/processviewer/processviewer.tsx create mode 100644 frontend/preview/previews/processviewer.preview.tsx create mode 100644 pkg/util/procinfo/procinfo.go create mode 100644 pkg/util/procinfo/procinfo_darwin.go create mode 100644 pkg/util/procinfo/procinfo_linux.go create mode 100644 pkg/util/procinfo/procinfo_windows.go create mode 100644 pkg/wshrpc/wshremote/processviewer.go diff --git a/.kilocode/skills/create-view/SKILL.md b/.kilocode/skills/create-view/SKILL.md index f39b1ce0d8..49049ca9e5 100644 --- a/.kilocode/skills/create-view/SKILL.md +++ b/.kilocode/skills/create-view/SKILL.md @@ -203,9 +203,11 @@ export const MyView: React.FC> = ({ ### 3. Register the View -Add your view to the `BlockRegistry` in `frontend/app/block/block.tsx`: +Add your view to the `BlockRegistry` in `frontend/app/block/blockregistry.ts`: ```typescript +import { MyViewModel } from "@/app/view/myview/myview-model"; + const BlockRegistry: Map = new Map(); BlockRegistry.set("term", TermViewModel); BlockRegistry.set("preview", PreviewModel); diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index b09cc1bdcc..f199dc5e9c 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -3,21 +3,13 @@ import { BlockComponentModel2, - BlockNodeModel, BlockProps, FullBlockProps, FullSubBlockProps, SubBlockProps, } from "@/app/block/blocktypes"; -import type { TabModel } from "@/app/store/tab-model"; import { useTabModel } from "@/app/store/tab-model"; -import { AiFileDiffViewModel } from "@/app/view/aifilediff/aifilediff"; -import { LauncherViewModel } from "@/app/view/launcher/launcher"; -import { PreviewModel } from "@/app/view/preview/preview-model"; -import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo"; -import { TsunamiViewModel } from "@/app/view/tsunami/tsunami"; -import { VDomModel } from "@/app/view/vdom/vdom-model"; -import { useWaveEnv, WaveEnv } from "@/app/waveenv/waveenv"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; import { ErrorBoundary } from "@/element/errorboundary"; import { CenteredDiv } from "@/element/quickelems"; import { useDebouncedNodeInnerRect } from "@/layout/index"; @@ -26,48 +18,13 @@ import { getBlockComponentModel, registerBlockComponentModel, unregisterBlockCom import { makeORef } from "@/store/wos"; import { focusedBlockId, getElemAsStr } from "@/util/focusutil"; import { isBlank, useAtomValueSafe } from "@/util/util"; -import { HelpViewModel } from "@/view/helpview/helpview"; -import { TermViewModel } from "@/view/term/term-model"; -import { WaveAiModel } from "@/view/waveai/waveai"; -import { WebViewModel } from "@/view/webview/webview"; import clsx from "clsx"; -import { atom, useAtomValue } from "jotai"; +import { useAtomValue } from "jotai"; import { memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { QuickTipsViewModel } from "../view/quicktipsview/quicktipsview"; -import { WaveConfigViewModel } from "../view/waveconfig/waveconfig-model"; import "./block.scss"; import { BlockEnv } from "./blockenv"; import { BlockFrame } from "./blockframe"; -import { blockViewToIcon, blockViewToName } from "./blockutil"; - -const BlockRegistry: Map = new Map(); -BlockRegistry.set("term", TermViewModel); -BlockRegistry.set("preview", PreviewModel); -BlockRegistry.set("web", WebViewModel); -BlockRegistry.set("waveai", WaveAiModel); -BlockRegistry.set("cpuplot", SysinfoViewModel); -BlockRegistry.set("sysinfo", SysinfoViewModel); -BlockRegistry.set("vdom", VDomModel); -BlockRegistry.set("tips", QuickTipsViewModel); -BlockRegistry.set("help", HelpViewModel); -BlockRegistry.set("launcher", LauncherViewModel); -BlockRegistry.set("tsunami", TsunamiViewModel); -BlockRegistry.set("aifilediff", AiFileDiffViewModel); -BlockRegistry.set("waveconfig", WaveConfigViewModel); - -function makeViewModel( - blockId: string, - blockView: string, - nodeModel: BlockNodeModel, - tabModel: TabModel, - waveEnv: WaveEnv -): ViewModel { - const ctor = BlockRegistry.get(blockView); - if (ctor != null) { - return new ctor({ blockId, nodeModel, tabModel, waveEnv }); - } - return makeDefaultViewModel(blockView); -} +import { makeViewModel } from "./blockregistry"; function getViewElem( blockId: string, @@ -86,18 +43,6 @@ function getViewElem( return ; } -function makeDefaultViewModel(viewType: string): ViewModel { - const viewModel: ViewModel = { - viewType: viewType, - viewIcon: atom(blockViewToIcon(viewType)), - viewName: atom(blockViewToName(viewType)), - preIconButton: atom(null), - endIconButtons: atom(null), - viewComponent: null, - }; - return viewModel; -} - const BlockPreview = memo(({ nodeModel, viewModel }: FullBlockProps) => { const waveEnv = useWaveEnv(); const blockIsNull = useAtomValue(waveEnv.wos.isWaveObjectNullAtom(makeORef("block", nodeModel.blockId))); @@ -250,8 +195,7 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { const focusFromPointerEnter = useCallback( (event: React.PointerEvent) => { const focusFollowsCursorEnabled = - focusFollowsCursorMode === "on" || - (focusFollowsCursorMode === "term" && blockView === "term"); + focusFollowsCursorMode === "on" || (focusFollowsCursorMode === "term" && blockView === "term"); if (!focusFollowsCursorEnabled || event.pointerType === "touch" || event.buttons > 0) { return; } diff --git a/frontend/app/block/blockregistry.ts b/frontend/app/block/blockregistry.ts new file mode 100644 index 0000000000..5de7e05bd3 --- /dev/null +++ b/frontend/app/block/blockregistry.ts @@ -0,0 +1,65 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BlockNodeModel } from "@/app/block/blocktypes"; +import type { TabModel } from "@/app/store/tab-model"; +import { AiFileDiffViewModel } from "@/app/view/aifilediff/aifilediff"; +import { LauncherViewModel } from "@/app/view/launcher/launcher"; +import { PreviewModel } from "@/app/view/preview/preview-model"; +import { ProcessViewerViewModel } from "@/app/view/processviewer/processviewer"; +import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo"; +import { TsunamiViewModel } from "@/app/view/tsunami/tsunami"; +import { VDomModel } from "@/app/view/vdom/vdom-model"; +import { WaveEnv } from "@/app/waveenv/waveenv"; +import { atom } from "jotai"; +import { QuickTipsViewModel } from "../view/quicktipsview/quicktipsview"; +import { WaveConfigViewModel } from "../view/waveconfig/waveconfig-model"; +import { blockViewToIcon, blockViewToName } from "./blockutil"; +import { HelpViewModel } from "@/view/helpview/helpview"; +import { TermViewModel } from "@/view/term/term-model"; +import { WaveAiModel } from "@/view/waveai/waveai"; +import { WebViewModel } from "@/view/webview/webview"; + +const BlockRegistry: Map = new Map(); +BlockRegistry.set("term", TermViewModel); +BlockRegistry.set("preview", PreviewModel); +BlockRegistry.set("web", WebViewModel); +BlockRegistry.set("waveai", WaveAiModel); +BlockRegistry.set("cpuplot", SysinfoViewModel); +BlockRegistry.set("sysinfo", SysinfoViewModel); +BlockRegistry.set("vdom", VDomModel); +BlockRegistry.set("tips", QuickTipsViewModel); +BlockRegistry.set("help", HelpViewModel); +BlockRegistry.set("launcher", LauncherViewModel); +BlockRegistry.set("tsunami", TsunamiViewModel); +BlockRegistry.set("aifilediff", AiFileDiffViewModel); +BlockRegistry.set("waveconfig", WaveConfigViewModel); +BlockRegistry.set("processviewer", ProcessViewerViewModel); + +function makeDefaultViewModel(viewType: string): ViewModel { + const viewModel: ViewModel = { + viewType: viewType, + viewIcon: atom(blockViewToIcon(viewType)), + viewName: atom(blockViewToName(viewType)), + preIconButton: atom(null), + endIconButtons: atom(null), + viewComponent: null, + }; + return viewModel; +} + +function makeViewModel( + blockId: string, + blockView: string, + nodeModel: BlockNodeModel, + tabModel: TabModel, + waveEnv: WaveEnv +): ViewModel { + const ctor = BlockRegistry.get(blockView); + if (ctor != null) { + return new ctor({ blockId, nodeModel, tabModel, waveEnv }); + } + return makeDefaultViewModel(blockView); +} + +export { makeViewModel }; diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx index 92d976400f..3ef4d39821 100644 --- a/frontend/app/block/blockutil.tsx +++ b/frontend/app/block/blockutil.tsx @@ -42,6 +42,9 @@ export function blockViewToIcon(view: string): string { if (view == "tips") { return "lightbulb"; } + if (view == "processviewer") { + return "microchip"; + } return "square"; } @@ -67,6 +70,9 @@ export function blockViewToName(view: string): string { if (view == "tips") { return "Tips"; } + if (view == "processviewer") { + return "Processes"; + } return view; } diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 2f5024f0ef..191877de82 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -756,6 +756,18 @@ export class RpcApiType { return client.wshRpcCall("remotemkdir", data, opts); } + // command "remoteprocesslist" [call] + RemoteProcessListCommand(client: WshClient, data: CommandRemoteProcessListData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remoteprocesslist", data, opts); + return client.wshRpcCall("remoteprocesslist", data, opts); + } + + // command "remoteprocesssignal" [call] + RemoteProcessSignalCommand(client: WshClient, data: CommandRemoteProcessSignalData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remoteprocesssignal", data, opts); + return client.wshRpcCall("remoteprocesssignal", data, opts); + } + // command "remotereconnecttojobmanager" [call] RemoteReconnectToJobManagerCommand(client: WshClient, data: CommandRemoteReconnectToJobManagerData, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotereconnecttojobmanager", data, opts); diff --git a/frontend/app/view/processviewer/processviewer.tsx b/frontend/app/view/processviewer/processviewer.tsx new file mode 100644 index 0000000000..d4f9a53920 --- /dev/null +++ b/frontend/app/view/processviewer/processviewer.tsx @@ -0,0 +1,852 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Tooltip } from "@/app/element/tooltip"; +import { ContextMenuModel } from "@/app/store/contextmenu"; +import { globalStore } from "@/app/store/jotaiStore"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { MetaKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; +import * as keyutil from "@/util/keyutil"; +import { isMacOS } from "@/util/platformutil"; +import { isBlank, makeConnRoute } from "@/util/util"; +import * as jotai from "jotai"; +import * as React from "react"; + +// ---- types ---- + +type ActionStatus = { + pid: number; + message: string; + isError: boolean; +}; + +type ProcessViewerEnv = WaveEnvSubset<{ + rpc: { + RemoteProcessListCommand: WaveEnv["rpc"]["RemoteProcessListCommand"]; + RemoteProcessSignalCommand: WaveEnv["rpc"]["RemoteProcessSignalCommand"]; + }; + getConnStatusAtom: WaveEnv["getConnStatusAtom"]; + getBlockMetaKeyAtom: MetaKeyAtomFnType<"connection">; +}>; + +type SortCol = "pid" | "command" | "user" | "cpu" | "mem" | "status" | "threads"; + +const RowHeight = 24; +const OverscanRows = 100; + +// ---- format helpers ---- + +function formatNumber4(n: number): string { + if (n < 10) return n.toFixed(2); + if (n < 100) return n.toFixed(1); + return Math.floor(n).toString().padStart(4); +} + +function fmtMem(bytes: number): string { + if (bytes == null) return ""; + if (bytes < 1024) return formatNumber4(bytes) + "B"; + if (bytes < 1024 * 1024) return formatNumber4(bytes / 1024) + "K"; + if (bytes < 1024 * 1024 * 1024) return formatNumber4(bytes / 1024 / 1024) + "M"; + return formatNumber4(bytes / 1024 / 1024 / 1024) + "G"; +} + +function fmtCpu(cpu: number): string { + if (cpu == null) return ""; + return cpu.toFixed(1) + "%"; +} + +function fmtLoad(load: number): string { + if (load == null) return " "; + return formatNumber4(load); +} + +// ---- model ---- + +export class ProcessViewerViewModel implements ViewModel { + viewType: string; + blockId: string; + env: ProcessViewerEnv; + + viewIcon = jotai.atom("microchip"); + viewName = jotai.atom("Processes"); + manageConnection = jotai.atom(true); + filterOutNowsh = jotai.atom(true); + noPadding = jotai.atom(true); + + dataAtom: jotai.PrimitiveAtom; + sortByAtom: jotai.PrimitiveAtom; + sortDescAtom: jotai.PrimitiveAtom; + scrollTopAtom: jotai.PrimitiveAtom; + containerHeightAtom: jotai.PrimitiveAtom; + loadingAtom: jotai.PrimitiveAtom; + errorAtom: jotai.PrimitiveAtom; + lastSuccessAtom: jotai.PrimitiveAtom; + pausedAtom: jotai.PrimitiveAtom; + selectedPidAtom: jotai.PrimitiveAtom; + actionStatusAtom: jotai.PrimitiveAtom; + textSearchAtom: jotai.PrimitiveAtom; + searchOpenAtom: jotai.PrimitiveAtom; + + connection: jotai.Atom; + connStatus: jotai.Atom; + + disposed = false; + cancelPoll: (() => void) | null = null; + + constructor({ blockId, waveEnv }: ViewModelInitType) { + this.viewType = "processviewer"; + this.blockId = blockId; + this.env = waveEnv; + + this.dataAtom = jotai.atom(null) as jotai.PrimitiveAtom; + this.sortByAtom = jotai.atom("cpu"); + this.sortDescAtom = jotai.atom(true); + this.scrollTopAtom = jotai.atom(0); + this.containerHeightAtom = jotai.atom(0); + this.loadingAtom = jotai.atom(true); + this.errorAtom = jotai.atom(null) as jotai.PrimitiveAtom; + this.lastSuccessAtom = jotai.atom(0) as jotai.PrimitiveAtom; + this.pausedAtom = jotai.atom(false) as jotai.PrimitiveAtom; + this.selectedPidAtom = jotai.atom(null) as jotai.PrimitiveAtom; + this.actionStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom; + this.textSearchAtom = jotai.atom("") as jotai.PrimitiveAtom; + this.searchOpenAtom = jotai.atom(false) as jotai.PrimitiveAtom; + + this.connection = jotai.atom((get) => { + const connValue = get(this.env.getBlockMetaKeyAtom(blockId, "connection")); + if (isBlank(connValue)) { + return "local"; + } + return connValue; + }); + this.connStatus = jotai.atom((get) => { + const connName = get(this.env.getBlockMetaKeyAtom(blockId, "connection")); + const connAtom = this.env.getConnStatusAtom(connName); + return get(connAtom); + }); + + this.startPolling(); + } + + get viewComponent(): ViewComponent { + return ProcessViewerView; + } + + async doOneFetch(cancelledFn?: () => boolean) { + if (this.disposed) return; + const sortBy = globalStore.get(this.sortByAtom); + const sortDesc = globalStore.get(this.sortDescAtom); + const scrollTop = globalStore.get(this.scrollTopAtom); + const containerHeight = globalStore.get(this.containerHeightAtom); + const conn = globalStore.get(this.connection); + const textSearch = globalStore.get(this.textSearchAtom); + + const start = Math.max(0, Math.floor(scrollTop / RowHeight) - OverscanRows); + const visibleRows = containerHeight > 0 ? Math.ceil(containerHeight / RowHeight) : 50; + const limit = visibleRows + OverscanRows * 2; + + const route = makeConnRoute(conn); + try { + const resp = await this.env.rpc.RemoteProcessListCommand( + TabRpcClient, + { sortby: sortBy, sortdesc: sortDesc, start, limit, textsearch: textSearch || undefined }, + { route } + ); + if (!this.disposed && !cancelledFn?.()) { + globalStore.set(this.dataAtom, resp); + globalStore.set(this.loadingAtom, false); + globalStore.set(this.errorAtom, null); + globalStore.set(this.lastSuccessAtom, Date.now()); + } + } catch (e) { + if (!this.disposed && !cancelledFn?.()) { + globalStore.set(this.loadingAtom, false); + globalStore.set(this.errorAtom, String(e)); + } + } + } + + startPolling() { + let cancelled = false; + this.cancelPoll = () => { + cancelled = true; + }; + + const poll = async () => { + while (!cancelled && !this.disposed) { + await this.doOneFetch(() => cancelled); + + if (cancelled || this.disposed) break; + + await new Promise((resolve) => { + const timer = setTimeout(resolve, 1000); + this.cancelPoll = () => { + clearTimeout(timer); + cancelled = true; + resolve(); + }; + }); + + if (!cancelled) { + this.cancelPoll = () => { + cancelled = true; + }; + } + } + }; + + poll(); + } + + triggerRefresh() { + if (this.cancelPoll) { + this.cancelPoll(); + } + this.cancelPoll = null; + if (!globalStore.get(this.pausedAtom)) { + this.startPolling(); + } + } + + setPaused(paused: boolean) { + globalStore.set(this.pausedAtom, paused); + if (paused) { + if (this.cancelPoll) { + this.cancelPoll(); + } + this.cancelPoll = null; + } else { + this.startPolling(); + } + } + + setTextSearch(text: string) { + globalStore.set(this.textSearchAtom, text); + if (globalStore.get(this.pausedAtom)) { + this.doOneFetch(); + } else { + this.triggerRefresh(); + } + } + + openSearch() { + globalStore.set(this.searchOpenAtom, true); + } + + closeSearch() { + globalStore.set(this.searchOpenAtom, false); + globalStore.set(this.textSearchAtom, ""); + this.triggerRefresh(); + } + + keyDownHandler(waveEvent: WaveKeyboardEvent): boolean { + if (keyutil.checkKeyPressed(waveEvent, "Cmd:f")) { + this.openSearch(); + return true; + } + if (keyutil.checkKeyPressed(waveEvent, "Space") && !globalStore.get(this.searchOpenAtom)) { + this.setPaused(!globalStore.get(this.pausedAtom)); + return true; + } + return false; + } + + setSort(col: SortCol) { + const curSort = globalStore.get(this.sortByAtom); + const curDesc = globalStore.get(this.sortDescAtom); + const numericCols: SortCol[] = ["cpu", "mem", "threads"]; + if (curSort === col) { + globalStore.set(this.sortDescAtom, !curDesc); + } else { + globalStore.set(this.sortByAtom, col); + globalStore.set(this.sortDescAtom, numericCols.includes(col)); + } + if (globalStore.get(this.pausedAtom)) { + this.doOneFetch(); + } else { + this.triggerRefresh(); + } + } + + setScrollTop(scrollTop: number) { + const cur = globalStore.get(this.scrollTopAtom); + if (Math.abs(cur - scrollTop) < RowHeight) return; + globalStore.set(this.scrollTopAtom, scrollTop); + this.triggerRefresh(); + } + + setContainerHeight(height: number) { + const cur = globalStore.get(this.containerHeightAtom); + if (cur === height) return; + globalStore.set(this.containerHeightAtom, height); + this.triggerRefresh(); + } + + async sendSignal(pid: number, signal: string, killLabel?: boolean) { + const conn = globalStore.get(this.connection); + const route = makeConnRoute(conn); + const label = killLabel ? "Killed" : `sent ${signal}`; + try { + await this.env.rpc.RemoteProcessSignalCommand(TabRpcClient, { pid, signal }, { route }); + this.setActionStatus({ pid, message: `Process #${pid} ${label}`, isError: false }); + } catch (e) { + this.setActionStatus({ pid, message: String(e), isError: true }); + } + } + + setActionStatus(status: ActionStatus) { + globalStore.set(this.actionStatusAtom, status); + if (!status.isError) { + setTimeout(() => { + const cur = globalStore.get(this.actionStatusAtom); + if (cur === status) { + globalStore.set(this.actionStatusAtom, null); + } + }, 3000); + } + } + + clearActionStatus() { + globalStore.set(this.actionStatusAtom, null); + } + + dispose() { + this.disposed = true; + if (this.cancelPoll) { + this.cancelPoll(); + this.cancelPoll = null; + } + } +} + +// ---- column definitions ---- + +type ColDef = { + key: SortCol; + label: string; + tooltip?: string; + width: string; + align?: "right"; + hideOnPlatform?: string[]; +}; + +const Columns: ColDef[] = [ + { key: "pid", label: "PID", width: "70px", align: "right" }, + { key: "command", label: "Command", width: "minmax(120px, 4fr)" }, + { key: "status", label: "Status", width: "75px", hideOnPlatform: ["windows", "darwin"] }, + { key: "user", label: "User", width: "80px" }, + { key: "threads", label: "NT", tooltip: "Num Threads", width: "40px", align: "right", hideOnPlatform: ["windows"] }, + { key: "cpu", label: "CPU%", width: "70px", align: "right" }, + { key: "mem", label: "Memory", width: "90px", align: "right" }, +]; + +function getColumns(platform: string): ColDef[] { + return Columns.filter((c) => !c.hideOnPlatform?.includes(platform)); +} + +function getGridTemplate(platform: string): string { + return getColumns(platform) + .map((c) => c.width) + .join(" "); +} + +// ---- components ---- + +const SortIndicator = React.memo(function SortIndicator({ active, desc }: { active: boolean; desc: boolean }) { + if (!active) return null; + return {desc ? "↓" : "↑"}; +}); +SortIndicator.displayName = "SortIndicator"; + +const StatusIndicator = React.memo(function StatusIndicator({ model }: { model: ProcessViewerViewModel }) { + const paused = jotai.useAtomValue(model.pausedAtom); + const error = jotai.useAtomValue(model.errorAtom); + const lastSuccess = jotai.useAtomValue(model.lastSuccessAtom); + const [now, setNow] = React.useState(() => Date.now()); + + React.useEffect(() => { + if (paused) return; + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, [paused]); + + if (paused) { + const tooltipContent = ( +
+ Paused + Click to resume +
+ ); + return ( + +
model.setPaused(false)} + > + + + + +
+
+ ); + } + + const stalled = lastSuccess > 0 && now - lastSuccess > 5000; + const circleColor = error != null ? "text-error" : stalled ? "text-warning" : "text-success"; + const statusLabel = error != null ? "Error" : stalled ? "Stalled" : "Updating"; + const tooltipContent = ( +
+ {statusLabel} + Click to pause +
+ ); + + return ( + +
model.setPaused(true)} + > + + + +
+
+ ); +}); +StatusIndicator.displayName = "StatusIndicator"; + +const TableHeader = React.memo(function TableHeader({ + model, + sortBy, + sortDesc, + platform, +}: { + model: ProcessViewerViewModel; + sortBy: SortCol; + sortDesc: boolean; + platform: string; +}) { + const cols = getColumns(platform); + const gridTemplate = getGridTemplate(platform); + return ( +
+ {cols.map((col) => ( + model.setSort(col.key)} + > + {col.label} + + + ))} +
+ ); +}); +TableHeader.displayName = "TableHeader"; + +const ProcessRow = React.memo(function ProcessRow({ + proc, + hasCpu, + platform, + selected, + onSelect, + onContextMenu, +}: { + proc: ProcessInfo; + hasCpu: boolean; + platform: string; + selected: boolean; + onSelect: (pid: number) => void; + onContextMenu: (pid: number, e: React.MouseEvent) => void; +}) { + const gridTemplate = getGridTemplate(platform); + const showStatus = platform !== "windows" && platform !== "darwin"; + const showThreads = platform !== "windows"; + return ( +
onSelect(proc.pid)} + onContextMenu={(e) => onContextMenu(proc.pid, e)} + > +
+ {proc.pid} +
+
{proc.command}
+ {showStatus && ( +
{proc.status}
+ )} +
{proc.user}
+ {showThreads && ( +
+ {proc.numthreads >= 1 ? proc.numthreads : ""} +
+ )} +
+ {hasCpu && proc.cpu != null ? fmtCpu(proc.cpu) : ""} +
+
{fmtMem(proc.mem)}
+
+ ); +}); +ProcessRow.displayName = "ProcessRow"; + +const ActionStatusBar = React.memo(function ActionStatusBar({ model }: { model: ProcessViewerViewModel }) { + const actionStatus = jotai.useAtomValue(model.actionStatusAtom); + if (actionStatus == null) return null; + + return ( +
+ + {actionStatus.isError ? `Error: ${actionStatus.message}` : actionStatus.message} + + {actionStatus.isError && ( + + )} +
+ ); +}); +ActionStatusBar.displayName = "ActionStatusBar"; + +type StatusBarProps = { + model: ProcessViewerViewModel; + data: ProcessListResponse; + loading: boolean; + error: string; + wide: boolean; +}; + +const StatusBar = React.memo(function StatusBar({ model, data, loading, error, wide }: StatusBarProps) { + const searchOpen = jotai.useAtomValue(model.searchOpenAtom); + const totalCount = data?.totalcount ?? 0; + const filteredCount = data?.filteredcount ?? 0; + const summary = data?.summary; + const memUsedFmt = summary?.memused != null ? fmtMem(summary.memused) : null; + const memTotalFmt = summary?.memtotal != null ? fmtMem(summary.memtotal) : null; + const cpuPct = + summary?.cpusum != null && summary?.numcpu != null && summary.numcpu > 0 + ? (summary.cpusum / summary.numcpu).toFixed(1).padStart(6, " ") + : null; + + const procCountValue = + totalCount > 0 + ? filteredCount < totalCount + ? `${filteredCount}/${totalCount}` + : String(totalCount).padStart(5, " ") + : loading + ? "…" + : error + ? "Err" + : ""; + + const hasSummaryLoad = summary != null && summary.load1 != null; + const hasSummaryMem = summary != null && memUsedFmt != null; + const hasSummaryCpu = summary != null && cpuPct != null; + + const searchTooltip = isMacOS() ? "Search (Cmd-F)" : "Search (Alt-F)"; + + if (wide) { + return ( +
+
+ +
+ {hasSummaryLoad && ( + + Load{" "} + + {fmtLoad(summary.load1)} {fmtLoad(summary.load5)} {fmtLoad(summary.load15)} + + + )} + {hasSummaryMem && ( + <> +
+ + Mem{" "} + + {memUsedFmt} / {memTotalFmt} + + + + )} + {hasSummaryCpu && ( + <> +
+ + + CPUx{summary.numcpu}{" "} + {cpuPct}% + + + + )} + + Procs {procCountValue} + + + + +
+ ); + } + + return ( +
+
+ +
+
+
+ {hasSummaryLoad && ( +
+
Load
+
+ {fmtLoad(summary.load1)} {fmtLoad(summary.load5)} {fmtLoad(summary.load15)} +
+
+ )} + {hasSummaryLoad &&
} + {hasSummaryMem && ( +
+
Mem
+
+ {memUsedFmt} / {memTotalFmt} +
+
+ )} + {hasSummaryMem &&
} + {hasSummaryCpu && ( +
+ +
+ CPUx{summary.numcpu} +
+
+
{cpuPct}%
+
+ )} + {hasSummaryCpu &&
} +
+
+
Procs
+
{procCountValue}
+
+ + + +
+
+ ); +}); +StatusBar.displayName = "StatusBar"; + +const SearchBar = React.memo(function SearchBar({ model }: { model: ProcessViewerViewModel }) { + const searchOpen = jotai.useAtomValue(model.searchOpenAtom); + const textSearch = jotai.useAtomValue(model.textSearchAtom); + const inputRef = React.useRef(null); + + React.useEffect(() => { + if (searchOpen && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [searchOpen]); + + if (!searchOpen) return null; + + return ( +
+ model.setTextSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault(); + model.closeSearch(); + } + }} + /> + +
+ ); +}); +SearchBar.displayName = "SearchBar"; + +export const ProcessViewerView: React.FC> = React.memo( + function ProcessViewerView({ blockId: _blockId, blockRef: _blockRef, contentRef: _contentRef, model }) { + const data = jotai.useAtomValue(model.dataAtom); + const sortBy = jotai.useAtomValue(model.sortByAtom); + const sortDesc = jotai.useAtomValue(model.sortDescAtom); + const loading = jotai.useAtomValue(model.loadingAtom); + const error = jotai.useAtomValue(model.errorAtom); + const scrollTop = jotai.useAtomValue(model.scrollTopAtom); + const [selectedPid, setSelectedPid] = jotai.useAtom(model.selectedPidAtom); + const bodyScrollRef = React.useRef(null); + const containerRef = React.useRef(null); + const [wide, setWide] = React.useState(false); + + const handleSelectPid = React.useCallback( + (pid: number) => { + setSelectedPid((cur) => (cur === pid ? null : pid)); + }, + [setSelectedPid] + ); + + const handleContextMenu = React.useCallback( + (pid: number, e: React.MouseEvent) => { + e.preventDefault(); + model.setPaused(true); + setSelectedPid(pid); + + const platform = globalStore.get(model.dataAtom)?.platform ?? ""; + const isWindows = platform === "windows"; + + const menu: ContextMenuItem[] = [ + { + label: "Copy PID", + click: () => navigator.clipboard.writeText(String(pid)), + }, + { type: "separator" }, + ]; + + if (!isWindows) { + menu.push({ + label: "Signal", + type: "submenu", + submenu: [ + { label: "SIGTERM", click: () => model.sendSignal(pid, "SIGTERM") }, + { label: "SIGINT", click: () => model.sendSignal(pid, "SIGINT") }, + { label: "SIGHUP", click: () => model.sendSignal(pid, "SIGHUP") }, + { label: "SIGKILL", click: () => model.sendSignal(pid, "SIGKILL") }, + { label: "SIGUSR1", click: () => model.sendSignal(pid, "SIGUSR1") }, + { label: "SIGUSR2", click: () => model.sendSignal(pid, "SIGUSR2") }, + ], + }); + menu.push({ type: "separator" }); + menu.push({ + label: "Kill Process", + click: () => model.sendSignal(pid, "SIGTERM", true), + }); + } + + ContextMenuModel.getInstance().showContextMenu(menu, e); + }, + [model, setSelectedPid] + ); + + const platform = data?.platform ?? ""; + const startIdx = Math.max(0, Math.floor(scrollTop / RowHeight) - OverscanRows); + const totalCount = data?.totalcount ?? 0; + const filteredCount = data?.filteredcount ?? totalCount; + const processes = data?.processes ?? []; + const hasCpu = data?.hascpu ?? false; + + React.useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + for (const entry of entries) { + model.setContainerHeight(entry.contentRect.height); + setWide(entry.contentRect.width >= 600); + } + }); + ro.observe(el); + model.setContainerHeight(el.clientHeight); + setWide(el.clientWidth >= 600); + return () => ro.disconnect(); + }, [model]); + + const handleScroll = React.useCallback(() => { + const el = bodyScrollRef.current; + if (!el) return; + model.setScrollTop(el.scrollTop); + }, [model]); + + const totalHeight = filteredCount * RowHeight; + const paddingTop = startIdx * RowHeight; + + return ( +
+ + + + {/* error */} + {error != null &&
{error}
} + + {/* outer h-scroll wrapper */} +
+ {/* inner column — expands to header's natural width, rows match */} +
+ + + {/* virtualized rows — same width as header, scrolls vertically */} +
+
+
+ {processes.map((proc) => ( + + ))} +
+
+
+
+
+ +
+ ); + } +); +ProcessViewerView.displayName = "ProcessViewerView"; diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index ea5a8b0b90..123b9d3144 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -22,6 +22,7 @@ export const PreviewWorkspaceId = crypto.randomUUID(); export const PreviewClientId = crypto.randomUUID(); export const WebBlockId = crypto.randomUUID(); export const SysinfoBlockId = crypto.randomUUID(); +export const ProcessViewerBlockId = crypto.randomUUID(); // What works "out of the box" in the mock environment (no MockEnv overrides needed): // @@ -388,7 +389,7 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { oid: PreviewTabId, version: 1, name: "Preview Tab", - blockids: [WebBlockId, SysinfoBlockId], + blockids: [WebBlockId, SysinfoBlockId, ProcessViewerBlockId], meta: {}, } as Tab, [`block:${WebBlockId}`]: { @@ -410,6 +411,14 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { "graph:numpoints": 90, }, } as Block, + [`block:${ProcessViewerBlockId}`]: { + otype: "block", + oid: ProcessViewerBlockId, + version: 1, + meta: { + view: "processviewer", + }, + } as Block, }; const defaultAtoms: Partial = { uiContext: atom({ windowid: PreviewWindowId, activetabid: PreviewTabId } as UIContext), diff --git a/frontend/preview/previews/processviewer.preview.tsx b/frontend/preview/previews/processviewer.preview.tsx new file mode 100644 index 0000000000..f4ab1d0289 --- /dev/null +++ b/frontend/preview/previews/processviewer.preview.tsx @@ -0,0 +1,96 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Block } from "@/app/block/block"; +import * as React from "react"; +import { makeMockNodeModel } from "../mock/mock-node-model"; +import { ProcessViewerBlockId } from "../mock/mockwaveenv"; +import { useRpcOverride } from "../mock/use-rpc-override"; + +const PreviewNodeId = "preview-processviewer-node"; + +const MockProcesses: ProcessInfo[] = [ + { pid: 1, ppid: 0, command: "launchd", user: "root", cpu: 0.0, mem: 4096 * 1024 }, + { pid: 123, ppid: 1, command: "kernel_task", user: "root", cpu: 12.3, mem: 2048 * 1024 * 1024 }, + { pid: 456, ppid: 1, command: "WindowServer", user: "_windowserver", cpu: 5.1, mem: 512 * 1024 * 1024 }, + { pid: 789, ppid: 1, command: "node", user: "mike", cpu: 8.7, mem: 256 * 1024 * 1024 }, + { pid: 1001, ppid: 1, command: "Electron", user: "mike", cpu: 3.2, mem: 400 * 1024 * 1024 }, + { pid: 1234, ppid: 1001, command: "waveterm-helper", user: "mike", cpu: 0.5, mem: 64 * 1024 * 1024 }, + { pid: 2001, ppid: 1, command: "sshd", user: "root", cpu: 0.0, mem: 8 * 1024 * 1024 }, + { pid: 2345, ppid: 1, command: "postgres", user: "postgres", cpu: 1.2, mem: 128 * 1024 * 1024 }, + { pid: 3001, ppid: 1, command: "nginx", user: "_www", cpu: 0.3, mem: 32 * 1024 * 1024 }, + { pid: 3456, ppid: 1, command: "python3", user: "mike", cpu: 2.8, mem: 96 * 1024 * 1024 }, + { pid: 4001, ppid: 1, command: "docker", user: "root", cpu: 0.1, mem: 48 * 1024 * 1024 }, + { pid: 4567, ppid: 4001, command: "containerd", user: "root", cpu: 0.2, mem: 80 * 1024 * 1024 }, + { pid: 5001, ppid: 1, command: "zsh", user: "mike", cpu: 0.0, mem: 6 * 1024 * 1024 }, + { pid: 5678, ppid: 5001, command: "vim", user: "mike", cpu: 0.0, mem: 20 * 1024 * 1024 }, + { pid: 6001, ppid: 1, command: "coreaudiod", user: "_coreaudiod", cpu: 0.4, mem: 16 * 1024 * 1024 }, +]; + +const MockSummary: ProcessSummary = { + total: MockProcesses.length, + load1: 1.42, + load5: 1.78, + load15: 2.01, + memtotal: 32 * 1024 * 1024 * 1024, + memused: 18 * 1024 * 1024 * 1024, + memfree: 2 * 1024 * 1024 * 1024, +}; + +function makeMockProcessListResponse(data: CommandRemoteProcessListData): ProcessListResponse { + let procs = [...MockProcesses]; + + const sortBy = (data.sortby as "pid" | "command" | "user" | "cpu" | "mem") ?? "cpu"; + const sortDesc = data.sortdesc ?? false; + + procs.sort((a, b) => { + let cmp = 0; + if (sortBy === "pid") cmp = a.pid - b.pid; + else if (sortBy === "command") cmp = (a.command ?? "").localeCompare(b.command ?? ""); + else if (sortBy === "user") cmp = (a.user ?? "").localeCompare(b.user ?? ""); + else if (sortBy === "cpu") cmp = (a.cpu ?? 0) - (b.cpu ?? 0); + else if (sortBy === "mem") cmp = (a.mem ?? 0) - (b.mem ?? 0); + return sortDesc ? -cmp : cmp; + }); + + const start = data.start ?? 0; + const limit = data.limit ?? procs.length; + const sliced = procs.slice(start, start + limit); + + return { + processes: sliced, + summary: MockSummary, + ts: Date.now(), + hascpu: true, + totalcount: procs.length, + filteredcount: procs.length, + }; +} + +export default function ProcessViewerPreview() { + const nodeModel = React.useMemo( + () => + makeMockNodeModel({ + nodeId: PreviewNodeId, + blockId: ProcessViewerBlockId, + innerRect: { width: "800px", height: "500px" }, + numLeafs: 1, + }), + [] + ); + + useRpcOverride("RemoteProcessListCommand", async (_client, data) => { + return makeMockProcessListResponse(data); + }); + + return ( +
+
processviewer block (mock RPC — RemoteProcessListCommand)
+
+
+ +
+
+
+ ); +} diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 7a60b6877d..0e56b7d345 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -557,6 +557,22 @@ declare global { fileinfo?: FileInfo[]; }; + // wshrpc.CommandRemoteProcessListData + type CommandRemoteProcessListData = { + sortby?: string; + sortdesc?: boolean; + start?: number; + limit?: number; + textsearch?: string; + pids?: number[]; + }; + + // wshrpc.CommandRemoteProcessSignalData + type CommandRemoteProcessSignalData = { + pid: number; + signal: string; + }; + // wshrpc.CommandRemoteReconnectToJobManagerData type CommandRemoteReconnectToJobManagerData = { jobid: string; @@ -1244,6 +1260,43 @@ declare global { y: number; }; + // wshrpc.ProcessInfo + type ProcessInfo = { + pid: number; + ppid?: number; + command?: string; + status?: string; + user?: string; + mem?: number; + mempct?: number; + cpu?: number; + numthreads?: number; + }; + + // wshrpc.ProcessListResponse + type ProcessListResponse = { + processes: ProcessInfo[]; + summary: ProcessSummary; + ts: number; + hascpu?: boolean; + platform?: string; + totalcount?: number; + filteredcount?: number; + }; + + // wshrpc.ProcessSummary + type ProcessSummary = { + total: number; + load1?: number; + load5?: number; + load15?: number; + memtotal?: number; + memused?: number; + memfree?: number; + numcpu?: number; + cpusum?: number; + }; + // uctypes.RateLimitInfo type RateLimitInfo = { req: number; diff --git a/go.mod b/go.mod index 1e9e2d3663..e6b811c3bf 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/Microsoft/go-winio v0.6.2 github.com/alexflint/go-filemutex v1.3.0 github.com/creack/pty v1.1.24 + github.com/ebitengine/purego v0.10.0 github.com/emirpasic/gods v1.18.1 github.com/fsnotify/fsnotify v1.9.0 github.com/golang-jwt/jwt/v5 v5.3.1 @@ -49,7 +50,6 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // 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/package-lock.json b/package-lock.json index 00bae215eb..c6c3fc35eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.14.4-beta.1", + "version": "0.14.4-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.14.4-beta.1", + "version": "0.14.4-beta.2", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/pkg/util/procinfo/procinfo.go b/pkg/util/procinfo/procinfo.go new file mode 100644 index 0000000000..1a1fa549ff --- /dev/null +++ b/pkg/util/procinfo/procinfo.go @@ -0,0 +1,40 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package procinfo + +import "errors" + +// ErrNotFound is returned by GetProcInfo when the requested pid does not exist. +var ErrNotFound = errors.New("procinfo: process not found") + +// LinuxStatStatus maps the single-character state from /proc/[pid]/stat to a human-readable name. +var LinuxStatStatus = map[string]string{ + "R": "running", + "S": "sleeping", + "D": "disk-wait", + "Z": "zombie", + "T": "stopped", + "t": "tracing-stop", + "W": "paging", + "X": "dead", + "x": "dead", + "K": "wakekill", + "P": "parked", + "I": "idle", +} + +// ProcInfo holds per-process information read from the OS. +// CpuUser and CpuSys are cumulative CPU seconds since process start; +// callers should diff two samples over a known interval to derive a rate. +type ProcInfo struct { + Pid int32 + Ppid int32 + Command string + Status string + CpuUser float64 // cumulative user CPU seconds + CpuSys float64 // cumulative system CPU seconds + VmRSS uint64 // resident set size in bytes + Uid uint32 + NumThreads int32 +} diff --git a/pkg/util/procinfo/procinfo_darwin.go b/pkg/util/procinfo/procinfo_darwin.go new file mode 100644 index 0000000000..b575d141e2 --- /dev/null +++ b/pkg/util/procinfo/procinfo_darwin.go @@ -0,0 +1,160 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package procinfo + +import ( + "context" + "fmt" + "sync" + "syscall" + "unsafe" + + "github.com/ebitengine/purego" + goproc "github.com/shirou/gopsutil/v4/process" + "golang.org/x/sys/unix" +) + +const ( + systemLibPath = "/usr/lib/libSystem.B.dylib" + procPidInfoSym = "proc_pidinfo" + machTimebaseSym = "mach_timebase_info" + procPidTaskInfo = 4 + kernSuccess = 0 +) + +// From +type machTimebaseInfo struct { + Numer uint32 + Denom uint32 +} + +// From libproc.h / proc_info.h +// This is the struct returned by PROC_PIDTASKINFO. +// Keep field order exact. +type procTaskInfo struct { + VirtualSize uint64 + ResidentSize uint64 + TotalUser uint64 + TotalSystem uint64 + ThreadsUser uint64 + ThreadsSys uint64 + Policy int32 + Faults int32 + Pageins int32 + CowFaults int32 + MessagesSent int32 + MessagesRecv int32 + SyscallsMach int32 + SyscallsUnix int32 + Csw int32 + Threadnum int32 + Numrunning int32 + Priority int32 +} + +var ( + darwinProcOnce sync.Once + darwinProcInitErr error + darwinLibHandle uintptr + darwinProcPidInfo procPidInfoFunc + darwinMachTimebase machTimebaseInfoFunc + darwinTimeScale float64 // mach absolute time units -> nanoseconds +) + +type procPidInfoFunc func(pid, flavor int32, arg uint64, buffer uintptr, bufferSize int32) int32 +type machTimebaseInfoFunc func(info uintptr) int32 + +func MakeGlobalSnapshot() (any, error) { + return nil, nil +} + +// GetProcInfo reads process information for the given pid. +// Core fields come from kern.proc.pid sysctl; CPU times, VmRSS, and NumThreads +// are fetched via proc_pidinfo(PROC_PIDTASKINFO). +func GetProcInfo(ctx context.Context, _ any, pid int32) (*ProcInfo, error) { + k, err := unix.SysctlKinfoProc("kern.proc.pid", int(pid)) + if err != nil { + if err == syscall.ESRCH { + return nil, ErrNotFound + } + return nil, fmt.Errorf("procinfo: SysctlKinfoProc pid %d: %w", pid, err) + } + + info := &ProcInfo{ + Pid: int32(k.Proc.P_pid), + Ppid: k.Eproc.Ppid, + Command: unix.ByteSliceToString(k.Proc.P_comm[:]), + Uid: k.Eproc.Ucred.Uid, + } + + if ti, terr := getDarwinProcTaskInfo(pid); terr == nil { + if darwinTimeScale > 0 { + info.CpuUser = float64(ti.TotalUser) * darwinTimeScale / 1e9 + info.CpuSys = float64(ti.TotalSystem) * darwinTimeScale / 1e9 + } + info.VmRSS = ti.ResidentSize + info.NumThreads = ti.Threadnum + } else { + if p, gerr := goproc.NewProcessWithContext(ctx, pid); gerr == nil { + if mi, merr := p.MemoryInfoWithContext(ctx); merr == nil { + info.VmRSS = mi.RSS + } + if nt, nerr := p.NumThreadsWithContext(ctx); nerr == nil { + info.NumThreads = nt + } + } + } + + return info, nil +} + +func initDarwinProcFuncs() error { + darwinProcOnce.Do(func() { + handle, err := purego.Dlopen(systemLibPath, purego.RTLD_LAZY|purego.RTLD_GLOBAL) + if err != nil { + darwinProcInitErr = fmt.Errorf("dlopen %s: %w", systemLibPath, err) + return + } + darwinLibHandle = handle + + purego.RegisterLibFunc(&darwinProcPidInfo, darwinLibHandle, procPidInfoSym) + purego.RegisterLibFunc(&darwinMachTimebase, darwinLibHandle, machTimebaseSym) + + var tb machTimebaseInfo + if rc := darwinMachTimebase(uintptr(unsafe.Pointer(&tb))); rc != kernSuccess { + darwinProcInitErr = fmt.Errorf("mach_timebase_info failed: %d", rc) + return + } + if tb.Denom == 0 { + darwinProcInitErr = fmt.Errorf("mach_timebase_info returned denom=0") + return + } + + darwinTimeScale = float64(tb.Numer) / float64(tb.Denom) + }) + return darwinProcInitErr +} + +func getDarwinProcTaskInfo(pid int32) (*procTaskInfo, error) { + if err := initDarwinProcFuncs(); err != nil { + return nil, err + } + + var ti procTaskInfo + ret := darwinProcPidInfo( + pid, + procPidTaskInfo, + 0, + uintptr(unsafe.Pointer(&ti)), + int32(unsafe.Sizeof(ti)), + ) + if ret <= 0 { + return nil, fmt.Errorf("proc_pidinfo(pid=%d) returned %d", pid, ret) + } + if ret != int32(unsafe.Sizeof(ti)) { + return nil, fmt.Errorf("proc_pidinfo(pid=%d) short read: got=%d want=%d", pid, ret, unsafe.Sizeof(ti)) + } + return &ti, nil +} + diff --git a/pkg/util/procinfo/procinfo_linux.go b/pkg/util/procinfo/procinfo_linux.go new file mode 100644 index 0000000000..c885456fc8 --- /dev/null +++ b/pkg/util/procinfo/procinfo_linux.go @@ -0,0 +1,144 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package procinfo + +import ( + "context" + "errors" + "fmt" + "os" + "strconv" + "strings" +) + +// userHz is USER_HZ, the kernel's timer frequency used in /proc/[pid]/stat CPU fields. +// On Linux this is always 100. +const userHz = 100.0 + +// pageSize is cached at init since it never changes at runtime. +var pageSize int64 + +func init() { + pageSize = int64(os.Getpagesize()) + if pageSize <= 0 { + pageSize = 4096 + } +} + +func MakeGlobalSnapshot() (any, error) { + return nil, nil +} + +// GetProcInfo reads process information for the given pid from /proc. +// It reads /proc/[pid]/stat for most fields and /proc/[pid]/status for the UID. +func GetProcInfo(_ context.Context, _ any, pid int32) (*ProcInfo, error) { + info, err := readStat(pid) + if err != nil { + return nil, err + } + if uid, err := readUid(pid); err == nil { + info.Uid = uid + } else if errors.Is(err, ErrNotFound) { + return nil, ErrNotFound + } + return info, nil +} + +// readStat parses /proc/[pid]/stat. +// +// The comm field (field 2) is enclosed in parentheses and may contain spaces +// and even parentheses itself, so we locate the last ')' to find the field +// boundary rather than splitting on whitespace naively. +func readStat(pid int32) (*ProcInfo, error) { + path := fmt.Sprintf("/proc/%d/stat", pid) + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("procinfo: read %s: %w", path, err) + } + s := strings.TrimRight(string(data), "\n") + + // Locate comm: everything between first '(' and last ')'. + lp := strings.Index(s, "(") + rp := strings.LastIndex(s, ")") + if lp < 0 || rp < 0 || rp <= lp { + return nil, fmt.Errorf("procinfo: malformed stat for pid %d", pid) + } + + pidStr := strings.TrimSpace(s[:lp]) + comm := s[lp+1 : rp] + rest := strings.Fields(s[rp+1:]) + + // rest[0] = field 3 (state), rest[1] = field 4 (ppid), ... + // Fields after comm are numbered starting at 3, so rest[i] = field (i+3). + // We need: + // rest[0] = field 3 state + // rest[1] = field 4 ppid + // rest[11] = field 14 utime + // rest[12] = field 15 stime + // rest[17] = field 20 num_threads + // rest[21] = field 24 rss (pages) + if len(rest) < 22 { + return nil, fmt.Errorf("procinfo: too few fields in stat for pid %d", pid) + } + + parsedPid, err := strconv.ParseInt(pidStr, 10, 32) + if err != nil { + return nil, fmt.Errorf("procinfo: parse pid: %w", err) + } + + ppid, _ := strconv.ParseInt(rest[1], 10, 32) + utime, _ := strconv.ParseUint(rest[11], 10, 64) + stime, _ := strconv.ParseUint(rest[12], 10, 64) + numThreads, _ := strconv.ParseInt(rest[17], 10, 32) + rssPages, _ := strconv.ParseInt(rest[21], 10, 64) + + statusChar := rest[0] + status, ok := LinuxStatStatus[statusChar] + if !ok { + status = "unknown" + } + + info := &ProcInfo{ + Pid: int32(parsedPid), + Ppid: int32(ppid), + Command: comm, + Status: status, + CpuUser: float64(utime) / userHz, + CpuSys: float64(stime) / userHz, + VmRSS: uint64(rssPages * pageSize), + NumThreads: int32(numThreads), + } + return info, nil +} + +// readUid reads the real UID from /proc/[pid]/status. +// The Uid line looks like: Uid: 1000 1000 1000 1000 +func readUid(pid int32) (uint32, error) { + path := fmt.Sprintf("/proc/%d/status", pid) + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return 0, ErrNotFound + } + return 0, fmt.Errorf("procinfo: read %s: %w", path, err) + } + for _, line := range strings.Split(string(data), "\n") { + if !strings.HasPrefix(line, "Uid:") { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + break + } + uid, err := strconv.ParseUint(fields[1], 10, 32) + if err != nil { + break + } + return uint32(uid), nil + } + return 0, fmt.Errorf("procinfo: Uid line not found in %s", path) +} diff --git a/pkg/util/procinfo/procinfo_windows.go b/pkg/util/procinfo/procinfo_windows.go new file mode 100644 index 0000000000..39054c6383 --- /dev/null +++ b/pkg/util/procinfo/procinfo_windows.go @@ -0,0 +1,140 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package procinfo + +import ( + "context" + "errors" + "fmt" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +var modpsapi = syscall.NewLazyDLL("psapi.dll") +var procGetProcessMemoryInfo = modpsapi.NewProc("GetProcessMemoryInfo") + +// processMemoryCounters mirrors PROCESS_MEMORY_COUNTERS from psapi.h. +type processMemoryCounters struct { + CB uint32 + PageFaultCount uint32 + PeakWorkingSetSize uintptr + WorkingSetSize uintptr + QuotaPeakPagedPoolUsage uintptr + QuotaPagedPoolUsage uintptr + QuotaPeakNonPagedPoolUsage uintptr + QuotaNonPagedPoolUsage uintptr + PagefileUsage uintptr + PeakPagefileUsage uintptr +} + +// snapInfo holds the data collected in a single pass of CreateToolhelp32Snapshot. +type snapInfo struct { + ppid uint32 + numThreads uint32 + exeName string +} + +// windowsSnapshot is the concrete type returned by MakeGlobalSnapshot on Windows. +type windowsSnapshot struct { + procs map[int32]*snapInfo +} + +// MakeGlobalSnapshot enumerates all processes once via CreateToolhelp32Snapshot. +func MakeGlobalSnapshot() (any, error) { + snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil, fmt.Errorf("procinfo: CreateToolhelp32Snapshot: %w", err) + } + defer windows.CloseHandle(snap) + + procs := make(map[int32]*snapInfo) + + var entry windows.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + + if err := windows.Process32First(snap, &entry); err != nil { + return nil, fmt.Errorf("procinfo: Process32First: %w", err) + } + for { + pid := int32(entry.ProcessID) + procs[pid] = &snapInfo{ + ppid: entry.ParentProcessID, + numThreads: entry.Threads, + exeName: windows.UTF16ToString(entry.ExeFile[:]), + } + if err := windows.Process32Next(snap, &entry); err != nil { + if errors.Is(err, windows.ERROR_NO_MORE_FILES) { + break + } + return nil, fmt.Errorf("procinfo: Process32Next: %w", err) + } + } + + return &windowsSnapshot{procs: procs}, nil +} + +// GetProcInfo returns a ProcInfo for the given pid. +// snap must be a non-nil value returned by MakeGlobalSnapshot. +// Returns nil, nil if the pid is not present in the snapshot. +func GetProcInfo(_ context.Context, snap any, pid int32) (*ProcInfo, error) { + if snap == nil { + return nil, fmt.Errorf("procinfo: GetProcInfo requires a snapshot on windows") + } + ws, ok := snap.(*windowsSnapshot) + if !ok { + return nil, fmt.Errorf("procinfo: invalid snapshot type") + } + si, found := ws.procs[pid] + if !found { + return nil, ErrNotFound + } + + info := &ProcInfo{ + Pid: pid, + Ppid: int32(si.ppid), + NumThreads: int32(si.numThreads), + Command: si.exeName, + } + + handle, err := windows.OpenProcess( + windows.PROCESS_QUERY_LIMITED_INFORMATION, + false, + uint32(pid), + ) + if err != nil { + // ERROR_INVALID_PARAMETER means the pid no longer exists. + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return nil, ErrNotFound + } + return info, nil + } + defer windows.CloseHandle(handle) + + var creation, exit, kernel, user windows.Filetime + if err := windows.GetProcessTimes(handle, &creation, &exit, &kernel, &user); err == nil { + info.CpuUser = filetimeToSeconds(user) + info.CpuSys = filetimeToSeconds(kernel) + } + + var mc processMemoryCounters + mc.CB = uint32(unsafe.Sizeof(mc)) + r, _, _ := procGetProcessMemoryInfo.Call( + uintptr(handle), + uintptr(unsafe.Pointer(&mc)), + uintptr(mc.CB), + ) + if r != 0 { + info.VmRSS = uint64(mc.WorkingSetSize) + } + + return info, nil +} + +// filetimeToSeconds converts a FILETIME (100-ns intervals) to cumulative seconds. +func filetimeToSeconds(ft windows.Filetime) float64 { + ns100 := (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime) + return float64(ns100) / 1e7 +} diff --git a/pkg/util/unixutil/unixutil_unix.go b/pkg/util/unixutil/unixutil_unix.go index f47a0e0f3b..3552f9b194 100644 --- a/pkg/util/unixutil/unixutil_unix.go +++ b/pkg/util/unixutil/unixutil_unix.go @@ -80,3 +80,15 @@ func IsPidRunning(pid int) bool { } return false } + +func SendSignalByName(pid int, sigName string) error { + sig := ParseSignal(sigName) + if sig == nil { + return fmt.Errorf("unsupported or invalid signal %q", sigName) + } + p, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("process %d not found: %w", pid, err) + } + return p.Signal(sig) +} diff --git a/pkg/util/unixutil/unixutil_windows.go b/pkg/util/unixutil/unixutil_windows.go index 5c7f72aba9..9500f198dc 100644 --- a/pkg/util/unixutil/unixutil_windows.go +++ b/pkg/util/unixutil/unixutil_windows.go @@ -44,3 +44,7 @@ func SignalHup(pid int) error { func IsPidRunning(pid int) bool { return false } + +func SendSignalByName(pid int, sigName string) error { + return fmt.Errorf("sending signals is not supported on Windows") +} diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 2968baa8d7..5c5cd62012 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -753,6 +753,18 @@ func RemoteMkdirCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) er return err } +// command "remoteprocesslist", wshserver.RemoteProcessListCommand +func RemoteProcessListCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteProcessListData, opts *wshrpc.RpcOpts) (*wshrpc.ProcessListResponse, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.ProcessListResponse](w, "remoteprocesslist", data, opts) + return resp, err +} + +// command "remoteprocesssignal", wshserver.RemoteProcessSignalCommand +func RemoteProcessSignalCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteProcessSignalData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "remoteprocesssignal", data, opts) + return err +} + // command "remotereconnecttojobmanager", wshserver.RemoteReconnectToJobManagerCommand func RemoteReconnectToJobManagerCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteReconnectToJobManagerData, opts *wshrpc.RpcOpts) (*wshrpc.CommandRemoteReconnectToJobManagerRtnData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.CommandRemoteReconnectToJobManagerRtnData](w, "remotereconnecttojobmanager", data, opts) diff --git a/pkg/wshrpc/wshremote/processviewer.go b/pkg/wshrpc/wshremote/processviewer.go new file mode 100644 index 0000000000..647c027424 --- /dev/null +++ b/pkg/wshrpc/wshremote/processviewer.go @@ -0,0 +1,470 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshremote + +import ( + "context" + "fmt" + "os" + "os/user" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/shirou/gopsutil/v4/load" + "github.com/shirou/gopsutil/v4/mem" + goproc "github.com/shirou/gopsutil/v4/process" + "github.com/wavetermdev/waveterm/pkg/panichandler" + "github.com/wavetermdev/waveterm/pkg/util/procinfo" + "github.com/wavetermdev/waveterm/pkg/util/unixutil" + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +const ( + ProcCacheIdleTimeout = 10 * time.Second + ProcCachePollInterval = 1 * time.Second + ProcViewerMaxLimit = 500 +) + +// cpuSample records a single CPU time measurement for a process. +type cpuSample struct { + CPUSec float64 // user+system cpu seconds at sample time + SampledAt time.Time // when the sample was taken + Epoch int // epoch at which this sample was recorded +} + +// procCacheState is the singleton background cache for process list data. +// lastCPUSamples, lastCPUEpoch, and uidCache are only accessed by the single runLoop goroutine. +type procCacheState struct { + lock sync.Mutex + cached *wshrpc.ProcessListResponse + lastRequest time.Time + running bool + // ready is closed when the first result is placed in cache; set to nil after close. + ready chan struct{} + + lastCPUSamples map[int32]cpuSample + lastCPUEpoch int + uidCache map[uint32]string // uid -> username, populated lazily +} + +// procCache is the singleton background cache for process list data. +var procCache = &procCacheState{} + +// requestAndWait marks the cache as recently requested and returns the current cached +// result. If the background goroutine is not running it starts it and waits for the +// first populate before returning. +func (s *procCacheState) requestAndWait(ctx context.Context) (*wshrpc.ProcessListResponse, error) { + s.lock.Lock() + s.lastRequest = time.Now() + if !s.running { + s.running = true + readyCh := make(chan struct{}) + s.ready = readyCh + go s.runLoop(readyCh) + } + readyCh := s.ready + s.lock.Unlock() + + if readyCh != nil { + select { + case <-readyCh: + case <-ctx.Done(): + return nil, ctx.Err() + } + } + + s.lock.Lock() + result := s.cached + s.lock.Unlock() + + if result == nil { + return nil, fmt.Errorf("process list unavailable") + } + return result, nil +} + +func (s *procCacheState) runLoop(firstReadyCh chan struct{}) { + defer func() { + panichandler.PanicHandler("procCache.runLoop", recover()) + }() + + numCPU := runtime.NumCPU() + if numCPU < 1 { + numCPU = 1 + } + + firstDone := false + + for { + iterStart := time.Now() + + s.lastCPUEpoch++ + result := s.collectSnapshot(numCPU) + + // Remove stale entries (pids that weren't seen this epoch). + for pid, sample := range s.lastCPUSamples { + if sample.Epoch < s.lastCPUEpoch { + delete(s.lastCPUSamples, pid) + } + } + + s.lock.Lock() + s.cached = result + idleFor := time.Since(s.lastRequest) + if !firstDone { + firstDone = true + close(firstReadyCh) + s.ready = nil + } + if idleFor >= ProcCacheIdleTimeout { + s.cached = nil + s.running = false + s.lastCPUSamples = nil + s.lastCPUEpoch = 0 + s.uidCache = nil + s.lock.Unlock() + return + } + s.lock.Unlock() + + elapsed := time.Since(iterStart) + if sleep := ProcCachePollInterval - elapsed; sleep > 0 { + time.Sleep(sleep) + } + } +} + +// lookupUID resolves a uid to a username, using the per-run cache to avoid +// repeated syscalls for the same uid. +func (s *procCacheState) lookupUID(uid uint32) string { + if s.uidCache == nil { + s.uidCache = make(map[uint32]string) + } + if name, ok := s.uidCache[uid]; ok { + return name + } + u, err := user.LookupId(strconv.FormatUint(uint64(uid), 10)) + if err != nil { + s.uidCache[uid] = "" + return "" + } + name := u.Username + s.uidCache[uid] = name + return name +} + +// collectSnapshot fetches all process info, updates lastCPUSamples with fresh measurements, +// and computes CPU% using each pid's previous sample (if available). +func (s *procCacheState) collectSnapshot(numCPU int) *wshrpc.ProcessListResponse { + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + + procs, err := goproc.ProcessesWithContext(ctx) + if err != nil { + return nil + } + + if s.lastCPUSamples == nil { + s.lastCPUSamples = make(map[int32]cpuSample, len(procs)) + } + + snap, _ := procinfo.MakeGlobalSnapshot() + + hasCPU := s.lastCPUEpoch > 1 // first epoch has no previous sample to diff against + + // Build per-pid procinfo in parallel, then compute CPU% sequentially. + type pidInfo struct { + pid int32 + info *procinfo.ProcInfo + } + rawInfos := make([]pidInfo, len(procs)) + var wg sync.WaitGroup + for i, p := range procs { + i, p := i, p + wg.Add(1) + go func() { + defer func() { + panichandler.PanicHandler("collectSnapshot:GetProcInfo", recover()) + wg.Done() + }() + pi, err := procinfo.GetProcInfo(ctx, snap, p.Pid) + if err != nil { + pi = nil + } + rawInfos[i] = pidInfo{pid: p.Pid, info: pi} + }() + } + wg.Wait() + + // Sample CPU times and compute CPU% sequentially to keep epoch accounting simple. + cpuPcts := make(map[int32]float64, len(procs)) + sampleTime := time.Now() + for _, ri := range rawInfos { + if ri.info == nil { + continue + } + curCPUSec := ri.info.CpuUser + ri.info.CpuSys + + if hasCPU { + if prev, ok := s.lastCPUSamples[ri.pid]; ok { + elapsed := sampleTime.Sub(prev.SampledAt).Seconds() + if elapsed > 0 { + cpuPcts[ri.pid] = computeCPUPct(prev.CPUSec, curCPUSec, elapsed) + } + } + } + + s.lastCPUSamples[ri.pid] = cpuSample{ + CPUSec: curCPUSec, + SampledAt: sampleTime, + Epoch: s.lastCPUEpoch, + } + } + + // Compute total memory for MemPct. + var totalMem uint64 + if vm, err := mem.VirtualMemoryWithContext(ctx); err == nil { + totalMem = vm.Total + } + + var cpuSum float64 + infos := make([]wshrpc.ProcessInfo, 0, len(rawInfos)) + for _, ri := range rawInfos { + if ri.info == nil { + continue + } + pi := ri.info + info := wshrpc.ProcessInfo{ + Pid: pi.Pid, + Ppid: pi.Ppid, + Command: pi.Command, + Status: pi.Status, + Mem: pi.VmRSS, + NumThreads: pi.NumThreads, + User: s.lookupUID(pi.Uid), + } + if totalMem > 0 { + info.MemPct = float64(pi.VmRSS) / float64(totalMem) * 100 + } + if hasCPU { + if cpu, ok := cpuPcts[pi.Pid]; ok { + v := cpu + info.Cpu = &v + cpuSum += cpu + } + } + infos = append(infos, info) + } + + summaryCh := make(chan wshrpc.ProcessSummary, 1) + go func() { + defer func() { + if err := panichandler.PanicHandler("buildProcessSummary", recover()); err != nil { + summaryCh <- wshrpc.ProcessSummary{Total: len(procs)} + } + }() + summaryCh <- buildProcessSummary(ctx, len(procs), numCPU, cpuSum) + }() + summary := <-summaryCh + + return &wshrpc.ProcessListResponse{ + Processes: infos, + Summary: summary, + Ts: time.Now().UnixMilli(), + HasCPU: hasCPU, + Platform: runtime.GOOS, + } +} + +func computeCPUPct(t1, t2, elapsedSec float64) float64 { + delta := (t2 - t1) / elapsedSec * 100 + if delta < 0 { + delta = 0 + } + return delta +} + +func buildProcessSummary(ctx context.Context, total int, numCPU int, cpuSum float64) wshrpc.ProcessSummary { + summary := wshrpc.ProcessSummary{Total: total, NumCPU: numCPU, CpuSum: cpuSum} + if avg, err := load.AvgWithContext(ctx); err == nil { + summary.Load1 = avg.Load1 + summary.Load5 = avg.Load5 + summary.Load15 = avg.Load15 + } + if vm, err := mem.VirtualMemoryWithContext(ctx); err == nil { + summary.MemTotal = vm.Total + summary.MemUsed = vm.Used + summary.MemFree = vm.Free + } + return summary +} + +func filterProcesses(processes []wshrpc.ProcessInfo, textSearch string) []wshrpc.ProcessInfo { + if textSearch == "" { + return processes + } + search := strings.ToLower(textSearch) + filtered := processes[:0] + for _, p := range processes { + pidStr := strconv.Itoa(int(p.Pid)) + if strings.Contains(strings.ToLower(p.Command), search) || + strings.Contains(strings.ToLower(p.Status), search) || + strings.Contains(strings.ToLower(p.User), search) || + strings.Contains(pidStr, search) { + filtered = append(filtered, p) + } + } + return filtered +} + +func sortAndLimitProcesses(processes []wshrpc.ProcessInfo, sortBy string, sortDesc bool, start int, limit int) []wshrpc.ProcessInfo { + switch sortBy { + case "cpu": + sort.Slice(processes, func(i, j int) bool { + ci, cj := 0.0, 0.0 + if processes[i].Cpu != nil { + ci = *processes[i].Cpu + } + if processes[j].Cpu != nil { + cj = *processes[j].Cpu + } + if sortDesc { + return ci > cj + } + return ci < cj + }) + case "mem": + sort.Slice(processes, func(i, j int) bool { + if sortDesc { + return processes[i].Mem > processes[j].Mem + } + return processes[i].Mem < processes[j].Mem + }) + case "command": + sort.Slice(processes, func(i, j int) bool { + if sortDesc { + return processes[i].Command > processes[j].Command + } + return processes[i].Command < processes[j].Command + }) + case "user": + sort.Slice(processes, func(i, j int) bool { + if sortDesc { + return processes[i].User > processes[j].User + } + return processes[i].User < processes[j].User + }) + case "status": + sort.Slice(processes, func(i, j int) bool { + if sortDesc { + return processes[i].Status > processes[j].Status + } + return processes[i].Status < processes[j].Status + }) + case "threads": + sort.Slice(processes, func(i, j int) bool { + if sortDesc { + return processes[i].NumThreads > processes[j].NumThreads + } + return processes[i].NumThreads < processes[j].NumThreads + }) + default: // "pid" + sort.Slice(processes, func(i, j int) bool { + if sortDesc { + return processes[i].Pid > processes[j].Pid + } + return processes[i].Pid < processes[j].Pid + }) + } + if start > 0 { + if start >= len(processes) { + return nil + } + processes = processes[start:] + } + if limit > 0 && len(processes) > limit { + processes = processes[:limit] + } + return processes +} + +func (impl *ServerImpl) RemoteProcessListCommand(ctx context.Context, data wshrpc.CommandRemoteProcessListData) (*wshrpc.ProcessListResponse, error) { + raw, err := procCache.requestAndWait(ctx) + if err != nil { + return nil, err + } + + // Pids overrides all other request fields; when set we skip sort/limit/start/textsearch + // and return only the exact pids requested. + if len(data.Pids) > 0 { + pidSet := make(map[int32]struct{}, len(data.Pids)) + for _, pid := range data.Pids { + pidSet[pid] = struct{}{} + } + processes := make([]wshrpc.ProcessInfo, 0, len(data.Pids)) + for _, p := range raw.Processes { + if _, ok := pidSet[p.Pid]; ok { + processes = append(processes, p) + } + } + return &wshrpc.ProcessListResponse{ + Processes: processes, + Summary: raw.Summary, + Ts: raw.Ts, + HasCPU: raw.HasCPU, + Platform: raw.Platform, + }, nil + } + + sortBy := data.SortBy + if sortBy == "" { + sortBy = "cpu" + } + limit := data.Limit + if limit <= 0 || limit > ProcViewerMaxLimit { + limit = ProcViewerMaxLimit + } + + totalCount := len(raw.Processes) + + // Copy processes so we can sort/limit without mutating the cache. + processes := make([]wshrpc.ProcessInfo, len(raw.Processes)) + copy(processes, raw.Processes) + processes = filterProcesses(processes, data.TextSearch) + filteredCount := len(processes) + processes = sortAndLimitProcesses(processes, sortBy, data.SortDesc, data.Start, limit) + + return &wshrpc.ProcessListResponse{ + Processes: processes, + Summary: raw.Summary, + Ts: raw.Ts, + HasCPU: raw.HasCPU, + Platform: raw.Platform, + TotalCount: totalCount, + FilteredCount: filteredCount, + }, nil +} + +func (impl *ServerImpl) RemoteProcessSignalCommand(ctx context.Context, data wshrpc.CommandRemoteProcessSignalData) error { + if runtime.GOOS == "windows" { + // special case handling for windows. SIGTERM is mapped to "Kill Process" context menu so will do a proc.Kill() on windows + proc, err := os.FindProcess(int(data.Pid)) + if err != nil { + return fmt.Errorf("process %d not found: %w", data.Pid, err) + } + sig := strings.ToUpper(data.Signal) + if sig == "SIGINT" { + return proc.Signal(os.Interrupt) + } + if sig == "SIGTERM" || sig == "SIGKILL" { + return proc.Kill() + } + return fmt.Errorf("signal %q is not supported on Windows", data.Signal) + } + return unixutil.SendSignalByName(int(data.Pid), data.Signal) +} diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 2fee3e392e..cc4b793da8 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -128,6 +128,8 @@ type WshRpcInterface interface { RemoteDisconnectFromJobManagerCommand(ctx context.Context, data CommandRemoteDisconnectFromJobManagerData) error RemoteTerminateJobManagerCommand(ctx context.Context, data CommandRemoteTerminateJobManagerData) error BadgeWatchPidCommand(ctx context.Context, data CommandBadgeWatchPidData) error + RemoteProcessListCommand(ctx context.Context, data CommandRemoteProcessListData) (*ProcessListResponse, error) + RemoteProcessSignalCommand(ctx context.Context, data CommandRemoteProcessSignalData) error // emain WebSelectorCommand(ctx context.Context, data CommandWebSelectorData) ([]string, error) @@ -908,3 +910,52 @@ type FocusedBlockData struct { TermShellIntegrationStatus string `json:"termshellintegrationstatus,omitempty"` TermLastCommand string `json:"termlastcommand,omitempty"` } + +type ProcessInfo struct { + Pid int32 `json:"pid"` + Ppid int32 `json:"ppid,omitempty"` + Command string `json:"command,omitempty"` + Status string `json:"status,omitempty"` + User string `json:"user,omitempty"` + Mem uint64 `json:"mem,omitempty"` + MemPct float64 `json:"mempct,omitempty"` + Cpu *float64 `json:"cpu,omitempty"` + NumThreads int32 `json:"numthreads,omitempty"` +} + +type ProcessSummary struct { + Total int `json:"total"` + Load1 float64 `json:"load1,omitempty"` + Load5 float64 `json:"load5,omitempty"` + Load15 float64 `json:"load15,omitempty"` + MemTotal uint64 `json:"memtotal,omitempty"` + MemUsed uint64 `json:"memused,omitempty"` + MemFree uint64 `json:"memfree,omitempty"` + NumCPU int `json:"numcpu,omitempty"` + CpuSum float64 `json:"cpusum,omitempty"` +} + +type ProcessListResponse struct { + Processes []ProcessInfo `json:"processes"` + Summary ProcessSummary `json:"summary"` + Ts int64 `json:"ts"` + HasCPU bool `json:"hascpu,omitempty"` + Platform string `json:"platform,omitempty"` + TotalCount int `json:"totalcount,omitempty"` + FilteredCount int `json:"filteredcount,omitempty"` +} + +type CommandRemoteProcessListData struct { + SortBy string `json:"sortby,omitempty"` + SortDesc bool `json:"sortdesc,omitempty"` + Start int `json:"start,omitempty"` + Limit int `json:"limit,omitempty"` + TextSearch string `json:"textsearch,omitempty"` + // Pids overrides all other fields; when set, returns only the specified pids (no sort/limit/start/textsearch). + Pids []int32 `json:"pids,omitempty"` +} + +type CommandRemoteProcessSignalData struct { + Pid int32 `json:"pid"` + Signal string `json:"signal"` +} From 0ade6ee99790c38fc6fc68649e9302fa55e4ccb4 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Mon, 30 Mar 2026 11:18:58 -0700 Subject: [PATCH 05/47] updates to make processviewer more robust (#3144) --- .../app/view/processviewer/processviewer.tsx | 200 +++++++++++-- .../previews/processviewer.preview.tsx | 30 +- frontend/tailwindsetup.css | 4 + frontend/types/gotypes.d.ts | 13 +- package-lock.json | 4 +- pkg/util/procinfo/procinfo.go | 10 +- pkg/util/procinfo/procinfo_darwin.go | 16 +- pkg/util/procinfo/procinfo_linux.go | 32 ++- pkg/util/procinfo/procinfo_windows.go | 5 +- pkg/wshrpc/wshremote/processviewer.go | 262 +++++++++++++----- pkg/wshrpc/wshrpctypes.go | 30 +- 11 files changed, 456 insertions(+), 150 deletions(-) diff --git a/frontend/app/view/processviewer/processviewer.tsx b/frontend/app/view/processviewer/processviewer.tsx index d4f9a53920..5accb79cb6 100644 --- a/frontend/app/view/processviewer/processviewer.tsx +++ b/frontend/app/view/processviewer/processviewer.tsx @@ -44,6 +44,7 @@ function formatNumber4(n: number): string { function fmtMem(bytes: number): string { if (bytes == null) return ""; + if (bytes === -1) return "-"; if (bytes < 1024) return formatNumber4(bytes) + "B"; if (bytes < 1024 * 1024) return formatNumber4(bytes / 1024) + "K"; if (bytes < 1024 * 1024 * 1024) return formatNumber4(bytes / 1024 / 1024) + "M"; @@ -52,7 +53,13 @@ function fmtMem(bytes: number): string { function fmtCpu(cpu: number): string { if (cpu == null) return ""; - return cpu.toFixed(1) + "%"; + if (cpu === -1) return " -"; + if (cpu === 0) return " 0.0%"; + if (cpu < 0.005) return "~0.0%"; + if (cpu < 10) return cpu.toFixed(2) + "%"; + if (cpu < 100) return cpu.toFixed(1) + "%"; + if (cpu < 1000) return " " + Math.floor(cpu).toString() + "%"; + return Math.floor(cpu).toString() + "%"; } function fmtLoad(load: number): string { @@ -74,6 +81,7 @@ export class ProcessViewerViewModel implements ViewModel { noPadding = jotai.atom(true); dataAtom: jotai.PrimitiveAtom; + dataStartAtom: jotai.PrimitiveAtom; sortByAtom: jotai.PrimitiveAtom; sortDescAtom: jotai.PrimitiveAtom; scrollTopAtom: jotai.PrimitiveAtom; @@ -86,12 +94,14 @@ export class ProcessViewerViewModel implements ViewModel { actionStatusAtom: jotai.PrimitiveAtom; textSearchAtom: jotai.PrimitiveAtom; searchOpenAtom: jotai.PrimitiveAtom; + fetchIntervalAtom: jotai.PrimitiveAtom; connection: jotai.Atom; connStatus: jotai.Atom; disposed = false; cancelPoll: (() => void) | null = null; + fetchEpoch = 0; constructor({ blockId, waveEnv }: ViewModelInitType) { this.viewType = "processviewer"; @@ -99,6 +109,7 @@ export class ProcessViewerViewModel implements ViewModel { this.env = waveEnv; this.dataAtom = jotai.atom(null) as jotai.PrimitiveAtom; + this.dataStartAtom = jotai.atom(0); this.sortByAtom = jotai.atom("cpu"); this.sortDescAtom = jotai.atom(true); this.scrollTopAtom = jotai.atom(0); @@ -111,6 +122,7 @@ export class ProcessViewerViewModel implements ViewModel { this.actionStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom; this.textSearchAtom = jotai.atom("") as jotai.PrimitiveAtom; this.searchOpenAtom = jotai.atom(false) as jotai.PrimitiveAtom; + this.fetchIntervalAtom = jotai.atom(2000) as jotai.PrimitiveAtom; this.connection = jotai.atom((get) => { const connValue = get(this.env.getBlockMetaKeyAtom(blockId, "connection")); @@ -132,8 +144,9 @@ export class ProcessViewerViewModel implements ViewModel { return ProcessViewerView; } - async doOneFetch(cancelledFn?: () => boolean) { + async doOneFetch(lastPidOrder: boolean, cancelledFn?: () => boolean) { if (this.disposed) return; + const epoch = ++this.fetchEpoch; const sortBy = globalStore.get(this.sortByAtom); const sortDesc = globalStore.get(this.sortDescAtom); const scrollTop = globalStore.get(this.scrollTopAtom); @@ -149,23 +162,47 @@ export class ProcessViewerViewModel implements ViewModel { try { const resp = await this.env.rpc.RemoteProcessListCommand( TabRpcClient, - { sortby: sortBy, sortdesc: sortDesc, start, limit, textsearch: textSearch || undefined }, + { + widgetid: this.blockId, + sortby: sortBy, + sortdesc: sortDesc, + start, + limit, + textsearch: textSearch || undefined, + lastpidorder: lastPidOrder, + }, { route } ); - if (!this.disposed && !cancelledFn?.()) { + if (!this.disposed && !cancelledFn?.() && this.fetchEpoch === epoch) { globalStore.set(this.dataAtom, resp); + globalStore.set(this.dataStartAtom, start); globalStore.set(this.loadingAtom, false); globalStore.set(this.errorAtom, null); globalStore.set(this.lastSuccessAtom, Date.now()); } } catch (e) { - if (!this.disposed && !cancelledFn?.()) { + if (!this.disposed && !cancelledFn?.() && this.fetchEpoch === epoch) { globalStore.set(this.loadingAtom, false); globalStore.set(this.errorAtom, String(e)); } } } + async doKeepAlive() { + if (this.disposed) return; + const conn = globalStore.get(this.connection); + const route = makeConnRoute(conn); + try { + await this.env.rpc.RemoteProcessListCommand( + TabRpcClient, + { widgetid: this.blockId, keepalive: true }, + { route } + ); + } catch (_) { + // keepalive failures are silent + } + } + startPolling() { let cancelled = false; this.cancelPoll = () => { @@ -174,12 +211,13 @@ export class ProcessViewerViewModel implements ViewModel { const poll = async () => { while (!cancelled && !this.disposed) { - await this.doOneFetch(() => cancelled); + await this.doOneFetch(false, () => cancelled); if (cancelled || this.disposed) break; + const interval = globalStore.get(this.fetchIntervalAtom); await new Promise((resolve) => { - const timer = setTimeout(resolve, 1000); + const timer = setTimeout(resolve, interval); this.cancelPoll = () => { clearTimeout(timer); cancelled = true; @@ -198,6 +236,38 @@ export class ProcessViewerViewModel implements ViewModel { poll(); } + startKeepAlive() { + let cancelled = false; + this.cancelPoll = () => { + cancelled = true; + }; + + const keepAliveLoop = async () => { + while (!cancelled && !this.disposed) { + await this.doKeepAlive(); + + if (cancelled || this.disposed) break; + + await new Promise((resolve) => { + const timer = setTimeout(resolve, 10000); + this.cancelPoll = () => { + clearTimeout(timer); + cancelled = true; + resolve(); + }; + }); + + if (!cancelled) { + this.cancelPoll = () => { + cancelled = true; + }; + } + } + }; + + keepAliveLoop(); + } + triggerRefresh() { if (this.cancelPoll) { this.cancelPoll(); @@ -208,6 +278,22 @@ export class ProcessViewerViewModel implements ViewModel { } } + forceRefreshOnConnectionChange() { + if (this.cancelPoll) { + this.cancelPoll(); + } + this.cancelPoll = null; + globalStore.set(this.dataAtom, null); + globalStore.set(this.loadingAtom, true); + globalStore.set(this.errorAtom, null); + if (globalStore.get(this.pausedAtom)) { + this.doOneFetch(false); + this.startKeepAlive(); + } else { + this.startPolling(); + } + } + setPaused(paused: boolean) { globalStore.set(this.pausedAtom, paused); if (paused) { @@ -215,6 +301,7 @@ export class ProcessViewerViewModel implements ViewModel { this.cancelPoll(); } this.cancelPoll = null; + this.startKeepAlive(); } else { this.startPolling(); } @@ -223,7 +310,7 @@ export class ProcessViewerViewModel implements ViewModel { setTextSearch(text: string) { globalStore.set(this.textSearchAtom, text); if (globalStore.get(this.pausedAtom)) { - this.doOneFetch(); + this.doOneFetch(false); } else { this.triggerRefresh(); } @@ -262,7 +349,7 @@ export class ProcessViewerViewModel implements ViewModel { globalStore.set(this.sortDescAtom, numericCols.includes(col)); } if (globalStore.get(this.pausedAtom)) { - this.doOneFetch(); + this.doOneFetch(false); } else { this.triggerRefresh(); } @@ -272,14 +359,20 @@ export class ProcessViewerViewModel implements ViewModel { const cur = globalStore.get(this.scrollTopAtom); if (Math.abs(cur - scrollTop) < RowHeight) return; globalStore.set(this.scrollTopAtom, scrollTop); - this.triggerRefresh(); + if (globalStore.get(this.pausedAtom)) { + this.doOneFetch(true); + } } setContainerHeight(height: number) { const cur = globalStore.get(this.containerHeightAtom); if (cur === height) return; globalStore.set(this.containerHeightAtom, height); - this.triggerRefresh(); + if (globalStore.get(this.pausedAtom)) { + this.doOneFetch(true); + } else { + this.triggerRefresh(); + } } async sendSignal(pid: number, signal: string, killLabel?: boolean) { @@ -310,6 +403,41 @@ export class ProcessViewerViewModel implements ViewModel { globalStore.set(this.actionStatusAtom, null); } + setFetchInterval(ms: number) { + globalStore.set(this.fetchIntervalAtom, ms); + this.triggerRefresh(); + } + + getSettingsMenuItems(): ContextMenuItem[] { + const currentInterval = globalStore.get(this.fetchIntervalAtom); + return [ + { + label: "Refresh Interval", + type: "submenu", + submenu: [ + { + label: "1 second", + type: "checkbox", + checked: currentInterval === 1000, + click: () => this.setFetchInterval(1000), + }, + { + label: "2 seconds", + type: "checkbox", + checked: currentInterval === 2000, + click: () => this.setFetchInterval(2000), + }, + { + label: "5 seconds", + type: "checkbox", + checked: currentInterval === 5000, + click: () => this.setFetchInterval(5000), + }, + ], + }, + ]; + } + dispose() { this.disposed = true; if (this.cancelPoll) { @@ -470,6 +598,26 @@ const ProcessRow = React.memo(function ProcessRow({ const gridTemplate = getGridTemplate(platform); const showStatus = platform !== "windows" && platform !== "darwin"; const showThreads = platform !== "windows"; + if (proc.gone) { + return ( +
onSelect(proc.pid)} + onContextMenu={(e) => onContextMenu(proc.pid, e)} + > +
+ {proc.pid} +
+
(gone)
+ {showStatus &&
} +
+ {showThreads &&
} +
+
+
+ ); + } return (
{proc.user}
{showThreads && (
- {proc.numthreads >= 1 ? proc.numthreads : ""} + {proc.numthreads === -1 ? "-" : proc.numthreads >= 1 ? proc.numthreads : ""}
)} -
- {hasCpu && proc.cpu != null ? fmtCpu(proc.cpu) : ""} +
+ {hasCpu ? fmtCpu(proc.cpu) : ""}
{fmtMem(proc.mem)}
@@ -589,7 +737,7 @@ const StatusBar = React.memo(function StatusBar({ model, data, loading, error, w <>
@@ -642,7 +790,7 @@ const StatusBar = React.memo(function StatusBar({ model, data, loading, error, w {hasSummaryCpu && (
@@ -720,12 +868,22 @@ export const ProcessViewerView: React.FC(null); const containerRef = React.useRef(null); const [wide, setWide] = React.useState(false); + const isFirstRender = React.useRef(true); + React.useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + model.forceRefreshOnConnectionChange(); + }, [connection]); + const handleSelectPid = React.useCallback( (pid: number) => { setSelectedPid((cur) => (cur === pid ? null : pid)); @@ -770,13 +928,15 @@ export const ProcessViewerView: React.FC @@ -823,7 +983,7 @@ export const ProcessViewerView: React.FC
diff --git a/frontend/preview/previews/processviewer.preview.tsx b/frontend/preview/previews/processviewer.preview.tsx index f4ab1d0289..72454d36b2 100644 --- a/frontend/preview/previews/processviewer.preview.tsx +++ b/frontend/preview/previews/processviewer.preview.tsx @@ -10,21 +10,21 @@ import { useRpcOverride } from "../mock/use-rpc-override"; const PreviewNodeId = "preview-processviewer-node"; const MockProcesses: ProcessInfo[] = [ - { pid: 1, ppid: 0, command: "launchd", user: "root", cpu: 0.0, mem: 4096 * 1024 }, - { pid: 123, ppid: 1, command: "kernel_task", user: "root", cpu: 12.3, mem: 2048 * 1024 * 1024 }, - { pid: 456, ppid: 1, command: "WindowServer", user: "_windowserver", cpu: 5.1, mem: 512 * 1024 * 1024 }, - { pid: 789, ppid: 1, command: "node", user: "mike", cpu: 8.7, mem: 256 * 1024 * 1024 }, - { pid: 1001, ppid: 1, command: "Electron", user: "mike", cpu: 3.2, mem: 400 * 1024 * 1024 }, - { pid: 1234, ppid: 1001, command: "waveterm-helper", user: "mike", cpu: 0.5, mem: 64 * 1024 * 1024 }, - { pid: 2001, ppid: 1, command: "sshd", user: "root", cpu: 0.0, mem: 8 * 1024 * 1024 }, - { pid: 2345, ppid: 1, command: "postgres", user: "postgres", cpu: 1.2, mem: 128 * 1024 * 1024 }, - { pid: 3001, ppid: 1, command: "nginx", user: "_www", cpu: 0.3, mem: 32 * 1024 * 1024 }, - { pid: 3456, ppid: 1, command: "python3", user: "mike", cpu: 2.8, mem: 96 * 1024 * 1024 }, - { pid: 4001, ppid: 1, command: "docker", user: "root", cpu: 0.1, mem: 48 * 1024 * 1024 }, - { pid: 4567, ppid: 4001, command: "containerd", user: "root", cpu: 0.2, mem: 80 * 1024 * 1024 }, - { pid: 5001, ppid: 1, command: "zsh", user: "mike", cpu: 0.0, mem: 6 * 1024 * 1024 }, - { pid: 5678, ppid: 5001, command: "vim", user: "mike", cpu: 0.0, mem: 20 * 1024 * 1024 }, - { pid: 6001, ppid: 1, command: "coreaudiod", user: "_coreaudiod", cpu: 0.4, mem: 16 * 1024 * 1024 }, + { pid: 1, ppid: 0, command: "launchd", user: "root", cpu: 0.0, mem: 4096 * 1024, mempct: 0.01 }, + { pid: 123, ppid: 1, command: "kernel_task", user: "root", cpu: 12.3, mem: 2048 * 1024 * 1024, mempct: 6.25 }, + { pid: 456, ppid: 1, command: "WindowServer", user: "_windowserver", cpu: 5.1, mem: 512 * 1024 * 1024, mempct: 1.56 }, + { pid: 789, ppid: 1, command: "node", user: "mike", cpu: 8.7, mem: 256 * 1024 * 1024, mempct: 0.78 }, + { pid: 1001, ppid: 1, command: "Electron", user: "mike", cpu: 3.2, mem: 400 * 1024 * 1024, mempct: 1.22 }, + { pid: 1234, ppid: 1001, command: "waveterm-helper", user: "mike", cpu: 0.5, mem: 64 * 1024 * 1024, mempct: 0.20 }, + { pid: 2001, ppid: 1, command: "sshd", user: "root", cpu: 0.0, mem: 8 * 1024 * 1024, mempct: 0.02 }, + { pid: 2345, ppid: 1, command: "postgres", user: "postgres", cpu: 1.2, mem: 128 * 1024 * 1024, mempct: 0.39 }, + { pid: 3001, ppid: 1, command: "nginx", user: "_www", cpu: 0.3, mem: 32 * 1024 * 1024, mempct: 0.10 }, + { pid: 3456, ppid: 1, command: "python3", user: "mike", cpu: 2.8, mem: 96 * 1024 * 1024, mempct: 0.29 }, + { pid: 4001, ppid: 1, command: "docker", user: "root", cpu: 0.1, mem: 48 * 1024 * 1024, mempct: 0.15 }, + { pid: 4567, ppid: 4001, command: "containerd", user: "root", cpu: 0.2, mem: 80 * 1024 * 1024, mempct: 0.24 }, + { pid: 5001, ppid: 1, command: "zsh", user: "mike", cpu: 0.0, mem: 6 * 1024 * 1024, mempct: 0.02 }, + { pid: 5678, ppid: 5001, command: "vim", user: "mike", cpu: 0.0, mem: 20 * 1024 * 1024, mempct: 0.06 }, + { pid: 6001, ppid: 1, command: "coreaudiod", user: "_coreaudiod", cpu: 0.4, mem: 16 * 1024 * 1024, mempct: 0.05 }, ]; const MockSummary: ProcessSummary = { diff --git a/frontend/tailwindsetup.css b/frontend/tailwindsetup.css index ffe6ed471b..3a0523c8ce 100644 --- a/frontend/tailwindsetup.css +++ b/frontend/tailwindsetup.css @@ -93,6 +93,10 @@ svg [aria-label="tip"] g path { color: var(--border-color); } +.wide-scrollbar::-webkit-scrollbar { + width: 10px; +} + /* Monaco editor scrollbar styling */ .monaco-editor .slider { background: rgba(255, 255, 255, 0.4); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 0e56b7d345..4784b05c19 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -559,12 +559,14 @@ declare global { // wshrpc.CommandRemoteProcessListData type CommandRemoteProcessListData = { + widgetid?: string; sortby?: string; sortdesc?: boolean; start?: number; limit?: number; textsearch?: string; - pids?: number[]; + lastpidorder?: boolean; + keepalive?: boolean; }; // wshrpc.CommandRemoteProcessSignalData @@ -1267,10 +1269,11 @@ declare global { command?: string; status?: string; user?: string; - mem?: number; - mempct?: number; - cpu?: number; - numthreads?: number; + mem: number; + mempct: number; + cpu: number; + numthreads: number; + gone?: boolean; }; // wshrpc.ProcessListResponse diff --git a/package-lock.json b/package-lock.json index c6c3fc35eb..d4fc4ab31c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.14.4-beta.2", + "version": "0.14.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.14.4-beta.2", + "version": "0.14.4", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/pkg/util/procinfo/procinfo.go b/pkg/util/procinfo/procinfo.go index 1a1fa549ff..858ef11863 100644 --- a/pkg/util/procinfo/procinfo.go +++ b/pkg/util/procinfo/procinfo.go @@ -27,14 +27,16 @@ var LinuxStatStatus = map[string]string{ // ProcInfo holds per-process information read from the OS. // CpuUser and CpuSys are cumulative CPU seconds since process start; // callers should diff two samples over a known interval to derive a rate. +// CpuUser, CpuSys, and VmRSS are set to -1 when the data is unavailable +// (e.g. permission denied reading another user's process). type ProcInfo struct { Pid int32 Ppid int32 Command string Status string - CpuUser float64 // cumulative user CPU seconds - CpuSys float64 // cumulative system CPU seconds - VmRSS uint64 // resident set size in bytes + CpuUser float64 // cumulative user CPU seconds; -1 if unavailable + CpuSys float64 // cumulative system CPU seconds; -1 if unavailable + VmRSS int64 // resident set size in bytes; -1 if unavailable Uid uint32 - NumThreads int32 + NumThreads int32 // -1 if unavailable } diff --git a/pkg/util/procinfo/procinfo_darwin.go b/pkg/util/procinfo/procinfo_darwin.go index b575d141e2..88dd4a885f 100644 --- a/pkg/util/procinfo/procinfo_darwin.go +++ b/pkg/util/procinfo/procinfo_darwin.go @@ -11,7 +11,6 @@ import ( "unsafe" "github.com/ebitengine/purego" - goproc "github.com/shirou/gopsutil/v4/process" "golang.org/x/sys/unix" ) @@ -88,22 +87,17 @@ func GetProcInfo(ctx context.Context, _ any, pid int32) (*ProcInfo, error) { Uid: k.Eproc.Ucred.Uid, } + info.CpuUser = -1 + info.CpuSys = -1 + info.VmRSS = -1 + info.NumThreads = -1 if ti, terr := getDarwinProcTaskInfo(pid); terr == nil { if darwinTimeScale > 0 { info.CpuUser = float64(ti.TotalUser) * darwinTimeScale / 1e9 info.CpuSys = float64(ti.TotalSystem) * darwinTimeScale / 1e9 } - info.VmRSS = ti.ResidentSize + info.VmRSS = int64(ti.ResidentSize) info.NumThreads = ti.Threadnum - } else { - if p, gerr := goproc.NewProcessWithContext(ctx, pid); gerr == nil { - if mi, merr := p.MemoryInfoWithContext(ctx); merr == nil { - info.VmRSS = mi.RSS - } - if nt, nerr := p.NumThreadsWithContext(ctx); nerr == nil { - info.NumThreads = nt - } - } } return info, nil diff --git a/pkg/util/procinfo/procinfo_linux.go b/pkg/util/procinfo/procinfo_linux.go index c885456fc8..abd6d3aa75 100644 --- a/pkg/util/procinfo/procinfo_linux.go +++ b/pkg/util/procinfo/procinfo_linux.go @@ -90,12 +90,6 @@ func readStat(pid int32) (*ProcInfo, error) { return nil, fmt.Errorf("procinfo: parse pid: %w", err) } - ppid, _ := strconv.ParseInt(rest[1], 10, 32) - utime, _ := strconv.ParseUint(rest[11], 10, 64) - stime, _ := strconv.ParseUint(rest[12], 10, 64) - numThreads, _ := strconv.ParseInt(rest[17], 10, 32) - rssPages, _ := strconv.ParseInt(rest[21], 10, 64) - statusChar := rest[0] status, ok := LinuxStatStatus[statusChar] if !ok { @@ -104,14 +98,30 @@ func readStat(pid int32) (*ProcInfo, error) { info := &ProcInfo{ Pid: int32(parsedPid), - Ppid: int32(ppid), Command: comm, Status: status, - CpuUser: float64(utime) / userHz, - CpuSys: float64(stime) / userHz, - VmRSS: uint64(rssPages * pageSize), - NumThreads: int32(numThreads), + CpuUser: -1, + CpuSys: -1, + VmRSS: -1, + NumThreads: -1, + } + + if ppid, err := strconv.ParseInt(rest[1], 10, 32); err == nil { + info.Ppid = int32(ppid) + } + if utime, err := strconv.ParseUint(rest[11], 10, 64); err == nil { + info.CpuUser = float64(utime) / userHz } + if stime, err := strconv.ParseUint(rest[12], 10, 64); err == nil { + info.CpuSys = float64(stime) / userHz + } + if numThreads, err := strconv.ParseInt(rest[17], 10, 32); err == nil { + info.NumThreads = int32(numThreads) + } + if rssPages, err := strconv.ParseInt(rest[21], 10, 64); err == nil { + info.VmRSS = rssPages * pageSize + } + return info, nil } diff --git a/pkg/util/procinfo/procinfo_windows.go b/pkg/util/procinfo/procinfo_windows.go index 39054c6383..6ea131bc01 100644 --- a/pkg/util/procinfo/procinfo_windows.go +++ b/pkg/util/procinfo/procinfo_windows.go @@ -97,6 +97,9 @@ func GetProcInfo(_ context.Context, snap any, pid int32) (*ProcInfo, error) { Ppid: int32(si.ppid), NumThreads: int32(si.numThreads), Command: si.exeName, + CpuUser: -1, + CpuSys: -1, + VmRSS: -1, } handle, err := windows.OpenProcess( @@ -127,7 +130,7 @@ func GetProcInfo(_ context.Context, snap any, pid int32) (*ProcInfo, error) { uintptr(mc.CB), ) if r != 0 { - info.VmRSS = uint64(mc.WorkingSetSize) + info.VmRSS = int64(mc.WorkingSetSize) } return info, nil diff --git a/pkg/wshrpc/wshremote/processviewer.go b/pkg/wshrpc/wshremote/processviewer.go index 647c027424..f4248d51f6 100644 --- a/pkg/wshrpc/wshremote/processviewer.go +++ b/pkg/wshrpc/wshremote/processviewer.go @@ -25,7 +25,7 @@ import ( ) const ( - ProcCacheIdleTimeout = 10 * time.Second + ProcCacheIdleTimeout = 60 * time.Second ProcCachePollInterval = 1 * time.Second ProcViewerMaxLimit = 500 ) @@ -37,6 +37,13 @@ type cpuSample struct { Epoch int // epoch at which this sample was recorded } +// widgetPidOrder stores the ordered pid list from the last non-LastPidOrder request for a widget. +type widgetPidOrder struct { + pids []int32 + totalCount int + lastRequest time.Time +} + // procCacheState is the singleton background cache for process list data. // lastCPUSamples, lastCPUEpoch, and uidCache are only accessed by the single runLoop goroutine. type procCacheState struct { @@ -50,6 +57,8 @@ type procCacheState struct { lastCPUSamples map[int32]cpuSample lastCPUEpoch int uidCache map[uint32]string // uid -> username, populated lazily + + widgetPidOrders map[string]*widgetPidOrder // keyed by widgetId } // procCache is the singleton background cache for process list data. @@ -88,6 +97,62 @@ func (s *procCacheState) requestAndWait(ctx context.Context) (*wshrpc.ProcessLis return result, nil } +func (s *procCacheState) touchLastRequest() { + s.lock.Lock() + defer s.lock.Unlock() + s.lastRequest = time.Now() +} + +func (s *procCacheState) touchWidgetPidOrder(widgetId string) { + if widgetId == "" { + return + } + s.lock.Lock() + defer s.lock.Unlock() + s.lastRequest = time.Now() + if s.widgetPidOrders != nil { + if entry, ok := s.widgetPidOrders[widgetId]; ok { + entry.lastRequest = time.Now() + } + } +} + +func (s *procCacheState) storeWidgetPidOrder(widgetId string, pids []int32, totalCount int) { + if widgetId == "" { + return + } + s.lock.Lock() + defer s.lock.Unlock() + if s.widgetPidOrders == nil { + s.widgetPidOrders = make(map[string]*widgetPidOrder) + } + s.widgetPidOrders[widgetId] = &widgetPidOrder{ + pids: pids, + totalCount: totalCount, + lastRequest: time.Now(), + } +} + +func (s *procCacheState) getWidgetPidOrder(widgetId string) ([]int32, int) { + if widgetId == "" { + return nil, 0 + } + s.lock.Lock() + defer s.lock.Unlock() + if s.widgetPidOrders == nil { + return nil, 0 + } + entry, ok := s.widgetPidOrders[widgetId] + if !ok { + return nil, 0 + } + if time.Since(entry.lastRequest) >= ProcCacheIdleTimeout { + delete(s.widgetPidOrders, widgetId) + return nil, 0 + } + return entry.pids, entry.totalCount +} + func (s *procCacheState) runLoop(firstReadyCh chan struct{}) { defer func() { panichandler.PanicHandler("procCache.runLoop", recover()) @@ -127,6 +192,7 @@ func (s *procCacheState) runLoop(firstReadyCh chan struct{}) { s.lastCPUSamples = nil s.lastCPUEpoch = 0 s.uidCache = nil + s.widgetPidOrders = nil s.lock.Unlock() return } @@ -208,6 +274,9 @@ func (s *procCacheState) collectSnapshot(numCPU int) *wshrpc.ProcessListResponse if ri.info == nil { continue } + if ri.info.CpuUser < 0 || ri.info.CpuSys < 0 { + continue + } curCPUSec := ri.info.CpuUser + ri.info.CpuSys if hasCPU { @@ -245,16 +314,17 @@ func (s *procCacheState) collectSnapshot(numCPU int) *wshrpc.ProcessListResponse Command: pi.Command, Status: pi.Status, Mem: pi.VmRSS, + MemPct: -1, + Cpu: -1, NumThreads: pi.NumThreads, User: s.lookupUID(pi.Uid), } - if totalMem > 0 { + if totalMem > 0 && pi.VmRSS >= 0 { info.MemPct = float64(pi.VmRSS) / float64(totalMem) * 100 } if hasCPU { if cpu, ok := cpuPcts[pi.Pid]; ok { - v := cpu - info.Cpu = &v + info.Cpu = cpu cpuSum += cpu } } @@ -322,56 +392,88 @@ func filterProcesses(processes []wshrpc.ProcessInfo, textSearch string) []wshrpc return filtered } -func sortAndLimitProcesses(processes []wshrpc.ProcessInfo, sortBy string, sortDesc bool, start int, limit int) []wshrpc.ProcessInfo { +func sortProcesses(processes []wshrpc.ProcessInfo, sortBy string, sortDesc bool) { switch sortBy { case "cpu": sort.Slice(processes, func(i, j int) bool { - ci, cj := 0.0, 0.0 - if processes[i].Cpu != nil { - ci = *processes[i].Cpu + ci := processes[i].Cpu + cj := processes[j].Cpu + iNull := ci < 0 + jNull := cj < 0 + if iNull != jNull { + return !iNull } - if processes[j].Cpu != nil { - cj = *processes[j].Cpu - } - if sortDesc { - return ci > cj + if !iNull && ci != cj { + if sortDesc { + return ci > cj + } + return ci < cj } - return ci < cj + return processes[i].Pid < processes[j].Pid }) case "mem": sort.Slice(processes, func(i, j int) bool { - if sortDesc { - return processes[i].Mem > processes[j].Mem + mi := processes[i].Mem + mj := processes[j].Mem + iNull := mi < 0 + jNull := mj < 0 + if iNull != jNull { + return !iNull + } + if !iNull && mi != mj { + if sortDesc { + return mi > mj + } + return mi < mj } - return processes[i].Mem < processes[j].Mem + return processes[i].Pid < processes[j].Pid }) case "command": sort.Slice(processes, func(i, j int) bool { - if sortDesc { - return processes[i].Command > processes[j].Command + if processes[i].Command != processes[j].Command { + if sortDesc { + return processes[i].Command > processes[j].Command + } + return processes[i].Command < processes[j].Command } - return processes[i].Command < processes[j].Command + return processes[i].Pid < processes[j].Pid }) case "user": sort.Slice(processes, func(i, j int) bool { - if sortDesc { - return processes[i].User > processes[j].User + if processes[i].User != processes[j].User { + if sortDesc { + return processes[i].User > processes[j].User + } + return processes[i].User < processes[j].User } - return processes[i].User < processes[j].User + return processes[i].Pid < processes[j].Pid }) case "status": sort.Slice(processes, func(i, j int) bool { - if sortDesc { - return processes[i].Status > processes[j].Status + if processes[i].Status != processes[j].Status { + if sortDesc { + return processes[i].Status > processes[j].Status + } + return processes[i].Status < processes[j].Status } - return processes[i].Status < processes[j].Status + return processes[i].Pid < processes[j].Pid }) case "threads": sort.Slice(processes, func(i, j int) bool { - if sortDesc { - return processes[i].NumThreads > processes[j].NumThreads + ti := processes[i].NumThreads + tj := processes[j].NumThreads + iNull := ti < 0 + jNull := tj < 0 + if iNull != jNull { + return !iNull + } + if !iNull && ti != tj { + if sortDesc { + return ti > tj + } + return ti < tj } - return processes[i].NumThreads < processes[j].NumThreads + return processes[i].Pid < processes[j].Pid }) default: // "pid" sort.Slice(processes, func(i, j int) bool { @@ -381,63 +483,83 @@ func sortAndLimitProcesses(processes []wshrpc.ProcessInfo, sortBy string, sortDe return processes[i].Pid < processes[j].Pid }) } - if start > 0 { - if start >= len(processes) { - return nil - } - processes = processes[start:] - } - if limit > 0 && len(processes) > limit { - processes = processes[:limit] - } - return processes } func (impl *ServerImpl) RemoteProcessListCommand(ctx context.Context, data wshrpc.CommandRemoteProcessListData) (*wshrpc.ProcessListResponse, error) { + if data.KeepAlive { + if data.WidgetId != "" { + procCache.touchWidgetPidOrder(data.WidgetId) + } else { + procCache.touchLastRequest() + } + return nil, nil + } + raw, err := procCache.requestAndWait(ctx) if err != nil { return nil, err } - // Pids overrides all other request fields; when set we skip sort/limit/start/textsearch - // and return only the exact pids requested. - if len(data.Pids) > 0 { - pidSet := make(map[int32]struct{}, len(data.Pids)) - for _, pid := range data.Pids { - pidSet[pid] = struct{}{} + totalCount := len(raw.Processes) + + // Phase 1: derive the pid order. + // Use cached order if LastPidOrder is set and a cached order exists; otherwise filter/sort and store. + var pidOrder []int32 + var filteredCount int + if data.LastPidOrder { + var cachedTotal int + pidOrder, cachedTotal = procCache.getWidgetPidOrder(data.WidgetId) + if pidOrder != nil { + filteredCount = len(pidOrder) + totalCount = cachedTotal } - processes := make([]wshrpc.ProcessInfo, 0, len(data.Pids)) - for _, p := range raw.Processes { - if _, ok := pidSet[p.Pid]; ok { - processes = append(processes, p) - } + } + if pidOrder == nil { + sortBy := data.SortBy + sortDesc := data.SortDesc + if sortBy == "" { + sortBy = "cpu" + sortDesc = true + } + procs := make([]wshrpc.ProcessInfo, len(raw.Processes)) + copy(procs, raw.Processes) + procs = filterProcesses(procs, data.TextSearch) + filteredCount = len(procs) + sortProcesses(procs, sortBy, sortDesc) + pidOrder = make([]int32, len(procs)) + for i, p := range procs { + pidOrder[i] = p.Pid + } + if data.WidgetId != "" { + procCache.storeWidgetPidOrder(data.WidgetId, pidOrder, totalCount) } - return &wshrpc.ProcessListResponse{ - Processes: processes, - Summary: raw.Summary, - Ts: raw.Ts, - HasCPU: raw.HasCPU, - Platform: raw.Platform, - }, nil } - sortBy := data.SortBy - if sortBy == "" { - sortBy = "cpu" - } + // Phase 2: limit and populate process info from the pid order. limit := data.Limit if limit <= 0 || limit > ProcViewerMaxLimit { limit = ProcViewerMaxLimit } - - totalCount := len(raw.Processes) - - // Copy processes so we can sort/limit without mutating the cache. - processes := make([]wshrpc.ProcessInfo, len(raw.Processes)) - copy(processes, raw.Processes) - processes = filterProcesses(processes, data.TextSearch) - filteredCount := len(processes) - processes = sortAndLimitProcesses(processes, sortBy, data.SortDesc, data.Start, limit) + pidMap := make(map[int32]wshrpc.ProcessInfo, len(raw.Processes)) + for _, p := range raw.Processes { + pidMap[p.Pid] = p + } + start := data.Start + if start >= len(pidOrder) { + start = len(pidOrder) + } + window := pidOrder[start:] + if limit > 0 && len(window) > limit { + window = window[:limit] + } + processes := make([]wshrpc.ProcessInfo, 0, len(window)) + for _, pid := range window { + if p, ok := pidMap[pid]; ok { + processes = append(processes, p) + } else { + processes = append(processes, wshrpc.ProcessInfo{Pid: pid, Gone: true}) + } + } return &wshrpc.ProcessListResponse{ Processes: processes, diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index cc4b793da8..98c65e0526 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -911,16 +911,20 @@ type FocusedBlockData struct { TermLastCommand string `json:"termlastcommand,omitempty"` } +// ProcessInfo holds per-process information for the process viewer. +// Mem, MemPct, Cpu, and NumThreads are set to -1 when the data is unavailable +// (e.g. permission denied reading another user's process on macOS). type ProcessInfo struct { - Pid int32 `json:"pid"` - Ppid int32 `json:"ppid,omitempty"` - Command string `json:"command,omitempty"` - Status string `json:"status,omitempty"` - User string `json:"user,omitempty"` - Mem uint64 `json:"mem,omitempty"` - MemPct float64 `json:"mempct,omitempty"` - Cpu *float64 `json:"cpu,omitempty"` - NumThreads int32 `json:"numthreads,omitempty"` + Pid int32 `json:"pid"` + Ppid int32 `json:"ppid,omitempty"` + Command string `json:"command,omitempty"` + Status string `json:"status,omitempty"` + User string `json:"user,omitempty"` + Mem int64 `json:"mem"` // resident set size in bytes; -1 if unavailable + MemPct float64 `json:"mempct"` // memory percent; -1 if unavailable + Cpu float64 `json:"cpu"` // cpu percent; -1 if unavailable + NumThreads int32 `json:"numthreads"` // -1 if unavailable + Gone bool `json:"gone,omitempty"` } type ProcessSummary struct { @@ -946,13 +950,17 @@ type ProcessListResponse struct { } type CommandRemoteProcessListData struct { + WidgetId string `json:"widgetid,omitempty"` SortBy string `json:"sortby,omitempty"` SortDesc bool `json:"sortdesc,omitempty"` Start int `json:"start,omitempty"` Limit int `json:"limit,omitempty"` TextSearch string `json:"textsearch,omitempty"` - // Pids overrides all other fields; when set, returns only the specified pids (no sort/limit/start/textsearch). - Pids []int32 `json:"pids,omitempty"` + // LastPidOrder, when set, ignores SortBy/SortDesc/TextSearch and returns processes in the order + // they were returned in the previous request for this WidgetId (with Gone=true for dead pids). + LastPidOrder bool `json:"lastpidorder,omitempty"` + // KeepAlive, when set, overrides all other fields and simply keeps the backend cache alive (returns nil). + KeepAlive bool `json:"keepalive,omitempty"` } type CommandRemoteProcessSignalData struct { From 02b9e5fe80bc27f7cc15126924c09a48ce2f44dd Mon Sep 17 00:00:00 2001 From: JustHereToHelp <50504690+JustHereToHelp@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:06:26 +0200 Subject: [PATCH 06/47] feat: drag and drop file paths into terminal (#2857) Fixes #746, fixes #2813 Drag a file from Finder into a terminal and it pastes the quoted path. Uses `webUtils.getPathForFile()` through a preload bridge since Electron 32 killed `File.path`. Handles spaces in filenames. Needs app restart after install (preload change). --- emain/preload.ts | 3 ++- frontend/app/view/term/termutil.ts | 4 +++ frontend/app/view/term/termwrap.ts | 41 +++++++++++++++++++++++++++++- frontend/types/custom.d.ts | 1 + 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/emain/preload.ts b/emain/preload.ts index 823f99c4cd..8d2b18a308 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -1,7 +1,7 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { contextBridge, ipcRenderer, Rectangle, WebviewTag } from "electron"; +import { contextBridge, ipcRenderer, Rectangle, webUtils, WebviewTag } from "electron"; // update type in custom.d.ts (ElectronApi type) contextBridge.exposeInMainWorld("api", { @@ -70,6 +70,7 @@ contextBridge.exposeInMainWorld("api", { openBuilder: (appId?: string) => ipcRenderer.send("open-builder", appId), setBuilderWindowAppId: (appId: string) => ipcRenderer.send("set-builder-window-appid", appId), doRefresh: () => ipcRenderer.send("do-refresh"), + getPathForFile: (file: File): string => webUtils.getPathForFile(file), saveTextFile: (fileName: string, content: string) => ipcRenderer.invoke("save-text-file", fileName, content), setIsActive: () => ipcRenderer.invoke("set-is-active"), }); diff --git a/frontend/app/view/term/termutil.ts b/frontend/app/view/term/termutil.ts index e49b8d4b8a..2fea30404a 100644 --- a/frontend/app/view/term/termutil.ts +++ b/frontend/app/view/term/termutil.ts @@ -389,3 +389,7 @@ export function bufferLinesToText(buffer: TermTypes.IBuffer, startIndex: number, return lines; } + +export function quoteForPosixShell(filePath: string): string { + return "'" + filePath.replace(/'/g, "'\\''") + "'"; +} diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index d79ce695cd..e1b129b72d 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -8,6 +8,7 @@ import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { fetchWaveFile, + getApi, getOverrideConfigAtom, getSettingsKeyAtom, globalStore, @@ -35,7 +36,13 @@ import { isClaudeCodeCommand, type ShellIntegrationStatus, } from "./osc-handlers"; -import { bufferLinesToText, createTempFileFromBlob, extractAllClipboardData, normalizeCursorStyle } from "./termutil"; +import { + bufferLinesToText, + createTempFileFromBlob, + extractAllClipboardData, + normalizeCursorStyle, + quoteForPosixShell, +} from "./termutil"; const dlog = debug("wave:termwrap"); @@ -274,6 +281,38 @@ export class TermWrap { this.heldData = []; this.handleResize_debounced = debounce(50, this.handleResize.bind(this)); this.terminal.open(this.connectElem); + + const dragoverHandler = (e: DragEvent) => { + e.preventDefault(); + if (e.dataTransfer) { + e.dataTransfer.dropEffect = "copy"; + } + }; + const dropHandler = (e: DragEvent) => { + e.preventDefault(); + if (!e.dataTransfer || e.dataTransfer.files.length === 0) { + return; + } + const paths: string[] = []; + for (let i = 0; i < e.dataTransfer.files.length; i++) { + const file = e.dataTransfer.files[i]; + const filePath = getApi().getPathForFile(file); + if (filePath) { + paths.push(quoteForPosixShell(filePath)); + } + } + if (paths.length > 0) { + this.terminal.paste(paths.join(" ") + " "); + } + }; + this.connectElem.addEventListener("dragover", dragoverHandler); + this.connectElem.addEventListener("drop", dropHandler); + this.toDispose.push({ + dispose: () => { + this.connectElem.removeEventListener("dragover", dragoverHandler); + this.connectElem.removeEventListener("drop", dropHandler); + }, + }); this.handleResize(); const pasteHandler = this.pasteHandler.bind(this); this.connectElem.addEventListener("paste", pasteHandler, true); diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 9f7cb15ad3..06157e2566 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -133,6 +133,7 @@ declare global { openBuilder: (appId?: string) => void; // open-builder setBuilderWindowAppId: (appId: string) => void; // set-builder-window-appid doRefresh: () => void; // do-refresh + getPathForFile: (file: File) => string; // webUtils.getPathForFile saveTextFile: (fileName: string, content: string) => Promise; // save-text-file setIsActive: () => Promise; // set-is-active }; From c99bd4888abbc6c68f3f534305a8e6832af5cc68 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Tue, 31 Mar 2026 10:46:43 -0700 Subject: [PATCH 07/47] new keybindng F2 to edit the active tab name, also fix refocus after name editing (#3158) --- docs/docs/keybindings.mdx | 2 ++ frontend/app/store/keymodel.ts | 8 ++++++++ frontend/app/store/tab-model.ts | 1 + frontend/app/tab/tab.tsx | 12 ++++++++++++ frontend/app/tab/vtab.tsx | 2 ++ frontend/app/tab/vtabbar.tsx | 12 ++++++++++++ 6 files changed, 37 insertions(+) diff --git a/docs/docs/keybindings.mdx b/docs/docs/keybindings.mdx index fa8dcae1ba..36ca33a9ce 100644 --- a/docs/docs/keybindings.mdx +++ b/docs/docs/keybindings.mdx @@ -6,6 +6,7 @@ title: "Key Bindings" import { Kbd, KbdChord } from "@site/src/components/kbd"; import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext"; +import { VersionBadge } from "@site/src/components/versionbadge"; @@ -44,6 +45,7 @@ Chords are shown with a + between the keys. You have 2 seconds to hit the 2nd ch | | Switch to block number | | / | Move left, right, up, down between blocks | | | Replace the current block with a launcher block | +| | Rename the current tab | | | Switch to tab number | | / | Switch tab left | | / | Switch tab right | diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index afa5209116..3df35f9ba3 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -634,6 +634,14 @@ function registerGlobalKeys() { ); return true; }); + globalKeyMap.set("F2", () => { + const tabModel = getActiveTabModel(); + if (tabModel?.startRenameCallback != null) { + tabModel.startRenameCallback(); + return true; + } + return false; + }); globalKeyMap.set("Cmd:g", () => { const bcm = getBlockComponentModel(getFocusedBlockInStaticTab()); if (bcm.openSwitchConnection != null) { diff --git a/frontend/app/store/tab-model.ts b/frontend/app/store/tab-model.ts index a867440820..75eeb479a7 100644 --- a/frontend/app/store/tab-model.ts +++ b/frontend/app/store/tab-model.ts @@ -21,6 +21,7 @@ export class TabModel { tabNumBlocksAtom: Atom; isTermMultiInput = atom(false) as PrimitiveAtom; metaCache: Map> = new Map(); + startRenameCallback: (() => void) | null = null; constructor(tabId: string, waveEnv?: TabModelEnv) { this.tabId = tabId; diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 7b2aa6856e..4972a13daa 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -3,6 +3,7 @@ import { getTabBadgeAtom } from "@/app/store/badge"; import { refocusNode } from "@/app/store/global"; +import { getTabModelByTabId } from "@/app/store/tab-model"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WaveEnv, WaveEnvSubset, useWaveEnv } from "@/app/waveenv/waveenv"; import { Button } from "@/element/button"; @@ -251,6 +252,7 @@ const TabInner = forwardRef((props, ref) => { const loadedRef = useRef(false); const renameRef = useRef<(() => void) | null>(null); + const tabModel = getTabModelByTabId(id, env); useEffect(() => { if (!loadedRef.current) { @@ -259,6 +261,16 @@ const TabInner = forwardRef((props, ref) => { } }, [onLoaded]); + useEffect(() => { + const cb = () => renameRef.current?.(); + tabModel.startRenameCallback = cb; + return () => { + if (tabModel.startRenameCallback === cb) { + tabModel.startRenameCallback = null; + } + }; + }, [tabModel]); + const handleTabClick = () => { onSelect(); }; diff --git a/frontend/app/tab/vtab.tsx b/frontend/app/tab/vtab.tsx index 7edd7b0f45..4c70d5ec37 100644 --- a/frontend/app/tab/vtab.tsx +++ b/frontend/app/tab/vtab.tsx @@ -1,6 +1,7 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { refocusNode } from "@/app/store/global"; import { validateCssColor } from "@/util/color-validator"; import { cn } from "@/util/util"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -122,6 +123,7 @@ export function VTab({ if (newText !== originalName) { onRename?.(newText); } + setTimeout(() => refocusNode(null), 10); }; const handleKeyDown: React.KeyboardEventHandler = (event) => { diff --git a/frontend/app/tab/vtabbar.tsx b/frontend/app/tab/vtabbar.tsx index f8cbc751fb..e40bcfb374 100644 --- a/frontend/app/tab/vtabbar.tsx +++ b/frontend/app/tab/vtabbar.tsx @@ -3,6 +3,7 @@ import { Tooltip } from "@/app/element/tooltip"; import { getTabBadgeAtom } from "@/app/store/badge"; +import { getTabModelByTabId } from "@/app/store/tab-model"; import { makeORef } from "@/app/store/wos"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { useWaveEnv } from "@/app/waveenv/waveenv"; @@ -121,6 +122,17 @@ function VTabWrapper({ const [tabData] = env.wos.useWaveObjectValue(makeORef("tab", tabId)); const badges = useAtomValue(getTabBadgeAtom(tabId, env)); const renameRef = useRef<(() => void) | null>(null); + const tabModel = getTabModelByTabId(tabId, env); + + useEffect(() => { + const cb = () => renameRef.current?.(); + tabModel.startRenameCallback = cb; + return () => { + if (tabModel.startRenameCallback === cb) { + tabModel.startRenameCallback = null; + } + }; + }, [tabModel]); const rawFlagColor = tabData?.meta?.["tab:flagcolor"]; let flagColor: string | null = null; From 984b4e5eb1d7c0f13faa611c02866c822f891a11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=9Awiszcz?= Date: Wed, 1 Apr 2026 00:11:15 +0200 Subject: [PATCH 08/47] fix: Mouse Back/Forward support in webviews + few bugfixes (#3141) - Add Mouse-3/Mouse-4 (back/forward) navigation support in webviews - Add COLORTERM=truecolor env variable for terminal sessions - Fix AI button width calculation when button is hidden - Fix setSizeAndPosition animation on tab layout updates - Increase DevInitTimeout for slower startup scenarios - Update .gitignore and package-lock.json --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .gitignore | 3 +++ emain/emain-ipc.ts | 8 ++++++++ emain/preload-webview.ts | 11 +++++++++++ frontend/app/tab/tabbar.tsx | 5 +++-- pkg/util/shellutil/shellutil.go | 3 +++ 5 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index a1c7240b5a..a56b777457 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ test-results.xml docsite/ .kilo-format-temp-* +.superpowers +docs/superpowers +.claude diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index 09830b9315..38067b7790 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -236,6 +236,14 @@ export function initIpcHandlers() { menu.popup(); }); + electron.ipcMain.on("webview-mouse-navigate", (event: electron.IpcMainEvent, direction: string) => { + if (direction === "back") { + event.sender.navigationHistory.goBack(); + } else if (direction === "forward") { + event.sender.navigationHistory.goForward(); + } + }); + electron.ipcMain.on("download", (event, payload) => { const baseName = encodeURIComponent(path.basename(payload.filePath)); const streamingUrl = diff --git a/emain/preload-webview.ts b/emain/preload-webview.ts index da60a6ea54..e2a39a3b4e 100644 --- a/emain/preload-webview.ts +++ b/emain/preload-webview.ts @@ -25,4 +25,15 @@ document.addEventListener("contextmenu", (event) => { // do nothing }); +document.addEventListener("mouseup", (event) => { + // Mouse button 3 = back, button 4 = forward + if (!event.isTrusted) { + return; + } + if (event.button === 3 || event.button === 4) { + event.preventDefault(); + ipcRenderer.send("webview-mouse-navigate", event.button === 3 ? "back" : "forward"); + } +}); + console.log("loaded wave preload-webview.ts"); diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index 62e9052e31..b404afcb7e 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -189,7 +189,8 @@ const TabBar = memo(({ workspace, noTabs }: TabBarProps) => { const addBtnWidth = getOuterWidth(addBtnRef.current); const appMenuButtonWidth = appMenuButtonRef.current?.getBoundingClientRect().width ?? 0; const workspaceSwitcherWidth = workspaceSwitcherRef.current?.getBoundingClientRect().width ?? 0; - const waveAIButtonWidth = waveAIButtonRef.current != null ? getOuterWidth(waveAIButtonRef.current) : 0; + const waveAIButtonWidth = + !hideAiButton && waveAIButtonRef.current != null ? getOuterWidth(waveAIButtonRef.current) : 0; const nonTabElementsWidth = windowDragLeftWidth + @@ -276,7 +277,7 @@ const TabBar = memo(({ workspace, noTabs }: TabBarProps) => { // Check if all tabs are loaded const allLoaded = tabIds.length > 0 && tabIds.every((id) => tabsLoaded[id]); if (allLoaded) { - setSizeAndPosition(newTabId === null && prevAllLoadedRef.current); + setSizeAndPosition(false); saveTabsPosition(); if (!prevAllLoadedRef.current) { prevAllLoadedRef.current = true; diff --git a/pkg/util/shellutil/shellutil.go b/pkg/util/shellutil/shellutil.go index e6f6c21f38..ec62c455d1 100644 --- a/pkg/util/shellutil/shellutil.go +++ b/pkg/util/shellutil/shellutil.go @@ -222,6 +222,9 @@ func WaveshellLocalEnvVars(termType string) map[string]string { } // these are not necessary since they should be set with the swap token, but no harm in setting them here rtn["TERM_PROGRAM"] = "waveterm" + if os.Getenv("COLORTERM") == "" { + rtn["COLORTERM"] = "truecolor" + } rtn["WAVETERM"], _ = os.Executable() rtn["WAVETERM_VERSION"] = wavebase.WaveVersion rtn["WAVETERM_WSHBINDIR"] = filepath.Join(wavebase.GetWaveDataDir(), WaveHomeBinDir) From bda8421377f4abcf9c30cfc9a40bfa7190a1f72e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=9Awiszcz?= Date: Wed, 1 Apr 2026 01:06:13 +0200 Subject: [PATCH 09/47] feat: add widgets sidebar toggle button to view menu (#3140) Add ability to toggle the Widgets sidebar visibility via a button in the tabbar. State persists across sessions and workspaces through workspace metadata (layout:widgetsvisible). --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: sawka --- emain/emain-menu.ts | 14 ++++++++++++++ frontend/app/workspace/workspace-layout-model.ts | 6 ++++++ frontend/app/workspace/workspace.tsx | 3 ++- frontend/types/gotypes.d.ts | 1 + pkg/waveobj/metaconsts.go | 1 + pkg/waveobj/wtypemeta.go | 3 ++- 6 files changed, 26 insertions(+), 2 deletions(-) diff --git a/emain/emain-menu.ts b/emain/emain-menu.ts index 691e475443..1bdf6a7139 100644 --- a/emain/emain-menu.ts +++ b/emain/emain-menu.ts @@ -311,6 +311,20 @@ function makeViewMenu( { role: "togglefullscreen", }, + { type: "separator" }, + { + label: "Toggle Widgets Bar", + click: () => { + fireAndForget(async () => { + const workspaceId = focusedWaveWindow?.workspaceId; + if (!workspaceId) return; + const oref = `workspace:${workspaceId}`; + const meta = await RpcApi.GetMetaCommand(ElectronWshClient, { oref }); + const current = meta?.["layout:widgetsvisible"] ?? true; + await RpcApi.SetMetaCommand(ElectronWshClient, { oref, meta: { "layout:widgetsvisible": !current } }); + }); + }, + }, ]; } diff --git a/frontend/app/workspace/workspace-layout-model.ts b/frontend/app/workspace/workspace-layout-model.ts index 1c86bb8a39..eb7065f90c 100644 --- a/frontend/app/workspace/workspace-layout-model.ts +++ b/frontend/app/workspace/workspace-layout-model.ts @@ -56,6 +56,7 @@ class WorkspaceLayoutModel { private focusTimeoutRef: NodeJS.Timeout | null = null; private debouncedPersistAIWidth: () => void; private debouncedPersistVTabWidth: () => void; + widgetsSidebarVisibleAtom: jotai.Atom; private constructor() { this.aiPanelRef = null; @@ -71,6 +72,11 @@ class WorkspaceLayoutModel { this.vtabWidth = VTabBar_DefaultWidth; this.vtabVisible = false; this.panelVisibleAtom = jotai.atom(false); + this.widgetsSidebarVisibleAtom = jotai.atom( + (get) => + get(getOrefMetaKeyAtom(WOS.makeORef("workspace", this.getWorkspaceId()), "layout:widgetsvisible")) ?? + true + ); this.initializeFromMeta(); this.handleWindowResize = this.handleWindowResize.bind(this); diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx index 3e29bfa9f1..08278a4eed 100644 --- a/frontend/app/workspace/workspace.tsx +++ b/frontend/app/workspace/workspace.tsx @@ -46,6 +46,7 @@ const WorkspaceElem = memo(() => { const tabBarPosition = useAtomValue(getSettingsKeyAtom("app:tabbar")) ?? "top"; const showLeftTabBar = tabBarPosition === "left"; const aiPanelVisible = useAtomValue(workspaceLayoutModel.panelVisibleAtom); + const widgetsSidebarVisible = useAtomValue(workspaceLayoutModel.widgetsSidebarVisibleAtom); const windowWidth = window.innerWidth; const leftGroupInitialPct = workspaceLayoutModel.getLeftGroupInitialPercentage(windowWidth, showLeftTabBar); const innerVTabInitialPct = workspaceLayoutModel.getInnerVTabInitialPercentage(windowWidth, showLeftTabBar); @@ -158,7 +159,7 @@ const WorkspaceElem = memo(() => { ) : (
- + {widgetsSidebarVisible && }
)} diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 4784b05c19..b4906cb036 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1161,6 +1161,7 @@ declare global { "bg:bordercolor"?: string; "bg:activebordercolor"?: string; "layout:vtabbarwidth"?: number; + "layout:widgetsvisible"?: boolean; "waveai:panelopen"?: boolean; "waveai:panelwidth"?: number; "waveai:model"?: string; diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index f41435954c..0ce08099d8 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -100,6 +100,7 @@ const ( MetaKey_BgActiveBorderColor = "bg:activebordercolor" MetaKey_LayoutVTabBarWidth = "layout:vtabbarwidth" + MetaKey_LayoutWidgetsVisible = "layout:widgetsvisible" MetaKey_WaveAiPanelOpen = "waveai:panelopen" MetaKey_WaveAiPanelWidth = "waveai:panelwidth" diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index 4a36fdd46f..2280b55d2d 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -102,7 +102,8 @@ type MetaTSType struct { BgActiveBorderColor string `json:"bg:activebordercolor,omitempty"` // frame:activebordercolor // for workspace - LayoutVTabBarWidth int `json:"layout:vtabbarwidth,omitempty"` + LayoutVTabBarWidth int `json:"layout:vtabbarwidth,omitempty"` + LayoutWidgetsVisible *bool `json:"layout:widgetsvisible,omitempty"` // for tabs+waveai WaveAiPanelOpen bool `json:"waveai:panelopen,omitempty"` From 86ed5e1f9f9dfbf1a5706bd96fbc72e58ff0e92c Mon Sep 17 00:00:00 2001 From: ShiaBB <51927805+JaiJun@users.noreply.github.com> Date: Wed, 1 Apr 2026 07:14:33 +0800 Subject: [PATCH 10/47] docs: add Traditional Chinese (zh-TW) README translation (#3157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `README.zh-TW.md` with Traditional Chinese translation, following the same pattern as the existing Korean translation (`README.ko.md`) - Enhanced feature descriptions with detailed explanations, sub-sections, and emoji icons for better readability - Updated language switcher in `README.md` and `README.ko.md` to include the new `繁體中文` option ## Details This is a community-contributed Traditional Chinese translation of the README. Key differences from a direct translation: - Each feature is broken into its own `###` sub-heading (vs. a single bullet list in the original) - Added contextual explanations (e.g., comparing Durable SSH Sessions to traditional SSH pain points) - Technical terms (SSH, API, WSH, etc.) are kept in English for clarity > 本文件為社群繁體中文翻譯版本。最新原文請參閱 README.md。 ## Test plan - [ ] Verify `README.zh-TW.md` renders correctly on GitHub - [ ] Verify language switcher links work in all three READMEs (EN, KO, ZH-TW) - [ ] Verify no broken image/badge links Co-authored-by: Claude Opus 4.6 (1M context) --- README.ko.md | 2 +- README.md | 2 +- README.zh-TW.md | 168 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 README.zh-TW.md diff --git a/README.ko.md b/README.ko.md index 88dbe29c09..d18ccfaed9 100644 --- a/README.ko.md +++ b/README.ko.md @@ -13,7 +13,7 @@
-[English](README.md) | [한국어](README.ko.md) +[English](README.md) | [한국어](README.ko.md) | [繁體中文](README.zh-TW.md)
diff --git a/README.md b/README.md index 2b8e0637ae..a9f406725c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@
-[English](README.md) | [한국어](README.ko.md) +[English](README.md) | [한국어](README.ko.md) | [繁體中文](README.zh-TW.md)
diff --git a/README.zh-TW.md b/README.zh-TW.md new file mode 100644 index 0000000000..c24dca360c --- /dev/null +++ b/README.zh-TW.md @@ -0,0 +1,168 @@ +

+ + + + + Wave Terminal Logo + + +
+

+ +# Wave Terminal + +
+ +[English](README.md) | [한국어](README.ko.md) | [繁體中文](README.zh-TW.md) + +
+ +[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm?ref=badge_shield) + +> 本文件為社群繁體中文翻譯版本。最新原文請參閱 [README.md](README.md)。 + +Wave 是一款開源、整合 AI 的終端機應用程式,支援 macOS、Linux 與 Windows。它可以搭配任何 AI 模型使用——自行提供 OpenAI、Claude 或 Gemini 的 API 金鑰,或透過 Ollama 與 LM Studio 執行本地模型,完全不需要註冊帳號。 + +Wave 同時支援**持久化 SSH 連線**,即使網路中斷或應用程式重新啟動,連線也會自動恢復。你可以使用內建的圖形化編輯器直接編輯遠端檔案,也能在不離開終端機的情況下即時預覽檔案內容。 + +![WaveTerm Screenshot](./assets/wave-screenshot.webp) + +## 主要功能 + +### 🤖 Wave AI — 情境感知終端機助手 + +Wave AI 不只是一個聊天機器人——它能直接讀取你的終端機輸出、分析目前開啟的小工具(Widget),還能執行檔案操作。當你在 Debug 時,AI 能看到你的錯誤訊息並給予針對性的建議,而不是泛泛的回答。 + +- **終端機情境感知**:自動讀取終端機輸出與捲動緩衝區(Scrollback),用於除錯與分析 +- **檔案操作**:可讀取、寫入、編輯檔案,搭配自動備份機制與使用者審核確認 +- **CLI 整合**:透過 `wsh ai` 命令,直接在命令列中將輸出導入 AI 或附加檔案 +- **BYOK(自帶金鑰)**:支援 OpenAI、Claude、Gemini、Azure 等多家供應商的 API 金鑰 +- **本地模型**:透過 Ollama、LM Studio 及其他 OpenAI 相容供應商執行本地模型,資料完全不離開你的電腦 +- **免費 Beta**:體驗優化期間提供免費 AI 額度 +- **即將推出**:命令執行功能(需使用者核准) + +詳細說明請參閱 [Wave AI 文件](https://docs.waveterm.dev/waveai) 與 [Wave AI Modes 文件](https://docs.waveterm.dev/waveai-modes)。 + +### 🔗 持久化 SSH 連線 + +傳統的 SSH 連線在網路不穩時就會斷開,你得重新連線、重新切換目錄、重新啟動程式。Wave 的持久化 SSH 連線徹底解決了這個痛點——連線中斷後會自動重新建立,你的工作階段(Session)完整保留,就像什麼都沒發生過一樣。 + +- 連線中斷、網路切換、Wave 重啟後自動重新連線 +- 工作階段狀態完整保留 +- 一鍵即可連線遠端伺服器,完整存取終端機與檔案系統 + +### 🧩 彈性拖放介面 + +Wave 的介面由可自由排列的「區塊(Block)」組成。你可以將終端機、編輯器、網頁瀏覽器、AI 助手像拼圖一樣排列在同一個畫面中,打造最適合你工作流程的佈局。每個區塊都能一鍵切換全螢幕,放大查看後立即回到多區塊視圖。 + +### ✏️ 內建編輯器 + +不需要額外開啟 VS Code 或 Vim——Wave 內建的圖形化編輯器支援語法高亮與現代編輯功能,可以直接編輯本地或遠端檔案。對於需要快速修改設定檔或程式碼的場景特別方便。 + +### 📄 豐富的檔案預覽系統 + +直接在終端機內預覽各種格式的遠端檔案,無需下載: + +- Markdown 文件(渲染後呈現) +- 圖片、影片 +- PDF 文件 +- CSV 試算表 +- 目錄結構 + +### 💬 AI 聊天小工具 + +支援多種 AI 模型的聊天介面,可同時開啟多個 AI 對話視窗: + +- OpenAI(GPT 系列) +- Anthropic Claude +- Azure OpenAI +- Perplexity +- Ollama(本地模型) + +### 📦 Command Blocks(命令區塊) + +每個執行的命令都會被獨立封裝在一個區塊中,你可以: + +- 清楚分隔不同命令的輸出結果 +- 個別監控長時間執行的命令 +- 輕鬆回顧歷史命令的輸出 + +### 🔐 安全的密鑰儲存 + +使用作業系統原生的安全儲存後端(如 macOS Keychain、Windows Credential Manager)來保存 API 金鑰和登入憑證。密鑰儲存在本地,並可在不同的 SSH 連線間共享使用。 + +### 🎨 豐富的自訂選項 + +- 分頁主題配色 +- 終端機樣式調整 +- 背景圖片設定 +- 打造專屬於你的工作環境 + +### 🛠️ `wsh` 命令系統 + +`wsh` 是 Wave 提供的強大 CLI 工具,讓你從命令列管理整個工作空間: + +- 在不同終端機連線間共享資料 +- 透過 `wsh file` 在本地與遠端 SSH 主機之間無縫複製和同步檔案 +- 從命令列直接控制 Wave 的介面佈局 + +## 安裝 + +Wave Terminal 支援 macOS、Linux 與 Windows。 + +各平台的安裝說明請參閱[此處](https://docs.waveterm.dev/gettingstarted)。 + +你也可以直接從官方下載頁面安裝:[www.waveterm.dev/download](https://www.waveterm.dev/download)。 + +### 最低系統需求 + +Wave Terminal 支援以下平台: + +- macOS 11 或更新版本(arm64、x64) +- Windows 10 1809 或更新版本(x64) +- 基於 glibc-2.28 或更新版本的 Linux(Debian 10、RHEL 8、Ubuntu 20.04 等)(arm64、x64) + +WSH 輔助程式支援以下平台: + +- macOS 11 或更新版本(arm64、x64) +- Windows 10 或更新版本(x64) +- Linux Kernel 2.6.32 或更新版本(x64)、Linux Kernel 3.1 或更新版本(arm64) + +## 發展藍圖 + +Wave 持續進化中!發展藍圖會隨每次發行版本持續更新,請至[此處](./ROADMAP.md)查閱。 + +想為未來版本提供建議?歡迎加入 [Discord](https://discord.gg/XfvZ334gwU) 社群,或提交 [Feature Request](https://github.com/wavetermdev/waveterm/issues/new/choose)! + +## 連結 + +- 官方網站 — https://www.waveterm.dev +- 下載頁面 — https://www.waveterm.dev/download +- 技術文件 — https://docs.waveterm.dev +- X(Twitter)— https://x.com/wavetermdev +- Discord 社群 — https://discord.gg/XfvZ334gwU + +## 從原始碼建置 + +請參閱 [Building Wave Terminal](BUILD.md)。 + +## 貢獻 + +Wave 使用 GitHub Issues 進行問題追蹤。 + +更多資訊請參閱[貢獻指南](CONTRIBUTING.md),其中包含: + +- [貢獻方式](CONTRIBUTING.md#contributing-to-wave-terminal) +- [貢獻規範](CONTRIBUTING.md#before-you-start) + +### 贊助 Wave ❤️ + +如果 Wave Terminal 對你或你的公司有幫助,歡迎贊助開發工作。 + +贊助有助於支持專案的建置與維護所投入的時間。 + +- https://github.com/sponsors/wavetermdev + +## 授權條款 + +Wave Terminal 採用 Apache-2.0 授權條款。相依性資訊請參閱[此處](./ACKNOWLEDGEMENTS.md)。 From 5ba6a360c727220bc478ea99ff20c62d0726ccd5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:17:12 -0700 Subject: [PATCH 11/47] Bump brace-expansion from 1.1.12 to 1.1.13 (#3139) Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.12 to 1.1.13.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=brace-expansion&package-manager=npm_and_yarn&previous-version=1.1.12&new-version=1.1.13)](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 | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index d4fc4ab31c..c01f1eeec4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4812,9 +4812,9 @@ } }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -6700,9 +6700,9 @@ } }, "node_modules/@npmcli/map-workspaces/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -11834,9 +11834,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -16462,9 +16462,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -16929,9 +16929,9 @@ "license": "BSD-2-Clause" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -22475,9 +22475,9 @@ } }, "node_modules/minimatch/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -30188,9 +30188,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { From 6431baab7357f9973c6158f3c55066ee93b3b395 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:17:47 -0700 Subject: [PATCH 12/47] Bump google.golang.org/api from 0.271.0 to 0.273.0 (#3132) Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.271.0 to 0.273.0.
Release notes

Sourced from google.golang.org/api's releases.

v0.273.0

0.273.0 (2026-03-23)

Features

v0.272.0

0.272.0 (2026-03-16)

Features

Changelog

Sourced from google.golang.org/api's changelog.

0.273.0 (2026-03-23)

Features

0.272.0 (2026-03-16)

Features

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=google.golang.org/api&package-manager=go_modules&previous-version=0.271.0&new-version=0.273.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 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 | 16 ++++++++-------- go.sum | 44 ++++++++++++++++++++++---------------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/go.mod b/go.mod index e6b811c3bf..16a062dfca 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,7 @@ require ( golang.org/x/sync v0.20.0 golang.org/x/sys v0.42.0 golang.org/x/term v0.41.0 - google.golang.org/api v0.271.0 + google.golang.org/api v0.273.0 ) require ( @@ -56,7 +56,7 @@ require ( github.com/go-ole/go-ole v1.2.6 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect - github.com/googleapis/gax-go/v2 v2.17.0 // indirect + github.com/googleapis/gax-go/v2 v2.19.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mailru/easyjson v0.7.7 // indirect @@ -73,15 +73,15 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect - golang.org/x/net v0.51.0 // indirect + go.opentelemetry.io/otel v1.42.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/trace v1.42.0 // indirect + golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index c9d7a83b4d..4d41502fdc 100644 --- a/go.sum +++ b/go.sum @@ -70,8 +70,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= -github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= +github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= +github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -165,23 +165,23 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= @@ -203,14 +203,14 @@ golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY= -google.golang.org/api v0.271.0/go.mod h1:CGT29bhwkbF+i11qkRUJb2KMKqcJ1hdFceEIRd9u64Q= -google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= -google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/api v0.273.0 h1:r/Bcv36Xa/te1ugaN1kdJ5LoA5Wj/cL+a4gj6FiPBjQ= +google.golang.org/api v0.273.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= +google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= +google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= +google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js= +google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= From fe58f5a78170ca42e7a03dda35553ce685d9dc5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:36:50 -0700 Subject: [PATCH 13/47] Bump electron from 41.0.2 to 41.1.0 (#3133) Bumps [electron](https://github.com/electron/electron) from 41.0.2 to 41.1.0.
Release notes

Sourced from electron's releases.

electron v41.1.0

Release Notes for v41.1.0

Features

  • Added nativeTheme.shouldDifferentiateWithoutColor on macOS. #50408 (Also in 42)
  • Notes: Added support for the urgency option in Notifications on Windows. #50382 (Also in 42)

Fixes

  • Fixed a bug where Windows notification icons could fail to save because their temporary filenames contained invalid characters. #50483 (Also in 40)
  • Fixed a crash in clipboard.readImage() when the clipboard contains malformed image data. #50492 (Also in 39, 40, 42)
  • Fixed a crash when calling an offscreen shared texture's release() after the texture object was garbage collected. #50501 (Also in 39, 40, 42)
  • Fixed an accessibility issue where the AXMenuOpened event was not fired on menu creation. #50506 (Also in 40, 42)
  • Fixed an issue where an app shortcut may lose its icon after auto-updating on Windows. #50519 (Also in 40)

Other Changes

  • Updated Chromium to 146.0.7680.166. #50458

electron v41.0.4

Release Notes for v41.0.4

Fixes

  • Fixed crash when handling JavaScript dialogs from windows opened with invalid or empty URLs. #50399 (Also in 39, 40, 42)
  • Fixed improper focus tracking in BaseWindow on MacOS. #50340 (Also in 39, 40, 42)
  • Fixed logic bug that rendered certain window types un-resizable on MAS builds. #50354 (Also in 40, 42)
  • Fixed utilityProcess exit event reporting incorrect exit codes on Windows when the exit code has the high bit. #50386 (Also in 40, 42)
  • Fixed window freeze when failing to enter/exit fullscreen on macOS. #50343 (Also in 39, 40, 42)
  • Improved the appearance of shadows and borders on frameless windows on Wayland. #50213

Other Changes

  • Added support for using a proxy during yarn install. #50350 (Also in 39, 40, 42)
  • Updated Chromium to 146.0.7680.153. #50346

electron v41.0.3

Release Notes for v41.0.3

Fixes

  • Added additional ASAR support to additional fs copy methods. #50286 (Also in 39, 40, 42)
  • Fixed an issue where some DevTools functionality didn't work as expected. #50276 (Also in 40, 42)
  • Fixed user resizing of transparent windows on win32 platform. #50298 (Also in 39, 40, 42)

Other Changes

  • Updated Chromium to 146.0.7680.80. #50262

Documentation

  • Documentation changes: #50293
Commits
  • eb49ed9 fix: outdated execution path for COM activation (#50519)
  • 7e36ac6 chore: bump chromium to 146.0.7680.166 (41-x-y) (#50458)
  • cbae32a fix: [a11y] fire AXMenuOpened event when ARIA menu is added to DOM (#50506)
  • 880b1e0 refactor: remove dead named-window lookup from guest-window-manager (#50497)
  • aedea57 fix: hex-encode Windows notification icon temp filenames (#50483)
  • 707541d fix: fall back to default DPI when GTK returns 0 on Linux (#50489)
  • 3dcb641 fix: crash calling OSR shared texture release() after texture GC'd (#50501)
  • 878a763 fix: crash in clipboard.readImage() on malformed image data (#50492)
  • 6a8d187 feat: add accessibilityDisplayShouldDifferentiateWithoutColor on macOS (#50408)
  • 2962293 feat: support notification priority on Windows (#50382)
  • Additional commits viewable in compare view

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 c01f1eeec4..77717de932 100644 --- a/package-lock.json +++ b/package-lock.json @@ -107,7 +107,7 @@ "@types/ws": "^8", "@vitejs/plugin-react-swc": "4.2.3", "@vitest/coverage-istanbul": "^3.0.9", - "electron": "^41.0.2", + "electron": "^41.1.0", "electron-builder": "^26.8", "electron-vite": "^5.0", "eslint": "^9.39", @@ -14890,9 +14890,9 @@ } }, "node_modules/electron": { - "version": "41.0.2", - "resolved": "https://registry.npmjs.org/electron/-/electron-41.0.2.tgz", - "integrity": "sha512-raotm/aO8kOs1jD8SI8ssJ7EKciQOY295AOOprl1TxW7B0At8m5Ae7qNU1xdMxofiHMR8cNEGi9PKD3U+yT/mA==", + "version": "41.1.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-41.1.0.tgz", + "integrity": "sha512-0XRFyxRqetmqtkkBvV++wGbHYJ7bD++f6EgJW8y9kX4pPRagwlmKDtzqXZhKiu0DIQppm3sXxzHWK9GYP91OKQ==", "dev": true, "hasInstallScript": true, "license": "MIT", diff --git a/package.json b/package.json index bc39047e2d..e698ad0e73 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@types/ws": "^8", "@vitejs/plugin-react-swc": "4.2.3", "@vitest/coverage-istanbul": "^3.0.9", - "electron": "^41.0.2", + "electron": "^41.1.0", "electron-builder": "^26.8", "electron-vite": "^5.0", "eslint": "^9.39", From 86d1a54b7fa61c126cf657eb202b1aee34b01a26 Mon Sep 17 00:00:00 2001 From: Midnight145 <23142346+Midnight145@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:41:04 -0500 Subject: [PATCH 14/47] Turns app:globalhotkey into a dedicated quake mode (#3151) closes #3138, closes #2128 --------- Co-authored-by: sawka Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com> --- emain/emain-window.ts | 208 +++++++++++++++++++++++++++++++++++++++--- emain/emain.ts | 13 +++ 2 files changed, 209 insertions(+), 12 deletions(-) diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 8dfd31789e..98276bbdd2 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { ClientService, ObjectService, WindowService, WorkspaceService } from "@/app/store/services"; +import { waveEventSubscribeSingle } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { fireAndForget } from "@/util/util"; import { BaseWindow, BaseWindowConstructorOptions, dialog, globalShortcut, ipcMain, screen } from "electron"; @@ -101,6 +102,13 @@ export const waveWindowMap = new Map(); // waveWindow // e.g. it persists when the app itself is not focused export let focusedWaveWindow: WaveBrowserWindow = null; +// quake window for toggle hotkey (show/hide behavior) +let quakeWindow: WaveBrowserWindow | null = null; + +export function getQuakeWindow(): WaveBrowserWindow | null { + return quakeWindow; +} + let cachedClientId: string = null; let hasCompletedFirstRelaunch = false; @@ -332,6 +340,9 @@ export class WaveBrowserWindow extends BaseWindow { if (focusedWaveWindow == this) { focusedWaveWindow = null; } + if (quakeWindow == this) { + quakeWindow = null; + } this.removeAllChildViews(); if (getGlobalIsRelaunching()) { console.log("win relaunching", this.waveWindowId); @@ -704,6 +715,7 @@ export async function createBrowserWindow( } console.log("createBrowserWindow", waveWindow.oid, workspace.oid, workspace); const bwin = new WaveBrowserWindow(waveWindow, fullConfig, opts); + if (workspace.activetabid) { await bwin.setActiveTab(workspace.activetabid, false, opts.isPrimaryStartupWindow ?? false); } @@ -832,6 +844,9 @@ export async function createNewWaveWindow() { unamePlatform, isPrimaryStartupWindow: false, }); + if (quakeWindow == null) { + quakeWindow = win; + } win.show(); recreatedWindow = true; } @@ -845,6 +860,9 @@ export async function createNewWaveWindow() { unamePlatform, isPrimaryStartupWindow: false, }); + if (quakeWindow == null) { + quakeWindow = newBrowserWindow; + } newBrowserWindow.show(); } @@ -887,6 +905,10 @@ export async function relaunchBrowserWindows() { foregroundWindow: windowId === primaryWindowId, }); wins.push(win); + if (windowId === primaryWindowId) { + quakeWindow = win; + console.log("designated quake window", win.waveWindowId); + } } hasCompletedFirstRelaunch = true; for (const win of wins) { @@ -895,22 +917,184 @@ export async function relaunchBrowserWindows() { } } +function getDisplayForQuakeToggle() { + // We cannot reliably query the OS-wide active window in Electron. + // Cursor position is the best cross-platform proxy for the user's active display. + const cursorPoint = screen.getCursorScreenPoint(); + const displayAtCursor = screen + .getAllDisplays() + .find( + (display) => + cursorPoint.x >= display.bounds.x && + cursorPoint.x < display.bounds.x + display.bounds.width && + cursorPoint.y >= display.bounds.y && + cursorPoint.y < display.bounds.y + display.bounds.height + ); + return displayAtCursor ?? screen.getDisplayNearestPoint(cursorPoint); +} + +function moveWindowToDisplay(win: WaveBrowserWindow, targetDisplay: Electron.Display) { + if (!win || !targetDisplay || win.isDestroyed()) { + return; + } + const curBounds = win.getBounds(); + const sourceDisplay = screen.getDisplayMatching(curBounds); + if (sourceDisplay.id === targetDisplay.id) { + return; + } + + const sourceArea = sourceDisplay.workArea; + const targetArea = targetDisplay.workArea; + const nextHeight = Math.min(curBounds.height, targetArea.height); + const nextWidth = Math.min(curBounds.width, targetArea.width); + const maxXOffset = Math.max(0, targetArea.width - nextWidth); + const maxYOffset = Math.max(0, targetArea.height - nextHeight); + const sourceXOffset = curBounds.x - sourceArea.x; + const sourceYOffset = curBounds.y - sourceArea.y; + const nextX = targetArea.x + Math.min(Math.max(sourceXOffset, 0), maxXOffset); + const nextY = targetArea.y + Math.min(Math.max(sourceYOffset, 0), maxYOffset); + + win.setBounds({ ...curBounds, x: nextX, y: nextY, width: nextWidth, height: nextHeight }); +} + +const FullscreenTransitionTimeoutMs = 2000; + +// handles a theoretical race condition where the user spams the hotkey before the toggle finishes +let quakeToggleInProgress = false; +let quakeRestoreFullscreenOnShow = false; + +function waitForFullscreenLeave(window: WaveBrowserWindow): Promise { + if (!window.isFullScreen()) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + // eslint-disable-next-line prefer-const + let timeout: ReturnType; + const onLeave = () => { + clearTimeout(timeout); + resolve(); + }; + timeout = setTimeout(() => { + window.removeListener("leave-full-screen", onLeave); + reject(new Error("fullscreen transition timeout")); + }, FullscreenTransitionTimeoutMs); + window.once("leave-full-screen", onLeave); + }); +} + +function waitForFullscreenEnter(window: WaveBrowserWindow): Promise { + if (window.isFullScreen()) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + // eslint-disable-next-line prefer-const + let timeout: ReturnType; + const onEnter = () => { + clearTimeout(timeout); + resolve(); + }; + timeout = setTimeout(() => { + window.removeListener("enter-full-screen", onEnter); + reject(new Error("fullscreen transition timeout")); + }, FullscreenTransitionTimeoutMs); + window.once("enter-full-screen", onEnter); + }); +} + +async function quakeToggle() { + if (quakeToggleInProgress) { + return; + } + quakeToggleInProgress = true; + try { + let window = quakeWindow; + if (window?.isDestroyed()) { + quakeWindow = null; + window = null; + } + if (window == null) { + await createNewWaveWindow(); + return; + } + // Some environments don't hide or move the window if it's fullscreen (even when hidden), so leave fullscreen first + if (window.isFullScreen()) { + // macos has a really long fullscreen animation and can have issues restoring from fullscreen, so we skip on macos + quakeRestoreFullscreenOnShow = process.platform !== "darwin"; + const leavePromise = waitForFullscreenLeave(window); + window.setFullScreen(false); + try { + await leavePromise; + } catch { + // timeout — proceed anyway + } + if (window.isDestroyed()) { + return; + } + } + if (window.isVisible()) { + window.hide(); + } else { + const targetDisplay = getDisplayForQuakeToggle(); + moveWindowToDisplay(window, targetDisplay); + window.show(); + if (quakeRestoreFullscreenOnShow) { + const enterPromise = waitForFullscreenEnter(window); + window.setFullScreen(true); + try { + await enterPromise; + } catch { + // timeout — proceed anyway + } + } + quakeRestoreFullscreenOnShow = false; + window.focus(); + if (window.activeTabView?.webContents) { + window.activeTabView.webContents.focus(); + } + } + } finally { + quakeToggleInProgress = false; + } +} + +let currentRawGlobalHotKey: string = null; +let currentGlobalHotKey: string = null; + export function registerGlobalHotkey(rawGlobalHotKey: string) { + if (rawGlobalHotKey === currentRawGlobalHotKey) { + return; + } + if (currentGlobalHotKey != null) { + globalShortcut.unregister(currentGlobalHotKey); + currentGlobalHotKey = null; + currentRawGlobalHotKey = null; + } + if (!rawGlobalHotKey) { + return; + } try { const electronHotKey = waveKeyToElectronKey(rawGlobalHotKey); - console.log("registering globalhotkey of ", electronHotKey); - globalShortcut.register(electronHotKey, () => { - const selectedWindow = focusedWaveWindow; - const firstWaveWindow = getAllWaveWindows()[0]; - if (focusedWaveWindow) { - selectedWindow.focus(); - } else if (firstWaveWindow) { - firstWaveWindow.focus(); - } else { - fireAndForget(createNewWaveWindow); - } + const ok = globalShortcut.register(electronHotKey, () => { + fireAndForget(quakeToggle); }); + currentRawGlobalHotKey = rawGlobalHotKey; + currentGlobalHotKey = electronHotKey; + console.log("registered globalhotkey", rawGlobalHotKey, "=>", electronHotKey, "ok=", ok); } catch (e) { - console.log("error registering global hotkey: ", e); + console.log("error registering global hotkey", rawGlobalHotKey, ":", e); } } + +export function initGlobalHotkeyEventSubscription() { + waveEventSubscribeSingle({ + eventType: "config", + handler: (event) => { + try { + const hotkey = event?.data?.fullconfig?.settings?.["app:globalhotkey"]; + registerGlobalHotkey(hotkey ?? null); + } catch (e) { + console.log("error handling config event for globalhotkey", e); + } + }, + }); +} diff --git a/emain/emain.ts b/emain/emain.ts index 7a2b0a0710..8b08178aec 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -46,8 +46,10 @@ import { createNewWaveWindow, focusedWaveWindow, getAllWaveWindows, + getQuakeWindow, getWaveWindowById, getWaveWindowByWorkspaceId, + initGlobalHotkeyEventSubscription, registerGlobalHotkey, relaunchBrowserWindows, WaveBrowserWindow, @@ -427,6 +429,16 @@ async function appMain() { electronApp.on("activate", () => { const allWindows = getAllWaveWindows(); + const anyVisible = allWindows.some((w) => !w.isDestroyed() && w.isVisible()); + if (anyVisible) { + return; + } + const qw = getQuakeWindow(); + if (qw != null && !qw.isDestroyed()) { + qw.show(); + qw.focus(); + return; + } if (allWindows.length === 0) { fireAndForget(createNewWaveWindow); } @@ -445,6 +457,7 @@ async function appMain() { if (rawGlobalHotKey) { registerGlobalHotkey(rawGlobalHotKey); } + initGlobalHotkeyEventSubscription(); } appMain().catch((e) => { From 57e4e27a291642406060c795d69c63450ee9229d Mon Sep 17 00:00:00 2001 From: Lucy Farnik <101606499+lucyfarnik@users.noreply.github.com> Date: Wed, 1 Apr 2026 01:07:34 +0100 Subject: [PATCH 15/47] feat: add opt-in split buttons to block headers (#3159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two icon buttons (horizontal/vertical split) to every block's header bar, gated behind a new `app:showsplitbuttons` setting (default false). When enabled, the buttons appear before the settings cog. Motivation: for users who split panes frequently, having the buttons always visible speeds up the workflow vs. right-click > context menu. The split functionality already exists — this just surfaces it more conveniently. - New setting `app:showsplitbuttons` (Go + TS + default config) - Split buttons in `blockframe-header.tsx`, using existing `createBlockSplitHorizontally`/`createBlockSplitVertically` - New pane clones the current block's meta so terminals inherit shell/connection config --------- Co-authored-by: Claude Opus 4.6 (1M context) --- frontend/app/block/blockenv.ts | 1 + frontend/app/block/blockframe-header.tsx | 41 +++++++++++++++++++++++- frontend/types/gotypes.d.ts | 1 + pkg/wconfig/defaultconfig/settings.json | 1 + pkg/wconfig/metaconsts.go | 1 + pkg/wconfig/settingsconfig.go | 1 + schema/settings.json | 3 ++ 7 files changed, 48 insertions(+), 1 deletion(-) diff --git a/frontend/app/block/blockenv.ts b/frontend/app/block/blockenv.ts index f4eebb192d..8a529be11b 100644 --- a/frontend/app/block/blockenv.ts +++ b/frontend/app/block/blockenv.ts @@ -13,6 +13,7 @@ export type BlockEnv = WaveEnvSubset<{ getSettingsKeyAtom: SettingsKeyAtomFnType< | "app:focusfollowscursor" | "app:showoverlayblocknums" + | "term:showsplitbuttons" | "window:magnifiedblockblurprimarypx" | "window:magnifiedblockopacity" >; diff --git a/frontend/app/block/blockframe-header.tsx b/frontend/app/block/blockframe-header.tsx index 319e9b4a49..a70f323e71 100644 --- a/frontend/app/block/blockframe-header.tsx +++ b/frontend/app/block/blockframe-header.tsx @@ -11,7 +11,13 @@ import { import { ConnectionButton } from "@/app/block/connectionbutton"; import { DurableSessionFlyover } from "@/app/block/durable-session-flyover"; import { getBlockBadgeAtom } from "@/app/store/badge"; -import { recordTEvent, refocusNode } from "@/app/store/global"; +import { + createBlockSplitHorizontally, + createBlockSplitVertically, + recordTEvent, + refocusNode, + WOS, +} from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; import { uxCloseBlock } from "@/app/store/keymodel"; import { TabRpcClient } from "@/app/store/wshrpcutil"; @@ -119,12 +125,45 @@ const HeaderEndIcons = React.memo(({ viewModel, nodeModel, blockId }: HeaderEndI const ephemeral = jotai.useAtomValue(nodeModel.isEphemeral); const numLeafs = jotai.useAtomValue(nodeModel.numLeafs); const magnifyDisabled = numLeafs <= 1; + const showSplitButtons = jotai.useAtomValue(blockEnv.getSettingsKeyAtom("term:showsplitbuttons")); const endIconsElem: React.ReactElement[] = []; if (endIconButtons && endIconButtons.length > 0) { endIconsElem.push(...endIconButtons.map((button, idx) => )); } + if (showSplitButtons && viewModel?.viewType === "term") { + const splitHorizontalDecl: IconButtonDecl = { + elemtype: "iconbutton", + icon: "columns", + title: "Split Horizontally", + click: (e) => { + e.stopPropagation(); + const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)); + const blockData = globalStore.get(blockAtom); + const blockDef: BlockDef = { + meta: blockData?.meta || { view: "term", controller: "shell" }, + }; + createBlockSplitHorizontally(blockDef, blockId, "after"); + }, + }; + const splitVerticalDecl: IconButtonDecl = { + elemtype: "iconbutton", + icon: "grip-lines", + title: "Split Vertically", + click: (e) => { + e.stopPropagation(); + const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)); + const blockData = globalStore.get(blockAtom); + const blockDef: BlockDef = { + meta: blockData?.meta || { view: "term", controller: "shell" }, + }; + createBlockSplitVertically(blockDef, blockId, "after"); + }, + }; + endIconsElem.push(); + endIconsElem.push(); + } const settingsDecl: IconButtonDecl = { elemtype: "iconbutton", icon: "cog", diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index b4906cb036..5c61e4199e 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1422,6 +1422,7 @@ declare global { "term:bellindicator"?: boolean; "term:osc52"?: string; "term:durable"?: boolean; + "term:showsplitbuttons"?: boolean; "editor:minimapenabled"?: boolean; "editor:stickyscrollenabled"?: boolean; "editor:wordwrap"?: boolean; diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index 8ed6af7235..ff6dbbe48a 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -7,6 +7,7 @@ "app:tabbar": "top", "app:confirmquit": true, "app:hideaibutton": false, + "term:showsplitbuttons": false, "app:disablectrlshiftarrows": false, "app:disablectrlshiftdisplay": false, "app:focusfollowscursor": "off", diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index df048b304c..6c813954f7 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -59,6 +59,7 @@ const ( ConfigKey_TermBellIndicator = "term:bellindicator" ConfigKey_TermOsc52 = "term:osc52" ConfigKey_TermDurable = "term:durable" + ConfigKey_TermShowSplitButtons = "term:showsplitbuttons" ConfigKey_EditorMinimapEnabled = "editor:minimapenabled" ConfigKey_EditorStickyScrollEnabled = "editor:stickyscrollenabled" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index b55cab8cbf..2a1927630f 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -110,6 +110,7 @@ type SettingsType struct { TermBellIndicator *bool `json:"term:bellindicator,omitempty"` TermOsc52 string `json:"term:osc52,omitempty" jsonschema:"enum=focus,enum=always"` TermDurable *bool `json:"term:durable,omitempty"` + TermShowSplitButtons bool `json:"term:showsplitbuttons,omitempty"` EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"` EditorStickyScrollEnabled bool `json:"editor:stickyscrollenabled,omitempty"` diff --git a/schema/settings.json b/schema/settings.json index 67d8f5b9d4..91de939c38 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -168,6 +168,9 @@ "term:durable": { "type": "boolean" }, + "term:showsplitbuttons": { + "type": "boolean" + }, "editor:minimapenabled": { "type": "boolean" }, From ac0299e576f04b337565e24d58ff82d1dcacf946 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:13:47 -0700 Subject: [PATCH 16/47] Bump actions/deploy-pages from 4 to 5 in /.github/workflows (#3131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5.
Release notes

Sourced from actions/deploy-pages's releases.

v5.0.0

Changelog


See details of all code changes since previous release.

:warning: For use with products other than GitHub.com, such as GitHub Enterprise Server, please consult the compatibility table.

v4.0.5

Changelog


See details of all code changes since previous release.

:warning: For use with products other than GitHub.com, such as GitHub Enterprise Server, please consult the compatibility table.

v4.0.4

Changelog


See details of all code changes since previous release.

:warning: For use with products other than GitHub.com, such as GitHub Enterprise Server, please consult the compatibility table.

v4.0.3

Changelog

... (truncated)

Commits
  • cd2ce8f Merge pull request #404 from salmanmkc/node24
  • bbe2a95 Update Node.js version to 24.x
  • 854d7aa Merge pull request #374 from actions/Jcambass-patch-1
  • 306bb81 Add workflow file for publishing releases to immutable action package
  • b742728 Merge pull request #360 from actions/dependabot/npm_and_yarn/npm_and_yarn-513...
  • 7273294 Bump braces in the npm_and_yarn group across 1 directory
  • 963791f Merge pull request #361 from actions/dependabot-friendly
  • 51bb29d Make the rebuild dist workflow safer for Dependabot
  • 89f3d10 Merge pull request #358 from actions/dependabot/npm_and_yarn/non-breaking-cha...
  • bce7355 Merge branch 'main' into dependabot/npm_and_yarn/non-breaking-changes-99c12deb21
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/deploy-pages&package-manager=github_actions&previous-version=4&new-version=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)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/deploy-docsite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-docsite.yml b/.github/workflows/deploy-docsite.yml index e9ba826c31..092b024cb5 100644 --- a/.github/workflows/deploy-docsite.yml +++ b/.github/workflows/deploy-docsite.yml @@ -77,4 +77,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 From 3922050d4596fff5d33db90fbcf9ed134d6f7fac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:08:07 -0700 Subject: [PATCH 17/47] Bump github.com/mattn/go-sqlite3 from 1.14.37 to 1.14.40 (#3176) Bumps [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) from 1.14.37 to 1.14.40.
Commits
  • f929738 Merge pull request #1384 from mattn/raise-go121-cleanup
  • efae5e7 raise minimum Go version to 1.21
  • b23d54c Merge pull request #1383 from mattn/codex/next-row-batch-fetch
  • e1557be batch row column fetches in Next
  • cc39db7 Merge pull request #1382 from mattn/codex/sqlite3-bind-fastpath
  • 9a908a9 optimize sqlite bind fast path
  • edadafa Merge pull request #1381 from mattn/eliminate-bounds-checks
  • 8f9f86e Eliminate unnecessary bounds checks in hot paths
  • 0d23881 Merge pull request #1379 from theimpostor/pr-1322-missing-constraint-op-types
  • 84bdc43 add missing index constraint op types
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/mattn/go-sqlite3&package-manager=go_modules&previous-version=1.14.37&new-version=1.14.40)](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 | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 16a062dfca..d515a2bbc2 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/junegunn/fzf v0.65.2 github.com/kevinburke/ssh_config v1.2.0 github.com/launchdarkly/eventsource v1.11.0 - github.com/mattn/go-sqlite3 v1.14.37 + github.com/mattn/go-sqlite3 v1.14.40 github.com/mitchellh/mapstructure v1.5.0 github.com/sashabaranov/go-openai v1.41.2 github.com/sawka/txwrap v0.2.0 diff --git a/go.sum b/go.sum index 4d41502fdc..d2996168d0 100644 --- a/go.sum +++ b/go.sum @@ -104,8 +104,8 @@ github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= -github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.40 h1:f7+saIsbq4EF86mUqe0uiecQOJYMOdfi5uATADmUG94= +github.com/mattn/go-sqlite3 v1.14.40/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/photostorm/pty v1.1.19-0.20230903182454-31354506054b h1:cLGKfKb1uk0hxI0Q8L83UAJPpeJ+gSpn3cCU/tjd3eg= From b3f5496350b81389a42706516905c936748b2ae1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:11:09 -0700 Subject: [PATCH 18/47] Bump github.com/shirou/gopsutil/v4 from 4.26.2 to 4.26.3 (#3177) 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.2 to 4.26.3.
Release notes

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

v4.26.3

Important Notice

The temporary opt-out option WillBeDeletedOptOutMemAvailableCalc, introduced in v4.25.8, has been removed in this release.

Code that relied on this option may fail to build. If so, please update your code to work with the current VirtualMemoryStat.Used calculation, as the opt-out mechanism is no longer available.

What's Changed

cpu

disk

host

load

mem

net

process

sensors

New Contributors

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

Commits
  • c2a1624 Merge pull request #2033 from Dylan-M/dylanmyers/disk-3b-iocounters
  • b32e3a1 Merge pull request #2032 from Dylan-M/dylanmyers/disk-3a-usage-fix
  • 730f763 Merge pull request #2063 from lubeschanin/fix/sensors-darwin-arm64-crash
  • 76137fe Fix SIGBUS/SIGSEGV crash on macOS ARM64: keep libraries open, fix data race
  • be66821 disk: implement IOCountersWithContext for AIX nocgo via iostat
  • 416f4ed disk: use unix.Statfs for fstype in UsageWithContext on AIX nocgo
  • 7661a67 Merge pull request #2031 from shirou/fix/net_linux_connection_unix_socket_dedup
  • 3095963 Merge pull request #2012 from shirou/fix/revert_opt-outed_mem_used_calc
  • bf942de [net][linux]: prevent incorrect deduplication of unnamed UNIX sockets
  • d7abb9a Merge pull request #2029 from skartikey/fix/openbsd-mem-cached
  • 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.2&new-version=4.26.3)](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 | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index d515a2bbc2..c2cbe0b978 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,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.2 + github.com/shirou/gopsutil/v4 v4.26.3 github.com/skeema/knownhosts v1.3.1 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index d2996168d0..9d55d98998 100644 --- a/go.sum +++ b/go.sum @@ -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.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= -github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/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 e46cf781d5bf306ee1bcd868fb4c9aaf234231b5 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 3 Apr 2026 12:11:49 -0700 Subject: [PATCH 19/47] remove unused deps from package.json (#3179) --- .gitignore | 1 + .kilocode/rules/rules.md | 1 - .roo/rules/rules.md | 1 - frontend/types/media.d.ts | 3 ++ package-lock.json | 88 --------------------------------------- package.json | 11 ----- 6 files changed, 4 insertions(+), 101 deletions(-) diff --git a/.gitignore b/.gitignore index a56b777457..2111b1182d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules/ frontend/bindings bindings/ *.log +*.tsbuildinfo bin/ *.dmg *.exe diff --git a/.kilocode/rules/rules.md b/.kilocode/rules/rules.md index 7efa154ea7..904292ea97 100644 --- a/.kilocode/rules/rules.md +++ b/.kilocode/rules/rules.md @@ -33,7 +33,6 @@ It has a TypeScript/React frontend and a Go backend. They talk together over `ws - **Coding Style**: - Use all lowercase filenames (except where case is actually important like Taskfile.yml) - Import the "cn" function from "@/util/util" to do classname / clsx class merge (it uses twMerge underneath) - - For element variants use class-variance-authority - Do NOT create private fields in classes (they are impossible to inspect) - Use PascalCase for global consts at the top of files - **Component Practices**: diff --git a/.roo/rules/rules.md b/.roo/rules/rules.md index 341d328f9e..99f3f08b70 100644 --- a/.roo/rules/rules.md +++ b/.roo/rules/rules.md @@ -33,7 +33,6 @@ It has a TypeScript/React frontend and a Go backend. They talk together over `ws - **Coding Style**: - Use all lowercase filenames (except where case is actually important like Taskfile.yml) - Import the "cn" function from "@/util/util" to do classname / clsx class merge (it uses twMerge underneath) - - For element variants use class-variance-authority - Do NOT create private fields in classes (they are impossible to inspect) - Use PascalCase for global consts at the top of files - **Component Practices**: diff --git a/frontend/types/media.d.ts b/frontend/types/media.d.ts index c8db4f661e..dadfdfd918 100644 --- a/frontend/types/media.d.ts +++ b/frontend/types/media.d.ts @@ -4,6 +4,9 @@ // CSS modules type CSSModuleClasses = { readonly [key: string]: string }; +declare module "*.scss" {} +declare module "*.css" {} + declare module "*.module.css" { const classes: CSSModuleClasses; export default classes; diff --git a/package-lock.json b/package-lock.json index 77717de932..9211ad86f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,9 +30,7 @@ "@xterm/xterm": "^6.0.0", "ai": "^5.0.92", "base64-js": "^1.5.1", - "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "color": "^4.2.3", "colord": "^2.9.3", "css-tree": "^3.1.0", "dayjs": "^1.11.19", @@ -52,8 +50,6 @@ "papaparse": "^5.5.3", "parse-srcset": "^1.0.2", "pngjs": "^7.0.0", - "prop-types": "^15.8.1", - "qs": "^6.15.0", "react": "^19.2.0", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", @@ -78,7 +74,6 @@ "streamdown": "^1.6.10", "tailwind-merge": "^3.5.0", "throttle-debounce": "^5.0.2", - "tinycolor2": "^1.6.0", "unist-util-visit": "^5.1.0", "use-device-pixel-ratio": "^1.1.2", "uuid": "^13.0.0", @@ -90,20 +85,17 @@ "@eslint/js": "^9.39", "@rollup/plugin-node-resolve": "^16.0.3", "@tailwindcss/vite": "^4.2.1", - "@types/color": "^4.2.0", "@types/css-tree": "^2", "@types/debug": "^4", "@types/node": "^22.13.17", "@types/papaparse": "^5", "@types/pngjs": "^6.0.5", - "@types/prop-types": "^15", "@types/react": "19", "@types/react-dom": "19", "@types/semver": "^7", "@types/shell-quote": "^1", "@types/sprintf-js": "^1", "@types/throttle-debounce": "^5", - "@types/tinycolor2": "^1", "@types/ws": "^8", "@vitejs/plugin-react-swc": "4.2.3", "@vitest/coverage-istanbul": "^3.0.9", @@ -114,15 +106,12 @@ "eslint-config-prettier": "^10.1.8", "globals": "^17.4.0", "node-abi": "^4.26.0", - "postcss": "^8.5.8", "prettier": "^3.8.1", "prettier-plugin-jsdoc": "^1.8.0", "prettier-plugin-organize-imports": "^4.3.0", "sass": "1.91.0", "tailwindcss": "^4.2.1", - "tailwindcss-animate": "^1.0.7", "ts-node": "^10.9.2", - "tslib": "^2.8.1", "tsx": "^4.21.0", "typescript": "^5.9.3", "typescript-eslint": "^8.56", @@ -9168,33 +9157,6 @@ "@types/deep-eql": "*" } }, - "node_modules/@types/color": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@types/color/-/color-4.2.0.tgz", - "integrity": "sha512-6+xrIRImMtGAL2X3qYkd02Mgs+gFGs+WsK0b7VVMaO4mYRISwyTjcqNrO0mNSmYEoq++rSLDB2F5HDNmqfOe+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/color-convert": "*" - } - }, - "node_modules/@types/color-convert": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/color-convert/-/color-convert-2.0.4.tgz", - "integrity": "sha512-Ub1MmDdyZ7mX//g25uBAoH/mWGd9swVbt8BseymnaE18SU4po/PjmCrHxqIIRjBo3hV/vh1KGr0eMxUhp+t+dQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/color-name": "^1.1.0" - } - }, - "node_modules/@types/color-name": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.5.tgz", - "integrity": "sha512-j2K5UJqGTxeesj6oQuGpMgifpT5k9HprgQd8D1Y0lOFqKHl3PJu5GMeS4Y5EgjS55AE6OQxf8mPED9uaGbf4Cg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/concat-stream": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/concat-stream/-/concat-stream-2.0.3.tgz", @@ -9945,13 +9907,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/tinycolor2": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", - "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -12459,18 +12414,6 @@ "node": ">=8" } }, - "node_modules/class-variance-authority": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", - "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", - "license": "Apache-2.0", - "dependencies": { - "clsx": "^2.1.1" - }, - "funding": { - "url": "https://polar.sh/cva" - } - }, "node_modules/clean-css": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", @@ -26112,21 +26055,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -29969,16 +29897,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tailwindcss-animate": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", - "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders" - } - }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -30323,12 +30241,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tinycolor2": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", - "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", - "license": "MIT" - }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", diff --git a/package.json b/package.json index e698ad0e73..fd4928b9be 100644 --- a/package.json +++ b/package.json @@ -31,20 +31,17 @@ "@eslint/js": "^9.39", "@rollup/plugin-node-resolve": "^16.0.3", "@tailwindcss/vite": "^4.2.1", - "@types/color": "^4.2.0", "@types/css-tree": "^2", "@types/debug": "^4", "@types/node": "^22.13.17", "@types/papaparse": "^5", "@types/pngjs": "^6.0.5", - "@types/prop-types": "^15", "@types/react": "19", "@types/react-dom": "19", "@types/semver": "^7", "@types/shell-quote": "^1", "@types/sprintf-js": "^1", "@types/throttle-debounce": "^5", - "@types/tinycolor2": "^1", "@types/ws": "^8", "@vitejs/plugin-react-swc": "4.2.3", "@vitest/coverage-istanbul": "^3.0.9", @@ -55,15 +52,12 @@ "eslint-config-prettier": "^10.1.8", "globals": "^17.4.0", "node-abi": "^4.26.0", - "postcss": "^8.5.8", "prettier": "^3.8.1", "prettier-plugin-jsdoc": "^1.8.0", "prettier-plugin-organize-imports": "^4.3.0", "sass": "1.91.0", "tailwindcss": "^4.2.1", - "tailwindcss-animate": "^1.0.7", "ts-node": "^10.9.2", - "tslib": "^2.8.1", "tsx": "^4.21.0", "typescript": "^5.9.3", "typescript-eslint": "^8.56", @@ -90,9 +84,7 @@ "@xterm/xterm": "^6.0.0", "ai": "^5.0.92", "base64-js": "^1.5.1", - "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "color": "^4.2.3", "colord": "^2.9.3", "css-tree": "^3.1.0", "dayjs": "^1.11.19", @@ -112,8 +104,6 @@ "papaparse": "^5.5.3", "parse-srcset": "^1.0.2", "pngjs": "^7.0.0", - "prop-types": "^15.8.1", - "qs": "^6.15.0", "react": "^19.2.0", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", @@ -138,7 +128,6 @@ "streamdown": "^1.6.10", "tailwind-merge": "^3.5.0", "throttle-debounce": "^5.0.2", - "tinycolor2": "^1.6.0", "unist-util-visit": "^5.1.0", "use-device-pixel-ratio": "^1.1.2", "uuid": "^13.0.0", From 388b4c9fabfde763a6a382a47c1704aae7c2e285 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:13:27 -0700 Subject: [PATCH 20/47] Bump google.golang.org/api from 0.273.0 to 0.274.0 (#3175) Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.273.0 to 0.274.0.
Release notes

Sourced from google.golang.org/api's releases.

v0.274.0

0.274.0 (2026-04-02)

Features

v0.273.1

0.273.1 (2026-03-31)

Bug Fixes

  • Merge duplicate x-goog-request-params header (#3547) (2008108)
Changelog

Sourced from google.golang.org/api's changelog.

0.274.0 (2026-04-02)

Features

0.273.1 (2026-03-31)

Bug Fixes

  • Merge duplicate x-goog-request-params header (#3547) (2008108)
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=google.golang.org/api&package-manager=go_modules&previous-version=0.273.0&new-version=0.274.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 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 | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c2cbe0b978..2ee2660c5c 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,7 @@ require ( golang.org/x/sync v0.20.0 golang.org/x/sys v0.42.0 golang.org/x/term v0.41.0 - google.golang.org/api v0.273.0 + google.golang.org/api v0.274.0 ) require ( diff --git a/go.sum b/go.sum index 9d55d98998..d6aa60afe4 100644 --- a/go.sum +++ b/go.sum @@ -203,8 +203,8 @@ golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.273.0 h1:r/Bcv36Xa/te1ugaN1kdJ5LoA5Wj/cL+a4gj6FiPBjQ= -google.golang.org/api v0.273.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= +google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA= +google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js= From 28bab88646b4477b088faa8d25e12ad09b29c904 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:20:31 -0700 Subject: [PATCH 21/47] Bump vite from 6.4.1 to 6.4.2 (#3190) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.4.1 to 6.4.2.
Release notes

Sourced from vite's releases.

v6.4.2

Please refer to CHANGELOG.md for details.

Changelog

Sourced from vite's changelog.

6.4.2 (2026-04-06)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=vite&package-manager=npm_and_yarn&previous-version=6.4.1&new-version=6.4.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) 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 | 10 +++++----- package.json | 2 +- tsunami/frontend/package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9211ad86f4..4d8200a859 100644 --- a/package-lock.json +++ b/package-lock.json @@ -115,7 +115,7 @@ "tsx": "^4.21.0", "typescript": "^5.9.3", "typescript-eslint": "^8.56", - "vite": "^6.4.1", + "vite": "^6.4.2", "vite-plugin-image-optimizer": "^2.0.3", "vite-plugin-svgr": "^4.5.0", "vite-tsconfig-paths": "^5.1.4", @@ -32043,9 +32043,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", "dependencies": { @@ -33382,7 +33382,7 @@ "@vitejs/plugin-react-swc": "^4.2.3", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", - "vite": "^6.4.1" + "vite": "^6.4.2" } }, "tsunami/frontend/node_modules/@reduxjs/toolkit": { diff --git a/package.json b/package.json index fd4928b9be..26098d270e 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "tsx": "^4.21.0", "typescript": "^5.9.3", "typescript-eslint": "^8.56", - "vite": "^6.4.1", + "vite": "^6.4.2", "vite-plugin-image-optimizer": "^2.0.3", "vite-plugin-svgr": "^4.5.0", "vite-tsconfig-paths": "^5.1.4", diff --git a/tsunami/frontend/package.json b/tsunami/frontend/package.json index f3faa7040d..060fe5e81e 100644 --- a/tsunami/frontend/package.json +++ b/tsunami/frontend/package.json @@ -34,6 +34,6 @@ "@vitejs/plugin-react-swc": "^4.2.3", "tailwindcss": "^4.2.1", "typescript": "^5.9.3", - "vite": "^6.4.1" + "vite": "^6.4.2" } } From 9f41b5761c6e19c5cfcc9372d646b4f131e0780a Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Tue, 7 Apr 2026 10:04:02 -0700 Subject: [PATCH 22/47] remove electron deps from about.tsx (#3194) --- frontend/app/modals/about.tsx | 11 ++++++----- frontend/types/gotypes.d.ts | 2 ++ pkg/wconfig/settingsconfig.go | 4 ++++ 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/frontend/app/modals/about.tsx b/frontend/app/modals/about.tsx index 08c0e2210e..d3a43d386d 100644 --- a/frontend/app/modals/about.tsx +++ b/frontend/app/modals/about.tsx @@ -3,13 +3,14 @@ import Logo from "@/app/asset/logo.svg"; import { OnboardingGradientBg } from "@/app/onboarding/onboarding-common"; +import { atoms } from "@/app/store/global"; import { modalsModel } from "@/app/store/modalmodel"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { isDev } from "@/util/isdev"; import { fireAndForget } from "@/util/util"; -import { useEffect, useState } from "react"; -import { getApi } from "../store/global"; +import { useAtomValue } from "jotai"; +import { useEffect } from "react"; import { Modal } from "./modal"; interface AboutModalVProps { @@ -84,9 +85,9 @@ const AboutModalV = ({ versionString, updaterChannel, onClose }: AboutModalVProp AboutModalV.displayName = "AboutModalV"; const AboutModal = () => { - const [details] = useState(() => getApi().getAboutModalDetails()); - const [updaterChannel] = useState(() => getApi().getUpdaterChannel()); - const versionString = `${details.version} (${isDev() ? "dev-" : ""}${details.buildTime})`; + const fullConfig = useAtomValue(atoms.fullConfigAtom); + const versionString = `${fullConfig?.version ?? ""} (${isDev() ? "dev-" : ""}${fullConfig?.buildtime ?? ""})`; + const updaterChannel = fullConfig?.settings?.["autoupdate:channel"] ?? "latest"; useEffect(() => { fireAndForget(async () => { diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 5c61e4199e..402757e121 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1020,6 +1020,8 @@ declare global { bookmarks: {[key: string]: WebBookmark}; waveai: {[key: string]: AIModeConfigType}; configerrors: ConfigError[]; + version: string; + buildtime: string; }; // waveobj.Job diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 2a1927630f..8de3832bcf 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -376,6 +376,8 @@ type FullConfigType struct { Bookmarks map[string]WebBookmark `json:"bookmarks"` WaveAIModes map[string]AIModeConfigType `json:"waveai"` ConfigErrors []ConfigError `json:"configerrors" configfile:"-"` + Version string `json:"version" configfile:"-"` + BuildTime string `json:"buildtime" configfile:"-"` } type ConnKeywords struct { @@ -696,6 +698,8 @@ func ReadFullConfig() FullConfigType { utilfn.ReUnmarshal(fieldPtr, configPart) } } + fullConfig.Version = wavebase.WaveVersion + fullConfig.BuildTime = wavebase.BuildTime return fullConfig } From 263eda42c6b9647ec864edb482b059b63f8fff37 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:10:11 -0700 Subject: [PATCH 23/47] Bump golang.org/x/sys from 0.42.0 to 0.43.0 (#3208) Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.42.0 to 0.43.0.
Commits
  • f33a730 windows: support nil security descriptor on GetNamedSecurityInfo
  • 493d172 cpu: add runtime import in cpu_darwin_arm64_other.go
  • 2c2be75 windows: use syscall.SyscallN in Proc.Call
  • a76ec62 cpu: roll back "use IsProcessorFeaturePresent to calculate ARM64 on windows"
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=golang.org/x/sys&package-manager=go_modules&previous-version=0.42.0&new-version=0.43.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 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 | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 2ee2660c5c..dcf58dddbb 100644 --- a/go.mod +++ b/go.mod @@ -35,7 +35,7 @@ require ( golang.org/x/crypto v0.49.0 golang.org/x/mod v0.34.0 golang.org/x/sync v0.20.0 - golang.org/x/sys v0.42.0 + golang.org/x/sys v0.43.0 golang.org/x/term v0.41.0 google.golang.org/api v0.274.0 ) diff --git a/go.sum b/go.sum index d6aa60afe4..c4fc0fc544 100644 --- a/go.sum +++ b/go.sum @@ -192,8 +192,8 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220721230656-c6bc011c0c49/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= From a08c2d74316298339515be7cf8b345b3ec924b23 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 15 Apr 2026 10:22:25 -0700 Subject: [PATCH 24/47] remove old waveai backend code (#3195) frontend was removed in the last release. cleaning up the backend code. remove wsapi host (cloud service is also getting removed) --- frontend/app/store/services.ts | 3 - frontend/app/store/wshclientapi.ts | 6 - frontend/types/gotypes.d.ts | 47 ---- pkg/service/blockservice/blockservice.go | 23 -- pkg/waveai/anthropicbackend.go | 316 ----------------------- pkg/waveai/cloudbackend.go | 127 --------- pkg/waveai/googlebackend.go | 117 --------- pkg/waveai/openaibackend.go | 179 ------------- pkg/waveai/perplexitybackend.go | 193 -------------- pkg/waveai/waveai.go | 118 --------- pkg/wcloud/wcloud.go | 28 -- pkg/wshrpc/wshclient/wshclient.go | 5 - pkg/wshrpc/wshrpctypes.go | 42 --- pkg/wshrpc/wshserver/wshserver.go | 5 - 14 files changed, 1209 deletions(-) delete mode 100644 pkg/waveai/anthropicbackend.go delete mode 100644 pkg/waveai/cloudbackend.go delete mode 100644 pkg/waveai/googlebackend.go delete mode 100644 pkg/waveai/openaibackend.go delete mode 100644 pkg/waveai/perplexitybackend.go delete mode 100644 pkg/waveai/waveai.go diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index 035834672a..9e6e156bc3 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -34,9 +34,6 @@ export class BlockServiceType { SaveTerminalState(blockId: string, state: string, stateType: string, ptyOffset: number, termSize: TermSize): Promise { return callBackendService(this?.waveEnv, "block", "SaveTerminalState", Array.from(arguments)) } - SaveWaveAiData(arg2: string, arg3: WaveAIPromptMessageType[]): Promise { - return callBackendService(this?.waveEnv, "block", "SaveWaveAiData", Array.from(arguments)) - } } export const BlockService = new BlockServiceType(); diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 191877de82..8482be260d 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -924,12 +924,6 @@ export class RpcApiType { return client.wshRpcStream("streamtest", null, opts); } - // command "streamwaveai" [responsestream] - StreamWaveAiCommand(client: WshClient, data: WaveAIStreamRequest, opts?: RpcOpts): AsyncGenerator { - if (this.mockClient) return this.mockClient.mockWshRpcStream(client, "streamwaveai", data, opts); - return client.wshRpcStream("streamwaveai", data, opts); - } - // command "termgetscrollbacklines" [call] TermGetScrollbackLinesCommand(client: WshClient, data: CommandTermGetScrollbackLinesData, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "termgetscrollbacklines", data, opts); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 402757e121..dadfc7969c 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -2011,53 +2011,6 @@ declare global { fullconfig: FullConfigType; }; - // wshrpc.WaveAIOptsType - type WaveAIOptsType = { - model: string; - apitype?: string; - apitoken: string; - orgid?: string; - apiversion?: string; - baseurl?: string; - proxyurl?: string; - maxtokens?: number; - maxchoices?: number; - timeoutms?: number; - }; - - // wshrpc.WaveAIPacketType - type WaveAIPacketType = { - type: string; - model?: string; - created?: number; - finish_reason?: string; - usage?: WaveAIUsageType; - index?: number; - text?: string; - error?: string; - }; - - // wshrpc.WaveAIPromptMessageType - type WaveAIPromptMessageType = { - role: string; - content: string; - name?: string; - }; - - // wshrpc.WaveAIStreamRequest - type WaveAIStreamRequest = { - clientid?: string; - opts: WaveAIOptsType; - prompt: WaveAIPromptMessageType[]; - }; - - // wshrpc.WaveAIUsageType - type WaveAIUsageType = { - prompt_tokens?: number; - completion_tokens?: number; - total_tokens?: number; - }; - // filestore.WaveFile type WaveFile = { diff --git a/pkg/service/blockservice/blockservice.go b/pkg/service/blockservice/blockservice.go index 4770931935..d2e6ca39da 100644 --- a/pkg/service/blockservice/blockservice.go +++ b/pkg/service/blockservice/blockservice.go @@ -5,7 +5,6 @@ package blockservice import ( "context" - "encoding/json" "fmt" "time" @@ -68,28 +67,6 @@ func (bs *BlockService) SaveTerminalState(ctx context.Context, blockId string, s return nil } -func (bs *BlockService) SaveWaveAiData(ctx context.Context, blockId string, history []wshrpc.WaveAIPromptMessageType) error { - block, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) - if err != nil { - return err - } - viewName := block.Meta.GetString(waveobj.MetaKey_View, "") - if viewName != "waveai" { - return fmt.Errorf("invalid view type: %s", viewName) - } - historyBytes, err := json.Marshal(history) - if err != nil { - return fmt.Errorf("unable to serialize ai history: %v", err) - } - // ignore MakeFile error (already exists is ok) - filestore.WFS.MakeFile(ctx, blockId, "aidata", nil, wshrpc.FileOpts{}) - err = filestore.WFS.WriteFile(ctx, blockId, "aidata", historyBytes) - if err != nil { - return fmt.Errorf("cannot save terminal state: %w", err) - } - return nil -} - func (*BlockService) CleanupOrphanedBlocks_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ Desc: "queue a layout action to cleanup orphaned blocks in the tab", diff --git a/pkg/waveai/anthropicbackend.go b/pkg/waveai/anthropicbackend.go deleted file mode 100644 index 05a605bad9..0000000000 --- a/pkg/waveai/anthropicbackend.go +++ /dev/null @@ -1,316 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package waveai - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" - - "github.com/wavetermdev/waveterm/pkg/panichandler" - "github.com/wavetermdev/waveterm/pkg/wshrpc" -) - -type AnthropicBackend struct{} - -var _ AIBackend = AnthropicBackend{} - -// Claude API request types -type anthropicMessage struct { - Role string `json:"role"` - Content string `json:"content"` -} - -type anthropicRequest struct { - Model string `json:"model"` - Messages []anthropicMessage `json:"messages"` - System string `json:"system,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - Stream bool `json:"stream"` - Temperature float32 `json:"temperature,omitempty"` -} - -// Claude API response types for SSE events -type anthropicContentBlock struct { - Type string `json:"type"` // "text" or other content types - Text string `json:"text,omitempty"` -} - -type anthropicUsage struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` -} - -type anthropicResponseMessage struct { - ID string `json:"id"` - Type string `json:"type"` - Role string `json:"role"` - Content []anthropicContentBlock `json:"content"` - Model string `json:"model"` - StopReason string `json:"stop_reason,omitempty"` - StopSequence string `json:"stop_sequence,omitempty"` - Usage *anthropicUsage `json:"usage,omitempty"` -} - -type anthropicStreamEventError struct { - Type string `json:"type"` - Message string `json:"message"` -} - -type anthropicStreamEventDelta struct { - Text string `json:"text"` -} - -type anthropicStreamEvent struct { - Type string `json:"type"` - Message *anthropicResponseMessage `json:"message,omitempty"` - ContentBlock *anthropicContentBlock `json:"content_block,omitempty"` - Delta *anthropicStreamEventDelta `json:"delta,omitempty"` - Error *anthropicStreamEventError `json:"error,omitempty"` - Usage *anthropicUsage `json:"usage,omitempty"` -} - -// SSE event represents a parsed Server-Sent Event -type sseEvent struct { - Event string // The event type field - Data string // The data field -} - -// parseSSE reads and parses SSE format from a bufio.Reader -func parseSSE(reader *bufio.Reader) (*sseEvent, error) { - var event sseEvent - - for { - line, err := reader.ReadString('\n') - if err != nil { - return nil, err - } - - line = strings.TrimSpace(line) - if line == "" { - // Empty line signals end of event - if event.Event != "" || event.Data != "" { - return &event, nil - } - continue - } - - if strings.HasPrefix(line, "event:") { - event.Event = strings.TrimSpace(strings.TrimPrefix(line, "event:")) - } else if strings.HasPrefix(line, "data:") { - event.Data = strings.TrimSpace(strings.TrimPrefix(line, "data:")) - } - } -} - -func (AnthropicBackend) StreamCompletion(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] { - rtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]) - - go func() { - defer func() { - panicErr := panichandler.PanicHandler("AnthropicBackend.StreamCompletion", recover()) - if panicErr != nil { - rtn <- makeAIError(panicErr) - } - close(rtn) - }() - - if request.Opts == nil { - rtn <- makeAIError(errors.New("no anthropic opts found")) - return - } - - model := request.Opts.Model - if model == "" { - model = "claude-3-sonnet-20250229" // default model - } - - // Convert messages format - var messages []anthropicMessage - var systemPrompt string - - for _, msg := range request.Prompt { - if msg.Role == "system" { - if systemPrompt != "" { - systemPrompt += "\n" - } - systemPrompt += msg.Content - continue - } - - role := "user" - if msg.Role == "assistant" { - role = "assistant" - } - - messages = append(messages, anthropicMessage{ - Role: role, - Content: msg.Content, - }) - } - - anthropicReq := anthropicRequest{ - Model: model, - Messages: messages, - System: systemPrompt, - Stream: true, - MaxTokens: request.Opts.MaxTokens, - } - - reqBody, err := json.Marshal(anthropicReq) - if err != nil { - rtn <- makeAIError(fmt.Errorf("failed to marshal anthropic request: %v", err)) - return - } - - // Build endpoint allowing custom base URL from presets/settings - endpoint := "https://api.anthropic.com/v1/messages" - if request.Opts.BaseURL != "" { - endpoint = strings.TrimSpace(request.Opts.BaseURL) - } - - req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(string(reqBody))) - if err != nil { - rtn <- makeAIError(fmt.Errorf("failed to create anthropic request: %v", err)) - return - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "text/event-stream") - req.Header.Set("x-api-key", request.Opts.APIToken) - version := "2023-06-01" - if request.Opts.APIVersion != "" { - version = request.Opts.APIVersion - } - req.Header.Set("anthropic-version", version) - - // Configure HTTP client with proxy if specified - client := &http.Client{} - if request.Opts.ProxyURL != "" { - proxyURL, err := url.Parse(request.Opts.ProxyURL) - if err != nil { - rtn <- makeAIError(fmt.Errorf("invalid proxy URL: %v", err)) - return - } - transport := &http.Transport{ - Proxy: http.ProxyURL(proxyURL), - } - client.Transport = transport - } - - resp, err := client.Do(req) - if err != nil { - rtn <- makeAIError(fmt.Errorf("failed to send anthropic request: %v", err)) - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(resp.Body) - rtn <- makeAIError(fmt.Errorf("Anthropic API error: %s - %s", resp.Status, string(bodyBytes))) - return - } - - reader := bufio.NewReader(resp.Body) - for { - // Check for context cancellation - select { - case <-ctx.Done(): - rtn <- makeAIError(fmt.Errorf("request cancelled: %v", ctx.Err())) - return - default: - } - - sse, err := parseSSE(reader) - if err == io.EOF { - break - } - if err != nil { - rtn <- makeAIError(fmt.Errorf("error reading SSE stream: %v", err)) - break - } - - if sse.Event == "ping" { - continue // Ignore ping events - } - - var event anthropicStreamEvent - if err := json.Unmarshal([]byte(sse.Data), &event); err != nil { - rtn <- makeAIError(fmt.Errorf("error unmarshaling event data: %v", err)) - break - } - - if event.Error != nil { - rtn <- makeAIError(fmt.Errorf("Anthropic API error: %s - %s", event.Error.Type, event.Error.Message)) - break - } - - switch sse.Event { - case "message_start": - if event.Message != nil { - pk := MakeWaveAIPacket() - pk.Model = event.Message.Model - rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk} - } - - case "content_block_start": - if event.ContentBlock != nil && event.ContentBlock.Text != "" { - pk := MakeWaveAIPacket() - pk.Text = event.ContentBlock.Text - rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk} - } - - case "content_block_delta": - if event.Delta != nil && event.Delta.Text != "" { - pk := MakeWaveAIPacket() - pk.Text = event.Delta.Text - rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk} - } - - case "content_block_stop": - // Note: According to the docs, this just signals the end of a content block - // We might want to use this for tracking block boundaries, but for now - // we don't need to send anything special to match OpenAI's format - - case "message_delta": - // Update message metadata, usage stats - if event.Usage != nil { - pk := MakeWaveAIPacket() - pk.Usage = &wshrpc.WaveAIUsageType{ - PromptTokens: event.Usage.InputTokens, - CompletionTokens: event.Usage.OutputTokens, - TotalTokens: event.Usage.InputTokens + event.Usage.OutputTokens, - } - rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk} - } - - case "message_stop": - if event.Message != nil { - pk := MakeWaveAIPacket() - pk.FinishReason = event.Message.StopReason - if event.Message.Usage != nil { - pk.Usage = &wshrpc.WaveAIUsageType{ - PromptTokens: event.Message.Usage.InputTokens, - CompletionTokens: event.Message.Usage.OutputTokens, - TotalTokens: event.Message.Usage.InputTokens + event.Message.Usage.OutputTokens, - } - } - rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk} - } - - default: - rtn <- makeAIError(fmt.Errorf("unknown Anthropic event type: %s", sse.Event)) - return - } - } - }() - - return rtn -} diff --git a/pkg/waveai/cloudbackend.go b/pkg/waveai/cloudbackend.go deleted file mode 100644 index f1148e591e..0000000000 --- a/pkg/waveai/cloudbackend.go +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package waveai - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log" - "time" - - "github.com/gorilla/websocket" - "github.com/wavetermdev/waveterm/pkg/panichandler" - "github.com/wavetermdev/waveterm/pkg/wcloud" - "github.com/wavetermdev/waveterm/pkg/wshrpc" -) - -type WaveAICloudBackend struct{} - -var _ AIBackend = WaveAICloudBackend{} - -const CloudWebsocketConnectTimeout = 1 * time.Minute -const OpenAICloudReqStr = "openai-cloudreq" -const PacketEOFStr = "EOF" - -type WaveAICloudReqPacketType struct { - Type string `json:"type"` - ClientId string `json:"clientid"` - Prompt []wshrpc.WaveAIPromptMessageType `json:"prompt"` - MaxTokens int `json:"maxtokens,omitempty"` - MaxChoices int `json:"maxchoices,omitempty"` -} - -func MakeWaveAICloudReqPacket() *WaveAICloudReqPacketType { - return &WaveAICloudReqPacketType{ - Type: OpenAICloudReqStr, - } -} - -func (WaveAICloudBackend) StreamCompletion(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] { - rtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]) - wsEndpoint := wcloud.GetWSEndpoint() - go func() { - defer func() { - panicErr := panichandler.PanicHandler("WaveAICloudBackend.StreamCompletion", recover()) - if panicErr != nil { - rtn <- makeAIError(panicErr) - } - close(rtn) - }() - if wsEndpoint == "" { - rtn <- makeAIError(fmt.Errorf("no cloud ws endpoint found")) - return - } - if request.Opts == nil { - rtn <- makeAIError(fmt.Errorf("no openai opts found")) - return - } - websocketContext, dialCancelFn := context.WithTimeout(context.Background(), CloudWebsocketConnectTimeout) - defer dialCancelFn() - conn, _, err := websocket.DefaultDialer.DialContext(websocketContext, wsEndpoint, nil) - if err == context.DeadlineExceeded { - rtn <- makeAIError(fmt.Errorf("OpenAI request, timed out connecting to cloud server: %v", err)) - return - } else if err != nil { - rtn <- makeAIError(fmt.Errorf("OpenAI request, websocket connect error: %v", err)) - return - } - defer func() { - err = conn.Close() - if err != nil { - rtn <- makeAIError(fmt.Errorf("unable to close openai channel: %v", err)) - } - }() - var sendablePromptMsgs []wshrpc.WaveAIPromptMessageType - for _, promptMsg := range request.Prompt { - if promptMsg.Role == "error" { - continue - } - sendablePromptMsgs = append(sendablePromptMsgs, promptMsg) - } - reqPk := MakeWaveAICloudReqPacket() - reqPk.ClientId = request.ClientId - reqPk.Prompt = sendablePromptMsgs - reqPk.MaxTokens = request.Opts.MaxTokens - reqPk.MaxChoices = request.Opts.MaxChoices - configMessageBuf, err := json.Marshal(reqPk) - if err != nil { - rtn <- makeAIError(fmt.Errorf("OpenAI request, packet marshal error: %v", err)) - return - } - err = conn.WriteMessage(websocket.TextMessage, configMessageBuf) - if err != nil { - rtn <- makeAIError(fmt.Errorf("OpenAI request, websocket write config error: %v", err)) - return - } - for { - _, socketMessage, err := conn.ReadMessage() - if err == io.EOF { - break - } - if err != nil { - log.Printf("err received: %v", err) - rtn <- makeAIError(fmt.Errorf("OpenAI request, websocket error reading message: %v", err)) - break - } - var streamResp *wshrpc.WaveAIPacketType - err = json.Unmarshal(socketMessage, &streamResp) - if err != nil { - rtn <- makeAIError(fmt.Errorf("OpenAI request, websocket response json decode error: %v", err)) - break - } - if streamResp.Error == PacketEOFStr { - // got eof packet from socket - break - } else if streamResp.Error != "" { - // use error from server directly - rtn <- makeAIError(fmt.Errorf("%v", streamResp.Error)) - break - } - rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *streamResp} - } - }() - return rtn -} diff --git a/pkg/waveai/googlebackend.go b/pkg/waveai/googlebackend.go deleted file mode 100644 index 9282bc5f87..0000000000 --- a/pkg/waveai/googlebackend.go +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package waveai - -import ( - "context" - "fmt" - "log" - "net/http" - "net/url" - - "github.com/google/generative-ai-go/genai" - "github.com/wavetermdev/waveterm/pkg/wshrpc" - "google.golang.org/api/iterator" - "google.golang.org/api/option" -) - -type GoogleBackend struct{} - -var _ AIBackend = GoogleBackend{} - -func (GoogleBackend) StreamCompletion(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] { - var clientOptions []option.ClientOption - clientOptions = append(clientOptions, option.WithAPIKey(request.Opts.APIToken)) - - // Configure proxy if specified - if request.Opts.ProxyURL != "" { - proxyURL, err := url.Parse(request.Opts.ProxyURL) - if err != nil { - rtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]) - go func() { - defer close(rtn) - rtn <- makeAIError(fmt.Errorf("invalid proxy URL: %v", err)) - }() - return rtn - } - transport := &http.Transport{ - Proxy: http.ProxyURL(proxyURL), - } - httpClient := &http.Client{ - Transport: transport, - } - clientOptions = append(clientOptions, option.WithHTTPClient(httpClient)) - } - - client, err := genai.NewClient(ctx, clientOptions...) - if err != nil { - log.Printf("failed to create client: %v", err) - return nil - } - - model := client.GenerativeModel(request.Opts.Model) - if model == nil { - log.Println("model not found") - client.Close() - return nil - } - - cs := model.StartChat() - cs.History = extractHistory(request.Prompt) - iter := cs.SendMessageStream(ctx, extractPrompt(request.Prompt)) - - rtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]) - - go func() { - defer client.Close() - defer close(rtn) - for { - // Check for context cancellation - if err := ctx.Err(); err != nil { - rtn <- makeAIError(fmt.Errorf("request cancelled: %v", err)) - break - } - - resp, err := iter.Next() - if err == iterator.Done { - break - } - if err != nil { - rtn <- makeAIError(fmt.Errorf("Google API error: %v", err)) - break - } - - rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: wshrpc.WaveAIPacketType{Text: convertCandidatesToText(resp.Candidates)}} - } - }() - return rtn -} - -func extractHistory(history []wshrpc.WaveAIPromptMessageType) []*genai.Content { - var rtn []*genai.Content - for _, h := range history[:len(history)-1] { - if h.Role == "user" || h.Role == "model" { - rtn = append(rtn, &genai.Content{ - Role: h.Role, - Parts: []genai.Part{genai.Text(h.Content)}, - }) - } - } - return rtn -} - -func extractPrompt(prompt []wshrpc.WaveAIPromptMessageType) genai.Part { - p := prompt[len(prompt)-1] - return genai.Text(p.Content) -} - -func convertCandidatesToText(candidates []*genai.Candidate) string { - var rtn string - for _, c := range candidates { - for _, p := range c.Content.Parts { - rtn += fmt.Sprintf("%v", p) - } - } - return rtn -} diff --git a/pkg/waveai/openaibackend.go b/pkg/waveai/openaibackend.go deleted file mode 100644 index 4001a3a670..0000000000 --- a/pkg/waveai/openaibackend.go +++ /dev/null @@ -1,179 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package waveai - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "regexp" - "strings" - - openaiapi "github.com/sashabaranov/go-openai" - "github.com/wavetermdev/waveterm/pkg/panichandler" - "github.com/wavetermdev/waveterm/pkg/wshrpc" -) - -type OpenAIBackend struct{} - -var _ AIBackend = OpenAIBackend{} - -const DefaultAzureAPIVersion = "2023-05-15" - -// copied from go-openai/config.go -func defaultAzureMapperFn(model string) string { - return regexp.MustCompile(`[.:]`).ReplaceAllString(model, "") -} - -func isReasoningModel(model string) bool { - m := strings.ToLower(model) - return strings.HasPrefix(m, "o1") || - strings.HasPrefix(m, "o3") || - strings.HasPrefix(m, "o4") || - strings.HasPrefix(m, "gpt-5") || - strings.HasPrefix(m, "gpt-5.1") -} - -func setApiType(opts *wshrpc.WaveAIOptsType, clientConfig *openaiapi.ClientConfig) error { - ourApiType := strings.ToLower(opts.APIType) - if ourApiType == "" || ourApiType == APIType_OpenAI || ourApiType == strings.ToLower(string(openaiapi.APITypeOpenAI)) { - clientConfig.APIType = openaiapi.APITypeOpenAI - return nil - } else if ourApiType == strings.ToLower(string(openaiapi.APITypeAzure)) { - clientConfig.APIType = openaiapi.APITypeAzure - clientConfig.APIVersion = DefaultAzureAPIVersion - clientConfig.AzureModelMapperFunc = defaultAzureMapperFn - return nil - } else if ourApiType == strings.ToLower(string(openaiapi.APITypeAzureAD)) { - clientConfig.APIType = openaiapi.APITypeAzureAD - clientConfig.APIVersion = DefaultAzureAPIVersion - clientConfig.AzureModelMapperFunc = defaultAzureMapperFn - return nil - } else if ourApiType == strings.ToLower(string(openaiapi.APITypeCloudflareAzure)) { - clientConfig.APIType = openaiapi.APITypeCloudflareAzure - clientConfig.APIVersion = DefaultAzureAPIVersion - clientConfig.AzureModelMapperFunc = defaultAzureMapperFn - return nil - } else { - return fmt.Errorf("invalid api type %q", opts.APIType) - } -} - -func convertPrompt(prompt []wshrpc.WaveAIPromptMessageType) []openaiapi.ChatCompletionMessage { - var rtn []openaiapi.ChatCompletionMessage - for _, p := range prompt { - msg := openaiapi.ChatCompletionMessage{Role: p.Role, Content: p.Content, Name: p.Name} - rtn = append(rtn, msg) - } - return rtn -} - -func (OpenAIBackend) StreamCompletion(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] { - rtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]) - go func() { - defer func() { - panicErr := panichandler.PanicHandler("OpenAIBackend.StreamCompletion", recover()) - if panicErr != nil { - rtn <- makeAIError(panicErr) - } - close(rtn) - }() - if request.Opts == nil { - rtn <- makeAIError(errors.New("no openai opts found")) - return - } - if request.Opts.Model == "" { - rtn <- makeAIError(errors.New("no openai model specified")) - return - } - if request.Opts.BaseURL == "" && request.Opts.APIToken == "" { - rtn <- makeAIError(errors.New("no api token")) - return - } - - clientConfig := openaiapi.DefaultConfig(request.Opts.APIToken) - if request.Opts.BaseURL != "" { - clientConfig.BaseURL = request.Opts.BaseURL - } - err := setApiType(request.Opts, &clientConfig) - if err != nil { - rtn <- makeAIError(err) - return - } - if request.Opts.OrgID != "" { - clientConfig.OrgID = request.Opts.OrgID - } - if request.Opts.APIVersion != "" { - clientConfig.APIVersion = request.Opts.APIVersion - } - - // Configure proxy if specified - if request.Opts.ProxyURL != "" { - proxyURL, err := url.Parse(request.Opts.ProxyURL) - if err != nil { - rtn <- makeAIError(fmt.Errorf("invalid proxy URL: %v", err)) - return - } - transport := &http.Transport{ - Proxy: http.ProxyURL(proxyURL), - } - clientConfig.HTTPClient = &http.Client{ - Transport: transport, - } - } - - client := openaiapi.NewClientWithConfig(clientConfig) - req := openaiapi.ChatCompletionRequest{ - Model: request.Opts.Model, - Messages: convertPrompt(request.Prompt), - } - - // Set MaxCompletionTokens for reasoning models, MaxTokens for others - if isReasoningModel(request.Opts.Model) { - req.MaxCompletionTokens = request.Opts.MaxTokens - } else { - req.MaxTokens = request.Opts.MaxTokens - } - - req.Stream = true - if request.Opts.MaxChoices > 1 { - req.N = request.Opts.MaxChoices - } - - apiResp, err := client.CreateChatCompletionStream(ctx, req) - if err != nil { - rtn <- makeAIError(fmt.Errorf("error calling openai API: %v", err)) - return - } - sentHeader := false - for { - streamResp, err := apiResp.Recv() - if err == io.EOF { - break - } - if err != nil { - rtn <- makeAIError(fmt.Errorf("OpenAI request, error reading message: %v", err)) - break - } - if streamResp.Model != "" && !sentHeader { - pk := MakeWaveAIPacket() - pk.Model = streamResp.Model - pk.Created = streamResp.Created - rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk} - sentHeader = true - } - for _, choice := range streamResp.Choices { - pk := MakeWaveAIPacket() - pk.Index = choice.Index - pk.Text = choice.Delta.Content - pk.FinishReason = string(choice.FinishReason) - rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk} - } - } - }() - return rtn -} diff --git a/pkg/waveai/perplexitybackend.go b/pkg/waveai/perplexitybackend.go deleted file mode 100644 index e24481d417..0000000000 --- a/pkg/waveai/perplexitybackend.go +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package waveai - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" - - "github.com/wavetermdev/waveterm/pkg/panichandler" - "github.com/wavetermdev/waveterm/pkg/wshrpc" -) - -type PerplexityBackend struct{} - -var _ AIBackend = PerplexityBackend{} - -// Perplexity API request types -type perplexityMessage struct { - Role string `json:"role"` - Content string `json:"content"` -} - -type perplexityRequest struct { - Model string `json:"model"` - Messages []perplexityMessage `json:"messages"` - Stream bool `json:"stream"` -} - -// Perplexity API response types -type perplexityResponseDelta struct { - Content string `json:"content"` -} - -type perplexityResponseChoice struct { - Delta perplexityResponseDelta `json:"delta"` - FinishReason string `json:"finish_reason"` -} - -type perplexityResponse struct { - ID string `json:"id"` - Choices []perplexityResponseChoice `json:"choices"` - Model string `json:"model"` -} - -func (PerplexityBackend) StreamCompletion(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] { - rtn := make(chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]) - - go func() { - defer func() { - panicErr := panichandler.PanicHandler("PerplexityBackend.StreamCompletion", recover()) - if panicErr != nil { - rtn <- makeAIError(panicErr) - } - close(rtn) - }() - - if request.Opts == nil { - rtn <- makeAIError(errors.New("no perplexity opts found")) - return - } - - model := request.Opts.Model - if model == "" { - model = "llama-3.1-sonar-small-128k-online" - } - - // Convert messages format - var messages []perplexityMessage - for _, msg := range request.Prompt { - role := "user" - if msg.Role == "assistant" { - role = "assistant" - } else if msg.Role == "system" { - role = "system" - } - - messages = append(messages, perplexityMessage{ - Role: role, - Content: msg.Content, - }) - } - - perplexityReq := perplexityRequest{ - Model: model, - Messages: messages, - Stream: true, - } - - reqBody, err := json.Marshal(perplexityReq) - if err != nil { - rtn <- makeAIError(fmt.Errorf("failed to marshal perplexity request: %v", err)) - return - } - - req, err := http.NewRequestWithContext(ctx, "POST", "https://api.perplexity.ai/chat/completions", strings.NewReader(string(reqBody))) - if err != nil { - rtn <- makeAIError(fmt.Errorf("failed to create perplexity request: %v", err)) - return - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+request.Opts.APIToken) - - // Configure HTTP client with proxy if specified - client := &http.Client{} - if request.Opts.ProxyURL != "" { - proxyURL, err := url.Parse(request.Opts.ProxyURL) - if err != nil { - rtn <- makeAIError(fmt.Errorf("invalid proxy URL: %v", err)) - return - } - transport := &http.Transport{ - Proxy: http.ProxyURL(proxyURL), - } - client.Transport = transport - } - - resp, err := client.Do(req) - if err != nil { - rtn <- makeAIError(fmt.Errorf("failed to send perplexity request: %v", err)) - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(resp.Body) - rtn <- makeAIError(fmt.Errorf("Perplexity API error: %s - %s", resp.Status, string(bodyBytes))) - return - } - - reader := bufio.NewReader(resp.Body) - sentHeader := false - - for { - // Check for context cancellation - select { - case <-ctx.Done(): - rtn <- makeAIError(fmt.Errorf("request cancelled: %v", ctx.Err())) - return - default: - } - - line, err := reader.ReadString('\n') - if err == io.EOF { - break - } - if err != nil { - rtn <- makeAIError(fmt.Errorf("error reading stream: %v", err)) - break - } - - line = strings.TrimSpace(line) - if !strings.HasPrefix(line, "data: ") { - continue - } - - data := strings.TrimPrefix(line, "data: ") - if data == "[DONE]" { - break - } - - var response perplexityResponse - if err := json.Unmarshal([]byte(data), &response); err != nil { - rtn <- makeAIError(fmt.Errorf("error unmarshaling response: %v", err)) - break - } - - if !sentHeader { - pk := MakeWaveAIPacket() - pk.Model = response.Model - rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk} - sentHeader = true - } - - for _, choice := range response.Choices { - pk := MakeWaveAIPacket() - pk.Text = choice.Delta.Content - pk.FinishReason = choice.FinishReason - rtn <- wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Response: *pk} - } - } - }() - - return rtn -} diff --git a/pkg/waveai/waveai.go b/pkg/waveai/waveai.go deleted file mode 100644 index 4d012e968a..0000000000 --- a/pkg/waveai/waveai.go +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package waveai - -import ( - "context" - "log" - "net/url" - "strings" - - "github.com/wavetermdev/waveterm/pkg/telemetry" - "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" - "github.com/wavetermdev/waveterm/pkg/wshrpc" -) - -const WaveAIPacketstr = "waveai" -const APIType_Anthropic = "anthropic" -const APIType_Perplexity = "perplexity" -const APIType_Google = "google" -const APIType_OpenAI = "openai" - -type WaveAICmdInfoPacketOutputType struct { - Model string `json:"model,omitempty"` - Created int64 `json:"created,omitempty"` - FinishReason string `json:"finish_reason,omitempty"` - Message string `json:"message,omitempty"` - Error string `json:"error,omitempty"` -} - -func MakeWaveAIPacket() *wshrpc.WaveAIPacketType { - return &wshrpc.WaveAIPacketType{Type: WaveAIPacketstr} -} - -type WaveAICmdInfoChatMessage struct { - MessageID int `json:"messageid"` - IsAssistantResponse bool `json:"isassistantresponse,omitempty"` - AssistantResponse *WaveAICmdInfoPacketOutputType `json:"assistantresponse,omitempty"` - UserQuery string `json:"userquery,omitempty"` - UserEngineeredQuery string `json:"userengineeredquery,omitempty"` -} - -type AIBackend interface { - StreamCompletion( - ctx context.Context, - request wshrpc.WaveAIStreamRequest, - ) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] -} - -func IsCloudAIRequest(opts *wshrpc.WaveAIOptsType) bool { - if opts == nil { - return true - } - return opts.BaseURL == "" && opts.APIToken == "" -} - -func isLocalURL(baseURL string) bool { - if baseURL == "" { - return false - } - - u, err := url.Parse(baseURL) - if err != nil { - return false - } - - host := strings.ToLower(u.Hostname()) - return host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" || strings.HasPrefix(host, "192.168.") || strings.HasPrefix(host, "10.") || (strings.HasPrefix(host, "172.") && len(host) > 4) -} - -func makeAIError(err error) wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] { - return wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType]{Error: err} -} - -func RunAICommand(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] { - telemetry.GoUpdateActivityWrap(wshrpc.ActivityUpdate{NumAIReqs: 1}, "RunAICommand") - - endpoint := request.Opts.BaseURL - if endpoint == "" { - endpoint = "default" - } - var backend AIBackend - var backendType string - if request.Opts.APIType == APIType_Anthropic { - backend = AnthropicBackend{} - backendType = APIType_Anthropic - } else if request.Opts.APIType == APIType_Perplexity { - backend = PerplexityBackend{} - backendType = APIType_Perplexity - } else if request.Opts.APIType == APIType_Google { - backend = GoogleBackend{} - backendType = APIType_Google - } else if IsCloudAIRequest(request.Opts) { - endpoint = "waveterm cloud" - request.Opts.APIType = APIType_OpenAI - request.Opts.Model = "default" - backend = WaveAICloudBackend{} - backendType = "wave" - } else { - backend = OpenAIBackend{} - backendType = APIType_OpenAI - } - if backend == nil { - log.Printf("no backend found for %s\n", request.Opts.APIType) - return nil - } - aiLocal := backendType != "wave" && isLocalURL(request.Opts.BaseURL) - telemetry.GoRecordTEventWrap(&telemetrydata.TEvent{ - Event: "action:runaicmd", - Props: telemetrydata.TEventProps{ - AiBackendType: backendType, - AiLocal: aiLocal, - }, - }) - - log.Printf("sending ai chat message to %s endpoint %q using model %s\n", request.Opts.APIType, endpoint, request.Opts.Model) - return backend.StreamCompletion(ctx, request) -} diff --git a/pkg/wcloud/wcloud.go b/pkg/wcloud/wcloud.go index 3b96df838b..b31ff94150 100644 --- a/pkg/wcloud/wcloud.go +++ b/pkg/wcloud/wcloud.go @@ -25,12 +25,9 @@ import ( const WCloudEndpoint = "https://api.waveterm.dev/central" const WCloudEndpointVarName = "WCLOUD_ENDPOINT" -const WCloudWSEndpoint = "wss://wsapi.waveterm.dev/" -const WCloudWSEndpointVarName = "WCLOUD_WS_ENDPOINT" const WCloudPingEndpoint = "https://ping.waveterm.dev/central" const WCloudPingEndpointVarName = "WCLOUD_PING_ENDPOINT" -var WCloudWSEndpoint_VarCache string var WCloudEndpoint_VarCache string var WCloudPingEndpoint_VarCache string @@ -59,12 +56,6 @@ func CacheAndRemoveEnvVars() error { return err } os.Unsetenv(WCloudEndpointVarName) - WCloudWSEndpoint_VarCache = os.Getenv(WCloudWSEndpointVarName) - err = checkWSEndpointVar(WCloudWSEndpoint_VarCache, "wcloud ws endpoint", WCloudWSEndpointVarName) - if err != nil { - return err - } - os.Unsetenv(WCloudWSEndpointVarName) WCloudPingEndpoint_VarCache = os.Getenv(WCloudPingEndpointVarName) os.Unsetenv(WCloudPingEndpointVarName) return nil @@ -80,17 +71,6 @@ func checkEndpointVar(endpoint string, debugName string, varName string) error { return nil } -func checkWSEndpointVar(endpoint string, debugName string, varName string) error { - if !wavebase.IsDevMode() { - return nil - } - log.Printf("checking endpoint %q\n", endpoint) - if endpoint == "" || !strings.HasPrefix(endpoint, "wss://") { - return fmt.Errorf("invalid %s, %s not set or invalid", debugName, varName) - } - return nil -} - func GetEndpoint() string { if !wavebase.IsDevMode() { return WCloudEndpoint @@ -99,14 +79,6 @@ func GetEndpoint() string { return endpoint } -func GetWSEndpoint() string { - if !wavebase.IsDevMode() { - return WCloudWSEndpoint - } - endpoint := WCloudWSEndpoint_VarCache - return endpoint -} - func GetPingEndpoint() string { if !wavebase.IsDevMode() { return WCloudPingEndpoint diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 5c5cd62012..d5333aec2b 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -918,11 +918,6 @@ func StreamTestCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) chan wshrpc.Resp return sendRpcRequestResponseStreamHelper[int](w, "streamtest", nil, opts) } -// command "streamwaveai", wshserver.StreamWaveAiCommand -func StreamWaveAiCommand(w *wshutil.WshRpc, data wshrpc.WaveAIStreamRequest, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] { - return sendRpcRequestResponseStreamHelper[wshrpc.WaveAIPacketType](w, "streamwaveai", data, opts) -} - // command "termgetscrollbacklines", wshserver.TermGetScrollbackLinesCommand func TermGetScrollbackLinesCommand(w *wshutil.WshRpc, data wshrpc.CommandTermGetScrollbackLinesData, opts *wshrpc.RpcOpts) (*wshrpc.CommandTermGetScrollbackLinesRtnData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.CommandTermGetScrollbackLinesRtnData](w, "termgetscrollbacklines", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 98c65e0526..51e2338ba8 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -71,7 +71,6 @@ type WshRpcInterface interface { GetTempDirCommand(ctx context.Context, data CommandGetTempDirData) (string, error) WriteTempFileCommand(ctx context.Context, data CommandWriteTempFileData) (string, error) StreamTestCommand(ctx context.Context) chan RespOrErrorUnion[int] - StreamWaveAiCommand(ctx context.Context, request WaveAIStreamRequest) chan RespOrErrorUnion[WaveAIPacketType] StreamCpuDataCommand(ctx context.Context, request CpuDataRequest) chan RespOrErrorUnion[TimeSeriesData] TestCommand(ctx context.Context, data string) error TestMultiArgCommand(ctx context.Context, arg1 string, arg2 int, arg3 bool) (string, error) @@ -342,47 +341,6 @@ type CommandEventReadHistoryData struct { MaxItems int `json:"maxitems"` } -type WaveAIStreamRequest struct { - ClientId string `json:"clientid,omitempty"` - Opts *WaveAIOptsType `json:"opts"` - Prompt []WaveAIPromptMessageType `json:"prompt"` -} - -type WaveAIPromptMessageType struct { - Role string `json:"role"` - Content string `json:"content"` - Name string `json:"name,omitempty"` -} - -type WaveAIOptsType struct { - Model string `json:"model"` - APIType string `json:"apitype,omitempty"` - APIToken string `json:"apitoken"` - OrgID string `json:"orgid,omitempty"` - APIVersion string `json:"apiversion,omitempty"` - BaseURL string `json:"baseurl,omitempty"` - ProxyURL string `json:"proxyurl,omitempty"` - MaxTokens int `json:"maxtokens,omitempty"` - MaxChoices int `json:"maxchoices,omitempty"` - TimeoutMs int `json:"timeoutms,omitempty"` -} - -type WaveAIPacketType struct { - Type string `json:"type"` - Model string `json:"model,omitempty"` - Created int64 `json:"created,omitempty"` - FinishReason string `json:"finish_reason,omitempty"` - Usage *WaveAIUsageType `json:"usage,omitempty"` - Index int `json:"index,omitempty"` - Text string `json:"text,omitempty"` - Error string `json:"error,omitempty"` -} - -type WaveAIUsageType struct { - PromptTokens int `json:"prompt_tokens,omitempty"` - CompletionTokens int `json:"completion_tokens,omitempty"` - TotalTokens int `json:"total_tokens,omitempty"` -} type CpuDataRequest struct { Id string `json:"id"` diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index e66e52320c..a78298c54e 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -43,7 +43,6 @@ import ( "github.com/wavetermdev/waveterm/pkg/util/envutil" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" - "github.com/wavetermdev/waveterm/pkg/waveai" "github.com/wavetermdev/waveterm/pkg/waveappstore" "github.com/wavetermdev/waveterm/pkg/waveapputil" "github.com/wavetermdev/waveterm/pkg/wavebase" @@ -114,10 +113,6 @@ func (ws *WshServer) StreamTestCommand(ctx context.Context) chan wshrpc.RespOrEr return rtn } -func (ws *WshServer) StreamWaveAiCommand(ctx context.Context, request wshrpc.WaveAIStreamRequest) chan wshrpc.RespOrErrorUnion[wshrpc.WaveAIPacketType] { - return waveai.RunAICommand(ctx, request) -} - func MakePlotData(ctx context.Context, blockId string) error { block, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) if err != nil { From ffd0b669a512eb4e08e38724a642df2847eac15c Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 15 Apr 2026 10:22:45 -0700 Subject: [PATCH 25/47] fix notfound error in settings (#3212) --- pkg/wshrpc/wshremote/wshremote_file.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/wshrpc/wshremote/wshremote_file.go b/pkg/wshrpc/wshremote/wshremote_file.go index 3589cc998c..f336b9d8bb 100644 --- a/pkg/wshrpc/wshremote/wshremote_file.go +++ b/pkg/wshrpc/wshremote/wshremote_file.go @@ -565,6 +565,14 @@ func (impl *ServerImpl) RemoteFileStreamCommand(ctx context.Context, data wshrpc finfo, err := os.Stat(cleanedPath) if err != nil { + if os.IsNotExist(err) { + writer.Close() + return &wshrpc.FileInfo{ + Path: wavebase.ReplaceHomeDir(data.Path), + Dir: computeDirPart(data.Path), + NotFound: true, + }, nil + } writer.CloseWithError(err) return nil, fmt.Errorf("cannot stat file %q: %w", data.Path, err) } From 38e3c2e9b39614f5c4ec994a42c0c619c723480a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 10:23:41 -0700 Subject: [PATCH 26/47] Bump google.golang.org/api from 0.274.0 to 0.275.0 (#3209) Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.274.0 to 0.275.0.
Release notes

Sourced from google.golang.org/api's releases.

v0.275.0

0.275.0 (2026-04-07)

Features

Changelog

Sourced from google.golang.org/api's changelog.

0.275.0 (2026-04-07)

Features

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 22 +++++++++++----------- go.sum | 56 ++++++++++++++++++++++++++++---------------------------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/go.mod b/go.mod index dcf58dddbb..3bc1b42063 100644 --- a/go.mod +++ b/go.mod @@ -37,13 +37,13 @@ require ( golang.org/x/sync v0.20.0 golang.org/x/sys v0.43.0 golang.org/x/term v0.41.0 - google.golang.org/api v0.274.0 + google.golang.org/api v0.275.0 ) require ( cloud.google.com/go v0.121.6 // indirect cloud.google.com/go/ai v0.8.0 // indirect - cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/longrunning v0.6.7 // indirect @@ -56,7 +56,7 @@ require ( github.com/go-ole/go-ole v1.2.6 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect - github.com/googleapis/gax-go/v2 v2.19.0 // indirect + github.com/googleapis/gax-go/v2 v2.21.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mailru/easyjson v0.7.7 // indirect @@ -71,18 +71,18 @@ require ( github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.42.0 // indirect - go.opentelemetry.io/otel/metric v1.42.0 // indirect - go.opentelemetry.io/otel/trace v1.42.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect - google.golang.org/grpc v1.79.3 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c4fc0fc544..06959d0325 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI= cloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w= cloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE= -cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= -cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= @@ -70,8 +70,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= -github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= +github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -161,20 +161,20 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= @@ -201,18 +201,18 @@ golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA= -google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= -google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js= -google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.275.0 h1:vfY5d9vFVJeWEZT65QDd9hbndr7FyZ2+6mIzGAh71NI= +google.golang.org/api v0.275.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 1fe0fa236c34e6f9614958dc421373ef4d5f7bf4 Mon Sep 17 00:00:00 2001 From: Drew Goddyn Date: Wed, 15 Apr 2026 10:24:39 -0700 Subject: [PATCH 27/47] fix: trim trailing whitespace from terminal clipboard copies (#3167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2778. ## Problem `xterm.js`'s `getSelection()` returns lines padded to the full terminal column width. Every copy path passed this directly to `navigator.clipboard.writeText()`, so pasting into Slack, editors, etc. included hundreds of trailing spaces. ## Solution Adds a `term:trimtrailingwhitespace` setting (default `true`) that strips trailing whitespace from each line before writing to the clipboard. Applied to all three copy paths: - copy-on-select (`termwrap.ts`) - `Ctrl+Shift+C` (`term-model.ts`) - right-click Copy context menu (`term-model.ts`) OSC 52 is intentionally excluded — that path copies program-provided text, not grid content. The trim itself uses the same per-line `trimEnd()` approach already present in `bufferLinesToText` via `translateToString(true)`. Setting to `false` restores the previous behaviour. ## Files changed - `pkg/wconfig/settingsconfig.go` — new `TermTrimTrailingWhitespace *bool` field - `pkg/wconfig/defaultconfig/settings.json` — default `true` - `frontend/app/view/term/termutil.ts` — `trimTerminalSelection` helper - `frontend/app/view/term/termwrap.ts` — copy-on-select path - `frontend/app/view/term/term-model.ts` — Ctrl+Shift+C and right-click paths - Generated: `pkg/wconfig/metaconsts.go`, `frontend/types/gotypes.d.ts`, `schema/settings.json` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) --- frontend/app/view/term/term-model.ts | 13 ++++++++++--- frontend/app/view/term/termutil.ts | 7 +++++++ frontend/app/view/term/termwrap.ts | 7 ++++++- frontend/types/gotypes.d.ts | 1 + pkg/wconfig/defaultconfig/settings.json | 1 + pkg/wconfig/metaconsts.go | 1 + pkg/wconfig/settingsconfig.go | 5 +++-- schema/settings.json | 3 +++ 8 files changed, 32 insertions(+), 6 deletions(-) diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index bf77ef9535..09ccce3e54 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -40,7 +40,7 @@ import { boundNumber, fireAndForget, stringToBase64 } from "@/util/util"; import * as jotai from "jotai"; import * as React from "react"; import { getBlockingCommand } from "./shellblocking"; -import { computeTheme, DefaultTermTheme } from "./termutil"; +import { computeTheme, DefaultTermTheme, trimTerminalSelection } from "./termutil"; import { TermWrap, WebGLSupported } from "./termwrap"; export class TermViewModel implements ViewModel { @@ -750,10 +750,13 @@ export class TermViewModel implements ViewModel { } else if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:c")) { event.preventDefault(); event.stopPropagation(); - const sel = this.termRef.current?.terminal.getSelection(); + let sel = this.termRef.current?.terminal.getSelection(); if (!sel) { return false; } + if (globalStore.get(getSettingsKeyAtom("term:trimtrailingwhitespace")) !== false) { + sel = trimTerminalSelection(sel); + } navigator.clipboard.writeText(sel); return false; } else if (keyutil.checkKeyPressed(waveEvent, "Cmd:k")) { @@ -829,7 +832,11 @@ export class TermViewModel implements ViewModel { label: "Copy", click: () => { if (selection) { - navigator.clipboard.writeText(selection); + const text = + globalStore.get(getSettingsKeyAtom("term:trimtrailingwhitespace")) !== false + ? trimTerminalSelection(selection) + : selection; + navigator.clipboard.writeText(text); } }, }); diff --git a/frontend/app/view/term/termutil.ts b/frontend/app/view/term/termutil.ts index 2fea30404a..838b8aaf92 100644 --- a/frontend/app/view/term/termutil.ts +++ b/frontend/app/view/term/termutil.ts @@ -10,6 +10,13 @@ import { colord } from "colord"; export type GenClipboardItem = { text?: string; image?: Blob }; +export function trimTerminalSelection(text: string): string { + return text + .split("\n") + .map((line) => line.trimEnd()) + .join("\n"); +} + export function normalizeCursorStyle(cursorStyle: string): TermTypes.Terminal["options"]["cursorStyle"] { if (cursorStyle === "underline" || cursorStyle === "bar") { return cursorStyle; diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index e1b129b72d..d10b600459 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -42,6 +42,7 @@ import { extractAllClipboardData, normalizeCursorStyle, quoteForPosixShell, + trimTerminalSelection, } from "./termutil"; const dlog = debug("wave:termwrap"); @@ -380,6 +381,7 @@ export class TermWrap { async initTerminal() { const copyOnSelectAtom = getSettingsKeyAtom("term:copyonselect"); + const trimTrailingWhitespaceAtom = getSettingsKeyAtom("term:trimtrailingwhitespace"); this.toDispose.push(this.terminal.onData(this.handleTermData.bind(this))); this.toDispose.push( this.terminal.onSelectionChange( @@ -393,8 +395,11 @@ export class TermWrap { if (active != null && active.closest(".search-container") != null) { return; } - const selectedText = this.terminal.getSelection(); + let selectedText = this.terminal.getSelection(); if (selectedText.length > 0) { + if (globalStore.get(trimTrailingWhitespaceAtom) !== false) { + selectedText = trimTerminalSelection(selectedText); + } navigator.clipboard.writeText(selectedText); } }) diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index dadfc7969c..4d200a6b11 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1425,6 +1425,7 @@ declare global { "term:osc52"?: string; "term:durable"?: boolean; "term:showsplitbuttons"?: boolean; + "term:trimtrailingwhitespace"?: boolean; "editor:minimapenabled"?: boolean; "editor:stickyscrollenabled"?: boolean; "editor:wordwrap"?: boolean; diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index ff6dbbe48a..d8847cabf2 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -36,6 +36,7 @@ "term:cursor": "block", "term:cursorblink": false, "term:copyonselect": true, + "term:trimtrailingwhitespace": true, "term:durable": false, "waveai:showcloudmodes": true, "waveai:defaultmode": "waveai@balanced", diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 6c813954f7..7d5bba5d9d 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -60,6 +60,7 @@ const ( ConfigKey_TermOsc52 = "term:osc52" ConfigKey_TermDurable = "term:durable" ConfigKey_TermShowSplitButtons = "term:showsplitbuttons" + ConfigKey_TermTrimTrailingWhitespace = "term:trimtrailingwhitespace" ConfigKey_EditorMinimapEnabled = "editor:minimapenabled" ConfigKey_EditorStickyScrollEnabled = "editor:stickyscrollenabled" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 8de3832bcf..67118b1670 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -109,8 +109,9 @@ type SettingsType struct { 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"` - TermShowSplitButtons bool `json:"term:showsplitbuttons,omitempty"` + TermDurable *bool `json:"term:durable,omitempty"` + TermShowSplitButtons bool `json:"term:showsplitbuttons,omitempty"` + TermTrimTrailingWhitespace *bool `json:"term:trimtrailingwhitespace,omitempty"` EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"` EditorStickyScrollEnabled bool `json:"editor:stickyscrollenabled,omitempty"` diff --git a/schema/settings.json b/schema/settings.json index 91de939c38..f341a0f365 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -171,6 +171,9 @@ "term:showsplitbuttons": { "type": "boolean" }, + "term:trimtrailingwhitespace": { + "type": "boolean" + }, "editor:minimapenabled": { "type": "boolean" }, From 5e3673c338fb7bdcb66acb16bf17bbea46650452 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 15 Apr 2026 11:06:26 -0700 Subject: [PATCH 28/47] batch wsh:run tevents by hour (#3181) --- frontend/types/gotypes.d.ts | 3 +- pkg/telemetry/telemetry.go | 42 ++++++++++++++++++++ pkg/telemetry/telemetrydata/telemetrydata.go | 5 ++- pkg/wshrpc/wshserver/wshserver.go | 5 ++- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 4d200a6b11..c5b870d7ed 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1592,7 +1592,8 @@ declare global { "ai:backendtype"?: string; "ai:local"?: boolean; "wsh:cmd"?: string; - "wsh:haderror"?: boolean; + "wsh:errorcount"?: number; + "wsh:count"?: number; "conn:conntype"?: string; "conn:wsherrorcode"?: string; "conn:errorcode"?: string; diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go index 7b91535bb4..b514ec9ada 100644 --- a/pkg/telemetry/telemetry.go +++ b/pkg/telemetry/telemetry.go @@ -27,6 +27,7 @@ import ( const MaxTzNameLen = 50 const ActivityEventName = "app:activity" +const WshRunEventName = "wsh:run" var cachedTosAgreedTs atomic.Int64 @@ -196,6 +197,44 @@ func updateActivityTEvent(ctx context.Context, tevent *telemetrydata.TEvent) err }) } +// aggregates wsh:run events per (cmd, haderror) key within the current 1-hour bucket +func updateWshRunTEvent(ctx context.Context, tevent *telemetrydata.TEvent) error { + eventTs := time.Now().Truncate(time.Hour).Add(time.Hour) + incomingCount := tevent.Props.WshCount + if incomingCount <= 0 { + incomingCount = 1 + } + return wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { + uuidStr := tx.GetString( + `SELECT uuid FROM db_tevent WHERE ts = ? AND event = ? AND json_extract(props, '$."wsh:cmd"') IS ?`, + eventTs.UnixMilli(), WshRunEventName, tevent.Props.WshCmd, + ) + if uuidStr != "" { + var curProps telemetrydata.TEventProps + rawProps := tx.GetString(`SELECT props FROM db_tevent WHERE uuid = ?`, uuidStr) + if rawProps != "" { + if err := json.Unmarshal([]byte(rawProps), &curProps); err != nil { + log.Printf("error unmarshalling wsh:run props: %v\n", err) + } + } + curCount := curProps.WshCount + if curCount <= 0 { + curCount = 1 + } + curProps.WshCount = curCount + incomingCount + curProps.WshErrorCount += tevent.Props.WshErrorCount + tx.Exec(`UPDATE db_tevent SET props = ? WHERE uuid = ?`, dbutil.QuickJson(curProps), uuidStr) + } else { + newProps := tevent.Props + newProps.WshCount = incomingCount + tsLocal := utilfn.ConvertToWallClockPT(eventTs).Format(time.RFC3339) + tx.Exec(`INSERT INTO db_tevent (uuid, ts, tslocal, event, props) VALUES (?, ?, ?, ?, ?)`, + uuid.New().String(), eventTs.UnixMilli(), tsLocal, WshRunEventName, dbutil.QuickJson(newProps)) + } + return nil + }) +} + func TruncateActivityTEventForShutdown(ctx context.Context) error { nowTs := time.Now() eventTs := nowTs.Truncate(time.Hour).Add(time.Hour) @@ -259,6 +298,9 @@ func RecordTEvent(ctx context.Context, tevent *telemetrydata.TEvent) error { if tevent.Event == ActivityEventName { return updateActivityTEvent(ctx, tevent) } + if tevent.Event == WshRunEventName { + return updateWshRunTEvent(ctx, tevent) + } return insertTEvent(ctx, tevent) } diff --git a/pkg/telemetry/telemetrydata/telemetrydata.go b/pkg/telemetry/telemetrydata/telemetrydata.go index 222ebfbaed..a08ff67bed 100644 --- a/pkg/telemetry/telemetrydata/telemetrydata.go +++ b/pkg/telemetry/telemetrydata/telemetrydata.go @@ -126,8 +126,9 @@ type TEventProps struct { AiBackendType string `json:"ai:backendtype,omitempty"` AiLocal bool `json:"ai:local,omitempty"` - WshCmd string `json:"wsh:cmd,omitempty"` - WshHadError bool `json:"wsh:haderror,omitempty"` + WshCmd string `json:"wsh:cmd,omitempty"` + WshErrorCount int `json:"wsh:errorcount,omitempty"` + WshCount int `json:"wsh:count,omitempty"` ConnType string `json:"conn:conntype,omitempty"` ConnWshErrorCode string `json:"conn:wsherrorcode,omitempty"` diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index a78298c54e..38006fd9a8 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -1329,7 +1329,8 @@ func (ws *WshServer) WshActivityCommand(ctx context.Context, data map[string]int delete(data, key) } if strings.HasSuffix(key, "#error") { - props.WshHadError = true + props.WshCmd = strings.TrimSuffix(key, "#error") + props.WshErrorCount = 1 } else { props.WshCmd = key } @@ -1339,7 +1340,7 @@ func (ws *WshServer) WshActivityCommand(ctx context.Context, data map[string]int } telemetry.GoUpdateActivityWrap(activityUpdate, "wsh-activity") telemetry.GoRecordTEventWrap(&telemetrydata.TEvent{ - Event: "wsh:run", + Event: telemetry.WshRunEventName, Props: props, }) return nil From 5d0e8e33b5d870f9cc69fb1527047b08c964d741 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 15 Apr 2026 11:44:19 -0700 Subject: [PATCH 29/47] fix connstatus in new processviewer widget (#3222) --- .../app/view/processviewer/processviewer.tsx | 60 +++++++++++-------- go.mod | 1 - go.sum | 2 - 3 files changed, 36 insertions(+), 27 deletions(-) diff --git a/frontend/app/view/processviewer/processviewer.tsx b/frontend/app/view/processviewer/processviewer.tsx index 5accb79cb6..a9d79c6ee0 100644 --- a/frontend/app/view/processviewer/processviewer.tsx +++ b/frontend/app/view/processviewer/processviewer.tsx @@ -153,7 +153,11 @@ export class ProcessViewerViewModel implements ViewModel { const containerHeight = globalStore.get(this.containerHeightAtom); const conn = globalStore.get(this.connection); const textSearch = globalStore.get(this.textSearchAtom); + const connStatus = globalStore.get(this.connStatus); + if (!connStatus?.connected) { + return; + } const start = Math.max(0, Math.floor(scrollTop / RowHeight) - OverscanRows); const visibleRows = containerHeight > 0 ? Math.ceil(containerHeight / RowHeight) : 50; const limit = visibleRows + OverscanRows * 2; @@ -190,6 +194,10 @@ export class ProcessViewerViewModel implements ViewModel { async doKeepAlive() { if (this.disposed) return; + const connStatus = globalStore.get(this.connStatus); + if (!connStatus?.connected) { + return; + } const conn = globalStore.get(this.connection); const route = makeConnRoute(conn); try { @@ -871,6 +879,7 @@ export const ProcessViewerView: React.FC(null); const containerRef = React.useRef(null); const [wide, setWide] = React.useState(false); @@ -976,33 +985,36 @@ export const ProcessViewerView: React.FC - {/* inner column — expands to header's natural width, rows match */} -
- - - {/* virtualized rows — same width as header, scrolls vertically */} -
-
-
- {processes.map((proc) => ( - - ))} + {!connStatus?.connected ? ( +
+ Waiting for connection… +
+ ) : ( +
+ +
+
+
+ {processes.map((proc) => ( + + ))} +
-
+ )}
diff --git a/go.mod b/go.mod index 3bc1b42063..c236a4bf4e 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,6 @@ require ( github.com/launchdarkly/eventsource v1.11.0 github.com/mattn/go-sqlite3 v1.14.40 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.3 github.com/skeema/knownhosts v1.3.1 diff --git a/go.sum b/go.sum index 06959d0325..c28e78d057 100644 --- a/go.sum +++ b/go.sum @@ -122,8 +122,6 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM= -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.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= From 97ffbd04c533c7b50b7900df7ee4deaeb6e92aa9 Mon Sep 17 00:00:00 2001 From: "wave-builder[bot]" <181805596+wave-builder[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:45:36 +0000 Subject: [PATCH 30/47] chore: bump package version to 0.14.5-beta.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 26098d270e..30380fc693 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.4", + "version": "0.14.5-beta.0", "homepage": "https://waveterm.dev", "build": { "appId": "dev.commandline.waveterm" From daaa010f1e2b8f1875a9183935c7dcc610b6003d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:40:20 -0700 Subject: [PATCH 31/47] Bump @xmldom/xmldom from 0.8.11 to 0.8.12 (#3161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@xmldom/xmldom](https://github.com/xmldom/xmldom) from 0.8.11 to 0.8.12.
Release notes

Sourced from @​xmldom/xmldom's releases.

0.8.12

Commits

Fixed

Code that passes a string containing "]]>" to createCDATASection and relied on the previously unsafe behavior will now receive InvalidCharacterError. Use a mutation method such as appendData if you intentionally need "]]>" in a CDATASection node's data.

Thank you, @​thesmartshadow, @​stevenobiajulu, for your contributions

https://github.com/xmldom/xmldom/discussions/357

Changelog

Sourced from @​xmldom/xmldom's changelog.

0.8.12

Fixed

Code that passes a string containing "]]>" to createCDATASection and relied on the previously unsafe behavior will now receive InvalidCharacterError. Use a mutation method such as appendData if you intentionally need "]]>" in a CDATASection node's data.

Thank you, @​thesmartshadow, @​stevenobiajulu, for your contributions

Commits
  • 189cb78 0.8.12
  • ed08df7 fix: XML injection via unsafe CDATA serialization (GHSA-wh4c-j3r5-mjhp) (#968)
  • a5b929b chore: clean up generated test artefacts before running ci-local
  • 4e37a20 ci: run format:check in lint job
  • ac0ac77 chore: ignore generated files when checking formatting
  • 968c893 chore: add local CI script and format:check script
  • ac40424 fix: preserve trailing whitespace in ProcessingInstruction data (#962)
  • cece752 chore: add .nvmrc pointing to node version 18
  • cbf44d9 docs: improve links to changes in most recent release
  • See full diff in compare view
Maintainer changes

This version was pushed to npm by karfau, a new releaser for @​xmldom/xmldom since your current version.


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@xmldom/xmldom&package-manager=npm_and_yarn&previous-version=0.8.11&new-version=0.8.12)](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 4d8200a859..adf83b6969 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10610,9 +10610,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", + "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", "dev": true, "license": "MIT", "engines": { From a6f438fa6ebd1cb987b290eb868e069b77ed48fc Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 15 Apr 2026 18:40:58 -0700 Subject: [PATCH 32/47] add docs for new term:showsplitbuttons config key (#3160) --- docs/docs/config.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index abeca3429f..e3b58325ae 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -76,6 +76,7 @@ wsh editconfig | 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) | +| term:showsplitbuttons | bool | when enabled, shows split horizontal and vertical buttons in the terminal block header (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 | | editor:wordwrap | bool | set to true to enable word wrapping in the editor (defaults to false) | From 812c8a8e6b8fe6f1eb6b207cb17747e4fe558f20 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 15 Apr 2026 18:43:45 -0700 Subject: [PATCH 33/47] add v0.14.5 release notes and onboarding (#3223) --- CLAUDE.md | 18 +++++ docs/docs/releasenotes.mdx | 16 ++++ frontend/app/onboarding/onboarding-common.tsx | 2 +- .../onboarding/onboarding-upgrade-patch.tsx | 7 ++ .../onboarding/onboarding-upgrade-v0145.tsx | 73 +++++++++++++++++++ package-lock.json | 4 +- 6 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 CLAUDE.md create mode 100644 frontend/app/onboarding/onboarding-upgrade-v0145.tsx diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..ea0daa9425 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,18 @@ +@.kilocode/rules/rules.md + +--- + +## Skill Guides + +This project uses a set of "skill" guides — focused how-to documents for common implementation tasks. When your task matches one of the descriptions below, **read the linked SKILL.md file before proceeding** and follow its instructions precisely. + +| Skill | File | Description | +| ------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| add-config | `.kilocode/skills/add-config/SKILL.md` | Guide for adding new configuration settings to Wave Terminal. Use when adding a new setting to the configuration system, implementing a new config key, or adding user-customizable settings. | +| add-rpc | `.kilocode/skills/add-rpc/SKILL.md` | Guide for adding new RPC calls to Wave Terminal. Use when implementing new RPC commands, adding server-client communication methods, or extending the RPC interface with new functionality. | +| add-wshcmd | `.kilocode/skills/add-wshcmd/SKILL.md` | Guide for adding new wsh commands to Wave Terminal. Use when implementing new CLI commands, adding command-line functionality, or extending the wsh command interface. | +| context-menu | `.kilocode/skills/context-menu/SKILL.md` | Guide for creating and displaying context menus in Wave Terminal. Use when implementing right-click menus, adding context menu items, creating submenus, or handling menu interactions with checkboxes and separators. | +| create-view | `.kilocode/skills/create-view/SKILL.md` | Guide for implementing a new view type in Wave Terminal. Use when creating a new view component, implementing the ViewModel interface, registering a new view type in BlockRegistry, or adding a new content type to display within blocks. | +| electron-api | `.kilocode/skills/electron-api/SKILL.md` | Guide for adding new Electron APIs to Wave Terminal. Use when implementing new frontend-to-electron communications via preload/IPC. | +| waveenv | `.kilocode/skills/waveenv/SKILL.md` | Guide for creating WaveEnv narrowings in Wave Terminal. Use when writing a named subset type of WaveEnv for a component tree, documenting environmental dependencies, or enabling mock environments for preview/test server usage. | +| wps-events | `.kilocode/skills/wps-events/SKILL.md` | Guide for working with Wave Terminal's WPS (Wave PubSub) event system. Use when implementing new event types, publishing events, subscribing to events, or adding asynchronous communication between components. | diff --git a/docs/docs/releasenotes.mdx b/docs/docs/releasenotes.mdx index db141b5626..352af2da78 100644 --- a/docs/docs/releasenotes.mdx +++ b/docs/docs/releasenotes.mdx @@ -6,6 +6,22 @@ sidebar_position: 200 # Release Notes +### v0.14.5 — Apr 16, 2026 + +Wave v0.14.5 introduces a new Process Viewer widget, Quake Mode for global hotkey, and several quality-of-life improvements. + +- **Process Viewer** - New widget that displays running processes on local and remote machines, with CPU and memory usage, sortable columns, and the ability to send signals to processes +- **Quake Mode** - The global hotkey (`app:globalhotkey`) now triggers a dedicated quake mode that drops a Wave window down from the top of the screen, similar to classic quake-style terminals +- **[bugfix] Settings Widget** - Fixed a bug where config files that didn't exist yet couldn't be created or edited from the Settings widget UI +- **Drag & Drop Files into Terminal** - Drag files from Finder (macOS) or your file manager into a terminal block to paste their quoted path ([#746](https://github.com/wavetermdev/waveterm/issues/746)) +- New opt-in `app:showsplitbuttons` setting adds horizontal/vertical split buttons to block headers +- Toggle the widgets sidebar from the View menu; visibility persists per workspace +- F2 to rename the active tab +- Mouse button 3/4 (back/forward) now navigate in web widgets +- Terminal sessions now set `COLORTERM=truecolor` for better color support in CLI tools +- [bugfix] Trim trailing whitespace from terminal clipboard copies +- Package updates and dependency upgrades + ### v0.14.4 — Mar 26, 2026 Wave v0.14.4 introduces vertical tabs, upgrades to xterm.js v6, and includes a collection of bug fixes and internal improvements. diff --git a/frontend/app/onboarding/onboarding-common.tsx b/frontend/app/onboarding/onboarding-common.tsx index 96e49b1a79..41f05e1f43 100644 --- a/frontend/app/onboarding/onboarding-common.tsx +++ b/frontend/app/onboarding/onboarding-common.tsx @@ -1,7 +1,7 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -export const CurrentOnboardingVersion = "v0.14.4"; +export const CurrentOnboardingVersion = "v0.14.5"; export function OnboardingGradientBg() { return ( diff --git a/frontend/app/onboarding/onboarding-upgrade-patch.tsx b/frontend/app/onboarding/onboarding-upgrade-patch.tsx index c3dd5004a2..87ffadb1e4 100644 --- a/frontend/app/onboarding/onboarding-upgrade-patch.tsx +++ b/frontend/app/onboarding/onboarding-upgrade-patch.tsx @@ -26,6 +26,7 @@ import { UpgradeOnboardingModal_v0_14_0_Content } from "./onboarding-upgrade-v01 import { UpgradeOnboardingModal_v0_14_1_Content } from "./onboarding-upgrade-v0141"; import { UpgradeOnboardingModal_v0_14_2_Content } from "./onboarding-upgrade-v0142"; import { UpgradeOnboardingModal_v0_14_4_Content } from "./onboarding-upgrade-v0144"; +import { UpgradeOnboardingModal_v0_14_5_Content } from "./onboarding-upgrade-v0145"; interface VersionConfig { version: string; @@ -146,6 +147,12 @@ export const UpgradeOnboardingVersions: VersionConfig[] = [ version: "v0.14.4", content: () => , prevText: "Prev (v0.14.3)", + nextText: "Next (v0.14.5)", + }, + { + version: "v0.14.5", + content: () => , + prevText: "Prev (v0.14.4)", }, ]; diff --git a/frontend/app/onboarding/onboarding-upgrade-v0145.tsx b/frontend/app/onboarding/onboarding-upgrade-v0145.tsx new file mode 100644 index 0000000000..88408ef827 --- /dev/null +++ b/frontend/app/onboarding/onboarding-upgrade-v0145.tsx @@ -0,0 +1,73 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +const UpgradeOnboardingModal_v0_14_5_Content = () => { + return ( +
+
+

+ Wave v0.14.5 introduces a new Process Viewer widget, Quake Mode for the global hotkey, and several + quality-of-life improvements. +

+
+ +
+
+ +
+
+
Process Viewer
+
+ New widget that displays running processes on local and remote machines, with CPU and memory + usage and sortable columns. +
+
+
+ +
+
+ +
+
+
Quake Mode
+
+ The global hotkey (app:globalhotkey) now triggers a dedicated quake mode that + drops a Wave window down from the top of the screen, similar to classic quake-style terminals. +
+
+
+ +
+
+ +
+
+
Other Changes
+
+
    +
  • + Drag & Drop Files into Terminal - Drag files from Finder or your + file manager into a terminal to paste their quoted path +
  • +
  • + New opt-in app:showsplitbuttons setting adds split buttons to block + headers +
  • +
  • Toggle the widgets sidebar from the View menu
  • +
  • F2 to rename the active tab
  • +
  • Mouse back/forward buttons now navigate in web widgets
  • +
  • + [bugfix]{" "}Config files that didn't exist yet couldn't be + created or edited from the Settings widget +
  • +
+
+
+
+
+ ); +}; + +UpgradeOnboardingModal_v0_14_5_Content.displayName = "UpgradeOnboardingModal_v0_14_5_Content"; + +export { UpgradeOnboardingModal_v0_14_5_Content }; diff --git a/package-lock.json b/package-lock.json index adf83b6969..667722b000 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.14.4", + "version": "0.14.5-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.14.4", + "version": "0.14.5-beta.0", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ From 4a55b7ed0f387f6453e74ea93baa723793589a01 Mon Sep 17 00:00:00 2001 From: "wave-builder[bot]" <181805596+wave-builder[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 01:45:18 +0000 Subject: [PATCH 34/47] chore: bump package version to 0.14.5-beta.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 30380fc693..348686fc09 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.5-beta.0", + "version": "0.14.5-beta.1", "homepage": "https://waveterm.dev", "build": { "appId": "dev.commandline.waveterm" From 158e404d8039a0d9c74d6bd9f34e16db562b1fa1 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 15 Apr 2026 22:36:28 -0700 Subject: [PATCH 35/47] Lots of fixes, big and small for processviewer (frontend and backend) (#3224) The big fix is not spawning a goroutine per process. other fixes are more minor, but improve the quality and clean up some edge cases. --- Taskfile.yml | 3 + docs/docs/keybindings.mdx | 8 ++ docs/docs/releasenotes.mdx | 2 +- docs/docs/widgets.mdx | 7 + .../onboarding/onboarding-upgrade-v0145.tsx | 32 ++--- .../app/view/processviewer/processviewer.tsx | 24 ++-- package-lock.json | 4 +- pkg/wconfig/defaultconfig/widgets.json | 10 ++ pkg/wshrpc/wshremote/processviewer.go | 134 +++++++++--------- 9 files changed, 125 insertions(+), 99 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 106ac99e0b..bf37a83e45 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -156,6 +156,7 @@ tasks: - tsunami/go.mod - tsunami/go.sum - tsunami/**/*.go + - package.json build:schema: desc: Build the schema for configuration. @@ -185,6 +186,7 @@ tasks: - "pkg/**/*.json" - "pkg/**/*.sh" - tsunami/**/*.go + - package.json generates: - dist/bin/wavesrv.* @@ -289,6 +291,7 @@ tasks: sources: - "cmd/wsh/**/*.go" - "pkg/**/*.go" + - package.json generates: - "dist/bin/wsh*" diff --git a/docs/docs/keybindings.mdx b/docs/docs/keybindings.mdx index 36ca33a9ce..d5d88856e8 100644 --- a/docs/docs/keybindings.mdx +++ b/docs/docs/keybindings.mdx @@ -107,6 +107,14 @@ Chords are shown with a + between the keys. You have 2 seconds to hit the 2nd ch | | Scroll up one page | | | Scroll down one page | +## Process Viewer Keybindings + +| Key | Function | +| ----------------------- | ------------------------------------- | +| | Pause / resume live updates | +| | Open process filter / search | +| | Close search bar | + ## Customizeable Systemwide Global Hotkey Wave allows setting a custom global hotkey to focus your most recent window from anywhere in your computer. For more information on this, see [the config docs](./config#customizable-systemwide-global-hotkey). diff --git a/docs/docs/releasenotes.mdx b/docs/docs/releasenotes.mdx index 352af2da78..987be81534 100644 --- a/docs/docs/releasenotes.mdx +++ b/docs/docs/releasenotes.mdx @@ -11,7 +11,7 @@ sidebar_position: 200 Wave v0.14.5 introduces a new Process Viewer widget, Quake Mode for global hotkey, and several quality-of-life improvements. - **Process Viewer** - New widget that displays running processes on local and remote machines, with CPU and memory usage, sortable columns, and the ability to send signals to processes -- **Quake Mode** - The global hotkey (`app:globalhotkey`) now triggers a dedicated quake mode that drops a Wave window down from the top of the screen, similar to classic quake-style terminals +- **Quake Mode** - The global hotkey (`app:globalhotkey`) now toggles a Wave window visible and invisible - **[bugfix] Settings Widget** - Fixed a bug where config files that didn't exist yet couldn't be created or edited from the Settings widget UI - **Drag & Drop Files into Terminal** - Drag files from Finder (macOS) or your file manager into a terminal block to paste their quoted path ([#746](https://github.com/wavetermdev/waveterm/issues/746)) - New opt-in `app:showsplitbuttons` setting adds horizontal/vertical split buttons to block headers diff --git a/docs/docs/widgets.mdx b/docs/docs/widgets.mdx index d8795ca4e9..52257619d1 100644 --- a/docs/docs/widgets.mdx +++ b/docs/docs/widgets.mdx @@ -6,6 +6,7 @@ title: "Widgets" import { Kbd } from "@site/src/components/kbd"; import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext"; +import { VersionBadge } from "@site/src/components/versionbadge"; @@ -138,4 +139,10 @@ You can also save by pressing . To exit **edit mode** without saving, click the cancel button to the right of the header. You can also exit without saving by pressing . +### Process Viewer + +The Process Viewer shows a live list of running processes on any connected host. It is similar to `top` or `htop`, displaying PID, command, CPU%, and memory usage. On Linux it also shows process status and thread count. + +Columns are sortable by clicking their headers. Right-clicking a row lets you send Unix signals (SIGTERM, SIGKILL, etc.) or copy the PID. You can filter the list by pressing and typing a search term. Press to pause live updates (useful when inspecting a specific process); press it again to resume. + diff --git a/frontend/app/onboarding/onboarding-upgrade-v0145.tsx b/frontend/app/onboarding/onboarding-upgrade-v0145.tsx index 88408ef827..be0b43cf4a 100644 --- a/frontend/app/onboarding/onboarding-upgrade-v0145.tsx +++ b/frontend/app/onboarding/onboarding-upgrade-v0145.tsx @@ -6,8 +6,8 @@ const UpgradeOnboardingModal_v0_14_5_Content = () => {

- Wave v0.14.5 introduces a new Process Viewer widget, Quake Mode for the global hotkey, and several - quality-of-life improvements. + Wave v0.14.5 introduces a new Process Viewer widget, several quality-of-life improvements, and a + fix for creating new config files from the Settings widget.

@@ -24,19 +24,6 @@ const UpgradeOnboardingModal_v0_14_5_Content = () => {
-
-
- -
-
-
Quake Mode
-
- The global hotkey (app:globalhotkey) now triggers a dedicated quake mode that - drops a Wave window down from the top of the screen, similar to classic quake-style terminals. -
-
-
-
@@ -46,18 +33,21 @@ const UpgradeOnboardingModal_v0_14_5_Content = () => {
  • - Drag & Drop Files into Terminal - Drag files from Finder or your - file manager into a terminal to paste their quoted path + Quake Mode — global hotkey ( + app:globalhotkey) now toggles a Wave window visible and invisible +
  • +
  • + Drag & Drop Files into Terminal + to paste their quoted path
  • - New opt-in app:showsplitbuttons setting adds split buttons to block - headers + New app:showsplitbuttons setting adds split buttons to block headers
  • -
  • Toggle the widgets sidebar from the View menu
  • +
  • Toggle the widgets sidebar on and off from the View menu
  • F2 to rename the active tab
  • Mouse back/forward buttons now navigate in web widgets
  • - [bugfix]{" "}Config files that didn't exist yet couldn't be + [bugfix] Config files that didn't exist yet couldn't be created or edited from the Settings widget
diff --git a/frontend/app/view/processviewer/processviewer.tsx b/frontend/app/view/processviewer/processviewer.tsx index a9d79c6ee0..d7f284bcf3 100644 --- a/frontend/app/view/processviewer/processviewer.tsx +++ b/frontend/app/view/processviewer/processviewer.tsx @@ -311,6 +311,10 @@ export class ProcessViewerViewModel implements ViewModel { this.cancelPoll = null; this.startKeepAlive(); } else { + if (this.cancelPoll) { + this.cancelPoll(); + } + this.cancelPoll = null; this.startPolling(); } } @@ -470,7 +474,7 @@ const Columns: ColDef[] = [ { key: "pid", label: "PID", width: "70px", align: "right" }, { key: "command", label: "Command", width: "minmax(120px, 4fr)" }, { key: "status", label: "Status", width: "75px", hideOnPlatform: ["windows", "darwin"] }, - { key: "user", label: "User", width: "80px" }, + { key: "user", label: "User", width: "80px", hideOnPlatform: ["windows"] }, { key: "threads", label: "NT", tooltip: "Num Threads", width: "40px", align: "right", hideOnPlatform: ["windows"] }, { key: "cpu", label: "CPU%", width: "70px", align: "right" }, { key: "mem", label: "Memory", width: "90px", align: "right" }, @@ -603,9 +607,9 @@ const ProcessRow = React.memo(function ProcessRow({ onSelect: (pid: number) => void; onContextMenu: (pid: number, e: React.MouseEvent) => void; }) { + const cols = getColumns(platform); + const visibleKeys = new Set(cols.map((c) => c.key)); const gridTemplate = getGridTemplate(platform); - const showStatus = platform !== "windows" && platform !== "darwin"; - const showThreads = platform !== "windows"; if (proc.gone) { return (
(gone)
- {showStatus &&
} -
- {showThreads &&
} + {visibleKeys.has("status") &&
} + {visibleKeys.has("user") &&
} + {visibleKeys.has("threads") &&
}
@@ -637,11 +641,13 @@ const ProcessRow = React.memo(function ProcessRow({ {proc.pid}
{proc.command}
- {showStatus && ( + {visibleKeys.has("status") && (
{proc.status}
)} -
{proc.user}
- {showThreads && ( + {visibleKeys.has("user") && ( +
{proc.user}
+ )} + {visibleKeys.has("threads") && (
{proc.numthreads === -1 ? "-" : proc.numthreads >= 1 ? proc.numthreads : ""}
diff --git a/package-lock.json b/package-lock.json index 667722b000..9e4a67a931 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.14.5-beta.0", + "version": "0.14.5-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.14.5-beta.0", + "version": "0.14.5-beta.1", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/pkg/wconfig/defaultconfig/widgets.json b/pkg/wconfig/defaultconfig/widgets.json index 2d0524b7dd..eb978d6448 100644 --- a/pkg/wconfig/defaultconfig/widgets.json +++ b/pkg/wconfig/defaultconfig/widgets.json @@ -40,5 +40,15 @@ "view": "sysinfo" } } + }, + "defwidget@processviewer": { + "display:order": -1, + "icon": "list-tree", + "label": "processes", + "blockdef": { + "meta": { + "view": "processviewer" + } + } } } diff --git a/pkg/wshrpc/wshremote/processviewer.go b/pkg/wshrpc/wshremote/processviewer.go index f4248d51f6..dc6bb0ee6e 100644 --- a/pkg/wshrpc/wshremote/processviewer.go +++ b/pkg/wshrpc/wshremote/processviewer.go @@ -27,6 +27,7 @@ import ( const ( ProcCacheIdleTimeout = 60 * time.Second ProcCachePollInterval = 1 * time.Second + ProcCacheMinSleep = 500 * time.Millisecond ProcViewerMaxLimit = 500 ) @@ -153,17 +154,48 @@ func (s *procCacheState) getWidgetPidOrder(widgetId string) ([]int32, int) { return entry.pids, entry.totalCount } +// updateCacheAndCheckIdle stores the latest snapshot, signals the first-ready channel if needed, +// and checks whether the loop has been idle long enough to shut down. +// Returns true if the loop should exit (idle timeout reached), false to continue. +func (s *procCacheState) updateCacheAndCheckIdle(result *wshrpc.ProcessListResponse, firstDone *bool, firstReadyCh chan struct{}) bool { + s.lock.Lock() + defer s.lock.Unlock() + if result != nil { + s.cached = result + } + if !*firstDone { + *firstDone = true + close(firstReadyCh) + s.ready = nil + } + if time.Since(s.lastRequest) < ProcCacheIdleTimeout { + return false + } + s.cached = nil + s.running = false + s.lastCPUSamples = nil + s.lastCPUEpoch = 0 + s.uidCache = nil + s.widgetPidOrders = nil + return true +} + func (s *procCacheState) runLoop(firstReadyCh chan struct{}) { + firstDone := false defer func() { - panichandler.PanicHandler("procCache.runLoop", recover()) + if panichandler.PanicHandler("procCache.runLoop", recover()) == nil { + return + } + s.lock.Lock() + defer s.lock.Unlock() + s.running = false + if !firstDone { + close(firstReadyCh) + s.ready = nil + } }() - numCPU := runtime.NumCPU() - if numCPU < 1 { - numCPU = 1 - } - - firstDone := false + numCPU := max(runtime.NumCPU(), 1) for { iterStart := time.Now() @@ -178,36 +210,21 @@ func (s *procCacheState) runLoop(firstReadyCh chan struct{}) { } } - s.lock.Lock() - s.cached = result - idleFor := time.Since(s.lastRequest) - if !firstDone { - firstDone = true - close(firstReadyCh) - s.ready = nil - } - if idleFor >= ProcCacheIdleTimeout { - s.cached = nil - s.running = false - s.lastCPUSamples = nil - s.lastCPUEpoch = 0 - s.uidCache = nil - s.widgetPidOrders = nil - s.lock.Unlock() + if s.updateCacheAndCheckIdle(result, &firstDone, firstReadyCh) { return } - s.lock.Unlock() elapsed := time.Since(iterStart) - if sleep := ProcCachePollInterval - elapsed; sleep > 0 { - time.Sleep(sleep) - } + time.Sleep(max(ProcCacheMinSleep, ProcCachePollInterval-elapsed)) } } // lookupUID resolves a uid to a username, using the per-run cache to avoid // repeated syscalls for the same uid. func (s *procCacheState) lookupUID(uid uint32) string { + if runtime.GOOS == "windows" { + return "" + } if s.uidCache == nil { s.uidCache = make(map[uint32]string) } @@ -239,33 +256,25 @@ func (s *procCacheState) collectSnapshot(numCPU int) *wshrpc.ProcessListResponse s.lastCPUSamples = make(map[int32]cpuSample, len(procs)) } - snap, _ := procinfo.MakeGlobalSnapshot() + snap, err := procinfo.MakeGlobalSnapshot() + if err != nil { + return nil + } hasCPU := s.lastCPUEpoch > 1 // first epoch has no previous sample to diff against - // Build per-pid procinfo in parallel, then compute CPU% sequentially. type pidInfo struct { pid int32 info *procinfo.ProcInfo } rawInfos := make([]pidInfo, len(procs)) - var wg sync.WaitGroup for i, p := range procs { - i, p := i, p - wg.Add(1) - go func() { - defer func() { - panichandler.PanicHandler("collectSnapshot:GetProcInfo", recover()) - wg.Done() - }() - pi, err := procinfo.GetProcInfo(ctx, snap, p.Pid) - if err != nil { - pi = nil - } - rawInfos[i] = pidInfo{pid: p.Pid, info: pi} - }() + pi, err := procinfo.GetProcInfo(ctx, snap, p.Pid) + if err != nil { + pi = nil + } + rawInfos[i] = pidInfo{pid: p.Pid, info: pi} } - wg.Wait() // Sample CPU times and compute CPU% sequentially to keep epoch accounting simple. cpuPcts := make(map[int32]float64, len(procs)) @@ -295,10 +304,11 @@ func (s *procCacheState) collectSnapshot(numCPU int) *wshrpc.ProcessListResponse } } - // Compute total memory for MemPct. + // Compute total memory for MemPct and summary. + vmStat, _ := mem.VirtualMemoryWithContext(ctx) var totalMem uint64 - if vm, err := mem.VirtualMemoryWithContext(ctx); err == nil { - totalMem = vm.Total + if vmStat != nil { + totalMem = vmStat.Total } var cpuSum float64 @@ -331,16 +341,7 @@ func (s *procCacheState) collectSnapshot(numCPU int) *wshrpc.ProcessListResponse infos = append(infos, info) } - summaryCh := make(chan wshrpc.ProcessSummary, 1) - go func() { - defer func() { - if err := panichandler.PanicHandler("buildProcessSummary", recover()); err != nil { - summaryCh <- wshrpc.ProcessSummary{Total: len(procs)} - } - }() - summaryCh <- buildProcessSummary(ctx, len(procs), numCPU, cpuSum) - }() - summary := <-summaryCh + summary := buildProcessSummary(ctx, len(procs), numCPU, cpuSum, vmStat) return &wshrpc.ProcessListResponse{ Processes: infos, @@ -351,6 +352,10 @@ func (s *procCacheState) collectSnapshot(numCPU int) *wshrpc.ProcessListResponse } } +func bound(v, lo, hi int) int { + return max(lo, min(v, hi)) +} + func computeCPUPct(t1, t2, elapsedSec float64) float64 { delta := (t2 - t1) / elapsedSec * 100 if delta < 0 { @@ -359,17 +364,17 @@ func computeCPUPct(t1, t2, elapsedSec float64) float64 { return delta } -func buildProcessSummary(ctx context.Context, total int, numCPU int, cpuSum float64) wshrpc.ProcessSummary { +func buildProcessSummary(ctx context.Context, total int, numCPU int, cpuSum float64, vmStat *mem.VirtualMemoryStat) wshrpc.ProcessSummary { summary := wshrpc.ProcessSummary{Total: total, NumCPU: numCPU, CpuSum: cpuSum} if avg, err := load.AvgWithContext(ctx); err == nil { summary.Load1 = avg.Load1 summary.Load5 = avg.Load5 summary.Load15 = avg.Load15 } - if vm, err := mem.VirtualMemoryWithContext(ctx); err == nil { - summary.MemTotal = vm.Total - summary.MemUsed = vm.Used - summary.MemFree = vm.Free + if vmStat != nil { + summary.MemTotal = vmStat.Total + summary.MemUsed = vmStat.Used + summary.MemFree = vmStat.Free } return summary } @@ -544,10 +549,7 @@ func (impl *ServerImpl) RemoteProcessListCommand(ctx context.Context, data wshrp for _, p := range raw.Processes { pidMap[p.Pid] = p } - start := data.Start - if start >= len(pidOrder) { - start = len(pidOrder) - } + start := bound(data.Start, 0, len(pidOrder)) window := pidOrder[start:] if limit > 0 && len(window) > limit { window = window[:limit] From b52652fe760fd796a710fb90899ea7a62fdc973f Mon Sep 17 00:00:00 2001 From: "wave-builder[bot]" <181805596+wave-builder[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 05:37:57 +0000 Subject: [PATCH 36/47] chore: bump package version to 0.14.5-beta.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 348686fc09..e8dbfc0541 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.5-beta.1", + "version": "0.14.5-beta.2", "homepage": "https://waveterm.dev", "build": { "appId": "dev.commandline.waveterm" From 97e560027f494d20fed347b2d6b72b6bcb3e50e0 Mon Sep 17 00:00:00 2001 From: "wave-builder[bot]" <181805596+wave-builder[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:33:19 +0000 Subject: [PATCH 37/47] chore: bump package version to 0.14.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e8dbfc0541..781a6a45fe 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.5-beta.2", + "version": "0.14.5", "homepage": "https://waveterm.dev", "build": { "appId": "dev.commandline.waveterm" From 80cb181daff628bbda0febb8c1fc7b8064b95845 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Thu, 16 Apr 2026 16:23:28 -0700 Subject: [PATCH 38/47] add waveenv to builder app (#3225) --- frontend/app/waveenv/waveenvimpl.ts | 2 +- frontend/builder/builder-app.tsx | 11 ++++++++--- package-lock.json | 4 ++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/frontend/app/waveenv/waveenvimpl.ts b/frontend/app/waveenv/waveenvimpl.ts index 6abe00e574..b6d68db936 100644 --- a/frontend/app/waveenv/waveenvimpl.ts +++ b/frontend/app/waveenv/waveenvimpl.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import { ContextMenuModel } from "@/app/store/contextmenu"; -import { AllServiceImpls } from "@/app/store/services"; import { atoms, createBlock, @@ -16,6 +15,7 @@ import { isDev, WOS, } from "@/app/store/global"; +import { AllServiceImpls } from "@/app/store/services"; import { RpcApi } from "@/app/store/wshclientapi"; import { WaveEnv } from "@/app/waveenv/waveenv"; import { isMacOS, isWindows, PLATFORM } from "@/util/platformutil"; diff --git a/frontend/builder/builder-app.tsx b/frontend/builder/builder-app.tsx index 5f78a6b9a7..447a16f7c0 100644 --- a/frontend/builder/builder-app.tsx +++ b/frontend/builder/builder-app.tsx @@ -1,8 +1,10 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { ModalsRenderer } from "@/app/modals/modalsrenderer"; import { globalStore } from "@/app/store/jotaiStore"; +import { WaveEnvContext } from "@/app/waveenv/waveenv"; +import { makeWaveEnvImpl } from "@/app/waveenv/waveenvimpl"; import { AppSelectionModal } from "@/builder/app-selection-modal"; import { BuilderWorkspace } from "@/builder/builder-workspace"; import { atoms, isDev } from "@/store/global"; @@ -10,7 +12,7 @@ import { appHandleKeyDown } from "@/store/keymodel"; import * as keyutil from "@/util/keyutil"; import { isBlank } from "@/util/util"; import { Provider, useAtomValue } from "jotai"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; @@ -60,13 +62,16 @@ function BuilderAppInner() { } export function BuilderApp({ initOpts, onFirstRender }: BuilderAppProps) { + const waveEnvRef = useRef(makeWaveEnvImpl()); useEffect(() => { onFirstRender(); }, []); return ( - + + + ); } diff --git a/package-lock.json b/package-lock.json index 9e4a67a931..b219e6cc92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.14.5-beta.1", + "version": "0.14.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.14.5-beta.1", + "version": "0.14.5", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ From fb7bdec8e424ea146b18d501aeb026963aab9405 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:04:09 -0700 Subject: [PATCH 39/47] Bump golang.org/x/term from 0.41.0 to 0.42.0 (#3228) Bumps [golang.org/x/term](https://github.com/golang/term) from 0.41.0 to 0.42.0.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=golang.org/x/term&package-manager=go_modules&previous-version=0.41.0&new-version=0.42.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 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 | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c236a4bf4e..0caa82ce93 100644 --- a/go.mod +++ b/go.mod @@ -35,7 +35,7 @@ require ( golang.org/x/mod v0.34.0 golang.org/x/sync v0.20.0 golang.org/x/sys v0.43.0 - golang.org/x/term v0.41.0 + golang.org/x/term v0.42.0 google.golang.org/api v0.275.0 ) diff --git a/go.sum b/go.sum index c28e78d057..23e2d3639a 100644 --- a/go.sum +++ b/go.sum @@ -192,8 +192,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= From 3d2c0d1ca88309809c7334bfaa9d5fd9c1413f47 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:30:10 -0700 Subject: [PATCH 40/47] Bump golang.org/x/crypto from 0.49.0 to 0.50.0 (#3230) Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.49.0 to 0.50.0.
Commits
  • 03ca0dc go.mod: update golang.org/x dependencies
  • 8400f4a ssh: respect signer's algorithm preference in pickSignatureAlgorithm
  • 81c6cb3 ssh: swap cbcMinPaddingSize to cbcMinPacketSize to get encLength
  • See full diff in compare view

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 0caa82ce93..56a4379f1d 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ require ( github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b github.com/wavetermdev/htmltoken v0.2.0 github.com/wavetermdev/waveterm/tsunami v0.12.3 - golang.org/x/crypto v0.49.0 + golang.org/x/crypto v0.50.0 golang.org/x/mod v0.34.0 golang.org/x/sync v0.20.0 golang.org/x/sys v0.43.0 @@ -77,7 +77,7 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect diff --git a/go.sum b/go.sum index 23e2d3639a..317b68821f 100644 --- a/go.sum +++ b/go.sum @@ -174,8 +174,8 @@ go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= @@ -194,8 +194,8 @@ golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 4969ee19b8c1d4c12f89cfe33c469add5dd5b2d0 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 17 Apr 2026 12:30:39 -0700 Subject: [PATCH 41/47] tsunami / builder updates (jsfuncs, devtools, gpt-5.4, etc) (#3226) lots of updates for tsunami and builder window: * jsfuncs * devtools windows * devtools proper cleanup (fixes crashes) * scrollbar fixes * lock AI models -- gpt-5.4, builder prompts, etc --- emain/emain-builder.ts | 16 ++++- emain/emain-ipc.ts | 11 +++ emain/emain-window.ts | 21 +++++- frontend/app/aipanel/waveai-model.tsx | 29 +++++++- frontend/app/view/webview/webview.tsx | 13 +++- frontend/builder/builder-apppanel.tsx | 13 +++- .../builder/store/builder-apppanel-model.ts | 11 +++ frontend/builder/tabs/builder-previewtab.tsx | 71 ++++++++++--------- pkg/aiusechat/uctypes/uctypes.go | 8 ++- pkg/aiusechat/usechat-mode.go | 43 ++++++++++- pkg/aiusechat/usechat.go | 2 +- tsconfig.json | 1 - tsunami/frontend/src/app.tsx | 2 +- tsunami/frontend/src/tailwind.css | 5 +- tsunami/frontend/src/types/custom.d.ts | 1 + tsunami/frontend/src/types/vdom.d.ts | 2 + tsunami/frontend/src/util/keyutil.ts | 14 +++- tsunami/frontend/src/vdom.tsx | 34 ++++++--- tsunami/vdom/vdom.go | 14 ++++ tsunami/vdom/vdom_types.go | 2 + 20 files changed, 252 insertions(+), 61 deletions(-) diff --git a/emain/emain-builder.ts b/emain/emain-builder.ts index 33ca244681..8b223c0f9c 100644 --- a/emain/emain-builder.ts +++ b/emain/emain-builder.ts @@ -4,7 +4,7 @@ import { ClientService } from "@/app/store/services"; import { RpcApi } from "@/app/store/wshclientapi"; import { randomUUID } from "crypto"; -import { BrowserWindow } from "electron"; +import { BrowserWindow, webContents } from "electron"; import { globalEvents } from "emain/emain-events"; import path from "path"; import { getElectronAppBasePath, isDevVite, unamePlatform } from "./emain-platform"; @@ -87,6 +87,20 @@ export async function createBuilderWindow(appId: string): Promise { + const wc = typedBuilderWindow.webContents; + if (wc.isDevToolsOpened()) { + wc.closeDevTools(); + } + for (const guest of webContents.getAllWebContents()) { + if (guest.getType() === "webview" && guest.hostWebContents?.id === wc.id) { + if (guest.isDevToolsOpened()) { + guest.closeDevTools(); + } + } + } + }); + typedBuilderWindow.on("focus", () => { focusedBuilderWindow = typedBuilderWindow; console.log("builder window focused", builderId); diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index 38067b7790..5e5f15b302 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -490,6 +490,17 @@ export function initIpcHandlers() { console.error("Error deleting builder rtinfo:", e); } } + const wc = bw.webContents; + if (wc.isDevToolsOpened()) { + wc.closeDevTools(); + } + for (const guest of electron.webContents.getAllWebContents()) { + if (guest.getType() === "webview" && guest.hostWebContents?.id === wc.id) { + if (guest.isDevToolsOpened()) { + guest.closeDevTools(); + } + } + } bw.destroy(); }); diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 98276bbdd2..e3bfa87751 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -5,7 +5,7 @@ import { ClientService, ObjectService, WindowService, WorkspaceService } from "@ import { waveEventSubscribeSingle } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { fireAndForget } from "@/util/util"; -import { BaseWindow, BaseWindowConstructorOptions, dialog, globalShortcut, ipcMain, screen } from "electron"; +import { BaseWindow, BaseWindowConstructorOptions, dialog, globalShortcut, ipcMain, screen, webContents } from "electron"; import { globalEvents } from "emain/emain-events"; import path from "path"; import { debounce } from "throttle-debounce"; @@ -299,6 +299,7 @@ export class WaveBrowserWindow extends BaseWindow { if (this.isDestroyed()) { return; } + this.closeAllDevTools(); console.log("win 'close' handler fired", this.waveWindowId); if (getGlobalIsQuitting() || updater?.status == "installing" || getGlobalIsRelaunching()) { return; @@ -358,6 +359,24 @@ export class WaveBrowserWindow extends BaseWindow { setTimeout(() => globalEvents.emit("windows-updated"), 50); } + private closeAllDevTools() { + for (const tabView of this.allLoadedTabViews.values()) { + if (tabView.webContents?.isDevToolsOpened()) { + tabView.webContents.closeDevTools(); + } + } + const tabViewIds = new Set( + [...this.allLoadedTabViews.values()].map((tv) => tv.webContents?.id).filter((id) => id != null) + ); + for (const wc of webContents.getAllWebContents()) { + if (wc.getType() === "webview" && tabViewIds.has(wc.hostWebContents?.id)) { + if (wc.isDevToolsOpened()) { + wc.closeDevTools(); + } + } + } + } + private removeAllChildViews() { for (const tabView of this.allLoadedTabViews.values()) { if (!this.isDestroyed()) { diff --git a/frontend/app/aipanel/waveai-model.tsx b/frontend/app/aipanel/waveai-model.tsx index 9af1d88508..194005adc6 100644 --- a/frontend/app/aipanel/waveai-model.tsx +++ b/frontend/app/aipanel/waveai-model.tsx @@ -41,6 +41,27 @@ export interface DroppedFile { previewUrl?: string; } +const BuilderAIModeConfigs: Record = { + "waveaibuilder@default": { + "display:name": "Builder Default", + "display:order": -2, + "display:icon": "sparkles", + "display:description": "Good mix of speed and accuracy\n(gpt-5.4 with minimal thinking)", + "ai:provider": "wave", + "ai:switchcompat": ["wavecloud"], + "waveai:premium": true, + }, + "waveaibuilder@deep": { + "display:name": "Builder Deep", + "display:order": -1, + "display:icon": "lightbulb", + "display:description": "Slower but most capable\n(gpt-5.4 with full reasoning)", + "ai:provider": "wave", + "ai:switchcompat": ["wavecloud"], + "waveai:premium": true, + }, +}; + export class WaveAIModel { private static instance: WaveAIModel | null = null; inputRef: React.RefObject | null = null; @@ -80,7 +101,11 @@ export class WaveAIModel { this.orefContext = orefContext; this.inBuilder = inBuilder; this.chatId = jotai.atom(null) as jotai.PrimitiveAtom; - this.aiModeConfigs = atoms.waveaiModeConfigAtom; + if (inBuilder) { + this.aiModeConfigs = jotai.atom(BuilderAIModeConfigs) as jotai.Atom>; + } else { + this.aiModeConfigs = atoms.waveaiModeConfigAtom; + } this.hasPremiumAtom = jotai.atom((get) => { const rateLimitInfo = get(atoms.waveAIRateLimitInfoAtom); @@ -118,7 +143,7 @@ export class WaveAIModel { this.defaultModeAtom = jotai.atom((get) => { const telemetryEnabled = get(getSettingsKeyAtom("telemetry:enabled")) ?? false; if (this.inBuilder) { - return telemetryEnabled ? "waveai@balanced" : "invalid"; + return telemetryEnabled ? "waveaibuilder@default" : "invalid"; } const aiModeConfigs = get(this.aiModeConfigs); if (!telemetryEnabled) { diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index 551f23bbb7..f6d98b8f22 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -19,9 +19,9 @@ import { openLink } from "@/store/global"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { fireAndForget, useAtomValueSafe } from "@/util/util"; import clsx from "clsx"; -import { WebviewTag } from "electron"; +import type { WebviewTag } from "electron"; import { Atom, PrimitiveAtom, atom, useAtomValue, useSetAtom } from "jotai"; -import { Fragment, createRef, memo, useCallback, useEffect, useRef, useState } from "react"; +import { Fragment, createRef, memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import "./webview.scss"; import type { WebViewEnv } from "./webviewenv"; @@ -951,6 +951,15 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) }, 100); } + useLayoutEffect(() => { + return () => { + const webview = model.webviewRef.current; + if (webview?.isDevToolsOpened()) { + webview.closeDevTools(); + } + }; + }, []); + useEffect(() => { return () => { globalStore.set(model.domReady, false); diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index 780f6efa99..3c7e08f0f8 100644 --- a/frontend/builder/builder-apppanel.tsx +++ b/frontend/builder/builder-apppanel.tsx @@ -257,6 +257,10 @@ const BuilderAppPanel = memo(() => { model.switchBuilderApp(); }, [model]); + const handleOpenDevToolsClick = useCallback(() => { + model.openPreviewDevTools(); + }, [model]); + const handleKebabClick = useCallback( (e: React.MouseEvent) => { const menu: ContextMenuItem[] = [ @@ -267,6 +271,13 @@ const BuilderAppPanel = memo(() => { { type: "separator", }, + { + label: "Open DevTools", + click: handleOpenDevToolsClick, + }, + { + type: "separator", + }, { label: "Switch App", click: handleSwitchAppClick, @@ -274,7 +285,7 @@ const BuilderAppPanel = memo(() => { ]; ContextMenuModel.getInstance().showContextMenu(menu, e); }, - [handleSwitchAppClick, handlePublishClick] + [handleSwitchAppClick, handlePublishClick, handleOpenDevToolsClick] ); return ( diff --git a/frontend/builder/store/builder-apppanel-model.ts b/frontend/builder/store/builder-apppanel-model.ts index 4decca651a..3065687cde 100644 --- a/frontend/builder/store/builder-apppanel-model.ts +++ b/frontend/builder/store/builder-apppanel-model.ts @@ -7,6 +7,7 @@ import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { atoms, getApi, WOS } from "@/store/global"; import { base64ToString, stringToBase64 } from "@/util/util"; +import type { WebviewTag } from "electron"; import { atom, type Atom, type PrimitiveAtom } from "jotai"; import type * as MonacoTypes from "monaco-editor"; import { debounce } from "throttle-debounce"; @@ -35,6 +36,7 @@ export class BuilderAppPanelModel { saveNeededAtom!: Atom; focusElemRef: { current: HTMLInputElement | null } = { current: null }; monacoEditorRef: { current: MonacoTypes.editor.IStandaloneCodeEditor | null } = { current: null }; + webviewRef: { current: WebviewTag | null } = { current: null }; statusUnsubFn: (() => void) | null = null; appGoUpdateUnsubFn: (() => void) | null = null; debouncedRestart: (() => void) & { cancel: () => void }; @@ -314,6 +316,15 @@ export class BuilderAppPanelModel { this.monacoEditorRef.current = ref; } + openPreviewDevTools() { + if (!this.webviewRef.current) return; + if (this.webviewRef.current.isDevToolsOpened()) { + this.webviewRef.current.closeDevTools(); + } else { + this.webviewRef.current.openDevTools(); + } + } + dispose() { if (this.statusUnsubFn) { this.statusUnsubFn(); diff --git a/frontend/builder/tabs/builder-previewtab.tsx b/frontend/builder/tabs/builder-previewtab.tsx index 2258e31441..2976080680 100644 --- a/frontend/builder/tabs/builder-previewtab.tsx +++ b/frontend/builder/tabs/builder-previewtab.tsx @@ -70,8 +70,8 @@ const ErrorStateView = memo(({ errorMsg }: { errorMsg: string }) => {

Secrets Required

- This app requires secrets that must be configured. Please use the Secrets tab to set and bind - the required secrets for your app to run. + This app requires secrets that must be configured. Please use the Secrets tab to set and + bind the required secrets for your app to run.

{displayMsg}
@@ -178,47 +178,48 @@ const BuilderPreviewTab = memo(() => { const originalContent = useAtomValue(model.originalContentAtom); const builderStatus = useAtomValue(model.builderStatusAtom); const builderId = useAtomValue(atoms.builderId); - const fileExists = originalContent.length > 0; - - if (isLoading) { - return null; - } - - if (builderStatus?.status === "error") { - return ; - } - - if (!fileExists) { - return ; - } + const [lastKnownUrl, setLastKnownUrl] = useState(null); const status = builderStatus?.status || "init"; + const isWebViewActive = status === "running" && builderStatus?.port && builderStatus.port !== 0; - if (status === "init") { - return null; - } - - if (status === "building") { - return ; - } - - if (status === "stopped") { - return model.startBuilder()} />; + if (isWebViewActive) { + const previewUrl = `http://localhost:${builderStatus.port}/?clientid=wave:${builderId}`; + if (previewUrl !== lastKnownUrl) { + setLastKnownUrl(previewUrl); + } } - const shouldShowWebView = status === "running" && builderStatus?.port && builderStatus.port !== 0; - - if (shouldShowWebView) { - const previewUrl = `http://localhost:${builderStatus.port}/?clientid=wave:${builderId}`; - return ( -
- -
- ); + let overlay = null; + if (!isLoading && !isWebViewActive) { + if (builderStatus?.status === "error") { + overlay = ; + } else if (!fileExists || status === "init") { + overlay = ; + } else if (status === "building") { + overlay = ; + } else if (status === "stopped") { + overlay = model.startBuilder()} />; + } } - return null; + return ( +
+ {lastKnownUrl && ( + + )} + {overlay &&
{overlay}
} +
+ ); }); BuilderPreviewTab.displayName = "BuilderPreviewTab"; diff --git a/pkg/aiusechat/uctypes/uctypes.go b/pkg/aiusechat/uctypes/uctypes.go index d2b25bbc1b..a222eb5c9c 100644 --- a/pkg/aiusechat/uctypes/uctypes.go +++ b/pkg/aiusechat/uctypes/uctypes.go @@ -163,9 +163,11 @@ const ( ) const ( - AIModeQuick = "waveai@quick" - AIModeBalanced = "waveai@balanced" - AIModeDeep = "waveai@deep" + AIModeQuick = "waveai@quick" + AIModeBalanced = "waveai@balanced" + AIModeDeep = "waveai@deep" + AIModeBuilderDefault = "waveaibuilder@default" + AIModeBuilderDeep = "waveaibuilder@deep" ) const ( diff --git a/pkg/aiusechat/usechat-mode.go b/pkg/aiusechat/usechat-mode.go index 1b1875202e..94fe20ef9d 100644 --- a/pkg/aiusechat/usechat-mode.go +++ b/pkg/aiusechat/usechat-mode.go @@ -247,7 +247,44 @@ func isValidAzureResourceName(name string) bool { return AzureResourceNameRegex.MatchString(name) } +var builderModeConfigs = map[string]wconfig.AIModeConfigType{ + uctypes.AIModeBuilderDefault: { + DisplayName: "Builder Default", + DisplayOrder: -2, + DisplayIcon: "sparkles", + DisplayDescription: "Good mix of speed and accuracy\n(gpt-5.4 with minimal thinking)", + Provider: uctypes.AIProvider_Wave, + APIType: uctypes.APIType_OpenAIResponses, + Model: "gpt-5.4", + ThinkingLevel: uctypes.ThinkingLevelLow, + Verbosity: uctypes.VerbosityLevelLow, + Capabilities: []string{uctypes.AICapabilityTools, uctypes.AICapabilityImages, uctypes.AICapabilityPdfs}, + WaveAIPremium: true, + SwitchCompat: []string{"wavecloud"}, + }, + uctypes.AIModeBuilderDeep: { + DisplayName: "Builder Deep", + DisplayOrder: -1, + DisplayIcon: "lightbulb", + DisplayDescription: "Slower but most capable\n(gpt-5.4 with full reasoning)", + Provider: uctypes.AIProvider_Wave, + APIType: uctypes.APIType_OpenAIResponses, + Model: "gpt-5.4", + ThinkingLevel: uctypes.ThinkingLevelMedium, + Verbosity: uctypes.VerbosityLevelLow, + Capabilities: []string{uctypes.AICapabilityTools, uctypes.AICapabilityImages, uctypes.AICapabilityPdfs}, + WaveAIPremium: true, + SwitchCompat: []string{"wavecloud"}, + }, +} + func getAIModeConfig(aiMode string) (*wconfig.AIModeConfigType, error) { + if config, ok := builderModeConfigs[aiMode]; ok { + resolved := config + applyProviderDefaults(&resolved) + return &resolved, nil + } + fullConfig := wconfig.GetWatcher().GetFullConfig() config, ok := fullConfig.WaveAIModes[aiMode] if !ok { @@ -271,13 +308,13 @@ func handleConfigUpdate(fullConfig wconfig.FullConfigType) { func ComputeResolvedAIModeConfigs(fullConfig wconfig.FullConfigType) map[string]wconfig.AIModeConfigType { resolvedConfigs := make(map[string]wconfig.AIModeConfigType) - + for modeName, modeConfig := range fullConfig.WaveAIModes { resolved := modeConfig applyProviderDefaults(&resolved) resolvedConfigs[modeName] = resolved } - + return resolvedConfigs } @@ -285,7 +322,7 @@ func broadcastAIModeConfigs(configs map[string]wconfig.AIModeConfigType) { update := wconfig.AIModeConfigUpdate{ Configs: configs, } - + wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_AIModeConfig, Data: update, diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index a55a10060a..d9de760afd 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -670,8 +670,8 @@ func WaveAIPostMessageHandler(w http.ResponseWriter, r *http.Request) { } // Get WaveAI settings - premium := shouldUsePremium() builderMode := req.BuilderId != "" + premium := shouldUsePremium() || builderMode if req.AIMode == "" { http.Error(w, "aimode is required in request body", http.StatusBadRequest) return diff --git a/tsconfig.json b/tsconfig.json index 8fd50d2f96..d12f31cd6c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,6 @@ "resolveJsonModule": true, "isolatedModules": true, "experimentalDecorators": true, - "downlevelIteration": true, "baseUrl": "./", "paths": { "@/app/*": ["frontend/app/*"], diff --git a/tsunami/frontend/src/app.tsx b/tsunami/frontend/src/app.tsx index 38c94e0a51..9b12c38b52 100644 --- a/tsunami/frontend/src/app.tsx +++ b/tsunami/frontend/src/app.tsx @@ -9,7 +9,7 @@ const globalModel = new TsunamiModel(); function App() { return ( -
+
); diff --git a/tsunami/frontend/src/tailwind.css b/tsunami/frontend/src/tailwind.css index 945398cd53..c6ae61ecb2 100644 --- a/tsunami/frontend/src/tailwind.css +++ b/tsunami/frontend/src/tailwind.css @@ -62,7 +62,10 @@ } /* Disable overscroll behavior */ -html, body { +html, body, #root { + height: 100%; + color-scheme: dark; + background: var(--color-background); overscroll-behavior: none; overscroll-behavior-x: none; overscroll-behavior-y: none; diff --git a/tsunami/frontend/src/types/custom.d.ts b/tsunami/frontend/src/types/custom.d.ts index b7c843aeb2..92264260e8 100644 --- a/tsunami/frontend/src/types/custom.d.ts +++ b/tsunami/frontend/src/types/custom.d.ts @@ -12,4 +12,5 @@ type KeyPressDecl = { }; key: string; keyType: string; + nomatch?: boolean; }; diff --git a/tsunami/frontend/src/types/vdom.d.ts b/tsunami/frontend/src/types/vdom.d.ts index 2ca0f73867..80b9215452 100644 --- a/tsunami/frontend/src/types/vdom.d.ts +++ b/tsunami/frontend/src/types/vdom.d.ts @@ -82,8 +82,10 @@ type VDomFunc = { type: "func"; stoppropagation?: boolean; preventdefault?: boolean; + preventbackend?: boolean; globalevent?: string; keys?: string[]; + jscode?: string; }; // vdom.VDomMessage diff --git a/tsunami/frontend/src/util/keyutil.ts b/tsunami/frontend/src/util/keyutil.ts index 625cc1fc7c..68eb0b6823 100644 --- a/tsunami/frontend/src/util/keyutil.ts +++ b/tsunami/frontend/src/util/keyutil.ts @@ -72,7 +72,9 @@ function parseKey(key: string): { key: string; type: string } { function parseKeyDescription(keyDescription: string): KeyPressDecl { let rtn = { key: "", mods: {} } as KeyPressDecl; let keys = keyDescription.replace(/[()]/g, "").split(":"); - for (let key of keys) { + for (let i = 0; i < keys.length; i++) { + let key = keys[i]; + let isLastToken = i === keys.length - 1; if (key == "Cmd") { if (PLATFORM == PlatformMacOS) { rtn.mods.Meta = true; @@ -106,6 +108,10 @@ function parseKeyDescription(keyDescription: string): KeyPressDecl { } rtn.mods.Meta = true; } else { + if (!isLastToken) { + rtn.nomatch = true; + return rtn; + } let { key: parsedKey, type: keyType } = parseKey(key); rtn.key = parsedKey; rtn.keyType = keyType; @@ -194,6 +200,9 @@ function isInputEvent(event: VDomKeyboardEvent): boolean { function checkKeyPressed(event: VDomKeyboardEvent, keyDescription: string): boolean { let keyPress = parseKeyDescription(keyDescription); + if (keyPress.nomatch) { + return false; + } if (notMod(keyPress.mods.Option, event.option)) { return false; } @@ -236,6 +245,9 @@ function checkKeyPressed(event: VDomKeyboardEvent, keyDescription: string): bool } function adaptFromReactOrNativeKeyEvent(event: React.KeyboardEvent | KeyboardEvent): VDomKeyboardEvent { + if (event == null || typeof event.key !== "string") { + return { type: "unknown" } as VDomKeyboardEvent; + } let rtn: VDomKeyboardEvent = {} as VDomKeyboardEvent; rtn.control = event.ctrlKey; rtn.shift = event.shiftKey; diff --git a/tsunami/frontend/src/vdom.tsx b/tsunami/frontend/src/vdom.tsx index a51e119193..b2753e1f7c 100644 --- a/tsunami/frontend/src/vdom.tsx +++ b/tsunami/frontend/src/vdom.tsx @@ -170,15 +170,21 @@ const SvgUrlIdAttributes = { "text-decoration": true, }; -function convertVDomFunc(model: TsunamiModel, fnDecl: VDomFunc, compId: string, propName: string): (e: any) => void { - return (e: any) => { +function convertVDomFunc( + model: TsunamiModel, + fnDecl: VDomFunc, + compId: string, + propName: string +): (...args: any[]) => any { + return (...args: any[]) => { + const e = args[0]; if ((propName == "onKeyDown" || propName == "onKeyDownCapture") && fnDecl["keys"]) { dlog("key event", fnDecl, e); let waveEvent = adaptFromReactOrNativeKeyEvent(e); for (let keyDesc of fnDecl["keys"] || []) { if (checkKeyPressed(waveEvent, keyDesc)) { - e.preventDefault(); - e.stopPropagation(); + e?.preventDefault?.(); + e?.stopPropagation?.(); model.callVDomFunc(fnDecl, e, compId, propName); return; } @@ -186,12 +192,24 @@ function convertVDomFunc(model: TsunamiModel, fnDecl: VDomFunc, compId: string, return; } if (fnDecl.preventdefault) { - e.preventDefault(); + e?.preventDefault?.(); } if (fnDecl.stoppropagation) { - e.stopPropagation(); + e?.stopPropagation?.(); } - model.callVDomFunc(fnDecl, e, compId, propName); + let retVal: any; + if (fnDecl.jscode) { + try { + const fn = eval(fnDecl.jscode); + if (typeof fn === "function") retVal = fn(...args); + } catch (err) { + console.error("vdom jscode error:", err); + } + } + if (!fnDecl.preventbackend) { + model.callVDomFunc(fnDecl, e, compId, propName); + } + return retVal; }; } @@ -254,7 +272,7 @@ function convertChildren(elem: VDomElem, model: TsunamiModel): React.ReactNode[] if (elem.children == null || elem.children.length == 0) { return null; } - let childrenComps: React.ReactNode[] = []; + const childrenComps: React.ReactNode[] = []; for (let child of elem.children) { if (child == null) { continue; diff --git a/tsunami/vdom/vdom.go b/tsunami/vdom/vdom.go index 513325dc0d..bd7099a200 100644 --- a/tsunami/vdom/vdom.go +++ b/tsunami/vdom/vdom.go @@ -99,6 +99,20 @@ func H(tag string, props map[string]any, children ...any) *VDomElem { return rtn } +// JSFunc creates a VDomFunc that executes client-side JS only, with no backend call. +// jsCode must be a JS function expression whose signature matches the natural arguments of the event handler +// (e.g. (e) => { ... } for DOM events, or whatever args the underlying handler receives). +func JSFunc(jsCode string) *VDomFunc { + return &VDomFunc{Type: ObjectType_Func, JsCode: jsCode, PreventBackend: true} +} + +// CombinedFunc creates a VDomFunc that executes client-side JS first, then fires to the backend. +// jsCode must be a JS function expression whose signature matches the natural arguments of the event handler +// (e.g. (e) => { ... } for DOM events, or whatever args the underlying handler receives). +func CombinedFunc(jsCode string, fn any) *VDomFunc { + return &VDomFunc{Type: ObjectType_Func, JsCode: jsCode, Fn: fn} +} + // If returns the provided part if the condition is true, otherwise returns nil. // This is useful for conditional rendering in VDOM children lists, props, and style attributes. func If(cond bool, part any) any { diff --git a/tsunami/vdom/vdom_types.go b/tsunami/vdom/vdom_types.go index 58725e4010..f3fcf558fc 100644 --- a/tsunami/vdom/vdom_types.go +++ b/tsunami/vdom/vdom_types.go @@ -32,8 +32,10 @@ type VDomFunc struct { Type string `json:"type" tstype:"\"func\""` StopPropagation bool `json:"stoppropagation,omitempty"` // set to call e.stopPropagation() on the client side PreventDefault bool `json:"preventdefault,omitempty"` // set to call e.preventDefault() on the client side + PreventBackend bool `json:"preventbackend,omitempty"` // set to skip firing the event to the backend GlobalEvent string `json:"globalevent,omitempty"` Keys []string `json:"keys,omitempty"` // special for keyDown events a list of keys to "capture" + JsCode string `json:"jscode,omitempty"` // client-side JS function expression: (e, elem) => { ... } } // used in props From 3e4fe8cf51e58649ac311d5a3df1e52dd25b22d3 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 17 Apr 2026 14:05:00 -0700 Subject: [PATCH 42/47] loosen up the requirements to show the File Browser in term context menu (#3232) --- frontend/app/view/term/term-model.ts | 6 +++--- frontend/app/view/term/termutil.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index 09ccce3e54..a256929e7d 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -40,7 +40,7 @@ import { boundNumber, fireAndForget, stringToBase64 } from "@/util/util"; import * as jotai from "jotai"; import * as React from "react"; import { getBlockingCommand } from "./shellblocking"; -import { computeTheme, DefaultTermTheme, trimTerminalSelection } from "./termutil"; +import { computeTheme, DefaultTermTheme, isLikelyOnSameHost, trimTerminalSelection } from "./termutil"; import { TermWrap, WebGLSupported } from "./termwrap"; export class TermViewModel implements ViewModel { @@ -958,9 +958,9 @@ export class TermViewModel implements ViewModel { }); fullMenu.push({ type: "separator" }); - const shellIntegrationStatus = globalStore.get(this.termRef?.current?.shellIntegrationStatusAtom); + const lastCommand = globalStore.get(this.termRef?.current?.lastCommandAtom); const cwd = blockData?.meta?.["cmd:cwd"]; - const canShowFileBrowser = shellIntegrationStatus === "ready" && cwd != null; + const canShowFileBrowser = cwd != null && isLikelyOnSameHost(lastCommand); if (canShowFileBrowser) { fullMenu.push({ diff --git a/frontend/app/view/term/termutil.ts b/frontend/app/view/term/termutil.ts index 838b8aaf92..add5e86e05 100644 --- a/frontend/app/view/term/termutil.ts +++ b/frontend/app/view/term/termutil.ts @@ -397,6 +397,14 @@ export function bufferLinesToText(buffer: TermTypes.IBuffer, startIndex: number, return lines; } +export function isLikelyOnSameHost(lastCommand: string): boolean { + if (lastCommand == null) { + return true; + } + const cmd = lastCommand.trimStart(); + return !cmd.startsWith("ssh "); +} + export function quoteForPosixShell(filePath: string): string { return "'" + filePath.replace(/'/g, "'\\''") + "'"; } From 6316093533d0b012c8b2e549827b674d8295bd02 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Mon, 20 Apr 2026 15:43:21 -0700 Subject: [PATCH 43/47] fix tsconfig warnings (tsunami) (#3233) --- tsunami/frontend/tsconfig.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/tsunami/frontend/tsconfig.json b/tsunami/frontend/tsconfig.json index 27d4e97b90..09eda49b41 100644 --- a/tsunami/frontend/tsconfig.json +++ b/tsunami/frontend/tsconfig.json @@ -5,7 +5,6 @@ "module": "ESNext", "skipLibCheck": true, "allowJs": false, - "esModuleInterop": false, "allowSyntheticDefaultImports": true, "strict": false, "strictNullChecks": false, @@ -16,7 +15,6 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", - "baseUrl": ".", "paths": { "@/*": ["./src/*"] } From c2a17e7eb2ac44b867277e8ca9da2a9fbff843c5 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 24 Apr 2026 14:18:13 -0700 Subject: [PATCH 44/47] fix typo in docs, set disablewebgl to true to disable (#3246) --- docs/docs/config.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index e3b58325ae..05389e99ef 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -60,7 +60,7 @@ wsh editconfig | conn:localhostdisplayname | string | override the display name for localhost in the UI (e.g., set to "My Laptop" or "Local", or set to empty string to hide the name) | | term:fontsize | float | the fontsize for the terminal block | | term:fontfamily | string | font family to use for terminal block | -| term:disablewebgl | bool | set to false to disable WebGL acceleration in terminal | +| term:disablewebgl | bool | set to true to disable WebGL acceleration in terminal (default false) | | term:localshellpath | string | set to override the default shell path for local terminals | | term:localshellopts | string[] | set to pass additional parameters to the term:localshellpath (example: `["-NoLogo"]` for PowerShell will remove the copyright notice) | | term:copyonselect | bool | set to false to disable terminal copy-on-select | From efd450fd3d6f4e6c17af55c24fb2e46ca8cd84f9 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 24 Apr 2026 16:24:43 -0700 Subject: [PATCH 45/47] send block creation events for sub-blocks as well (#3247) need to understand vdom usage --- .gitignore | 1 + pkg/telemetry/telemetrydata/telemetrydata.go | 1 + pkg/wcore/block.go | 8 ++++++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 2111b1182d..7bd717e540 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ aiplans/ manifests/ .env out +.kilocode/package-lock.json # Yarn Modern .pnp.* diff --git a/pkg/telemetry/telemetrydata/telemetrydata.go b/pkg/telemetry/telemetrydata/telemetrydata.go index a08ff67bed..0f164501ef 100644 --- a/pkg/telemetry/telemetrydata/telemetrydata.go +++ b/pkg/telemetry/telemetrydata/telemetrydata.go @@ -122,6 +122,7 @@ type TEventProps struct { BlockView string `json:"block:view,omitempty"` BlockController string `json:"block:controller,omitempty"` + BlockSubBlock bool `json:"block:subblock,omitempty"` AiBackendType string `json:"ai:backendtype,omitempty"` AiLocal bool `json:"ai:local,omitempty"` diff --git a/pkg/wcore/block.go b/pkg/wcore/block.go index d9f484df86..d62d8e1f38 100644 --- a/pkg/wcore/block.go +++ b/pkg/wcore/block.go @@ -32,6 +32,9 @@ func CreateSubBlock(ctx context.Context, blockId string, blockDef *waveobj.Block if err != nil { return nil, fmt.Errorf("error creating sub block: %w", err) } + blockView := blockDef.Meta.GetString(waveobj.MetaKey_View, "") + blockController := blockDef.Meta.GetString(waveobj.MetaKey_Controller, "") + go recordBlockCreationTelemetry(blockView, blockController, true) return blockData, nil } @@ -100,12 +103,12 @@ func CreateBlockWithTelemetry(ctx context.Context, tabId string, blockDef *waveo if recordTelemetry { blockView := blockDef.Meta.GetString(waveobj.MetaKey_View, "") blockController := blockDef.Meta.GetString(waveobj.MetaKey_Controller, "") - go recordBlockCreationTelemetry(blockView, blockController) + go recordBlockCreationTelemetry(blockView, blockController, false) } return blockData, nil } -func recordBlockCreationTelemetry(blockView string, blockController string) { +func recordBlockCreationTelemetry(blockView string, blockController string, subBlock bool) { defer func() { panichandler.PanicHandler("CreateBlock:telemetry", recover()) }() @@ -122,6 +125,7 @@ func recordBlockCreationTelemetry(blockView string, blockController string) { Props: telemetrydata.TEventProps{ BlockView: blockView, BlockController: blockController, + BlockSubBlock: subBlock, }, }) } From 64c78efdc87eb44182b01b3f69d672814fb5ea73 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 10:56:19 -0700 Subject: [PATCH 46/47] Bump golang.org/x/mod from 0.34.0 to 0.35.0 (#3244) Bumps [golang.org/x/mod](https://github.com/golang/mod) from 0.34.0 to 0.35.0.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=golang.org/x/mod&package-manager=go_modules&previous-version=0.34.0&new-version=0.35.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 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 | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 56a4379f1d..ca28b59ee5 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( github.com/wavetermdev/htmltoken v0.2.0 github.com/wavetermdev/waveterm/tsunami v0.12.3 golang.org/x/crypto v0.50.0 - golang.org/x/mod v0.34.0 + golang.org/x/mod v0.35.0 golang.org/x/sync v0.20.0 golang.org/x/sys v0.43.0 golang.org/x/term v0.42.0 diff --git a/go.sum b/go.sum index 317b68821f..ddc7470472 100644 --- a/go.sum +++ b/go.sum @@ -176,8 +176,8 @@ go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLh go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= From 2e25ea1e0abb4bc1b7fa64daf54f07fb60fb0770 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 10:57:08 -0700 Subject: [PATCH 47/47] Bump uuid from 13.0.0 to 14.0.0 (#3241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [uuid](https://github.com/uuidjs/uuid) from 13.0.0 to 14.0.0.
Release notes

Sourced from uuid's releases.

v14.0.0

14.0.0 (2026-04-19)

⚠ BREAKING CHANGES

  • expect crypto to be global everywhere (requires node@20+) (#935)
  • drop node@18 support (#934)

Features

Bug Fixes

  • expect crypto to be global everywhere (requires node@20+) (#935) (f2c235f)
  • Use GITHUB_TOKEN for release-please and enable npm provenance (#925) (ffa3138)
Changelog

Sourced from uuid's changelog.

14.0.0 (2026-04-19)

Security

  • Fixes GHSA-w5hq-g745-h8pq: v3(), v5(), and v6() did not validate that writes would remain within the bounds of a caller-supplied buffer, allowing out-of-bounds writes when an invalid offset was provided. A RangeError is now thrown if offset < 0 or offset + 16 > buf.length.

⚠ BREAKING CHANGES

  • crypto is now expected to be globally defined (requires node@20+) (#935)
  • drop node@18 support (#934)
  • upgrade minimum supported TypeScript version to 5.4.3, in keeping with the project's policy of supporting TypeScript versions released within the last two years
Commits
  • 7c1ea08 chore(main): release 14.0.0 (#926)
  • 3d2c5b0 Merge commit from fork
  • f2c235f fix!: expect crypto to be global everywhere (requires node@20+) (#935)
  • 529ef08 chore: upgrade TypeScript and fixup types (#927)
  • 086fd79 chore: update dependencies (#933)
  • dc4ddb8 feat!: drop node@18 support (#934)
  • 0f1f9c9 chore: switch to Biome for parsing and linting (#932)
  • e2879e6 chore: use maintained version of npm-run-all (#930)
  • ffa3138 fix: Use GITHUB_TOKEN for release-please and enable npm provenance (#925)
  • 0423d49 docs: remove obsolete v1 option notes (#915)
  • Additional commits viewable in compare view
Maintainer changes

This version was pushed to npm by GitHub Actions, a new releaser for uuid since your current version.


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=uuid&package-manager=npm_and_yarn&previous-version=13.0.0&new-version=14.0.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 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 | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index b219e6cc92..1798a0fa38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,7 +76,7 @@ "throttle-debounce": "^5.0.2", "unist-util-visit": "^5.1.0", "use-device-pixel-ratio": "^1.1.2", - "uuid": "^13.0.0", + "uuid": "^14.0.0", "winston": "^3.19.0", "ws": "^8.19.0", "yaml": "^2.8.3" @@ -31825,9 +31825,9 @@ } }, "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/package.json b/package.json index 781a6a45fe..4c1d56798f 100644 --- a/package.json +++ b/package.json @@ -130,7 +130,7 @@ "throttle-debounce": "^5.0.2", "unist-util-visit": "^5.1.0", "use-device-pixel-ratio": "^1.1.2", - "uuid": "^13.0.0", + "uuid": "^14.0.0", "winston": "^3.19.0", "ws": "^8.19.0", "yaml": "^2.8.3"