bubblessh

package module
v1.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 15, 2026 License: MIT Imports: 17 Imported by: 0

README

bubble-ssh

An interactive SSH terminal as a Bubble Tea v2 component. bubblessh.Model dials an SSH server, requests a PTY, starts a shell, and renders it exactly like a normal ssh session would — keystrokes go to the remote shell, remote output (colors, cursor movement, full-screen apps like vim/htop) is interpreted and rendered back. Drop it in as your whole program, or embed it as one pane among several.

import "github.com/muhamm-ad/bubble-ssh"

m := bubblessh.New("example.com:22",
    bubblessh.WithUser("alice"),
    WithPassword(...) // or WithPrivateKeyFile(...)
    bubblessh.WithSize(80, 24),
    bubblessh.WithKnownHostsFile("~/.ssh/known_hosts"),
)

p := tea.NewProgram(m)
p.Run()

Install

go get github.com/muhamm-ad/bubble-ssh

Why this needed writing (and what it's built on)

As of August 2026, we didn't find a single off-the-shelf "SSH pane for Bubble Tea" package. bubble-ssh wires together three libraries that each do one part well, and is a standalone package that can be used in any Bubble Tea application:

Library Role
golang.org/x/crypto/ssh dials the server, authenticates, opens the PTY + shell
github.com/charmbracelet/x/vt a full VT220/ANSI terminal emulator — turns the remote byte stream into a screen you can render, and turns key/mouse events back into the right escape sequences
charm.land/bubbletea/v2 the Elm-architecture event loop that ties it into your TUI

See doc.go for an architecture diagram of how data flows between them.

Usage patterns

Examples live in their own Go module (examples/go.mod) so demo-only dependencies like lipgloss never end up in this library's own go.mod — run them from inside examples/, not the repo root:

cd examples
go run ./basic
go run ./split-pane
As the whole program

See examples/basic — wraps bubblessh.Model in a tiny root model that handles quitting on Ctrl+Q and forwards tea.WindowSizeMsg into SetSize. bubblessh.Model deliberately never calls tea.Quit itself or auto-tracks the window size, since it's also meant to be embedded — those are decisions for whatever owns the top-level program.

Embedded as one pane among several

See examples/split-pane — two independent SSH sessions rendered side by side with lipgloss, Tab to switch which one receives keystrokes. Every bubblessh.Model tags its internal async messages with its own instance id, so it's safe to Update() several instances with the same incoming message — each one ignores messages that aren't its own. Use Content() (a plain ANSI string) rather than View() (a tea.View) when composing a pane into a bigger layout.

A fuller reference app

examples/lazyssh is a separate project built on this library — a connection form driving the terminal pane. Linked here as a git submodule; see .gitmodules for how to populate it after cloning and how to bump it to lazyssh's latest commit.

Options

Option Purpose
WithUser(user) SSH username (required)
WithPassword(pw) password auth
WithPrivateKey(pem, passphrase) key auth from raw PEM bytes
WithPrivateKeyFile(path, passphrase) key auth, read from disk
WithAgent() auth via ssh-agent (SSH_AUTH_SOCK)
WithPort(n) override the port
WithKnownHostsFile(paths...) verify the host key against known_hosts file(s), reject unknown hosts
WithAcceptNewHostKeys(path) trust a new host once, remember it, verify strictly after that (ssh -o StrictHostKeyChecking=accept-new)
WithInsecureIgnoreHostKey() disables host key verification — testing/localhost only
WithSize(cols, rows) initial PTY size (default 80x24)
WithTerm(term) TERM sent to the remote PTY (default xterm-256color)
WithEnv(k, v) request an env var (subject to the server's AcceptEnv)
WithMouseForwarding() forward mouse events to the remote program
WithConnectTimeout(d) dial/auth timeout (default 10s)

You can combine multiple auth options — they're tried in the order given, same as the underlying ssh package.

Several auth methods (password, key, agent) can be combined; the client tries each ssh.AuthMethod in order, same as the underlying ssh package. Multiple calls to WithPassword/WithPrivateKey*/WithAgent all just append to that list.

Host key verification

If you don't call WithKnownHostsFile, WithAcceptNewHostKeys, or WithInsecureIgnoreHostKey, bubblessh tries ~/.ssh/known_hosts and returns a clear error if it can't find it — it will not silently skip verification. This is a deliberate "fail closed" default. For a real host you'll reconnect to, WithAcceptNewHostKeys(path) is the closest equivalent to the interactive "are you sure you want to continue connecting?" prompt a normal ssh client shows (bubble-ssh can't show that prompt itself — Connect runs on a background goroutine while Bubble Tea already owns the terminal — so it trades the prompt for automatic, remembered trust instead). Reach for WithInsecureIgnoreHostKey() only where MITM risk genuinely doesn't matter, e.g. a local container — it trusts every connection, forever, with no memory of anything.

A note on the dependency versions

This was written in July 2026, right after Bubble Tea shipped a stable v2 (new module path charm.land/bubbletea/v2, rebuilt on charmbracelet/ultraviolet primitives — Model.View() now returns a tea.View instead of a plain string, and KeyMsg is an interface implemented by KeyPressMsg/KeyReleaseMsg). charmbracelet/x/vt is explicitly labeled experimental by Charm, so its API can still move. Every method/type this package calls was checked against the actual upstream source at the time of writing, but if go build complains after you go mod tidy, it's most likely a small rename in x/vt or ultraviolet; the fix is almost always a one-line adjustment in keys.go, mouse.go, or connect.go.

The root go.mod only ever needs golang.org/x/crypto, charm.land/bubbletea/v2, github.com/charmbracelet/x/vt, and github.com/charmbracelet/ultraviolet — the library itself doesn't touch lipgloss, which examples/split-pane needs for its side-by-side layout. That lives in examples/go.mod instead, same reasoning charm.land/bubbletea/v2 itself uses for its own examples/ directory: demo-only dependencies shouldn't show up as required dependencies, or shift minimum-version floors, for someone who only imports the library.

License

MIT, see LICENSE.

Documentation

Overview

Package bubblessh embeds a real, interactive SSH session inside a Bubble Tea (charm.land/bubbletea/v2) model.

It opens an SSH connection with golang.org/x/crypto/ssh, requests a PTY, starts a remote shell, and feeds the remote output through a virtual terminal emulator (github.com/charmbracelet/x/vt) that understands ANSI/ VT220 escape sequences. The emulator's rendered screen becomes the Model's View(), and keystrokes typed into the Bubble Tea program are encoded and forwarded to the remote shell — the same way a normal `ssh` client would behave, but as a component you can drop into any TUI.

Architecture

┌──────────────┐  bytes   ┌──────────────┐  screen   ┌────────────┐
│ SSH session  │ ───────► │ vt.Emulator  │ ────────► │  View()    │
│ (remote pty) │ ◄─────── │ (ANSI state) │           │ (string)   │
└──────────────┘  bytes   └──────────────┘           └────────────┘
       ▲                         ▲
       │                         │ SendKey()
       │                  ┌──────┴───────────┐
       └── stdin.Write ── │  tea.KeyPressMsg │
                          └──────────────────┘

All calls into the vt.Emulator (Write, Resize, Render) happen on Bubble Tea's single Update/View goroutine, so there is no manual locking. Reading SSH stdout happens on a background goroutine that only ever pushes bytes onto a channel — the actual terminal-state mutation happens inside Update(), which is what Bubble Tea guarantees is single-threaded.

Basic usage

m := bubblessh.New("example.com:22",
	bubblessh.WithUser("alice"),
	bubblessh.WithAgent(),
	bubblessh.WithSize(80, 24),
)
p := tea.NewProgram(m)
if _, err := p.Run(); err != nil {
	log.Fatal(err)
}

See the examples/ directory for a standalone full-screen client and for embedding the pane as one half of a split-screen layout.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type CursorShape added in v0.2.0

type CursorShape int
const (
	CursorBlock CursorShape = iota
	CursorUnderline
	CursorBar
)

type Model

type Model struct {
	// contains filtered or unexported fields
}

Model is a Bubble Tea component that renders a live, interactive SSH session. Build one with New, run its Init() Cmd (directly, or batched into a parent's Init), and route Update/View like any other Bubble Tea model or embeddable sub-model.

The zero value is not usable — always construct via New.

func New

func New(addr string, opts ...Option) Model

New creates a Model for the given address ("host" or "host:port"). Call options like WithUser and WithAgent/WithPassword/WithPrivateKey to configure authentication before use — nothing is connected yet, that happens when Init()'s command runs.

func (Model) Close

func (m Model) Close() error

Close tears down the SSH session and the underlying TCP connection. Call it when you're done with this pane — Bubble Tea has no "unmount" hook, so nothing does this for you automatically (e.g. call it before switching away from this pane, or on program exit).

func (Model) Connected

func (m Model) Connected() bool

Connected reports whether the SSH session is currently up.

func (Model) Content

func (m Model) Content() string

Content returns the current screen content as a plain styled string (ANSI colors/links included, no cursor-positioning wrapper). Use this instead of View() when embedding the pane inside a larger layout, e.g. with lipgloss.JoinHorizontal.

The result is always exactly height rows (and at most width cells per row), matching the size last set via WithSize/SetSize. Overflow is clipped from the top so the bottom — the most recent output — stays visible; ScrollUp/ScrollDown already choose which window of history to show, and this only clamps that window to the pane.

func (Model) Cursor added in v0.2.0

func (m Model) Cursor() *tea.Cursor

Cursor returns the cursor to draw for the current state, in content-local coordinates (0,0 is the top-left of Content()) — or nil if no cursor should be shown right now (not connected, or the remote hid it). View() uses this directly; if you're composing Content() into a bigger layout instead, use this too and offset the position by wherever you place that content on screen.

func (Model) Err

func (m Model) Err() error

Err returns the last error (connection failure or unexpected close), if any.

func (Model) Init

func (m Model) Init() tea.Cmd

Init satisfies tea.Model. It kicks off the SSH connection asynchronously; nothing blocks here.

func (Model) ScrollDown added in v0.2.0

func (m Model) ScrollDown(lines int) Model

ScrollDown scrolls the view down by the given number of lines, back toward the live screen.

func (Model) ScrollToBottom added in v0.2.0

func (m Model) ScrollToBottom() Model

ScrollToBottom returns to the live view. A normal terminal does this the moment you type anything while scrolled back — Update() already calls this on every key press and paste, so you're never typing blind into a view that's showing history instead of the live screen.

func (Model) ScrollUp added in v0.2.0

func (m Model) ScrollUp(lines int) Model

ScrollUp scrolls the view up by the given number of lines, into the scrollback history. Clamped at the oldest available line.

func (Model) Scrolled added in v0.2.0

func (m Model) Scrolled() bool

Scrolled reports whether the view is currently showing scrollback history instead of the live screen.

func (Model) SetSize

func (m Model) SetSize(cols, rows int) (Model, tea.Cmd)

SetSize resizes the PTY, both locally (the virtual terminal emulator) and on the remote end (an SSH "window-change" request). Wire this to tea.WindowSizeMsg yourself if this pane should track the full window — it's not done automatically since an embedded pane is often smaller than the whole screen.

func (Model) State added in v1.1.0

func (m Model) State() State

func (Model) Update

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd)

Update satisfies tea.Model.

func (Model) View

func (m Model) View() tea.View

View satisfies tea.Model.

type Option

type Option func(*Model)

Option configures a Model. Pass any number of them to New.

func WithAcceptNewHostKeys

func WithAcceptNewHostKeys(path string) Option

WithAcceptNewHostKeys behaves like `ssh -o StrictHostKeyChecking=accept-new`: a host you've never connected to before is trusted automatically, and its key is appended to the known_hosts file at path (created if it doesn't exist yet, along with any missing parent directories). Every later connection to that host is then checked strictly against what was learned — if the server's key ever changes unexpectedly, the connection is refused. That refusal on change is the actual security property; the "trust" part only ever applies once, to a genuinely new host. This is the closest equivalent to the interactive "are you sure you want to continue connecting?" prompt a normal ssh client shows — bubble-ssh can't show that prompt itself (Connect runs on a background goroutine while Bubble Tea already owns the terminal), so this trades the prompt for automatic, remembered trust instead.

func WithAgent

func WithAgent() Option

SSH_AUTH_SOCK environment variable. It's resolved lazily at connection time, so it's safe to call even if no agent is running yet (it'll just fail at Connect time with a clear error).

Windows: this only reaches agents exposed as a Unix domain socket (macOS, Linux, Git Bash/MSYS2, WSL). The native Windows OpenSSH agent service uses a named pipe instead, which net.Dial("unix", ...) can't reach — see docs/ssh-agent-windows.md for the full explanation and possible fixes. Not fixed yet; documented as a known gap.

func WithConnectTimeout

func WithConnectTimeout(d time.Duration) Option

WithConnectTimeout bounds how long dialing and authentication may take. Default is 10 seconds.

func WithCursorShape added in v0.2.0

func WithCursorShape(shape CursorShape) Option

WithCursorShape sets how the connected terminal's cursor is drawn. Default is CursorBar.

This is a fixed choice — the cursor always renders in this shape, regardless of what the remote program is doing. It does not track cursor-shape requests from the remote side. bubblessh always shows the one shape you pick here.

func WithEnv

func WithEnv(key, value string) Option

WithEnv requests an extra environment variable on the remote session. Note most sshd configs only forward variables listed in their AcceptEnv directive — this is a server-side restriction bubblessh can't work around.

func WithInsecureIgnoreHostKey

func WithInsecureIgnoreHostKey() Option

WithInsecureIgnoreHostKey disables host key verification entirely — every connection, forever, no memory of anything. This makes the connection vulnerable to man-in-the-middle attacks — only use it for throwaway boxes (e.g. a container on localhost) or local testing. For a real host you'll reconnect to, prefer WithAcceptNewHostKeys: it only trusts blindly once, then verifies strictly from then on.

func WithKnownHostsFile

func WithKnownHostsFile(paths ...string) Option

WithKnownHostsFile verifies the server's host key against one or more OpenSSH-format known_hosts files (e.g. "~/.ssh/known_hosts", expanded by you). Unknown hosts are rejected — this never prompts or writes anything, it only checks. If you want a new host to be trusted automatically on first connection and remembered from then on, use WithAcceptNewHostKeys instead. If you never call this, WithAcceptNewHostKeys, or WithInsecureIgnoreHostKey, Connect will try the default "~/.ssh/known_hosts" and fail loudly if it can't find it, rather than silently skipping verification.

func WithMouseForwarding

func WithMouseForwarding() Option

WithMouseForwarding forwards mouse events (clicks, wheel, motion) to the remote program, useful for full-screen remote apps like vim or tmux with mouse mode on. Your top-level tea.Program / parent View still needs to request mouse tracking (set tea.View.MouseMode) for Bubble Tea to emit mouse messages in the first place.

func WithPassword

func WithPassword(password string) Option

WithPassword adds password authentication. You can combine this with other auth options (e.g. WithAgent) — the SSH client tries each in turn.

func WithPort

func WithPort(port int) Option

WithPort overrides the port. If you already included a port in the addr passed to New (e.g. "host:2222"), this is not needed.

func WithPrivateKey

func WithPrivateKey(pemBytes []byte, passphrase string) Option

WithPrivateKey adds public-key authentication from raw PEM bytes. Pass an empty passphrase if the key isn't encrypted.

func WithPrivateKeyFile

func WithPrivateKeyFile(path, passphrase string) Option

WithPrivateKeyFile adds public-key authentication, reading the key from disk (e.g. "~/.ssh/id_ed25519" — expand "~" yourself, Go doesn't). Pass an empty passphrase if the key isn't encrypted.

func WithSize

func WithSize(cols, rows int) Option

WithSize sets the initial PTY size, in columns and rows. Default is 80x24. Call SetSize later to resize an already-connected session (e.g. in response to tea.WindowSizeMsg).

func WithTerm

func WithTerm(term string) Option

WithTerm sets the TERM environment variable requested for the remote PTY. Default is "xterm-256color".

func WithUser

func WithUser(user string) Option

WithUser sets the SSH username. Required.

type State added in v1.1.0

type State int

State is the internal connection lifecycle of a Model.

const (
	StateIdle State = iota
	StateConnecting
	StateConnected
	StateClosed
	StateError
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL