wijjit_ssh.input

Async byte-level input decoding for SSH sessions.

A local Wijjit app reads the keyboard through wijjit.terminal.input.InputHandler, which polls prompt_toolkit on a background thread. That is fine for one foreground app, but wrong at server scale: it costs an OS thread (and a prompt_toolkit pipe) per connection, when the bytes are already being delivered to us on the event loop by asyncssh.

This module replaces that path for remote sessions with two pieces:

KeyDecoder

A pure, resumable state machine turning raw terminal bytes into Wijjit Key and MouseEvent objects. It buffers incomplete trailing sequences across calls, so a keystroke split across TCP packets (or a UTF-8 rune split mid-character) decodes correctly. No I/O, no threads, no clock - which also makes it exhaustively unit-testable.

ChannelInputSource

The event loop’s input handler for a session. It owns a decoder, pushes decoded events onto an asyncio.Queue, and satisfies the duck-typed surface the loop calls (read_input_async, mouse_enabled, enable/disable_mouse_tracking, close, restore_terminal).

The one place a timer is unavoidable is the lone-ESC ambiguity: a bare ESC byte is either the Escape key or the first byte of a sequence still in flight, and nothing in the byte stream distinguishes them. ChannelInputSource resolves it by scheduling KeyDecoder.flush() a few tens of milliseconds after the last byte (see ESCAPE_TIMEOUT). SSH almost always delivers a full sequence in one packet, so this rarely fires.

class wijjit_ssh.input.KeyDecoder(mouse_parser=None)[source]

Bases: object

Resumable byte-to-event decoder for terminal input.

Feed it whatever bytes arrive on the wire; it returns the events it can fully decode and retains any incomplete trailing sequence for the next call. It is a pure state machine: no I/O, no threads, and no clock (the one time-dependent decision, the lone-ESC ambiguity, is delegated to the caller via pending_escape() / flush()).

Parameters:

mouse_parser (MouseEventParser, optional) – Parser used for mouse sequences. Supply one to share click-synthesis state; by default a fresh parser is created.

Variables:

mouse_parser (MouseEventParser) – The parser used for SGR and legacy mouse reports. It carries the press/release state that synthesizes CLICK and DOUBLE_CLICK events, so it must persist across calls.

feed(data)[source]

Decode a chunk of raw terminal bytes.

Parameters:

data (bytes) – Bytes as received from the channel. May contain any number of whole events, and may end mid-sequence or mid-rune.

Returns:

Every event that could be fully decoded, in arrival order. An incomplete trailing sequence is retained internally for the next call and is not reported here.

Return type:

list of Key or MouseEvent

pending_escape()[source]

Whether the buffer holds a bare ESC awaiting disambiguation.

Returns:

True when the only thing buffered is a single ESC byte, which is either the Escape key or the start of a sequence still in flight. The caller resolves this with a timer (see ESCAPE_TIMEOUT) and then calls flush().

Return type:

bool

Notes

Deliberately narrow: a partial sequence (ESC [ with no final byte) is not reported, because the rest is almost certainly in the next packet and flushing it as Escape would corrupt a real keypress.

flush()[source]

Resolve a pending lone ESC as the Escape key.

Called by the session once ESCAPE_TIMEOUT has elapsed with no further bytes.

Returns:

[Keys.ESCAPE] if a bare ESC was buffered, else an empty list (bytes arrived in the meantime and already decoded).

Return type:

list of Key or MouseEvent

class wijjit_ssh.input.ChannelInputSource(writer, *, enable_mouse=False, mouse_tracking_mode=None)[source]

Bases: object

Event-loop input handler backed by an SSH channel’s byte stream.

Implements the duck-typed surface Wijjit’s event loop expects of an input handler, but sourced from feed() (called by the SSH session as bytes arrive) rather than from a thread polling a tty. Decoded events queue up and are handed to the loop one at a time by read_input_async().

Parameters:
  • writer (TextIO) – Stream that mouse-tracking escape sequences are written to - the SSH channel, so the sequences reach the client’s terminal rather than the server’s stdout.

  • enable_mouse (bool, optional) – Whether the app wants mouse tracking (default False). Tracking is not turned on here; the event loop calls enable_mouse_tracking().

  • mouse_tracking_mode (MouseTrackingMode, optional) – Tracking granularity to request (default MouseTrackingMode.BUTTON_EVENT).

Variables:

mouse_enabled (bool) – Whether mouse tracking is currently active on the client terminal (i.e. the enable sequences have been sent).

feed(data)[source]

Decode inbound channel bytes and queue the resulting events.

Parameters:

data (bytes) – Raw bytes received on the channel.

Return type:

None

async read_input_async(timeout=None)[source]

Wait for the next decoded input event.

Parameters:

timeout (float or None, optional) – Maximum time to wait, in seconds. None waits indefinitely.

Returns:

The next event, or None if the timeout expired first - which the event loop treats as a quiet frame (it uses the timeout to drive animations and pending re-renders).

Return type:

Key, MouseEvent, or None

enable_mouse_tracking(mode=None)[source]

Turn on mouse reporting in the client’s terminal.

Parameters:

mode (MouseTrackingMode, optional) – Tracking mode to request; defaults to the one given at construction.

Return type:

None

disable_mouse_tracking()[source]

Turn off mouse reporting in the client’s terminal.

Return type:

None

restore_terminal()[source]

Undo terminal-affecting state on the client.

For a remote session this is only mouse tracking: there is no local tty and no raw mode to leave. Safe to call more than once.

Return type:

None

close()[source]

Release the input source at session teardown.

Idempotent. Cancels the escape timer and restores the client’s terminal. The event loop calls this from its finally block, including when the session task is cancelled by a dropped connection.

Return type:

None