Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion crates/protocol/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ futures = { workspace = true }
base64 = { workspace = true }
which.workspace = true
portable-pty = { workspace = true, optional = true }
vt100 = { workspace = true, optional = true }

[features]
default = []
# Enable the [`adapter::pty`] runtime helper. Heavy: pulls in `portable-pty`
# and friends. Only adapters that spawn a PTY-backed child should opt in.
pty = ["dep:portable-pty"]
pty = ["dep:portable-pty", "dep:vt100"]

[dev-dependencies]
tempfile = "3"
169 changes: 166 additions & 3 deletions crates/protocol/src/adapter/pty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,77 @@ use std::time::Duration;
use tokio::sync::{mpsc, oneshot};

const READ_BUF: usize = 8 * 1024;
const CURSOR_POSITION_QUERY: &[u8] = b"\x1b[6n";

/// Answers terminal queries that require a live terminal counterpart.
///
/// The child PTY cannot talk directly to the user's outer terminal: Construct
/// sits between them and renders the byte stream itself. Cursor-position
/// reports therefore have to be answered here, where every client surface
/// (native TUI, web, detached/no client) shares the same live-only behavior.
/// Handled queries are removed from the emitted stream so downstream terminal
/// emulators cannot answer a second time and persisted replay stays inert.
struct TerminalQueryResponder {
parser: vt100::Parser,
tail: Vec<u8>,
}

impl TerminalQueryResponder {
fn new(size: PtySize) -> Self {
Self {
parser: vt100::Parser::new(size.rows.max(2), size.cols.max(2), 0),
tail: Vec::new(),
}
}

fn resize(&mut self, cols: u16, rows: u16) {
self.parser.screen_mut().set_size(rows.max(2), cols.max(2));
}

/// Feed one child-output chunk, returning bytes safe to emit/persist and
/// terminal replies to write directly back to the child PTY.
fn feed(&mut self, chunk: &[u8]) -> (Vec<u8>, Vec<Vec<u8>>) {
let mut combined = std::mem::take(&mut self.tail);
combined.extend_from_slice(chunk);

let mut passthrough = Vec::with_capacity(combined.len());
let mut responses = Vec::new();
let mut parsed = 0usize;
let mut i = 0usize;
while i < combined.len() {
let rest = &combined[i..];
if rest.starts_with(CURSOR_POSITION_QUERY) {
self.parser.process(&passthrough[parsed..]);
parsed = passthrough.len();
let (row, col) = self.parser.screen().cursor_position();
responses.push(format!("\x1b[{};{}R", row + 1, col + 1).into_bytes());
i += CURSOR_POSITION_QUERY.len();
continue;
}
if is_cursor_position_query_prefix(rest) {
self.tail.extend_from_slice(rest);
break;
}
passthrough.push(combined[i]);
i += 1;
}
self.parser.process(&passthrough[parsed..]);
(passthrough, responses)
}

/// Flush bytes held only because they might have begun a split query.
fn finish(&mut self) -> Vec<u8> {
let tail = std::mem::take(&mut self.tail);
self.parser.process(&tail);
tail
}
}

fn is_cursor_position_query_prefix(bytes: &[u8]) -> bool {
!bytes.is_empty()
&& bytes.len() < CURSOR_POSITION_QUERY.len()
&& CURSOR_POSITION_QUERY.starts_with(bytes)
}

/// What to spawn under the PTY and how.
pub struct PtySpec {
Expand Down Expand Up @@ -189,6 +260,7 @@ async fn run_session_inner(
let mut pgrp_timer = tokio::time::interval(Duration::from_millis(400));
pgrp_timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut pgrp_state: Option<SessionState> = None;
let mut terminal_queries = TerminalQueryResponder::new(spec.size);

let mut read_closed = false;
let mut inbox_closed = false;
Expand All @@ -198,8 +270,22 @@ async fn run_session_inner(
biased;
bytes = read_rx.recv(), if !read_closed => {
match bytes {
Some(b) => emit.emit_pty(&b),
None => { read_closed = true; }
Some(b) => {
let (passthrough, responses) = terminal_queries.feed(&b);
for response in responses {
let _ = write_tx.send(response);
}
if !passthrough.is_empty() {
emit.emit_pty(&passthrough);
}
}
None => {
read_closed = true;
let tail = terminal_queries.finish();
if !tail.is_empty() {
emit.emit_pty(&tail);
}
}
}
}
msg = inbox.recv(), if !inbox_closed => {
Expand All @@ -221,6 +307,7 @@ async fn run_session_inner(
cols, rows,
pixel_width: 0, pixel_height: 0,
});
terminal_queries.resize(cols, rows);
}
Some(AdapterInboxMsg::Input(text)) => {
let mut b = text.into_bytes();
Expand Down Expand Up @@ -260,7 +347,14 @@ async fn run_session_inner(
_ => -1,
};
while let Ok(b) = read_rx.try_recv() {
emit.emit_pty(&b);
let (passthrough, _) = terminal_queries.feed(&b);
if !passthrough.is_empty() {
emit.emit_pty(&passthrough);
}
}
let tail = terminal_queries.finish();
if !tail.is_empty() {
emit.emit_pty(&tail);
}
break;
}
Expand All @@ -269,3 +363,72 @@ async fn run_session_inner(
emit.emit(SessionEvent::Done { exit_code });
exit_code
}

#[cfg(test)]
mod tests {
use super::*;

fn responder() -> TerminalQueryResponder {
TerminalQueryResponder::new(PtySize { cols: 80, rows: 24 })
}

#[test]
fn cursor_position_query_is_answered_and_not_emitted() {
let mut responder = responder();
let (output, responses) = responder.feed(b"hello\r\nxy\x1b[6nworld");

assert_eq!(output, b"hello\r\nxyworld");
assert_eq!(responses, vec![b"\x1b[2;3R".to_vec()]);
assert!(!output
.windows(CURSOR_POSITION_QUERY.len())
.any(|window| window == CURSOR_POSITION_QUERY));
}

#[test]
fn split_cursor_position_query_waits_for_completion() {
let mut responder = responder();

let (first, responses) = responder.feed(b"abc\x1b[");
assert_eq!(first, b"abc");
assert!(responses.is_empty());

let (second, responses) = responder.feed(b"6n");
assert!(second.is_empty());
assert_eq!(responses, vec![b"\x1b[1;4R".to_vec()]);
}

#[test]
fn false_query_prefix_is_preserved() {
let mut responder = responder();

let (first, responses) = responder.feed(b"before\x1b[");
assert_eq!(first, b"before");
assert!(responses.is_empty());

let (second, responses) = responder.feed(b"2Jafter");
assert_eq!(second, b"\x1b[2Jafter");
assert!(responses.is_empty());
}

#[test]
fn incomplete_query_prefix_is_preserved_at_end_of_stream() {
let mut responder = responder();

let (output, responses) = responder.feed(b"before\x1b[");
assert_eq!(output, b"before");
assert!(responses.is_empty());
assert_eq!(responder.finish(), b"\x1b[");
}

#[test]
fn each_query_observes_cursor_at_its_stream_position() {
let mut responder = responder();
let (output, responses) = responder.feed(b"a\x1b[6n\r\nbc\x1b[6n");

assert_eq!(output, b"a\r\nbc");
assert_eq!(
responses,
vec![b"\x1b[1;2R".to_vec(), b"\x1b[2;3R".to_vec()]
);
}
}
43 changes: 43 additions & 0 deletions specs/0194-live-pty-terminal-query-responses.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# 0194-live-pty-terminal-query-responses

Status: accepted
Date: 2026-08-05
Area: protocol
Scope: Terminal queries from PTY-backed children receive one live response independent of the attached Construct client.

## Decision

The PTY adapter runtime is the terminal counterpart for queries whose answers
come from terminal state it can track, including cursor-position reports. It
answers those queries directly on the live child input stream and removes the
handled query bytes before emitting, persisting, or broadcasting PTY output.

Client renderers do not own these responses. A session receives the same
behavior in the native TUI, web UI, remote clients, and while detached.

## Reason

An interactive child runs behind Construct's PTY rather than directly inside
the user's outer terminal. Forwarding a terminal query to a renderer makes
startup depend on that renderer's emulation features and whether a client is
currently attached. Some full-screen harnesses require cursor-position reports
before drawing and exit when no answer arrives.

Central live handling also prevents two clients from racing to answer the same
query and prevents persisted query bytes from producing input during replay.

## Consequences

- PTY-backed harnesses can initialize while detached or under any Construct
client surface.
- Exactly one response is written for each handled live query.
- Handled queries are absent from transcript replay and cannot generate future
child input.
- The runtime must track enough terminal state to answer each supported query
at its position in the byte stream and update that state on PTY resize.

## Non-Goals

Construct does not emulate every optional terminal capability query. Queries
without a runtime-owned answer continue to pass through unchanged unless a
later decision explicitly adopts them.
Loading