Skip to content

Commit 5b94b95

Browse files
authored
add context menu items for terminal (splits, open url, themes, file browser, etc) (#2745)
1 parent 7970507 commit 5b94b95

File tree

4 files changed

+219
-42
lines changed

4 files changed

+219
-42
lines changed

frontend/app/block/blocktypes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import { Atom } from "jotai";
77
export interface BlockNodeModel {
88
blockId: string;
99
isFocused: Atom<boolean>;
10+
isMagnified: Atom<boolean>;
1011
onClose: () => void;
1112
focusNode: () => void;
13+
toggleMagnify: () => void;
1214
}
1315

1416
export type FullBlockProps = {

frontend/app/view/term/term-model.ts

Lines changed: 169 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright 2025, Command Line Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
import { WaveAIModel } from "@/app/aipanel/waveai-model";
45
import { BlockNodeModel } from "@/app/block/blocktypes";
56
import { appHandleKeyDown } from "@/app/store/keymodel";
67
import type { TabModel } from "@/app/store/tab-model";
@@ -14,6 +15,9 @@ import { VDomModel } from "@/app/view/vdom/vdom-model";
1415
import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
1516
import {
1617
atoms,
18+
createBlock,
19+
createBlockSplitHorizontally,
20+
createBlockSplitVertically,
1721
getAllBlockComponentModels,
1822
getApi,
1923
getBlockComponentModel,
@@ -663,6 +667,97 @@ export class TermViewModel implements ViewModel {
663667
prtn.catch((e) => console.log("error controller resync (force restart)", e));
664668
}
665669

670+
getContextMenuItems(): ContextMenuItem[] {
671+
const menu: ContextMenuItem[] = [];
672+
const hasSelection = this.termRef.current?.terminal?.hasSelection();
673+
const selection = hasSelection ? this.termRef.current?.terminal.getSelection() : null;
674+
675+
if (hasSelection) {
676+
menu.push({
677+
label: "Copy",
678+
click: () => {
679+
if (selection) {
680+
navigator.clipboard.writeText(selection);
681+
}
682+
},
683+
});
684+
menu.push({ type: "separator" });
685+
menu.push({
686+
label: "Send to Wave AI",
687+
click: () => {
688+
if (selection) {
689+
const aiModel = WaveAIModel.getInstance();
690+
aiModel.appendText(selection, true, { scrollToBottom: true });
691+
const layoutModel = WorkspaceLayoutModel.getInstance();
692+
if (!layoutModel.getAIPanelVisible()) {
693+
layoutModel.setAIPanelVisible(true);
694+
}
695+
aiModel.focusInput();
696+
}
697+
},
698+
});
699+
700+
let selectionURL: URL = null;
701+
if (selection) {
702+
try {
703+
const trimmedSelection = selection.trim();
704+
const url = new URL(trimmedSelection);
705+
if (url.protocol.startsWith("http")) {
706+
selectionURL = url;
707+
}
708+
} catch (e) {
709+
// not a valid URL
710+
}
711+
}
712+
713+
if (selectionURL) {
714+
menu.push({ type: "separator" });
715+
menu.push({
716+
label: "Open URL (" + selectionURL.hostname + ")",
717+
click: () => {
718+
createBlock({
719+
meta: {
720+
view: "web",
721+
url: selectionURL.toString(),
722+
},
723+
});
724+
},
725+
});
726+
menu.push({
727+
label: "Open URL in External Browser",
728+
click: () => {
729+
getApi().openExternal(selectionURL.toString());
730+
},
731+
});
732+
}
733+
menu.push({ type: "separator" });
734+
}
735+
736+
menu.push({
737+
label: "Paste",
738+
click: () => {
739+
getApi().nativePaste();
740+
},
741+
});
742+
743+
menu.push({ type: "separator" });
744+
745+
const magnified = globalStore.get(this.nodeModel.isMagnified);
746+
menu.push({
747+
label: magnified ? "Un-Magnify Block" : "Magnify Block",
748+
click: () => {
749+
this.nodeModel.toggleMagnify();
750+
},
751+
});
752+
753+
menu.push({ type: "separator" });
754+
755+
const settingsItems = this.getSettingsMenuItems();
756+
menu.push(...settingsItems);
757+
758+
return menu;
759+
}
760+
666761
getSettingsMenuItems(): ContextMenuItem[] {
667762
const fullConfig = globalStore.get(atoms.fullConfigAtom);
668763
const termThemes = fullConfig?.termthemes ?? {};
@@ -677,7 +772,61 @@ export class TermViewModel implements ViewModel {
677772
termThemeKeys.sort((a, b) => {
678773
return (termThemes[a]["display:order"] ?? 0) - (termThemes[b]["display:order"] ?? 0);
679774
});
775+
const defaultTermBlockDef: BlockDef = {
776+
meta: {
777+
view: "term",
778+
controller: "shell",
779+
},
780+
};
781+
680782
const fullMenu: ContextMenuItem[] = [];
783+
fullMenu.push({
784+
label: "Split Horizontally",
785+
click: () => {
786+
const blockData = globalStore.get(this.blockAtom);
787+
const blockDef: BlockDef = {
788+
meta: blockData?.meta || defaultTermBlockDef.meta,
789+
};
790+
createBlockSplitHorizontally(blockDef, this.blockId, "after");
791+
},
792+
});
793+
fullMenu.push({
794+
label: "Split Vertically",
795+
click: () => {
796+
const blockData = globalStore.get(this.blockAtom);
797+
const blockDef: BlockDef = {
798+
meta: blockData?.meta || defaultTermBlockDef.meta,
799+
};
800+
createBlockSplitVertically(blockDef, this.blockId, "after");
801+
},
802+
});
803+
fullMenu.push({ type: "separator" });
804+
805+
const shellIntegrationStatus = globalStore.get(this.termRef?.current?.shellIntegrationStatusAtom);
806+
const cwd = blockData?.meta?.["cmd:cwd"];
807+
const canShowFileBrowser = shellIntegrationStatus === "ready" && cwd != null;
808+
809+
if (canShowFileBrowser) {
810+
fullMenu.push({
811+
label: "File Browser",
812+
click: () => {
813+
const blockData = globalStore.get(this.blockAtom);
814+
const connection = blockData?.meta?.connection;
815+
const cwd = blockData?.meta?.["cmd:cwd"];
816+
const meta: Record<string, any> = {
817+
view: "preview",
818+
file: cwd,
819+
};
820+
if (connection) {
821+
meta.connection = connection;
822+
}
823+
const blockDef: BlockDef = { meta };
824+
createBlock(blockDef);
825+
},
826+
});
827+
fullMenu.push({ type: "separator" });
828+
}
829+
681830
const submenu: ContextMenuItem[] = termThemeKeys.map((themeName) => {
682831
return {
683832
label: termThemes[themeName]["display:name"] ?? themeName,
@@ -765,8 +914,10 @@ export class TermViewModel implements ViewModel {
765914
label: "Transparency",
766915
submenu: transparencySubMenu,
767916
});
917+
fullMenu.push({ type: "separator" });
918+
const advancedSubmenu: ContextMenuItem[] = [];
768919
const allowBracketedPaste = blockData?.meta?.["term:allowbracketedpaste"];
769-
fullMenu.push({
920+
advancedSubmenu.push({
770921
label: "Allow Bracketed Paste Mode",
771922
submenu: [
772923
{
@@ -804,13 +955,12 @@ export class TermViewModel implements ViewModel {
804955
},
805956
],
806957
});
807-
fullMenu.push({ type: "separator" });
808-
fullMenu.push({
958+
advancedSubmenu.push({
809959
label: "Force Restart Controller",
810960
click: this.forceRestartController.bind(this),
811961
});
812962
const isClearOnStart = blockData?.meta?.["cmd:clearonstart"];
813-
fullMenu.push({
963+
advancedSubmenu.push({
814964
label: "Clear Output On Restart",
815965
submenu: [
816966
{
@@ -838,7 +988,7 @@ export class TermViewModel implements ViewModel {
838988
],
839989
});
840990
const runOnStart = blockData?.meta?.["cmd:runonstart"];
841-
fullMenu.push({
991+
advancedSubmenu.push({
842992
label: "Run On Startup",
843993
submenu: [
844994
{
@@ -865,17 +1015,8 @@ export class TermViewModel implements ViewModel {
8651015
},
8661016
],
8671017
});
868-
if (blockData?.meta?.["term:vdomtoolbarblockid"]) {
869-
fullMenu.push({ type: "separator" });
870-
fullMenu.push({
871-
label: "Close Toolbar",
872-
click: () => {
873-
RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: blockData.meta["term:vdomtoolbarblockid"] });
874-
},
875-
});
876-
}
8771018
const debugConn = blockData?.meta?.["term:conndebug"];
878-
fullMenu.push({
1019+
advancedSubmenu.push({
8791020
label: "Debug Connection",
8801021
submenu: [
8811022
{
@@ -913,6 +1054,19 @@ export class TermViewModel implements ViewModel {
9131054
},
9141055
],
9151056
});
1057+
fullMenu.push({
1058+
label: "Advanced",
1059+
submenu: advancedSubmenu,
1060+
});
1061+
if (blockData?.meta?.["term:vdomtoolbarblockid"]) {
1062+
fullMenu.push({ type: "separator" });
1063+
fullMenu.push({
1064+
label: "Close Toolbar",
1065+
click: () => {
1066+
RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: blockData.meta["term:vdomtoolbarblockid"] });
1067+
},
1068+
});
1069+
}
9161070
return fullMenu;
9171071
}
9181072
}

frontend/app/view/term/term.tsx

Lines changed: 47 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { Block, SubBlock } from "@/app/block/block";
5+
import type { BlockNodeModel } from "@/app/block/blocktypes";
56
import { Search, useSearch } from "@/app/element/search";
7+
import { ContextMenuModel } from "@/app/store/contextmenu";
68
import { useTabModel } from "@/app/store/tab-model";
79
import { waveEventSubscribe } from "@/app/store/wps";
810
import { RpcApi } from "@/app/store/wshclientapi";
@@ -70,16 +72,21 @@ const TermVDomToolbarNode = ({ vdomBlockId, blockId, model }: TerminalViewProps
7072
unsub();
7173
};
7274
}, []);
73-
let vdomNodeModel = {
74-
blockId: vdomBlockId,
75-
isFocused: jotai.atom(false),
76-
focusNode: () => {},
77-
onClose: () => {
78-
if (vdomBlockId != null) {
79-
RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: vdomBlockId });
80-
}
81-
},
82-
};
75+
const vdomNodeModel: BlockNodeModel = React.useMemo(
76+
() => ({
77+
blockId: vdomBlockId,
78+
isFocused: jotai.atom(false),
79+
isMagnified: jotai.atom(false),
80+
focusNode: () => {},
81+
toggleMagnify: () => {},
82+
onClose: () => {
83+
if (vdomBlockId != null) {
84+
RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: vdomBlockId });
85+
}
86+
},
87+
}),
88+
[vdomBlockId]
89+
);
8390
const toolbarTarget = jotai.useAtomValue(model.vdomToolbarTarget);
8491
const heightStr = toolbarTarget?.height ?? "1.5em";
8592
return (
@@ -108,21 +115,25 @@ const TermVDomNodeSingleId = ({ vdomBlockId, blockId, model }: TerminalViewProps
108115
unsub();
109116
};
110117
}, []);
111-
const isFocusedAtom = jotai.atom((get) => {
112-
return get(model.nodeModel.isFocused) && get(model.termMode) == "vdom";
113-
});
114-
let vdomNodeModel = {
115-
blockId: vdomBlockId,
116-
isFocused: isFocusedAtom,
117-
focusNode: () => {
118-
model.nodeModel.focusNode();
119-
},
120-
onClose: () => {
121-
if (vdomBlockId != null) {
122-
RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: vdomBlockId });
123-
}
124-
},
125-
};
118+
const vdomNodeModel: BlockNodeModel = React.useMemo(() => {
119+
const isFocusedAtom = jotai.atom((get) => {
120+
return get(model.nodeModel.isFocused) && get(model.termMode) == "vdom";
121+
});
122+
return {
123+
blockId: vdomBlockId,
124+
isFocused: isFocusedAtom,
125+
isMagnified: jotai.atom(false),
126+
focusNode: () => {
127+
model.nodeModel.focusNode();
128+
},
129+
toggleMagnify: () => {},
130+
onClose: () => {
131+
if (vdomBlockId != null) {
132+
RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: vdomBlockId });
133+
}
134+
},
135+
};
136+
}, [vdomBlockId, model]);
126137
return (
127138
<div key="htmlElem" className="term-htmlelem">
128139
<SubBlock key="vdom" nodeModel={vdomNodeModel} />
@@ -353,8 +364,18 @@ const TerminalView = ({ blockId, model }: ViewComponentProps<TermViewModel>) =>
353364

354365
const termBg = computeBgStyleFromMeta(blockData?.meta);
355366

367+
const handleContextMenu = React.useCallback(
368+
(e: React.MouseEvent<HTMLDivElement>) => {
369+
e.preventDefault();
370+
e.stopPropagation();
371+
const menuItems = model.getContextMenuItems();
372+
ContextMenuModel.showContextMenu(menuItems, e);
373+
},
374+
[model]
375+
);
376+
356377
return (
357-
<div className={clsx("view-term", "term-mode-" + termMode)} ref={viewRef}>
378+
<div className={clsx("view-term", "term-mode-" + termMode)} ref={viewRef} onContextMenu={handleContextMenu}>
358379
{termBg && <div className="absolute inset-0 z-0 pointer-events-none" style={termBg} />}
359380
<TermResyncHandler blockId={blockId} model={model} />
360381
<TermThemeUpdater blockId={blockId} model={model} termRef={model.termRef} />

frontend/util/previewutil.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export function addOpenMenuItems(menu: ContextMenuItem[], conn: string, finfo: F
5959
// TODO: improve behavior as we add more connection types
6060
if (!conn?.startsWith("aws:")) {
6161
menu.push({
62-
label: "Open Terminal in New Block",
62+
label: "Open Terminal Here",
6363
click: () => {
6464
const termBlockDef: BlockDef = {
6565
meta: {

0 commit comments

Comments
 (0)