mirror of
https://github.com/wavetermdev/waveterm
synced 2026-05-24 09:18:27 +00:00
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>
276 lines
8.7 KiB
Go
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
|
|
}
|