diff --git a/.github/workflows/deploy-docsite.yml b/.github/workflows/deploy-docsite.yml index b09fa60a25..092b024cb5 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: @@ -77,4 +77,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.gitignore b/.gitignore index a1c7240b5a..7bd717e540 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules/ frontend/bindings bindings/ *.log +*.tsbuildinfo bin/ *.dmg *.exe @@ -20,6 +21,7 @@ aiplans/ manifests/ .env out +.kilocode/package-lock.json # Yarn Modern .pnp.* @@ -39,3 +41,6 @@ test-results.xml docsite/ .kilo-format-temp-* +.superpowers +docs/superpowers +.claude 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/.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/.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/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/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)。 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/config.mdx b/docs/docs/config.mdx index abeca3429f..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 | @@ -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) | diff --git a/docs/docs/keybindings.mdx b/docs/docs/keybindings.mdx index fa8dcae1ba..d5d88856e8 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 | @@ -105,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 421f212731..987be81534 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 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 +- 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. @@ -27,6 +43,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 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/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 09830b9315..5e5f15b302 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 = @@ -482,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-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/emain/emain-window.ts b/emain/emain-window.ts index 8dfd31789e..e3bfa87751 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -2,9 +2,10 @@ // 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"; +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"; @@ -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; @@ -291,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; @@ -332,6 +341,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); @@ -347,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()) { @@ -704,6 +734,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 +863,9 @@ export async function createNewWaveWindow() { unamePlatform, isPrimaryStartupWindow: false, }); + if (quakeWindow == null) { + quakeWindow = win; + } win.show(); recreatedWindow = true; } @@ -845,6 +879,9 @@ export async function createNewWaveWindow() { unamePlatform, isPrimaryStartupWindow: false, }); + if (quakeWindow == null) { + quakeWindow = newBrowserWindow; + } newBrowserWindow.show(); } @@ -887,6 +924,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 +936,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) => { 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/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/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/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/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/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/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/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..be0b43cf4a --- /dev/null +++ b/frontend/app/onboarding/onboarding-upgrade-v0145.tsx @@ -0,0 +1,63 @@ +// 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, several quality-of-life improvements, and a + fix for creating new config files from the Settings widget. +

+
+ +
+
+ +
+
+
Process Viewer
+
+ New widget that displays running processes on local and remote machines, with CPU and memory + usage and sortable columns. +
+
+
+ +
+
+ +
+
+
Other Changes
+
+
    +
  • + 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 app:showsplitbuttons setting adds split buttons to block headers +
  • +
  • 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 + 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/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/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/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/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 2f5024f0ef..8482be260d 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); @@ -912,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/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/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/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; diff --git a/frontend/app/view/processviewer/processviewer.tsx b/frontend/app/view/processviewer/processviewer.tsx new file mode 100644 index 0000000000..d7f284bcf3 --- /dev/null +++ b/frontend/app/view/processviewer/processviewer.tsx @@ -0,0 +1,1030 @@ +// 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 === -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"; + return formatNumber4(bytes / 1024 / 1024 / 1024) + "G"; +} + +function fmtCpu(cpu: number): string { + if (cpu == null) return ""; + 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 { + 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; + dataStartAtom: 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; + fetchIntervalAtom: jotai.PrimitiveAtom; + + connection: jotai.Atom; + connStatus: jotai.Atom; + + disposed = false; + cancelPoll: (() => void) | null = null; + fetchEpoch = 0; + + constructor({ blockId, waveEnv }: ViewModelInitType) { + this.viewType = "processviewer"; + this.blockId = blockId; + 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); + 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.fetchIntervalAtom = jotai.atom(2000) 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(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); + 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; + + const route = makeConnRoute(conn); + try { + const resp = await this.env.rpc.RemoteProcessListCommand( + TabRpcClient, + { + widgetid: this.blockId, + sortby: sortBy, + sortdesc: sortDesc, + start, + limit, + textsearch: textSearch || undefined, + lastpidorder: lastPidOrder, + }, + { route } + ); + 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?.() && this.fetchEpoch === epoch) { + globalStore.set(this.loadingAtom, false); + globalStore.set(this.errorAtom, String(e)); + } + } + } + + 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 { + await this.env.rpc.RemoteProcessListCommand( + TabRpcClient, + { widgetid: this.blockId, keepalive: true }, + { route } + ); + } catch (_) { + // keepalive failures are silent + } + } + + startPolling() { + let cancelled = false; + this.cancelPoll = () => { + cancelled = true; + }; + + const poll = async () => { + while (!cancelled && !this.disposed) { + await this.doOneFetch(false, () => cancelled); + + if (cancelled || this.disposed) break; + + const interval = globalStore.get(this.fetchIntervalAtom); + await new Promise((resolve) => { + const timer = setTimeout(resolve, interval); + this.cancelPoll = () => { + clearTimeout(timer); + cancelled = true; + resolve(); + }; + }); + + if (!cancelled) { + this.cancelPoll = () => { + cancelled = true; + }; + } + } + }; + + 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(); + } + this.cancelPoll = null; + if (!globalStore.get(this.pausedAtom)) { + this.startPolling(); + } + } + + 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) { + if (this.cancelPoll) { + this.cancelPoll(); + } + this.cancelPoll = null; + this.startKeepAlive(); + } else { + if (this.cancelPoll) { + this.cancelPoll(); + } + this.cancelPoll = null; + this.startPolling(); + } + } + + setTextSearch(text: string) { + globalStore.set(this.textSearchAtom, text); + if (globalStore.get(this.pausedAtom)) { + this.doOneFetch(false); + } 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(false); + } else { + this.triggerRefresh(); + } + } + + setScrollTop(scrollTop: number) { + const cur = globalStore.get(this.scrollTopAtom); + if (Math.abs(cur - scrollTop) < RowHeight) return; + globalStore.set(this.scrollTopAtom, scrollTop); + 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); + if (globalStore.get(this.pausedAtom)) { + this.doOneFetch(true); + } else { + 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); + } + + 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) { + 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", 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" }, +]; + +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 cols = getColumns(platform); + const visibleKeys = new Set(cols.map((c) => c.key)); + const gridTemplate = getGridTemplate(platform); + if (proc.gone) { + return ( +
onSelect(proc.pid)} + onContextMenu={(e) => onContextMenu(proc.pid, e)} + > +
+ {proc.pid} +
+
(gone)
+ {visibleKeys.has("status") &&
} + {visibleKeys.has("user") &&
} + {visibleKeys.has("threads") &&
} +
+
+
+ ); + } + return ( +
onSelect(proc.pid)} + onContextMenu={(e) => onContextMenu(proc.pid, e)} + > +
+ {proc.pid} +
+
{proc.command}
+ {visibleKeys.has("status") && ( +
{proc.status}
+ )} + {visibleKeys.has("user") && ( +
{proc.user}
+ )} + {visibleKeys.has("threads") && ( +
+ {proc.numthreads === -1 ? "-" : proc.numthreads >= 1 ? proc.numthreads : ""} +
+ )} +
+ {hasCpu ? 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 [selectedPid, setSelectedPid] = jotai.useAtom(model.selectedPidAtom); + const dataStart = jotai.useAtomValue(model.dataStartAtom); + const connection = jotai.useAtomValue(model.connection); + const connStatus = jotai.useAtomValue(model.connStatus); + const bodyScrollRef = React.useRef(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)); + }, + [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), + }); + } + + menu.push({ type: "separator" }); + menu.push(...model.getSettingsMenuItems()); + + ContextMenuModel.getInstance().showContextMenu(menu, e); + }, + [model, setSelectedPid] + ); + + const platform = data?.platform ?? ""; + 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 = dataStart * RowHeight; + + return ( +
+ + + + {/* error */} + {error != null &&
{error}
} + + {/* outer h-scroll wrapper */} +
+ {!connStatus?.connected ? ( +
+ Waiting for connection… +
+ ) : ( +
+ +
+
+
+ {processes.map((proc) => ( + + ))} +
+
+
+
+ )} +
+ +
+ ); + } +); +ProcessViewerView.displayName = "ProcessViewerView"; diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index bf77ef9535..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 } from "./termutil"; +import { computeTheme, DefaultTermTheme, isLikelyOnSameHost, 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); } }, }); @@ -951,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 e49b8d4b8a..add5e86e05 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; @@ -389,3 +396,15 @@ 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, "'\\''") + "'"; +} diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index d79ce695cd..d10b600459 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,14 @@ import { isClaudeCodeCommand, type ShellIntegrationStatus, } from "./osc-handlers"; -import { bufferLinesToText, createTempFileFromBlob, extractAllClipboardData, normalizeCursorStyle } from "./termutil"; +import { + bufferLinesToText, + createTempFileFromBlob, + extractAllClipboardData, + normalizeCursorStyle, + quoteForPosixShell, + trimTerminalSelection, +} from "./termutil"; const dlog = debug("wave:termwrap"); @@ -274,6 +282,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); @@ -341,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( @@ -354,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/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/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/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/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/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/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..72454d36b2 --- /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, 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 = { + 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/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/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 }; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 7a60b6877d..c5b870d7ed 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -557,6 +557,24 @@ declare global { fileinfo?: FileInfo[]; }; + // wshrpc.CommandRemoteProcessListData + type CommandRemoteProcessListData = { + widgetid?: string; + sortby?: string; + sortdesc?: boolean; + start?: number; + limit?: number; + textsearch?: string; + lastpidorder?: boolean; + keepalive?: boolean; + }; + + // wshrpc.CommandRemoteProcessSignalData + type CommandRemoteProcessSignalData = { + pid: number; + signal: string; + }; + // wshrpc.CommandRemoteReconnectToJobManagerData type CommandRemoteReconnectToJobManagerData = { jobid: string; @@ -1002,6 +1020,8 @@ declare global { bookmarks: {[key: string]: WebBookmark}; waveai: {[key: string]: AIModeConfigType}; configerrors: ConfigError[]; + version: string; + buildtime: string; }; // waveobj.Job @@ -1143,6 +1163,7 @@ declare global { "bg:bordercolor"?: string; "bg:activebordercolor"?: string; "layout:vtabbarwidth"?: number; + "layout:widgetsvisible"?: boolean; "waveai:panelopen"?: boolean; "waveai:panelwidth"?: number; "waveai:model"?: string; @@ -1244,6 +1265,44 @@ 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; + gone?: boolean; + }; + + // 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; @@ -1365,6 +1424,8 @@ declare global { "term:bellindicator"?: boolean; "term:osc52"?: string; "term:durable"?: boolean; + "term:showsplitbuttons"?: boolean; + "term:trimtrailingwhitespace"?: boolean; "editor:minimapenabled"?: boolean; "editor:stickyscrollenabled"?: boolean; "editor:wordwrap"?: boolean; @@ -1531,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; @@ -1951,53 +2013,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/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/go.mod b/go.mod index 1e9e2d3663..ca28b59ee5 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 @@ -20,43 +21,41 @@ 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 - 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 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/mod v0.34.0 + golang.org/x/crypto v0.50.0 + golang.org/x/mod v0.35.0 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 + golang.org/x/sys v0.43.0 + golang.org/x/term v0.42.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 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 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.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 +70,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.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/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/text v0.36.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/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 c9d7a83b4d..ddc7470472 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.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.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= @@ -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= @@ -122,12 +122,10 @@ 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.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= @@ -161,27 +159,27 @@ 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.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/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.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/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/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.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= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= @@ -192,27 +190,27 @@ 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/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= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +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.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= -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/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= diff --git a/package-lock.json b/package-lock.json index 00bae215eb..1798a0fa38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.14.4-beta.1", + "version": "0.14.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.14.4-beta.1", + "version": "0.14.5", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -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,10 +74,9 @@ "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", + "uuid": "^14.0.0", "winston": "^3.19.0", "ws": "^8.19.0", "yaml": "^2.8.3" @@ -90,43 +85,37 @@ "@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", - "electron": "^41.0.2", + "electron": "^41.1.0", "electron-builder": "^26.8", "electron-vite": "^5.0", "eslint": "^9.39", "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", - "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", @@ -4812,9 +4801,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 +6689,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": { @@ -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", @@ -10655,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": { @@ -11834,9 +11789,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", @@ -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", @@ -14890,9 +14833,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", @@ -16462,9 +16405,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 +16872,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 +22418,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": { @@ -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", @@ -30188,9 +30106,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": { @@ -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", @@ -31913,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" @@ -32131,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": { @@ -33470,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 2b68fef8a7..4c1d56798f 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.5", "homepage": "https://waveterm.dev", "build": { "appId": "dev.commandline.waveterm" @@ -31,43 +31,37 @@ "@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", - "electron": "^41.0.2", + "electron": "^41.1.0", "electron-builder": "^26.8", "electron-vite": "^5.0", "eslint": "^9.39", "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", - "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", @@ -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,10 +128,9 @@ "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", + "uuid": "^14.0.0", "winston": "^3.19.0", "ws": "^8.19.0", "yaml": "^2.8.3" 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/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/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..0f164501ef 100644 --- a/pkg/telemetry/telemetrydata/telemetrydata.go +++ b/pkg/telemetry/telemetrydata/telemetrydata.go @@ -122,12 +122,14 @@ 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"` - 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/util/procinfo/procinfo.go b/pkg/util/procinfo/procinfo.go new file mode 100644 index 0000000000..858ef11863 --- /dev/null +++ b/pkg/util/procinfo/procinfo.go @@ -0,0 +1,42 @@ +// 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. +// 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; -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 // -1 if unavailable +} diff --git a/pkg/util/procinfo/procinfo_darwin.go b/pkg/util/procinfo/procinfo_darwin.go new file mode 100644 index 0000000000..88dd4a885f --- /dev/null +++ b/pkg/util/procinfo/procinfo_darwin.go @@ -0,0 +1,154 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package procinfo + +import ( + "context" + "fmt" + "sync" + "syscall" + "unsafe" + + "github.com/ebitengine/purego" + "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, + } + + 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 = int64(ti.ResidentSize) + info.NumThreads = ti.Threadnum + } + + 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..abd6d3aa75 --- /dev/null +++ b/pkg/util/procinfo/procinfo_linux.go @@ -0,0 +1,154 @@ +// 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) + } + + statusChar := rest[0] + status, ok := LinuxStatStatus[statusChar] + if !ok { + status = "unknown" + } + + info := &ProcInfo{ + Pid: int32(parsedPid), + Command: comm, + Status: status, + 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 +} + +// 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..6ea131bc01 --- /dev/null +++ b/pkg/util/procinfo/procinfo_windows.go @@ -0,0 +1,143 @@ +// 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, + CpuUser: -1, + CpuSys: -1, + VmRSS: -1, + } + + 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 = int64(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/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) 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/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/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"` 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/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index 8ed6af7235..d8847cabf2 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", @@ -35,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/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/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index df048b304c..7d5bba5d9d 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -59,6 +59,8 @@ const ( ConfigKey_TermBellIndicator = "term:bellindicator" 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 b55cab8cbf..67118b1670 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -109,7 +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"` + 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"` @@ -375,6 +377,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 { @@ -695,6 +699,8 @@ func ReadFullConfig() FullConfigType { utilfn.ReUnmarshal(fieldPtr, configPart) } } + fullConfig.Version = wavebase.WaveVersion + fullConfig.BuildTime = wavebase.BuildTime return fullConfig } 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, }, }) } diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 2968baa8d7..d5333aec2b 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) @@ -906,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/wshremote/processviewer.go b/pkg/wshrpc/wshremote/processviewer.go new file mode 100644 index 0000000000..dc6bb0ee6e --- /dev/null +++ b/pkg/wshrpc/wshremote/processviewer.go @@ -0,0 +1,594 @@ +// 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 = 60 * time.Second + ProcCachePollInterval = 1 * time.Second + ProcCacheMinSleep = 500 * time.Millisecond + 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 +} + +// 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 { + 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 + + widgetPidOrders map[string]*widgetPidOrder // keyed by widgetId +} + +// 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) 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 +} + +// 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() { + 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 := max(runtime.NumCPU(), 1) + + 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) + } + } + + if s.updateCacheAndCheckIdle(result, &firstDone, firstReadyCh) { + return + } + + elapsed := time.Since(iterStart) + 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) + } + 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, err := procinfo.MakeGlobalSnapshot() + if err != nil { + return nil + } + + hasCPU := s.lastCPUEpoch > 1 // first epoch has no previous sample to diff against + + type pidInfo struct { + pid int32 + info *procinfo.ProcInfo + } + rawInfos := make([]pidInfo, len(procs)) + for i, p := range procs { + pi, err := procinfo.GetProcInfo(ctx, snap, p.Pid) + if err != nil { + pi = nil + } + rawInfos[i] = pidInfo{pid: p.Pid, info: pi} + } + + // 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 + } + if ri.info.CpuUser < 0 || ri.info.CpuSys < 0 { + 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 and summary. + vmStat, _ := mem.VirtualMemoryWithContext(ctx) + var totalMem uint64 + if vmStat != nil { + totalMem = vmStat.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, + MemPct: -1, + Cpu: -1, + NumThreads: pi.NumThreads, + User: s.lookupUID(pi.Uid), + } + if totalMem > 0 && pi.VmRSS >= 0 { + info.MemPct = float64(pi.VmRSS) / float64(totalMem) * 100 + } + if hasCPU { + if cpu, ok := cpuPcts[pi.Pid]; ok { + info.Cpu = cpu + cpuSum += cpu + } + } + infos = append(infos, info) + } + + summary := buildProcessSummary(ctx, len(procs), numCPU, cpuSum, vmStat) + + return &wshrpc.ProcessListResponse{ + Processes: infos, + Summary: summary, + Ts: time.Now().UnixMilli(), + HasCPU: hasCPU, + Platform: runtime.GOOS, + } +} + +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 { + delta = 0 + } + return delta +} + +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 vmStat != nil { + summary.MemTotal = vmStat.Total + summary.MemUsed = vmStat.Used + summary.MemFree = vmStat.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 sortProcesses(processes []wshrpc.ProcessInfo, sortBy string, sortDesc bool) { + switch sortBy { + case "cpu": + sort.Slice(processes, func(i, j int) bool { + ci := processes[i].Cpu + cj := processes[j].Cpu + iNull := ci < 0 + jNull := cj < 0 + if iNull != jNull { + return !iNull + } + if !iNull && ci != cj { + if sortDesc { + return ci > cj + } + return ci < cj + } + return processes[i].Pid < processes[j].Pid + }) + case "mem": + sort.Slice(processes, func(i, j int) bool { + 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].Pid < processes[j].Pid + }) + case "command": + sort.Slice(processes, func(i, j int) bool { + 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].Pid < processes[j].Pid + }) + case "user": + sort.Slice(processes, func(i, j int) bool { + 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].Pid < processes[j].Pid + }) + case "status": + sort.Slice(processes, func(i, j int) bool { + 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].Pid < processes[j].Pid + }) + case "threads": + sort.Slice(processes, func(i, j int) bool { + 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].Pid < processes[j].Pid + }) + 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 + }) + } +} + +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 + } + + 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 + } + } + 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) + } + } + + // Phase 2: limit and populate process info from the pid order. + limit := data.Limit + if limit <= 0 || limit > ProcViewerMaxLimit { + limit = ProcViewerMaxLimit + } + pidMap := make(map[int32]wshrpc.ProcessInfo, len(raw.Processes)) + for _, p := range raw.Processes { + pidMap[p.Pid] = p + } + start := bound(data.Start, 0, 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, + 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/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) } diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 2fee3e392e..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) @@ -128,6 +127,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) @@ -340,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"` @@ -908,3 +868,60 @@ type FocusedBlockData struct { TermShellIntegrationStatus string `json:"termshellintegrationstatus,omitempty"` 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 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 { + 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 { + 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"` + // 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 { + Pid int32 `json:"pid"` + Signal string `json:"signal"` +} diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index e66e52320c..38006fd9a8 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 { @@ -1334,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 } @@ -1344,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 diff --git a/schema/settings.json b/schema/settings.json index 67d8f5b9d4..f341a0f365 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -168,6 +168,12 @@ "term:durable": { "type": "boolean" }, + "term:showsplitbuttons": { + "type": "boolean" + }, + "term:trimtrailingwhitespace": { + "type": "boolean" + }, "editor:minimapenabled": { "type": "boolean" }, 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/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" } } 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/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/*"] } 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