Skip to content

Commit 1a1cd85

Browse files
Copilotsawka
andauthored
Add wave:term component with direct SSE output + /api/terminput input path (#2974)
This PR introduces a standalone Tsunami terminal element (`wave:term`) and routes terminal IO outside the normal render/event loop for lower-latency streaming. It adds imperative terminal output (`TermWrite`) over SSE and terminal input/resize delivery over a dedicated `/api/terminput` endpoint. - **Frontend: new `wave:term` element** - Added `tsunami/frontend/src/element/tsunamiterm.tsx`. - Uses `@xterm/xterm` with `@xterm/addon-fit`. - Renders as an outer `<div>` (style/class/ref target), with xterm auto-fit to that container. - Supports ref passthrough on the outer element. - **Frontend: terminal transport wiring** - Registered `wave:term` in `tsunami/frontend/src/vdom.tsx`. - Added SSE listener handling for `termwrite` in `tsunami/frontend/src/model/tsunami-model.tsx`, dispatched to the terminal component via a local custom event. - `onData` and `onResize` now POST directly to `/api/terminput` as JSON payloads: - `id` - `data64` (base64 terminal input) - `termsize` (`rows`, `cols`) for resize updates - **Backend: new terminal IO APIs** - Added `/api/terminput` handler in `tsunami/engine/serverhandlers.go`. - Added protocol types in `tsunami/rpctypes/protocoltypes.go`: - `TermInputPacket`, `TermWritePacket`, `TermSize` - Added engine/client support in `tsunami/engine/clientimpl.go`: - `SendTermWrite(id, data64)` -> emits SSE event `termwrite` - `SetTermInputHandler(...)` and `HandleTermInput(...)` - Exposed app-level APIs in `tsunami/app/defaultclient.go`: - `TermWrite(id, data64) error` - `SetTermInputHandler(func(TermInputPacket))` - **Example usage** ```go app.SetTermInputHandler(func(input app.TermInputPacket) { // input.Id, input.Data64, input.TermSize.Rows/Cols }) _ = app.TermWrite("term1", "SGVsbG8gZnJvbSB0aGUgYmFja2VuZA0K") ``` - **<screenshot>** - Provided screenshot URL: https://github.com/user-attachments/assets/58c92ebb-0a52-43d2-b577-17c9cf92a19c <!-- START COPILOT CODING AGENT TIPS --> --- 💬 We'd love your input! Share your thoughts on Copilot coding agent in our [2 minute survey](https://gh.io/copilot-coding-agent-survey). --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> Co-authored-by: sawka <mike@commandline.dev>
1 parent 0ab26ef commit 1a1cd85

File tree

15 files changed

+454
-35
lines changed

15 files changed

+454
-35
lines changed

tsunami/app/defaultclient.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ func SendAsyncInitiation() error {
6464
return engine.GetDefaultClient().SendAsyncInitiation()
6565
}
6666

67+
func TermWrite(ref *vdom.VDomRef, data string) error {
68+
if ref == nil || !ref.HasCurrent.Load() {
69+
return nil
70+
}
71+
return engine.GetDefaultClient().SendTermWrite(ref.RefId, data)
72+
}
73+
6774
func ConfigAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] {
6875
fullName := "$config." + name
6976
client := engine.GetDefaultClient()
@@ -155,7 +162,7 @@ func DeepCopy[T any](v T) T {
155162
// If the ref is nil or not current, the operation is ignored.
156163
// This function must be called within a component context.
157164
func QueueRefOp(ref *vdom.VDomRef, op vdom.VDomRefOperation) {
158-
if ref == nil || !ref.HasCurrent {
165+
if ref == nil || !ref.HasCurrent.Load() {
159166
return
160167
}
161168
if op.RefId == "" {

tsunami/app/hooks.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,38 @@ func UseVDomRef() *vdom.VDomRef {
3131
return refVal
3232
}
3333

34+
// TermRef wraps a VDomRef and implements io.Writer by forwarding writes to the terminal.
35+
type TermRef struct {
36+
*vdom.VDomRef
37+
}
38+
39+
// Write implements io.Writer by sending data to the terminal via TermWrite.
40+
func (tr *TermRef) Write(p []byte) (n int, err error) {
41+
if tr.VDomRef == nil || !tr.VDomRef.HasCurrent.Load() {
42+
return 0, fmt.Errorf("TermRef not current")
43+
}
44+
err = TermWrite(tr.VDomRef, string(p))
45+
if err != nil {
46+
return 0, err
47+
}
48+
return len(p), nil
49+
}
50+
51+
// TermSize returns the current terminal size, or nil if not yet set.
52+
func (tr *TermRef) TermSize() *vdom.VDomTermSize {
53+
if tr.VDomRef == nil {
54+
return nil
55+
}
56+
return tr.VDomRef.TermSize
57+
}
58+
59+
// UseTermRef returns a TermRef that can be passed as a ref to "wave:term" elements
60+
// and also implements io.Writer for writing directly to the terminal.
61+
func UseTermRef() *TermRef {
62+
ref := UseVDomRef()
63+
return &TermRef{VDomRef: ref}
64+
}
65+
3466
// UseRef is the tsunami analog to React's useRef hook.
3567
// It provides a mutable ref object that persists across re-renders.
3668
// Unlike UseVDomRef, this is not tied to DOM elements but holds arbitrary values.

tsunami/cmd/main-tsunami.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,22 @@ func validateEnvironmentVars(opts *build.BuildOpts) error {
4141
if scaffoldPath == "" {
4242
return fmt.Errorf("%s environment variable must be set", EnvTsunamiScaffoldPath)
4343
}
44+
absScaffoldPath, err := filepath.Abs(scaffoldPath)
45+
if err != nil {
46+
return fmt.Errorf("failed to resolve %s to absolute path: %w", EnvTsunamiScaffoldPath, err)
47+
}
4448

4549
sdkReplacePath := os.Getenv(EnvTsunamiSdkReplacePath)
4650
if sdkReplacePath == "" {
4751
return fmt.Errorf("%s environment variable must be set", EnvTsunamiSdkReplacePath)
4852
}
53+
absSdkReplacePath, err := filepath.Abs(sdkReplacePath)
54+
if err != nil {
55+
return fmt.Errorf("failed to resolve %s to absolute path: %w", EnvTsunamiSdkReplacePath, err)
56+
}
4957

50-
opts.ScaffoldPath = scaffoldPath
51-
opts.SdkReplacePath = sdkReplacePath
58+
opts.ScaffoldPath = absScaffoldPath
59+
opts.SdkReplacePath = absSdkReplacePath
5260

5361
// NodePath is optional
5462
if nodePath := os.Getenv(EnvTsunamiNodePath); nodePath != "" {

tsunami/engine/clientimpl.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package engine
55

66
import (
77
"context"
8+
"encoding/base64"
89
"encoding/json"
910
"fmt"
1011
"io/fs"
@@ -304,6 +305,18 @@ func (c *ClientImpl) SendAsyncInitiation() error {
304305
return c.SendSSEvent(ssEvent{Event: "asyncinitiation", Data: nil})
305306
}
306307

308+
func (c *ClientImpl) SendTermWrite(refId string, data string) error {
309+
payload := rpctypes.TermWritePacket{
310+
RefId: refId,
311+
Data64: base64.StdEncoding.EncodeToString([]byte(data)),
312+
}
313+
jsonData, err := json.Marshal(payload)
314+
if err != nil {
315+
return err
316+
}
317+
return c.SendSSEvent(ssEvent{Event: "termwrite", Data: jsonData})
318+
}
319+
307320
func makeNullRendered() *rpctypes.RenderedElem {
308321
return &rpctypes.RenderedElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag}
309322
}

tsunami/engine/render.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package engine
55

66
import (
77
"fmt"
8+
"log"
89
"reflect"
910
"unicode"
1011

@@ -247,12 +248,6 @@ func convertPropsToVDom(props map[string]any) map[string]any {
247248
vdomProps[k] = vdomFuncPtr
248249
continue
249250
}
250-
if vdomRef, ok := v.(vdom.VDomRef); ok {
251-
// ensure Type is set on all VDomRefs
252-
vdomRef.Type = vdom.ObjectType_Ref
253-
vdomProps[k] = vdomRef
254-
continue
255-
}
256251
if vdomRefPtr, ok := v.(*vdom.VDomRef); ok {
257252
if vdomRefPtr == nil {
258253
continue // handle typed-nil
@@ -263,6 +258,10 @@ func convertPropsToVDom(props map[string]any) map[string]any {
263258
continue
264259
}
265260
val := reflect.ValueOf(v)
261+
if val.Type() == reflect.TypeOf(vdom.VDomRef{}) {
262+
log.Printf("warning: VDomRef passed as non-pointer for prop %q (VDomRef contains atomics and must be passed as *VDomRef); dropping prop\n", k)
263+
continue
264+
}
266265
if val.Kind() == reflect.Func {
267266
// convert go functions passed to event handlers to VDomFuncs
268267
vdomProps[k] = vdom.VDomFunc{Type: vdom.ObjectType_Func}

tsunami/engine/rootelem.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -443,9 +443,11 @@ func (r *RootElem) UpdateRef(updateRef rpctypes.VDomRefUpdate) {
443443
if !ok {
444444
return
445445
}
446-
ref.HasCurrent = updateRef.HasCurrent
446+
ref.HasCurrent.Store(updateRef.HasCurrent)
447447
ref.Position = updateRef.Position
448-
r.addRenderWork(waveId)
448+
if updateRef.TermSize != nil {
449+
ref.TermSize = updateRef.TermSize
450+
}
449451
}
450452

451453
func (r *RootElem) QueueRefOp(op vdom.VDomRefOperation) {

tsunami/engine/serverhandlers.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818

1919
"github.com/wavetermdev/waveterm/tsunami/rpctypes"
2020
"github.com/wavetermdev/waveterm/tsunami/util"
21+
"github.com/wavetermdev/waveterm/tsunami/vdom"
2122
)
2223

2324
const SSEKeepAliveDuration = 5 * time.Second
@@ -83,6 +84,7 @@ func (h *httpHandlers) registerHandlers(mux *http.ServeMux, opts handlerOpts) {
8384
mux.HandleFunc("/api/schemas", h.handleSchemas)
8485
mux.HandleFunc("/api/manifest", h.handleManifest(opts.ManifestFile))
8586
mux.HandleFunc("/api/modalresult", h.handleModalResult)
87+
mux.HandleFunc("/api/terminput", h.handleTermInput)
8688
mux.HandleFunc("/dyn/", h.handleDynContent)
8789

8890
// Add handler for static files at /static/ path
@@ -392,6 +394,48 @@ func (h *httpHandlers) handleModalResult(w http.ResponseWriter, r *http.Request)
392394
json.NewEncoder(w).Encode(map[string]any{"success": true})
393395
}
394396

397+
func (h *httpHandlers) handleTermInput(w http.ResponseWriter, r *http.Request) {
398+
defer func() {
399+
panicErr := util.PanicHandler("handleTermInput", recover())
400+
if panicErr != nil {
401+
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
402+
}
403+
}()
404+
405+
setNoCacheHeaders(w)
406+
407+
if r.Method != http.MethodPost {
408+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
409+
return
410+
}
411+
412+
body, err := io.ReadAll(r.Body)
413+
if err != nil {
414+
http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest)
415+
return
416+
}
417+
418+
var event vdom.VDomEvent
419+
if err := json.Unmarshal(body, &event); err != nil {
420+
http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest)
421+
return
422+
}
423+
if strings.TrimSpace(event.WaveId) == "" {
424+
http.Error(w, "waveid is required", http.StatusBadRequest)
425+
return
426+
}
427+
if event.TermInput == nil {
428+
http.Error(w, "terminput is required", http.StatusBadRequest)
429+
return
430+
}
431+
432+
h.renderLock.Lock()
433+
h.Client.Root.Event(event, h.Client.GlobalEventHandler)
434+
h.renderLock.Unlock()
435+
436+
w.WriteHeader(http.StatusNoContent)
437+
}
438+
395439
func (h *httpHandlers) handleDynContent(w http.ResponseWriter, r *http.Request) {
396440
defer func() {
397441
panicErr := util.PanicHandler("handleDynContent", recover())
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { FitAddon } from "@xterm/addon-fit";
2+
import { Terminal } from "@xterm/xterm";
3+
import "@xterm/xterm/css/xterm.css";
4+
import * as React from "react";
5+
6+
import { base64ToArray } from "@/util/base64";
7+
8+
export type TsunamiTermElem = HTMLDivElement & {
9+
__termWrite: (data64: string) => void;
10+
__termFocus: () => void;
11+
__termSize: () => VDomTermSize | null;
12+
};
13+
14+
type TsunamiTermProps = React.HTMLAttributes<HTMLDivElement> & {
15+
onData?: (data: string | null, termsize: VDomTermSize | null) => void;
16+
termFontSize?: number;
17+
termFontFamily?: string;
18+
termScrollback?: number;
19+
};
20+
21+
const TsunamiTerm = React.forwardRef<HTMLDivElement, TsunamiTermProps>(function TsunamiTerm(props, ref) {
22+
const { onData, termFontSize, termFontFamily, termScrollback, ...outerProps } = props;
23+
const outerRef = React.useRef<TsunamiTermElem>(null);
24+
const termRef = React.useRef<HTMLDivElement>(null);
25+
const terminalRef = React.useRef<Terminal | null>(null);
26+
const onDataRef = React.useRef(onData);
27+
onDataRef.current = onData;
28+
29+
const setOuterRef = React.useCallback(
30+
(elem: TsunamiTermElem) => {
31+
outerRef.current = elem;
32+
if (elem != null) {
33+
elem.__termWrite = (data64: string) => {
34+
if (data64 == null || data64 === "") {
35+
return;
36+
}
37+
try {
38+
terminalRef.current?.write(base64ToArray(data64));
39+
} catch (error) {
40+
console.error("Failed to write to terminal:", error);
41+
}
42+
};
43+
elem.__termFocus = () => {
44+
terminalRef.current?.focus();
45+
};
46+
elem.__termSize = () => {
47+
const terminal = terminalRef.current;
48+
if (terminal == null) {
49+
return null;
50+
}
51+
return { rows: terminal.rows, cols: terminal.cols };
52+
};
53+
}
54+
if (typeof ref === "function") {
55+
ref(elem);
56+
return;
57+
}
58+
if (ref != null) {
59+
ref.current = elem;
60+
}
61+
},
62+
[ref]
63+
);
64+
65+
React.useEffect(() => {
66+
if (termRef.current == null) {
67+
return;
68+
}
69+
const terminal = new Terminal({
70+
convertEol: false,
71+
...(termFontSize != null ? { fontSize: termFontSize } : {}),
72+
...(termFontFamily != null ? { fontFamily: termFontFamily } : {}),
73+
...(termScrollback != null ? { scrollback: termScrollback } : {}),
74+
});
75+
const fitAddon = new FitAddon();
76+
terminal.loadAddon(fitAddon);
77+
terminal.open(termRef.current);
78+
fitAddon.fit();
79+
terminalRef.current = terminal;
80+
81+
const onDataDisposable = terminal.onData((data) => {
82+
if (onDataRef.current == null) {
83+
return;
84+
}
85+
onDataRef.current(data, null);
86+
});
87+
const onResizeDisposable = terminal.onResize((size) => {
88+
if (onDataRef.current == null) {
89+
return;
90+
}
91+
onDataRef.current(null, { rows: size.rows, cols: size.cols });
92+
});
93+
if (onDataRef.current != null) {
94+
onDataRef.current(null, { rows: terminal.rows, cols: terminal.cols });
95+
}
96+
97+
const resizeObserver = new ResizeObserver(() => {
98+
fitAddon.fit();
99+
});
100+
if (outerRef.current != null) {
101+
resizeObserver.observe(outerRef.current);
102+
}
103+
104+
return () => {
105+
resizeObserver.disconnect();
106+
onResizeDisposable.dispose();
107+
onDataDisposable.dispose();
108+
terminal.dispose();
109+
terminalRef.current = null;
110+
};
111+
}, []);
112+
113+
React.useEffect(() => {
114+
const terminal = terminalRef.current;
115+
if (terminal == null) {
116+
return;
117+
}
118+
if (termFontSize != null) {
119+
terminal.options.fontSize = termFontSize;
120+
}
121+
if (termFontFamily != null) {
122+
terminal.options.fontFamily = termFontFamily;
123+
}
124+
if (termScrollback != null) {
125+
terminal.options.scrollback = termScrollback;
126+
}
127+
}, [termFontSize, termFontFamily, termScrollback]);
128+
129+
const handleFocus = React.useCallback(
130+
(e: React.FocusEvent<HTMLDivElement>) => {
131+
terminalRef.current?.focus();
132+
outerProps.onFocus?.(e);
133+
},
134+
[outerProps.onFocus]
135+
);
136+
137+
const handleBlur = React.useCallback(
138+
(e: React.FocusEvent<HTMLDivElement>) => {
139+
terminalRef.current?.blur();
140+
outerProps.onBlur?.(e);
141+
},
142+
[outerProps.onBlur]
143+
);
144+
145+
return (
146+
<div
147+
{...outerProps}
148+
ref={setOuterRef as React.RefCallback<HTMLDivElement>}
149+
onFocus={handleFocus}
150+
onBlur={handleBlur}
151+
>
152+
<div ref={termRef} className="w-full h-full" />
153+
</div>
154+
);
155+
});
156+
157+
export { TsunamiTerm };

0 commit comments

Comments
 (0)