-
-
Notifications
You must be signed in to change notification settings - Fork 839
Test pr 1 rebased #3086
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Test pr 1 rebased #3086
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| // Copyright 2026, Command Line Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { IconButton } from "@/app/element/iconbutton"; | ||
| import { useAtomValue } from "jotai"; | ||
| import { memo, useEffect, useState } from "react"; | ||
| import { SavedCommand } from "./aitypes"; | ||
| import { WaveAIModel } from "./waveai-model"; | ||
|
|
||
| const formatCommandPreview = (text: string): string => { | ||
| const firstLine = text.trim().split("\n")[0] ?? ""; | ||
| return firstLine.length > 72 ? `${firstLine.slice(0, 69)}...` : firstLine; | ||
| }; | ||
|
|
||
| const SavedCommandCard = memo(({ command }: { command: SavedCommand }) => { | ||
| const model = WaveAIModel.getInstance(); | ||
|
|
||
| return ( | ||
| <div className="rounded-md border border-white/10 bg-black/30 p-2"> | ||
| <div className="mb-2 flex items-center justify-between gap-3"> | ||
| <div className="truncate text-[11px] uppercase tracking-[0.18em] text-white/45"> | ||
| {formatCommandPreview(command.text || "Untitled command")} | ||
| </div> | ||
| <div className="flex items-center gap-1"> | ||
| <IconButton | ||
| decl={{ | ||
| elemtype: "iconbutton", | ||
| icon: "regular@square-terminal", | ||
| title: "Run in focused terminal", | ||
| click: () => void model.runSavedCommand(command.text), | ||
| disabled: command.text.trim().length === 0, | ||
| }} | ||
| /> | ||
| <IconButton | ||
| decl={{ | ||
| elemtype: "iconbutton", | ||
| icon: "plus", | ||
| title: "Insert into prompt", | ||
| click: () => model.appendText(command.text, true, { scrollToBottom: true }), | ||
| disabled: command.text.trim().length === 0, | ||
| }} | ||
| /> | ||
| <IconButton | ||
| decl={{ | ||
| elemtype: "iconbutton", | ||
| icon: "trash", | ||
| title: "Remove saved command", | ||
| click: () => model.removeSavedCommand(command.id), | ||
| }} | ||
| /> | ||
| </div> | ||
| </div> | ||
| <textarea | ||
| value={command.text} | ||
| onChange={(e) => model.updateSavedCommand(command.id, e.target.value)} | ||
| spellCheck={false} | ||
| rows={Math.min(Math.max(command.text.split("\n").length || 1, 2), 6)} | ||
| placeholder="Enter a command..." | ||
| className="w-full resize-y rounded-md border border-white/10 bg-zinc-900 px-2 py-2 font-mono text-xs text-primary outline-none focus:border-accent" | ||
| /> | ||
| </div> | ||
| ); | ||
| }); | ||
|
|
||
| SavedCommandCard.displayName = "SavedCommandCard"; | ||
|
|
||
| export const SavedCommandsPanel = memo(() => { | ||
| const model = WaveAIModel.getInstance(); | ||
| const commands = useAtomValue(model.savedCommandsAtom); | ||
| const [isOpen, setIsOpen] = useState(commands.length > 0); | ||
|
|
||
| useEffect(() => { | ||
| if (commands.length > 0) { | ||
| setIsOpen(true); | ||
| } | ||
| }, [commands.length]); | ||
|
|
||
| return ( | ||
| <div className="mx-2 mb-2 rounded-lg border border-white/10 bg-zinc-950/70"> | ||
| <button | ||
| className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left cursor-pointer" | ||
| onClick={() => setIsOpen(!isOpen)} | ||
| > | ||
| <div className="flex items-center gap-2 min-w-0"> | ||
| <i | ||
| className={`fa-solid ${isOpen ? "fa-chevron-down" : "fa-chevron-right"} text-[11px] text-white/60`} | ||
| /> | ||
| <span className="text-sm font-medium text-primary">Saved Commands</span> | ||
| <span className="rounded-full bg-white/8 px-2 py-0.5 text-[11px] text-secondary"> | ||
| {commands.length} | ||
| </span> | ||
| </div> | ||
| <div className="text-[11px] text-secondary">Reusable command snippets</div> | ||
| </button> | ||
| {isOpen && ( | ||
| <div className="border-t border-white/10 px-3 py-3"> | ||
| <div className="mb-3 flex items-center justify-between gap-3"> | ||
| <div className="text-xs text-secondary"> | ||
| Save shell commands from AI replies, edit them here, and insert them back into the prompt. | ||
| </div> | ||
| <button | ||
| className="rounded-md border border-white/10 px-2.5 py-1 text-xs text-primary hover:bg-white/5 cursor-pointer" | ||
| onClick={() => model.addSavedCommand("")} | ||
| > | ||
| Add Command | ||
| </button> | ||
| </div> | ||
| {commands.length === 0 ? ( | ||
| <div className="rounded-md border border-dashed border-white/10 px-3 py-4 text-sm text-secondary"> | ||
| No saved commands yet. | ||
| </div> | ||
| ) : ( | ||
| <div className="flex max-h-56 flex-col gap-3 overflow-y-auto pr-1"> | ||
| {commands.map((command) => ( | ||
| <SavedCommandCard key={command.id} command={command} /> | ||
| ))} | ||
| </div> | ||
| )} | ||
| </div> | ||
| )} | ||
| </div> | ||
| ); | ||
| }); | ||
|
|
||
| SavedCommandsPanel.displayName = "SavedCommandsPanel"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,13 +2,21 @@ | |
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { | ||
| SavedCommand, | ||
| UseChatSendMessageType, | ||
| UseChatSetMessagesType, | ||
| WaveUIMessage, | ||
| WaveUIMessagePart, | ||
| } from "@/app/aipanel/aitypes"; | ||
| import { FocusManager } from "@/app/store/focusManager"; | ||
| import { atoms, createBlock, getOrefMetaKeyAtom, getSettingsKeyAtom } from "@/app/store/global"; | ||
| import { | ||
| atoms, | ||
| createBlock, | ||
| getBlockComponentModel, | ||
| getFocusedBlockId, | ||
| getOrefMetaKeyAtom, | ||
| getSettingsKeyAtom, | ||
| } from "@/app/store/global"; | ||
| import { globalStore } from "@/app/store/jotaiStore"; | ||
| import { isBuilderWindow } from "@/app/store/windowtype"; | ||
| import * as WOS from "@/app/store/wos"; | ||
|
|
@@ -65,6 +73,7 @@ export class WaveAIModel { | |
| errorMessage: jotai.PrimitiveAtom<string> = jotai.atom(null) as jotai.PrimitiveAtom<string>; | ||
| containerWidth: jotai.PrimitiveAtom<number> = jotai.atom(0); | ||
| codeBlockMaxWidth!: jotai.Atom<number>; | ||
| savedCommandsAtom: jotai.PrimitiveAtom<SavedCommand[]> = jotai.atom([]); | ||
| inputAtom: jotai.PrimitiveAtom<string> = jotai.atom(""); | ||
| isLoadingChatAtom: jotai.PrimitiveAtom<boolean> = jotai.atom(false); | ||
| isChatEmptyAtom: jotai.PrimitiveAtom<boolean> = jotai.atom(true); | ||
|
|
@@ -282,6 +291,102 @@ export class WaveAIModel { | |
| this.useChatSetMessages?.([]); | ||
| } | ||
|
|
||
| private normalizeSavedCommands(commands: ObjRTInfo["waveai:savedcommands"]): SavedCommand[] { | ||
| if (!Array.isArray(commands)) { | ||
| return []; | ||
| } | ||
| return commands | ||
| .filter((command): command is SavedCommand => command != null && typeof command.text === "string") | ||
| .map((command) => ({ | ||
| id: command.id || crypto.randomUUID(), | ||
| text: command.text, | ||
| createdts: command.createdts ?? Date.now(), | ||
| updatedts: command.updatedts ?? command.createdts ?? Date.now(), | ||
| })); | ||
| } | ||
|
|
||
| private persistSavedCommands(commands: SavedCommand[]) { | ||
| globalStore.set(this.savedCommandsAtom, commands); | ||
| void RpcApi.SetRTInfoCommand(TabRpcClient, { | ||
| oref: this.orefContext, | ||
| data: { "waveai:savedcommands": commands.length > 0 ? commands : null }, | ||
| }).catch((error) => { | ||
| console.error("Failed to persist saved commands:", error); | ||
| }); | ||
| } | ||
|
|
||
| addSavedCommand(text: string): string { | ||
| const normalizedText = text.replace(/\n$/, ""); | ||
| const now = Date.now(); | ||
| const currentCommands = globalStore.get(this.savedCommandsAtom); | ||
| const existing = currentCommands.find( | ||
| (command) => command.text.trim() === normalizedText.trim() && normalizedText.trim().length > 0 | ||
| ); | ||
| if (existing) { | ||
| this.persistSavedCommands( | ||
| currentCommands.map((command) => | ||
| command.id === existing.id ? { ...command, updatedts: now } : command | ||
| ) | ||
| ); | ||
| return existing.id; | ||
| } | ||
|
|
||
| const nextCommand: SavedCommand = { | ||
| id: crypto.randomUUID(), | ||
| text: normalizedText, | ||
| createdts: now, | ||
| updatedts: now, | ||
| }; | ||
| this.persistSavedCommands([nextCommand, ...currentCommands]); | ||
| return nextCommand.id; | ||
| } | ||
|
|
||
| updateSavedCommand(id: string, text: string) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: Inconsistent text normalization - Consider normalizing the text in updateSavedCommand(id: string, text: string) {
const normalizedText = text.replace(/\n$/, "");
// ... |
||
| const now = Date.now(); | ||
| this.persistSavedCommands( | ||
| globalStore.get(this.savedCommandsAtom).map((command) => | ||
| command.id === id ? { ...command, text, updatedts: now } : command | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| removeSavedCommand(id: string) { | ||
| this.persistSavedCommands(globalStore.get(this.savedCommandsAtom).filter((command) => command.id !== id)); | ||
| } | ||
|
|
||
| async runSavedCommand(text: string) { | ||
| const commandText = text.trim(); | ||
| if (!commandText) { | ||
| return; | ||
| } | ||
| if (this.inBuilder) { | ||
| this.setError("Running saved commands in the terminal is not available from the builder."); | ||
| return; | ||
| } | ||
|
|
||
| const focusedBlockId = getFocusedBlockId(); | ||
| if (!focusedBlockId) { | ||
| this.setError("Focus a terminal block, then run the saved command again."); | ||
| return; | ||
| } | ||
| const blockAtom = WOS.getWaveObjectAtom<Block>(WOS.makeORef("block", focusedBlockId)); | ||
| const blockData = globalStore.get(blockAtom); | ||
| if (blockData?.meta?.view !== "term") { | ||
| this.setError("Focus a terminal block, then run the saved command again."); | ||
| return; | ||
| } | ||
| const blockComponentModel = getBlockComponentModel(focusedBlockId); | ||
| const termViewModel = blockComponentModel?.viewModel as { sendDataToController?: (data: string) => void }; | ||
| if (typeof termViewModel?.sendDataToController !== "function") { | ||
| this.setError("The focused terminal is not ready to receive commands."); | ||
| return; | ||
| } | ||
|
|
||
| const commandWithNewline = text.endsWith("\n") ? text : `${text}\n`; | ||
| termViewModel.sendDataToController(commandWithNewline); | ||
| this.requestNodeFocus(); | ||
| } | ||
|
|
||
| setError(message: string) { | ||
| globalStore.set(this.errorMessage, message); | ||
| } | ||
|
|
@@ -449,6 +554,7 @@ export class WaveAIModel { | |
| const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { | ||
| oref: this.orefContext, | ||
| }); | ||
| globalStore.set(this.savedCommandsAtom, this.normalizeSavedCommands(rtInfo?.["waveai:savedcommands"])); | ||
| let chatIdValue = rtInfo?.["waveai:chatid"]; | ||
| if (chatIdValue == null) { | ||
| chatIdValue = crypto.randomUUID(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { canSaveCommand } from "./streamdown"; | ||
|
|
||
| describe("canSaveCommand", () => { | ||
| it("accepts explicit shell code blocks", () => { | ||
| expect(canSaveCommand("bash", "npm run build")).toBe(true); | ||
| expect(canSaveCommand("pwsh", "Get-ChildItem")).toBe(true); | ||
| }); | ||
|
|
||
| it("accepts shell-looking unlabeled blocks", () => { | ||
| expect(canSaveCommand("text", "$ git status\n$ npm test")).toBe(true); | ||
| expect(canSaveCommand("text", "docker compose up")).toBe(true); | ||
| }); | ||
|
|
||
| it("rejects empty or obviously non-command blocks", () => { | ||
| expect(canSaveCommand("text", "")).toBe(false); | ||
| expect(canSaveCommand("javascript", "const x = 1;\nconsole.log(x);")).toBe(false); | ||
| expect(canSaveCommand("text", "This is explanatory prose, not a command.")).toBe(false); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add validation for empty or whitespace-only input.
If
textis empty or contains only whitespace,existingwill never match (due to thenormalizedText.trim().length > 0check in the find predicate), and the code will proceed to create a new command with empty text. This could result in multiple empty commands being saved.🛡️ Proposed fix to add early validation
addSavedCommand(text: string): string { const normalizedText = text.replace(/\n$/, ""); + if (!normalizedText.trim()) { + return ""; + } const now = Date.now();📝 Committable suggestion
🤖 Prompt for AI Agents