Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/app/aipanel/aimessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ const AIMessagePart = memo(({ part, role, isStreaming }: AIMessagePartProps) =>
text={content}
parseIncompleteMarkdown={isStreaming}
className="text-gray-100"
onClickSaveCommand={(cmd) => model.addSavedCommand(cmd)}
codeBlockMaxWidthAtom={model.codeBlockMaxWidth}
/>
);
Expand Down
2 changes: 2 additions & 0 deletions frontend/app/aipanel/aipanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { AIPanelHeader } from "./aipanelheader";
import { AIPanelInput } from "./aipanelinput";
import { AIPanelMessages } from "./aipanelmessages";
import { AIRateLimitStrip } from "./airatelimitstrip";
import { SavedCommandsPanel } from "./savedcommandspanel";
import { WaveUIMessage } from "./aitypes";
import { BYOKAnnouncement } from "./byokannouncement";
import { TelemetryRequiredMessage } from "./telemetryrequired";
Expand Down Expand Up @@ -596,6 +597,7 @@ const AIPanelComponentInner = memo(() => {
/>
)}
<AIErrorMessage />
<SavedCommandsPanel />
<AIDroppedFiles model={model} />
<AIPanelInput onSubmit={handleSubmit} status={status} model={model} />
</>
Expand Down
7 changes: 7 additions & 0 deletions frontend/app/aipanel/aitypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export type UseChatSetMessagesType = (
messages: WaveUIMessage[] | ((messages: WaveUIMessage[]) => WaveUIMessage[])
) => void;

export interface SavedCommand {
id: string;
text: string;
createdts?: number;
updatedts?: number;
}

export type UseChatSendMessageType = (
message?:
| (Omit<WaveUIMessage, "id" | "role"> & {
Expand Down
125 changes: 125 additions & 0 deletions frontend/app/aipanel/savedcommandspanel.tsx
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";
108 changes: 107 additions & 1 deletion frontend/app/aipanel/waveai-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Comment on lines +318 to +342
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add validation for empty or whitespace-only input.

If text is empty or contains only whitespace, existing will never match (due to the normalizedText.trim().length > 0 check 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
addSavedCommand(text: string): string {
const normalizedText = text.replace(/\n$/, "");
if (!normalizedText.trim()) {
return "";
}
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;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/aipanel/waveai-model.tsx` around lines 318 - 342, The
addSavedCommand function currently allows empty or whitespace-only text because
the find predicate prevents matching but doesn’t prevent creating a new empty
command; add an early validation in addSavedCommand to trim the input and if
trimmed text length is 0 return an empty string (or null per project convention)
without persisting; use the normalizedText (or trimmed variable) to check and
bail out before accessing globalStore or calling persistSavedCommands,
referencing addSavedCommand, savedCommandsAtom, and persistSavedCommands to
locate the change.


updateSavedCommand(id: string, text: string) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Inconsistent text normalization - addSavedCommand normalizes text by removing trailing newlines (line 319), but updateSavedCommand does not. This can cause duplicate commands if a user updates an existing command with trailing whitespace/newlines.

Consider normalizing the text in updateSavedCommand as well:

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);
}
Expand Down Expand Up @@ -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();
Expand Down
21 changes: 21 additions & 0 deletions frontend/app/element/streamdown.test.ts
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);
});
});
Loading