oc_chat
Loading...
Searching...
No Matches
turn_layout.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_ui/terminal/turn_layout.py
4
5"""
6Turn-scoped layout primitives shared by every terminal presenter (plan 218).
7
8Purpose
9-------
10Plan 218 makes each chat turn read as ONE bounded, indented block:
11a ``start @`` rule opens the session, the assistant's answer sits in a
12box whose top border carries ``Assistant Answer:`` and whose bottom
13border carries that iteration's metadata, the turn-end rule absorbs the
14turn stats plus the running total, and every line between the turn
15rules is indented by :data:`TURN_INDENT`.
16
17None of that geometry is presenter-specific, so it lives here as pure
18string logic: :class:`~oc_ui.terminal.rich_presenter.RichPresenter`
19maps the composed segments onto ``rich`` renderables and
20:class:`~oc_ui.terminal.presenter.ConsolePresenter` maps the same
21segments onto ASCII marker lines. One algorithm, two skins — the plain
22presenter can never drift out of structural agreement with the rich one.
23
24Framework-free by the same reasoning as
25:mod:`~oc_ui.terminal.statusline`: no ``rich`` / ``prompt_toolkit``
26import, so both presenters and the prompt driver can share it.
27
28Responsibilities
29----------------
30- :data:`TURN_INDENT` / :func:`indent_text` — the three-space turn indent.
31- :class:`LineIndenter` — line-buffered indenting for streamed chunks,
32 so a presenter that prints as it streams can still prefix whole lines.
33- :func:`compose_rule` — the two-cluster rule layout (centred label plus
34 a trailer centred in the right-hand fill run) used by the turn-end rule.
35- :func:`start_rule_label` / :func:`turn_rule_label` /
36 :func:`turn_end_rule_label` / :func:`running_total_text` — the rule
37 label strings.
38- :func:`plain_box_top` / :func:`plain_box_bottom` — the glyph-free
39 degraded box markers for the plain presenter (P4).
40
41Diagnostics
42-----------
43Domain: OC_UI.TERMINAL
44Levels:
45 L2 — lifecycle events
46 L3 — semantic details
47 L4 — deep tracing
48
49Contracts
50---------
51- Pure functions: no I/O, no clock reads, no framework imports. Callers
52 pass the timestamp/width they want rendered.
53- :func:`compose_rule` always returns segments whose joined text is
54 EXACTLY *width* columns when the label fits, and never wraps: a label
55 wider than the available run is emitted un-truncated with the fill
56 runs collapsed to zero rather than being cut (the same "never wrap,
57 never lie" discipline :func:`~oc_ui.terminal.statusline.render_status_line`
58 applies to the status bar).
59- The trailer is DROPPED, not truncated, when it cannot fit beside the
60 label — a partial running total would be a wrong number on screen.
61- :class:`LineIndenter` is append-only and never re-emits a line it has
62 already returned; ``flush`` returns whatever tail is still buffered.
63- No colour/style decisions here — segments are TAGGED (``"fill"`` /
64 ``"label"`` / ``"trailer"``) and each presenter maps tags onto its own
65 existing style tokens, so plan 218's byte-preserved-colour pin (P3)
66 stays enforceable at the presenter boundary.
67
68Dependencies
69------------
70- oc_diagnostics (structured debug logging via _dbg)
71- oc_ui.terminal.statusline (lazy; fmt_tokens_short as the default running total formatter)
72"""
73from __future__ import annotations
74
75from typing import TYPE_CHECKING
76
77from oc_diagnostics import _dbg
78
79if TYPE_CHECKING:
80 from typing import Callable
81
82_dbg("OC_UI.TERMINAL", "module", "loaded", 4)
83
84
85#: The turn-scoped indent (plan 218 P1 element 5). Every line rendered
86#: between the turn rules carries it; the rules themselves and the status
87#: bar do not.
88TURN_INDENT = " "
89
90#: Title cut into the answer box's top border (plan 218 P1 element 2).
91#: Identical string to the pre-218 standalone header line, so a reader
92#: (and every existing substring assertion) sees the same words.
93ANSWER_BOX_TITLE = "Assistant Answer:"
94
95#: Reasoning header, kept verbatim from the pre-218 layout.
96REASONING_TITLE = "Assistant CoT:"
97
98#: Fill character for the rich rules (unchanged from pre-218).
99RULE_FILL = "─"
100
101#: Fill character for the plain presenter's label line (unchanged).
102PLAIN_RULE_FILL = "·"
103
104#: Marker character for the plain presenter's degraded answer box (P4/W5) —
105#: deliberately ASCII so a dumb terminal renders it faithfully.
106PLAIN_BOX_FILL = "-"
107
108
109def indent_text(text: str, indent: str = TURN_INDENT) -> str:
110 """Prefix every line of *text* with *indent*.
111
112 A trailing newline is preserved without gaining a phantom indented
113 empty line, so ``indent_text("a\\n")`` is ``" a\\n"`` — the caller can
114 hand the result straight to ``print(..., end="")``.
115 """
116 if not text:
117 return text
118 lines = text.split("\n")
119 tail = ""
120 if lines and lines[-1] == "":
121 lines = lines[:-1]
122 tail = "\n"
123 return "\n".join(indent + line for line in lines) + tail
124
125
127 """Line-buffered indenter for streamed chunks (plan 218 P2).
128
129 A presenter that prints each streaming chunk as it arrives cannot
130 simply prefix the chunk — chunks split mid-line, so the indent would
131 land in the middle of a sentence. Feed chunks in; get back only the
132 lines that have COMPLETED, already indented and newline-terminated.
133 Whatever is still partial stays buffered until :meth:`flush`.
134
135 Parameters
136 ----------
137 indent:
138 The prefix applied to each completed line.
139 """
140
141 def __init__(self, indent: str = TURN_INDENT) -> None:
142 self._indent = indent
143 self._buf = ""
144
145 def feed(self, text: str) -> list[str]:
146 """Consume *text*; return the indented lines it completed."""
147 self._buf += text
148 out: list[str] = []
149 while "\n" in self._buf:
150 line, _, self._buf = self._buf.partition("\n")
151 out.append(self._indent + line)
152 return out
153
154 def flush(self) -> list[str]:
155 """Return the buffered tail (if any) as one indented line and reset."""
156 if not self._buf:
157 return []
158 line = self._indent + self._buf
159 self._buf = ""
160 return [line]
161
162 @property
163 def pending(self) -> bool:
164 """``True`` while a partial line is still buffered."""
165 return bool(self._buf)
166
167
168# ── Rule labels ──────────────────────────────────────────────────────────────
169
170
171def start_rule_label(ts: str) -> str:
172 """Label for the session-opening rule: ``" start @ 02:13:32 "``."""
173 return f" start @ {ts} "
174
175
176def turn_rule_label(turn_number: int, ts: str) -> str:
177 """Label for the turn-opening rule: ``" turn 1 @ 02:13:37 "``."""
178 return f" turn {turn_number} @ {ts} "
179
180
181def turn_end_rule_label(turn_number: int, ts: str, stats: str = "") -> str:
182 """Label for the turn-closing rule, with the turn stats absorbed.
183
184 Plan 218 P1 element 4: the stats that used to occupy their own line
185 under the rule move INTO the rule text. *stats* is the presenter's
186 existing stats string, passed through unchanged (P3 — positions
187 change, styles and wording do not); an empty *stats* yields the
188 pre-218 label.
189 """
190 body = f" turn {turn_number} end @ {ts}"
191 if stats:
192 body += f" - {stats.strip()}"
193 return body + " "
194
195
196def turn_end_rule_segments(turn_number: int, ts: str,
197 stats: str = "") -> list[tuple[str, str]]:
198 """:func:`turn_end_rule_label` split so the stats keep their own style.
199
200 Returns ``[(rule-label, "label"), (stats, "stats")]``. The split
201 exists for exactly one reason (plan 218 P3): the rule title was dim
202 cyan and the stats line was dim, and moving the stats INTO the rule
203 must not repaint them in the rule's colour. Presenters map the two
204 tags onto the two style tokens they already used.
205 """
206 head = f" turn {turn_number} end @ {ts}"
207 if not stats:
208 return [(head + " ", "label")]
209 return [(head + " - ", "label"), (stats.strip() + " ", "stats")]
210
211
212def running_total_text(turn_tokens: int, session_tokens: int,
213 fmt: "Callable[[int], str] | None" = None) -> str:
214 """The running-total trailer: ``"↻ total · 7.2K / 7.2K"``.
215
216 Reuses the ``↻`` marker and the compact token formatting the
217 between-turns rprompt already shows
218 (:meth:`~oc_ui.terminal.input.TurnStats.rprompt_text`), so the same
219 two numbers keep the same shape wherever they appear. *fmt* defaults
220 to :func:`~oc_ui.terminal.statusline.fmt_tokens_short`; it is a
221 parameter only so this module keeps its no-sibling-import purity in
222 tests.
223 """
224 if fmt is None:
225 from oc_ui.terminal.statusline import fmt_tokens_short
226 fmt = fmt_tokens_short
227 return f"↻ total · {fmt(turn_tokens)} / {fmt(session_tokens)}"
228
229
230# ── Rule composition ─────────────────────────────────────────────────────────
231
232
233def compose_rule(label: str | list[tuple[str, str]], width: int,
234 trailer: str = "", fill: str = RULE_FILL) -> list[tuple[str, str]]:
235 """Compose a rule line as tagged segments.
236
237 Geometry (plan 218 P1 element 4, read off the operator's mock): the
238 *label* block is centred over the full *width* exactly as the pre-218
239 ``rich.rule.Rule`` centred its title, and the *trailer* block is then
240 centred within the right-hand fill run that remains. Each block is
241 padded with two spaces either side, matching the two-space gutter
242 ``Rule`` already produced for a label that itself carried one space.
243
244 *label* is either a plain string or a list of ``(text, tag)`` pairs
245 (see :func:`turn_end_rule_segments`) when parts of the label must
246 keep distinct styles.
247
248 Returns a list of ``(text, tag)`` pairs where *tag* is ``"fill"``,
249 ``"trailer"``, or whichever tag the caller supplied for a label part
250 (``"label"`` for a plain-string label). Callers map tags onto their
251 own style tokens; this function makes no colour decision.
252 """
253 label_parts: list[tuple[str, str]]
254 if isinstance(label, str):
255 label_parts = [(label, "label")] if label else []
256 else:
257 label_parts = [part for part in label if part[0]]
258 if label_parts:
259 # One space either side of the whole label block, on the outermost
260 # parts, so a multi-part label still reads as one centred title.
261 label_parts = list(label_parts)
262 label_parts[0] = (" " + label_parts[0][0], label_parts[0][1])
263 label_parts[-1] = (label_parts[-1][0] + " ", label_parts[-1][1])
264 label_len = sum(len(text) for text, _tag in label_parts)
265 trailer_block = f" {trailer} " if trailer else ""
266
267 if label_len >= width:
268 # Never wrap, never truncate: emit the label alone and let the
269 # terminal decide. The trailer is dropped rather than half-shown.
270 return label_parts
271
272 left = (width - label_len) // 2
273 right_run = width - left - label_len
274
275 segments: list[tuple[str, str]] = []
276 if left:
277 segments.append((fill * left, "fill"))
278 segments.extend(label_parts)
279
280 # The trailer needs its block plus at least one fill cell either side,
281 # otherwise it would collide with the label or the line end.
282 if trailer_block and right_run >= len(trailer_block) + 2:
283 gap = (right_run - len(trailer_block)) // 2
284 tail = right_run - gap - len(trailer_block)
285 segments.append((fill * gap, "fill"))
286 segments.append((trailer_block, "trailer"))
287 if tail:
288 segments.append((fill * tail, "fill"))
289 elif right_run:
290 segments.append((fill * right_run, "fill"))
291 return segments
292
293
294def rule_line(label: str | list[tuple[str, str]], width: int, trailer: str = "",
295 fill: str = RULE_FILL) -> str:
296 """Return :func:`compose_rule`'s segments joined into one plain string."""
297 return "".join(text for text, _tag in compose_rule(label, width, trailer, fill))
298
299
300#: Minimum fill cells either side of a label before it counts as "in a rule"
301#: rather than "a line that happens to be a label".
302_MIN_RULE_FILL = 4
303
304
305def rule_fits(label: str | list[tuple[str, str]], width: int) -> bool:
306 """Can *label* be absorbed into a *width*-column rule and still read as one?
307
308 Plan 218's turn-end rule swallows the whole turn-stats string, which on
309 a narrow terminal is longer than the terminal itself. Callers use this
310 to choose between the absorbed one-line rule and the pre-218 two-line
311 form (rule, then stats beneath): degrading to the OLD layout keeps
312 every figure legible, whereas letting an oversized label wrap would
313 split a rule across lines and cut a ``ctx:NN% of <model>`` gauge in
314 half. Never truncate, never wrap — degrade.
315 """
316 if isinstance(label, str):
317 label_len = len(label)
318 else:
319 label_len = sum(len(text) for text, _tag in label)
320 return label_len + 2 + _MIN_RULE_FILL <= width
321
322
323# ── Degraded (glyph-free) answer box — plain presenter, plan 218 P4/W5 ───────
324
325
326def plain_box_top(width: int, title: str = ANSWER_BOX_TITLE,
327 indent: str = TURN_INDENT) -> str:
328 """ASCII opening marker for the degraded answer box.
329
330 Renders ``" --- Assistant Answer: -----"`` — the same information
331 the rich box carries in its top border, using only characters a dumb
332 terminal is guaranteed to draw (W5).
333 """
334 inner = max(0, width - len(indent))
335 head = f"{PLAIN_BOX_FILL * 3} {title} "
336 pad = max(0, inner - len(head))
337 return indent + head + PLAIN_BOX_FILL * pad
338
339
340def plain_box_bottom(width: int, subtitle: str = "",
341 indent: str = TURN_INDENT) -> str:
342 """ASCII closing marker for the degraded answer box.
343
344 Carries the iteration metadata in the same place the rich box's
345 bottom border does. Always emitted — the plain presenter closes its
346 box on every path too (W2).
347 """
348 inner = max(0, width - len(indent))
349 head = PLAIN_BOX_FILL * 3
350 if subtitle:
351 head += f" {subtitle} "
352 else:
353 head += " "
354 pad = max(0, inner - len(head))
355 return indent + head + PLAIN_BOX_FILL * pad
None __init__(self, str indent=TURN_INDENT)
str turn_rule_label(int turn_number, str ts)
str turn_end_rule_label(int turn_number, str ts, str stats="")
bool rule_fits(str|list[tuple[str, str]] label, int width)
str indent_text(str text, str indent=TURN_INDENT)
str running_total_text(int turn_tokens, int session_tokens, "Callable[[int], str] | None" fmt=None)
str plain_box_bottom(int width, str subtitle="", str indent=TURN_INDENT)
str rule_line(str|list[tuple[str, str]] label, int width, str trailer="", str fill=RULE_FILL)
list[tuple[str, str]] turn_end_rule_segments(int turn_number, str ts, str stats="")
str plain_box_top(int width, str title=ANSWER_BOX_TITLE, str indent=TURN_INDENT)
list[tuple[str, str]] compose_rule(str|list[tuple[str, str]] label, int width, str trailer="", str fill=RULE_FILL)