terminal multi-input for tab (#1643)

This commit is contained in:
Mike Sawka 2025-01-02 10:06:47 -08:00 committed by GitHub
parent e4c1ab6ea5
commit 3fc400960b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 124 additions and 7 deletions

View File

@ -24,7 +24,7 @@ Legend: ✅ Done | 🔧 In Progress | 🔷 Planned | 🤞 Stretch Goal
- ✅ Search in Web Views - ✅ Search in Web Views
- ✅ Search in the Terminal - ✅ Search in the Terminal
- 🔷 Custom init files for widgets and terminal blocks - 🔷 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 - ✅ Gemini AI support
- 🔷 Monaco Theming - 🔷 Monaco Theming
- 🤞 Blockcontroller fixes for terminal escape sequences - 🤞 Blockcontroller fixes for terminal escape sequences

View File

@ -292,7 +292,7 @@ const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; previe
); );
} else if (elem.elemtype == "textbutton") { } else if (elem.elemtype == "textbutton") {
return ( return (
<Button className={elem.className} onClick={(e) => elem.onClick(e)}> <Button className={elem.className} onClick={(e) => elem.onClick(e)} title={elem.title}>
{elem.text} {elem.text}
</Button> </Button>
); );

View File

@ -164,6 +164,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
notifications: notificationsAtom, notifications: notificationsAtom,
notificationPopoverMode: notificationPopoverModeAtom, notificationPopoverMode: notificationPopoverModeAtom,
reinitVersion, reinitVersion,
isTermMultiInput: atom(false),
}; };
} }
@ -496,6 +497,10 @@ function getBlockComponentModel(blockId: string): BlockComponentModel {
return blockComponentModelMap.get(blockId); return blockComponentModelMap.get(blockId);
} }
function getAllBlockComponentModels(): BlockComponentModel[] {
return Array.from(blockComponentModelMap.values());
}
function getFocusedBlockId(): string { function getFocusedBlockId(): string {
const layoutModel = getLayoutModelForStaticTab(); const layoutModel = getLayoutModelForStaticTab();
const focusedLayoutNode = globalStore.get(layoutModel.focusedNode); const focusedLayoutNode = globalStore.get(layoutModel.focusedNode);
@ -665,6 +670,7 @@ export {
createBlock, createBlock,
createTab, createTab,
fetchWaveFile, fetchWaveFile,
getAllBlockComponentModels,
getApi, getApi,
getBlockComponentModel, getBlockComponentModel,
getBlockMetaKeyAtom, getBlockMetaKeyAtom,

View File

@ -5,6 +5,7 @@ import {
atoms, atoms,
createBlock, createBlock,
createTab, createTab,
getAllBlockComponentModels,
getApi, getApi,
getBlockComponentModel, getBlockComponentModel,
globalStore, globalStore,
@ -232,6 +233,19 @@ function tryReinjectKey(event: WaveKeyboardEvent): boolean {
return appHandleKeyDown(event); 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() { function registerGlobalKeys() {
globalKeyMap.set("Cmd:]", () => { globalKeyMap.set("Cmd:]", () => {
switchTab(1); switchTab(1);
@ -314,6 +328,15 @@ function registerGlobalKeys() {
return true; 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++) { for (let idx = 1; idx <= 9; idx++) {
globalKeyMap.set(`Cmd:${idx}`, () => { globalKeyMap.set(`Cmd:${idx}`, () => {
switchTabAbs(idx); switchTabAbs(idx);

View File

@ -13,6 +13,7 @@ import { TermWshClient } from "@/app/view/term/term-wsh";
import { VDomModel } from "@/app/view/vdom/vdom-model"; import { VDomModel } from "@/app/view/vdom/vdom-model";
import { import {
atoms, atoms,
getAllBlockComponentModels,
getBlockComponentModel, getBlockComponentModel,
getBlockMetaKeyAtom, getBlockMetaKeyAtom,
getConnStatusAtom, getConnStatusAtom,
@ -25,7 +26,7 @@ import {
} from "@/store/global"; } from "@/store/global";
import * as services from "@/store/services"; import * as services from "@/store/services";
import * as keyutil from "@/util/keyutil"; 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 { ISearchOptions } from "@xterm/addon-search";
import clsx from "clsx"; import clsx from "clsx";
import debug from "debug"; import debug from "debug";
@ -132,7 +133,7 @@ class TermViewModel implements ViewModel {
]; ];
} }
const vdomBlockId = get(this.vdomBlockId); const vdomBlockId = get(this.vdomBlockId);
const rtn = []; const rtn: HeaderElem[] = [];
if (vdomBlockId) { if (vdomBlockId) {
rtn.push({ rtn.push({
elemtype: "iconbutton", 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; return rtn;
}); });
this.manageConnection = jotai.atom((get) => { 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") { setTermMode(mode: "term" | "vdom") {
if (mode == "term") { if (mode == "term") {
mode = null; 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 { function makeTerminalModel(blockId: string, nodeModel: BlockNodeModel): TermViewModel {
return new TermViewModel(blockId, nodeModel); return new TermViewModel(blockId, nodeModel);
} }
@ -791,6 +849,9 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
const termFontSize = jotai.useAtomValue(model.fontSizeAtom); const termFontSize = jotai.useAtomValue(model.fontSizeAtom);
const fullConfig = globalStore.get(atoms.fullConfigAtom); const fullConfig = globalStore.get(atoms.fullConfigAtom);
const connFontFamily = fullConfig.connections?.[blockData?.meta?.connection]?.["term:fontfamily"]; 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 // search
const searchProps = useSearch({ const searchProps = useSearch({
@ -898,6 +959,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
{ {
keydownHandler: model.handleTerminalKeydown.bind(model), keydownHandler: model.handleTerminalKeydown.bind(model),
useWebGl: !termSettings?.["term:disablewebgl"], useWebGl: !termSettings?.["term:disablewebgl"],
sendDataHandler: model.sendDataToController.bind(model),
} }
); );
(window as any).term = termWrap; (window as any).term = termWrap;
@ -930,6 +992,18 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
termModeRef.current = termMode; termModeRef.current = termMode;
}, [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<HTMLDivElement>(null); const scrollbarHideObserverRef = React.useRef<HTMLDivElement>(null);
const onScrollbarShowObserver = React.useCallback(() => { const onScrollbarShowObserver = React.useCallback(() => {
const termViewport = viewRef.current.getElementsByClassName("xterm-viewport")[0] as HTMLDivElement; const termViewport = viewRef.current.getElementsByClassName("xterm-viewport")[0] as HTMLDivElement;

View File

@ -7,7 +7,6 @@ import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil"; import { TabRpcClient } from "@/app/store/wshrpcutil";
import { PLATFORM, WOS, atoms, fetchWaveFile, getSettingsKeyAtom, globalStore, openLink } from "@/store/global"; import { PLATFORM, WOS, atoms, fetchWaveFile, getSettingsKeyAtom, globalStore, openLink } from "@/store/global";
import * as services from "@/store/services"; import * as services from "@/store/services";
import * as util from "@/util/util";
import { base64ToArray, fireAndForget } from "@/util/util"; import { base64ToArray, fireAndForget } from "@/util/util";
import { SearchAddon } from "@xterm/addon-search"; import { SearchAddon } from "@xterm/addon-search";
import { SerializeAddon } from "@xterm/addon-serialize"; import { SerializeAddon } from "@xterm/addon-serialize";
@ -42,6 +41,7 @@ let loggedWebGL = false;
type TermWrapOptions = { type TermWrapOptions = {
keydownHandler?: (e: KeyboardEvent) => boolean; keydownHandler?: (e: KeyboardEvent) => boolean;
useWebGl?: boolean; useWebGl?: boolean;
sendDataHandler?: (data: string) => void;
}; };
export class TermWrap { export class TermWrap {
@ -58,6 +58,8 @@ export class TermWrap {
heldData: Uint8Array[]; heldData: Uint8Array[];
handleResize_debounced: () => void; handleResize_debounced: () => void;
hasResized: boolean; hasResized: boolean;
multiInputCallback: (data: string) => void;
sendDataHandler: (data: string) => void;
onSearchResultsDidChange?: (result: { resultIndex: number; resultCount: number }) => void; onSearchResultsDidChange?: (result: { resultIndex: number; resultCount: number }) => void;
private toDispose: TermTypes.IDisposable[] = []; private toDispose: TermTypes.IDisposable[] = [];
@ -69,6 +71,7 @@ export class TermWrap {
) { ) {
this.loaded = false; this.loaded = false;
this.blockId = blockId; this.blockId = blockId;
this.sendDataHandler = waveOptions.sendDataHandler;
this.ptyOffset = 0; this.ptyOffset = 0;
this.dataBytesProcessed = 0; this.dataBytesProcessed = 0;
this.hasResized = false; this.hasResized = false;
@ -146,6 +149,7 @@ export class TermWrap {
async initTerminal() { async initTerminal() {
const copyOnSelectAtom = getSettingsKeyAtom("term:copyonselect"); const copyOnSelectAtom = getSettingsKeyAtom("term:copyonselect");
this.toDispose.push(this.terminal.onData(this.handleTermData.bind(this))); this.toDispose.push(this.terminal.onData(this.handleTermData.bind(this)));
this.toDispose.push(this.terminal.onKey(this.onKeyHandler.bind(this)));
this.toDispose.push( this.toDispose.push(
this.terminal.onSelectionChange( this.terminal.onSelectionChange(
debounce(50, () => { debounce(50, () => {
@ -186,8 +190,13 @@ export class TermWrap {
if (!this.loaded) { if (!this.loaded) {
return; return;
} }
const b64data = util.stringToBase64(data); this.sendDataHandler?.(data);
RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data }); }
onKeyHandler(data: { key: string; domEvent: KeyboardEvent }) {
if (this.multiInputCallback) {
this.multiInputCallback(data.key);
}
} }
addFocusListener(focusFn: () => void) { addFocusListener(focusFn: () => void) {

View File

@ -27,6 +27,7 @@ declare global {
notifications: jotai.PrimitiveAtom<NotificationType[]>; notifications: jotai.PrimitiveAtom<NotificationType[]>;
notificationPopoverMode: jotia.atom<boolean>; notificationPopoverMode: jotia.atom<boolean>;
reinitVersion: jotai.PrimitiveAtom<number>; reinitVersion: jotai.PrimitiveAtom<number>;
isTermMultiInput: jotai.PrimitiveAtom<boolean>;
}; };
type WritableWaveObjectAtom<T extends WaveObj> = jotai.WritableAtom<T, [value: T], void>; type WritableWaveObjectAtom<T extends WaveObj> = jotai.WritableAtom<T, [value: T], void>;
@ -176,6 +177,7 @@ declare global {
elemtype: "textbutton"; elemtype: "textbutton";
text: string; text: string;
className?: string; className?: string;
title?: string;
onClick?: (e: React.MouseEvent<any>) => void; onClick?: (e: React.MouseEvent<any>) => void;
}; };
@ -260,6 +262,9 @@ declare global {
filterOutNowsh?: jotai.Atom<boolean>; filterOutNowsh?: jotai.Atom<boolean>;
searchAtoms?: SearchAtoms; searchAtoms?: SearchAtoms;
// just for terminal
isBasicTerm?: (getFn: jotai.Getter) => boolean;
onBack?: () => void; onBack?: () => void;
onForward?: () => void; onForward?: () => void;
getSettingsMenuItems?: () => ContextMenuItem[]; getSettingsMenuItems?: () => ContextMenuItem[];