Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
frontend/dist
dist/
dist-dev/
.waveterm-dev/
frontend/node_modules
node_modules/
frontend/bindings
Expand Down
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.
111 changes: 103 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,92 @@ 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 sessionIdRef = React.useRef(0);

const saveRename = React.useCallback(
async (newTitle: string, sessionId: number) => {
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 },
});
if (sessionIdRef.current === sessionId) {
stopBlockRename();
}
} catch (error) {
console.error("Failed to save block rename:", error);
}
},
[blockId, waveEnv]
);

React.useEffect(() => {
if (isRenaming) {
sessionIdRef.current++;
}
}, [isRenaming]);

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, sessionIdRef.current);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
cancelRef.current = true;
saveRename(e.currentTarget.value, sessionIdRef.current);
} else if (e.key === "Escape") {
cancelRef.current = true;
stopBlockRename();
}
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 +210,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 +263,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 +346,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 +381,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