Skip to content
1 change: 1 addition & 0 deletions frontend/app/asset/claude-color.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions frontend/app/view/term/osc-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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);
expect(isClaudeCodeCommand("/usr/local/bin/claude chat")).toBe(true);
});

it("matches Claude Code invocations wrapped with env assignments", () => {
expect(isClaudeCodeCommand('ANTHROPIC_API_KEY="test" claude')).toBe(true);
expect(isClaudeCodeCommand("FOO=bar env claude --print")).toBe(true);
});

it("ignores other commands", () => {
expect(isClaudeCodeCommand("claudes")).toBe(false);
expect(isClaudeCodeCommand("echo claude")).toBe(false);
expect(isClaudeCodeCommand("")).toBe(false);
});
});
36 changes: 30 additions & 6 deletions frontend/app/view/term/osc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Anchor Claude detection to the command token (argv[0]) only.

Line 28 currently matches /claude anywhere in the command string, so inputs like echo /usr/local/bin/claude are false positives.

🔧 Suggested fix
-const ClaudeCodeRegex = /(?:^|\/)claude\b/;
+const ClaudeCodeRegex = /^(?:\S*[\\/])?claude\b/;

Also applies to: 91-96

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/view/term/osc-handlers.ts` around lines 28 - 29, The current
Claude detection uses ClaudeCodeRegex (/?:^|\/)claude\b/ ) against the entire
command string which produces false positives; update the code so detection runs
only against the command token (argv[0]) instead of the full command text —
i.e., extract the command token (e.g., const cmd = argv[0] or the variable
already holding the command name) and apply ClaudeCodeRegex to cmd, and make the
same change wherever ClaudeCodeRegex is used (including the other occurrence
around lines 91–96) so only the executable name/path is checked.

type Osc16162Command =
| { command: "A"; data: Record<string, never> }
| { command: "C"; data: { cmd64?: string } }
Expand All @@ -43,41 +45,56 @@ type Osc16162Command =
| { command: "I"; data: { inputempty?: boolean } }
| { command: "R"; data: Record<string, never> };

function normalizeCmd(decodedCmd: string): string {
let normalizedCmd = decodedCmd.trim();
normalizedCmd = normalizedCmd.replace(/^(?:\w+=(?:"[^"]*"|'[^']*'|\S+)\s+)*/, "");
normalizedCmd = normalizedCmd.replace(/^env\s+/, "");
return normalizedCmd;
Comment on lines +48 to +52
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

normalizeCmd misses env KEY=... claude form.

Line 51 strips env , but assignment tokens after env are left in place, so Claude detection fails for common wrapper usage.

🔧 Suggested fix
 function normalizeCmd(decodedCmd: string): string {
-    let normalizedCmd = decodedCmd.trim();
-    normalizedCmd = normalizedCmd.replace(/^(?:\w+=(?:"[^"]*"|'[^']*'|\S+)\s+)*/, "");
-    normalizedCmd = normalizedCmd.replace(/^env\s+/, "");
-    return normalizedCmd;
+    let normalizedCmd = decodedCmd.trim();
+    while (true) {
+        const prev = normalizedCmd;
+        normalizedCmd = normalizedCmd.replace(/^env\s+/, "");
+        normalizedCmd = normalizedCmd.replace(/^(?:\w+=(?:"[^"]*"|'[^']*'|\S+)\s+)*/, "");
+        normalizedCmd = normalizedCmd.trimStart();
+        if (normalizedCmd === prev) {
+            break;
+        }
+    }
+    return normalizedCmd;
 }
📝 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
function normalizeCmd(decodedCmd: string): string {
let normalizedCmd = decodedCmd.trim();
normalizedCmd = normalizedCmd.replace(/^(?:\w+=(?:"[^"]*"|'[^']*'|\S+)\s+)*/, "");
normalizedCmd = normalizedCmd.replace(/^env\s+/, "");
return normalizedCmd;
function normalizeCmd(decodedCmd: string): string {
let normalizedCmd = decodedCmd.trim();
while (true) {
const prev = normalizedCmd;
normalizedCmd = normalizedCmd.replace(/^env\s+/, "");
normalizedCmd = normalizedCmd.replace(/^(?:\w+=(?:"[^"]*"|'[^']*'|\S+)\s+)*/, "");
normalizedCmd = normalizedCmd.trimStart();
if (normalizedCmd === prev) {
break;
}
}
return normalizedCmd;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/view/term/osc-handlers.ts` around lines 48 - 52, normalizeCmd
fails to strip forms like "env KEY=val claude" because it only removes a leading
"env " token; update normalizeCmd to remove an "env" wrapper plus any following
KEY=... assignments before the command. Specifically, modify the second
replacement (the one handling /^env\s+/ in normalizeCmd) to match
/^env(?:\s+\w+=(?:"[^"]*"|'[^']*'|\S+))*\s+/ so it strips "env" and zero-or-more
assignment tokens that follow, then returns the remaining command (so Claude
detection will see "claude" at the start).

}

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,
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand All @@ -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");
}
Expand Down
14 changes: 9 additions & 5 deletions frontend/app/view/term/term-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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";
Expand All @@ -436,7 +440,7 @@ export class TermViewModel implements ViewModel {

return {
elemtype: "iconbutton",
icon: "sparkles",
icon,
className: "text-warning",
title: title,
noAction: true,
Expand Down
19 changes: 15 additions & 4 deletions frontend/app/view/term/term.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -34,6 +35,16 @@ interface TerminalViewProps {
model: TermViewModel;
}

const TermClaudeIcon = React.memo(() => {
return (
<div className="[&_svg]:w-[15px] [&_svg]:h-[15px]" aria-hidden="true">
<ClaudeColorSvg />
</div>
);
});

TermClaudeIcon.displayName = "TermClaudeIcon";

const TermResyncHandler = React.memo(({ blockId, model }: TerminalViewProps) => {
const connStatus = jotai.useAtomValue(model.connStatus);
const [lastConnStatus, setLastConnStatus] = React.useState<ConnStatus>(connStatus);
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -390,4 +401,4 @@ const TerminalView = ({ blockId, model }: ViewComponentProps<TermViewModel>) =>
);
};

export { TerminalView };
export { TermClaudeIcon, TerminalView };
18 changes: 14 additions & 4 deletions frontend/app/view/term/termwrap.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -32,6 +32,7 @@ import {
handleOsc16162Command,
handleOsc52Command,
handleOsc7Command,
isClaudeCodeCommand,
type ShellIntegrationStatus,
} from "./osc-handlers";
import { bufferLinesToText, createTempFileFromBlob, extractAllClipboardData, normalizeCursorStyle } from "./termutil";
Expand Down Expand Up @@ -92,6 +93,7 @@ export class TermWrap {
promptMarkers: TermTypes.IMarker[] = [];
shellIntegrationStatusAtom: jotai.PrimitiveAtom<ShellIntegrationStatus | null>;
lastCommandAtom: jotai.PrimitiveAtom<string | null>;
claudeCodeActiveAtom: jotai.PrimitiveAtom<boolean>;
nodeModel: BlockNodeModel; // this can be null
hoveredLinkUri: string | null = null;
onLinkHover?: (uri: string | null, mouseX: number, mouseY: number) => void;
Expand Down Expand Up @@ -131,6 +133,7 @@ export class TermWrap {
this.promptMarkers = [];
this.shellIntegrationStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom<ShellIntegrationStatus | null>;
this.lastCommandAtom = jotai.atom(null) as jotai.PrimitiveAtom<string | null>;
this.claudeCodeActiveAtom = jotai.atom(false);
this.webglEnabledAtom = jotai.atom(false) as jotai.PrimitiveAtom<boolean>;
this.terminal = new Terminal(options);
this.fitAddon = new FitAddon();
Expand Down Expand Up @@ -345,16 +348,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);
}
Expand All @@ -371,7 +377,9 @@ export class TermWrap {
this.promptMarkers.forEach((marker) => {
try {
marker.dispose();
} catch (_) {}
} catch (_) {
/* nothing */
}
});
this.promptMarkers = [];
this.webglContextLossDisposable?.dispose();
Expand All @@ -380,7 +388,9 @@ export class TermWrap {
this.toDispose.forEach((d) => {
try {
d.dispose();
} catch (_) {}
} catch (_) {
/* nothing */
}
});
this.mainFileSubject.release();
}
Expand Down
Loading
Loading