Add chat-friendly shell read rendering

Make workspace shell reads usable as direct chat-model input without changing the PTY or cursor model. This adds optional plain rendering and idle-window batching across CLI, SDK, and MCP while keeping raw reads backward-compatible.

Implement the rendering and wait-for-idle logic in the manager layer so the existing guest/backend shell transport stays unchanged. The new helper strips ANSI and other terminal control noise, handles carriage-return overwrite and backspace, and preserves raw cursor semantics even when plain output is requested.

Refresh the stable shell docs/examples to recommend --plain --wait-for-idle-ms 300, mark the 3.5.0 roadmap milestone done, and bump the package/catalog version to 3.5.0.

Validation: uv lock; UV_CACHE_DIR=.uv-cache make check; UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed Firecracker smoke covering shell open/write/read with ANSI plus delayed output.
This commit is contained in:
Thales Maciel 2026-03-13 01:10:26 -03:00
parent eecfd7a7d7
commit 21a88312b6
22 changed files with 539 additions and 45 deletions

View file

@ -0,0 +1,116 @@
"""Helpers for chat-friendly workspace shell output rendering."""
from __future__ import annotations
def _apply_csi(
final: str,
parameters: str,
line: list[str],
cursor: int,
lines: list[str],
) -> tuple[list[str], int, list[str]]:
if final == "K":
mode = parameters or "0"
if mode in {"0", ""}:
del line[cursor:]
elif mode == "1":
for index in range(min(cursor, len(line))):
line[index] = " "
elif mode == "2":
line.clear()
cursor = 0
elif final == "J":
mode = parameters or "0"
if mode in {"2", "3"}:
lines.clear()
line.clear()
cursor = 0
return line, cursor, lines
def _consume_escape_sequence(
text: str,
index: int,
line: list[str],
cursor: int,
lines: list[str],
) -> tuple[int, list[str], int, list[str]]:
if index + 1 >= len(text):
return len(text), line, cursor, lines
leader = text[index + 1]
if leader == "[":
cursor_index = index + 2
while cursor_index < len(text):
char = text[cursor_index]
if "\x40" <= char <= "\x7e":
parameters = text[index + 2 : cursor_index]
line, cursor, lines = _apply_csi(char, parameters, line, cursor, lines)
return cursor_index + 1, line, cursor, lines
cursor_index += 1
return len(text), line, cursor, lines
if leader in {"]", "P", "_", "^"}:
cursor_index = index + 2
while cursor_index < len(text):
char = text[cursor_index]
if char == "\x07":
return cursor_index + 1, line, cursor, lines
if char == "\x1b" and cursor_index + 1 < len(text) and text[cursor_index + 1] == "\\":
return cursor_index + 2, line, cursor, lines
cursor_index += 1
return len(text), line, cursor, lines
if leader == "O":
return min(index + 3, len(text)), line, cursor, lines
return min(index + 2, len(text)), line, cursor, lines
def render_plain_shell_output(raw_text: str) -> str:
"""Render PTY output into chat-friendly plain text."""
lines: list[str] = []
line: list[str] = []
cursor = 0
ended_with_newline = False
index = 0
while index < len(raw_text):
char = raw_text[index]
if char == "\x1b":
index, line, cursor, lines = _consume_escape_sequence(
raw_text,
index,
line,
cursor,
lines,
)
ended_with_newline = False
continue
if char == "\r":
cursor = 0
ended_with_newline = False
index += 1
continue
if char == "\n":
lines.append("".join(line))
line = []
cursor = 0
ended_with_newline = True
index += 1
continue
if char == "\b":
if cursor > 0:
cursor -= 1
if cursor < len(line):
del line[cursor]
ended_with_newline = False
index += 1
continue
if char == "\t" or (ord(char) >= 32 and ord(char) != 127):
if cursor < len(line):
line[cursor] = char
else:
line.append(char)
cursor += 1
ended_with_newline = False
index += 1
if line or ended_with_newline:
lines.append("".join(line))
return "\n".join(lines)