Serve Bubble Tea apps through the browser - Drinking tea through the browser 🍵
  • JavaScript 87.7%
  • Go 11.2%
  • CSS 0.8%
  • Shell 0.2%
  • HTML 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Gaurav Gosain bd307d112e Merge branch 'fix/kitty-edge-crop'
A kitty image leaving the viewport is cropped at the edge rather than
scaled into the rows that are left, so scrolling back through history no
longer shrinks it a step at a time.
2026-09-13 18:43:34 +04:00
clienttests feat(pageapi): a page drives the terminal, and the deployment says how far 2026-09-11 16:22:07 +04:00
cmd feat(cli): a cert command, --auto-tls, and one question at the right moment 2026-08-15 14:49:10 +04:00
docs feat(pageapi): a page drives the terminal, and the deployment says how far 2026-09-11 16:22:07 +04:00
examples feat(pageapi): a page drives the terminal, and the deployment says how far 2026-09-11 16:22:07 +04:00
middleware test(clipboard): cover the OSC 52 copy chain on both sides 2026-07-18 10:18:39 +04:00
scripts docs: give the README a banner, a social preview and a real hero recording 2026-07-19 17:16:05 +04:00
static fix(kitty): an image leaving the viewport is cropped, not squashed 2026-09-13 18:43:31 +04:00
wasm chore: gofmt two files that had drifted 2026-08-15 14:46:22 +04:00
.gitignore test: cover the vtgl renderer path end to end 2026-07-18 12:35:57 +04:00
AGENTS.md fix(kitty): an image leaving the viewport is cropped, not squashed 2026-09-13 18:43:31 +04:00
appearance.go feat(pointer): a program in the terminal names the mouse pointer 2026-09-11 15:31:17 +04:00
appearance_test.go feat(appearance): the palette, the cursors and the chrome are set from Go 2026-09-11 14:32:08 +04:00
assets.go feat(extend): the page can be changed from Go without forking sip 2026-09-11 14:56:53 +04:00
assets_test.go fix(kitty): an image leaving the viewport is cropped, not squashed 2026-09-13 18:43:31 +04:00
cert.go wip: checkpoint ghostty-web client migration 2026-07-18 08:59:08 +04:00
certstore.go feat(cert): manage a self-signed keypair instead of ending at "get a cert" 2026-08-15 14:46:22 +04:00
certstore_test.go feat(cert): manage a self-signed keypair instead of ending at "get a cert" 2026-08-15 14:46:22 +04:00
clipboard_test.go test(clipboard): cover the OSC 52 copy chain on both sides 2026-07-18 10:18:39 +04:00
cmd_session.go wip: checkpoint ghostty-web client migration 2026-07-18 08:59:08 +04:00
cmd_unix.go fix: unblock session teardown and single-own the child reap 2026-07-18 09:11:11 +04:00
cmd_windows.go fix: unblock session teardown and single-own the child reap 2026-07-18 09:11:11 +04:00
config_context.go fix(transport): bound output writes with a deadline 2026-07-18 09:53:00 +04:00
connecterror.go wip: checkpoint ghostty-web client migration 2026-07-18 08:59:08 +04:00
go.mod feat(cli): a cert command, --auto-tls, and one question at the right moment 2026-08-15 14:49:10 +04:00
go.sum feat: migrate to charm.land bubbletea/v2 and lipgloss/v2 2025-12-23 12:23:02 +04:00
handlers.go feat(appearance): the palette, the cursors and the chrome are set from Go 2026-09-11 14:32:08 +04:00
identity.go wip: checkpoint ghostty-web client migration 2026-07-18 08:59:08 +04:00
kittygfx.go docs: describe the xterm.js client instead of the ghostty-web one 2026-07-19 09:17:29 +04:00
kittygfx_test.go fix(kitty): stop a never-terminating chunked transfer from wedging the transcoder 2026-07-18 09:42:01 +04:00
LICENSE docs: sip library planning and vision (v0.1.0) 2025-12-02 23:12:01 +04:00
lifecycle_test.go fix: unblock session teardown and single-own the child reap 2026-07-18 09:11:11 +04:00
middleware.go wip: checkpoint ghostty-web client migration 2026-07-18 08:59:08 +04:00
origin_test.go chore: gofmt two files that had drifted 2026-08-15 14:46:22 +04:00
pageapi.go feat(pageapi): a page drives the terminal, and the deployment says how far 2026-09-11 16:22:07 +04:00
pageapi_test.go feat(pageapi): a page drives the terminal, and the deployment says how far 2026-09-11 16:22:07 +04:00
pointershapes.go feat(pointer): a program in the terminal names the mouse pointer 2026-09-11 15:31:17 +04:00
pointershapes_test.go feat(pointer): a program in the terminal names the mouse pointer 2026-09-11 15:31:17 +04:00
README.md feat(pageapi): a page drives the terminal, and the deployment says how far 2026-09-11 16:22:07 +04:00
resize_throttle.go fix(server): apply an isolated resize immediately instead of on the next tick 2026-07-20 14:52:35 +04:00
run_js.go wip: checkpoint ghostty-web client migration 2026-07-18 08:59:08 +04:00
run_native.go wip: checkpoint ghostty-web client migration 2026-07-18 08:59:08 +04:00
server.go feat(pageapi): a page drives the terminal, and the deployment says how far 2026-09-11 16:22:07 +04:00
server_test.go feat(touch): a thumb-sized bar, a measured keyboard state, and a taught drag 2026-09-09 08:51:11 +04:00
session.go wip: checkpoint ghostty-web client migration 2026-07-18 08:59:08 +04:00
session_unix.go wip: checkpoint ghostty-web client migration 2026-07-18 08:59:08 +04:00
session_windows.go wip: checkpoint ghostty-web client migration 2026-07-18 08:59:08 +04:00
sip.go feat(pageapi): a page drives the terminal, and the deployment says how far 2026-09-11 16:22:07 +04:00

sip: a real terminal, served in the browser

sip

Go reference

A Go server that puts a real terminal in a browser tab.

a browser tab holds a live shell served by sip: a directory listing of this repository comes back with colour and Nerd Font glyphs, glow opens on the alternate screen and a mouse wheel scrolls the rendered README, then kitten icat sends this project's banner over the same socket and the client's kitty overlay decodes and draws it on the cells it occupies, and finally a drag selects the icat command line, Ctrl+C puts it on the system clipboard, and pasting it back fills the prompt with the text that came out of the clipboard

every keystroke, wheel event, drag and copy above crosses a websocket to a real pseudo-terminal, and the image is decoded in the browser from the kitty escape the program wrote

You bring a command, or a Bubble Tea model, and a browser. sip gives back a pseudo-terminal to run it on, a client that renders the result with xterm.js, and a wire between them that speaks WebTransport where the browser will and WebSocket everywhere else. The program on the far side takes its interactive code path: isatty is true, TERM means something, SIGWINCH arrives when the tab is resized, and the window size carries pixel dimensions so kitty graphics tools can size an image to the cell grid.

There are two ways in and neither is the lesser one. sip -- htop serves a command with no Go anywhere. server.Serve runs a Bubble Tea model per browser session, with a handler shaped like Wish's. Both build the same session over the same transports, so a bug in one is a bug in the other.

The client is vendored, not built. static/webterm.js is a standalone build of the webterm package, which inlines xterm.js and its fit, webgl, canvas, web-links, image and unicode-graphemes addons along with the kitty overlay, the clipboard layer and the width overrides. There is no build step, no bundler and no node_modules between a clone and a running server: go:embed bakes the whole client, fonts included, into one binary.

What it does

  • Runs the program on a real pseudo-terminal through xpty, so it takes its interactive path rather than the piped-output one, and tears it down by process group so a multiplexer's daemon does not outlive the session.
  • Speaks WebTransport over HTTP/3 where the browser negotiates it and falls back to WebSocket otherwise, with the same type-prefixed binary framing behind both and a /cert-hash exchange for the self-signed loopback case.
  • Draws kitty graphics through an overlay that anchors each placement to the buffer row that introduced it, so images scroll with their text instead of hanging over it, and repositions every placement on scroll and resize.
  • Decodes PNG placements in the browser with createImageBitmap. The server-side transcoder that re-emits them as raw RGBA is there for clients without a decoder and is off by default, because forwarding the compressed payload is both smaller and faster.
  • Carries pixel dimensions on resize and forwards them to TIOCSWINSZ, which is what kitten icat and ntcharts read to size an image to the grid.
  • Copies a selection to the system clipboard on Ctrl+C, and forwards OSC 52 writes from programs that set the clipboard themselves. It never answers an OSC 52 read: a remote program cannot pull your clipboard back through the terminal.
  • Sets the mouse pointer from the program, through the kitty pointer shapes protocol: a stack per screen, queries answered on the program's own stdin, and every name checked against the thirty the specification names before it reaches a CSS property.
  • Clusters graphemes to UAX 29 rather than billing per scalar, so a ZWJ family emoji takes the columns it draws in instead of eight.
  • Puts a key bar over the software keyboard on a phone, carrying the keys a phone keyboard does not have: Escape, Tab, the arrows, and Ctrl and Alt as sticky modifiers, because a touch screen cannot hold one key while pressing another. It measures the keyboard and gives the terminal what is left, and it makes a finger act like a mouse: tap to click, long press to right click, hold and drag to drag or to select.
  • Refuses to bind a non-loopback address without TLS, and refuses basic auth without it, unless told otherwise by an explicit flag that logs what it is giving up. Loopback gets a self-signed certificate generated on the spot.
  • Composes at three layers, after Wish: ConnectMiddleware wraps the handshake, SessionMiddleware wraps the byte streams, and Middleware wraps the per-session handler. LiftHTTPMiddleware turns any func(http.Handler) http.Handler into the first of those, so chi, gorilla and otelhttp are all reusable at the door.
  • Ships as one binary. The client, the stylesheets and JetBrains Mono Nerd Font are embedded, tagged with a content ETag so a redeployed client actually takes effect.

What it looks like

Every image below is a frame of the real client in a real browser, driven against a real server by scripts/demo/record-hero.mjs, which is also what regenerates the recording at the top of this page. The programs are the ones on this machine and the files are this repository's own.

a shell in the browser has just run kitten icat on this repository's banner, and the image is drawn across the cells above the next prompt at its full width
a kitty graphics placement, sent as PNG over the socket and decoded in the browser
glow is running on the alternate screen with this repository's README rendered as styled markdown, scrolled part way down by a mouse wheel
a Bubble Tea program on the alternate screen, scrolled with a real mouse wheel
a drag has highlighted the kitten icat command line in the scrollback, leaving the rest of the screen unselected
a drag selection over a command line in the scrollback, on its way to the clipboard
the prompt at the bottom of the screen now holds the command that was selected further up, pasted back out of the system clipboard and highlighted by the shell as bracketed paste
the same text pasted back at the prompt, out of the system clipboard Ctrl+C wrote it to
the client's settings panel is open over the terminal with rows for transport and renderer, a font size slider, checkboxes for copy on select and cursor blink, and a status block reporting that the renderer is WebGL and the transport fell back to WebSocket
the client's own settings, reporting the renderer and the transport it actually got

Install

go get github.com/Gaurav-Gosain/sip          # library
go install github.com/Gaurav-Gosain/sip/cmd/sip@latest   # command line

Command line

sip -- htop                                   # serve a command
sip -p 8080 -- lazygit                        # on another port
sip --host 0.0.0.0 --cert s.crt --key s.key -- bash       # public, with TLS
sip --basic-user admin --basic-pass-file /run/secrets/sip --cert s.crt --key s.key -- bash
sip --font /path/to/CommitMono.ttf --font-family "Commit Mono" -- nvim

Then open http://localhost:7681. The command to run goes after --, so its own flags are never read as sip's.

flag meaning
-H, --host, -p, --port bind address and HTTP port; WebTransport uses port+1
--cert, --key TLS certificate and key
--auto-tls serve HTTPS from a self-signed certificate sip generates and keeps
--cert-dir, --cert-host, --cert-days where that certificate lives, what else it signs for, how long it lasts
--allow-insecure-no-tls permit a non-loopback bind, or basic auth, without TLS
--origin browser origin allowlist, a path.Match glob, repeatable
--basic-user, --basic-pass-file HTTP basic auth; $SIP_PASSWORD is also read
--max-conns, --idle-timeout concurrent session limit and idle cutoff
--renderer webgl, canvas or dom; empty picks the best available
--font, --font-family serve a font from disk instead of the embedded one
-d, --dir working directory for the wrapped command

sip completion writes a bash, zsh, fish or powershell script. Commands, help and completion are built on spf13/cobra and rendered by charmbracelet/fang.

Library

server := sip.NewServer(sip.DefaultConfig())

err := server.Serve(ctx, func(sess sip.Session) (tea.Model, []tea.ProgramOption) {
	pty := sess.Pty()
	return model{width: pty.Width, height: pty.Height}, nil
})

Handler returns a model and its options per session. ServeWithProgram takes a ProgramHandler and hands back a *tea.Program instead, for when the program needs constructing directly:

server.ServeWithProgram(ctx, func(sess sip.Session) *tea.Program {
	return tea.NewProgram(myModel{}, sip.MakeOptions(sess)...)
})

A Session is the terminal:

type Session interface {
	Pty() Pty                            // terminal dimensions
	Context() context.Context            // cancelled when the browser goes away
	Read(p []byte) (n int, err error)
	Write(p []byte) (n int, err error)
	WindowChanges() <-chan WindowSize    // resize events
}

Config covers the rest: Host, Port, ReadOnly, MaxConnections, IdleTimeout, AllowOrigins and OriginPatterns, TLSCert and TLSKey, AutoTLS and its CertDir, CertHosts and CertValidity, BasicUsername and BasicPassword, MaxPasteBytes, ResizeThrottle, MaxWindowDims, FontPath and FontFamily, Appearance for the palette and the cursors, the touch key bar's MobileKeys, MobileRows and MobilePrefix, and the three middleware slices. Every field has a working default; sip.DefaultConfig() is a complete configuration.

Three middleware subpackages ship with it: middleware/osc52gate to allow, deny or audit outbound OSC 52 clipboard writes, middleware/recover to catch a panic while a handler builds a model, and middleware/logging for slog session start and end lines.

A Bubble Tea program can also be compiled to wasm and run with no server at all: go run ./cmd/sip-wasm-build -o web/app.wasm ./cmd/myapp builds it, and the client connects to it through the same adapter it uses for a socket.

Colours

The terminal's palette, its two cursors and the chrome around them are Config.Appearance. The zero value is sip's own Catppuccin Mocha, so a program that sets nothing looks exactly as it did before the field existed.

sip.Config{
    Appearance: sip.Appearance{
        Theme: sip.Theme{
            Foreground: "#ebdbb2",
            Background: "#282828",
            Cursor:     "#fe8019",
        }.WithANSI(sip.ANSIPalette{
            "#282828", "#cc241d", "#98971a", "#d79921",
            "#458588", "#b16286", "#689d6a", "#a89984",
            "#928374", "#fb4934", "#b8bb26", "#fabd2f",
            "#83a598", "#d3869b", "#8ec07c", "#ebdbb2",
        }),
        MouseCursor: "crosshair",
        CursorStyle: "bar",
        Title:       "Gruvbox shell",
    },
}

A theme is a patch, not a replacement: an unset colour keeps sip's own, so three colours change three colours. WithANSI takes the sixteen in index order, which is how a palette read out of kitty, ghostty, alacritty or wezterm arrives, and the named fields are xterm.js's own so an imported theme reads next to the file it came from.

The page follows the palette. The settings panel, the status pill, the scrollbar, the bell flash and the touch key bar are painted from the same colours as the terminal, so a Gruvbox terminal does not sit in a Catppuccin panel. PageBackground sets the ground behind the grid on its own, for the one case where the two should differ.

Colours are hex: #rgb, #rgba, #rrggbb or #rrggbbaa. A bad colour or an unknown cursor keyword stops the server at startup and names the field. That is deliberate. A browser drops a value it cannot parse and paints the default, which makes a typo look like sip ignoring the setting.

examples/appearance is the whole thing in one file.

Hacking the page

Everything the browser runs is embedded in sip's binary, so until this existed changing one CSS rule meant forking sip. A handful of options change the page from Go instead, and a deployment that sets none of them serves exactly what it served before they existed.

sip.Config{
    ExtraCSS: `#connection-status { border-color: #a6e3a1; }`,
    ExtraJS:  `sip.on('connect', (e) => console.log('on', e.transport));`,
    PageAPI:  sip.PageAPI{Grant: []sip.Capability{sip.CapAppearance}},
    StaticFS: myAssets,                       // wins over sip's files, name by name
    Routes: []sip.Route{
        {Pattern: "/manifest.webmanifest", Handler: http.HandlerFunc(manifest)},
    },
}

Reach for them in that order. ExtraCSS and ExtraJS add to what sip serves, so an upgrade carries every fix to the files underneath. StaticFS replaces a file, which is a fork of that one file: sip names the replaced files at startup and sip.AssetDigest lets a test of yours fail on the upgrade that moved the original.

window.sip is what a page script may rely on: on, off, send, size, and the events ready, connect, disconnect, resize and title. There is no handle to the xterm.js terminal in it, on purpose. Sip may change what renders the grid, and a promise it plans to break is worse than no promise.

Config.PageAPI decides what else that script may do. sip.claim() hands over a theme switcher, a search box, the selection, the clipboard and a reconnect button, one capability at a time. Every one of them is off unless you grant it, and the default is what window.sip answered before the option existed. Read docs/extending.md before granting any of them: it carries the threat model, including the part a capability list cannot do.

examples/hackable is all of it in one file, and docs/extending.md argues each option and says what is deliberately missing.

Phones

A phone keyboard has no Escape, no Tab, no Ctrl and no arrows, which is most of what a terminal is driven with, and when it opens it covers the bottom half of the terminal it was opened for. On a touch device sip puts a scrolling key bar above the keyboard with those keys on it, measures how much of the window the keyboard is actually covering, and reserves it so the grid is refitted to what is left rather than being hidden behind it. Ctrl and Alt are sticky: one tap arms the modifier for the next keystroke, a second locks it until tapped off.

The default key set assumes nothing about what is running, because sip serves arbitrary programs. A library caller that knows better can replace it:

srv, _ := sip.New(sip.Config{
    MobileKeys: []sip.MobileKey{
        {Label: "esc", Title: "Escape", Key: "Escape"},
        {Label: "ctrl", Title: "Ctrl", Mod: "ctrl"},
        {Label: "^C", Title: "Interrupt", Key: "c", Ctrl: true},
        {Label: "/", Title: "Search", Key: "/", Narrow: true},
    },
})

Key is a KeyboardEvent key name or a literal character, Mod makes the button a sticky modifier instead, and DisableMobileKeyBar leaves the strip out for a program that draws touch controls of its own while keeping the keyboard-aware layout.

Leader chords

tmux, screen, zellij and emacs are all driven by a leader: press Ctrl+B, let go, then press a letter. A touch screen cannot hold a modifier while pressing another key, so on a phone every one of those bindings is unreachable, which for those programs is most of what they can do.

Declare the leader and the chords are buttons:

sip.Config{
    MobilePrefix: sip.MobilePrefix{Key: "b", Code: "KeyB", Ctrl: true},

    MobileRows: []sip.MobileRow{{
        Label:       "tmux",
        Collapsible: true,
        Keys: []sip.MobileKey{
            {Label: "pfx", Title: "Prefix, then a key", Prefix: true},
            {Label: "new", Title: "New window", Key: "c", Prefixed: true},
            {Label: "next", Title: "Next window", Key: "n", Prefixed: true},
            {Label: "split", Title: "Split", Key: "%", Prefixed: true},
            {Label: "zoom", Title: "Zoom the pane", Key: "z", Prefixed: true},
            {Label: "detach", Title: "Detach", Key: "d", Prefixed: true},
        },
    }, {
        Label: "keys",
        Keys:  sip.DefaultMobileKeys(),
    }},
}

MobilePrefix is whatever your leader is: Ctrl+A for a rebound tmux, Ctrl+X for emacs. A Prefixed key is one tap for the whole chord: the leader goes out, then the key with whatever Ctrl, Alt and Shift the button declares, so {Key: "o", Ctrl: true, Prefixed: true} is tmux's Ctrl+B Ctrl+O in one tap. The bar's own sticky modifiers are cleared rather than folded in, because Ctrl+B Ctrl+C is a different chord from Ctrl+B C and the user pressed one button. The Prefix button is for everything you did not give a button to: tap it and it lights up, then type the second half on the software keyboard.

Rows are drawn top to bottom and the typing row goes last, nearest the thumb that is already on the keyboard. A Collapsible row can be folded away by a control pinned to the right of the bar, which gives its height back to the terminal, and the choice is remembered.

A button is a keystroke and nothing more. If the program has no binding for the key, tapping it does exactly what typing that key does, which for an unbound chord is nothing at all: sip cannot know your keymap, so it never reports a success it did not have. The one case it will not fake is a Prefix button with no MobilePrefix set, which would light up and arm a chord that is never sent; that button is left out of the bar, and a Prefixed key with no prefix sends itself bare.

A finger is a mouse

xterm.js's own touch handling stops at turning a pan into scroll, so on a phone a tap, a long press and a drag reach the program as nothing at all: not a click, not a cursor placed, not a selection. sip fills that in.

gesture what the program gets
tap a click, and the software keyboard
long press a right click
press, hold, then drag a press, motion and a release: pull a split, move a window, select a region
pan scroll, as before

It is expressed as the mouse events a browser would have synthesized, not as bytes, so whichever mouse mode and encoding your program asked for is what it gets, and a program that asked for no mouse reporting at all gets the other useful thing for free: the same press-hold-drag selects text.

The zero value is all of it, on, and none of it installs without a touch screen. Config.MobileMouse turns parts off and retunes the hold:

sip.Config{
    MobileMouse: sip.MobileMouse{DisableTap: true, LongPressMs: 600},
}

The floating settings gear is draggable on a desktop and remembers where it was left, because it floats over whatever the program is drawing and there is no corner that is free of every program. On a phone it is not a floating control at all: it moves into the key bar.

TLS

sip refuses a non-loopback bind without TLS. Binding a LAN address is exactly what you do to reach a terminal from your phone, so that refusal is in the common path, and until it had an answer the answer people found was --allow-insecure-no-tls, permanently.

sip --host 0.0.0.0 --auto-tls -- bash

That generates a keypair on first use, keeps it in your user config directory, and reuses it afterwards. Run it at a terminal without --auto-tls and sip asks first; run it from a script, a container or a systemd unit and it does not ask, because there is nobody to answer, so what those get is the same refusal with a message naming the flag. Nothing here turns the requirement off.

sip cert            # where it is, what it covers, when it expires, its fingerprint
sip cert new        # generate or replace one
sip cert rm         # delete it
sip cert path       # just the path, for a unit file (--key for the key's)

The certificate signs for itself, so the first visit from any browser shows a warning: "Your connection is not private", NET::ERR_CERT_AUTHORITY_INVALID, or "Potential Security Risk Ahead". That is expected. Choose Advanced, then Proceed. The connection is encrypted either way; what the browser cannot do is vouch for who is on the other end. To stop seeing it, copy the .crt to the device and install it as a trusted certificate: on Android under Settings, Encryption & credentials, Install a certificate, CA certificate; on iOS open the file, install the profile, then enable it under About, Certificate Trust Settings. sip cert prints all of this with the fingerprint to compare against.

It signs for localhost, this machine's hostname and hostname.local, and every non-loopback address on every interface, so the LAN address you actually type works. --cert-host adds names only your router's DNS knows. A certificate that stops covering the address being bound, which is what a moved DHCP lease looks like, is regenerated rather than served into a name mismatch the browser will not let you click through.

The private key is written 0600 inside a 0700 directory, and its path is printed by sip cert path --key and nowhere else. Everywhere else it could appear is a terminal that may be shared or recorded, which is the thing sip is for.

Clipboard

Two independent paths, and which one you need depends on the program.

Selection, which works everywhere and needs no configuration: drag to select and press Ctrl+C. Double-click takes a word, triple-click a line. When a program holds the mouse, as tmux, vim and htop do with mouse mode on, hold Shift while dragging to bypass mouse reporting and select the screen text instead. "Copy on select" in the settings panel copies on release. Ctrl+Shift+C is not the path to rely on: both Chromium and Firefox reserve it for devtools and it never reaches the page.

OSC 52, for programs that set the clipboard themselves: sip forwards the escape to the browser, which writes it to the system clipboard. tmux does not emit OSC 52 under its default set-clipboard external, so add set -g set-clipboard on to your tmux.conf, at every level if you nest it. On a secure origin, meaning https or http on localhost, the write is immediate; on a plain-http LAN address the browser blocks the async clipboard API and sip falls back to a legacy copy that runs on your next click or keypress. Serving over https is how to get gesture-free clipboard writes.

  • Bubble Tea, the TUI framework the library mode serves
  • Wish, SSH for Bubble Tea apps, and the model for sip's handler and middleware API
  • webterm, the embeddable browser terminal sip vendors as its client
  • TUIOS, the terminal window manager sip was extracted from
  • xterm.js, which parses the VT stream and owns input, selection and scrollback
  • ttyd, the same idea in C

License

MIT. See LICENSE.