Skip to content
Merged
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.
23 changes: 23 additions & 0 deletions frontend/app/view/term/osc-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
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/;

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(/^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,
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 };
48 changes: 41 additions & 7 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 @@ -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;
}
});
Comment on lines 176 to 199
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 | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In xterm.js, for parser.registerOscHandler(code, handler), what is the exact behavior difference between returning trueandfalse from the handler callback?

💡 Result:

In xterm.js, for parser.registerOscHandler(code, handler), the handler is a callback that receives the OSC payload string and returns a boolean (or Promise). Returning true indicates the OSC sequence was successfully handled by this handler. The parser stops processing and does not invoke any further (earlier registered) handlers for this OSC code. Returning false (or any falsy value except undefined) indicates the sequence was not handled by this handler. The parser continues to the next handler in the list (handlers are tried from most recently added to earliest). Handlers are invoked in LIFO order (last in, first out). This allows later-registered handlers to intercept and handle the sequence exclusively by returning true, or pass it through by returning false to reach built-in or earlier handlers. This behavior is consistent across parser hooks (OSC, CSI, DCS, ESC).

Citations:


🏁 Script executed:

# First, let's look at the file and surrounding context
head -220 frontend/app/view/term/termwrap.ts | tail -100

Repository: wavetermdev/waveterm

Length of output: 4310


🏁 Script executed:

# Search for other OSC handler registrations for codes 7, 52, 16162
rg "registerOscHandler\((7|52|16162)" --context 3

Repository: wavetermdev/waveterm

Length of output: 1640


🏁 Script executed:

# Look for the handler function definitions
fd "termwrap\|handleOsc" -e ts | head -20

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

# Search for handleOsc7Command, handleOsc52Command, handleOsc16162Command
rg "handleOsc(7|52|16162)Command" -A 5

Repository: wavetermdev/waveterm

Length of output: 3486


🏁 Script executed:

# Search for comments about OSC ownership or error handling
rg "OSC.*own|own.*OSC|handler.*own|own.*handler" -i -A 2 -B 2

Repository: wavetermdev/waveterm

Length of output: 50377


Return true in catch blocks to maintain OSC handler ownership on errors.

Lines 181, 189, and 197 return false on exceptions, which contradicts the ownership behavior: the handlers themselves return true when not ready, signaling they own these OSC codes. Returning false on error allows other handlers to process sequences this handler already attempted, risking malformed data propagation.

Suggested fix
         this.terminal.parser.registerOscHandler(7, (data: string) => {
             try {
                 return handleOsc7Command(data, this.blockId, this.loaded);
             } catch (e) {
                 console.error("[termwrap] osc 7 handler error", this.blockId, e);
-                return false;
+                return true;
             }
         });
         this.terminal.parser.registerOscHandler(52, (data: string) => {
             try {
                 return handleOsc52Command(data, this.blockId, this.loaded, this);
             } catch (e) {
                 console.error("[termwrap] osc 52 handler error", this.blockId, e);
-                return false;
+                return true;
             }
         });
         this.terminal.parser.registerOscHandler(16162, (data: string) => {
             try {
                 return handleOsc16162Command(data, this.blockId, this.loaded, this);
             } catch (e) {
                 console.error("[termwrap] osc 16162 handler error", this.blockId, e);
-                return false;
+                return true;
             }
         });
📝 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
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.terminal.parser.registerOscHandler(7, (data: string) => {
try {
return handleOsc7Command(data, this.blockId, this.loaded);
} catch (e) {
console.error("[termwrap] osc 7 handler error", this.blockId, e);
return true;
}
});
this.terminal.parser.registerOscHandler(52, (data: string) => {
try {
return handleOsc52Command(data, this.blockId, this.loaded, this);
} catch (e) {
console.error("[termwrap] osc 52 handler error", this.blockId, e);
return true;
}
});
this.terminal.parser.registerOscHandler(16162, (data: string) => {
try {
return handleOsc16162Command(data, this.blockId, this.loaded, this);
} catch (e) {
console.error("[termwrap] osc 16162 handler error", this.blockId, e);
return true;
}
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/view/term/termwrap.ts` around lines 176 - 199, The OSC handler
catch blocks currently return false which relinquishes ownership on errors;
update the three handlers registered with registerOscHandler(7),
registerOscHandler(52), and registerOscHandler(16162) so their catch blocks
return true instead of false, keeping ownership when handleOsc7Command,
handleOsc52Command, or handleOsc16162Command throw; keep the existing logging
(console.error) and otherwise leave the try/catch behavior unchanged.

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) {
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -371,7 +401,9 @@ export class TermWrap {
this.promptMarkers.forEach((marker) => {
try {
marker.dispose();
} catch (_) {}
} catch (_) {
/* nothing */
}
});
this.promptMarkers = [];
this.webglContextLossDisposable?.dispose();
Expand All @@ -380,7 +412,9 @@ export class TermWrap {
this.toDispose.forEach((d) => {
try {
d.dispose();
} catch (_) {}
} catch (_) {
/* nothing */
}
});
this.mainFileSubject.release();
}
Expand Down
Loading
Loading