Skip to content

Commit e4613b9

Browse files
committed
fix(tui): preserve reasoning and cancellation boundaries
1 parent a8a0b25 commit e4613b9

7 files changed

Lines changed: 88 additions & 24 deletions

File tree

src/pythinker_code/ui/shell/visualize/_blocks.py

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,7 @@ def _tail_lines(text: str, n: int) -> str:
608608
class _ThinkingSegment:
609609
summary_index: int | None
610610
text: str = ""
611+
encrypted_boundary: bool = False
611612

612613

613614
class _StyleOverrideRenderable:
@@ -762,11 +763,21 @@ def render_expanded(self) -> RenderableType:
762763
finally:
763764
self._report_update.set_expanded(was_expanded)
764765

765-
def append(self, content: str, *, summary_index: int | None = None) -> None:
766+
def append(
767+
self,
768+
content: str,
769+
*,
770+
summary_index: int | None = None,
771+
encrypted: str | None = None,
772+
) -> None:
766773
self.raw_text += content
767774
self._token_count += _estimate_tokens(content)
768-
if self.is_think and content:
769-
self._append_thinking_segment(content, summary_index=summary_index)
775+
if self.is_think and (content or encrypted):
776+
self._append_thinking_segment(
777+
content,
778+
summary_index=summary_index,
779+
encrypted=encrypted,
780+
)
770781
self._invalidate_preview_cache()
771782
if self._paced:
772783
# Reveal is paced by reveal_tick() for smooth streaming; just buffer
@@ -970,11 +981,30 @@ def take_committed_renderables(self) -> list[RenderableType]:
970981

971982
# -- Private -------------------------------------------------------------
972983

973-
def _append_thinking_segment(self, content: str, *, summary_index: int | None) -> None:
974-
if self._thinking_segments and self._thinking_segments[-1].summary_index == summary_index:
975-
self._thinking_segments[-1].text += content
984+
def _append_thinking_segment(
985+
self,
986+
content: str,
987+
*,
988+
summary_index: int | None,
989+
encrypted: str | None,
990+
) -> None:
991+
encrypted_boundary = bool(encrypted)
992+
if (
993+
self._thinking_segments
994+
and self._thinking_segments[-1].summary_index == summary_index
995+
and not self._thinking_segments[-1].encrypted_boundary
996+
):
997+
segment = self._thinking_segments[-1]
998+
segment.text += content
999+
segment.encrypted_boundary = encrypted_boundary
9761000
return
977-
self._thinking_segments.append(_ThinkingSegment(summary_index=summary_index, text=content))
1001+
self._thinking_segments.append(
1002+
_ThinkingSegment(
1003+
summary_index=summary_index,
1004+
text=content,
1005+
encrypted_boundary=encrypted_boundary,
1006+
)
1007+
)
9781008

9791009
def _pending_text(self) -> str:
9801010
return self.raw_text[self._committed_len : self._revealed_len]

src/pythinker_code/ui/shell/visualize/_live_view.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,6 +1014,9 @@ def _working_indicator(self, *, hide_tips: bool = False) -> RenderableType:
10141014
elapsed_s=elapsed,
10151015
tokens=get_turn_output_tokens(),
10161016
token_rate=self._turn_token_rate(now),
1017+
interrupt_hint=(
1018+
"esc to interrupt" if getattr(self, "_cancel_event", None) is not None else ""
1019+
),
10171020
),
10181021
width=width,
10191022
)
@@ -1695,13 +1698,14 @@ def flush_notifications(self) -> None:
16951698

16961699
def append_content(self, part: ContentPart) -> None:
16971700
match part:
1698-
case ThinkPart(think=text, summary_index=summary_index):
1701+
case ThinkPart(think=text, encrypted=encrypted, summary_index=summary_index):
16991702
is_think = True
17001703
case TextPart(text=text):
17011704
if not text:
17021705
return
17031706
is_think = False
17041707
summary_index = None
1708+
encrypted = None
17051709
case ImageURLPart():
17061710
self._append_content_label("[image]")
17071711
return
@@ -1739,8 +1743,12 @@ def append_content(self, part: ContentPart) -> None:
17391743
paced=self._stream_pacing,
17401744
)
17411745
self.refresh_soon()
1742-
if text:
1743-
self._current_content_block.append(text, summary_index=summary_index)
1746+
if text or encrypted:
1747+
self._current_content_block.append(
1748+
text,
1749+
summary_index=summary_index,
1750+
encrypted=encrypted,
1751+
)
17441752
self.refresh_soon()
17451753

17461754
def _append_content_label(self, label: str, *, unknown: bool = False) -> None:

tests/e2e/test_shell_pty_e2e.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -927,7 +927,7 @@ def test_shell_cancel_running_command_kills_process_and_recovers(tmp_path: Path)
927927
scripts = [
928928
build_shell_tool_call(
929929
"tc-c1",
930-
"printf started > cancel_started.txt && sleep 30 && "
930+
"printf '%s' \"$$\" > cancel_pgid.txt && sleep 30 && "
931931
"printf should-not-exist > cancel_output.txt",
932932
),
933933
"text: Cancel recovery completed.",
@@ -948,19 +948,14 @@ def test_shell_cancel_running_command_kills_process_and_recovers(tmp_path: Path)
948948

949949
cancel_mark = shell.mark()
950950
shell.send_line("start cancellable command")
951-
started_path = work_dir / "cancel_started.txt"
951+
pgid_path = work_dir / "cancel_pgid.txt"
952952
started_deadline = time.monotonic() + 10.0
953-
while not started_path.exists():
953+
while not pgid_path.exists():
954954
if time.monotonic() >= started_deadline:
955955
raise AssertionError("Timed out waiting for cancellable command to start.")
956956
shell.read_available(timeout=0.05)
957-
# The child can begin while prompt_toolkit is still switching from the
958-
# submitted prompt to the running-turn delegate that owns Escape. Keep
959-
# the command alive well beyond this short stabilization window so the
960-
# key cannot land in the transition and be discarded.
961-
stabilization_deadline = time.monotonic() + 1.0
962-
while time.monotonic() < stabilization_deadline:
963-
shell.read_available(timeout=0.05)
957+
command_pgid = int(pgid_path.read_text(encoding="utf-8"))
958+
shell.read_until_contains("esc)", after=cancel_mark, timeout=10.0)
964959
shell.send_key("escape")
965960
# The "Interrupted by user" acknowledgement only prints after the soul
966961
# re-raises the cancellation, which first awaits a shielded, disk-first
@@ -973,7 +968,8 @@ def test_shell_cancel_running_command_kills_process_and_recovers(tmp_path: Path)
973968
cancel_prompt_mark = shell.mark()
974969
_read_until_prompt(shell, after=cancel_prompt_mark)
975970

976-
time.sleep(5.3)
971+
with pytest.raises(ProcessLookupError):
972+
os.killpg(command_pgid, 0)
977973
assert not (work_dir / "cancel_output.txt").exists()
978974

979975
recovery_mark = shell.mark()

tests/ui_and_conv/test_live_content_parts.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,24 @@ def test_live_view_dispatch_preserves_reasoning_summary_boundaries_and_style(
9292
assert all(style.italic for style in styles)
9393

9494

95+
def test_live_view_preserves_encrypted_only_reasoning_boundary(
96+
monkeypatch: pytest.MonkeyPatch,
97+
) -> None:
98+
emitted = _capture_scrollback(monkeypatch)
99+
view = _LiveView(StatusUpdate(context_tokens=1000), show_thinking_stream=True)
100+
101+
view.dispatch_wire_message(ThinkPart(think="**Planning**", summary_index=0))
102+
view.dispatch_wire_message(ThinkPart(think="", encrypted="signature", summary_index=0))
103+
view.dispatch_wire_message(ThinkPart(think="**Executing**", summary_index=0))
104+
view.flush_content()
105+
106+
output = _render(emitted)
107+
lines = [line for line in output.splitlines() if line.strip()]
108+
assert len(lines) == 2
109+
assert "Planning" in lines[0]
110+
assert "Executing" in lines[1]
111+
112+
95113
def test_text_media_text_flushes_at_stable_boundaries(
96114
monkeypatch: pytest.MonkeyPatch,
97115
) -> None:

tests/ui_and_conv/test_live_view_notifications.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import asyncio
4+
35
from pythinker_core.message import ToolCall
46
from pythinker_core.tooling import ToolResult, ToolReturnValue
57
from rich.console import Console, Group
@@ -158,6 +160,15 @@ def test_working_indicator_uses_turn_elapsed_time(monkeypatch):
158160
assert "4h" not in rendered
159161

160162

163+
def test_working_indicator_exposes_escape_when_turn_is_cancellable():
164+
view = _LiveView(StatusUpdate(), asyncio.Event())
165+
view.dispatch_wire_message(TurnBegin(user_input="scan"))
166+
167+
rendered = _render(view._working_indicator())
168+
169+
assert "esc)" in rendered
170+
171+
161172
def test_working_indicator_uses_rotating_thinking_words(monkeypatch):
162173
now = 90.0
163174
monkeypatch.setattr(live_view_module.time, "monotonic", lambda: now)

tests/ui_and_conv/test_subagent_live_stream.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,7 @@ def test_run_agents_background_result_keeps_one_semantic_live_tree_after_nested_
680680
assert "searching…" in output
681681
assert "2 queued" in output
682682
assert "agent-alpha-raw-id" not in output
683+
assert "agent-beta-raw-id" not in output
683684
for line in output.splitlines():
684685
assert cell_width(line) <= 80
685686

tests/ui_and_conv/test_tool_call_block.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -523,9 +523,8 @@ def test_run_agents_background_launch_keeps_live_agent_activity():
523523
assert "Run Agents completed" not in output
524524

525525

526-
def test_run_agents_background_launch_keeps_live_agent_activity_in_card_style(
527-
_card_style_with_builtin_renderers,
528-
):
526+
@pytest.mark.usefixtures("_card_style_with_builtin_renderers")
527+
def test_run_agents_background_launch_keeps_live_agent_activity_in_card_style():
529528
block = _ToolCallBlock(
530529
_tool_call(
531530
"RunAgents",
@@ -551,6 +550,7 @@ def test_run_agents_background_launch_keeps_live_agent_activity_in_card_style(
551550
output = _plain(block.compose())
552551

553552
assert "waiting Explore Audit the renderer" in output
553+
assert "Run Agents completed" not in output
554554

555555

556556
def test_run_agents_foreground_completion_is_not_background_pending():

0 commit comments

Comments
 (0)