oc_chat
Loading...
Searching...
No Matches
tui_spike.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/scripts/tui_spike.py
4
5"""
6Standalone prompt_toolkit TUI feasibility spike (Phase TUI-1).
7
8Purpose
9-------
10Prove — before committing to a full TUI rewrite of OC Chat — that the
11risky parts of a prompt_toolkit-based three-pane terminal application
12actually work on Windows Terminal + PowerShell, the project's
13declared host. The risky parts (Codex review):
14
151. Rich-ANSI rendering for Markdown headings, fenced code with syntax
16 highlighting, GitHub-style tables, and wide unicode (CJK / combining
17 marks) inside a prompt_toolkit pane.
182. Independent pane scrolling via PgUp/PgDn/Home/End on the
19 conversation pane only — header and input stay pinned.
203. SIGINT-during-stream behavior. A worker thread appends fake
21 streaming chunks; Ctrl+C must cancel the stream and leave the app
22 alive (no terminal corruption, no hang).
234. Resize tolerance. SIGWINCH on POSIX; prompt_toolkit's internal
24 terminal-size polling on Windows.
255. Alt-screen vs non-alt-screen exit semantics. We use the default
26 (non-alt-screen + full_screen=True style with explicit redraw) and
27 verify the terminal restores cleanly after Ctrl+Q.
28
29The spike is **deliberately not wired into the OC Chat runtime**. It
30is a standalone runnable: ``python -m oc_chat.scripts.tui_spike``. The
31companion ``README_TUI_SPIKE.md`` documents how to run it, the exit
32criteria that gate Round 2, and a results table for manual smoke.
33
34Responsibilities
35----------------
36- :func:`main` — bootstrap the prompt_toolkit Application and run it.
37- :func:`build_application` — assemble the three-pane layout, key
38 bindings, and styled controls. Returns a ready-to-run
39 :class:`Application`.
40- :class:`_StreamSimulator` — background thread that appends fake
41 Rich-ANSI streaming chunks to the conversation buffer at a bounded
42 rate. Stop event for SIGINT cancellation.
43- :data:`SAMPLE_RICH_BLOCKS` — representative Rich-ANSI samples
44 (Markdown heading, fenced code, table, long paragraph, wide
45 unicode) used to populate the conversation pane on launch.
46
47Diagnostics
48-----------
49Domain: OC_CHAT.TUI_SPIKE
50Levels:
51 L2 — application lifecycle (start / exit)
52 L3 — stream simulation events (start, cancel, chunk rate cap)
53 L4 — per-chunk trace
54
55Contracts
56---------
57- The spike must not import any OC Chat runtime modules other than
58 :mod:`oc_diagnostics`. It does not touch sessions, providers, the
59 agent, or the registry. Deletable later without touching anything
60 else.
61- Rendering uses ``FormattedTextControl(text=ANSI(...))`` so Rich's
62 ANSI output is passed through verbatim — prompt_toolkit does not
63 re-parse the escape codes; it just hands them to the terminal.
64- Worker-thread → UI handoff goes via
65 :meth:`Application.create_background_task` /
66 ``app.loop.call_soon_threadsafe``. Direct buffer mutation from the
67 worker thread would race with the event loop.
68- The simulated chunk rate is capped at 80 chunks/sec, matching the
69 observed max from real DeepSeek-v4-pro generations. Above this,
70 prompt_toolkit's redraw cadence struggles and the test isn't
71 representative.
72- SIGINT during streaming sets a ``threading.Event`` that the worker
73 polls between chunks; the app stays alive. Ctrl+Q exits via the
74 normal key-binding path.
75
76Dependencies
77------------
78- oc_diagnostics (structured debug logging via _dbg)
79- prompt_toolkit (lazy; 3.x Application, layout and key bindings, assumed installed for any developer running this spike)
80- rich (lazy; optional ANSI rendering of the sample blocks, falls back to a plain-text sample when missing)
81"""
82from __future__ import annotations
83
84import threading
85import time
86from typing import Optional
87
88from oc_diagnostics import _dbg
89
90
91_dbg("OC_CHAT.TUI_SPIKE", "module", "loaded", 4)
92
93
94# ── Constants ─────────────────────────────────────────────────────────────
95
96
97#: Maximum chunks-per-second the worker thread will emit. Matches the
98#: observed DeepSeek-v4-pro real-world max; higher rates expose
99#: prompt_toolkit redraw scheduling issues that aren't representative
100#: of production load.
101MAX_CHUNKS_PER_SECOND: int = 80
102
103
104# ── Rich-ANSI sample blocks ───────────────────────────────────────────────
105
106
107def _build_sample_blocks() -> list[str]:
108 """Return a list of Rich-rendered ANSI strings.
109
110 Tests the four risky rendering paths: Markdown heading, fenced
111 code with syntax highlighting, table, long paragraph, and wide
112 unicode. Falls back to plain text if ``rich`` is missing so the
113 spike still loads on a bare environment.
114 """
115 func = "_build_sample_blocks"
116 try:
117 from io import StringIO
118 from rich.console import Console
119 from rich.markdown import Markdown
120 from rich.table import Table
121 except ImportError as exc:
122 _dbg("OC_CHAT.TUI_SPIKE", func, f"rich missing — plain fallback: {exc}", 3)
123 return [
124 "# Heading (plain)\n",
125 "```py\nprint('hello')\n```\n",
126 "| a | b |\n|---|---|\n| 1 | 2 |\n",
127 (
128 "Long paragraph: "
129 "Lorem ipsum dolor sit amet, consectetur adipiscing elit. "
130 * 30
131 ),
132 # OCT-LINT: disable=no_hardcoded_secrets reason="plain-fallback TUI sample text with wide Unicode characters; no credential material (operator-ruled 2026-09-28)"
133 "Wide unicode: 你好 こんにちは 안녕하세요 — combining: á é\n",
134 ]
135
136 def _render(renderable: object) -> str:
137 buf = StringIO()
138 console = Console(file=buf, force_terminal=True, color_system="truecolor", width=100)
139 console.print(renderable)
140 return buf.getvalue()
141
142 blocks: list[str] = []
143 blocks.append(_render(Markdown("# Markdown heading — render sanity")))
144 blocks.append(_render(Markdown(
145 "```python\n"
146 "def hello(name: str) -> str:\n"
147 " return f'Hello, {name}!'\n"
148 "```\n"
149 )))
150 table = Table(title="Table sanity (Rich → ANSI → prompt_toolkit)")
151 table.add_column("Phase", style="cyan")
152 table.add_column("Status", style="green")
153 table.add_column("Notes", style="white")
154 table.add_row("TUI-1 spike", "in flight", "this run")
155 table.add_row("TUI-1 last-pager", "in flight", "Agent A")
156 table.add_row("TUI-1 runtime-prep", "in flight", "Agent B (this)")
157 blocks.append(_render(table))
158 blocks.append(_render(
159 "Long paragraph: "
160 + (
161 "Lorem ipsum dolor sit amet, consectetur adipiscing elit, "
162 "sed do eiusmod tempor incididunt ut labore et dolore magna "
163 "aliqua. Ut enim ad minim veniam, quis nostrud exercitation. "
164 ) * 20
165 ))
166 blocks.append(_render(
167 # OCT-LINT: disable=no_hardcoded_secrets reason="TUI render-sanity sample text with wide Unicode and emoji; no credential material (operator-ruled 2026-09-28)"
168 "Wide unicode sanity: "
169 "中文 日本語 한국어 — combining marks: café, naïve, résumé. "
170 "Emoji: \U0001F600 \U0001F525 \U0001F4DD."
171 ))
172 return blocks
173
174
175# ── Stream simulator ──────────────────────────────────────────────────────
176
177
179 """Background thread that appends fake streaming chunks.
180
181 Polls a :class:`threading.Event` between chunks so SIGINT (or any
182 explicit ``.stop()`` call) cancels mid-stream cleanly.
183 """
184
185 __slots__ = ("_stop", "_thread", "_rate", "_on_chunk")
186
187 def __init__(self, on_chunk: "callable[[str], None]", rate_per_sec: int) -> None:
188 rate = min(max(int(rate_per_sec), 1), MAX_CHUNKS_PER_SECOND)
189 self._rate = rate
190 self._on_chunk = on_chunk
191 self._stop = threading.Event()
192 self._thread: Optional[threading.Thread] = None
193
194 def start(self) -> None:
195 func = "_StreamSimulator.start"
196 if self._thread is not None and self._thread.is_alive():
197 _dbg("OC_CHAT.TUI_SPIKE", func, "already running", 3)
198 return
199 self._stop.clear()
200 self._thread = threading.Thread(target=self._loop, daemon=True, name="tui-spike-stream")
201 self._thread.start()
202 _dbg("OC_CHAT.TUI_SPIKE", func, f"started @ {self._rate} chunks/s", 2)
203
204 def stop(self) -> None:
205 func = "_StreamSimulator.stop"
206 self._stop.set()
207 _dbg("OC_CHAT.TUI_SPIKE", func, "stop requested", 3)
208
209 def is_running(self) -> bool:
210 return self._thread is not None and self._thread.is_alive()
211
212 def _loop(self) -> None:
213 func = "_StreamSimulator._loop"
214 interval = 1.0 / self._rate
215 idx = 0
216 words = (
217 "The quick brown fox jumps over the lazy dog. ".split()
218 + "Spike chunk".split()
219 )
220 while not self._stop.is_set():
221 chunk = words[idx % len(words)] + " "
222 idx += 1
223 try:
224 self._on_chunk(chunk)
225 except Exception as exc:
226 _dbg("OC_CHAT.TUI_SPIKE", func,
227 f"SYSTEM_ERROR: on_chunk: {type(exc).__name__}: {exc}",
228 1, override=True)
229 break
230 time.sleep(interval)
231 _dbg("OC_CHAT.TUI_SPIKE", func, f"loop ended after {idx} chunks", 2)
232
233
234# ── Application factory ───────────────────────────────────────────────────
235
236
238 *,
239 sample_blocks: list[str] | None = None,
240 # OCT-LINT: disable=no_hardcoded_secrets reason="default status-line help text naming the key bindings; no credential material (operator-ruled 2026-09-28)"
241 initial_status: str = "Ready. Ctrl+S start stream, Ctrl+Q quit, PgUp/PgDn scroll.",
242 input: object | None = None,
243 output: object | None = None,
244) -> "tuple[object, _StreamSimulator]":
245 """Construct the prompt_toolkit Application + its stream simulator.
246
247 Returns ``(application, stream_simulator)``. The application is not
248 yet running — call ``application.run()`` to drive it. Splitting the
249 factory from :func:`main` keeps the wiring inspectable from a
250 headless test (see :mod:`oc_chat.tests.unit.test_tui_spike`).
251
252 Headless test pattern (no real terminal needed)::
253
254 from prompt_toolkit.input import create_pipe_input
255 from prompt_toolkit.output import DummyOutput
256
257 with create_pipe_input() as pipe_input:
258 app, _ = build_application(input=pipe_input, output=DummyOutput())
259 # ... drive app, inspect state ...
260 """
261 func = "build_application"
262 # Lazy import so module import doesn't require prompt_toolkit when
263 # we're only testing the simulator class.
264 from prompt_toolkit.application import Application
265 from prompt_toolkit.formatted_text import ANSI
266 from prompt_toolkit.key_binding import KeyBindings
267 from prompt_toolkit.layout import HSplit, Layout, Window
268 from prompt_toolkit.layout.controls import FormattedTextControl
269 from prompt_toolkit.layout.dimension import Dimension as D
270
271 blocks = sample_blocks if sample_blocks is not None else _build_sample_blocks()
272 conversation_buffer: list[str] = list(blocks)
273 status_buffer = [initial_status]
274
275 def _conversation_text() -> "ANSI":
276 return ANSI("\n".join(conversation_buffer))
277
278 def _header_text() -> "ANSI":
279 return ANSI(
280 # OCT-LINT: disable=no_hardcoded_secrets reason="ANSI-styled TUI header text naming the key bindings; no credential material (operator-ruled 2026-09-28)"
281 "\x1b[1m[OC Chat TUI feasibility spike]\x1b[0m "
282 f"chunks={sum(1 for _ in conversation_buffer)} "
283 "Ctrl+S: stream Ctrl+C: cancel Ctrl+Q: quit"
284 )
285
286 def _input_text() -> "ANSI":
287 return ANSI(status_buffer[-1])
288
289 conversation_control = FormattedTextControl(text=_conversation_text, focusable=True)
290 header_control = FormattedTextControl(text=_header_text, focusable=False)
291 input_control = FormattedTextControl(text=_input_text, focusable=False)
292
293 # Scrollable middle pane via prompt_toolkit's built-in vertical
294 # scroll on Window — we provide our own PgUp/PgDn bindings further
295 # down to drive the scroll position deterministically.
296 conversation_window = Window(
297 content=conversation_control,
298 wrap_lines=True,
299 always_hide_cursor=True,
300 dont_extend_height=False,
301 )
302
303 layout = Layout(
304 HSplit([
305 Window(content=header_control, height=D.exact(1)),
306 conversation_window,
307 Window(content=input_control, height=D.exact(1)),
308 ]),
309 focused_element=conversation_window,
310 )
311
312 kb = KeyBindings()
313 simulator_ref: list["_StreamSimulator | None"] = [None]
314
315 def _append_chunk(chunk: str) -> None:
316 """Append a chunk from the worker thread (event-loop safe)."""
317 if not conversation_buffer or not conversation_buffer[-1].endswith("\n"):
318 if conversation_buffer:
319 conversation_buffer[-1] = conversation_buffer[-1] + chunk
320 else:
321 conversation_buffer.append(chunk)
322 else:
323 conversation_buffer.append(chunk)
324 # Trigger a redraw from the worker thread safely.
325 app = application
326 if app is not None and app.is_running:
327 app.invalidate()
328
329 simulator = _StreamSimulator(on_chunk=_append_chunk, rate_per_sec=40)
330 simulator_ref[0] = simulator
331
332 @kb.add("c-q")
333 def _exit(event: object) -> None:
334 func = "build_application._exit"
335 _dbg("OC_CHAT.TUI_SPIKE", func, "Ctrl+Q pressed — exiting", 2)
336 simulator.stop()
337 event.app.exit() # type: ignore[attr-defined]
338
339 @kb.add("c-s")
340 def _start_stream(event: object) -> None:
341 func = "build_application._start_stream"
342 _dbg("OC_CHAT.TUI_SPIKE", func, "Ctrl+S pressed — starting stream", 3)
343 status_buffer[-1] = "Streaming… Ctrl+C cancels."
344 event.app.invalidate() # type: ignore[attr-defined]
345 simulator.start()
346
347 @kb.add("c-c")
348 def _cancel_stream(event: object) -> None:
349 # Cooperative cancel — does NOT exit the app.
350 func = "build_application._cancel_stream"
351 _dbg("OC_CHAT.TUI_SPIKE", func, "Ctrl+C pressed — cancelling stream", 3)
352 if simulator.is_running():
353 simulator.stop()
354 status_buffer[-1] = "Stream cancelled. Ctrl+S to restart, Ctrl+Q to quit."
355 else:
356 status_buffer[-1] = "No active stream. Ctrl+S to start, Ctrl+Q to quit."
357 event.app.invalidate() # type: ignore[attr-defined]
358
359 @kb.add("pageup")
360 def _scroll_up(event: object) -> None:
361 # Reset to top is fine for spike; real impl would track offset.
362 info = conversation_window.render_info
363 if info is not None:
364 conversation_window.vertical_scroll = max(
365 0, conversation_window.vertical_scroll - info.window_height + 1
366 )
367 event.app.invalidate() # type: ignore[attr-defined]
368
369 @kb.add("pagedown")
370 def _scroll_down(event: object) -> None:
371 info = conversation_window.render_info
372 if info is not None:
373 conversation_window.vertical_scroll += info.window_height - 1
374 event.app.invalidate() # type: ignore[attr-defined]
375
376 @kb.add("home")
377 def _scroll_home(event: object) -> None:
378 conversation_window.vertical_scroll = 0
379 event.app.invalidate() # type: ignore[attr-defined]
380
381 @kb.add("end")
382 def _scroll_end(event: object) -> None:
383 conversation_window.vertical_scroll = 10_000 # clamped by prompt_toolkit
384 event.app.invalidate() # type: ignore[attr-defined]
385
386 application_kwargs: dict[str, object] = {
387 "layout": layout,
388 "key_bindings": kb,
389 "full_screen": True,
390 "mouse_support": False,
391 }
392 if input is not None:
393 application_kwargs["input"] = input
394 if output is not None:
395 application_kwargs["output"] = output
396 application = Application(**application_kwargs) # type: ignore[arg-type]
397
398 _dbg("OC_CHAT.TUI_SPIKE", func, "Application built", 2)
399 return application, simulator
400
401
402# ── Entry point ───────────────────────────────────────────────────────────
403
404
405def main() -> int:
406 """Run the spike. Returns a process exit code (0 on clean exit)."""
407 func = "main"
408 _dbg("OC_CHAT.TUI_SPIKE", func, "spike starting", 2)
409 application, simulator = build_application()
410 try:
411 application.run()
412 except KeyboardInterrupt:
413 # Outer Ctrl+C while NOT focused on the cancel binding — exit
414 # cleanly. The simulator may still be running; tear it down.
415 _dbg("OC_CHAT.TUI_SPIKE", func, "outer KeyboardInterrupt — exiting", 2, override=True)
416 finally:
417 simulator.stop()
418 _dbg("OC_CHAT.TUI_SPIKE", func, "spike exited cleanly", 2)
419 return 0
420
421
422if __name__ == "__main__":
423 import sys
424 sys.exit(main())
Optional[threading.Thread] _thread
Definition tui_spike.py:192
None __init__(self, "callable[[str], None]" on_chunk, int rate_per_sec)
Definition tui_spike.py:187
"tuple[object, _StreamSimulator]" build_application(*, list[str]|None sample_blocks=None, str initial_status="Ready. Ctrl+S start stream, Ctrl+Q quit, PgUp/PgDn scroll.", object|None input=None, object|None output=None)
Definition tui_spike.py:244
list[str] _build_sample_blocks()
Definition tui_spike.py:107