Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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 },
});
} catch (error) {
console.error("Failed to save block rename:", error);
}
stopBlockRename();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Silent RPC failure leaves user thinking rename succeeded.

On SetMetaCommand failure, the error is logged but stopBlockRename() still runs, so the input closes and the pre-existing frame:title stays — the user has no indication their edit was lost. Consider surfacing a toast/notification, or keeping the input open on failure so the user can retry.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/block/blockframe-header.tsx` around lines 111 - 119, The RPC
failure is currently only logged but stopBlockRename() is always called, closing
the input and hiding the failed save; change the flow so stopBlockRename() is
only invoked after a successful waveEnv.rpc.SetMetaCommand(TabRpcClient, ...)
and on catch keep the input open and surface an error notification to the user
(use the app's existing notification/toast utility or add a showToast/errorToast
call) so they can retry; ensure you reference the same parameters (oref via
WOS.makeORef("block", blockId) and meta "frame:title": val) and do not swallow
the caught error — pass its message into the toast.

},
[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();
}
}}
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 +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);
}
57 changes: 57 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,50 @@ 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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

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

const termBlockIds = blockIds
.filter((bid) => {
if (bid === this.blockId) return false;
const block = WOS.getObjectValue<Block>(WOS.makeORef("block", bid), globalStore.get);
return block?.meta?.view === "term";
});

const terms = termBlockIds.map((bid) => {
const block = WOS.getObjectValue<Block>(WOS.makeORef("block", bid), globalStore.get);
const termIndex = termBlockIds.indexOf(bid) + 1;
return {
blockId: bid,
title: (block?.meta?.["frame:title"] as string) || `Terminal ${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