diff --git a/docs/docs/releasenotes.mdx b/docs/docs/releasenotes.mdx index adc2ce6fe7..421f212731 100644 --- a/docs/docs/releasenotes.mdx +++ b/docs/docs/releasenotes.mdx @@ -6,6 +6,30 @@ sidebar_position: 200 # Release Notes +### 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. + +**Vertical Tab Bar:** + +- **New Vertical Tab Bar Option** - Tabs can now be displayed vertically along the side of the window, giving you more horizontal space and easier access to tabs when you have many open. Toggle between horizontal and vertical tab layouts in settings. + +**Terminal Improvements:** + +- **xterm.js v6.0.0 Upgrade** - Upgraded to the latest xterm.js v6, bringing improved terminal compatibility and rendering. This should resolve various terminal rendering quirks observed with tools like Claude Code. + +**Other Changes:** + +- **`backgrounds.json`** - Renamed `presets/bg.json` to `backgrounds.json` and moved background config to new `tab:background` key (auto-migrated on startup) +- **Config Errors Moved** - Config errors removed from the tab bar and moved to Settings / WaveConfig view for less clutter +- **Warn on Unsaved Changes** - WaveConfig view now warns before discarding unsaved changes +- **Stream Performance** - Migrated file streaming to new modern interface with flow control, fixing a large time-to-first-byte streaming bug +- **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 +- Electron upgraded to v41 +- Package updates and dependency upgrades + ### v0.14.2 — Mar 12, 2026 Wave v0.14.2 adds block/tab badges, directory preview improvements, and assorted bug fixes. diff --git a/frontend/app/asset/claude-color.svg b/frontend/app/asset/claude-color.svg new file mode 100644 index 0000000000..b70e167740 --- /dev/null +++ b/frontend/app/asset/claude-color.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/app/onboarding/onboarding-common.tsx b/frontend/app/onboarding/onboarding-common.tsx index 60711746e1..96e49b1a79 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.3"; +export const CurrentOnboardingVersion = "v0.14.4"; export function OnboardingGradientBg() { return ( diff --git a/frontend/app/onboarding/onboarding-upgrade-patch.tsx b/frontend/app/onboarding/onboarding-upgrade-patch.tsx index 0eded88f12..c3dd5004a2 100644 --- a/frontend/app/onboarding/onboarding-upgrade-patch.tsx +++ b/frontend/app/onboarding/onboarding-upgrade-patch.tsx @@ -25,6 +25,7 @@ import { UpgradeOnboardingModal_v0_13_1_Content } from "./onboarding-upgrade-v01 import { UpgradeOnboardingModal_v0_14_0_Content } from "./onboarding-upgrade-v0140"; 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"; interface VersionConfig { version: string; @@ -139,6 +140,12 @@ export const UpgradeOnboardingVersions: VersionConfig[] = [ version: "v0.14.3", content: () => , prevText: "Prev (v0.14.1)", + nextText: "Next (v0.14.4)", + }, + { + version: "v0.14.4", + content: () => , + prevText: "Prev (v0.14.3)", }, ]; diff --git a/frontend/app/onboarding/onboarding-upgrade-v0144.tsx b/frontend/app/onboarding/onboarding-upgrade-v0144.tsx new file mode 100644 index 0000000000..6fc50f8919 --- /dev/null +++ b/frontend/app/onboarding/onboarding-upgrade-v0144.tsx @@ -0,0 +1,84 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +const UpgradeOnboardingModal_v0_14_4_Content = () => { + return ( +
+
+

+ Wave v0.14.4 introduces vertical tabs, upgrades to xterm.js v6, and includes bug fixes and UI + improvements. +

+
+ +
+
+ +
+
+
Vertical Tab Bar
+
+
    +
  • + New Vertical Tab Bar Option - Tabs can now be displayed vertically + along the side of the window for more horizontal space. Toggle between horizontal and + vertical layouts in settings. +
  • +
+
+
+
+ +
+
+ +
+
+
Terminal Improvements
+
+
    +
  • + xterm.js v6.0.0 Upgrade - Improved terminal compatibility and + rendering, resolving quirks with tools like Claude Code +
  • +
+
+
+
+ +
+
+ +
+
+
Other Changes
+
+
    +
  • + macOS First Click - First click now focuses the clicked widget +
  • +
  • + + backgrounds.json + {" "} + - Renamed presets/bg.json to backgrounds.json +
  • +
  • + Config Errors Moved - Config errors to the WaveConfig view for less + clutter +
  • +
  • WaveConfig now warns on Unsaved Changes
  • +
  • Preview streaming fixes for images/videos
  • +
  • Deprecated legacy AI widget has been removed
  • +
  • [bugfix] Fixed focus bug for newly created blocks
  • +
+
+
+
+
+ ); +}; + +UpgradeOnboardingModal_v0_14_4_Content.displayName = "UpgradeOnboardingModal_v0_14_4_Content"; + +export { UpgradeOnboardingModal_v0_14_4_Content }; diff --git a/frontend/app/view/term/osc-handlers.test.ts b/frontend/app/view/term/osc-handlers.test.ts new file mode 100644 index 0000000000..3955a2bc8f --- /dev/null +++ b/frontend/app/view/term/osc-handlers.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { isClaudeCodeCommand } from "./osc-handlers"; + +describe("isClaudeCodeCommand", () => { + it("matches direct Claude Code invocations", () => { + expect(isClaudeCodeCommand("claude")).toBe(true); + expect(isClaudeCodeCommand("claude --dangerously-skip-permissions")).toBe(true); + }); + + it("matches Claude Code invocations wrapped with env assignments", () => { + expect(isClaudeCodeCommand('ANTHROPIC_API_KEY="test" claude')).toBe(true); + expect(isClaudeCodeCommand("env FOO=bar claude --print")).toBe(true); + }); + + it("ignores other commands", () => { + expect(isClaudeCodeCommand("claudes")).toBe(false); + expect(isClaudeCodeCommand("echo claude")).toBe(false); + expect(isClaudeCodeCommand("ls ~/claude")).toBe(false); + expect(isClaudeCodeCommand("cat /logs/claude")).toBe(false); + expect(isClaudeCodeCommand("")).toBe(false); + }); +}); diff --git a/frontend/app/view/term/osc-handlers.ts b/frontend/app/view/term/osc-handlers.ts index f44659d2c6..7fe7dcd4ee 100644 --- a/frontend/app/view/term/osc-handlers.ts +++ b/frontend/app/view/term/osc-handlers.ts @@ -25,6 +25,8 @@ const Osc52MaxRawLength = 128 * 1024; // includes selector + base64 + whitespace // See aiprompts/wave-osc-16162.md for full documentation export type ShellIntegrationStatus = "ready" | "running-command"; +const ClaudeCodeRegex = /^claude\b/; + type Osc16162Command = | { command: "A"; data: Record } | { command: "C"; data: { cmd64?: string } } @@ -43,41 +45,56 @@ type Osc16162Command = | { command: "I"; data: { inputempty?: boolean } } | { command: "R"; data: Record }; +function normalizeCmd(decodedCmd: string): string { + let normalizedCmd = decodedCmd.trim(); + normalizedCmd = normalizedCmd.replace(/^env\s+/, ""); + normalizedCmd = normalizedCmd.replace(/^(?:\w+=(?:"[^"]*"|'[^']*'|\S+)\s+)*/, ""); + return normalizedCmd; +} + function checkCommandForTelemetry(decodedCmd: string) { if (!decodedCmd) { return; } - if (decodedCmd.startsWith("ssh ")) { + const normalizedCmd = normalizeCmd(decodedCmd); + + if (normalizedCmd.startsWith("ssh ")) { recordTEvent("conn:connect", { "conn:conntype": "ssh-manual" }); return; } const editorsRegex = /^(vim|vi|nano|nvim)\b/; - if (editorsRegex.test(decodedCmd)) { + if (editorsRegex.test(normalizedCmd)) { recordTEvent("action:term", { "action:type": "cli-edit" }); return; } const tailFollowRegex = /(^|\|\s*)tail\s+-[fF]\b/; - if (tailFollowRegex.test(decodedCmd)) { + if (tailFollowRegex.test(normalizedCmd)) { recordTEvent("action:term", { "action:type": "cli-tailf" }); return; } - const claudeRegex = /^claude\b/; - if (claudeRegex.test(decodedCmd)) { + if (ClaudeCodeRegex.test(normalizedCmd)) { recordTEvent("action:term", { "action:type": "claude" }); return; } const opencodeRegex = /^opencode\b/; - if (opencodeRegex.test(decodedCmd)) { + if (opencodeRegex.test(normalizedCmd)) { recordTEvent("action:term", { "action:type": "opencode" }); return; } } +export function isClaudeCodeCommand(decodedCmd: string): boolean { + if (!decodedCmd) { + return false; + } + return ClaudeCodeRegex.test(normalizeCmd(decodedCmd)); +} + function handleShellIntegrationCommandStart( termWrap: TermWrap, blockId: string, @@ -101,16 +118,20 @@ function handleShellIntegrationCommandStart( const decodedCmd = base64ToString(cmd.data.cmd64); rtInfo["shell:lastcmd"] = decodedCmd; globalStore.set(termWrap.lastCommandAtom, decodedCmd); + const isCC = isClaudeCodeCommand(decodedCmd); + globalStore.set(termWrap.claudeCodeActiveAtom, isCC); checkCommandForTelemetry(decodedCmd); } catch (e) { console.error("Error decoding cmd64:", e); rtInfo["shell:lastcmd"] = null; globalStore.set(termWrap.lastCommandAtom, null); + globalStore.set(termWrap.claudeCodeActiveAtom, false); } } } else { rtInfo["shell:lastcmd"] = null; globalStore.set(termWrap.lastCommandAtom, null); + globalStore.set(termWrap.claudeCodeActiveAtom, false); } rtInfo["shell:lastcmdexitcode"] = null; } @@ -287,6 +308,7 @@ export function handleOsc16162Command(data: string, blockId: string, loaded: boo case "A": { rtInfo["shell:state"] = "ready"; globalStore.set(termWrap.shellIntegrationStatusAtom, "ready"); + globalStore.set(termWrap.claudeCodeActiveAtom, false); const marker = terminal.registerMarker(0); if (marker) { termWrap.promptMarkers.push(marker); @@ -324,6 +346,7 @@ export function handleOsc16162Command(data: string, blockId: string, loaded: boo } break; case "D": + globalStore.set(termWrap.claudeCodeActiveAtom, false); if (cmd.data.exitcode != null) { rtInfo["shell:lastcmdexitcode"] = cmd.data.exitcode; } else { @@ -337,6 +360,7 @@ export function handleOsc16162Command(data: string, blockId: string, loaded: boo break; case "R": globalStore.set(termWrap.shellIntegrationStatusAtom, null); + globalStore.set(termWrap.claudeCodeActiveAtom, false); if (terminal.buffer.active.type === "alternate") { terminal.write("\x1b[?1049l"); } diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index f9ff44f325..bf77ef9535 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -10,7 +10,7 @@ import { waveEventSubscribeSingle } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { makeFeBlockRouteId } from "@/app/store/wshrouter"; import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil"; -import { TerminalView } from "@/app/view/term/term"; +import { TermClaudeIcon, TerminalView } from "@/app/view/term/term"; import { TermWshClient } from "@/app/view/term/term-wsh"; import { VDomModel } from "@/app/view/vdom/vdom-model"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; @@ -404,10 +404,12 @@ export class TermViewModel implements ViewModel { return null; } const shellIntegrationStatus = get(this.termRef.current.shellIntegrationStatusAtom); + const claudeCodeActive = get(this.termRef.current.claudeCodeActiveAtom); + const icon = claudeCodeActive ? React.createElement(TermClaudeIcon) : "sparkles"; if (shellIntegrationStatus == null) { return { elemtype: "iconbutton", - icon: "sparkles", + icon, className: "text-muted", title: "No shell integration — Wave AI unable to run commands.", noAction: true, @@ -416,14 +418,16 @@ export class TermViewModel implements ViewModel { if (shellIntegrationStatus === "ready") { return { elemtype: "iconbutton", - icon: "sparkles", + icon, className: "text-accent", title: "Shell ready — Wave AI can run commands in this terminal.", noAction: true, }; } if (shellIntegrationStatus === "running-command") { - let title = "Shell busy — Wave AI unable to run commands while another command is running."; + let title = claudeCodeActive + ? "Claude Code Detected" + : "Shell busy — Wave AI unable to run commands while another command is running."; if (this.termRef.current) { const inAltBuffer = this.termRef.current.terminal?.buffer?.active?.type === "alternate"; @@ -436,7 +440,7 @@ export class TermViewModel implements ViewModel { return { elemtype: "iconbutton", - icon: "sparkles", + icon, className: "text-warning", title: title, noAction: true, diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index e385894825..67eb5737c6 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -1,6 +1,7 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import ClaudeColorSvg from "@/app/asset/claude-color.svg"; import { SubBlock } from "@/app/block/block"; import type { BlockNodeModel } from "@/app/block/blocktypes"; import { NullErrorBoundary } from "@/app/element/errorboundary"; @@ -34,6 +35,16 @@ interface TerminalViewProps { model: TermViewModel; } +const TermClaudeIcon = React.memo(() => { + return ( + + ); +}); + +TermClaudeIcon.displayName = "TermClaudeIcon"; + const TermResyncHandler = React.memo(({ blockId, model }: TerminalViewProps) => { const connStatus = jotai.useAtomValue(model.connStatus); const [lastConnStatus, setLastConnStatus] = React.useState(connStatus); @@ -61,7 +72,7 @@ const TermVDomToolbarNode = ({ vdomBlockId, blockId, model }: TerminalViewProps const unsub = waveEventSubscribeSingle({ eventType: "blockclose", scope: WOS.makeORef("block", vdomBlockId), - handler: (event) => { + handler: (_event) => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", blockId), meta: { @@ -104,7 +115,7 @@ const TermVDomNodeSingleId = ({ vdomBlockId, blockId, model }: TerminalViewProps const unsub = waveEventSubscribeSingle({ eventType: "blockclose", scope: WOS.makeORef("block", vdomBlockId), - handler: (event) => { + handler: (_event) => { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", blockId), meta: { @@ -390,4 +401,4 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => ); }; -export { TerminalView }; +export { TermClaudeIcon, TerminalView }; diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 46211fd330..d79ce695cd 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import type { BlockNodeModel } from "@/app/block/blocktypes"; @@ -32,6 +32,7 @@ import { handleOsc16162Command, handleOsc52Command, handleOsc7Command, + isClaudeCodeCommand, type ShellIntegrationStatus, } from "./osc-handlers"; import { bufferLinesToText, createTempFileFromBlob, extractAllClipboardData, normalizeCursorStyle } from "./termutil"; @@ -92,6 +93,7 @@ export class TermWrap { promptMarkers: TermTypes.IMarker[] = []; shellIntegrationStatusAtom: jotai.PrimitiveAtom; lastCommandAtom: jotai.PrimitiveAtom; + claudeCodeActiveAtom: jotai.PrimitiveAtom; nodeModel: BlockNodeModel; // this can be null hoveredLinkUri: string | null = null; onLinkHover?: (uri: string | null, mouseX: number, mouseY: number) => void; @@ -131,6 +133,7 @@ export class TermWrap { this.promptMarkers = []; this.shellIntegrationStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom; this.lastCommandAtom = jotai.atom(null) as jotai.PrimitiveAtom; + this.claudeCodeActiveAtom = jotai.atom(false); this.webglEnabledAtom = jotai.atom(false) as jotai.PrimitiveAtom; this.terminal = new Terminal(options); this.fitAddon = new FitAddon(); @@ -171,16 +174,34 @@ export class TermWrap { this.setTermRenderer(WebGLSupported && waveOptions.useWebGl ? "webgl" : "dom"); // Register OSC handlers this.terminal.parser.registerOscHandler(7, (data: string) => { - return handleOsc7Command(data, this.blockId, this.loaded); + try { + return handleOsc7Command(data, this.blockId, this.loaded); + } catch (e) { + console.error("[termwrap] osc 7 handler error", this.blockId, e); + return false; + } }); this.terminal.parser.registerOscHandler(52, (data: string) => { - return handleOsc52Command(data, this.blockId, this.loaded, this); + try { + return handleOsc52Command(data, this.blockId, this.loaded, this); + } catch (e) { + console.error("[termwrap] osc 52 handler error", this.blockId, e); + return false; + } }); this.terminal.parser.registerOscHandler(16162, (data: string) => { - return handleOsc16162Command(data, this.blockId, this.loaded, this); + try { + return handleOsc16162Command(data, this.blockId, this.loaded, this); + } catch (e) { + console.error("[termwrap] osc 16162 handler error", this.blockId, e); + return false; + } }); this.toDispose.push( this.terminal.parser.registerCsiHandler({ final: "J" }, (params) => { + if (params == null || params.length < 1) { + return false; + } if (params[0] === 3) { this.lastClearScrollbackTs = Date.now(); if (this.inSyncTransaction) { @@ -193,6 +214,9 @@ export class TermWrap { ); this.toDispose.push( this.terminal.parser.registerCsiHandler({ prefix: "?", final: "h" }, (params) => { + if (params == null || params.length < 1) { + return false; + } if (params[0] === 2026) { this.lastMode2026SetTs = Date.now(); this.inSyncTransaction = true; @@ -202,6 +226,9 @@ export class TermWrap { ); this.toDispose.push( this.terminal.parser.registerCsiHandler({ prefix: "?", final: "l" }, (params) => { + if (params == null || params.length < 1) { + return false; + } if (params[0] === 2026) { this.lastMode2026ResetTs = Date.now(); this.inSyncTransaction = false; @@ -345,16 +372,19 @@ export class TermWrap { const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), }); + let shellState: ShellIntegrationStatus = null; if (rtInfo && rtInfo["shell:integration"]) { - const shellState = rtInfo["shell:state"] as ShellIntegrationStatus; + shellState = rtInfo["shell:state"] as ShellIntegrationStatus; globalStore.set(this.shellIntegrationStatusAtom, shellState || null); } else { globalStore.set(this.shellIntegrationStatusAtom, null); } const lastCmd = rtInfo ? rtInfo["shell:lastcmd"] : null; + const isCC = shellState === "running-command" && isClaudeCodeCommand(lastCmd); globalStore.set(this.lastCommandAtom, lastCmd || null); + globalStore.set(this.claudeCodeActiveAtom, isCC); } catch (e) { console.log("Error loading runtime info:", e); } @@ -371,7 +401,9 @@ export class TermWrap { this.promptMarkers.forEach((marker) => { try { marker.dispose(); - } catch (_) {} + } catch (_) { + /* nothing */ + } }); this.promptMarkers = []; this.webglContextLossDisposable?.dispose(); @@ -380,7 +412,9 @@ export class TermWrap { this.toDispose.forEach((d) => { try { d.dispose(); - } catch (_) {} + } catch (_) { + /* nothing */ + } }); this.mainFileSubject.release(); } diff --git a/frontend/app/view/waveai/waveai.scss b/frontend/app/view/waveai/waveai.scss deleted file mode 100644 index 2d463fd88e..0000000000 --- a/frontend/app/view/waveai/waveai.scss +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -.waveai { - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - - .waveai-chat { - flex: 1 1 auto; - overflow: hidden; - .chat-window-container { - overflow-y: auto; - margin-bottom: 0; - height: 100%; - - .chat-window { - flex-flow: column nowrap; - display: flex; - gap: 8px; - - // This is the filler that will push the chat messages to the bottom until the chat window is full - .filler { - flex: 1 1 auto; - } - - .chat-msg-container { - display: flex; - gap: 8px; - .chat-msg { - margin: 10px 0; - display: flex; - align-items: flex-start; - border-radius: 8px; - - &.chat-msg-header { - display: flex; - flex-direction: column; - justify-content: flex-start; - - .icon-box { - padding-top: 0; - border-radius: 4px; - background-color: rgb(from var(--highlight-bg-color) r g b / 0.05); - display: flex; - padding: 6px; - } - } - - &.chat-msg-assistant { - color: var(--main-text-color); - background-color: rgb(from var(--highlight-bg-color) r g b / 0.1); - margin-right: auto; - padding: 10px; - max-width: 85%; - - .markdown { - width: 100%; - - pre { - white-space: pre-wrap; - word-break: break-word; - max-width: 100%; - overflow-x: auto; - margin-left: 0; - } - } - } - &.chat-msg-user { - margin-left: auto; - padding: 10px; - max-width: 85%; - background-color: rgb(from var(--accent-color) r g b / 0.15); - } - - &.chat-msg-error { - color: var(--main-text-color); - background-color: rgb(from var(--error-color) r g b / 0.25); - margin-right: auto; - padding: 10px; - max-width: 85%; - - .markdown { - width: 100%; - - pre { - white-space: pre-wrap; - word-break: break-word; - max-width: 100%; - overflow-x: auto; - margin-left: 0; - } - } - } - - &.typing-indicator { - margin-top: 4px; - } - } - } - } - } - } - - .waveai-controls { - flex: 0 0 auto; - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-start; - gap: 10px; - padding: 8px 6px; - - .waveai-input-wrapper { - padding: 8px 12px; - flex: 1 1 auto; - display: flex; - flex-direction: column; - justify-content: center; - align-items: flex-start; - border-radius: 6px; - border: 1px solid rgb(from var(--highlight-bg-color) r g b / 0.42); - - .waveai-input { - color: var(--main-text-color); - background-color: inherit; - resize: none; - width: 100%; - border: transparent; - outline: none; - overflow: auto; - overflow-wrap: anywhere; - height: 21px; - } - } - - .waveai-submit-button { - border-radius: 100%; - width: 27px; - aspect-ratio: 1 /1; - display: flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - padding: 0; - } - } -} diff --git a/frontend/app/view/waveai/waveai.tsx b/frontend/app/view/waveai/waveai.tsx index c71d012a61..baf6acf711 100644 --- a/frontend/app/view/waveai/waveai.tsx +++ b/frontend/app/view/waveai/waveai.tsx @@ -1,912 +1,40 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { BlockNodeModel } from "@/app/block/blocktypes"; import { Button } from "@/app/element/button"; -import { Markdown } from "@/app/element/markdown"; -import { TypingIndicator } from "@/app/element/typingindicator"; -import { ClientModel } from "@/app/store/client-model"; -import { globalStore } from "@/app/store/jotaiStore"; -import type { TabModel } from "@/app/store/tab-model"; -import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; -import { RpcApi } from "@/app/store/wshclientapi"; -import { makeFeBlockRouteId } from "@/app/store/wshrouter"; -import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; -import { atoms, createBlock, fetchWaveFile, getApi, WOS } from "@/store/global"; -import { BlockService, ObjectService } from "@/store/services"; -import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; -import { fireAndForget, isBlank, makeIconClass, mergeMeta } from "@/util/util"; -import { atom, Atom, PrimitiveAtom, useAtomValue, WritableAtom } from "jotai"; -import { splitAtom } from "jotai/utils"; -import type { OverlayScrollbars } from "overlayscrollbars"; -import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; -import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; -import { debounce, throttle } from "throttle-debounce"; -import "./waveai.scss"; - -interface ChatMessageType { - id: string; - user: string; - text: string; - isUpdating?: boolean; -} - -const outline = "2px solid var(--accent-color)"; -const slidingWindowSize = 30; - -interface ChatItemProps { - chatItemAtom: Atom; - model: WaveAiModel; -} - -function promptToMsg(prompt: WaveAIPromptMessageType): ChatMessageType { - return { - id: crypto.randomUUID(), - user: prompt.role, - text: prompt.content, - }; -} - -class AiWshClient extends WshClient { - blockId: string; - model: WaveAiModel; - - constructor(blockId: string, model: WaveAiModel) { - super(makeFeBlockRouteId(blockId)); - this.blockId = blockId; - this.model = model; - } - - handle_aisendmessage(rh: RpcResponseHelper, data: AiMessageData) { - if (isBlank(data.message)) { - return; - } - this.model.sendMessage(data.message); - } -} +import { atom } from "jotai"; +import { useCallback } from "react"; export class WaveAiModel implements ViewModel { - viewType: string; - blockId: string; - nodeModel: BlockNodeModel; - tabModel: TabModel; - blockAtom: Atom; - presetKey: Atom; - presetMap: Atom<{ [k: string]: MetaType }>; - mergedPresets: Atom; - aiOpts: Atom; - viewIcon?: Atom; - viewName?: Atom; - viewText?: Atom; - preIconButton?: Atom; - endIconButtons?: Atom; - messagesAtom: PrimitiveAtom>; - messagesSplitAtom: SplitAtom>; - latestMessageAtom: Atom; - addMessageAtom: WritableAtom; - updateLastMessageAtom: WritableAtom; - removeLastMessageAtom: WritableAtom; - simulateAssistantResponseAtom: WritableAtom>; - textAreaRef: React.RefObject; - locked: PrimitiveAtom; - cancel: boolean; - aiWshClient: AiWshClient; - - constructor({ blockId, nodeModel, tabModel }: ViewModelInitType) { - this.blockId = blockId; - this.nodeModel = nodeModel; - this.tabModel = tabModel; - this.aiWshClient = new AiWshClient(blockId, this); - DefaultRouter.registerRoute(makeFeBlockRouteId(blockId), this.aiWshClient); - this.locked = atom(false); - this.cancel = false; - this.viewType = "waveai"; - this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); - this.viewIcon = atom("sparkles"); - this.viewName = atom("Wave AI"); - this.messagesAtom = atom([]); - this.messagesSplitAtom = splitAtom(this.messagesAtom); - this.latestMessageAtom = atom((get) => get(this.messagesAtom).slice(-1)[0]); - this.presetKey = atom((get) => { - const metaPresetKey = get(this.blockAtom).meta["ai:preset"]; - const globalPresetKey = get(atoms.settingsAtom)["ai:preset"]; - return metaPresetKey ?? globalPresetKey; - }); - this.presetMap = atom((get) => { - const fullConfig = get(atoms.fullConfigAtom); - const presets = fullConfig.presets; - const settings = fullConfig.settings; - return Object.fromEntries( - Object.entries(presets) - .filter(([k]) => k.startsWith("ai@")) - .map(([k, v]) => { - const aiPresetKeys = Object.keys(v).filter((k) => k.startsWith("ai:")); - const newV = { ...v }; - newV["display:name"] = - aiPresetKeys.length == 1 && aiPresetKeys.includes("ai:*") - ? `${newV["display:name"] ?? "Default"} (${settings["ai:model"]})` - : newV["display:name"]; - return [k, newV]; - }) - ); - }); - - this.addMessageAtom = atom(null, (get, set, message: ChatMessageType) => { - const messages = get(this.messagesAtom); - set(this.messagesAtom, [...messages, message]); - }); - - this.updateLastMessageAtom = atom(null, (get, set, text: string, isUpdating: boolean) => { - const messages = get(this.messagesAtom); - const lastMessage = messages[messages.length - 1]; - if (lastMessage.user == "assistant") { - const updatedMessage = { ...lastMessage, text: lastMessage.text + text, isUpdating }; - set(this.messagesAtom, [...messages.slice(0, -1), updatedMessage]); - } - }); - this.removeLastMessageAtom = atom(null, (get, set) => { - const messages = get(this.messagesAtom); - messages.pop(); - set(this.messagesAtom, [...messages]); - }); - this.simulateAssistantResponseAtom = atom(null, async (_, set, userMessage: ChatMessageType) => { - // unused at the moment. can replace the temp() function in the future - const typingMessage: ChatMessageType = { - id: crypto.randomUUID(), - user: "assistant", - text: "", - }; - - // Add a typing indicator - set(this.addMessageAtom, typingMessage); - const parts = userMessage.text.split(" "); - let currentPart = 0; - while (currentPart < parts.length) { - const part = parts[currentPart] + " "; - set(this.updateLastMessageAtom, part, true); - currentPart++; - } - set(this.updateLastMessageAtom, "", false); - }); - - this.mergedPresets = atom((get) => { - const meta = get(this.blockAtom).meta; - let settings = get(atoms.settingsAtom); - let presetKey = get(this.presetKey); - let presets = get(atoms.fullConfigAtom).presets; - let selectedPresets = presets?.[presetKey] ?? {}; - - let mergedPresets: MetaType = {}; - mergedPresets = mergeMeta(settings, selectedPresets, "ai"); - mergedPresets = mergeMeta(mergedPresets, meta, "ai"); - - return mergedPresets; - }); - - this.aiOpts = atom((get) => { - const mergedPresets = get(this.mergedPresets); - - const opts: WaveAIOptsType = { - model: mergedPresets["ai:model"] ?? null, - apitype: mergedPresets["ai:apitype"] ?? null, - orgid: mergedPresets["ai:orgid"] ?? null, - apitoken: mergedPresets["ai:apitoken"] ?? null, - apiversion: mergedPresets["ai:apiversion"] ?? null, - maxtokens: mergedPresets["ai:maxtokens"] ?? null, - timeoutms: mergedPresets["ai:timeoutms"] ?? 60000, - baseurl: mergedPresets["ai:baseurl"] ?? null, - proxyurl: mergedPresets["ai:proxyurl"] ?? null, - }; - return opts; - }); - - this.viewText = atom((get) => { - const viewTextChildren: HeaderElem[] = []; - const aiOpts = get(this.aiOpts); - const presets = get(this.presetMap); - const presetKey = get(this.presetKey); - const presetName = presets[presetKey]?.["display:name"] ?? ""; - const isCloud = isBlank(aiOpts.apitoken) && isBlank(aiOpts.baseurl); - - // Handle known API providers - switch (aiOpts?.apitype) { - case "anthropic": - viewTextChildren.push({ - elemtype: "iconbutton", - icon: "globe", - title: `Using Remote Anthropic API (${aiOpts.model})`, - noAction: true, - }); - break; - case "perplexity": - viewTextChildren.push({ - elemtype: "iconbutton", - icon: "globe", - title: `Using Remote Perplexity API (${aiOpts.model})`, - noAction: true, - }); - break; - default: - if (isCloud) { - viewTextChildren.push({ - elemtype: "iconbutton", - icon: "cloud", - title: "Using Wave's AI Proxy (gpt-5-mini)", - noAction: true, - }); - } else { - const baseUrl = aiOpts.baseurl ?? "OpenAI Default Endpoint"; - const modelName = aiOpts.model; - if (baseUrl.startsWith("http://localhost") || baseUrl.startsWith("http://127.0.0.1")) { - viewTextChildren.push({ - elemtype: "iconbutton", - icon: "location-dot", - title: `Using Local Model @ ${baseUrl} (${modelName})`, - noAction: true, - }); - } else { - viewTextChildren.push({ - elemtype: "iconbutton", - icon: "globe", - title: `Using Remote Model @ ${baseUrl} (${modelName})`, - noAction: true, - }); - } - } - } - - const dropdownItems = Object.entries(presets) - .sort((a, b) => ((a[1]["display:order"] ?? 0) > (b[1]["display:order"] ?? 0) ? 1 : -1)) - .map( - (preset) => - ({ - label: preset[1]["display:name"], - onClick: () => - fireAndForget(() => - ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { - "ai:preset": preset[0], - }) - ), - }) as MenuItem - ); - dropdownItems.push({ - label: "Add AI preset...", - onClick: () => { - fireAndForget(async () => { - const path = `${getApi().getConfigDir()}/presets/ai.json`; - const blockDef: BlockDef = { - meta: { - view: "preview", - file: path, - }, - }; - await createBlock(blockDef, false, true); - }); - }, - }); - viewTextChildren.push({ - elemtype: "menubutton", - text: presetName, - title: "Select AI Configuration", - items: dropdownItems, - }); - return viewTextChildren; - }); - this.endIconButtons = atom((_) => { - let clearButton: IconButtonDecl = { - elemtype: "iconbutton", - icon: "delete-left", - title: "Clear Chat History", - click: this.clearMessages.bind(this), - }; - return [clearButton]; - }); - } + viewType = "waveai"; + viewIcon = atom("sparkles"); + viewName = atom("Wave AI"); + noPadding = atom(true); + viewComponent = WaveAiDeprecatedView; - get viewComponent(): ViewComponent { - return WaveAi; - } - - dispose() { - DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId)); - } - - async populateMessages(): Promise { - const history = await this.fetchAiData(); - globalStore.set(this.messagesAtom, history.map(promptToMsg)); - } - - async fetchAiData(): Promise> { - const { data } = await fetchWaveFile(this.blockId, "aidata"); - if (!data) { - return []; - } - const history: Array = JSON.parse(new TextDecoder().decode(data)); - return history.slice(Math.max(history.length - slidingWindowSize, 0)); - } - - giveFocus(): boolean { - if (this?.textAreaRef?.current) { - this.textAreaRef.current?.focus(); - return true; - } - return false; - } - - getAiName(): string { - const blockMeta = globalStore.get(this.blockAtom)?.meta ?? {}; - const settings = globalStore.get(atoms.settingsAtom) ?? {}; - const name = blockMeta["ai:name"] ?? settings["ai:name"] ?? null; - return name; - } - - setLocked(locked: boolean) { - globalStore.set(this.locked, locked); - } - - sendMessage(text: string, user: string = "user") { - const clientId = ClientModel.getInstance().clientId; - this.setLocked(true); - - const newMessage: ChatMessageType = { - id: crypto.randomUUID(), - user, - text, - }; - globalStore.set(this.addMessageAtom, newMessage); - // send message to backend and get response - const opts = globalStore.get(this.aiOpts); - const newPrompt: WaveAIPromptMessageType = { - role: "user", - content: text, - }; - const handleAiStreamingResponse = async () => { - const typingMessage: ChatMessageType = { - id: crypto.randomUUID(), - user: "assistant", - text: "", - }; - - // Add a typing indicator - globalStore.set(this.addMessageAtom, typingMessage); - const history = await this.fetchAiData(); - const beMsg: WaveAIStreamRequest = { - clientid: clientId, - opts: opts, - prompt: [...history, newPrompt], - }; - let fullMsg = ""; - try { - const aiGen = RpcApi.StreamWaveAiCommand(TabRpcClient, beMsg, { timeout: opts.timeoutms }); - for await (const msg of aiGen) { - fullMsg += msg.text ?? ""; - globalStore.set(this.updateLastMessageAtom, msg.text ?? "", true); - if (this.cancel) { - break; - } - } - if (fullMsg == "") { - // remove a message if empty - globalStore.set(this.removeLastMessageAtom); - // only save the author's prompt - await BlockService.SaveWaveAiData(this.blockId, [...history, newPrompt]); - } else { - const responsePrompt: WaveAIPromptMessageType = { - role: "assistant", - content: fullMsg, - }; - //mark message as complete - globalStore.set(this.updateLastMessageAtom, "", false); - // save a complete message prompt and response - await BlockService.SaveWaveAiData(this.blockId, [...history, newPrompt, responsePrompt]); - } - } catch (error) { - const updatedHist = [...history, newPrompt]; - if (fullMsg == "") { - globalStore.set(this.removeLastMessageAtom); - } else { - globalStore.set(this.updateLastMessageAtom, "", false); - const responsePrompt: WaveAIPromptMessageType = { - role: "assistant", - content: fullMsg, - }; - updatedHist.push(responsePrompt); - } - const errMsg: string = (error as Error).message; - const errorMessage: ChatMessageType = { - id: crypto.randomUUID(), - user: "error", - text: errMsg, - }; - globalStore.set(this.addMessageAtom, errorMessage); - globalStore.set(this.updateLastMessageAtom, "", false); - const errorPrompt: WaveAIPromptMessageType = { - role: "error", - content: errMsg, - }; - updatedHist.push(errorPrompt); - await BlockService.SaveWaveAiData(this.blockId, updatedHist); - } - this.setLocked(false); - this.cancel = false; - }; - fireAndForget(handleAiStreamingResponse); - } - - useWaveAi() { - return { - sendMessage: this.sendMessage.bind(this) as (text: string) => void, - }; - } - - async clearMessages() { - await BlockService.SaveWaveAiData(this.blockId, []); - globalStore.set(this.messagesAtom, []); - } - - keyDownHandler(waveEvent: WaveKeyboardEvent): boolean { - if (checkKeyPressed(waveEvent, "Cmd:l")) { - fireAndForget(this.clearMessages.bind(this)); - return true; - } - return false; - } -} - -const ChatItem = ({ chatItemAtom, model }: ChatItemProps) => { - const chatItem = useAtomValue(chatItemAtom); - const { user, text } = chatItem; - const fontSize = useAtomValue(model.mergedPresets)?.["ai:fontsize"]; - const fixedFontSize = useAtomValue(model.mergedPresets)?.["ai:fixedfontsize"]; - const renderContent = useMemo(() => { - if (user == "error") { - return ( - <> -
-
- -
-
-
- -
- - ); - } - if (user == "assistant") { - return text ? ( - <> -
-
- -
-
-
- -
- - ) : ( - <> -
- -
- - - ); - } - return ( - <> -
- -
- - ); - }, [text, user, fontSize, fixedFontSize]); - - return
{renderContent}
; -}; - -interface ChatWindowProps { - chatWindowRef: React.RefObject; - msgWidths: object; - model: WaveAiModel; + constructor(_: ViewModelInitType) {} } -const ChatWindow = memo( - forwardRef(({ chatWindowRef, msgWidths, model }, ref) => { - const isUserScrolling = useRef(false); - const osRef = useRef(null); - const splitMessages = useAtomValue(model.messagesSplitAtom) as Atom[]; - const latestMessage = useAtomValue(model.latestMessageAtom); - const prevMessagesLenRef = useRef(splitMessages.length); - - useImperativeHandle(ref, () => osRef.current as OverlayScrollbarsComponentRef); - - const handleNewMessage = useCallback( - throttle(100, (messagesLen: number) => { - if (osRef.current?.osInstance()) { - const { viewport } = osRef.current.osInstance().elements(); - if (prevMessagesLenRef.current !== messagesLen || !isUserScrolling.current) { - viewport.scrollTo({ - behavior: "auto", - top: chatWindowRef.current?.scrollHeight || 0, - }); - } - - prevMessagesLenRef.current = messagesLen; - } - }), - [] - ); - - useEffect(() => { - handleNewMessage(splitMessages.length); - }, [splitMessages, latestMessage]); - - // Wait 300 ms after the user stops scrolling to determine if the user is within 300px of the bottom of the chat window. - // If so, unset the user scrolling flag. - const determineUnsetScroll = useCallback( - debounce(300, () => { - const { viewport } = osRef.current.osInstance().elements(); - if (viewport.scrollTop > chatWindowRef.current?.clientHeight - viewport.clientHeight - 100) { - isUserScrolling.current = false; - } - }), - [] - ); - - const handleUserScroll = useCallback( - throttle(100, () => { - isUserScrolling.current = true; - determineUnsetScroll(); - }), - [] - ); - - useEffect(() => { - if (osRef.current?.osInstance()) { - const { viewport } = osRef.current.osInstance().elements(); - - viewport.addEventListener("wheel", handleUserScroll, { passive: true }); - viewport.addEventListener("touchmove", handleUserScroll, { passive: true }); - - return () => { - viewport.removeEventListener("wheel", handleUserScroll); - viewport.removeEventListener("touchmove", handleUserScroll); - if (osRef.current && osRef.current.osInstance()) { - osRef.current.osInstance().destroy(); - } - }; - } - }, []); - - const handleScrollbarInitialized = (instance: OverlayScrollbars) => { - const { viewport } = instance.elements(); - viewport.removeAttribute("tabindex"); - viewport.scrollTo({ - behavior: "auto", - top: chatWindowRef.current?.scrollHeight || 0, - }); - }; - - const handleScrollbarUpdated = (instance: OverlayScrollbars) => { - const { viewport } = instance.elements(); - viewport.removeAttribute("tabindex"); - }; - - return ( - -
-
- {splitMessages.map((chitem, idx) => ( - - ))} -
-
- ); - }) -); - -interface ChatInputProps { - value: string; - baseFontSize: number; - onChange: (e: React.ChangeEvent) => void; - onKeyDown: (e: React.KeyboardEvent) => void; - onMouseDown: (e: React.MouseEvent) => void; - model: WaveAiModel; -} - -const ChatInput = forwardRef( - ({ value, onChange, onKeyDown, onMouseDown, baseFontSize, model }, ref) => { - const textAreaRef = useRef(null); - - useImperativeHandle(ref, () => textAreaRef.current as HTMLTextAreaElement); - - useEffect(() => { - model.textAreaRef = textAreaRef; - }, []); - - const adjustTextAreaHeight = useCallback( - (value: string) => { - if (textAreaRef.current == null) { - return; - } - - // Adjust the height of the textarea to fit the text - const textAreaMaxLines = 5; - const textAreaLineHeight = baseFontSize * 1.5; - const textAreaMinHeight = textAreaLineHeight; - const textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines; - - if (value === "") { - textAreaRef.current.style.height = `${textAreaLineHeight}px`; - return; - } - - textAreaRef.current.style.height = `${textAreaLineHeight}px`; - const scrollHeight = textAreaRef.current.scrollHeight; - const newHeight = Math.min(Math.max(scrollHeight, textAreaMinHeight), textAreaMaxHeight); - textAreaRef.current.style.height = newHeight + "px"; - }, - [baseFontSize] - ); - - useEffect(() => { - adjustTextAreaHeight(value); - }, [value]); - - return ( - - ); - } -); - -const WaveAi = ({ model }: { model: WaveAiModel; blockId: string }) => { - const { sendMessage } = model.useWaveAi(); - const waveaiRef = useRef(null); - const chatWindowRef = useRef(null); - const osRef = useRef(null); - const inputRef = useRef(null); - - const [value, setValue] = useState(""); - const [selectedBlockIdx, setSelectedBlockIdx] = useState(null); - - const baseFontSize: number = 14; - const msgWidths = {}; - const locked = useAtomValue(model.locked); - const aiOpts = useAtomValue(model.aiOpts); - const isUsingProxy = isBlank(aiOpts.apitoken) && isBlank(aiOpts.baseurl); - - // a weird workaround to initialize ansynchronously - useEffect(() => { - fireAndForget(model.populateMessages.bind(model)); - }, []); - - const handleTextAreaChange = (e: React.ChangeEvent) => { - setValue(e.target.value); - }; - - const updatePreTagOutline = (clickedPre?: HTMLElement | null) => { - const pres = chatWindowRef.current?.querySelectorAll("pre"); - if (!pres) return; - - pres.forEach((preElement, idx) => { - if (preElement === clickedPre) { - setSelectedBlockIdx(idx); - } else { - preElement.style.outline = "none"; - } - }); - - if (clickedPre) { - clickedPre.style.outline = outline; - } - }; - - useEffect(() => { - if (selectedBlockIdx !== null) { - const pres = chatWindowRef.current?.querySelectorAll("pre"); - if (pres && pres[selectedBlockIdx]) { - pres[selectedBlockIdx].style.outline = outline; - } - } - }, [selectedBlockIdx]); - - const handleTextAreaMouseDown = () => { - updatePreTagOutline(); - setSelectedBlockIdx(null); - }; - - const handleEnterKeyPressed = useCallback(() => { - // using globalStore to avoid potential timing problems - // useAtom means the component must rerender once before - // the unlock is detected. this automatically checks on the - // callback firing instead - const locked = globalStore.get(model.locked); - if (locked || value === "") return; - - sendMessage(value); - setValue(""); - setSelectedBlockIdx(null); - }, [value]); - - const updateScrollTop = () => { - const pres = chatWindowRef.current?.querySelectorAll("pre"); - if (!pres || selectedBlockIdx === null) return; - - const block = pres[selectedBlockIdx]; - if (!block || !osRef.current?.osInstance()) return; - - const { viewport, scrollOffsetElement } = osRef.current.osInstance().elements(); - const chatWindowTop = scrollOffsetElement.scrollTop; - const chatWindowHeight = chatWindowRef.current.clientHeight; - const chatWindowBottom = chatWindowTop + chatWindowHeight; - const elemTop = block.offsetTop; - const elemBottom = elemTop + block.offsetHeight; - const elementIsInView = elemBottom <= chatWindowBottom && elemTop >= chatWindowTop; - - if (!elementIsInView) { - let scrollPosition; - if (elemBottom > chatWindowBottom) { - scrollPosition = elemTop - chatWindowHeight + block.offsetHeight + 15; - } else if (elemTop < chatWindowTop) { - scrollPosition = elemTop - 15; - } - viewport.scrollTo({ - behavior: "auto", - top: scrollPosition, - }); - } - }; - - const shouldSelectCodeBlock = (key: "ArrowUp" | "ArrowDown") => { - const textarea = inputRef.current; - const cursorPosition = textarea?.selectionStart || 0; - const textBeforeCursor = textarea?.value.slice(0, cursorPosition) || ""; - - return ( - (textBeforeCursor.indexOf("\n") === -1 && cursorPosition === 0 && key === "ArrowUp") || - selectedBlockIdx !== null - ); - }; - - const handleArrowUpPressed = (e: React.KeyboardEvent) => { - if (shouldSelectCodeBlock("ArrowUp")) { - e.preventDefault(); - const pres = chatWindowRef.current?.querySelectorAll("pre"); - let blockIndex = selectedBlockIdx; - if (!pres) return; - if (blockIndex === null) { - setSelectedBlockIdx(pres.length - 1); - } else if (blockIndex > 0) { - blockIndex--; - setSelectedBlockIdx(blockIndex); - } - updateScrollTop(); - } - }; - - const handleArrowDownPressed = (e: React.KeyboardEvent) => { - if (shouldSelectCodeBlock("ArrowDown")) { - e.preventDefault(); - const pres = chatWindowRef.current?.querySelectorAll("pre"); - let blockIndex = selectedBlockIdx; - if (!pres) return; - if (blockIndex === null) return; - if (blockIndex < pres.length - 1 && blockIndex >= 0) { - setSelectedBlockIdx(++blockIndex); - updateScrollTop(); - } else { - inputRef.current.focus(); - setSelectedBlockIdx(null); - } - updateScrollTop(); - } - }; - - const handleTextAreaKeyDown = (e: React.KeyboardEvent) => { - const waveEvent = adaptFromReactOrNativeKeyEvent(e); - if (checkKeyPressed(waveEvent, "Enter")) { - e.preventDefault(); - handleEnterKeyPressed(); - } else if (checkKeyPressed(waveEvent, "ArrowUp")) { - handleArrowUpPressed(e); - } else if (checkKeyPressed(waveEvent, "ArrowDown")) { - handleArrowDownPressed(e); - } - }; - - let buttonClass = "waveai-submit-button"; - let buttonIcon = makeIconClass("arrow-up", false); - let buttonTitle = "run"; - if (locked) { - buttonClass = "waveai-submit-button stop"; - buttonIcon = makeIconClass("stop", false); - buttonTitle = "stop"; - } - const handleButtonPress = useCallback(() => { - if (locked) { - model.cancel = true; - } else { - handleEnterKeyPressed(); - } - }, [locked, handleEnterKeyPressed]); - +function WaveAiDeprecatedView() { const handleOpenAIPanel = useCallback(() => { WorkspaceLayoutModel.getInstance().setAIPanelVisible(true); }, []); return ( -
- {isUsingProxy && ( -
- - - Wave AI Proxy is deprecated and will be removed. Please use the new{" "} - {" "} - instead (better model, terminal integration, tool support, image uploads). - -
- )} -
- -
-
-
- -
-
+
); -}; - -export { WaveAi }; +} diff --git a/frontend/app/workspace/widgets.tsx b/frontend/app/workspace/widgets.tsx index f3043e6d98..f11eca91da 100644 --- a/frontend/app/workspace/widgets.tsx +++ b/frontend/app/workspace/widgets.tsx @@ -373,7 +373,6 @@ const Widgets = memo(() => { const fullConfig = useAtomValue(env.atoms.fullConfigAtom); const hasConfigErrors = useAtomValue(env.atoms.hasConfigErrors); const workspaceId = useAtomValue(env.atoms.workspaceId); - const hasCustomAIPresets = useAtomValue(env.atoms.hasCustomAIPresetsAtom); const [mode, setMode] = useState<"normal" | "compact" | "supercompact">("normal"); const containerRef = useRef(null); const measurementRef = useRef(null); @@ -381,12 +380,7 @@ const Widgets = memo(() => { const featureWaveAppBuilder = fullConfig?.settings?.["feature:waveappbuilder"] ?? false; const widgetsMap = fullConfig?.widgets ?? {}; const filteredWidgets = Object.fromEntries( - Object.entries(widgetsMap).filter(([key, widget]) => { - if (!hasCustomAIPresets && key === "defwidget@ai") { - return false; - } - return shouldIncludeWidgetForWorkspace(widget, workspaceId); - }) + Object.entries(widgetsMap).filter(([_key, widget]) => shouldIncludeWidgetForWorkspace(widget, workspaceId)) ); const widgets = sortByDisplayOrder(filteredWidgets); diff --git a/frontend/preview/previews/waveai.preview.tsx b/frontend/preview/previews/waveai.preview.tsx new file mode 100644 index 0000000000..1d5003f0d4 --- /dev/null +++ b/frontend/preview/previews/waveai.preview.tsx @@ -0,0 +1,53 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Block } from "@/app/block/block"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; +import * as React from "react"; +import { makeMockNodeModel } from "../mock/mock-node-model"; + +const PreviewNodeId = "preview-waveai-node"; + +export default function WaveAIPreview() { + const env = useWaveEnv(); + const [blockId, setBlockId] = React.useState(null); + + React.useEffect(() => { + env.createBlock( + { + meta: { + view: "waveai", + }, + }, + false, + false + ).then((id) => setBlockId(id)); + }, [env]); + + const nodeModel = React.useMemo( + () => + blockId == null + ? null + : makeMockNodeModel({ + nodeId: PreviewNodeId, + blockId, + innerRect: { width: "900px", height: "480px" }, + }), + [blockId] + ); + + if (blockId == null || nodeModel == null) { + return null; + } + + return ( +
+
full deprecated waveai block with the FE-only replacement UI
+
+
+ +
+
+
+ ); +} diff --git a/frontend/preview/previews/widgets.preview.tsx b/frontend/preview/previews/widgets.preview.tsx index 440ae03a6a..4b82314510 100644 --- a/frontend/preview/previews/widgets.preview.tsx +++ b/frontend/preview/previews/widgets.preview.tsx @@ -59,20 +59,12 @@ const mockWidgets: { [key: string]: WidgetConfigType } = { "display:order": 2, blockdef: { meta: { view: "web", url: "https://waveterm.dev" } }, }, - "defwidget@ai": { - icon: "sparkles", - color: "#a78bfa", - label: "AI", - description: "Open Wave AI", - "display:order": 3, - blockdef: { meta: { view: "waveai" } }, - }, "defwidget@files": { icon: "folder", color: "#fbbf24", label: "Files", description: "Open file browser", - "display:order": 4, + "display:order": 3, blockdef: { meta: { view: "preview", connection: "local" } }, }, "defwidget@sysinfo": { @@ -80,7 +72,7 @@ const mockWidgets: { [key: string]: WidgetConfigType } = { color: "#34d399", label: "Sysinfo", description: "Open system info", - "display:order": 5, + "display:order": 4, blockdef: { meta: { view: "sysinfo" } }, }, }; @@ -90,7 +82,6 @@ const fullConfigAtom = atom({ settings: {}, widgets: mockWidgets function makeWidgetsEnv( baseEnv: WaveEnv, isDev: boolean, - hasCustomAIPresets: boolean, apps?: AppInfo[], atomOverrides?: Partial ) { @@ -99,7 +90,6 @@ function makeWidgetsEnv( rpc: { ListAllAppsCommand: () => Promise.resolve(apps ?? []) }, atoms: { fullConfigAtom, - hasCustomAIPresetsAtom: atom(hasCustomAIPresets), ...atomOverrides, }, }); @@ -108,20 +98,18 @@ function makeWidgetsEnv( function WidgetsScenario({ label, isDev = false, - hasCustomAIPresets = true, height, apps, }: { label: string; isDev?: boolean; - hasCustomAIPresets?: boolean; height?: number; apps?: AppInfo[]; }) { const baseEnv = useWaveEnv(); const envRef = useRef(null); if (envRef.current == null) { - envRef.current = makeWidgetsEnv(baseEnv, isDev, hasCustomAIPresets, apps, { + envRef.current = makeWidgetsEnv(baseEnv, isDev, apps, { hasConfigErrors: hasConfigErrorsAtom, }); } @@ -149,7 +137,7 @@ function WidgetsResizable({ isDev }: { isDev: boolean }) { const baseEnv = useWaveEnv(); const envRef = useRef(null); if (envRef.current == null) { - envRef.current = makeWidgetsEnv(baseEnv, isDev, true, mockApps, { hasConfigErrors: hasConfigErrorsAtom }); + envRef.current = makeWidgetsEnv(baseEnv, isDev, mockApps, { hasConfigErrors: hasConfigErrorsAtom }); } return ( @@ -224,8 +212,7 @@ export function WidgetsPreview() {
- - +
diff --git a/package-lock.json b/package-lock.json index 5a528967b7..00bae215eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.14.3", + "version": "0.14.4-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.14.3", + "version": "0.14.4-beta.1", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -84,7 +84,7 @@ "uuid": "^13.0.0", "winston": "^3.19.0", "ws": "^8.19.0", - "yaml": "^2.7.1" + "yaml": "^2.8.3" }, "devDependencies": { "@eslint/js": "^9.39", @@ -3880,9 +3880,9 @@ } }, "node_modules/@docusaurus/core/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -5434,32 +5434,32 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.10" + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.3", - "@floating-ui/utils": "^0.2.10" + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/react": { - "version": "0.27.16", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.16.tgz", - "integrity": "sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==", + "version": "0.27.19", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz", + "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", "license": "MIT", "dependencies": { - "@floating-ui/react-dom": "^2.1.6", - "@floating-ui/utils": "^0.2.10", + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", "tabbable": "^6.0.0" }, "peerDependencies": { @@ -5468,12 +5468,12 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", - "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.4" + "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", @@ -5481,9 +5481,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, "node_modules/@hapi/hoek": { @@ -11078,9 +11078,9 @@ } }, "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -15182,9 +15182,9 @@ "license": "MIT" }, "node_modules/emoji-regex": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", - "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, "node_modules/emojilib": { @@ -15353,9 +15353,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.39.10", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz", - "integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==", + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", "license": "MIT", "workspaces": [ "docs", @@ -18764,9 +18764,9 @@ } }, "node_modules/jest-util/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -18948,9 +18948,9 @@ } }, "node_modules/katex": { - "version": "0.16.22", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", - "integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==", + "version": "0.16.38", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.38.tgz", + "integrity": "sha512-cjHooZUmIAUmDsHBN+1n8LaZdpmbj03LtYeYPyuYB7OuloiaeaV6N4LcfjcnHVzGWjVQmKrxxTrpDcmSzEZQwQ==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -22358,9 +22358,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -24055,9 +24055,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -29918,13 +29918,13 @@ } }, "node_modules/swr": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz", - "integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz", + "integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==", "license": "MIT", "dependencies": { "dequal": "^2.0.3", - "use-sync-external-store": "^1.4.0" + "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -29947,9 +29947,9 @@ } }, "node_modules/tabbable": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", - "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", "license": "MIT" }, "node_modules/tailwind-merge": { @@ -31342,9 +31342,9 @@ } }, "node_modules/unified-args/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -31867,9 +31867,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -32824,9 +32824,9 @@ } }, "node_modules/webpack-dev-server/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -33310,15 +33310,18 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yargs": { diff --git a/package.json b/package.json index 9ed6d6b2c3..2b68fef8a7 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.0", + "version": "0.14.4-beta.2", "homepage": "https://waveterm.dev", "build": { "appId": "dev.commandline.waveterm" @@ -144,7 +144,7 @@ "uuid": "^13.0.0", "winston": "^3.19.0", "ws": "^8.19.0", - "yaml": "^2.7.1" + "yaml": "^2.8.3" }, "packageManager": "npm@10.9.2", "workspaces": [ diff --git a/pkg/blockcontroller/durableshellcontroller.go b/pkg/blockcontroller/durableshellcontroller.go index d3481b172d..a21dac153b 100644 --- a/pkg/blockcontroller/durableshellcontroller.go +++ b/pkg/blockcontroller/durableshellcontroller.go @@ -7,11 +7,13 @@ import ( "context" "encoding/base64" "fmt" + "io/fs" "log" "sync" "time" "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/jobcontroller" "github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" @@ -163,6 +165,10 @@ func (dsc *DurableShellController) Start(ctx context.Context, blockMeta waveobj. if jobId == "" { log.Printf("block %q starting new durable shell\n", dsc.BlockId) + fsErr := filestore.WFS.MakeFile(ctx, dsc.BlockId, wavebase.BlockFile_Term, nil, wshrpc.FileOpts{MaxSize: DefaultTermMaxFileSize, Circular: true}) + if fsErr != nil && fsErr != fs.ErrExist { + return fmt.Errorf("error creating block term file: %w", fsErr) + } newJobId, err := dsc.startNewJob(ctx, blockMeta, dsc.ConnName, rtOpts) if err != nil { return fmt.Errorf("failed to start new job: %w", err) diff --git a/pkg/remote/fileshare/fspath/fspath_test.go b/pkg/remote/fileshare/fspath/fspath_test.go new file mode 100644 index 0000000000..c634f665ce --- /dev/null +++ b/pkg/remote/fileshare/fspath/fspath_test.go @@ -0,0 +1,21 @@ +package fspath + +import "testing" + +func TestBase(t *testing.T) { + tests := []struct { + path string + want string + }{ + {`D:\package\AA.tar`, "AA.tar"}, + {`D:/package/AA.tar`, "AA.tar"}, + {"/home/user/file.txt", "file.txt"}, + {"file.txt", "file.txt"}, + } + for _, tt := range tests { + got := Base(tt.path) + if got != tt.want { + t.Errorf("Base(%q) = %q, want %q", tt.path, got, tt.want) + } + } +} diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go index 4850eee1b8..35af5446a3 100644 --- a/pkg/shellexec/shellexec.go +++ b/pkg/shellexec/shellexec.go @@ -760,5 +760,11 @@ func tryGetPamEnvVars() map[string]string { if runtime_dir, ok := envVars["XDG_RUNTIME_DIR"]; !ok || runtime_dir == "" { envVars["XDG_RUNTIME_DIR"] = "/run/user/" + fmt.Sprint(os.Getuid()) } + if configDirs, ok := envVars["XDG_CONFIG_DIRS"]; !ok || configDirs == "" { + envVars["XDG_CONFIG_DIRS"] = "/etc/xdg" + } + if dataDirs, ok := envVars["XDG_DATA_DIRS"]; !ok || dataDirs == "" { + envVars["XDG_DATA_DIRS"] = "/usr/local/share:/usr/share" + } return envVars } diff --git a/pkg/util/shellutil/shellintegration/pwsh_wavepwsh.sh b/pkg/util/shellutil/shellintegration/pwsh_wavepwsh.sh index cc002b57a9..6fd70eee14 100644 --- a/pkg/util/shellutil/shellintegration/pwsh_wavepwsh.sh +++ b/pkg/util/shellutil/shellintegration/pwsh_wavepwsh.sh @@ -16,6 +16,10 @@ if ($PSVersionTable.PSVersion.Major -lt 7) { return # skip OSC setup entirely } +if ($PSStyle.FileInfo.Directory -eq "`e[44;1m") { + $PSStyle.FileInfo.Directory = "`e[34;1m" +} + $Global:_WAVETERM_SI_FIRSTPROMPT = $true # shell integration diff --git a/pkg/wconfig/defaultconfig/widgets.json b/pkg/wconfig/defaultconfig/widgets.json index 97a3d26c10..2d0524b7dd 100644 --- a/pkg/wconfig/defaultconfig/widgets.json +++ b/pkg/wconfig/defaultconfig/widgets.json @@ -31,18 +31,8 @@ } } }, - "defwidget@ai": { - "display:order": -2, - "icon": "sparkles", - "label": "ai", - "blockdef": { - "meta": { - "view": "waveai" - } - } - }, "defwidget@sysinfo": { - "display:order": -1, + "display:order": -2, "icon": "chart-line", "label": "sysinfo", "blockdef": { diff --git a/pkg/wshrpc/wshremote/wshremote_file.go b/pkg/wshrpc/wshremote/wshremote_file.go index 845fb64ac4..3589cc998c 100644 --- a/pkg/wshrpc/wshremote/wshremote_file.go +++ b/pkg/wshrpc/wshremote/wshremote_file.go @@ -18,6 +18,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/remote/connparse" + "github.com/wavetermdev/waveterm/pkg/remote/fileshare/fspath" "github.com/wavetermdev/waveterm/pkg/remote/fileshare/wshfs" "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" @@ -156,7 +157,7 @@ func (impl *ServerImpl) RemoteFileCopyCommand(ctx context.Context, data wshrpc.C return false, fmt.Errorf("file %q size %d exceeds transfer limit of %d bytes", data.SrcUri, srcFileInfo.Size, RemoteFileTransferSizeLimit) } - destFilePath, err := prepareDestForCopy(destPathCleaned, filepath.Base(srcConn.Path), destHasSlash, opts.Overwrite) + destFilePath, err := prepareDestForCopy(destPathCleaned, fspath.Base(srcConn.Path), destHasSlash, opts.Overwrite) if err != nil { return false, err }