Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,16 @@ 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 make/mac-arm64/Wave\ Dev.app/Contents/MacOS/Wave\ Dev
```
`WAVETERM_HOME` gives the dev build a separate data directory (`~/.waveterm-dev`) from the vanilla install (`~/.waveterm`).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
- **Do not modify Info.plist or re-sign** the built app bundle — it breaks code signing on macOS and causes crashes.
58 changes: 58 additions & 0 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 @@ -42,6 +43,10 @@ function handleHeaderContextMenu(
e.stopPropagation();
const magnified = globalStore.get(nodeModel.isMagnified);
const menu: ContextMenuItem[] = [
{
label: "Rename Block",
click: () => startBlockRename(blockId),
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
{
label: magnified ? "Un-Magnify Block" : "Magnify Block",
click: () => {
Expand Down Expand Up @@ -78,11 +83,64 @@ 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 saveRename = React.useCallback(
(newTitle: string) => {
const val = newTitle.trim() || null;
waveEnv.rpc.SetMetaCommand(TabRpcClient, {
oref: WOS.makeORef("block", blockId),
meta: { "frame:title": val },
});
stopBlockRename();
},
[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]"
onBlur={(e) => saveRename(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
saveRename(e.currentTarget.value);
} else if (e.key === "Escape") {
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
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);
}
52 changes: 52 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,45 @@ 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 terms = blockIds
.filter((bid) => bid !== this.blockId)
.map((bid) => {
const block = WOS.getObjectValue<Block>(WOS.makeORef("block", bid), globalStore.get);
return { blockId: bid, block };
})
.filter(({ block }) => block?.meta?.view === "term")
.map(({ blockId: bid, block }, i) => ({
blockId: bid,
title: (block?.meta?.["frame:title"] as string) || `Terminal ${i + 1}`,
}));

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
149 changes: 147 additions & 2 deletions frontend/app/view/preview/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,28 @@
import { CenteredDiv } from "@/app/element/quickelems";
import { globalStore } from "@/app/store/jotaiStore";
import { TabRpcClient } from "@/app/store/wshrpcutil";

function shellEscapePath(path: string): string {
if (path === "~") return "~";
if (path.startsWith("~/")) {
// ~ must be unquoted to expand; single-quote the rest
return "~/" + "'" + path.slice(2).replace(/'/g, "'\\''") + "'";
}
return "'" + path.replace(/'/g, "'\\''") + "'";
}

async function sendCdToTerminal(termBlockId: string, path: string, env: import("./previewenv").PreviewEnv) {
const command = "\x15cd " + shellEscapePath(path) + "\r";
await env.rpc.ControllerInputCommand(TabRpcClient, { blockid: termBlockId, inputdata64: stringToBase64(command) });
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
import { BlockHeaderSuggestionControl } from "@/app/suggestion/suggestion";
import { useWaveEnv } from "@/app/waveenv/waveenv";
import { isBlank, makeConnRoute } from "@/util/util";
import { BlockModel } from "@/app/block/block-model";
import * as WOS from "@/store/wos";
import { fireAndForget, isBlank, makeConnRoute, stringToBase64 } from "@/util/util";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { memo, useEffect } from "react";
import { memo, useEffect, useRef } from "react";
import ReactDOM from "react-dom";
import { CSVView } from "./csvview";
import { DirectoryPreview } from "./preview-directory";
import { CodeEditPreview } from "./preview-edit";
Expand Down Expand Up @@ -96,6 +113,110 @@ const fetchSuggestions = async (
});
};

function FollowTermDropdown({ model }: { model: PreviewModel }) {
const menuData = useAtomValue(model.followTermMenuDataAtom);

if (!menuData) return null;

const { pos, terms, currentFollowId, bidir } = menuData;
const closeMenu = () => {
BlockModel.getInstance().setBlockHighlight(null);
globalStore.set(model.followTermMenuDataAtom, null);
};
const linkTerm = (blockId: string) => {
BlockModel.getInstance().setBlockHighlight(null);
fireAndForget(async () => {
await model.env.services.object.UpdateObjectMeta(WOS.makeORef("block", model.blockId), {
"preview:followtermid": blockId,
});
});
globalStore.set(model.followTermMenuDataAtom, null);
};
const toggleBidir = () => {
fireAndForget(async () => {
await model.env.services.object.UpdateObjectMeta(WOS.makeORef("block", model.blockId), {
"preview:followterm:bidir": !bidir,
});
});
globalStore.set(model.followTermMenuDataAtom, { ...menuData, bidir: !bidir });
};
const unlink = () => {
fireAndForget(async () => {
await model.env.services.object.UpdateObjectMeta(WOS.makeORef("block", model.blockId), {
"preview:followtermid": null,
"preview:followterm:bidir": null,
});
});
globalStore.set(model.followTermMenuDataAtom, null);
};

const dropdownStyle: React.CSSProperties = {
left: pos.x,
top: pos.y,
background: "var(--modal-bg-color)",
border: "1px solid var(--border-color)",
boxShadow: "0px 8px 24px 0px rgba(0,0,0,0.4)",
borderRadius: "var(--modal-border-radius)",
};
const dividerStyle: React.CSSProperties = { borderTop: "1px solid var(--border-color)" };

return ReactDOM.createPortal(
<>
<div className="fixed inset-0 z-[9998]" onMouseDown={closeMenu} />
<div className="fixed z-[9999] py-1 min-w-[200px] text-sm" style={dropdownStyle}>
{terms.length === 0 ? (
<div className="px-3 py-1.5 opacity-50">No terminals on this tab</div>
) : (
terms.map(({ blockId, title }) => (
<div
key={blockId}
className="px-3 py-1.5 cursor-pointer hover:bg-white/10 flex items-center gap-2"
onMouseEnter={() =>
BlockModel.getInstance().setBlockHighlight({ blockId, icon: "terminal" })
}
onMouseLeave={() => BlockModel.getInstance().setBlockHighlight(null)}
onMouseDown={(e) => e.stopPropagation()}
onClick={() => linkTerm(blockId)}
>
<i className="fa-sharp fa-solid fa-terminal opacity-50" />
{title}
</div>
))
)}
{currentFollowId && (
<>
<div className="my-1" style={dividerStyle} />
<div
className="px-3 py-1.5 cursor-pointer hover:bg-white/10 flex items-center gap-2"
onMouseDown={(e) => e.stopPropagation()}
onClick={toggleBidir}
>
<i
className={
bidir
? "fa-sharp fa-solid fa-square-check"
: "fa-sharp fa-regular fa-square"
}
style={{ color: bidir ? "var(--success-color)" : undefined, width: 14 }}
/>
Bidirectional
</div>
<div className="my-1" style={dividerStyle} />
<div
className="px-3 py-1.5 cursor-pointer hover:bg-white/10"
onMouseDown={(e) => e.stopPropagation()}
onClick={unlink}
>
Stop Following
</div>
</>
)}
</div>
</>,
document.body
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function PreviewView({
blockRef,
contentRef,
Expand All @@ -111,6 +232,11 @@ function PreviewView({
const [errorMsg, setErrorMsg] = useAtom(model.errorMsgAtom);
const connection = useAtomValue(model.connectionImmediate);
const fileInfo = useAtomValue(model.statFile);
const followTermId = useAtomValue(model.followTermIdAtom);
const followTermCwd = useAtomValue(model.followTermCwdAtom);
const followTermBidir = useAtomValue(model.followTermBidirAtom);
const loadableFileInfo = useAtomValue(model.loadableFileInfo);
const suppressBidirRef = useRef(false);

useEffect(() => {
console.log("fileInfo or connection changed", fileInfo, connection);
Expand All @@ -120,6 +246,24 @@ function PreviewView({
setErrorMsg(null);
}, [connection, fileInfo]);

useEffect(() => {
if (!followTermId || !followTermCwd) return;
suppressBidirRef.current = true;
fireAndForget(() => model.goHistory(followTermCwd));
}, [followTermCwd]);

useEffect(() => {
if (!followTermId || !followTermBidir) return;
if (suppressBidirRef.current) {
suppressBidirRef.current = false;
return;
}
if (loadableFileInfo.state !== "hasData") return;
const fi = loadableFileInfo.data;
if (!fi || fi.mimetype !== "directory" || !fi.path) return;
fireAndForget(() => sendCdToTerminal(followTermId, fi.path, env));
}, [loadableFileInfo]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

if (connStatus?.status != "connected") {
return null;
}
Expand Down Expand Up @@ -148,6 +292,7 @@ function PreviewView({

return (
<>
<FollowTermDropdown model={model} />
<div key="fullpreview" className="flex flex-col w-full overflow-hidden scrollbar-hide-until-hover">
{errorMsg && <ErrorOverlay errorMsg={errorMsg} resetOverlay={() => setErrorMsg(null)} />}
<div ref={contentRef} className="flex-grow overflow-hidden">
Expand Down
2 changes: 2 additions & 0 deletions frontend/types/gotypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1196,6 +1196,8 @@ declare global {
"web:useragenttype"?: string;
"markdown:fontsize"?: number;
"markdown:fixedfontsize"?: number;
"preview:followtermid"?: string;
"preview:followterm:bidir"?: boolean;
"tsunami:*"?: boolean;
"tsunami:sdkreplacepath"?: string;
"tsunami:apppath"?: string;
Expand Down
4 changes: 4 additions & 0 deletions launch_wave_dev.command
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/bin/bash
# Launch Wave (dev build) with a separate data directory
# Double-click this file in Finder to launch, or run from terminal.
WAVETERM_HOME="$HOME/.waveterm-dev" exec /Users/zmd2hi/Documents/00_personal/wave_term/make/mac-arm64/Wave\ Dev.app/Contents/MacOS/Wave\ Dev
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Loading