kyroon_pty 1.0.1
kyroon_pty: ^1.0.1 copied to clipboard
Native pseudo-terminal (PTY) for Flutter — spawn shells and processes with full ANSI, resize and job control. Backed by ConPTY/forkpty and ready to pair with xterm.
kyroon_pty #
Pty for Flutter. Spawn a child process attached to a pseudo-terminal (PTY) so it behaves exactly like it would inside a real terminal: line editing, ANSI colors, cursor control, job control, and resize all work.
It implements the PTY in native code (instead of pure FFI + blocking isolates),
which makes it more stable than the older pty
package. It pairs naturally with xterm to
render a fully interactive terminal widget in your app.
Platforms #
| Linux | macOS | Windows | Android |
|---|---|---|---|
| ✔️ | ✔️ | ✔️ | ✔️ |
On Windows the PTY is backed by ConPTY, so Windows 10 (1809+) or later is required.
Web: browsers can't spawn processes, so there's no native PTY on web. Instead, attach to a PTY running on another machine over a remote transport — the example ships a ready-to-use WebSocket transport + server (see Pluggable backends).
Install #
dependencies:
kyroon_pty: ^0.4.2
flutter pub add kyroon_pty
No extra platform setup is needed — the native library is built and bundled automatically as an FFI plugin.
Quick start #
import 'dart:convert';
import 'package:kyroon_pty/kyroon_pty.dart';
// Start a shell inside a pseudo-terminal.
final pty = Pty.start(
Platform.isWindows ? 'cmd.exe' : 'bash',
columns: 80,
rows: 25,
);
// Read everything the process prints (stdout AND stderr share one stream).
pty.output
.cast<List<int>>()
.transform(const Utf8Decoder())
.listen((text) => print(text));
// Send input, exactly like typing it at a prompt. Don't forget the newline.
pty.write(const Utf8Encoder().convert('ls -al\n'));
// React when the process ends.
pty.exitCode.then((code) => print('exited with $code'));
// Tell the PTY when the viewport changes size (rows, cols).
pty.resize(30, 100);
// Terminate it.
pty.kill();
Configuration #
All configuration is done through Pty.start:
final pty = Pty.start(
'bash', // executable to run (positional)
arguments: ['-l'], // process arguments
workingDirectory: '/home/me', // cwd of the child (null = inherit)
environment: { // extra env vars (merged, see note below)
'FOO': 'bar',
},
rows: 25, // initial terminal height
columns: 80, // initial terminal width
ackRead: false, // flow control, see "Backpressure"
);
Environment #
kyroon_pty always sets TERM=xterm-256color and LANG=en_US.UTF-8 (so tools
like vi emit UTF-8-friendly sequences), and copies a small set of variables
from the parent process: LOGNAME, USER, DISPLAY, LC_TYPE, HOME,
PATH. Anything you pass in environment is merged on top.
If you want the child to see the full parent environment (recommended for a
real terminal — on Windows the minimal subset misses Path, SystemRoot,
APPDATA, etc., which breaks resolving external commands), pass it explicitly:
final pty = Pty.start(
shell,
environment: Map<String, String>.from(Platform.environment),
);
Picking the shell per platform #
String get defaultShell {
if (Platform.isWindows) {
return Platform.environment['COMSPEC'] ?? 'cmd.exe';
}
return Platform.environment['SHELL'] ?? 'bash';
}
API reference #
| Member | Description |
|---|---|
Pty.start(executable, {...}) |
Spawn executable in a new pseudo-terminal. |
Stream<Uint8List> output |
Combined stdout/stderr bytes from the process. |
Future<int> exitCode |
Completes with the exit code when the process ends. |
int pid |
Process id of the child. |
void write(Uint8List data) |
Write bytes to the PTY (the child's stdin). |
void resize(int rows, int cols) |
Inform the PTY of a new viewport size. |
bool kill([ProcessSignal signal]) |
Send a signal (default SIGTERM) to the process. |
void ackRead() |
Acknowledge a chunk when ackRead: true (see below). |
A PTY does not distinguish stdout from stderr — both arrive on
output.
Exit codes #
On Linux/macOS a normal exit is 0..255; a process killed by a signal reports
the negative signal number (e.g. -11 for SIGSEGV). On Windows any 32-bit
value is possible and is returned as a signed int (e.g. an access violation
0xc0000005 comes back as -1073741819). There's no guarantee output has
drained when exitCode completes — wait for the stream's done event if you
need every last byte.
Using it with xterm (full terminal widget) #
This is the common case: render an interactive terminal in Flutter. Wire the
Pty to an xterm Terminal in both directions.
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:kyroon_pty/kyroon_pty.dart';
import 'package:xterm/xterm.dart';
class TerminalWidget extends StatefulWidget {
const TerminalWidget({super.key});
@override
State<TerminalWidget> createState() => _TerminalWidgetState();
}
class _TerminalWidgetState extends State<TerminalWidget> {
final terminal = Terminal(maxLines: 10000);
late final Pty pty;
@override
void initState() {
super.initState();
pty = Pty.start(
Platform.isWindows ? 'cmd.exe' : 'bash',
columns: terminal.viewWidth,
rows: terminal.viewHeight,
environment: Map<String, String>.from(Platform.environment),
);
// PTY output → terminal emulator (it does the ANSI/VT parsing).
pty.output
.cast<List<int>>()
.transform(const Utf8Decoder())
.listen(terminal.write);
pty.exitCode.then((code) {
terminal.write('\r\n[process exited: $code]\r\n');
});
// Keyboard / paste from the widget → PTY stdin.
terminal.onOutput = (data) {
pty.write(const Utf8Encoder().convert(data));
};
// The view reports its size in cells → forward to the PTY.
terminal.onResize = (w, h, pw, ph) => pty.resize(h, w);
}
@override
void dispose() {
pty.kill();
super.dispose();
}
@override
Widget build(BuildContext context) => TerminalView(terminal);
}
The runnable version of this — with tabs, multiple sessions, scroll-to-bottom,
OSC title handling, a styled theme, a command bar and pluggable backends —
lives in example/.
Keyboard & input rules #
Input flows terminal → onOutput → pty.write. A few things matter for it
to feel right:
| Key / gesture | Behavior |
|---|---|
| Typing | Sent to the PTY's stdin as-is. |
| Enter | Sends \r (carriage return) — that's what the line discipline expects, not \n. |
| Ctrl+C | With a selection → copies. With no selection → passes through as SIGINT. |
| Ctrl+Shift+C / Ctrl+Shift+V | Copy / paste (xterm defaults). |
| Mouse selection | Selects text; right-click / shortcuts copy it. |
| Resize | onResize(w, h, …) → pty.resize(h, w) (note the rows, cols order). |
Desktop vs. mobile keyboards #
xterm's TerminalView can take input two ways, and the right choice depends
on the platform:
TerminalView(
terminal,
// Desktop (Windows/macOS/Linux): read characters straight from hardware key
// events (event.character). Reliable typing, no on-screen keyboard.
hardwareKeyboardOnly: true,
)
- Desktop →
hardwareKeyboardOnly: true. The platform IME/text-input connection can be flaky for a custom client; readingevent.characterfrom hardware key events is robust. (If you only seeEnterregister but no letters, this is the fix.) - Mobile (Android/iOS) → leave it
false(the default) so the on-screen keyboard / IME shows and works.
final bool isMobile = Platform.isAndroid || Platform.isIOS;
TerminalView(terminal, hardwareKeyboardOnly: !isMobile);
Sending a whole command programmatically #
Besides live typing, you often want to push a full command (a "type this and run it" button, or input coming from elsewhere). Just write the line followed by Enter:
void sendCommand(Pty pty, String command) {
pty.write(const Utf8Encoder().convert('$command\r'));
}
sendCommand(pty, 'git status');
The example wraps this in PtySession.sendCommand / sendText and exposes a
command bar at the bottom of each terminal.
Pluggable backends: local PTY vs. remote stream #
A terminal is just two byte streams (out/in) plus a resize signal. The xterm
Terminal doesn't care where those bytes come from. That lets you run the
same UI in two very different setups:
- Local — the shell runs on this machine; bytes come from
kyroon_pty. - Remote — the shell runs on another machine (and you're watching from a phone, say). Bytes arrive over the network (e.g. a gRPC stream), and your keystrokes are sent back to the host, which executes them. The relay in the middle is yours (the example author uses gRPC + Redis on the server).
The example models this with a small interface so the widget code is identical either way:
abstract class TerminalBackend {
Stream<String> get output; // bytes FROM the process (UTF-8 decoded)
void write(String data); // input TO the process
void resize(int rows, int cols); // viewport size changed
Future<void> get done; // process / stream ended
int? get pid;
int? get exitCode;
ValueListenable<bool> get inputEnabled; // false = read-only (no control lease)
void dispose();
}
Local backend (this machine) #
LocalPtyBackend simply wraps Pty. Note the streaming UTF-8 decode — a
multi-byte glyph (box-drawing ─ │ ┌, accents) can be split across two output
chunks, so decode with a stream transform, never utf8.decode per chunk:
_pty.output
.cast<List<int>>()
.transform(const Utf8Decoder(allowMalformed: true)) // buffers partials
.listen(_output.add);
@override
void write(String data) => _pty.write(const Utf8Encoder().convert(data));
@override
void resize(int rows, int cols) => _pty.resize(rows, cols);
Remote backend (another machine / mobile) #
On mobile you do not spawn a PTY on the phone — there's nothing to spawn.
Instead you implement a transport that talks to the host, and feed its frames to
the same Terminal. The example ships RemotePtyBackend + a RemotePtyTransport
interface (no gRPC dependency baked in) that you implement against your own RPC
layer:
abstract class RemotePtyTransport {
Stream<RemotePtyFrame> streamPty(); // server → client output
Future<String?> acquireControl({bool force}); // input lease (token)
Future<void> releaseControl(String token);
Future<void> sendInput(String token, List<int> data);
Future<void> resize(String token, {required int cols, required int rows});
}
A RemotePtyBackend built on that handles the things a naive wiring gets wrong:
- snapshot/replay — on (re)connect the server sends the buffered screen with
isSnapshot: true; reset the emulator (\x1b[2J\x1b[3J\x1b[H) before writing it so reconnects don't stack; - sequence dedup — ignore frames whose
seqyou've already seen; - streaming UTF-8 — decode with a stateful chunked converter so glyphs split across frames don't turn into ``;
- control lease — input is disabled until
acquireControl()succeeds; bindTerminalView.readOnlytoinputEnabled, and remember the lease has a TTL on the server (refreshed by each input/resize) — pure viewers stay read-only.
// Sketch: implement RemotePtyTransport over your own gRPC client.
class GrpcPtyTransport implements RemotePtyTransport {
GrpcPtyTransport(this._client, this.taskId, this.workspaceId);
// ...
@override
Stream<RemotePtyFrame> streamPty() => _client
.streamPty(StreamPtyRequest(taskId: taskId, workspaceId: workspaceId))
.map((f) => RemotePtyFrame(
data: f.data,
seq: f.seq.toInt(),
isSnapshot: f.isSnapshot,
closed: f.closed,
controlHolderUserId: f.controlHolderUserId,
));
@override
Future<void> sendInput(String token, List<int> data) =>
_client.sendPtyInput(PtyInputRequest(
taskId: taskId, workspaceId: workspaceId, controlToken: token, data: data,
));
// acquireControl / releaseControl / resize map the same way.
}
Then the session is created the same way as a local one — only the backend differs:
// Local (desktop)
PtySession.local(id: 1);
// Remote (mobile / another machine)
PtySession.remote(
id: 2,
backendBuilder: (cols, rows) =>
RemotePtyBackend(GrpcPtyTransport(client, taskId, workspaceId)),
);
See example/lib/terminal_backend.dart, example/lib/remote_pty_backend.dart
and example/lib/pty_session.dart for the full, commented implementation.
What your backend must provide (server side) #
The Flutter app is only the viewer/controller. For the remote mode to work, your backend is responsible for actually owning the PTY and relaying it. This package doesn't ship that — here's the contract it has to satisfy. (The transport can be anything: gRPC, WebSocket, SignalR… The reference setup uses gRPC for the edge + Redis pub/sub to fan out across server instances.)
Data flow
┌─────────── host machine (agent) ───────────┐ ┌──── server/relay ────┐ ┌── client(s) ──┐
│ real PTY (kyroon_pty / node-pty / …) │ │ pub/sub + buffer │ │ Flutter app │
│ │ │ (e.g. Redis) │ │ (xterm) │
│ stdout/stderr ──────────────────────────────────▶ fan-out ───────────────────────▶ StreamPty │
│ stdin ◀────────────────────────────────── publish ◀─────────────────────── SendPtyInput │
│ resize ◀────────────────────────────────── publish ◀─────────────────────── ResizePty │
└─────────────────────────────────────────────┘ └──────────────────────┘ └────────────────┘
The backend MUST:
- Own the real PTY on the host. Spawn the shell/process in a pseudo-terminal
on the target machine (this is where
kyroon_ptyitself can run, or node-pty, etc.). The phone never spawns anything. - Stream output in real time. Expose a server-stream endpoint
(
StreamPty) that pushes every chunk of PTY output to all subscribed clients as it's produced. Output bytes are raw — do not re-encode; let the client decode UTF-8 in streaming mode. - Send a snapshot on (re)connect. Keep a rolling buffer of recent output
(capped, e.g. last N KB) and, as the first frame of every new stream,
send it with
is_snapshot = true. This is what lets a phone that joins late — or reconnects after a drop — immediately see the current screen instead of a blank one. - Tag frames with a sequence number. A monotonic
seqper PTY lets clients drop duplicates and detect gaps (important with pub/sub redelivery). - Accept input (
SendPtyInput): take bytes from a client and write them to the PTY's stdin on the host. Keystrokes, pastes and whole commands all arrive here. - Accept resize (
ResizePty): applycols/rowsto the host PTY so the remote program reflows correctly. - Enforce a single writer (control lease). Many viewers, one typist:
AcquirePtyControl→ hand out a short-lived control token (TTL, e.g. 30 s). If someone already holds it, deny (unlessforce).- Require that token on every
SendPtyInput/ResizePty; reject stale or missing tokens. Refresh the TTL on each accepted input/resize. ReleasePtyControl→ free the lease. Without a token, the client is read-only.
- Signal end of session. When the host process exits, emit a final frame
with
closed = true(and stop the stream) so clients can show "encerrado". - Authenticate & authorize. Validate who's connecting (the example checks task/workspace membership) and gate input behind a permission (owner/admin or an explicit flag) — viewing can be broader than typing.
- Fan out + clean up. Support multiple concurrent subscribers per PTY, and unsubscribe/release on disconnect so you don't leak streams or leave a dangling control lease.
What the client expects per frame
Each output frame the backend sends maps to RemotePtyFrame:
| Field | Meaning | Client behavior |
|---|---|---|
data |
raw PTY output bytes | decoded (streaming UTF-8) → terminal.write |
seq |
monotonic counter | drop if seq <= lastSeen |
is_snapshot |
full-buffer replay | reset screen (\x1b[2J\x1b[3J\x1b[H) then write |
closed |
process ended | mark session finished |
control_holder_user_id |
who holds the lease | show read-only banner |
Minimum vs. nice-to-have
- Minimum to function: output stream + input + resize.
- Needed for good UX: snapshot/replay,
seqdedup, the control lease, and theclosedsignal — without these you get blank reconnects, duplicated output, multiple people fighting over the keyboard, and no "session ended".
The example author's implementation of exactly this lives server-side as a gRPC
TerminalStreamService(StreamPty/SendPtyInput/ResizePty/AcquirePtyControl/ReleasePtyControl) backed by Redis for the snapshot buffer, the input channel, and the control-token lease.
WebSocket transport — batteries included (incl. web) #
gRPC is great when you already run it. For everything else — and especially for web, where a browser can't spawn a process at all — the example ships a ready-to-use WebSocket transport and a matching server, so you can stand up a remote terminal with zero backend infrastructure:
example/lib/pty_websocket_server.dart—PtyWebSocketServer: runs on the host (a desktop app using kyroon_pty), spawns a realPty, and serves it over a WebSocket. Uses onlydart:io(no extra deps).example/lib/websocket_pty_transport.dart—WebSocketPtyTransport: the client (web / mobile / another desktop). ImplementsRemotePtyTransport, so it drops straight intoRemotePtyBackendand inherits snapshot reset, seq dedup, streaming UTF-8 and read-only gating.
This is the capability packages like portable_pty expose for web; here it's
integrated with the same pluggable backend, so the exact same UI renders a
local PTY or a remote one.
Wire protocol
One WebSocket per session. Output stays on binary frames (no base64 on the hot path); control is human-readable JSON.
| Direction | Frame | Meaning |
|---|---|---|
| host → client | binary | raw PTY output |
| host → client | {"type":"snapshot","dataB64":"…"} |
buffered screen, sent once on connect |
| host → client | {"type":"exit","code":0} |
process ended |
| client → host | binary | raw stdin (typing / paste / commands) |
| client → host | {"type":"resize","cols":80,"rows":24} |
viewport resized |
Host (the machine running the shell)
import 'package:kyroon_pty_example/pty_websocket_server.dart';
final server = PtyWebSocketServer(
// shell: 'bash', // defaults to the platform shell
// arguments: ['/k', 'claude'], // e.g. launch Claude on connect (Windows)
address: InternetAddress.anyIPv4, // omit for localhost-only
port: 8080,
);
await server.start(); // now serving ws://<host>:8080/
// ...
await server.stop(); // kills the PTY, closes clients
Client (web / mobile / another desktop)
import 'package:kyroon_pty_example/pty_session.dart';
import 'package:kyroon_pty_example/remote_pty_backend.dart';
import 'package:kyroon_pty_example/websocket_pty_transport.dart';
final session = PtySession.remote(
id: 1,
label: 'remote',
backendBuilder: (cols, rows) =>
RemotePtyBackend(WebSocketPtyTransport('ws://192.168.0.10:8080')),
);
// drop session.terminal into a TerminalView — typing, output, resize and the
// command bar all work exactly like the local case.
⚠️
PtyWebSocketServeris intentionally minimal: one shared session, no auth, every client can type. It's perfect for a LAN / demo. For the internet you want TLS (wss://), authentication, and the single-writer control lease — that's where the gRPC + Redis backend (above) earns its keep. The client (RemotePtyBackend) is identical either way.On web, only the client half runs (browsers can't bind a server); host a
PtyWebSocketServeron a real machine and point the browser at it.
Running the web demo end-to-end
The example ships three ready-to-run entrypoints:
| Entrypoint | What it is | Run on |
|---|---|---|
lib/main.dart |
the full local terminal (tabs, command bar, Claude button) | desktop |
lib/main_host.dart |
a host: starts PtyWebSocketServer and serves a PTY over ws://…:8080 |
desktop (the machine you want to drive) |
lib/main_web.dart |
the web client: connects to a host and renders the terminal in the browser | web (and mobile/desktop) |
Prerequisites (already set up in example/):
cd example
# 1. Web platform support (creates web/). One-time.
flutter create --platforms=web .
# 2. Deps: web_socket_channel (cross-platform WS, incl. web) is in pubspec.
flutter pub get
The terminal font (
CascadiaMono) is bundled as an asset so it renders as a crisp monospace on web too — Flutter's web canvas does not use system-installed fonts, so without a bundled font the terminal falls back to a proportional font squeezed into monospace cells (the "spaced-out" look).
Step 1 — start the host on the machine whose shell you want to drive:
flutter run -d windows -t lib/main_host.dart # or -d macos / -d linux
It auto-starts and prints Servindo um PTY em ws://localhost:8080. To reach it
from another machine / a phone it already binds InternetAddress.anyIPv4; just
open TCP 8080 in the firewall and use the host's LAN IP.
Step 2 — run the web client. Either way works:
# A) Normal Flutter web (debug): opens Chrome and hot-reloads.
flutter run -d chrome -t lib/main_web.dart
# B) Release build + static server (use this if `flutter run -d chrome` is
# unavailable, e.g. a restricted/CI environment missing the web SDK):
flutter build web -t lib/main_web.dart
cd build/web && python -m http.server 5599
# then open http://localhost:5599 in any browser
Step 3 — connect. The web client auto-connects to ws://localhost:8080
(editable in the connect bar). When it goes 🟢 ao vivo, you're typing into
the host's PTY from the browser — output, resize, paste and full TUIs (vim,
claude, …) all stream live.
For a phone or another machine, change the URL to ws://<host-LAN-IP>:8080.
For anything beyond a trusted LAN, front it with TLS (wss://), authentication
and the single-writer control lease — i.e. the gRPC + Redis backend.
Keyboard on web/mobile: the web client leaves
hardwareKeyboardOnlyoff so the browser / on-screen keyboard works. Only the desktop local terminal setshardwareKeyboardOnly: true(readsevent.characterdirectly). See Keyboard & input rules.
Backpressure (ackRead) #
By default the PTY streams output as fast as the process produces it. If your
consumer can't keep up (e.g. heavy rendering), start with ackRead: true: the
PTY then pauses after each chunk until you call pty.ackRead(), giving you
explicit flow control.
final pty = Pty.start('bash', ackRead: true);
pty.output.listen((chunk) {
render(chunk);
pty.ackRead(); // request the next chunk
});
Lifecycle & cleanup #
Always tear the session down to avoid leaking the native process and the output subscription:
final sub = pty.output.listen(...);
// ...
await sub.cancel();
pty.kill(); // best-effort; no-op if already exited
When integrating with widgets, do this in dispose(). If you also own xterm
ScrollController/TerminalController, dispose them after the TerminalView
has unmounted to avoid "used after dispose" errors — see the example's
PtySession.dispose for the pattern.
How it works #
src/— native PTY implementation (forkptyon Unix, ConPTY on Windows) plus aCMakeLists.txtto build it into a dynamic library.lib/— the Dart API inkyroon_pty.dart, calling the native library viadart:ffi. Bindings inlib/src/kyroon_pty_bindings_generated.dartare generated fromsrc/kyroon_pty.hbypackage:ffigen(flutter pub run ffigen --config ffigen.yaml).- platform folders (
android,ios,windows, …) — build glue that compiles and bundles the native library with your app.
Contributing / regenerating bindings #
After editing the native header src/kyroon_pty.h, regenerate the FFI
bindings:
flutter pub run ffigen --config ffigen.yaml
License #
See LICENSE.