From 6b15bf164ad554df982b74192195afc0aab646d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 05:20:26 +0000 Subject: [PATCH 1/8] Initial plan From e0355954854688b91ce30608b82ac92df94a1d16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 05:34:37 +0000 Subject: [PATCH 2/8] feat(tsunami): add wave term component and direct terminal io path Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- tsunami/app/defaultclient.go | 12 ++ tsunami/engine/clientimpl.go | 29 ++++ tsunami/engine/serverhandlers.go | 36 +++++ tsunami/frontend/src/element/tsunamiterm.tsx | 142 +++++++++++++++++++ tsunami/frontend/src/model/tsunami-model.tsx | 10 ++ tsunami/frontend/src/vdom.tsx | 7 + tsunami/rpctypes/protocoltypes.go | 16 +++ 7 files changed, 252 insertions(+) create mode 100644 tsunami/frontend/src/element/tsunamiterm.tsx diff --git a/tsunami/app/defaultclient.go b/tsunami/app/defaultclient.go index e033bfd2a6..35c1e95545 100644 --- a/tsunami/app/defaultclient.go +++ b/tsunami/app/defaultclient.go @@ -16,6 +16,7 @@ import ( "time" "github.com/wavetermdev/waveterm/tsunami/engine" + "github.com/wavetermdev/waveterm/tsunami/rpctypes" "github.com/wavetermdev/waveterm/tsunami/util" "github.com/wavetermdev/waveterm/tsunami/vdom" ) @@ -64,6 +65,17 @@ func SendAsyncInitiation() error { return engine.GetDefaultClient().SendAsyncInitiation() } +type TermSize = rpctypes.TermSize +type TermInputPacket = rpctypes.TermInputPacket + +func SetTermInputHandler(handler func(input TermInputPacket)) { + engine.GetDefaultClient().SetTermInputHandler(handler) +} + +func TermWrite(id string, data64 string) error { + return engine.GetDefaultClient().SendTermWrite(id, data64) +} + func ConfigAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] { fullName := "$config." + name client := engine.GetDefaultClient() diff --git a/tsunami/engine/clientimpl.go b/tsunami/engine/clientimpl.go index 79c760e98b..10c90f25aa 100644 --- a/tsunami/engine/clientimpl.go +++ b/tsunami/engine/clientimpl.go @@ -72,6 +72,7 @@ type ClientImpl struct { SSEChannels map[string]chan ssEvent // map of connectionId to SSE channel SSEChannelsLock *sync.Mutex GlobalEventHandler func(event vdom.VDomEvent) + TermInputHandler func(input rpctypes.TermInputPacket) UrlHandlerMux *http.ServeMux AppInitFn func() error AssetsFS fs.FS @@ -157,6 +158,12 @@ func (c *ClientImpl) SetGlobalEventHandler(handler func(event vdom.VDomEvent)) { c.GlobalEventHandler = handler } +func (c *ClientImpl) SetTermInputHandler(handler func(input rpctypes.TermInputPacket)) { + c.Lock.Lock() + defer c.Lock.Unlock() + c.TermInputHandler = handler +} + func (c *ClientImpl) getFaviconPath() string { if c.StaticFS != nil { faviconNames := []string{"favicon.ico", "favicon.png", "favicon.svg", "favicon.gif", "favicon.jpg"} @@ -304,6 +311,28 @@ func (c *ClientImpl) SendAsyncInitiation() error { return c.SendSSEvent(ssEvent{Event: "asyncinitiation", Data: nil}) } +func (c *ClientImpl) SendTermWrite(id string, data64 string) error { + payload := rpctypes.TermWritePacket{ + Id: id, + Data64: data64, + } + data, err := json.Marshal(payload) + if err != nil { + return err + } + return c.SendSSEvent(ssEvent{Event: "termwrite", Data: data}) +} + +func (c *ClientImpl) HandleTermInput(input rpctypes.TermInputPacket) { + c.Lock.Lock() + handler := c.TermInputHandler + c.Lock.Unlock() + if handler == nil { + return + } + handler(input) +} + func makeNullRendered() *rpctypes.RenderedElem { return &rpctypes.RenderedElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag} } diff --git a/tsunami/engine/serverhandlers.go b/tsunami/engine/serverhandlers.go index 5c5325610c..5f3103be07 100644 --- a/tsunami/engine/serverhandlers.go +++ b/tsunami/engine/serverhandlers.go @@ -83,6 +83,7 @@ func (h *httpHandlers) registerHandlers(mux *http.ServeMux, opts handlerOpts) { mux.HandleFunc("/api/schemas", h.handleSchemas) mux.HandleFunc("/api/manifest", h.handleManifest(opts.ManifestFile)) mux.HandleFunc("/api/modalresult", h.handleModalResult) + mux.HandleFunc("/api/terminput", h.handleTermInput) mux.HandleFunc("/dyn/", h.handleDynContent) // Add handler for static files at /static/ path @@ -392,6 +393,41 @@ func (h *httpHandlers) handleModalResult(w http.ResponseWriter, r *http.Request) json.NewEncoder(w).Encode(map[string]any{"success": true}) } +func (h *httpHandlers) handleTermInput(w http.ResponseWriter, r *http.Request) { + defer func() { + panicErr := util.PanicHandler("handleTermInput", recover()) + if panicErr != nil { + http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) + } + }() + + setNoCacheHeaders(w) + + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest) + return + } + + var input rpctypes.TermInputPacket + if err := json.Unmarshal(body, &input); err != nil { + http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest) + return + } + if strings.TrimSpace(input.Id) == "" { + http.Error(w, "id is required", http.StatusBadRequest) + return + } + + h.Client.HandleTermInput(input) + w.WriteHeader(http.StatusNoContent) +} + func (h *httpHandlers) handleDynContent(w http.ResponseWriter, r *http.Request) { defer func() { panicErr := util.PanicHandler("handleDynContent", recover()) diff --git a/tsunami/frontend/src/element/tsunamiterm.tsx b/tsunami/frontend/src/element/tsunamiterm.tsx new file mode 100644 index 0000000000..5c52f1a262 --- /dev/null +++ b/tsunami/frontend/src/element/tsunamiterm.tsx @@ -0,0 +1,142 @@ +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal } from "@xterm/xterm"; +import "@xterm/xterm/css/xterm.css"; +import * as React from "react"; + +import { base64ToArray, stringToBase64 } from "@/util/base64"; + +const TermWriteEventName = "tsunami:termwrite"; + +type TermSize = { + rows: number; + cols: number; +}; + +type TermInputPayload = { + id: string; + termsize?: TermSize; + data64?: string; +}; + +type TermWritePayload = { + id: string; + data64: string; +}; + +async function sendTermInput(payload: TermInputPayload) { + const response = await fetch("/api/terminput", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error(`terminal input request failed: ${response.status} ${response.statusText}`); + } +} + +const TsunamiTerm = React.forwardRef>(function TsunamiTerm( + props, + ref +) { + const { id, ...outerProps } = props; + const outerRef = React.useRef(null); + const termRef = React.useRef(null); + const terminalRef = React.useRef(null); + + const setOuterRef = React.useCallback( + (elem: HTMLDivElement) => { + outerRef.current = elem; + if (typeof ref === "function") { + ref(elem); + return; + } + if (ref != null) { + ref.current = elem; + } + }, + [ref] + ); + + React.useEffect(() => { + if (termRef.current == null) { + return; + } + const terminal = new Terminal({ + convertEol: false, + }); + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + terminal.open(termRef.current); + fitAddon.fit(); + terminalRef.current = terminal; + + const onDataDisposable = terminal.onData((data) => { + if (id == null || id === "") { + return; + } + sendTermInput({ + id, + data64: stringToBase64(data), + }).catch((error) => { + console.error("Failed to send terminal input:", error); + }); + }); + const onResizeDisposable = terminal.onResize((size) => { + if (id == null || id === "") { + return; + } + sendTermInput({ + id, + termsize: { + rows: size.rows, + cols: size.cols, + }, + }).catch((error) => { + console.error("Failed to send terminal resize:", error); + }); + }); + + const resizeObserver = new ResizeObserver(() => { + fitAddon.fit(); + }); + if (outerRef.current != null) { + resizeObserver.observe(outerRef.current); + } + + return () => { + resizeObserver.disconnect(); + onResizeDisposable.dispose(); + onDataDisposable.dispose(); + terminal.dispose(); + terminalRef.current = null; + }; + }, [id]); + + React.useEffect(() => { + const handleTermWrite = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail == null || detail.id !== id || detail.data64 == null || detail.data64 === "") { + return; + } + try { + terminalRef.current?.write(base64ToArray(detail.data64)); + } catch (error) { + console.error("Failed to process term write event:", error); + } + }; + window.addEventListener(TermWriteEventName, handleTermWrite); + return () => { + window.removeEventListener(TermWriteEventName, handleTermWrite); + }; + }, [id]); + + return ( +
+
+
+ ); +}); + +export { TermWriteEventName, TsunamiTerm }; diff --git a/tsunami/frontend/src/model/tsunami-model.tsx b/tsunami/frontend/src/model/tsunami-model.tsx index 61857dbebe..d51fbef518 100644 --- a/tsunami/frontend/src/model/tsunami-model.tsx +++ b/tsunami/frontend/src/model/tsunami-model.tsx @@ -4,6 +4,7 @@ import debug from "debug"; import * as jotai from "jotai"; +import { TermWriteEventName } from "@/element/tsunamiterm"; import { arrayBufferToBase64 } from "@/util/base64"; import { getOrCreateClientId } from "@/util/clientid"; import { adaptFromReactOrNativeKeyEvent } from "@/util/keyutil"; @@ -236,6 +237,15 @@ export class TsunamiModel { } }); + this.serverEventSource.addEventListener("termwrite", (event: MessageEvent) => { + try { + const detail = JSON.parse(event.data); + window.dispatchEvent(new CustomEvent(TermWriteEventName, { detail })); + } catch (e) { + console.error("Failed to parse termwrite event:", e); + } + }); + this.serverEventSource.addEventListener("error", (event) => { console.error("SSE connection error:", event); }); diff --git a/tsunami/frontend/src/vdom.tsx b/tsunami/frontend/src/vdom.tsx index 37de4c0f1c..30a2b25b2b 100644 --- a/tsunami/frontend/src/vdom.tsx +++ b/tsunami/frontend/src/vdom.tsx @@ -9,6 +9,7 @@ import { twMerge } from "tailwind-merge"; import { AlertModal, ConfirmModal } from "@/element/modals"; import { Markdown } from "@/element/markdown"; +import { TsunamiTerm } from "@/element/tsunamiterm"; import { getTextChildren } from "@/model/model-utils"; import type { TsunamiModel } from "@/model/tsunami-model"; import { RechartsTag } from "@/recharts/recharts"; @@ -30,6 +31,7 @@ type VDomReactTagType = (props: { elem: VDomElem; model: TsunamiModel }) => Reac const WaveTagMap: Record = { "wave:markdown": WaveMarkdown, + "wave:term": WaveTerm, }; const AllowedSimpleTags: { [tagName: string]: boolean } = { @@ -278,6 +280,11 @@ function WaveMarkdown({ elem, model }: { elem: VDomElem; model: TsunamiModel }) ); } +function WaveTerm({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { + const props = useVDom(model, elem); + return ; +} + function StyleTag({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { const styleText = getTextChildren(elem); if (styleText == null) { diff --git a/tsunami/rpctypes/protocoltypes.go b/tsunami/rpctypes/protocoltypes.go index f2728f0bb6..74a8c0338a 100644 --- a/tsunami/rpctypes/protocoltypes.go +++ b/tsunami/rpctypes/protocoltypes.go @@ -206,3 +206,19 @@ type ModalResult struct { ModalId string `json:"modalid"` // ID of the modal Confirm bool `json:"confirm"` // true = confirmed/ok, false = cancelled } + +type TermSize struct { + Rows int `json:"rows"` + Cols int `json:"cols"` +} + +type TermInputPacket struct { + Id string `json:"id"` + TermSize *TermSize `json:"termsize,omitempty"` + Data64 string `json:"data64,omitempty"` +} + +type TermWritePacket struct { + Id string `json:"id"` + Data64 string `json:"data64"` +} From a9cd808519422105f99be3b1a5043aea2bb30fcf Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 4 Mar 2026 23:36:30 -0800 Subject: [PATCH 3/8] update HasCurrent to be atomic, dont re-render on ref update, ensure abs paths for env vars, dont pass position back for refs --- tsunami/app/defaultclient.go | 2 +- tsunami/cmd/main-tsunami.go | 12 ++++- tsunami/engine/rootelem.go | 3 +- tsunami/frontend/src/types/vdom.d.ts | 1 - tsunami/templates/package.json.tmpl | 4 +- tsunami/vdom/vdom_types.go | 72 +++++++++++++++++++++------- 6 files changed, 69 insertions(+), 25 deletions(-) diff --git a/tsunami/app/defaultclient.go b/tsunami/app/defaultclient.go index 35c1e95545..aac7c69bd3 100644 --- a/tsunami/app/defaultclient.go +++ b/tsunami/app/defaultclient.go @@ -167,7 +167,7 @@ func DeepCopy[T any](v T) T { // If the ref is nil or not current, the operation is ignored. // This function must be called within a component context. func QueueRefOp(ref *vdom.VDomRef, op vdom.VDomRefOperation) { - if ref == nil || !ref.HasCurrent { + if ref == nil || !ref.HasCurrent.Load() { return } if op.RefId == "" { diff --git a/tsunami/cmd/main-tsunami.go b/tsunami/cmd/main-tsunami.go index 6399aa6d52..f8b85f3e46 100644 --- a/tsunami/cmd/main-tsunami.go +++ b/tsunami/cmd/main-tsunami.go @@ -41,14 +41,22 @@ func validateEnvironmentVars(opts *build.BuildOpts) error { if scaffoldPath == "" { return fmt.Errorf("%s environment variable must be set", EnvTsunamiScaffoldPath) } + absScaffoldPath, err := filepath.Abs(scaffoldPath) + if err != nil { + return fmt.Errorf("failed to resolve %s to absolute path: %w", EnvTsunamiScaffoldPath, err) + } sdkReplacePath := os.Getenv(EnvTsunamiSdkReplacePath) if sdkReplacePath == "" { return fmt.Errorf("%s environment variable must be set", EnvTsunamiSdkReplacePath) } + absSdkReplacePath, err := filepath.Abs(sdkReplacePath) + if err != nil { + return fmt.Errorf("failed to resolve %s to absolute path: %w", EnvTsunamiSdkReplacePath, err) + } - opts.ScaffoldPath = scaffoldPath - opts.SdkReplacePath = sdkReplacePath + opts.ScaffoldPath = absScaffoldPath + opts.SdkReplacePath = absSdkReplacePath // NodePath is optional if nodePath := os.Getenv(EnvTsunamiNodePath); nodePath != "" { diff --git a/tsunami/engine/rootelem.go b/tsunami/engine/rootelem.go index 1d8b93808e..c76fb541ab 100644 --- a/tsunami/engine/rootelem.go +++ b/tsunami/engine/rootelem.go @@ -443,9 +443,8 @@ func (r *RootElem) UpdateRef(updateRef rpctypes.VDomRefUpdate) { if !ok { return } - ref.HasCurrent = updateRef.HasCurrent + ref.HasCurrent.Store(updateRef.HasCurrent) ref.Position = updateRef.Position - r.addRenderWork(waveId) } func (r *RootElem) QueueRefOp(op vdom.VDomRefOperation) { diff --git a/tsunami/frontend/src/types/vdom.d.ts b/tsunami/frontend/src/types/vdom.d.ts index 485ada680b..5440827844 100644 --- a/tsunami/frontend/src/types/vdom.d.ts +++ b/tsunami/frontend/src/types/vdom.d.ts @@ -103,7 +103,6 @@ type VDomRef = { type: "ref"; refid: string; trackposition?: boolean; - position?: VDomRefPosition; hascurrent?: boolean; }; diff --git a/tsunami/templates/package.json.tmpl b/tsunami/templates/package.json.tmpl index a214510649..c8d88dae83 100644 --- a/tsunami/templates/package.json.tmpl +++ b/tsunami/templates/package.json.tmpl @@ -10,7 +10,7 @@ "email": "info@commandline.dev" }, "dependencies": { - "@tailwindcss/cli": "^4.1.13", - "tailwindcss": "^4.1.13" + "@tailwindcss/cli": "^4.2.1", + "tailwindcss": "^4.2.1" } } diff --git a/tsunami/vdom/vdom_types.go b/tsunami/vdom/vdom_types.go index d20a02ac3d..d1629b33f3 100644 --- a/tsunami/vdom/vdom_types.go +++ b/tsunami/vdom/vdom_types.go @@ -1,8 +1,13 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package vdom +import ( + "encoding/json" + "sync/atomic" +) + const TextTag = "#text" const WaveTextTag = "wave:text" const WaveNullTag = "wave:null" @@ -36,8 +41,41 @@ type VDomRef struct { Type string `json:"type" tstype:"\"ref\""` RefId string `json:"refid"` TrackPosition bool `json:"trackposition,omitempty"` - Position *VDomRefPosition `json:"position,omitempty"` - HasCurrent bool `json:"hascurrent,omitempty"` + Position *VDomRefPosition `json:"-"` + HasCurrent atomic.Bool `json:"-"` +} + +func (r *VDomRef) MarshalJSON() ([]byte, error) { + type vdomRefAlias struct { + Type string `json:"type"` + RefId string `json:"refid"` + TrackPosition bool `json:"trackposition,omitempty"` + HasCurrent bool `json:"hascurrent,omitempty"` + } + return json.Marshal(vdomRefAlias{ + Type: r.Type, + RefId: r.RefId, + TrackPosition: r.TrackPosition, + HasCurrent: r.HasCurrent.Load(), + }) +} + +func (r *VDomRef) UnmarshalJSON(data []byte) error { + type vdomRefAlias struct { + Type string `json:"type"` + RefId string `json:"refid"` + TrackPosition bool `json:"trackposition,omitempty"` + HasCurrent bool `json:"hascurrent,omitempty"` + } + var alias vdomRefAlias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + r.Type = alias.Type + r.RefId = alias.RefId + r.TrackPosition = alias.TrackPosition + r.HasCurrent.Store(alias.HasCurrent) + return nil } type VDomSimpleRef[T any] struct { @@ -66,14 +104,14 @@ type VDomEvent struct { WaveId string `json:"waveid"` EventType string `json:"eventtype"` // usually the prop name (e.g. onClick, onKeyDown) GlobalEventType string `json:"globaleventtype,omitempty"` - TargetValue string `json:"targetvalue,omitempty"` // set for onChange events on input/textarea/select + TargetValue string `json:"targetvalue,omitempty"` // set for onChange events on input/textarea/select TargetChecked bool `json:"targetchecked,omitempty"` // set for onChange events on checkbox/radio inputs - TargetName string `json:"targetname,omitempty"` // target element's name attribute - TargetId string `json:"targetid,omitempty"` // target element's id attribute - TargetFiles []VDomFileData `json:"targetfiles,omitempty"` // set for onChange events on file inputs - KeyData *VDomKeyboardEvent `json:"keydata,omitempty"` // set for onKeyDown events - MouseData *VDomPointerData `json:"mousedata,omitempty"` // set for onClick, onMouseDown, onMouseUp, onDoubleClick events - FormData *VDomFormData `json:"formdata,omitempty"` // set for onSubmit events on forms + TargetName string `json:"targetname,omitempty"` // target element's name attribute + TargetId string `json:"targetid,omitempty"` // target element's id attribute + TargetFiles []VDomFileData `json:"targetfiles,omitempty"` // set for onChange events on file inputs + KeyData *VDomKeyboardEvent `json:"keydata,omitempty"` // set for onKeyDown events + MouseData *VDomPointerData `json:"mousedata,omitempty"` // set for onClick, onMouseDown, onMouseUp, onDoubleClick events + FormData *VDomFormData `json:"formdata,omitempty"` // set for onSubmit events on forms } type VDomKeyboardEvent struct { @@ -115,13 +153,13 @@ type VDomPointerData struct { } type VDomFormData struct { - Action string `json:"action,omitempty"` - Method string `json:"method"` - Enctype string `json:"enctype"` - FormId string `json:"formid,omitempty"` - FormName string `json:"formname,omitempty"` - Fields map[string][]string `json:"fields"` - Files map[string][]VDomFileData `json:"files"` + Action string `json:"action,omitempty"` + Method string `json:"method"` + Enctype string `json:"enctype"` + FormId string `json:"formid,omitempty"` + FormName string `json:"formname,omitempty"` + Fields map[string][]string `json:"fields"` + Files map[string][]VDomFileData `json:"files"` } func (f *VDomFormData) GetField(fieldName string) string { From 8d2bbe6df3f470a4f2e37e666d517aa8705ed7cb Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 4 Mar 2026 23:38:52 -0800 Subject: [PATCH 4/8] fix the way terminalwrites work (use ref) --- tsunami/app/defaultclient.go | 7 ++- tsunami/engine/clientimpl.go | 11 ++-- tsunami/frontend/src/element/tsunamiterm.tsx | 63 ++++++++++++-------- tsunami/frontend/src/model/model-utils.ts | 18 ++++++ tsunami/frontend/src/model/tsunami-model.tsx | 24 ++++++-- tsunami/rpctypes/protocoltypes.go | 2 +- 6 files changed, 85 insertions(+), 40 deletions(-) diff --git a/tsunami/app/defaultclient.go b/tsunami/app/defaultclient.go index aac7c69bd3..af4a32b28c 100644 --- a/tsunami/app/defaultclient.go +++ b/tsunami/app/defaultclient.go @@ -72,8 +72,11 @@ func SetTermInputHandler(handler func(input TermInputPacket)) { engine.GetDefaultClient().SetTermInputHandler(handler) } -func TermWrite(id string, data64 string) error { - return engine.GetDefaultClient().SendTermWrite(id, data64) +func TermWrite(ref *vdom.VDomRef, data string) error { + if ref == nil || !ref.HasCurrent.Load() { + return nil + } + return engine.GetDefaultClient().SendTermWrite(ref.RefId, data) } func ConfigAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] { diff --git a/tsunami/engine/clientimpl.go b/tsunami/engine/clientimpl.go index 10c90f25aa..696bf06d3c 100644 --- a/tsunami/engine/clientimpl.go +++ b/tsunami/engine/clientimpl.go @@ -5,6 +5,7 @@ package engine import ( "context" + "encoding/base64" "encoding/json" "fmt" "io/fs" @@ -311,16 +312,16 @@ func (c *ClientImpl) SendAsyncInitiation() error { return c.SendSSEvent(ssEvent{Event: "asyncinitiation", Data: nil}) } -func (c *ClientImpl) SendTermWrite(id string, data64 string) error { +func (c *ClientImpl) SendTermWrite(refId string, data string) error { payload := rpctypes.TermWritePacket{ - Id: id, - Data64: data64, + RefId: refId, + Data64: base64.StdEncoding.EncodeToString([]byte(data)), } - data, err := json.Marshal(payload) + jsonData, err := json.Marshal(payload) if err != nil { return err } - return c.SendSSEvent(ssEvent{Event: "termwrite", Data: data}) + return c.SendSSEvent(ssEvent{Event: "termwrite", Data: jsonData}) } func (c *ClientImpl) HandleTermInput(input rpctypes.TermInputPacket) { diff --git a/tsunami/frontend/src/element/tsunamiterm.tsx b/tsunami/frontend/src/element/tsunamiterm.tsx index 5c52f1a262..7a31ac4522 100644 --- a/tsunami/frontend/src/element/tsunamiterm.tsx +++ b/tsunami/frontend/src/element/tsunamiterm.tsx @@ -5,8 +5,6 @@ import * as React from "react"; import { base64ToArray, stringToBase64 } from "@/util/base64"; -const TermWriteEventName = "tsunami:termwrite"; - type TermSize = { rows: number; cols: number; @@ -18,9 +16,9 @@ type TermInputPayload = { data64?: string; }; -type TermWritePayload = { - id: string; - data64: string; +export type TsunamiTermElem = HTMLDivElement & { + __termWrite: (data64: string) => void; + __termFocus: () => void; }; async function sendTermInput(payload: TermInputPayload) { @@ -41,13 +39,28 @@ const TsunamiTerm = React.forwardRef(null); + const outerRef = React.useRef(null); const termRef = React.useRef(null); const terminalRef = React.useRef(null); const setOuterRef = React.useCallback( - (elem: HTMLDivElement) => { + (elem: TsunamiTermElem) => { outerRef.current = elem; + if (elem != null) { + elem.__termWrite = (data64: string) => { + if (data64 == null || data64 === "") { + return; + } + try { + terminalRef.current?.write(base64ToArray(data64)); + } catch (error) { + console.error("Failed to write to terminal:", error); + } + }; + elem.__termFocus = () => { + terminalRef.current?.focus(); + }; + } if (typeof ref === "function") { ref(elem); return; @@ -114,29 +127,27 @@ const TsunamiTerm = React.forwardRef { - const handleTermWrite = (event: Event) => { - const detail = (event as CustomEvent).detail; - if (detail == null || detail.id !== id || detail.data64 == null || detail.data64 === "") { - return; - } - try { - terminalRef.current?.write(base64ToArray(detail.data64)); - } catch (error) { - console.error("Failed to process term write event:", error); - } - }; - window.addEventListener(TermWriteEventName, handleTermWrite); - return () => { - window.removeEventListener(TermWriteEventName, handleTermWrite); - }; - }, [id]); + const handleFocus = React.useCallback( + (e: React.FocusEvent) => { + terminalRef.current?.focus(); + outerProps.onFocus?.(e); + }, + [outerProps.onFocus] + ); + + const handleBlur = React.useCallback( + (e: React.FocusEvent) => { + terminalRef.current?.blur(); + outerProps.onBlur?.(e); + }, + [outerProps.onBlur] + ); return ( -
+
} onFocus={handleFocus} onBlur={handleBlur}>
); }); -export { TermWriteEventName, TsunamiTerm }; +export { TsunamiTerm }; diff --git a/tsunami/frontend/src/model/model-utils.ts b/tsunami/frontend/src/model/model-utils.ts index 9ea4d92982..da14252a6e 100644 --- a/tsunami/frontend/src/model/model-utils.ts +++ b/tsunami/frontend/src/model/model-utils.ts @@ -1,6 +1,8 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import type { TsunamiTermElem } from "@/element/tsunamiterm"; + const TextTag = "#text"; // TODO support binding @@ -79,6 +81,22 @@ export function restoreVDomElems(backendUpdate: VDomBackendUpdate) { }); } +export function isTsunamiTermElem(elem: HTMLElement): elem is TsunamiTermElem { + return elem != null && typeof (elem as TsunamiTermElem).__termWrite === "function"; +} + +export function applyTermOp(elem: TsunamiTermElem, termOp: VDomRefOperation) { + const { op, params } = termOp; + if (op === "termwrite") { + const data64 = params?.[0]; + if (typeof data64 === "string" && data64 !== "") { + elem.__termWrite(data64); + } + } else if (op === "focus") { + elem.__termFocus(); + } +} + export function applyCanvasOp(canvas: HTMLCanvasElement, canvasOp: VDomRefOperation, refStore: Map) { const ctx = canvas.getContext("2d"); if (!ctx) { diff --git a/tsunami/frontend/src/model/tsunami-model.tsx b/tsunami/frontend/src/model/tsunami-model.tsx index d51fbef518..3ca7920e15 100644 --- a/tsunami/frontend/src/model/tsunami-model.tsx +++ b/tsunami/frontend/src/model/tsunami-model.tsx @@ -4,13 +4,12 @@ import debug from "debug"; import * as jotai from "jotai"; -import { TermWriteEventName } from "@/element/tsunamiterm"; import { arrayBufferToBase64 } from "@/util/base64"; import { getOrCreateClientId } from "@/util/clientid"; import { adaptFromReactOrNativeKeyEvent } from "@/util/keyutil"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; import { getDefaultStore } from "jotai"; -import { applyCanvasOp, restoreVDomElems } from "./model-utils"; +import { applyCanvasOp, applyTermOp, isTsunamiTermElem, restoreVDomElems } from "./model-utils"; const dlog = debug("wave:vdom"); @@ -239,8 +238,18 @@ export class TsunamiModel { this.serverEventSource.addEventListener("termwrite", (event: MessageEvent) => { try { - const detail = JSON.parse(event.data); - window.dispatchEvent(new CustomEvent(TermWriteEventName, { detail })); + const packet = JSON.parse(event.data); + if (packet?.refid == null || packet?.data64 == null) { + return; + } + const refOp: VDomRefOperation = { refid: packet.refid, op: "termwrite", params: [packet.data64] }; + const elem = this.getRefElem(refOp.refid); + if (elem == null) { + return; + } + if (isTsunamiTermElem(elem)) { + applyTermOp(elem, refOp); + } } catch (e) { console.error("Failed to parse termwrite event:", e); } @@ -616,6 +625,10 @@ export class TsunamiModel { applyCanvasOp(elem, refOp, this.refOutputStore); continue; } + if (isTsunamiTermElem(elem)) { + applyTermOp(elem, refOp); + continue; + } if (refOp.op == "focus") { if (elem == null) { this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: elem is null`); @@ -728,8 +741,7 @@ export class TsunamiModel { vdomEvent.globaleventtype = fnDecl.globalevent; } const needsAsync = - propName == "onSubmit" || - (propName == "onChange" && (e.target as HTMLInputElement)?.type === "file"); + propName == "onSubmit" || (propName == "onChange" && (e.target as HTMLInputElement)?.type === "file"); if (needsAsync) { asyncAnnotateEvent(vdomEvent, propName, e) .then(() => { diff --git a/tsunami/rpctypes/protocoltypes.go b/tsunami/rpctypes/protocoltypes.go index 74a8c0338a..118b19dc28 100644 --- a/tsunami/rpctypes/protocoltypes.go +++ b/tsunami/rpctypes/protocoltypes.go @@ -219,6 +219,6 @@ type TermInputPacket struct { } type TermWritePacket struct { - Id string `json:"id"` + RefId string `json:"refid"` Data64 string `json:"data64"` } From c71b09a3690cd270cef4b2250e71273dde1c29ad Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Mar 2026 00:01:06 -0800 Subject: [PATCH 5/8] new UseTermRef to create an io.Writer to the terminal --- tsunami/app/hooks.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tsunami/app/hooks.go b/tsunami/app/hooks.go index 6b6ebbd36c..cf611f8e04 100644 --- a/tsunami/app/hooks.go +++ b/tsunami/app/hooks.go @@ -31,6 +31,30 @@ func UseVDomRef() *vdom.VDomRef { return refVal } +// TermRef wraps a VDomRef and implements io.Writer by forwarding writes to the terminal. +type TermRef struct { + *vdom.VDomRef +} + +// Write implements io.Writer by sending data to the terminal via TermWrite. +func (tr *TermRef) Write(p []byte) (n int, err error) { + if tr.VDomRef == nil || !tr.VDomRef.HasCurrent.Load() { + return 0, fmt.Errorf("TermRef not current") + } + err = TermWrite(tr.VDomRef, string(p)) + if err != nil { + return 0, err + } + return len(p), nil +} + +// UseTermRef returns a TermRef that can be passed as a ref to "wave:term" elements +// and also implements io.Writer for writing directly to the terminal. +func UseTermRef() *TermRef { + ref := UseVDomRef() + return &TermRef{VDomRef: ref} +} + // UseRef is the tsunami analog to React's useRef hook. // It provides a mutable ref object that persists across re-renders. // Unlike UseVDomRef, this is not tied to DOM elements but holds arbitrary values. From b1df7da88cc76373a40b780a6749e06463d4cefe Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Mar 2026 00:26:04 -0800 Subject: [PATCH 6/8] use an onData handler rather than a different api --- tsunami/app/defaultclient.go | 8 --- tsunami/engine/clientimpl.go | 17 ----- tsunami/engine/serverhandlers.go | 18 ++++-- tsunami/frontend/src/element/tsunamiterm.tsx | 67 ++++++-------------- tsunami/frontend/src/types/vdom.d.ts | 13 ++++ tsunami/frontend/src/vdom.tsx | 39 +++++++++++- tsunami/rpctypes/protocoltypes.go | 11 ---- tsunami/vdom/vdom_types.go | 11 ++++ 8 files changed, 93 insertions(+), 91 deletions(-) diff --git a/tsunami/app/defaultclient.go b/tsunami/app/defaultclient.go index af4a32b28c..1359f0f58b 100644 --- a/tsunami/app/defaultclient.go +++ b/tsunami/app/defaultclient.go @@ -16,7 +16,6 @@ import ( "time" "github.com/wavetermdev/waveterm/tsunami/engine" - "github.com/wavetermdev/waveterm/tsunami/rpctypes" "github.com/wavetermdev/waveterm/tsunami/util" "github.com/wavetermdev/waveterm/tsunami/vdom" ) @@ -65,13 +64,6 @@ func SendAsyncInitiation() error { return engine.GetDefaultClient().SendAsyncInitiation() } -type TermSize = rpctypes.TermSize -type TermInputPacket = rpctypes.TermInputPacket - -func SetTermInputHandler(handler func(input TermInputPacket)) { - engine.GetDefaultClient().SetTermInputHandler(handler) -} - func TermWrite(ref *vdom.VDomRef, data string) error { if ref == nil || !ref.HasCurrent.Load() { return nil diff --git a/tsunami/engine/clientimpl.go b/tsunami/engine/clientimpl.go index 696bf06d3c..ac9cb29109 100644 --- a/tsunami/engine/clientimpl.go +++ b/tsunami/engine/clientimpl.go @@ -73,7 +73,6 @@ type ClientImpl struct { SSEChannels map[string]chan ssEvent // map of connectionId to SSE channel SSEChannelsLock *sync.Mutex GlobalEventHandler func(event vdom.VDomEvent) - TermInputHandler func(input rpctypes.TermInputPacket) UrlHandlerMux *http.ServeMux AppInitFn func() error AssetsFS fs.FS @@ -159,12 +158,6 @@ func (c *ClientImpl) SetGlobalEventHandler(handler func(event vdom.VDomEvent)) { c.GlobalEventHandler = handler } -func (c *ClientImpl) SetTermInputHandler(handler func(input rpctypes.TermInputPacket)) { - c.Lock.Lock() - defer c.Lock.Unlock() - c.TermInputHandler = handler -} - func (c *ClientImpl) getFaviconPath() string { if c.StaticFS != nil { faviconNames := []string{"favicon.ico", "favicon.png", "favicon.svg", "favicon.gif", "favicon.jpg"} @@ -324,16 +317,6 @@ func (c *ClientImpl) SendTermWrite(refId string, data string) error { return c.SendSSEvent(ssEvent{Event: "termwrite", Data: jsonData}) } -func (c *ClientImpl) HandleTermInput(input rpctypes.TermInputPacket) { - c.Lock.Lock() - handler := c.TermInputHandler - c.Lock.Unlock() - if handler == nil { - return - } - handler(input) -} - func makeNullRendered() *rpctypes.RenderedElem { return &rpctypes.RenderedElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag} } diff --git a/tsunami/engine/serverhandlers.go b/tsunami/engine/serverhandlers.go index 5f3103be07..1e7bc94fda 100644 --- a/tsunami/engine/serverhandlers.go +++ b/tsunami/engine/serverhandlers.go @@ -18,6 +18,7 @@ import ( "github.com/wavetermdev/waveterm/tsunami/rpctypes" "github.com/wavetermdev/waveterm/tsunami/util" + "github.com/wavetermdev/waveterm/tsunami/vdom" ) const SSEKeepAliveDuration = 5 * time.Second @@ -414,17 +415,24 @@ func (h *httpHandlers) handleTermInput(w http.ResponseWriter, r *http.Request) { return } - var input rpctypes.TermInputPacket - if err := json.Unmarshal(body, &input); err != nil { + var event vdom.VDomEvent + if err := json.Unmarshal(body, &event); err != nil { http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest) return } - if strings.TrimSpace(input.Id) == "" { - http.Error(w, "id is required", http.StatusBadRequest) + if strings.TrimSpace(event.WaveId) == "" { + http.Error(w, "waveid is required", http.StatusBadRequest) return } + if event.TermInput == nil { + http.Error(w, "terminput is required", http.StatusBadRequest) + return + } + + h.renderLock.Lock() + h.Client.Root.Event(event, h.Client.GlobalEventHandler) + h.renderLock.Unlock() - h.Client.HandleTermInput(input) w.WriteHeader(http.StatusNoContent) } diff --git a/tsunami/frontend/src/element/tsunamiterm.tsx b/tsunami/frontend/src/element/tsunamiterm.tsx index 7a31ac4522..24c3065d2d 100644 --- a/tsunami/frontend/src/element/tsunamiterm.tsx +++ b/tsunami/frontend/src/element/tsunamiterm.tsx @@ -3,45 +3,24 @@ import { Terminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; import * as React from "react"; -import { base64ToArray, stringToBase64 } from "@/util/base64"; - -type TermSize = { - rows: number; - cols: number; -}; - -type TermInputPayload = { - id: string; - termsize?: TermSize; - data64?: string; -}; +import { base64ToArray } from "@/util/base64"; export type TsunamiTermElem = HTMLDivElement & { __termWrite: (data64: string) => void; __termFocus: () => void; }; -async function sendTermInput(payload: TermInputPayload) { - const response = await fetch("/api/terminput", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }); - if (!response.ok) { - throw new Error(`terminal input request failed: ${response.status} ${response.statusText}`); - } -} +type TsunamiTermProps = React.HTMLAttributes & { + onData?: (data: string | null, termsize: VDomTermSize | null) => void; +}; -const TsunamiTerm = React.forwardRef>(function TsunamiTerm( - props, - ref -) { - const { id, ...outerProps } = props; +const TsunamiTerm = React.forwardRef(function TsunamiTerm(props, ref) { + const { onData, ...outerProps } = props; const outerRef = React.useRef(null); const termRef = React.useRef(null); const terminalRef = React.useRef(null); + const onDataRef = React.useRef(onData); + onDataRef.current = onData; const setOuterRef = React.useCallback( (elem: TsunamiTermElem) => { @@ -86,29 +65,16 @@ const TsunamiTerm = React.forwardRef { - if (id == null || id === "") { + if (onDataRef.current == null) { return; } - sendTermInput({ - id, - data64: stringToBase64(data), - }).catch((error) => { - console.error("Failed to send terminal input:", error); - }); + onDataRef.current(data, null); }); const onResizeDisposable = terminal.onResize((size) => { - if (id == null || id === "") { + if (onDataRef.current == null) { return; } - sendTermInput({ - id, - termsize: { - rows: size.rows, - cols: size.cols, - }, - }).catch((error) => { - console.error("Failed to send terminal resize:", error); - }); + onDataRef.current(null, { rows: size.rows, cols: size.cols }); }); const resizeObserver = new ResizeObserver(() => { @@ -125,7 +91,7 @@ const TsunamiTerm = React.forwardRef) => { @@ -144,7 +110,12 @@ const TsunamiTerm = React.forwardRef} onFocus={handleFocus} onBlur={handleBlur}> +
} + onFocus={handleFocus} + onBlur={handleBlur} + >
); diff --git a/tsunami/frontend/src/types/vdom.d.ts b/tsunami/frontend/src/types/vdom.d.ts index 5440827844..d65dcd6d97 100644 --- a/tsunami/frontend/src/types/vdom.d.ts +++ b/tsunami/frontend/src/types/vdom.d.ts @@ -33,6 +33,18 @@ type VDomElem = { text?: string; }; +// vdom.VDomTermSize +type VDomTermSize = { + rows: number; + cols: number; +}; + +// vdom.VDomTermInputData +type VDomTermInputData = { + termsize?: VDomTermSize; + data?: string; +}; + // vdom.VDomEvent type VDomEvent = { waveid: string; @@ -46,6 +58,7 @@ type VDomEvent = { keydata?: VDomKeyboardEvent; mousedata?: VDomPointerData; formdata?: VDomFormData; + terminput?: VDomTermInputData; }; // vdom.VDomFrontendUpdate diff --git a/tsunami/frontend/src/vdom.tsx b/tsunami/frontend/src/vdom.tsx index 30a2b25b2b..a51e119193 100644 --- a/tsunami/frontend/src/vdom.tsx +++ b/tsunami/frontend/src/vdom.tsx @@ -7,8 +7,8 @@ import * as jotai from "jotai"; import * as React from "react"; import { twMerge } from "tailwind-merge"; -import { AlertModal, ConfirmModal } from "@/element/modals"; import { Markdown } from "@/element/markdown"; +import { AlertModal, ConfirmModal } from "@/element/modals"; import { TsunamiTerm } from "@/element/tsunamiterm"; import { getTextChildren } from "@/model/model-utils"; import type { TsunamiModel } from "@/model/tsunami-model"; @@ -280,9 +280,44 @@ function WaveMarkdown({ elem, model }: { elem: VDomElem; model: TsunamiModel }) ); } +async function sendTermInputEvent(event: VDomEvent) { + const response = await fetch("/api/terminput", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(event), + }); + if (!response.ok) { + throw new Error(`terminal input request failed: ${response.status} ${response.statusText}`); + } +} + function WaveTerm({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { const props = useVDom(model, elem); - return ; + const hasOnData = props.onData != null; + const onData = React.useCallback( + (data: string | null, termsize: VDomTermSize | null) => { + const terminput: VDomTermInputData = {}; + if (data != null) { + terminput.data = data; + } + if (termsize != null) { + terminput.termsize = termsize; + } + const event: VDomEvent = { + waveid: elem.waveid, + eventtype: "onData", + terminput: terminput, + }; + sendTermInputEvent(event).catch((error) => { + console.error("Failed to send terminal input:", error); + }); + }, + [elem.waveid] + ); + const termProps = { ...props, onData: hasOnData ? onData : undefined }; + return ; } function StyleTag({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { diff --git a/tsunami/rpctypes/protocoltypes.go b/tsunami/rpctypes/protocoltypes.go index 118b19dc28..00520ad606 100644 --- a/tsunami/rpctypes/protocoltypes.go +++ b/tsunami/rpctypes/protocoltypes.go @@ -207,17 +207,6 @@ type ModalResult struct { Confirm bool `json:"confirm"` // true = confirmed/ok, false = cancelled } -type TermSize struct { - Rows int `json:"rows"` - Cols int `json:"cols"` -} - -type TermInputPacket struct { - Id string `json:"id"` - TermSize *TermSize `json:"termsize,omitempty"` - Data64 string `json:"data64,omitempty"` -} - type TermWritePacket struct { RefId string `json:"refid"` Data64 string `json:"data64"` diff --git a/tsunami/vdom/vdom_types.go b/tsunami/vdom/vdom_types.go index d1629b33f3..b95b05b369 100644 --- a/tsunami/vdom/vdom_types.go +++ b/tsunami/vdom/vdom_types.go @@ -100,6 +100,16 @@ type VDomRefPosition struct { BoundingClientRect DomRect `json:"boundingclientrect"` } +type VDomTermInputData struct { + TermSize *VDomTermSize `json:"termsize,omitempty"` + Data string `json:"data,omitempty"` +} + +type VDomTermSize struct { + Rows int `json:"rows"` + Cols int `json:"cols"` +} + type VDomEvent struct { WaveId string `json:"waveid"` EventType string `json:"eventtype"` // usually the prop name (e.g. onClick, onKeyDown) @@ -112,6 +122,7 @@ type VDomEvent struct { KeyData *VDomKeyboardEvent `json:"keydata,omitempty"` // set for onKeyDown events MouseData *VDomPointerData `json:"mousedata,omitempty"` // set for onClick, onMouseDown, onMouseUp, onDoubleClick events FormData *VDomFormData `json:"formdata,omitempty"` // set for onSubmit events on forms + TermInput *VDomTermInputData `json:"terminput,omitempty"` // set for onData events on wave:term elements } type VDomKeyboardEvent struct { From 8841c2a0298778122bbe014694896d5d2988b787 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Mar 2026 09:22:01 -0800 Subject: [PATCH 7/8] get termsize propagated onmount... --- tsunami/app/hooks.go | 8 +++++ tsunami/engine/rootelem.go | 3 ++ tsunami/frontend/src/element/tsunamiterm.tsx | 35 +++++++++++++++++++- tsunami/frontend/src/model/tsunami-model.tsx | 6 ++++ tsunami/frontend/src/types/vdom.d.ts | 1 + tsunami/rpctypes/protocoltypes.go | 1 + tsunami/vdom/vdom_types.go | 1 + 7 files changed, 54 insertions(+), 1 deletion(-) diff --git a/tsunami/app/hooks.go b/tsunami/app/hooks.go index cf611f8e04..54418a00e0 100644 --- a/tsunami/app/hooks.go +++ b/tsunami/app/hooks.go @@ -48,6 +48,14 @@ func (tr *TermRef) Write(p []byte) (n int, err error) { return len(p), nil } +// TermSize returns the current terminal size, or nil if not yet set. +func (tr *TermRef) TermSize() *vdom.VDomTermSize { + if tr.VDomRef == nil { + return nil + } + return tr.VDomRef.TermSize +} + // UseTermRef returns a TermRef that can be passed as a ref to "wave:term" elements // and also implements io.Writer for writing directly to the terminal. func UseTermRef() *TermRef { diff --git a/tsunami/engine/rootelem.go b/tsunami/engine/rootelem.go index c76fb541ab..787be044e0 100644 --- a/tsunami/engine/rootelem.go +++ b/tsunami/engine/rootelem.go @@ -445,6 +445,9 @@ func (r *RootElem) UpdateRef(updateRef rpctypes.VDomRefUpdate) { } ref.HasCurrent.Store(updateRef.HasCurrent) ref.Position = updateRef.Position + if updateRef.TermSize != nil { + ref.TermSize = updateRef.TermSize + } } func (r *RootElem) QueueRefOp(op vdom.VDomRefOperation) { diff --git a/tsunami/frontend/src/element/tsunamiterm.tsx b/tsunami/frontend/src/element/tsunamiterm.tsx index 24c3065d2d..603d4b1889 100644 --- a/tsunami/frontend/src/element/tsunamiterm.tsx +++ b/tsunami/frontend/src/element/tsunamiterm.tsx @@ -8,14 +8,18 @@ import { base64ToArray } from "@/util/base64"; export type TsunamiTermElem = HTMLDivElement & { __termWrite: (data64: string) => void; __termFocus: () => void; + __termSize: () => VDomTermSize | null; }; type TsunamiTermProps = React.HTMLAttributes & { onData?: (data: string | null, termsize: VDomTermSize | null) => void; + termFontSize?: number; + termFontFamily?: string; + termScrollback?: number; }; const TsunamiTerm = React.forwardRef(function TsunamiTerm(props, ref) { - const { onData, ...outerProps } = props; + const { onData, termFontSize, termFontFamily, termScrollback, ...outerProps } = props; const outerRef = React.useRef(null); const termRef = React.useRef(null); const terminalRef = React.useRef(null); @@ -39,6 +43,13 @@ const TsunamiTerm = React.forwardRef(function elem.__termFocus = () => { terminalRef.current?.focus(); }; + elem.__termSize = () => { + const terminal = terminalRef.current; + if (terminal == null) { + return null; + } + return { rows: terminal.rows, cols: terminal.cols }; + }; } if (typeof ref === "function") { ref(elem); @@ -57,6 +68,9 @@ const TsunamiTerm = React.forwardRef(function } const terminal = new Terminal({ convertEol: false, + ...(termFontSize != null ? { fontSize: termFontSize } : {}), + ...(termFontFamily != null ? { fontFamily: termFontFamily } : {}), + ...(termScrollback != null ? { scrollback: termScrollback } : {}), }); const fitAddon = new FitAddon(); terminal.loadAddon(fitAddon); @@ -76,6 +90,9 @@ const TsunamiTerm = React.forwardRef(function } onDataRef.current(null, { rows: size.rows, cols: size.cols }); }); + if (onDataRef.current != null) { + onDataRef.current(null, { rows: terminal.rows, cols: terminal.cols }); + } const resizeObserver = new ResizeObserver(() => { fitAddon.fit(); @@ -93,6 +110,22 @@ const TsunamiTerm = React.forwardRef(function }; }, []); + React.useEffect(() => { + const terminal = terminalRef.current; + if (terminal == null) { + return; + } + if (termFontSize != null) { + terminal.options.fontSize = termFontSize; + } + if (termFontFamily != null) { + terminal.options.fontFamily = termFontFamily; + } + if (termScrollback != null) { + terminal.options.scrollback = termScrollback; + } + }, [termFontSize, termFontFamily, termScrollback]); + const handleFocus = React.useCallback( (e: React.FocusEvent) => { terminalRef.current?.focus(); diff --git a/tsunami/frontend/src/model/tsunami-model.tsx b/tsunami/frontend/src/model/tsunami-model.tsx index 3ca7920e15..cc83104b81 100644 --- a/tsunami/frontend/src/model/tsunami-model.tsx +++ b/tsunami/frontend/src/model/tsunami-model.tsx @@ -338,6 +338,12 @@ export class TsunamiModel { boundingclientrect: ref.elem.getBoundingClientRect(), }; } + if (isTsunamiTermElem(ref.elem)) { + const termsize = ref.elem.__termSize(); + if (termsize != null) { + ru.termsize = termsize; + } + } updates.push(ru); ref.updated = false; } diff --git a/tsunami/frontend/src/types/vdom.d.ts b/tsunami/frontend/src/types/vdom.d.ts index d65dcd6d97..2ca0f73867 100644 --- a/tsunami/frontend/src/types/vdom.d.ts +++ b/tsunami/frontend/src/types/vdom.d.ts @@ -142,6 +142,7 @@ type VDomRefUpdate = { refid: string; hascurrent: boolean; position?: VDomRefPosition; + termsize?: VDomTermSize; }; // rpctypes.VDomRenderContext diff --git a/tsunami/rpctypes/protocoltypes.go b/tsunami/rpctypes/protocoltypes.go index 00520ad606..bad88a8745 100644 --- a/tsunami/rpctypes/protocoltypes.go +++ b/tsunami/rpctypes/protocoltypes.go @@ -166,6 +166,7 @@ type VDomRefUpdate struct { RefId string `json:"refid"` HasCurrent bool `json:"hascurrent"` Position *vdom.VDomRefPosition `json:"position,omitempty"` + TermSize *vdom.VDomTermSize `json:"termsize,omitempty"` } type VDomBackendOpts struct { diff --git a/tsunami/vdom/vdom_types.go b/tsunami/vdom/vdom_types.go index b95b05b369..58725e4010 100644 --- a/tsunami/vdom/vdom_types.go +++ b/tsunami/vdom/vdom_types.go @@ -43,6 +43,7 @@ type VDomRef struct { TrackPosition bool `json:"trackposition,omitempty"` Position *VDomRefPosition `json:"-"` HasCurrent atomic.Bool `json:"-"` + TermSize *VDomTermSize `json:"-"` } func (r *VDomRef) MarshalJSON() ([]byte, error) { From c786d4ec16b7c2450e3c79342f96a0174b05f354 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Mar 2026 09:31:32 -0800 Subject: [PATCH 8/8] fix go vet warning --- tsunami/engine/render.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tsunami/engine/render.go b/tsunami/engine/render.go index 29c39c9cb7..87750f8740 100644 --- a/tsunami/engine/render.go +++ b/tsunami/engine/render.go @@ -5,6 +5,7 @@ package engine import ( "fmt" + "log" "reflect" "unicode" @@ -247,12 +248,6 @@ func convertPropsToVDom(props map[string]any) map[string]any { vdomProps[k] = vdomFuncPtr continue } - if vdomRef, ok := v.(vdom.VDomRef); ok { - // ensure Type is set on all VDomRefs - vdomRef.Type = vdom.ObjectType_Ref - vdomProps[k] = vdomRef - continue - } if vdomRefPtr, ok := v.(*vdom.VDomRef); ok { if vdomRefPtr == nil { continue // handle typed-nil @@ -263,6 +258,10 @@ func convertPropsToVDom(props map[string]any) map[string]any { continue } val := reflect.ValueOf(v) + if val.Type() == reflect.TypeOf(vdom.VDomRef{}) { + log.Printf("warning: VDomRef passed as non-pointer for prop %q (VDomRef contains atomics and must be passed as *VDomRef); dropping prop\n", k) + continue + } if val.Kind() == reflect.Func { // convert go functions passed to event handlers to VDomFuncs vdomProps[k] = vdom.VDomFunc{Type: vdom.ObjectType_Func}