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
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,19 @@ This project uses a set of "skill" guides — focused how-to documents for commo
| electron-api | `.kilocode/skills/electron-api/SKILL.md` | Guide for adding new Electron APIs to Wave Terminal. Use when implementing new frontend-to-electron communications via preload/IPC. |
| waveenv | `.kilocode/skills/waveenv/SKILL.md` | Guide for creating WaveEnv narrowings in Wave Terminal. Use when writing a named subset type of WaveEnv for a component tree, documenting environmental dependencies, or enabling mock environments for preview/test server usage. |
| wps-events | `.kilocode/skills/wps-events/SKILL.md` | Guide for working with Wave Terminal's WPS (Wave PubSub) event system. Use when implementing new event types, publishing events, subscribing to events, or adding asynchronous communication between components. |

---

## Dev Build & Packaging

- **App ID & Name**: Changed to `dev.commandline.waveterm.custom` and productName to `Wave Dev` in `package.json` so the dev build appears as a completely separate app to macOS (avoids Electron single-instance lock conflict and Launch Services confusion).
- **Build**: `task package` (requires `PATH="/opt/homebrew/bin:$PATH"` for Go/Task). Builds as `Wave Dev.app` in `make/mac-arm64/`.
- **Launch**: Use `launch_wave_dev.command` or run directly:
```bash
WAVETERM_HOME=~/.waveterm-dev \
WAVETERM_CONFIG_HOME=~/.waveterm-dev/config \
WAVETERM_DATA_HOME=~/.waveterm-dev/data \
make/mac-arm64/Wave\ Dev.app/Contents/MacOS/Wave\ Dev
```
These variables create isolated config and data directories for the dev build. Note: `getWaveHomeDir()` only honors `WAVETERM_HOME` after `wave.lock` exists, so explicit `CONFIG/DATA` overrides are needed for clean installs and newly launched dev instances.
- **Do not modify Info.plist or re-sign** the built app bundle — it breaks code signing on macOS and causes crashes.
101 changes: 93 additions & 8 deletions frontend/app/block/blockframe-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import { globalStore } from "@/app/store/jotaiStore";
import { uxCloseBlock } from "@/app/store/keymodel";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { renamingBlockIdAtom, startBlockRename, stopBlockRename } from "@/app/block/blockrenamestate";
import { useWaveEnv } from "@/app/waveenv/waveenv";
import { IconButton } from "@/element/iconbutton";
import { NodeModel } from "@/layout/index";
Expand All @@ -36,12 +37,24 @@ function handleHeaderContextMenu(
blockId: string,
viewModel: ViewModel,
nodeModel: NodeModel,
blockEnv: BlockEnv
blockEnv: BlockEnv,
preview: boolean
) {
e.preventDefault();
e.stopPropagation();
const magnified = globalStore.get(nodeModel.isMagnified);
const menu: ContextMenuItem[] = [
const ephemeral = globalStore.get(nodeModel.isEphemeral);
const useTermHeader = viewModel?.useTermHeader ? globalStore.get(viewModel.useTermHeader) : false;
const menu: ContextMenuItem[] = [];

if (!ephemeral && !preview && useTermHeader) {
menu.push({
label: "Rename Block",
click: () => startBlockRename(blockId),
});
}

menu.push(
{
label: magnified ? "Un-Magnify Block" : "Magnify Block",
click: () => {
Expand All @@ -54,8 +67,8 @@ function handleHeaderContextMenu(
click: () => {
navigator.clipboard.writeText(blockId);
},
},
];
}
);
const extraItems = viewModel?.getSettingsMenuItems?.();
if (extraItems && extraItems.length > 0) menu.push({ type: "separator" }, ...extraItems);
menu.push(
Expand All @@ -78,11 +91,82 @@ type HeaderTextElemsProps = {
const HeaderTextElems = React.memo(({ viewModel, blockId, preview, error }: HeaderTextElemsProps) => {
const waveEnv = useWaveEnv<BlockEnv>();
const frameTextAtom = waveEnv.getBlockMetaKeyAtom(blockId, "frame:text");
const frameTitleAtom = waveEnv.getBlockMetaKeyAtom(blockId, "frame:title");
const frameText = jotai.useAtomValue(frameTextAtom);
const frameTitle = jotai.useAtomValue(frameTitleAtom);
const renamingBlockId = jotai.useAtomValue(renamingBlockIdAtom);
const isRenaming = renamingBlockId === blockId;
const useTermHeader = util.useAtomValueSafe(viewModel?.useTermHeader);
let headerTextUnion = util.useAtomValueSafe(viewModel?.viewText);
headerTextUnion = frameText ?? headerTextUnion;
const cancelRef = React.useRef(false);

const saveRename = React.useCallback(
async (newTitle: string) => {
if (cancelRef.current) {
cancelRef.current = false;
return;
}
const val = newTitle.trim() || null;
try {
await waveEnv.rpc.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", blockId),
meta: { "frame:title": val },
});
stopBlockRename();
} catch (error) {
console.error("Failed to save block rename:", error);
}
},
[blockId, waveEnv]
);

if (isRenaming) {
return (
<div className="block-frame-textelems-wrapper">
<input
autoFocus
defaultValue={frameTitle ?? ""}
placeholder="Block name..."
className="block-frame-rename-input bg-transparent border border-white/20 rounded px-2 py-0.5 text-sm outline-none focus:border-white/40 min-w-0 w-full max-w-[200px]"
onFocus={(e) => e.currentTarget.select()}
onBlur={(e) => {
if (cancelRef.current) {
cancelRef.current = false;
stopBlockRename();
return;
}
saveRename(e.currentTarget.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
saveRename(e.currentTarget.value);
} else if (e.key === "Escape") {
cancelRef.current = true;
stopBlockRename();
}
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const headerTextElems: React.ReactElement[] = [];

// For terminal blocks, show frame:title as a name badge in the text area
if (useTermHeader && frameTitle) {
headerTextElems.push(
<div
key="frame-title"
className="block-frame-text shrink-0 opacity-70 cursor-pointer"
title="Right-click header to rename"
>
{frameTitle}
</div>
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (typeof headerTextUnion === "string") {
if (!util.isBlank(headerTextUnion)) {
headerTextElems.push(
Expand Down Expand Up @@ -116,9 +200,10 @@ type HeaderEndIconsProps = {
viewModel: ViewModel;
nodeModel: NodeModel;
blockId: string;
preview: boolean;
};

const HeaderEndIcons = React.memo(({ viewModel, nodeModel, blockId }: HeaderEndIconsProps) => {
const HeaderEndIcons = React.memo(({ viewModel, nodeModel, blockId, preview }: HeaderEndIconsProps) => {
const blockEnv = useWaveEnv<BlockEnv>();
const endIconButtons = util.useAtomValueSafe(viewModel?.endIconButtons);
const magnified = jotai.useAtomValue(nodeModel.isMagnified);
Expand Down Expand Up @@ -168,7 +253,7 @@ const HeaderEndIcons = React.memo(({ viewModel, nodeModel, blockId }: HeaderEndI
elemtype: "iconbutton",
icon: "cog",
title: "Settings",
click: (e) => handleHeaderContextMenu(e, blockId, viewModel, nodeModel, blockEnv),
click: (e) => handleHeaderContextMenu(e, blockId, viewModel, nodeModel, blockEnv, preview),
};
endIconsElem.push(<IconButton key="settings" decl={settingsDecl} className="block-frame-settings" />);
if (ephemeral) {
Expand Down Expand Up @@ -251,7 +336,7 @@ const BlockFrame_Header = ({
className={cn("block-frame-default-header", useTermHeader && "!pl-[2px]")}
data-role="block-header"
ref={dragHandleRef}
onContextMenu={(e) => handleHeaderContextMenu(e, nodeModel.blockId, viewModel, nodeModel, waveEnv)}
onContextMenu={(e) => handleHeaderContextMenu(e, nodeModel.blockId, viewModel, nodeModel, waveEnv, preview)}
>
{!useTermHeader && (
<>
Expand Down Expand Up @@ -286,7 +371,7 @@ const BlockFrame_Header = ({
</div>
)}
<HeaderTextElems viewModel={viewModel} blockId={nodeModel.blockId} preview={preview} error={error} />
<HeaderEndIcons viewModel={viewModel} nodeModel={nodeModel} blockId={nodeModel.blockId} />
<HeaderEndIcons viewModel={viewModel} nodeModel={nodeModel} blockId={nodeModel.blockId} preview={preview} />
</div>
);
};
Expand Down
15 changes: 15 additions & 0 deletions frontend/app/block/blockrenamestate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { globalStore } from "@/app/store/jotaiStore";
import * as jotai from "jotai";

export const renamingBlockIdAtom = jotai.atom<string | null>(null);

export function startBlockRename(blockId: string) {
globalStore.set(renamingBlockIdAtom, blockId);
}

export function stopBlockRename() {
globalStore.set(renamingBlockIdAtom, null);
}
55 changes: 55 additions & 0 deletions frontend/app/view/preview/preview-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,11 @@ export class PreviewModel implements ViewModel {
codeEditKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean;
env: PreviewEnv;

followTermIdAtom: Atom<string | null>;
followTermCwdAtom: Atom<string | null>;
followTermBidirAtom: Atom<boolean>;
followTermMenuDataAtom: PrimitiveAtom<{ pos: { x: number; y: number }; terms: { blockId: string; title: string }[]; currentFollowId: string | null; bidir: boolean } | null>;

constructor({ blockId, nodeModel, tabModel, waveEnv }: ViewModelInitType) {
this.viewType = "preview";
this.blockId = blockId;
Expand Down Expand Up @@ -334,6 +339,7 @@ export class PreviewModel implements ViewModel {
const isCeView = loadableSV.state == "hasData" && loadableSV.data.specializedView == "codeedit";
if (mimeType == "directory") {
const showHiddenFiles = get(this.showHiddenFiles);
const followTermId = get(this.followTermIdAtom);
return [
{
elemtype: "iconbutton",
Expand All @@ -343,6 +349,13 @@ export class PreviewModel implements ViewModel {
globalStore.set(this.showHiddenFiles, (prev) => !prev);
},
},
{
elemtype: "iconbutton",
icon: "link",
title: followTermId ? "Following Terminal (click to change or unlink)" : "Follow a Terminal",
iconColor: followTermId ? "var(--success-color)" : undefined,
click: (e: React.MouseEvent<any>) => this.showFollowTermMenu(e),
},
{
elemtype: "iconbutton",
icon: "arrows-rotate",
Expand Down Expand Up @@ -489,6 +502,48 @@ export class PreviewModel implements ViewModel {
});

this.noPadding = atom(true);
this.followTermIdAtom = atom<string | null>((get) => {
return (get(this.blockAtom)?.meta?.["preview:followtermid"] as string) ?? null;
});
this.followTermCwdAtom = atom<string | null>((get) => {
const termId = get(this.followTermIdAtom);
if (!termId) return null;
const termBlock = WOS.getObjectValue<Block>(WOS.makeORef("block", termId), get);
return (termBlock?.meta?.["cmd:cwd"] as string) ?? null;
});
this.followTermBidirAtom = atom<boolean>((get) => {
return (get(this.blockAtom)?.meta?.["preview:followterm:bidir"] as boolean) ?? false;
});
this.followTermMenuDataAtom = atom(null) as PrimitiveAtom<{ pos: { x: number; y: number }; terms: { blockId: string; title: string }[]; currentFollowId: string | null; bidir: boolean } | null>;
}

showFollowTermMenu(e: React.MouseEvent<any>) {
const tabData = globalStore.get(this.tabModel.tabAtom);
const blockIds = tabData?.blockids ?? [];

const terms: { blockId: string; title: string }[] = [];
let termIndex = 1;
for (const bid of blockIds) {
if (bid === this.blockId) continue;
const block = WOS.getObjectValue<Block>(WOS.makeORef("block", bid), globalStore.get);
if (block?.meta?.view === "term") {
terms.push({
blockId: bid,
title: (block?.meta?.["frame:title"] as string) || `Terminal ${termIndex}`,
});
termIndex++;
}
}

const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const currentFollowId = globalStore.get(this.followTermIdAtom);
const bidir = globalStore.get(this.followTermBidirAtom);
globalStore.set(this.followTermMenuDataAtom, {
pos: { x: rect.left, y: rect.bottom + 4 },
terms,
currentFollowId,
bidir,
});
}

markdownShowTocToggle() {
Expand Down
Loading