waveterm/tsunami/app/defaultclient.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

276 lines
8.7 KiB
Go

// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package app
import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/wavetermdev/waveterm/tsunami/engine"
"github.com/wavetermdev/waveterm/tsunami/util"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)
const TsunamiCloseOnStdinEnvVar = "TSUNAMI_CLOSEONSTDIN"
const MaxShortDescLen = 120
type AppMeta engine.AppMeta
type staticFileInfo struct {
fullPath string
info fs.FileInfo
}
func (sfi *staticFileInfo) Name() string { return sfi.fullPath }
func (sfi *staticFileInfo) Size() int64 { return sfi.info.Size() }
func (sfi *staticFileInfo) Mode() fs.FileMode { return sfi.info.Mode() }
func (sfi *staticFileInfo) ModTime() time.Time { return sfi.info.ModTime() }
func (sfi *staticFileInfo) IsDir() bool { return sfi.info.IsDir() }
func (sfi *staticFileInfo) Sys() any { return sfi.info.Sys() }
func DefineComponent[P any](name string, renderFn func(props P) any) vdom.Component[P] {
return engine.DefineComponentEx(engine.GetDefaultClient(), name, renderFn)
}
func Ptr[T any](v T) *T {
return &v
}
func SetGlobalEventHandler(handler func(event vdom.VDomEvent)) {
engine.GetDefaultClient().SetGlobalEventHandler(handler)
}
// RegisterAppInitFn registers a single setup function that is called before the app starts running.
// Only one setup function is allowed, so calling this will replace any previously registered
// setup function.
func RegisterAppInitFn(fn func() error) {
engine.GetDefaultClient().RegisterAppInitFn(fn)
}
// SendAsyncInitiation notifies the frontend that the backend has updated state
// and requires a re-render. Normally the frontend calls the backend in response
// to events, but when the backend changes state independently (e.g., from a
// background process), this function gives the frontend a "nudge" to update.
func SendAsyncInitiation() error {
return engine.GetDefaultClient().SendAsyncInitiation()
}
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] {
fullName := "$config." + name
client := engine.GetDefaultClient()
engineMeta := convertAppMetaToEngineMeta(meta)
atom := engine.MakeAtomImpl(defaultValue, engineMeta)
client.Root.RegisterAtom(fullName, atom)
return Atom[T]{name: fullName, client: client}
}
func DataAtom[T any](name string, defaultValue T, meta *AtomMeta) Atom[T] {
fullName := "$data." + name
client := engine.GetDefaultClient()
engineMeta := convertAppMetaToEngineMeta(meta)
atom := engine.MakeAtomImpl(defaultValue, engineMeta)
client.Root.RegisterAtom(fullName, atom)
return Atom[T]{name: fullName, client: client}
}
func SharedAtom[T any](name string, defaultValue T) Atom[T] {
fullName := "$shared." + name
client := engine.GetDefaultClient()
atom := engine.MakeAtomImpl(defaultValue, nil)
client.Root.RegisterAtom(fullName, atom)
return Atom[T]{name: fullName, client: client}
}
func convertAppMetaToEngineMeta(appMeta *AtomMeta) *engine.AtomMeta {
if appMeta == nil {
return nil
}
return &engine.AtomMeta{
Description: appMeta.Desc,
Units: appMeta.Units,
Min: appMeta.Min,
Max: appMeta.Max,
Enum: appMeta.Enum,
Pattern: appMeta.Pattern,
}
}
// HandleDynFunc registers a dynamic HTTP handler function with the internal http.ServeMux.
// The pattern MUST start with "/dyn/" to be valid. This allows registration of dynamic
// routes that can be handled at runtime.
func HandleDynFunc(pattern string, fn func(http.ResponseWriter, *http.Request)) {
engine.GetDefaultClient().HandleDynFunc(pattern, fn)
}
// RunMain is used internally by generated code and should not be called directly.
func RunMain() {
closeOnStdin := os.Getenv(TsunamiCloseOnStdinEnvVar) != ""
if closeOnStdin {
go func() {
// Read stdin until EOF/close, then exit the process
io.Copy(io.Discard, os.Stdin)
log.Printf("[tsunami] shutting down due to close of stdin\n")
os.Exit(0)
}()
}
engine.GetDefaultClient().RunMain()
}
// RegisterEmbeds is used internally by generated code and should not be called directly.
func RegisterEmbeds(assetsFilesystem fs.FS, staticFilesystem fs.FS, manifest []byte) {
client := engine.GetDefaultClient()
client.AssetsFS = assetsFilesystem
client.StaticFS = staticFilesystem
client.ManifestFileBytes = manifest
}
// DeepCopy creates a deep copy of the input value using JSON marshal/unmarshal.
// Panics on JSON errors.
func DeepCopy[T any](v T) T {
data, err := json.Marshal(v)
if err != nil {
panic(err)
}
var result T
err = json.Unmarshal(data, &result)
if err != nil {
panic(err)
}
return result
}
// QueueRefOp queues a reference operation to be executed on the DOM element.
// Operations include actions like "focus", "scrollIntoView", etc.
// 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.Load() {
return
}
if op.RefId == "" {
op.RefId = ref.RefId
}
client := engine.GetDefaultClient()
client.Root.QueueRefOp(op)
}
func SetAppMeta(meta AppMeta) {
meta.ShortDesc = util.TruncateString(meta.ShortDesc, MaxShortDescLen)
client := engine.GetDefaultClient()
client.SetAppMeta(engine.AppMeta(meta))
}
func SetTitle(title string) {
client := engine.GetDefaultClient()
m := client.GetAppMeta()
m.Title = title
client.SetAppMeta(m)
}
func SetShortDesc(shortDesc string) {
shortDesc = util.TruncateString(shortDesc, MaxShortDescLen)
client := engine.GetDefaultClient()
m := client.GetAppMeta()
m.ShortDesc = shortDesc
client.SetAppMeta(m)
}
func DeclareSecret(secretName string, meta *SecretMeta) string {
client := engine.GetDefaultClient()
var secretDesc string
var secretOptional bool
if meta != nil {
secretDesc = meta.Desc
secretOptional = meta.Optional
}
client.DeclareSecret(secretName, secretDesc, secretOptional)
return os.Getenv(secretName)
}
func PrintAppManifest() {
client := engine.GetDefaultClient()
client.PrintAppManifest()
}
// ReadStaticFile reads a file from the embedded static filesystem.
// The path MUST start with "static/" (e.g., "static/config.json").
// Returns the file contents or an error if the file doesn't exist or can't be read.
func ReadStaticFile(path string) ([]byte, error) {
client := engine.GetDefaultClient()
if client.StaticFS == nil {
return nil, errors.New("static files not available before app initialization; use AppInit to access files during initialization")
}
if !strings.HasPrefix(path, "static/") {
return nil, fmt.Errorf("ReadStaticFile path must start with 'static/': %w", fs.ErrNotExist)
}
// Strip "static/" prefix since the FS is already sub'd to the static directory
relativePath := strings.TrimPrefix(path, "static/")
return fs.ReadFile(client.StaticFS, relativePath)
}
// OpenStaticFile opens a file from the embedded static filesystem.
// The path MUST start with "static/" (e.g., "static/config.json").
// Returns an fs.File or an error if the file doesn't exist or can't be opened.
func OpenStaticFile(path string) (fs.File, error) {
client := engine.GetDefaultClient()
if client.StaticFS == nil {
return nil, errors.New("static files not available before app initialization; use AppInit to access files during initialization")
}
if !strings.HasPrefix(path, "static/") {
return nil, fmt.Errorf("OpenStaticFile path must start with 'static/': %w", fs.ErrNotExist)
}
// Strip "static/" prefix since the FS is already sub'd to the static directory
relativePath := strings.TrimPrefix(path, "static/")
return client.StaticFS.Open(relativePath)
}
// ListStaticFiles returns FileInfo for all files in the embedded static filesystem.
// The Name() of each FileInfo will be the full path prefixed with "static/" (e.g., "static/config.json"),
// which can be passed directly to ReadStaticFile or OpenStaticFile.
func ListStaticFiles() ([]fs.FileInfo, error) {
client := engine.GetDefaultClient()
if client.StaticFS == nil {
return nil, errors.New("static files not available before app initialization; use AppInit to access files during initialization")
}
var fileInfos []fs.FileInfo
err := fs.WalkDir(client.StaticFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
info, err := d.Info()
if err != nil {
return err
}
fullPath := "static/" + path
fileInfos = append(fileInfos, &staticFileInfo{
fullPath: fullPath,
info: info,
})
}
return nil
})
if err != nil {
return nil, err
}
return fileInfos, nil
}