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

@ -278,6 +278,8 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "read"
).format_help()
assert "Shell output is written to stdout." in workspace_shell_read_help
assert "--plain" in workspace_shell_read_help
assert "--wait-for-idle-ms" in workspace_shell_read_help
workspace_shell_write_help = _subparser_choice(
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "write"
@ -2259,11 +2261,15 @@ def test_cli_workspace_shell_open_and_read_human(
*,
cursor: int,
max_chars: int,
plain: bool = False,
wait_for_idle_ms: int | None = None,
) -> dict[str, Any]:
assert workspace_id == "workspace-123"
assert shell_id == "shell-123"
assert cursor == 0
assert max_chars == 1024
assert plain is True
assert wait_for_idle_ms == 300
return {
"workspace_id": workspace_id,
"shell_id": shell_id,
@ -2279,6 +2285,8 @@ def test_cli_workspace_shell_open_and_read_human(
"next_cursor": 14,
"output": "pyro$ pwd\n",
"truncated": False,
"plain": plain,
"wait_for_idle_ms": wait_for_idle_ms,
}
class OpenParser:
@ -2309,6 +2317,8 @@ def test_cli_workspace_shell_open_and_read_human(
shell_id="shell-123",
cursor=0,
max_chars=1024,
plain=True,
wait_for_idle_ms=300,
json=False,
)
@ -2319,6 +2329,8 @@ def test_cli_workspace_shell_open_and_read_human(
assert "pyro$ pwd\n" in captured.out
assert "[workspace-shell-open] workspace_id=workspace-123 shell_id=shell-123" in captured.err
assert "[workspace-shell-read] workspace_id=workspace-123 shell_id=shell-123" in captured.err
assert "plain=True" in captured.err
assert "wait_for_idle_ms=300" in captured.err
def test_cli_workspace_shell_write_signal_close_json(
@ -2478,7 +2490,11 @@ def test_cli_workspace_shell_open_and_read_json(
*,
cursor: int,
max_chars: int,
plain: bool = False,
wait_for_idle_ms: int | None = None,
) -> dict[str, Any]:
assert plain is False
assert wait_for_idle_ms is None
return {
"workspace_id": workspace_id,
"shell_id": shell_id,
@ -2494,6 +2510,8 @@ def test_cli_workspace_shell_open_and_read_json(
"next_cursor": max_chars,
"output": "pyro$ pwd\n",
"truncated": False,
"plain": plain,
"wait_for_idle_ms": wait_for_idle_ms,
}
class OpenParser:
@ -2526,6 +2544,8 @@ def test_cli_workspace_shell_open_and_read_json(
shell_id="shell-123",
cursor=0,
max_chars=1024,
plain=False,
wait_for_idle_ms=None,
json=True,
)
@ -2655,7 +2675,16 @@ def test_cli_workspace_shell_write_signal_close_human(
("shell_command", "kwargs"),
[
("open", {"cwd": "/workspace", "cols": 120, "rows": 30}),
("read", {"shell_id": "shell-123", "cursor": 0, "max_chars": 1024}),
(
"read",
{
"shell_id": "shell-123",
"cursor": 0,
"max_chars": 1024,
"plain": False,
"wait_for_idle_ms": None,
},
),
("write", {"shell_id": "shell-123", "input": "pwd", "no_newline": False}),
("signal", {"shell_id": "shell-123", "signal": "INT"}),
("close", {"shell_id": "shell-123"}),