diff --git a/cmd/wsh/cmd/wshcmd-editconfig.go b/cmd/wsh/cmd/wshcmd-editconfig.go index 19785f5d0d..2adf1b7647 100644 --- a/cmd/wsh/cmd/wshcmd-editconfig.go +++ b/cmd/wsh/cmd/wshcmd-editconfig.go @@ -13,6 +13,8 @@ import ( "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) +var editConfigMagnified bool + var editConfigCmd = &cobra.Command{ Use: "editconfig [configfile]", Short: "edit Wave configuration files", @@ -23,6 +25,7 @@ var editConfigCmd = &cobra.Command{ } func init() { + editConfigCmd.Flags().BoolVarP(&editConfigMagnified, "magnified", "m", false, "open config in magnified mode") rootCmd.AddCommand(editConfigCmd) } @@ -52,7 +55,8 @@ func editConfigRun(cmd *cobra.Command, args []string) (rtnErr error) { waveobj.MetaKey_Edit: true, }, }, - Focused: true, + Magnified: editConfigMagnified, + Focused: true, } _, err = RpcClient.SendRpcRequest(wshrpc.Command_CreateBlock, wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) diff --git a/emain/emain-window.ts b/emain/emain-window.ts index c008e6d370..4ceb6782d4 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -26,8 +26,8 @@ export type WindowOpts = { unamePlatform: string; }; -const MIN_WINDOW_WIDTH = 600; -const MIN_WINDOW_HEIGHT = 350; +const MIN_WINDOW_WIDTH = 800; +const MIN_WINDOW_HEIGHT = 500; export const waveWindowMap = new Map(); // waveWindowId -> WaveBrowserWindow diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index d92c029130..146f0c72f6 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -4,12 +4,14 @@ import { WaveUIMessagePart } from "@/app/aipanel/aitypes"; import { waveAIHasSelection } from "@/app/aipanel/waveai-focus-utils"; import { ErrorBoundary } from "@/app/element/errorboundary"; +import { ContextMenuModel } from "@/app/store/contextmenu"; import { focusManager } from "@/app/store/focusManager"; import { atoms, getSettingsKeyAtom } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { getWebServerEndpoint } from "@/util/endpoints"; import { checkKeyPressed, keydownWrapper } from "@/util/keyutil"; +import { isMacOS } from "@/util/platformutil"; import { cn } from "@/util/util"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; @@ -18,26 +20,186 @@ import { memo, useCallback, useEffect, useRef, useState } from "react"; import { createDataUrl, formatFileSizeError, isAcceptableFile, normalizeMimeType, validateFileSize } from "./ai-utils"; import { AIDroppedFiles } from "./aidroppedfiles"; import { AIPanelHeader } from "./aipanelheader"; -import { AIPanelInput, type AIPanelInputRef } from "./aipanelinput"; +import { AIPanelInput } from "./aipanelinput"; import { AIPanelMessages } from "./aipanelmessages"; import { AIRateLimitStrip } from "./airatelimitstrip"; import { TelemetryRequiredMessage } from "./telemetryrequired"; import { WaveAIModel, type DroppedFile } from "./waveai-model"; +const AIBlockMask = memo(() => { + return ( +
+
+
0
+
+
+ ); +}); + +AIBlockMask.displayName = "AIBlockMask"; + +const AIDragOverlay = memo(() => { + return ( +
+
+ +
Drop files here
+
Images, PDFs, and text/code files supported
+
+
+ ); +}); + +AIDragOverlay.displayName = "AIDragOverlay"; + +const KeyCap = memo(({ children, className }: { children: React.ReactNode; className?: string }) => { + return ( + + {children} + + ); +}); + +KeyCap.displayName = "KeyCap"; + +const AIWelcomeMessage = memo(() => { + const modKey = isMacOS() ? "⌘" : "Alt"; + return ( +
+
+ +

Welcome to Wave AI

+
+
+

+ Wave AI is your terminal assistant with context. I can read your terminal output, analyze widgets, + access files, and help you solve problems faster. +

+
+
Getting Started:
+
+
+
+ +
+
+ Widget Context +
When ON, I can read your terminal and analyze widgets.
+
When OFF, I'm sandboxed with no system access.
+
+
+
+
+ +
+
Drag & drop files or images for analysis
+
+
+
+ +
+
+
+ {modKey} + K + to start a new chat +
+
+ {modKey} + Shift + A + to toggle panel +
+
+ Ctrl + Shift + 0 + to focus +
+
+
+
+
+ +
+
+ Questions or feedback?{" "} + + Join our Discord + +
+
+
+
+
+ (BETA: 50 free requests daily) +
+
+
+ ); +}); + +AIWelcomeMessage.displayName = "AIWelcomeMessage"; + +interface AIErrorMessageProps { + errorMessage: string; + onClear: () => void; +} + +const AIErrorMessage = memo(({ errorMessage, onClear }: AIErrorMessageProps) => { + return ( +
+ +
{errorMessage}
+
+ ); +}); + +AIErrorMessage.displayName = "AIErrorMessage"; + interface AIPanelProps { className?: string; onClose?: () => void; } const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { - const [input, setInput] = useState(""); const [isDragOver, setIsDragOver] = useState(false); const [isLoadingChat, setIsLoadingChat] = useState(true); const model = WaveAIModel.getInstance(); const containerRef = useRef(null); const errorMessage = jotai.useAtomValue(model.errorMessage); const realMessageRef = useRef(null); - const inputRef = useRef(null); const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom); const showOverlayBlockNums = jotai.useAtomValue(getSettingsKeyAtom("app:showoverlayblocknums")) ?? true; const focusType = jotai.useAtomValue(focusManager.focusType); @@ -96,10 +258,6 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { }; }, []); - useEffect(() => { - model.registerInputRef(inputRef); - }, [model]); - useEffect(() => { const loadMessages = async () => { const messages = await model.loadChat(); @@ -137,11 +295,12 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + const input = globalStore.get(model.inputAtom); if (!input.trim() || status !== "ready" || isLoadingChat) return; if (input.trim() === "/clear" || input.trim() === "/new") { clearChat(); - setInput(""); + globalStore.set(model.inputAtom, ""); return; } @@ -195,13 +354,11 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { // sendMessage uses UIMessageParts sendMessage({ parts: uiMessageParts }); - setInput(""); + globalStore.set(model.inputAtom, ""); model.clearFiles(); - // Keep focus on input after submission setTimeout(() => { - console.log("trying to reset focus", inputRef.current); - inputRef.current?.focus(); + model.focusInput(); }, 100); }; @@ -298,6 +455,39 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { }, 0); }; + const handleMessagesContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const menu: ContextMenuItem[] = []; + + const hasSelection = waveAIHasSelection(); + if (hasSelection) { + menu.push({ + role: "copy", + }); + menu.push({ type: "separator" }); + } + + menu.push({ + label: "New Chat", + click: () => { + clearChat(); + }, + }); + + menu.push({ type: "separator" }); + + menu.push({ + label: "Hide Wave AI", + click: () => { + onClose?.(); + }, + }); + + ContextMenuModel.showContextMenu(menu, e); + }; + const showBlockMask = isLayoutMode && showOverlayBlockNums; return ( @@ -323,37 +513,8 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { onClick={handleClick} inert={!isPanelVisible ? true : undefined} > - {isDragOver && ( -
-
- -
Drop files here
-
Images, PDFs, and text/code files supported
-
-
- )} - {showBlockMask && ( -
-
-
0
-
-
- )} + {isDragOver && } + {showBlockMask && } @@ -362,28 +523,20 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => { ) : ( <> - - {errorMessage && ( -
- -
{errorMessage}
+ {messages.length === 0 && !isLoadingChat ? ( +
+ +
+ ) : ( +
+
)} + {errorMessage && ( + model.clearError()} /> + )} - + )}
diff --git a/frontend/app/aipanel/aipanelheader.tsx b/frontend/app/aipanel/aipanelheader.tsx index 6d2d63c557..b0e87e1cc3 100644 --- a/frontend/app/aipanel/aipanelheader.tsx +++ b/frontend/app/aipanel/aipanelheader.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { ContextMenuModel } from "@/app/store/contextmenu"; -import { useAtom, useAtomValue } from "jotai"; +import { useAtomValue } from "jotai"; import { memo } from "react"; import { WaveAIModel } from "./waveai-model"; @@ -14,18 +14,6 @@ interface AIPanelHeaderProps { export const AIPanelHeader = memo(({ onClose, model, onClearChat }: AIPanelHeaderProps) => { const widgetAccess = useAtomValue(model.widgetAccessAtom); - const currentModel = useAtomValue(model.modelAtom); - - const modelOptions = [ - { value: "gpt-5", label: "GPT-5" }, - { value: "gpt-5-mini", label: "GPT-5 Mini" }, - { value: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" }, - ]; - - const getModelLabel = (modelValue: string): string => { - const option = modelOptions.find((opt) => opt.value === modelValue); - return option?.label ?? modelValue; - }; const handleKebabClick = (e: React.MouseEvent) => { const menu: ContextMenuItem[] = [ @@ -36,18 +24,6 @@ export const AIPanelHeader = memo(({ onClose, model, onClearChat }: AIPanelHeade }, }, { type: "separator" }, - { - label: `Model (${getModelLabel(currentModel)})`, - submenu: modelOptions.map((option) => ({ - label: option.label, - type: currentModel === option.value ? "checkbox" : undefined, - checked: currentModel === option.value, - click: () => { - model.setModel(option.value); - }, - })), - }, - { type: "separator" }, { label: "Hide Wave AI", click: () => { diff --git a/frontend/app/aipanel/aipanelinput.tsx b/frontend/app/aipanel/aipanelinput.tsx index 23bcac3238..10ad616b65 100644 --- a/frontend/app/aipanel/aipanelinput.tsx +++ b/frontend/app/aipanel/aipanelinput.tsx @@ -4,16 +4,13 @@ import { formatFileSizeError, isAcceptableFile, validateFileSize } from "@/app/aipanel/ai-utils"; import { waveAIHasFocusWithin } from "@/app/aipanel/waveai-focus-utils"; import { type WaveAIModel } from "@/app/aipanel/waveai-model"; -import { atoms, globalStore } from "@/app/store/global"; import { focusManager } from "@/app/store/focusManager"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { cn } from "@/util/util"; -import { useAtomValue } from "jotai"; -import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef } from "react"; +import { useAtom, useAtomValue } from "jotai"; +import { memo, useCallback, useEffect, useRef } from "react"; interface AIPanelInputProps { - input: string; - setInput: (value: string) => void; onSubmit: (e: React.FormEvent) => void; status: string; model: WaveAIModel; @@ -24,152 +21,154 @@ export interface AIPanelInputRef { resize: () => void; } -export const AIPanelInput = memo( - forwardRef(({ input, setInput, onSubmit, status, model }, ref) => { - const focusType = useAtomValue(focusManager.focusType); - const isFocused = focusType === "waveai"; - const textareaRef = useRef(null); - const fileInputRef = useRef(null); - const isPanelOpen = useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom); - - const resizeTextarea = useCallback(() => { - const textarea = textareaRef.current; - if (!textarea) return; - - textarea.style.height = "auto"; - const scrollHeight = textarea.scrollHeight; - const maxHeight = 6 * 24; - textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`; - }, []); - - useImperativeHandle(ref, () => ({ - focus: () => { - textareaRef.current?.focus(); +export const AIPanelInput = memo(({ onSubmit, status, model }: AIPanelInputProps) => { + const [input, setInput] = useAtom(model.inputAtom); + const focusType = useAtomValue(focusManager.focusType); + const isFocused = focusType === "waveai"; + const textareaRef = useRef(null); + const fileInputRef = useRef(null); + const isPanelOpen = useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom); + + const resizeTextarea = useCallback(() => { + const textarea = textareaRef.current; + if (!textarea) return; + + textarea.style.height = "auto"; + const scrollHeight = textarea.scrollHeight; + const maxHeight = 7 * 24; + textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`; + }, []); + + useEffect(() => { + const inputRefObject: React.RefObject = { + current: { + focus: () => { + textareaRef.current?.focus(); + }, + resize: resizeTextarea, }, - resize: resizeTextarea, - })); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - onSubmit(e as any); - } }; - - const handleFocus = useCallback(() => { - focusManager.requestWaveAIFocus(); - }, []); - - const handleBlur = useCallback((e: React.FocusEvent) => { - if (e.relatedTarget === null) { - return; - } - - if (waveAIHasFocusWithin()) { - return; - } - - focusManager.requestNodeFocus(); - }, []); - - useEffect(() => { + model.registerInputRef(inputRefObject); + }, [model, resizeTextarea]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + onSubmit(e as any); + } + }; + + const handleFocus = useCallback(() => { + focusManager.requestWaveAIFocus(); + }, []); + + const handleBlur = useCallback((e: React.FocusEvent) => { + if (e.relatedTarget === null) { + return; + } + + if (waveAIHasFocusWithin(e.relatedTarget)) { + return; + } + + focusManager.requestNodeFocus(); + }, []); + + useEffect(() => { + resizeTextarea(); + }, [input, resizeTextarea]); + + useEffect(() => { + if (isPanelOpen) { resizeTextarea(); - }, [input, resizeTextarea]); - - useEffect(() => { - if (isPanelOpen) { - resizeTextarea(); - } - }, [isPanelOpen, resizeTextarea]); - - const handleUploadClick = () => { - fileInputRef.current?.click(); - }; - - const handleFileChange = async (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []); - const acceptableFiles = files.filter(isAcceptableFile); - - for (const file of acceptableFiles) { - const sizeError = validateFileSize(file); - if (sizeError) { - model.setError(formatFileSizeError(sizeError)); - if (e.target) { - e.target.value = ""; - } - return; + } + }, [isPanelOpen, resizeTextarea]); + + const handleUploadClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + const acceptableFiles = files.filter(isAcceptableFile); + + for (const file of acceptableFiles) { + const sizeError = validateFileSize(file); + if (sizeError) { + model.setError(formatFileSizeError(sizeError)); + if (e.target) { + e.target.value = ""; } - await model.addFile(file); - } - - if (acceptableFiles.length < files.length) { - console.warn( - `${files.length - acceptableFiles.length} files were rejected due to unsupported file types` - ); - } - - if (e.target) { - e.target.value = ""; + return; } - }; - - return ( -
- -
-
-