oc_chat
Loading...
Searching...
No Matches
render_turn_layout.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/scripts/render_turn_layout.py
4
5# OCT-LINT: disable=tests_exist reason="Helper script, not a production module"
6"""
7Render one scripted chat turn to a file — the plan-218 acceptance capture.
8
9Purpose
10-------
11Plan 218's acceptance target is the operator's layout mock at
12``docs/architecture/Terminal output layout Improvements.txt``. Proving a
13render matches it needs a DETERMINISTIC capture of the same event
14sequence, taken twice: once on the pre-218 tree and once after, so the
15report can show them side by side.
16
17This script drives a presenter through exactly the mock's sequence —
18welcome banner, the two pre-turn notices, the session-start rule, the
19prompt echo, one streamed answer, the per-iteration metadata, and the
20turn-end rule — with every clock-dependent value injected, so two runs
21differ only where the LAYOUT differs.
22
23It runs unchanged on a pre-218 tree: hooks plan 218 adds are called
24through ``getattr`` guards, so the "before" capture is taken with the
25same script as the "after" one.
26
27Responsibilities
28----------------
29- :func:`render` — drive one presenter and return the captured text.
30- :func:`main` — write the rich and plain captures to a UTF-8 file.
31
32Diagnostics
33-----------
34Domain: OC_CHAT.MAIN
35Levels:
36 L2 — lifecycle events
37 L3 — semantic details
38 L4 — deep tracing
39
40Contracts
41---------
42- Deterministic: timestamps, token counts and durations are fixed
43 literals; the only variable is terminal width, which is a parameter.
44- Console output is ASCII-only (the capture itself is UTF-8 and goes to
45 the ``--out`` file) so the script is safe under a cp1252 console.
46- Read-only with respect to the app: it constructs presenters directly
47 and never touches a session, the registry, or the network.
48
49Dependencies
50------------
51- oc_diagnostics (structured debug logging via _dbg)
52- rich.console (lazy; Console, which renders the rich capture into a string buffer at a fixed width)
53- oc_chat.core.iteration_stat (lazy; IterationStat, the pinned record the per-iteration metadata line reports)
54- oc_ui.terminal.statusline (lazy; StatusBarState, the pinned status snapshot bound to each presenter)
55- oc_ui.terminal.rich_presenter (lazy; RichPresenter, the rich presenter under capture)
56- oc_ui.terminal.presenter (lazy; ConsolePresenter, the plain presenter under capture)
57- oc_ui.terminal.input (lazy; build_prompt_prefix for the prompt echo line)
58- rich.text (lazy; Text, so the prompt echo prints without markup parsing)
59"""
60from __future__ import annotations
61
62import argparse
63import io
64import os
65import sys
66from pathlib import Path
67from types import SimpleNamespace
68
69from oc_diagnostics import _dbg
70
71_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
72
73#: Every clock/usage value the mock shows, pinned so two captures differ
74#: only in layout.
75SESSION_START_TS = "02:13:32"
76TURN_TS = "02:13:37"
77TURN_END_TS = "02:13:40"
78MODEL = "z-ai/glm-5.3-flash"
79ANSWER = (
80 "Hello. I'm ready to help with your Option C project - code, tests, git, "
81 "quality gates, or planning. What would you like to work on?\n"
82)
83SECURITY_LINE = (
84 "[security] permissive profile: the agent runs in-process with your "
85 "account's authority. Use the 'guarded' profile for less-trusted projects."
86)
87PROMPT_TOOLKIT_LINE = (
88 "[prompt_toolkit enabled - Tab completes commands, up/down cycles history, "
89 "Alt+Enter inserts newline, Ctrl+P opens command palette]"
90)
91
92
93def _stat():
94 """The single iteration record the mock's metadata line reports."""
95 from oc_chat.core.iteration_stat import IterationStat
96 return IterationStat(
97 ts=f"2026-08-31T{TURN_END_TS}.371+02:00",
98 iter=0,
99 model=MODEL,
100 effort="high",
101 tools=(),
102 prompt_tokens=7148,
103 completion_tokens=83,
104 cache_hit_tokens=0,
105 cache_miss_tokens=0,
106 dur_ms=2530,
107 )
108
109
111 """The bound status snapshot the running total and status bar read."""
112 from oc_ui.terminal.statusline import StatusBarState
113 return StatusBarState(
114 model=MODEL, session_name="GLM 5.4 Flash test 01", effort="high",
115 turn_tokens=7231, turn_iterations=1, turn_tools=0,
116 turn_cache_hit_pct=None, turn_cache_saved_tokens=0,
117 turn_elapsed_ms=2530,
118 session_tokens=0, session_tools=0,
119 session_cache_hit_pct=None, session_cache_saved_tokens=0,
120 session_elapsed_ms=1_103_000,
121 )
122
123
124def _frozen_clock(presenter, ts: str) -> None:
125 """Pin the presenter's rule clock so timestamps are reproducible.
126
127 ``_now_ts`` is the presenters' one wall-clock read for rule labels
128 (plan 218). On a pre-218 tree the attribute does not exist and the
129 capture simply carries real timestamps — the layout comparison the
130 "before" capture exists for is unaffected.
131 """
132 if hasattr(type(presenter), "_now_ts"):
133 setattr(presenter, "_now_ts", lambda: ts)
134
135
136def render(kind: str, width: int) -> str:
137 """Drive *kind* (``"rich"`` / ``"plain"``) through the scripted turn."""
138 func = "render"
139 _dbg("OC_CHAT.MAIN", func, f"kind={kind} width={width}", 3)
140 session = SimpleNamespace(name="GLM 5.4 Flash test 01", model=MODEL,
141 reasoning_effort="high", ui_prefs={})
142 buf = io.StringIO()
143
144 if kind == "rich":
145 from rich.console import Console
146 from rich.text import Text
147 from oc_ui.terminal.rich_presenter import RichPresenter
148 # legacy_windows=False: Rich otherwise deducts a column from an
149 # explicit width on a legacy Windows console, which would make the
150 # capture one column narrower than the width being demonstrated.
151 console = Console(file=buf, force_terminal=False, width=width,
152 no_color=True, soft_wrap=False, legacy_windows=False)
153 presenter = RichPresenter(console=console)
154
155 def emit(text: str) -> None:
156 # Text(), never a markup string: the prompt echo contains
157 # ``[<model>]``, which Rich would otherwise parse as markup.
158 console.print(Text(text), highlight=False)
159 else:
160 from oc_ui.terminal.presenter import ConsolePresenter
161 presenter = ConsolePresenter()
162 emit = print
163
164 presenter.bind_status(_status_state)
165
166 stdout, sys.stdout = sys.stdout, buf
167 try:
168 presenter.show_welcome(session)
169 presenter.show_info(SECURITY_LINE)
170 presenter.show_info(PROMPT_TOOLKIT_LINE)
171
172 _frozen_clock(presenter, SESSION_START_TS)
173 session_start = getattr(presenter, "show_session_start", None)
174 if session_start is not None:
175 session_start()
176
177 from oc_ui.terminal.input import build_prompt_prefix
178 emit(build_prompt_prefix(session) + "Hello.")
179
180 _frozen_clock(presenter, TURN_TS)
181 presenter.start_turn()
182 _frozen_clock(presenter, TURN_END_TS)
183 with presenter.turn():
184 presenter.show_waiting()
185 presenter.show_content(ANSWER)
186 presenter.show_iteration_stat(_stat())
187 presenter.end_turn()
188 presenter.show_turn_summary(
189 prompt=7148, completion=83, total=7231, iterations=1, tool_calls=0,
190 model=MODEL, context_used_pct=11.0, duration_ms=2900,
191 )
192 finally:
193 sys.stdout = stdout
194 return buf.getvalue()
195
196
197def main(argv: list[str] | None = None) -> int:
198 """Write the rich + plain captures to ``--out`` and report the path."""
199 func = "main"
200 parser = argparse.ArgumentParser(
201 description="Capture the scripted plan-218 turn render.")
202 parser.add_argument("--out", required=True, help="UTF-8 capture file")
203 parser.add_argument("--width", type=int, default=120,
204 help="terminal width to render at")
205 args = parser.parse_args(argv)
206
207 # Set BEFORE the presenters are imported (they read both at import
208 # time): NO_COLOR keeps the capture readable as plain text, COLUMNS is
209 # what shutil.get_terminal_size reports to the plain presenter, so both
210 # presenters render at the SAME requested width.
211 os.environ["NO_COLOR"] = "1"
212 os.environ["COLUMNS"] = str(args.width)
213 os.environ["LINES"] = "40"
214
215 chunks = []
216 for kind in ("rich", "plain"):
217 chunks.append(f"===== {kind} presenter @ width {args.width} =====\n")
218 chunks.append(render(kind, args.width))
219 chunks.append("\n")
220
221 out = Path(args.out)
222 out.parent.mkdir(parents=True, exist_ok=True)
223 out.write_text("".join(chunks), encoding="utf-8")
224 _dbg("OC_CHAT.MAIN", func, f"wrote {out}", 2)
225 print(f"wrote capture: {out}")
226 return 0
227
228
229if __name__ == "__main__":
230 raise SystemExit(main())