waveterm/tsunami/engine/render.go
Copilot 1a1cd853f8
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>
2026-03-05 09:32:01 -08:00

324 lines
8.4 KiB
Go

// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package engine
import (
"fmt"
"log"
"reflect"
"unicode"
"github.com/google/uuid"
"github.com/wavetermdev/waveterm/tsunami/rpctypes"
"github.com/wavetermdev/waveterm/tsunami/util"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)
// see render.md for a complete guide to how tsunami rendering, lifecycle, and reconciliation works
type RenderOpts struct {
Resync bool
}
func (r *RootElem) Render(elem *vdom.VDomElem, opts *RenderOpts) {
r.render(elem, &r.Root, "root", opts)
}
func getElemKey(elem *vdom.VDomElem) string {
if elem == nil {
return ""
}
keyVal, ok := elem.Props[vdom.KeyPropKey]
if !ok {
return ""
}
return fmt.Sprint(keyVal)
}
func (r *RootElem) render(elem *vdom.VDomElem, comp **ComponentImpl, containingComp string, opts *RenderOpts) {
if elem == nil || elem.Tag == "" {
r.unmount(comp)
return
}
elemKey := getElemKey(elem)
if *comp == nil || !(*comp).compMatch(elem.Tag, elemKey) {
r.unmount(comp)
r.createComp(elem.Tag, elemKey, containingComp, comp)
}
(*comp).Elem = elem
if elem.Tag == vdom.TextTag {
// Pattern 1: Text Nodes
r.renderText(elem.Text, comp)
return
}
if isBaseTag(elem.Tag) {
// Pattern 2: Base elements
r.renderSimple(elem, comp, containingComp, opts)
return
}
cfunc := r.CFuncs[elem.Tag]
if cfunc == nil {
text := fmt.Sprintf("<%s>", elem.Tag)
r.renderText(text, comp)
return
}
// Pattern 3: components
r.renderComponent(cfunc, elem, comp, opts)
}
// Pattern 1
func (r *RootElem) renderText(text string, comp **ComponentImpl) {
// No need to clear Children/Comp - text components cannot have them
if (*comp).Text != text {
(*comp).Text = text
}
}
// Pattern 2
func (r *RootElem) renderSimple(elem *vdom.VDomElem, comp **ComponentImpl, containingComp string, opts *RenderOpts) {
if (*comp).RenderedComp != nil {
// Clear Comp since base elements don't use it
r.unmount(&(*comp).RenderedComp)
}
(*comp).Children = r.renderChildren(elem.Children, (*comp).Children, containingComp, opts)
}
// Pattern 3
func (r *RootElem) renderComponent(cfunc any, elem *vdom.VDomElem, comp **ComponentImpl, opts *RenderOpts) {
if (*comp).Children != nil {
// Clear Children since custom components don't use them
for _, child := range (*comp).Children {
r.unmount(&child)
}
(*comp).Children = nil
}
props := make(map[string]any)
for k, v := range elem.Props {
props[k] = v
}
props[ChildrenPropKey] = elem.Children
vc := makeContextVal(r, *comp, opts)
rtnElemArr := withGlobalRenderCtx(vc, func() []vdom.VDomElem {
renderedElem := callCFuncWithErrorGuard(cfunc, props, elem.Tag)
return vdom.ToElems(renderedElem)
})
// Process atom usage after render
r.updateComponentAtomUsage(*comp, vc.UsedAtoms)
var rtnElem *vdom.VDomElem
if len(rtnElemArr) == 0 {
rtnElem = nil
} else if len(rtnElemArr) == 1 {
rtnElem = &rtnElemArr[0]
} else {
rtnElem = &vdom.VDomElem{Tag: vdom.FragmentTag, Children: rtnElemArr}
}
r.render(rtnElem, &(*comp).RenderedComp, elem.Tag, opts)
}
func (r *RootElem) unmount(comp **ComponentImpl) {
if *comp == nil {
return
}
waveId := (*comp).WaveId
for _, hook := range (*comp).Hooks {
if hook.UnmountFn != nil {
hook.UnmountFn()
}
}
if (*comp).RenderedComp != nil {
r.unmount(&(*comp).RenderedComp)
}
if (*comp).Children != nil {
for _, child := range (*comp).Children {
r.unmount(&child)
}
}
delete(r.CompMap, waveId)
r.cleanupUsedByForUnmount(*comp)
*comp = nil
}
func (r *RootElem) createComp(tag string, key string, containingComp string, comp **ComponentImpl) {
*comp = &ComponentImpl{WaveId: uuid.New().String(), Tag: tag, Key: key, ContainingComp: containingComp}
r.CompMap[(*comp).WaveId] = *comp
}
// handles reconcilation
// maps children via key or index (exclusively)
func (r *RootElem) renderChildren(elems []vdom.VDomElem, curChildren []*ComponentImpl, containingComp string, opts *RenderOpts) []*ComponentImpl {
newChildren := make([]*ComponentImpl, len(elems))
curCM := make(map[ChildKey]*ComponentImpl)
usedMap := make(map[*ComponentImpl]bool)
for idx, child := range curChildren {
if child.Key != "" {
curCM[ChildKey{Tag: child.Tag, Idx: 0, Key: child.Key}] = child
} else {
curCM[ChildKey{Tag: child.Tag, Idx: idx, Key: ""}] = child
}
}
for idx, elem := range elems {
elemKey := getElemKey(&elem)
var curChild *ComponentImpl
if elemKey != "" {
curChild = curCM[ChildKey{Tag: elem.Tag, Idx: 0, Key: elemKey}]
} else {
curChild = curCM[ChildKey{Tag: elem.Tag, Idx: idx, Key: ""}]
}
usedMap[curChild] = true
newChildren[idx] = curChild
r.render(&elem, &newChildren[idx], containingComp, opts)
}
for _, child := range curChildren {
if !usedMap[child] {
r.unmount(&child)
}
}
return newChildren
}
// safely calls the component function with panic recovery
func callCFuncWithErrorGuard(cfunc any, props map[string]any, componentName string) (result any) {
defer func() {
if panicErr := util.PanicHandler(fmt.Sprintf("render component '%s'", componentName), recover()); panicErr != nil {
result = renderErrorComponent(componentName, panicErr.Error())
}
}()
result = callCFunc(cfunc, props)
return result
}
// uses reflection to call the component function
func callCFunc(cfunc any, props map[string]any) any {
rval := reflect.ValueOf(cfunc)
rtype := rval.Type()
if rtype.NumIn() != 1 {
fmt.Printf("component function must have exactly 1 parameter, got %d\n", rtype.NumIn())
return nil
}
argType := rtype.In(0)
var arg1Val reflect.Value
if argType.Kind() == reflect.Interface && argType.NumMethod() == 0 {
arg1Val = reflect.New(argType)
} else {
arg1Val = reflect.New(argType)
if argType.Kind() == reflect.Map {
arg1Val.Elem().Set(reflect.ValueOf(props))
} else {
err := util.MapToStruct(props, arg1Val.Interface())
if err != nil {
fmt.Printf("error converting props: %v\n", err)
}
}
}
rtnVal := rval.Call([]reflect.Value{arg1Val.Elem()})
if len(rtnVal) == 0 {
return nil
}
return rtnVal[0].Interface()
}
func convertPropsToVDom(props map[string]any) map[string]any {
if len(props) == 0 {
return nil
}
vdomProps := make(map[string]any)
for k, v := range props {
if v == nil {
continue
}
if vdomFunc, ok := v.(vdom.VDomFunc); ok {
// ensure Type is set on all VDomFuncs
vdomFunc.Type = vdom.ObjectType_Func
vdomProps[k] = vdomFunc
continue
}
if vdomFuncPtr, ok := v.(*vdom.VDomFunc); ok {
if vdomFuncPtr == nil {
continue // handled typed-nil
}
// ensure Type is set on all VDomFuncs (pointer)
vdomFuncPtr.Type = vdom.ObjectType_Func
vdomProps[k] = vdomFuncPtr
continue
}
if vdomRefPtr, ok := v.(*vdom.VDomRef); ok {
if vdomRefPtr == nil {
continue // handle typed-nil
}
// ensure Type is set on all VDomRefs (pointer)
vdomRefPtr.Type = vdom.ObjectType_Ref
vdomProps[k] = vdomRefPtr
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}
continue
}
vdomProps[k] = v
}
return vdomProps
}
func (r *RootElem) MakeRendered() *rpctypes.RenderedElem {
if r.Root == nil {
return nil
}
return r.convertCompToRendered(r.Root)
}
func (r *RootElem) convertCompToRendered(c *ComponentImpl) *rpctypes.RenderedElem {
if c == nil {
return nil
}
if c.RenderedComp != nil {
return r.convertCompToRendered(c.RenderedComp)
}
if len(c.Children) == 0 && r.CFuncs[c.Tag] != nil {
return nil
}
return r.convertBaseToRendered(c)
}
func (r *RootElem) convertBaseToRendered(c *ComponentImpl) *rpctypes.RenderedElem {
elem := &rpctypes.RenderedElem{WaveId: c.WaveId, Tag: c.Tag}
if c.Elem != nil {
elem.Props = convertPropsToVDom(c.Elem.Props)
}
for _, child := range c.Children {
childElem := r.convertCompToRendered(child)
if childElem != nil {
elem.Children = append(elem.Children, *childElem)
}
}
if c.Tag == vdom.TextTag {
elem.Text = c.Text
}
return elem
}
func isBaseTag(tag string) bool {
if tag == "" {
return false
}
if tag == vdom.TextTag || tag == vdom.WaveTextTag || tag == vdom.WaveNullTag || tag == vdom.FragmentTag {
return true
}
if tag[0] == '#' {
return true
}
firstChar := rune(tag[0])
return unicode.IsLower(firstChar)
}