From 3fc400960bc1ae261b8b1d73de477f1065f16526 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Thu, 2 Jan 2025 10:06:47 -0800 Subject: [PATCH] terminal multi-input for tab (#1643) --- ROADMAP.md | 2 +- frontend/app/block/blockframe.tsx | 2 +- frontend/app/store/global.ts | 6 +++ frontend/app/store/keymodel.ts | 23 +++++++++ frontend/app/view/term/term.tsx | 78 +++++++++++++++++++++++++++++- frontend/app/view/term/termwrap.ts | 15 ++++-- frontend/types/custom.d.ts | 5 ++ 7 files changed, 124 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index cd4b5063a..4853080c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,7 +24,7 @@ Legend: ✅ Done | 🔧 In Progress | 🔷 Planned | 🤞 Stretch Goal - ✅ Search in Web Views - ✅ Search in the Terminal - 🔷 Custom init files for widgets and terminal blocks -- 🔧 Multi-Input between terminal blocks on the same tab +- ✅ Multi-Input between terminal blocks on the same tab - ✅ Gemini AI support - 🔷 Monaco Theming - 🤞 Blockcontroller fixes for terminal escape sequences diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 647702fc9..2963e17d7 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -292,7 +292,7 @@ const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; previe ); } else if (elem.elemtype == "textbutton") { return ( - ); diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 4ecc084ae..67ebe10de 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -164,6 +164,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { notifications: notificationsAtom, notificationPopoverMode: notificationPopoverModeAtom, reinitVersion, + isTermMultiInput: atom(false), }; } @@ -496,6 +497,10 @@ function getBlockComponentModel(blockId: string): BlockComponentModel { return blockComponentModelMap.get(blockId); } +function getAllBlockComponentModels(): BlockComponentModel[] { + return Array.from(blockComponentModelMap.values()); +} + function getFocusedBlockId(): string { const layoutModel = getLayoutModelForStaticTab(); const focusedLayoutNode = globalStore.get(layoutModel.focusedNode); @@ -665,6 +670,7 @@ export { createBlock, createTab, fetchWaveFile, + getAllBlockComponentModels, getApi, getBlockComponentModel, getBlockMetaKeyAtom, diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index f9f6d988a..cadbc8a43 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -5,6 +5,7 @@ import { atoms, createBlock, createTab, + getAllBlockComponentModels, getApi, getBlockComponentModel, globalStore, @@ -232,6 +233,19 @@ function tryReinjectKey(event: WaveKeyboardEvent): boolean { return appHandleKeyDown(event); } +function countTermBlocks(): number { + const allBCMs = getAllBlockComponentModels(); + let count = 0; + let gsGetBound = globalStore.get.bind(globalStore); + for (const bcm of allBCMs) { + const viewModel = bcm.viewModel; + if (viewModel.viewType == "term" && viewModel.isBasicTerm?.(gsGetBound)) { + count++; + } + } + return count; +} + function registerGlobalKeys() { globalKeyMap.set("Cmd:]", () => { switchTab(1); @@ -314,6 +328,15 @@ function registerGlobalKeys() { return true; } }); + globalKeyMap.set("Ctrl:Shift:i", () => { + const curMI = globalStore.get(atoms.isTermMultiInput); + if (!curMI && countTermBlocks() <= 1) { + // don't turn on multi-input unless there are 2 or more basic term blocks + return true; + } + globalStore.set(atoms.isTermMultiInput, !curMI); + return true; + }); for (let idx = 1; idx <= 9; idx++) { globalKeyMap.set(`Cmd:${idx}`, () => { switchTabAbs(idx); diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 5aaaf475a..654aa10a5 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -13,6 +13,7 @@ import { TermWshClient } from "@/app/view/term/term-wsh"; import { VDomModel } from "@/app/view/vdom/vdom-model"; import { atoms, + getAllBlockComponentModels, getBlockComponentModel, getBlockMetaKeyAtom, getConnStatusAtom, @@ -25,7 +26,7 @@ import { } from "@/store/global"; import * as services from "@/store/services"; import * as keyutil from "@/util/keyutil"; -import { boundNumber, fireAndForget, useAtomValueSafe } from "@/util/util"; +import { boundNumber, fireAndForget, stringToBase64, useAtomValueSafe } from "@/util/util"; import { ISearchOptions } from "@xterm/addon-search"; import clsx from "clsx"; import debug from "debug"; @@ -132,7 +133,7 @@ class TermViewModel implements ViewModel { ]; } const vdomBlockId = get(this.vdomBlockId); - const rtn = []; + const rtn: HeaderElem[] = []; if (vdomBlockId) { rtn.push({ elemtype: "iconbutton", @@ -189,6 +190,18 @@ class TermViewModel implements ViewModel { } } } + const isMI = get(atoms.isTermMultiInput); + if (isMI && this.isBasicTerm(get)) { + rtn.push({ + elemtype: "textbutton", + text: "Multi Input ON", + className: "yellow", + title: "Input will be sent to all connected terminals (click to disable)", + onClick: () => { + globalStore.set(atoms.isTermMultiInput, false); + }, + }); + } return rtn; }); this.manageConnection = jotai.atom((get) => { @@ -305,6 +318,36 @@ class TermViewModel implements ViewModel { }); } + isBasicTerm(getFn: jotai.Getter): boolean { + // needs to match "const isBasicTerm" in TerminalView() + const termMode = getFn(this.termMode); + if (termMode == "vdom") { + return false; + } + const blockData = getFn(this.blockAtom); + if (blockData?.meta?.controller == "cmd") { + return false; + } + return true; + } + + multiInputHandler(data: string) { + let tvms = getAllBasicTermModels(); + // filter out "this" from the list + tvms = tvms.filter((tvm) => tvm != this); + if (tvms.length == 0) { + return; + } + for (const tvm of tvms) { + tvm.sendDataToController(data); + } + } + + sendDataToController(data: string) { + const b64data = stringToBase64(data); + RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data }); + } + setTermMode(mode: "term" | "vdom") { if (mode == "term") { mode = null; @@ -643,6 +686,21 @@ class TermViewModel implements ViewModel { } } +function getAllBasicTermModels(): TermViewModel[] { + const allBCMs = getAllBlockComponentModels(); + const rtn: TermViewModel[] = []; + for (const bcm of allBCMs) { + if (bcm.viewModel?.viewType != "term") { + continue; + } + const termVM = bcm.viewModel as TermViewModel; + if (termVM.isBasicTerm(globalStore.get)) { + rtn.push(termVM); + } + } + return rtn; +} + function makeTerminalModel(blockId: string, nodeModel: BlockNodeModel): TermViewModel { return new TermViewModel(blockId, nodeModel); } @@ -791,6 +849,9 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { const termFontSize = jotai.useAtomValue(model.fontSizeAtom); const fullConfig = globalStore.get(atoms.fullConfigAtom); const connFontFamily = fullConfig.connections?.[blockData?.meta?.connection]?.["term:fontfamily"]; + const isFocused = jotai.useAtomValue(model.nodeModel.isFocused); + const isMI = jotai.useAtomValue(atoms.isTermMultiInput); + const isBasicTerm = termMode != "vdom" && blockData?.meta?.controller != "cmd"; // needs to match isBasicTerm // search const searchProps = useSearch({ @@ -898,6 +959,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { { keydownHandler: model.handleTerminalKeydown.bind(model), useWebGl: !termSettings?.["term:disablewebgl"], + sendDataHandler: model.sendDataToController.bind(model), } ); (window as any).term = termWrap; @@ -930,6 +992,18 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { termModeRef.current = termMode; }, [termMode]); + React.useEffect(() => { + if (isMI && isBasicTerm && isFocused && model.termRef.current != null) { + model.termRef.current.multiInputCallback = (data: string) => { + model.multiInputHandler(data); + }; + } else { + if (model.termRef.current != null) { + model.termRef.current.multiInputCallback = null; + } + } + }, [isMI, isBasicTerm, isFocused]); + const scrollbarHideObserverRef = React.useRef(null); const onScrollbarShowObserver = React.useCallback(() => { const termViewport = viewRef.current.getElementsByClassName("xterm-viewport")[0] as HTMLDivElement; diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 1b7e52a1b..203ce7133 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -7,7 +7,6 @@ import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { PLATFORM, WOS, atoms, fetchWaveFile, getSettingsKeyAtom, globalStore, openLink } from "@/store/global"; import * as services from "@/store/services"; -import * as util from "@/util/util"; import { base64ToArray, fireAndForget } from "@/util/util"; import { SearchAddon } from "@xterm/addon-search"; import { SerializeAddon } from "@xterm/addon-serialize"; @@ -42,6 +41,7 @@ let loggedWebGL = false; type TermWrapOptions = { keydownHandler?: (e: KeyboardEvent) => boolean; useWebGl?: boolean; + sendDataHandler?: (data: string) => void; }; export class TermWrap { @@ -58,6 +58,8 @@ export class TermWrap { heldData: Uint8Array[]; handleResize_debounced: () => void; hasResized: boolean; + multiInputCallback: (data: string) => void; + sendDataHandler: (data: string) => void; onSearchResultsDidChange?: (result: { resultIndex: number; resultCount: number }) => void; private toDispose: TermTypes.IDisposable[] = []; @@ -69,6 +71,7 @@ export class TermWrap { ) { this.loaded = false; this.blockId = blockId; + this.sendDataHandler = waveOptions.sendDataHandler; this.ptyOffset = 0; this.dataBytesProcessed = 0; this.hasResized = false; @@ -146,6 +149,7 @@ export class TermWrap { async initTerminal() { const copyOnSelectAtom = getSettingsKeyAtom("term:copyonselect"); this.toDispose.push(this.terminal.onData(this.handleTermData.bind(this))); + this.toDispose.push(this.terminal.onKey(this.onKeyHandler.bind(this))); this.toDispose.push( this.terminal.onSelectionChange( debounce(50, () => { @@ -186,8 +190,13 @@ export class TermWrap { if (!this.loaded) { return; } - const b64data = util.stringToBase64(data); - RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data }); + this.sendDataHandler?.(data); + } + + onKeyHandler(data: { key: string; domEvent: KeyboardEvent }) { + if (this.multiInputCallback) { + this.multiInputCallback(data.key); + } } addFocusListener(focusFn: () => void) { diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index c13b3f704..c16491347 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -27,6 +27,7 @@ declare global { notifications: jotai.PrimitiveAtom; notificationPopoverMode: jotia.atom; reinitVersion: jotai.PrimitiveAtom; + isTermMultiInput: jotai.PrimitiveAtom; }; type WritableWaveObjectAtom = jotai.WritableAtom; @@ -176,6 +177,7 @@ declare global { elemtype: "textbutton"; text: string; className?: string; + title?: string; onClick?: (e: React.MouseEvent) => void; }; @@ -260,6 +262,9 @@ declare global { filterOutNowsh?: jotai.Atom; searchAtoms?: SearchAtoms; + // just for terminal + isBasicTerm?: (getFn: jotai.Getter) => boolean; + onBack?: () => void; onForward?: () => void; getSettingsMenuItems?: () => ContextMenuItem[];