Chat room
A terminal chat room with a scrolling transcript, join and leave notices, and a handful of slash commands - and no user accounts at all.
uv run python examples/chat_ssh.py # :8023
ssh -p 8023 alice@localhost # one terminal
ssh -p 8023 bob@localhost # another
Alice types a line and it appears in Bob’s window without Bob touching a key.
No user accounts, deliberately
There is no registration step, no nickname collision to resolve, and no password
to store, because SSH authenticated everyone before the app was ever built. The
display name is session.username, and under
AuthorizedKeys a user’s identity is their SSH key.
An app served this way inherits an account system it did not have to write - and one with better properties than most chat rooms ship with, since there is no credential in the app to leak.
That also makes running it anonymously worse than it is for hello_ssh.py.
With no auth policy, anyone can connect as anyone; in a chat room that is
impersonation, not merely unauthenticated access. The example says so, loudly,
when it falls back:
WARNING: no ~/.ssh/authorized_keys found - running with NO AUTHENTICATION.
In a chat room that is worse than it sounds: usernames come
straight from SSH, so with no auth policy anyone can connect
as anyone. Fine on localhost; never on a real network.
One transcript, no copies
The room owns the conversation and every view reads it directly, so there is exactly one transcript in the process no matter how many people are in it:
@dataclass
class ChatRoom:
transcript: deque[str] = field(
default_factory=lambda: deque(maxlen=HISTORY_LIMIT),
)
subscribers: dict[str, Subscriber] = field(default_factory=dict)
def post(self, line):
self.transcript.append(f"{_stamp()} {line}")
self.broadcast()
def broadcast(self):
for subscriber in self.subscribers.values():
subscriber.app.refresh()
Because nothing is copied, broadcast has nothing to distribute - by the time
it runs, every window’s data is already correct and only its pixels are
stale. All it does is ask each app to look again. See Examples for what
refresh() costs and how fast it lands.
The deque bound matters more than it looks. A chat room that stays up for a
week must not grow a list for a week; maxlen drops the oldest line instead.
Private replies go somewhere else
/who answers one person. Appending that answer to the transcript would be
wrong twice over - everybody else would see one user’s /who, and the
transcript belongs to the room, so a private line written into it would either
leak or be lost on the next post.
Per-user replies go to state["notice"], which the view renders in the status
line. That state is the app’s own, which makes it exactly the right place for
something only this user should see:
elif command == "who":
app.state["notice"] = f"Here now: {', '.join(room.usernames) or 'nobody'}"
Two things the layout has to get right
Focus. Wijjit starts with nothing focused on purpose, so that focus does not
appear to “skip” the first element before its bounds are known - Tab is what
normally selects the first field. A chat room wants the message box live from the
start, and focus_element_by_id only resolves once the element has actually
rendered, so the view retries until it lands:
focused = False
@app.view("main", default=True)
def main():
nonlocal focused
if not focused:
focused = app.focus_element_by_id("message")
Size. Every client is a different shape and any of them may resize
mid-conversation, so the view reads
get_terminal_size() at render time rather than
trusting session.columns from connect time. That function reads this task’s
size override, so concurrent sessions each see their own dimensions.
One consequence is worth knowing before you debug it: a status line that wraps costs a row the layout has already spent, and the frame answers by growing a scrollbar over everything. The example truncates its status line to the terminal width for exactly that reason.
Trying it properly
The interesting test is not two people chatting - it is the rude disconnect.
Close Bob’s terminal window rather than pressing Ctrl+Q, and Alice should still
see * bob left. That is the connection_lost path into
on_event, and it is the case a hand-rolled
subscriber list gets wrong.