Skip to content

Commit 5665370

Browse files
authored
fix(tui): recover Windows shell UI from mid-session console blanking (#192)
* fix(tui): recover Windows shell UI from mid-session console blanking The Windows shell UI could go blank mid-session (transcript and input box gone until terminal restart). Two mechanisms, both fixed: - Child processes attached to the interactive console: Shell-tool, background-task, and shell-escape spawns now pass CREATE_NO_WINDOW so a child touching the console via the Win32 console API (cls, Clear-Host, SetConsoleMode) cannot corrupt the TUI. Kill semantics are unchanged (TerminateProcess; no CTRL_BREAK usage anywhere). - prompt_toolkit's differential renderer diffing against a stale frame after the real screen diverged (ConPTY resize rewrap, terminal replay, half-completed teardown erase): the prompt renderer state is now reset after terminal geometry changes and failed scrollback handoffs so the next redraw is absolute. Also corrects the sync_output docstring: Windows10_Output delegates _buffer to its vt100 output, so DEC-2026 marks ARE installed on VT-capable Windows consoles (safe: WT expires after 100ms, xterm.js 5s). * fix(tui): address review — Ctrl+L hard repaint, spawn-flag test coverage - Add an explicit Ctrl+L binding (pins prompt_toolkit's default clear-screen) with a contained-failure handler, list it in /help shortcuts and docs/en/reference/keyboard.md. - Add spawn-flag tests for the background worker child and the `!` foreground shell command (CREATE_NO_WINDOW on Windows), plus hard-repaint tests. - Reviewed e2e cancellation failure: test_shell_cancel_running_command_kills_ process_and_recovers passes outside the sandboxed test runner and on CI; the local failure was sandbox interference with PTY signal delivery. * refactor(host): centralize Windows console-detach creationflags The CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW computation was duplicated across four subprocess spawn sites, and had already drifted: local.py used a 0x00000200 fallback for CREATE_NEW_PROCESS_GROUP while manager.py/worker.py used 0. Extract a shared windows_console_detach_flags() helper so the fallback constants and console-detachment logic can't diverge again.
1 parent 29174c3 commit 5665370

17 files changed

Lines changed: 398 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- Fix the Windows shell UI going blank mid-session (transcript and input box
19+
disappearing until terminal restart): child processes no longer attach to the
20+
interactive console (`CREATE_NO_WINDOW` on Shell-tool, background-task, and
21+
`!` command spawns), and the prompt renderer now forces an absolute repaint
22+
after terminal resizes and failed scrollback handoffs instead of diffing
23+
against a stale frame.
1824
- Fix Workflow progress rendering duplicating agents truncated by the per-phase
1925
display cap, close leaked `agent()` coroutines when `parallel()` rejects its
2026
arguments, and add a 1000-agent lifetime backstop against runaway workflow loops.

docs/en/reference/keyboard.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Pythinker Code shell mode supports the following keyboard shortcuts.
1717
| `Ctrl-V` | Paste (supports images and video files) |
1818
| `Ctrl-E` | Expand full approval request content |
1919
| `Ctrl-T` | Show/hide the pinned todo list (during a running turn) |
20+
| `Ctrl-L` | Clear and repaint the screen (recovers a blank/corrupted display) |
2021
| `1``4` | Quick select approval option (`4` for decline with feedback) |
2122
| `1``5` | Select question option by number |
2223
| `Ctrl-D` | Exit Pythinker Code |

packages/pythinker-host/src/pythinker_host/local.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import asyncio
44
import os
55
import signal
6-
import subprocess
76
from asyncio.subprocess import Process as AsyncioProcess
87
from collections.abc import AsyncGenerator
98
from pathlib import Path, PurePath
@@ -30,6 +29,7 @@
3029
StrOrHostPath,
3130
)
3231
from pythinker_host.path import HostPath
32+
from pythinker_host.windows import windows_console_detach_flags
3333

3434
if TYPE_CHECKING:
3535

@@ -198,9 +198,12 @@ async def exec(
198198

199199
process_options: dict[str, Any] = {}
200200
if os.name == "nt":
201-
process_options["creationflags"] = getattr(
202-
subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200
203-
)
201+
# CREATE_NO_WINDOW detaches the child from the interactive console
202+
# (it gets its own hidden one): console-API writes, `cls`, or
203+
# SetConsoleMode calls from the child would otherwise bypass the
204+
# stdio pipes and corrupt the parent TUI until terminal restart.
205+
# CREATE_NEW_PROCESS_GROUP keeps kill() semantics unchanged.
206+
process_options["creationflags"] = windows_console_detach_flags()
204207
else:
205208
process_options["start_new_session"] = True
206209

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from __future__ import annotations
2+
3+
import subprocess
4+
5+
_CREATE_NO_WINDOW = 0x08000000
6+
_CREATE_NEW_PROCESS_GROUP = 0x00000200
7+
8+
9+
def windows_console_detach_flags(*, new_process_group: bool = True) -> int:
10+
"""Build Windows ``creationflags`` that keep a spawned child off the console.
11+
12+
``CREATE_NO_WINDOW`` gives the child its own hidden console: without it, a
13+
child that touches the Win32 console API (``cls``, ``SetConsoleMode``, ...)
14+
bypasses redirected stdio and can blank the parent TUI until the terminal is
15+
restarted. ``CREATE_NEW_PROCESS_GROUP`` is included by default so existing
16+
``kill()``/``taskkill`` semantics against the child are unaffected; pass
17+
``new_process_group=False`` for callers that only need console detachment.
18+
19+
Centralized here so the fallback constants (used when the real Win32
20+
constants aren't defined, e.g. off-Windows) cannot drift between the
21+
several process-spawn sites that need them.
22+
"""
23+
flags = getattr(subprocess, "CREATE_NO_WINDOW", _CREATE_NO_WINDOW)
24+
if new_process_group:
25+
flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", _CREATE_NEW_PROCESS_GROUP)
26+
return flags

packages/pythinker-host/tests/test_local_host.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,46 @@ def _record_killpg(pgid: int, sig: int) -> None:
257257
await process.wait()
258258

259259
assert sent and sent[0] == signal_any.SIGKILL
260+
261+
262+
async def test_exec_windows_child_gets_hidden_console(
263+
local_host: LocalHost, monkeypatch: pytest.MonkeyPatch
264+
):
265+
"""On Windows the child must not attach to the interactive console.
266+
267+
A child sharing the TUI's console can clear it or reset its modes through
268+
the Win32 console API (bypassing the stdio pipes), blanking the shell UI
269+
until the terminal is restarted. CREATE_NO_WINDOW gives the child its own
270+
hidden console; CREATE_NEW_PROCESS_GROUP keeps kill() semantics unchanged.
271+
"""
272+
from types import SimpleNamespace
273+
274+
from pythinker_host import local as local_module
275+
276+
captured: dict[str, Any] = {}
277+
278+
class _FakePipe:
279+
pass
280+
281+
class _FakeProcess:
282+
stdin: Any = _FakePipe()
283+
stdout: Any = _FakePipe()
284+
stderr: Any = _FakePipe()
285+
286+
async def _fake_create_subprocess_exec(*args: Any, **kwargs: Any) -> Any:
287+
captured.update(kwargs)
288+
return _FakeProcess()
289+
290+
# Swap the module's `os` reference rather than mutating the global
291+
# `os.name`, which corrupts pathlib/pytest path handling mid-run.
292+
monkeypatch.setattr(local_module, "os", SimpleNamespace(name="nt"))
293+
monkeypatch.setattr(
294+
local_module.asyncio, "create_subprocess_exec", _fake_create_subprocess_exec
295+
)
296+
297+
await local_host.exec("cmd", "/c", "echo hi")
298+
299+
flags = captured["creationflags"]
300+
assert flags & 0x00000200 # CREATE_NEW_PROCESS_GROUP
301+
assert flags & 0x08000000 # CREATE_NO_WINDOW
302+
assert "start_new_session" not in captured

src/pythinker_code/background/manager.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from typing import TYPE_CHECKING, Any, Literal
1313

1414
from pythinker_host.local import local_host
15+
from pythinker_host.windows import windows_console_detach_flags
1516

1617
from pythinker_code.config import BackgroundConfig
1718
from pythinker_code.notifications import NotificationEvent, NotificationManager
@@ -243,7 +244,10 @@ def _launch_worker(self, task_dir: Path) -> int:
243244
"cwd": str(task_dir),
244245
}
245246
if os.name == "nt":
246-
kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
247+
# CREATE_NO_WINDOW: don't share the interactive console — a child
248+
# touching it via the Win32 console API bypasses DEVNULL stdio and
249+
# can blank the parent TUI until terminal restart.
250+
kwargs["creationflags"] = windows_console_detach_flags()
247251
else:
248252
kwargs["start_new_session"] = True
249253

src/pythinker_code/background/worker.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from pathlib import Path
1010
from typing import Any
1111

12+
from pythinker_host.windows import windows_console_detach_flags
13+
1214
from pythinker_code.utils.logging import logger
1315
from pythinker_code.utils.subprocess_env import get_clean_env, scrub_secret_env
1416

@@ -185,7 +187,10 @@ async def _input_loop() -> None:
185187
"env": scrub_secret_env(get_clean_env()) if spec.scrub_secrets else get_clean_env(),
186188
}
187189
if os.name == "nt":
188-
spawn_kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
190+
# CREATE_NO_WINDOW: don't share the interactive console — a child
191+
# touching it via the Win32 console API bypasses the redirected
192+
# stdio and can blank the parent TUI until terminal restart.
193+
spawn_kwargs["creationflags"] = windows_console_detach_flags()
189194
else:
190195
spawn_kwargs["start_new_session"] = True
191196

src/pythinker_code/ui/shell/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import asyncio
55
import contextlib
66
import json
7+
import os
78
import re
89
import shlex
910
import textwrap
@@ -24,6 +25,7 @@
2425
APITimeoutError,
2526
ChatProviderError,
2627
)
28+
from pythinker_host.windows import windows_console_detach_flags
2729
from rich import box
2830
from rich.align import Align
2931
from rich.cells import cell_len
@@ -1271,11 +1273,20 @@ def _handler():
12711273
try:
12721274
# TODO: For the sake of simplicity, we now use `create_subprocess_shell`.
12731275
# Later we should consider making this behave like a real shell.
1276+
spawn_kwargs: dict[str, Any] = {}
1277+
if os.name == "nt":
1278+
# CREATE_NO_WINDOW: don't share the interactive console — a child
1279+
# touching it via the Win32 console API bypasses the pipes and
1280+
# can blank the TUI until terminal restart.
1281+
spawn_kwargs["creationflags"] = windows_console_detach_flags(
1282+
new_process_group=False
1283+
)
12741284
proc = await asyncio.create_subprocess_shell(
12751285
command,
12761286
env=get_clean_env(),
12771287
stdout=asyncio.subprocess.PIPE,
12781288
stderr=asyncio.subprocess.PIPE,
1289+
**spawn_kwargs,
12791290
)
12801291
stdout_task = asyncio.create_task(_read_stream_limited(proc.stdout, max_output_bytes))
12811292
stderr_task = asyncio.create_task(_read_stream_limited(proc.stderr, max_output_bytes))

src/pythinker_code/ui/shell/prompt.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2554,6 +2554,11 @@ def _(event: KeyPressEvent) -> None:
25542554
track("shortcut_editor")
25552555
self._open_in_external_editor(event)
25562556

2557+
@_kb.add("c-l", eager=True)
2558+
def _(event: KeyPressEvent) -> None:
2559+
"""Erase and fully repaint the screen (recovery from console damage)."""
2560+
self._hard_repaint(event)
2561+
25572562
def _has_staged_suggestion_prefill() -> bool:
25582563
return bool(getattr(self, "_staged_suggestion_prefill", None))
25592564

@@ -3092,6 +3097,21 @@ def _render_shell_prompt_message(self) -> FormattedText:
30923097
fragments.append(("bold", f"{PROMPT_SYMBOL_SHELL} "))
30933098
return fragments
30943099

3100+
def _hard_repaint(self, event: KeyPressEvent) -> None:
3101+
"""Erase the screen and absolutely repaint the prompt (Ctrl+L escape hatch).
3102+
3103+
Pins prompt_toolkit's default clear-screen behavior explicitly: when the
3104+
real screen has diverged from the renderer's frame model (Windows ConPTY
3105+
replay, a child process writing to the shared console), the differential
3106+
renderer keeps emitting empty diffs and the UI looks blank; this forces
3107+
an absolute frame. Explicit so future custom bindings cannot silently
3108+
shadow the recovery path.
3109+
"""
3110+
try:
3111+
event.app.renderer.clear()
3112+
except Exception as exc: # noqa: BLE001 — recovery must never crash the prompt
3113+
logger.debug("Hard repaint (ctrl-l) failed: {}", exc)
3114+
30953115
def _open_in_external_editor(self, event: KeyPressEvent) -> None:
30963116
"""Open the current buffer content in an external editor."""
30973117
from prompt_toolkit.application.run_in_terminal import run_in_terminal

src/pythinker_code/ui/shell/slash.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ def slash_command_arg_suggestions() -> dict[str, tuple[str, ...]]:
8181
("Ctrl-O", "Edit in external editor ($VISUAL/$EDITOR)"),
8282
("Ctrl-J / Alt-Enter", "Insert newline"),
8383
("Ctrl-V", "Paste (supports images)"),
84+
("Ctrl-L", "Repaint the screen (recover a blank/corrupted display)"),
8485
("Ctrl-D", "Exit"),
8586
("Ctrl-C", "Interrupt"),
8687
]

0 commit comments

Comments
 (0)