wijjit-ssh

Flask for SSH apps. Serve Wijjit TUI applications over SSH: Wijjit draws the UI, asyncssh handles the transport and PTY, and every connection gets its own live app instance.

from wijjit import Wijjit, render_template_string
from wijjit_ssh import WijjitSSH, SSHSession, AuthorizedKeys, ensure_host_key

def make_app(session: SSHSession) -> Wijjit:
    app = Wijjit(backend=session.backend)          # <- routes I/O to the channel

    @app.view("main", default=True)
    def main():
        return render_template_string(
            "{% frame %}{% text %}Hi {{ who }}!{% endtext %}{% endframe %}",
            who=session.username,
        )

    return app

WijjitSSH(
    make_app,
    host_keys=[ensure_host_key("ssh_host_key")],   # generated on first run
    auth=AuthorizedKeys("~/.ssh/authorized_keys"),
).run(port=8022)
ssh -p 8022 you@localhost

That is the whole idea: a function that builds an app, and a client that lands straight in it. No shell, no exec, no SFTP, no port forwarding - a session only ever runs your Wijjit app.

The backend seam

Wijjit’s event loop talks to “the terminal” through a TerminalBackend - a small seam covering four things: frame output, key/mouse input, terminal size, and whether the app owns the process terminal. Locally that is LocalTerminalBackend. RemoteTerminalBackend implements the same seam against an SSH channel:

Concern

Local backend

Remote (SSH) backend

Frame output

sys.stdout

chan.write(...)

Input

real stdin, via prompt_toolkit on a thread

raw channel bytes decoded on the event loop - no thread, no prompt_toolkit

Size

shutil.get_terminal_size()

negotiated PTY size, refreshed on resize, published per task

Terminal ownership

owns_terminal=True (signals, atexit, suspend, raw mode)

owns_terminal=False (none of that)

Because Wijjit’s render context and terminal-size override are contextvar-based, N concurrent sessions of different sizes coexist in one process without stepping on each other - each runs as its own asyncio task.

Status

Early, but no longer a prototype. The input path is production-shaped, authentication is pluggable and fail-closed, resources are bounded by default, and shutdown drains rather than kills. What is not done yet is backpressure: a client that stops reading buffers frames in asyncssh without bound. See Resource limits for what is enforced today.

Indices