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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ GitHub Releases page; `0.8.0` is the new starting line.

## Unreleased

- Fix the Windows shell UI going blank mid-session (transcript and input box
disappearing until terminal restart): child processes no longer attach to the
interactive console (`CREATE_NO_WINDOW` on Shell-tool, background-task, and
`!` command spawns), and the prompt renderer now forces an absolute repaint
after terminal resizes and failed scrollback handoffs instead of diffing
against a stale frame.
- Fix Workflow progress rendering duplicating agents truncated by the per-phase
display cap, close leaked `agent()` coroutines when `parallel()` rejects its
arguments, and add a 1000-agent lifetime backstop against runaway workflow loops.
Expand Down
1 change: 1 addition & 0 deletions docs/en/reference/keyboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Pythinker Code shell mode supports the following keyboard shortcuts.
| `Ctrl-V` | Paste (supports images and video files) |
| `Ctrl-E` | Expand full approval request content |
| `Ctrl-T` | Show/hide the pinned todo list (during a running turn) |
| `Ctrl-L` | Clear and repaint the screen (recovers a blank/corrupted display) |
| `1`–`4` | Quick select approval option (`4` for decline with feedback) |
| `1`–`5` | Select question option by number |
| `Ctrl-D` | Exit Pythinker Code |
Expand Down
11 changes: 7 additions & 4 deletions packages/pythinker-host/src/pythinker_host/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import asyncio
import os
import signal
import subprocess
from asyncio.subprocess import Process as AsyncioProcess
from collections.abc import AsyncGenerator
from pathlib import Path, PurePath
Expand All @@ -30,6 +29,7 @@
StrOrHostPath,
)
from pythinker_host.path import HostPath
from pythinker_host.windows import windows_console_detach_flags

if TYPE_CHECKING:

Expand Down Expand Up @@ -198,9 +198,12 @@ async def exec(

process_options: dict[str, Any] = {}
if os.name == "nt":
process_options["creationflags"] = getattr(
subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200
)
# CREATE_NO_WINDOW detaches the child from the interactive console
# (it gets its own hidden one): console-API writes, `cls`, or
# SetConsoleMode calls from the child would otherwise bypass the
# stdio pipes and corrupt the parent TUI until terminal restart.
# CREATE_NEW_PROCESS_GROUP keeps kill() semantics unchanged.
process_options["creationflags"] = windows_console_detach_flags()
else:
process_options["start_new_session"] = True
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand Down
26 changes: 26 additions & 0 deletions packages/pythinker-host/src/pythinker_host/windows.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from __future__ import annotations

import subprocess

_CREATE_NO_WINDOW = 0x08000000
_CREATE_NEW_PROCESS_GROUP = 0x00000200


def windows_console_detach_flags(*, new_process_group: bool = True) -> int:
"""Build Windows ``creationflags`` that keep a spawned child off the console.

``CREATE_NO_WINDOW`` gives the child its own hidden console: without it, a
child that touches the Win32 console API (``cls``, ``SetConsoleMode``, ...)
bypasses redirected stdio and can blank the parent TUI until the terminal is
restarted. ``CREATE_NEW_PROCESS_GROUP`` is included by default so existing
``kill()``/``taskkill`` semantics against the child are unaffected; pass
``new_process_group=False`` for callers that only need console detachment.

Centralized here so the fallback constants (used when the real Win32
constants aren't defined, e.g. off-Windows) cannot drift between the
several process-spawn sites that need them.
"""
flags = getattr(subprocess, "CREATE_NO_WINDOW", _CREATE_NO_WINDOW)
if new_process_group:
flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", _CREATE_NEW_PROCESS_GROUP)
return flags
43 changes: 43 additions & 0 deletions packages/pythinker-host/tests/test_local_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,46 @@ def _record_killpg(pgid: int, sig: int) -> None:
await process.wait()

assert sent and sent[0] == signal_any.SIGKILL


async def test_exec_windows_child_gets_hidden_console(
local_host: LocalHost, monkeypatch: pytest.MonkeyPatch
):
"""On Windows the child must not attach to the interactive console.

A child sharing the TUI's console can clear it or reset its modes through
the Win32 console API (bypassing the stdio pipes), blanking the shell UI
until the terminal is restarted. CREATE_NO_WINDOW gives the child its own
hidden console; CREATE_NEW_PROCESS_GROUP keeps kill() semantics unchanged.
"""
from types import SimpleNamespace

from pythinker_host import local as local_module

captured: dict[str, Any] = {}

class _FakePipe:
pass

class _FakeProcess:
stdin: Any = _FakePipe()
stdout: Any = _FakePipe()
stderr: Any = _FakePipe()

async def _fake_create_subprocess_exec(*args: Any, **kwargs: Any) -> Any:
captured.update(kwargs)
return _FakeProcess()

# Swap the module's `os` reference rather than mutating the global
# `os.name`, which corrupts pathlib/pytest path handling mid-run.
monkeypatch.setattr(local_module, "os", SimpleNamespace(name="nt"))
monkeypatch.setattr(
local_module.asyncio, "create_subprocess_exec", _fake_create_subprocess_exec
)

await local_host.exec("cmd", "/c", "echo hi")

flags = captured["creationflags"]
assert flags & 0x00000200 # CREATE_NEW_PROCESS_GROUP
assert flags & 0x08000000 # CREATE_NO_WINDOW
assert "start_new_session" not in captured
6 changes: 5 additions & 1 deletion src/pythinker_code/background/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from typing import TYPE_CHECKING, Any, Literal

from pythinker_host.local import local_host
from pythinker_host.windows import windows_console_detach_flags

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

Expand Down
7 changes: 6 additions & 1 deletion src/pythinker_code/background/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from pathlib import Path
from typing import Any

from pythinker_host.windows import windows_console_detach_flags

from pythinker_code.utils.logging import logger
from pythinker_code.utils.subprocess_env import get_clean_env, scrub_secret_env

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

Expand Down
11 changes: 11 additions & 0 deletions src/pythinker_code/ui/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import asyncio
import contextlib
import json
import os
import re
import shlex
import textwrap
Expand All @@ -24,6 +25,7 @@
APITimeoutError,
ChatProviderError,
)
from pythinker_host.windows import windows_console_detach_flags
from rich import box
from rich.align import Align
from rich.cells import cell_len
Expand Down Expand Up @@ -1271,11 +1273,20 @@ def _handler():
try:
# TODO: For the sake of simplicity, we now use `create_subprocess_shell`.
# Later we should consider making this behave like a real shell.
spawn_kwargs: dict[str, Any] = {}
if os.name == "nt":
# CREATE_NO_WINDOW: don't share the interactive console — a child
# touching it via the Win32 console API bypasses the pipes and
# can blank the TUI until terminal restart.
spawn_kwargs["creationflags"] = windows_console_detach_flags(
new_process_group=False
)
proc = await asyncio.create_subprocess_shell(
command,
env=get_clean_env(),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
**spawn_kwargs,
)
stdout_task = asyncio.create_task(_read_stream_limited(proc.stdout, max_output_bytes))
stderr_task = asyncio.create_task(_read_stream_limited(proc.stderr, max_output_bytes))
Expand Down
20 changes: 20 additions & 0 deletions src/pythinker_code/ui/shell/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -2554,6 +2554,11 @@ def _(event: KeyPressEvent) -> None:
track("shortcut_editor")
self._open_in_external_editor(event)

@_kb.add("c-l", eager=True)
def _(event: KeyPressEvent) -> None:
"""Erase and fully repaint the screen (recovery from console damage)."""
self._hard_repaint(event)

def _has_staged_suggestion_prefill() -> bool:
return bool(getattr(self, "_staged_suggestion_prefill", None))

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

def _hard_repaint(self, event: KeyPressEvent) -> None:
"""Erase the screen and absolutely repaint the prompt (Ctrl+L escape hatch).

Pins prompt_toolkit's default clear-screen behavior explicitly: when the
real screen has diverged from the renderer's frame model (Windows ConPTY
replay, a child process writing to the shared console), the differential
renderer keeps emitting empty diffs and the UI looks blank; this forces
an absolute frame. Explicit so future custom bindings cannot silently
shadow the recovery path.
"""
try:
event.app.renderer.clear()
except Exception as exc: # noqa: BLE001 — recovery must never crash the prompt
logger.debug("Hard repaint (ctrl-l) failed: {}", exc)

def _open_in_external_editor(self, event: KeyPressEvent) -> None:
"""Open the current buffer content in an external editor."""
from prompt_toolkit.application.run_in_terminal import run_in_terminal
Expand Down
1 change: 1 addition & 0 deletions src/pythinker_code/ui/shell/slash.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ def slash_command_arg_suggestions() -> dict[str, tuple[str, ...]]:
("Ctrl-O", "Edit in external editor ($VISUAL/$EDITOR)"),
("Ctrl-J / Alt-Enter", "Insert newline"),
("Ctrl-V", "Paste (supports images)"),
("Ctrl-L", "Repaint the screen (recover a blank/corrupted display)"),
("Ctrl-D", "Exit"),
("Ctrl-C", "Interrupt"),
]
Expand Down
6 changes: 5 additions & 1 deletion src/pythinker_code/ui/shell/sync_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ def install_synchronized_output(output: Output) -> bool:
"""Bracket every flushed frame of *output* in synchronized-update marks.

Returns ``True`` when installed (or already installed). Outputs without
the vt100 list buffer (Windows console, dummy outputs) are left untouched.
the vt100 list buffer (legacy ``Win32Output``, dummy outputs) are left
untouched. Note that on VT-capable Windows consoles prompt_toolkit uses
``Windows10_Output``, which delegates ``_buffer`` to its inner vt100
output — so those ARE patched, same as Unix. Safe either way: Windows
Terminal expires an unmatched begin-mark after 100ms, xterm.js after 5s.
"""
if getattr(output, _INSTALLED_MARKER, False):
return True
Expand Down
26 changes: 26 additions & 0 deletions src/pythinker_code/ui/shell/visualize/_interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,34 @@ def _tick_resize_recovery(self) -> None:
self._resize_recovery_remaining = _RESIZE_RECOVERY_FRAMES
self._force_refresh = True
_handoff_trace(f"RESIZE\t{columns}x{rows}")
self._reset_prompt_renderer("resize")
else:
_handoff_trace(f"RESIZE_IGNORE\t{columns}x{rows}")
if self._resize_recovery_remaining > 0:
self._resize_recovery_remaining -= 1

def _reset_prompt_renderer(self, reason: str) -> None:
"""Drop the prompt renderer's diff state so the next redraw repaints every row.

When the real screen diverges from prompt_toolkit's frame model —
ConPTY rewraps lines on resize, a terminal replays/clears the
viewport, a scrollback handoff dies mid-erase — the differential
renderer keeps emitting empty diffs over a blank screen and the UI
never comes back. Resetting makes the next frame absolute.
"""
from prompt_toolkit.application import get_app_or_none

app = get_app_or_none()
if app is None:
return
try:
app.renderer.reset()
except Exception as exc: # noqa: BLE001 — recovery must never take down the UI loop
_handoff_trace(f"RENDERER_RESET_FAIL\t{reason}\t{type(exc).__name__}:{exc}")
logger.debug("Prompt renderer reset failed ({}): {}", reason, exc)
else:
_handoff_trace(f"RENDERER_RESET\t{reason}")

def _defer_scrollback_handoff(self) -> bool:
"""Backpressure: defer permanent scrollback while preamble geometry is unstable."""
return self._resize_recovery_remaining > 0
Expand Down Expand Up @@ -244,6 +267,9 @@ async def _run_scrollback_handoff(self, emit: Callable[[], None], *, reason: str
emit()
except Exception as exc:
_handoff_trace(f"HANDOFF_FAIL\t{reason}\t{type(exc).__name__}:{exc}")
# The teardown/erase may have half-completed; force an absolute
# repaint so the prompt recovers instead of diffing a wrong model.
self._reset_prompt_renderer("handoff-fail")
raise
finally:
self._scrollback_handoff_depth -= 1
Expand Down
42 changes: 42 additions & 0 deletions tests/background/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -1622,3 +1622,45 @@ def fail_write_runtime(*_a, **_k):
assert events == ["lock_enter", "read", "write_unlocked", "lock_exit"]
# The mutation still landed: state reflects the terminal status.
assert real_read(task_id).status == "completed"


def test_launch_worker_windows_child_gets_hidden_console(monkeypatch):
"""The detached worker must not attach to the interactive console on Windows.

A worker sharing the TUI's console can corrupt it via the Win32 console
API even with DEVNULL stdio; CREATE_NO_WINDOW detaches it.
"""
from pathlib import Path as _Path

manager = object.__new__(manager_module.BackgroundTaskManager)
monkeypatch.setattr(
manager_module.BackgroundTaskManager,
"_worker_command",
lambda self, task_dir: ["worker"],
)

captured: dict[str, object] = {}

class _FakeProcess:
pid = 4242

def _fake_popen(cmd, **kwargs):
captured.update(kwargs)
return _FakeProcess()

from types import SimpleNamespace

monkeypatch.setattr(manager_module.subprocess, "Popen", _fake_popen)
# Swap the module's `os` reference rather than mutating the global
# `os.name`, which corrupts pathlib/pytest path handling mid-run.
monkeypatch.setattr(manager_module, "os", SimpleNamespace(name="nt"))

pid = manager._launch_worker(_Path("/tmp/task"))

assert pid == 4242
flags = captured["creationflags"]
assert isinstance(flags, int)
# CREATE_NEW_PROCESS_GROUP resolves to 0 off-Windows (getattr fallback);
# this test only owns the console-detachment bit.
assert flags & 0x08000000 # CREATE_NO_WINDOW
assert "start_new_session" not in captured
Loading
Loading