From 024ab197b5be674b2d70e3ac18c58961b0143d67 Mon Sep 17 00:00:00 2001 From: Adrian Challinor Date: Wed, 14 Oct 2026 14:56:21 +0100 Subject: [PATCH] Add a per-terminal title bar color so panes are easy to tell apart. Co-authored-by: Cursor --- frontend/app/block/blockenv.ts | 1 + frontend/app/block/blockframe-header.tsx | 2 ++ frontend/app/view/term/headercolor.ts | 34 ++++++++++++++++++++++++ frontend/app/view/term/term-model.ts | 7 +++++ frontend/types/gotypes.d.ts | 1 + pkg/waveobj/metaconsts.go | 1 + pkg/waveobj/wtypemeta.go | 1 + 7 files changed, 47 insertions(+) create mode 100644 frontend/app/view/term/headercolor.ts diff --git a/frontend/app/block/blockenv.ts b/frontend/app/block/blockenv.ts index 8a529be11b..71dd7570de 100644 --- a/frontend/app/block/blockenv.ts +++ b/frontend/app/block/blockenv.ts @@ -46,6 +46,7 @@ export type BlockEnv = WaveEnvSubset<{ | "icon:color" | "frame:title" | "frame:icon" + | "frame:headercolor" >; getTabMetaKeyAtom: MetaKeyAtomFnType<"bg:activebordercolor" | "bg:bordercolor" | "tab:background">; getConfigBackgroundAtom: WaveEnv["getConfigBackgroundAtom"]; diff --git a/frontend/app/block/blockframe-header.tsx b/frontend/app/block/blockframe-header.tsx index a70f323e71..1855fed6a9 100644 --- a/frontend/app/block/blockframe-header.tsx +++ b/frontend/app/block/blockframe-header.tsx @@ -231,6 +231,7 @@ const BlockFrame_Header = ({ const prevMagifiedState = React.useRef(magnified); const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection); const iconColor = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "icon:color")); + const headerColor = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "frame:headercolor")); const dragHandleRef = preview ? null : nodeModel.dragHandleRef; const isTerminalBlock = metaView === "term"; viewName = metaFrameTitle ?? viewName; @@ -251,6 +252,7 @@ const BlockFrame_Header = ({ className={cn("block-frame-default-header", useTermHeader && "!pl-[2px]")} data-role="block-header" ref={dragHandleRef} + style={headerColor ? { backgroundColor: headerColor } : undefined} onContextMenu={(e) => handleHeaderContextMenu(e, nodeModel.blockId, viewModel, nodeModel, waveEnv)} > {!useTermHeader && ( diff --git a/frontend/app/view/term/headercolor.ts b/frontend/app/view/term/headercolor.ts new file mode 100644 index 0000000000..186c6ceb11 --- /dev/null +++ b/frontend/app/view/term/headercolor.ts @@ -0,0 +1,34 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { WOS } from "@/store/global"; + +const termHeaderColors: { label: string; value: string | null }[] = [ + { label: "Default", value: null }, + { label: "Red", value: "#8b3a3a" }, + { label: "Orange", value: "#8a5528" }, + { label: "Gold", value: "#7a6840" }, + { label: "Green", value: "#2f6b45" }, + { label: "Teal", value: "#1f6e78" }, + { label: "Blue", value: "#2c4f8a" }, + { label: "Purple", value: "#5c3d82" }, + { label: "Pink", value: "#8a3d62" }, +]; + +export function termHeaderColorMenuItems(blockId: string, headerColor: string | null | undefined): ContextMenuItem[] { + return termHeaderColors.map((option) => { + return { + label: option.label, + type: "checkbox", + checked: option.value == null ? headerColor == null : headerColor === option.value, + click: () => { + RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", blockId), + meta: { "frame:headercolor": option.value }, + }); + }, + }; + }); +} diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index a256929e7d..15093c3ddf 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -39,6 +39,7 @@ import { isMacOS, isWindows } from "@/util/platformutil"; import { boundNumber, fireAndForget, stringToBase64 } from "@/util/util"; import * as jotai from "jotai"; import * as React from "react"; +import { termHeaderColorMenuItems } from "./headercolor"; import { getBlockingCommand } from "./shellblocking"; import { computeTheme, DefaultTermTheme, isLikelyOnSameHost, trimTerminalSelection } from "./termutil"; import { TermWrap, WebGLSupported } from "./termwrap"; @@ -1189,6 +1190,12 @@ export class TermViewModel implements ViewModel { label: "Transparency", submenu: transparencySubMenu, }); + const headerColor = blockData?.meta?.["frame:headercolor"] as string | null | undefined; + const headerColorSubMenu = termHeaderColorMenuItems(this.blockId, headerColor); + fullMenu.push({ + label: "Title Bar Color", + submenu: headerColorSubMenu, + }); fullMenu.push({ type: "separator" }); const advancedSubmenu: ContextMenuItem[] = []; const allowBracketedPaste = blockData?.meta?.["term:allowbracketedpaste"]; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index c5b870d7ed..983f1ee079 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1108,6 +1108,7 @@ declare global { "frame:title"?: string; "frame:icon"?: string; "frame:text"?: string; + "frame:headercolor"?: string; "cmd:*"?: boolean; cmd?: string; "cmd:interactive"?: boolean; diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index 0ce08099d8..c27e3d0195 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -36,6 +36,7 @@ const ( MetaKey_FrameTitle = "frame:title" MetaKey_FrameIcon = "frame:icon" MetaKey_FrameText = "frame:text" + MetaKey_FrameHeaderColor = "frame:headercolor" MetaKey_CmdClear = "cmd:*" MetaKey_Cmd = "cmd" diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index 2280b55d2d..1361fc764d 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -35,6 +35,7 @@ type MetaTSType struct { FrameTitle string `json:"frame:title,omitempty"` FrameIcon string `json:"frame:icon,omitempty"` FrameText string `json:"frame:text,omitempty"` + FrameHeaderColor string `json:"frame:headercolor,omitempty"` CmdClear bool `json:"cmd:*,omitempty"` Cmd string `json:"cmd,omitempty"`