- JavaScript 87.7%
- Go 11.2%
- CSS 0.8%
- Shell 0.2%
- HTML 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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. |
||
| clienttests | ||
| cmd | ||
| docs | ||
| examples | ||
| middleware | ||
| scripts | ||
| static | ||
| wasm | ||
| .gitignore | ||
| AGENTS.md | ||
| appearance.go | ||
| appearance_test.go | ||
| assets.go | ||
| assets_test.go | ||
| cert.go | ||
| certstore.go | ||
| certstore_test.go | ||
| clipboard_test.go | ||
| cmd_session.go | ||
| cmd_unix.go | ||
| cmd_windows.go | ||
| config_context.go | ||
| connecterror.go | ||
| go.mod | ||
| go.sum | ||
| handlers.go | ||
| identity.go | ||
| kittygfx.go | ||
| kittygfx_test.go | ||
| LICENSE | ||
| lifecycle_test.go | ||
| middleware.go | ||
| origin_test.go | ||
| pageapi.go | ||
| pageapi_test.go | ||
| pointershapes.go | ||
| pointershapes_test.go | ||
| README.md | ||
| resize_throttle.go | ||
| run_js.go | ||
| run_native.go | ||
| server.go | ||
| server_test.go | ||
| session.go | ||
| session_unix.go | ||
| session_windows.go | ||
| sip.go | ||
sip
A Go server that puts a real terminal in a browser tab.
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-hashexchange 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 whatkitten icatand 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:
ConnectMiddlewarewraps the handshake,SessionMiddlewarewraps the byte streams, andMiddlewarewraps the per-session handler.LiftHTTPMiddlewareturns anyfunc(http.Handler) http.Handlerinto 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 kitty graphics placement, sent as PNG over the socket and decoded in the browser |
![]() |
| a Bubble Tea program on the alternate screen, scrolled with a real mouse wheel |
![]() |
| a drag selection over a command line in the scrollback, on its way to the clipboard |
![]() |
| the same text pasted back at the prompt, out of the system clipboard Ctrl+C wrote it to |
![]() |
| 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.
Related
- 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.




