Skip to content

Commit 889e628

Browse files
Copilotsawka
andauthored
Show Claude icon in terminal header while Claude Code is active (#3046)
This updates the terminal shell-integration badge so it reflects Claude Code activity instead of always rendering the generic AI icon. When the active shell command is Claude Code, the header now shows the Claude logo. - **Terminal shell-integration badge** - Updated `getShellIntegrationIconButton()` to render the Claude logo while Claude Code is the active running command. - Kept the existing shell-integration states and messaging intact for non-Claude commands. - **Claude Code detection** - Added command detection for Claude Code in the OSC shell-integration flow. - Tracks active Claude sessions on `TermWrap`, including initial runtime-info hydration and command lifecycle transitions. - Handles common invocation forms, including direct binary paths and commands wrapped by env var assignments / `env`. - **UI rendering** - Added `@lobehub/icons` and used its `Claude` icon in the terminal header path. - Reused the existing icon-button rendering contract by passing a React node for the icon where needed. - **Focused coverage** - Added a small unit test for Claude command detection to lock in the supported command forms. ```ts const claudeCodeActive = get(this.termRef.current.claudeCodeActiveAtom); const icon = claudeCodeActive ? React.createElement(TermClaudeIcon) : "sparkles"; ``` - **screenshot** - ![Claude terminal header badge](https://github.com/user-attachments/assets/4b53f671-8432-4878-b2d2-e3afeba7814f) <!-- START COPILOT CODING AGENT TIPS --> --- 💬 Send tasks to Copilot coding agent from [Slack](https://gh.io/cca-slack-docs) and [Teams](https://gh.io/cca-teams-docs) to turn conversations into code. Copilot posts an update in your thread when it's finished. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> Co-authored-by: sawka <mike@commandline.dev>
1 parent f92a953 commit 889e628

File tree

7 files changed

+159
-62
lines changed

7 files changed

+159
-62
lines changed
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { isClaudeCodeCommand } from "./osc-handlers";
4+
5+
describe("isClaudeCodeCommand", () => {
6+
it("matches direct Claude Code invocations", () => {
7+
expect(isClaudeCodeCommand("claude")).toBe(true);
8+
expect(isClaudeCodeCommand("claude --dangerously-skip-permissions")).toBe(true);
9+
});
10+
11+
it("matches Claude Code invocations wrapped with env assignments", () => {
12+
expect(isClaudeCodeCommand('ANTHROPIC_API_KEY="test" claude')).toBe(true);
13+
expect(isClaudeCodeCommand("env FOO=bar claude --print")).toBe(true);
14+
});
15+
16+
it("ignores other commands", () => {
17+
expect(isClaudeCodeCommand("claudes")).toBe(false);
18+
expect(isClaudeCodeCommand("echo claude")).toBe(false);
19+
expect(isClaudeCodeCommand("ls ~/claude")).toBe(false);
20+
expect(isClaudeCodeCommand("cat /logs/claude")).toBe(false);
21+
expect(isClaudeCodeCommand("")).toBe(false);
22+
});
23+
});

frontend/app/view/term/osc-handlers.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ const Osc52MaxRawLength = 128 * 1024; // includes selector + base64 + whitespace
2525
// See aiprompts/wave-osc-16162.md for full documentation
2626
export type ShellIntegrationStatus = "ready" | "running-command";
2727

28+
const ClaudeCodeRegex = /^claude\b/;
29+
2830
type Osc16162Command =
2931
| { command: "A"; data: Record<string, never> }
3032
| { command: "C"; data: { cmd64?: string } }
@@ -43,41 +45,56 @@ type Osc16162Command =
4345
| { command: "I"; data: { inputempty?: boolean } }
4446
| { command: "R"; data: Record<string, never> };
4547

48+
function normalizeCmd(decodedCmd: string): string {
49+
let normalizedCmd = decodedCmd.trim();
50+
normalizedCmd = normalizedCmd.replace(/^env\s+/, "");
51+
normalizedCmd = normalizedCmd.replace(/^(?:\w+=(?:"[^"]*"|'[^']*'|\S+)\s+)*/, "");
52+
return normalizedCmd;
53+
}
54+
4655
function checkCommandForTelemetry(decodedCmd: string) {
4756
if (!decodedCmd) {
4857
return;
4958
}
5059

51-
if (decodedCmd.startsWith("ssh ")) {
60+
const normalizedCmd = normalizeCmd(decodedCmd);
61+
62+
if (normalizedCmd.startsWith("ssh ")) {
5263
recordTEvent("conn:connect", { "conn:conntype": "ssh-manual" });
5364
return;
5465
}
5566

5667
const editorsRegex = /^(vim|vi|nano|nvim)\b/;
57-
if (editorsRegex.test(decodedCmd)) {
68+
if (editorsRegex.test(normalizedCmd)) {
5869
recordTEvent("action:term", { "action:type": "cli-edit" });
5970
return;
6071
}
6172

6273
const tailFollowRegex = /(^|\|\s*)tail\s+-[fF]\b/;
63-
if (tailFollowRegex.test(decodedCmd)) {
74+
if (tailFollowRegex.test(normalizedCmd)) {
6475
recordTEvent("action:term", { "action:type": "cli-tailf" });
6576
return;
6677
}
6778

68-
const claudeRegex = /^claude\b/;
69-
if (claudeRegex.test(decodedCmd)) {
79+
if (ClaudeCodeRegex.test(normalizedCmd)) {
7080
recordTEvent("action:term", { "action:type": "claude" });
7181
return;
7282
}
7383

7484
const opencodeRegex = /^opencode\b/;
75-
if (opencodeRegex.test(decodedCmd)) {
85+
if (opencodeRegex.test(normalizedCmd)) {
7686
recordTEvent("action:term", { "action:type": "opencode" });
7787
return;
7888
}
7989
}
8090

91+
export function isClaudeCodeCommand(decodedCmd: string): boolean {
92+
if (!decodedCmd) {
93+
return false;
94+
}
95+
return ClaudeCodeRegex.test(normalizeCmd(decodedCmd));
96+
}
97+
8198
function handleShellIntegrationCommandStart(
8299
termWrap: TermWrap,
83100
blockId: string,
@@ -101,16 +118,20 @@ function handleShellIntegrationCommandStart(
101118
const decodedCmd = base64ToString(cmd.data.cmd64);
102119
rtInfo["shell:lastcmd"] = decodedCmd;
103120
globalStore.set(termWrap.lastCommandAtom, decodedCmd);
121+
const isCC = isClaudeCodeCommand(decodedCmd);
122+
globalStore.set(termWrap.claudeCodeActiveAtom, isCC);
104123
checkCommandForTelemetry(decodedCmd);
105124
} catch (e) {
106125
console.error("Error decoding cmd64:", e);
107126
rtInfo["shell:lastcmd"] = null;
108127
globalStore.set(termWrap.lastCommandAtom, null);
128+
globalStore.set(termWrap.claudeCodeActiveAtom, false);
109129
}
110130
}
111131
} else {
112132
rtInfo["shell:lastcmd"] = null;
113133
globalStore.set(termWrap.lastCommandAtom, null);
134+
globalStore.set(termWrap.claudeCodeActiveAtom, false);
114135
}
115136
rtInfo["shell:lastcmdexitcode"] = null;
116137
}
@@ -287,6 +308,7 @@ export function handleOsc16162Command(data: string, blockId: string, loaded: boo
287308
case "A": {
288309
rtInfo["shell:state"] = "ready";
289310
globalStore.set(termWrap.shellIntegrationStatusAtom, "ready");
311+
globalStore.set(termWrap.claudeCodeActiveAtom, false);
290312
const marker = terminal.registerMarker(0);
291313
if (marker) {
292314
termWrap.promptMarkers.push(marker);
@@ -324,6 +346,7 @@ export function handleOsc16162Command(data: string, blockId: string, loaded: boo
324346
}
325347
break;
326348
case "D":
349+
globalStore.set(termWrap.claudeCodeActiveAtom, false);
327350
if (cmd.data.exitcode != null) {
328351
rtInfo["shell:lastcmdexitcode"] = cmd.data.exitcode;
329352
} else {
@@ -337,6 +360,7 @@ export function handleOsc16162Command(data: string, blockId: string, loaded: boo
337360
break;
338361
case "R":
339362
globalStore.set(termWrap.shellIntegrationStatusAtom, null);
363+
globalStore.set(termWrap.claudeCodeActiveAtom, false);
340364
if (terminal.buffer.active.type === "alternate") {
341365
terminal.write("\x1b[?1049l");
342366
}

frontend/app/view/term/term-model.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { waveEventSubscribeSingle } from "@/app/store/wps";
1010
import { RpcApi } from "@/app/store/wshclientapi";
1111
import { makeFeBlockRouteId } from "@/app/store/wshrouter";
1212
import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil";
13-
import { TerminalView } from "@/app/view/term/term";
13+
import { TermClaudeIcon, TerminalView } from "@/app/view/term/term";
1414
import { TermWshClient } from "@/app/view/term/term-wsh";
1515
import { VDomModel } from "@/app/view/vdom/vdom-model";
1616
import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
@@ -404,10 +404,12 @@ export class TermViewModel implements ViewModel {
404404
return null;
405405
}
406406
const shellIntegrationStatus = get(this.termRef.current.shellIntegrationStatusAtom);
407+
const claudeCodeActive = get(this.termRef.current.claudeCodeActiveAtom);
408+
const icon = claudeCodeActive ? React.createElement(TermClaudeIcon) : "sparkles";
407409
if (shellIntegrationStatus == null) {
408410
return {
409411
elemtype: "iconbutton",
410-
icon: "sparkles",
412+
icon,
411413
className: "text-muted",
412414
title: "No shell integration — Wave AI unable to run commands.",
413415
noAction: true,
@@ -416,14 +418,16 @@ export class TermViewModel implements ViewModel {
416418
if (shellIntegrationStatus === "ready") {
417419
return {
418420
elemtype: "iconbutton",
419-
icon: "sparkles",
421+
icon,
420422
className: "text-accent",
421423
title: "Shell ready — Wave AI can run commands in this terminal.",
422424
noAction: true,
423425
};
424426
}
425427
if (shellIntegrationStatus === "running-command") {
426-
let title = "Shell busy — Wave AI unable to run commands while another command is running.";
428+
let title = claudeCodeActive
429+
? "Claude Code Detected"
430+
: "Shell busy — Wave AI unable to run commands while another command is running.";
427431

428432
if (this.termRef.current) {
429433
const inAltBuffer = this.termRef.current.terminal?.buffer?.active?.type === "alternate";
@@ -436,7 +440,7 @@ export class TermViewModel implements ViewModel {
436440

437441
return {
438442
elemtype: "iconbutton",
439-
icon: "sparkles",
443+
icon,
440444
className: "text-warning",
441445
title: title,
442446
noAction: true,

frontend/app/view/term/term.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
// Copyright 2025, Command Line Inc.
1+
// Copyright 2026, Command Line Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
import ClaudeColorSvg from "@/app/asset/claude-color.svg";
45
import { SubBlock } from "@/app/block/block";
56
import type { BlockNodeModel } from "@/app/block/blocktypes";
67
import { NullErrorBoundary } from "@/app/element/errorboundary";
@@ -34,6 +35,16 @@ interface TerminalViewProps {
3435
model: TermViewModel;
3536
}
3637

38+
const TermClaudeIcon = React.memo(() => {
39+
return (
40+
<div className="[&_svg]:w-[15px] [&_svg]:h-[15px]" aria-hidden="true">
41+
<ClaudeColorSvg />
42+
</div>
43+
);
44+
});
45+
46+
TermClaudeIcon.displayName = "TermClaudeIcon";
47+
3748
const TermResyncHandler = React.memo(({ blockId, model }: TerminalViewProps) => {
3849
const connStatus = jotai.useAtomValue(model.connStatus);
3950
const [lastConnStatus, setLastConnStatus] = React.useState<ConnStatus>(connStatus);
@@ -61,7 +72,7 @@ const TermVDomToolbarNode = ({ vdomBlockId, blockId, model }: TerminalViewProps
6172
const unsub = waveEventSubscribeSingle({
6273
eventType: "blockclose",
6374
scope: WOS.makeORef("block", vdomBlockId),
64-
handler: (event) => {
75+
handler: (_event) => {
6576
RpcApi.SetMetaCommand(TabRpcClient, {
6677
oref: WOS.makeORef("block", blockId),
6778
meta: {
@@ -104,7 +115,7 @@ const TermVDomNodeSingleId = ({ vdomBlockId, blockId, model }: TerminalViewProps
104115
const unsub = waveEventSubscribeSingle({
105116
eventType: "blockclose",
106117
scope: WOS.makeORef("block", vdomBlockId),
107-
handler: (event) => {
118+
handler: (_event) => {
108119
RpcApi.SetMetaCommand(TabRpcClient, {
109120
oref: WOS.makeORef("block", blockId),
110121
meta: {
@@ -390,4 +401,4 @@ const TerminalView = ({ blockId, model }: ViewComponentProps<TermViewModel>) =>
390401
);
391402
};
392403

393-
export { TerminalView };
404+
export { TermClaudeIcon, TerminalView };

frontend/app/view/term/termwrap.ts

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2025, Command Line Inc.
1+
// Copyright 2026, Command Line Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

44
import type { BlockNodeModel } from "@/app/block/blocktypes";
@@ -32,6 +32,7 @@ import {
3232
handleOsc16162Command,
3333
handleOsc52Command,
3434
handleOsc7Command,
35+
isClaudeCodeCommand,
3536
type ShellIntegrationStatus,
3637
} from "./osc-handlers";
3738
import { bufferLinesToText, createTempFileFromBlob, extractAllClipboardData, normalizeCursorStyle } from "./termutil";
@@ -92,6 +93,7 @@ export class TermWrap {
9293
promptMarkers: TermTypes.IMarker[] = [];
9394
shellIntegrationStatusAtom: jotai.PrimitiveAtom<ShellIntegrationStatus | null>;
9495
lastCommandAtom: jotai.PrimitiveAtom<string | null>;
96+
claudeCodeActiveAtom: jotai.PrimitiveAtom<boolean>;
9597
nodeModel: BlockNodeModel; // this can be null
9698
hoveredLinkUri: string | null = null;
9799
onLinkHover?: (uri: string | null, mouseX: number, mouseY: number) => void;
@@ -131,6 +133,7 @@ export class TermWrap {
131133
this.promptMarkers = [];
132134
this.shellIntegrationStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom<ShellIntegrationStatus | null>;
133135
this.lastCommandAtom = jotai.atom(null) as jotai.PrimitiveAtom<string | null>;
136+
this.claudeCodeActiveAtom = jotai.atom(false);
134137
this.webglEnabledAtom = jotai.atom(false) as jotai.PrimitiveAtom<boolean>;
135138
this.terminal = new Terminal(options);
136139
this.fitAddon = new FitAddon();
@@ -171,16 +174,34 @@ export class TermWrap {
171174
this.setTermRenderer(WebGLSupported && waveOptions.useWebGl ? "webgl" : "dom");
172175
// Register OSC handlers
173176
this.terminal.parser.registerOscHandler(7, (data: string) => {
174-
return handleOsc7Command(data, this.blockId, this.loaded);
177+
try {
178+
return handleOsc7Command(data, this.blockId, this.loaded);
179+
} catch (e) {
180+
console.error("[termwrap] osc 7 handler error", this.blockId, e);
181+
return false;
182+
}
175183
});
176184
this.terminal.parser.registerOscHandler(52, (data: string) => {
177-
return handleOsc52Command(data, this.blockId, this.loaded, this);
185+
try {
186+
return handleOsc52Command(data, this.blockId, this.loaded, this);
187+
} catch (e) {
188+
console.error("[termwrap] osc 52 handler error", this.blockId, e);
189+
return false;
190+
}
178191
});
179192
this.terminal.parser.registerOscHandler(16162, (data: string) => {
180-
return handleOsc16162Command(data, this.blockId, this.loaded, this);
193+
try {
194+
return handleOsc16162Command(data, this.blockId, this.loaded, this);
195+
} catch (e) {
196+
console.error("[termwrap] osc 16162 handler error", this.blockId, e);
197+
return false;
198+
}
181199
});
182200
this.toDispose.push(
183201
this.terminal.parser.registerCsiHandler({ final: "J" }, (params) => {
202+
if (params == null || params.length < 1) {
203+
return false;
204+
}
184205
if (params[0] === 3) {
185206
this.lastClearScrollbackTs = Date.now();
186207
if (this.inSyncTransaction) {
@@ -193,6 +214,9 @@ export class TermWrap {
193214
);
194215
this.toDispose.push(
195216
this.terminal.parser.registerCsiHandler({ prefix: "?", final: "h" }, (params) => {
217+
if (params == null || params.length < 1) {
218+
return false;
219+
}
196220
if (params[0] === 2026) {
197221
this.lastMode2026SetTs = Date.now();
198222
this.inSyncTransaction = true;
@@ -202,6 +226,9 @@ export class TermWrap {
202226
);
203227
this.toDispose.push(
204228
this.terminal.parser.registerCsiHandler({ prefix: "?", final: "l" }, (params) => {
229+
if (params == null || params.length < 1) {
230+
return false;
231+
}
205232
if (params[0] === 2026) {
206233
this.lastMode2026ResetTs = Date.now();
207234
this.inSyncTransaction = false;
@@ -345,16 +372,19 @@ export class TermWrap {
345372
const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, {
346373
oref: WOS.makeORef("block", this.blockId),
347374
});
375+
let shellState: ShellIntegrationStatus = null;
348376

349377
if (rtInfo && rtInfo["shell:integration"]) {
350-
const shellState = rtInfo["shell:state"] as ShellIntegrationStatus;
378+
shellState = rtInfo["shell:state"] as ShellIntegrationStatus;
351379
globalStore.set(this.shellIntegrationStatusAtom, shellState || null);
352380
} else {
353381
globalStore.set(this.shellIntegrationStatusAtom, null);
354382
}
355383

356384
const lastCmd = rtInfo ? rtInfo["shell:lastcmd"] : null;
385+
const isCC = shellState === "running-command" && isClaudeCodeCommand(lastCmd);
357386
globalStore.set(this.lastCommandAtom, lastCmd || null);
387+
globalStore.set(this.claudeCodeActiveAtom, isCC);
358388
} catch (e) {
359389
console.log("Error loading runtime info:", e);
360390
}
@@ -371,7 +401,9 @@ export class TermWrap {
371401
this.promptMarkers.forEach((marker) => {
372402
try {
373403
marker.dispose();
374-
} catch (_) {}
404+
} catch (_) {
405+
/* nothing */
406+
}
375407
});
376408
this.promptMarkers = [];
377409
this.webglContextLossDisposable?.dispose();
@@ -380,7 +412,9 @@ export class TermWrap {
380412
this.toDispose.forEach((d) => {
381413
try {
382414
d.dispose();
383-
} catch (_) {}
415+
} catch (_) {
416+
/* nothing */
417+
}
384418
});
385419
this.mainFileSubject.release();
386420
}

0 commit comments

Comments
 (0)