108 """Return a list of Rich-rendered ANSI strings.
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.
115 func =
"_build_sample_blocks"
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)
124 "# Heading (plain)\n",
125 "```py\nprint('hello')\n```\n",
126 "| a | b |\n|---|---|\n| 1 | 2 |\n",
129 "Lorem ipsum dolor sit amet, consectetur adipiscing elit. "
133 "Wide unicode: 你好 こんにちは 안녕하세요 — combining: á é\n",
136 def _render(renderable: object) -> str:
138 console = Console(file=buf, force_terminal=
True, color_system=
"truecolor", width=100)
139 console.print(renderable)
140 return buf.getvalue()
142 blocks: list[str] = []
143 blocks.append(_render(Markdown(
"# Markdown heading — render sanity")))
144 blocks.append(_render(Markdown(
146 "def hello(name: str) -> str:\n"
147 " return f'Hello, {name}!'\n"
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(
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. "
166 blocks.append(_render(
168 "Wide unicode sanity: "
169 "中文 日本語 한국어 — combining marks: café, naïve, résumé. "
170 "Emoji: \U0001F600 \U0001F525 \U0001F4DD."
239 sample_blocks: list[str] |
None =
None,
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.
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`).
252 Headless test pattern (no real terminal needed)::
254 from prompt_toolkit.input import create_pipe_input
255 from prompt_toolkit.output import DummyOutput
257 with create_pipe_input() as pipe_input:
258 app, _ = build_application(input=pipe_input, output=DummyOutput())
259 # ... drive app, inspect state ...
261 func =
"build_application"
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
272 conversation_buffer: list[str] = list(blocks)
273 status_buffer = [initial_status]
275 def _conversation_text() -> "ANSI":
276 return ANSI(
"\n".join(conversation_buffer))
278 def _header_text() -> "ANSI":
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"
286 def _input_text() -> "ANSI":
287 return ANSI(status_buffer[-1])
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)
296 conversation_window = Window(
297 content=conversation_control,
299 always_hide_cursor=
True,
300 dont_extend_height=
False,
305 Window(content=header_control, height=D.exact(1)),
307 Window(content=input_control, height=D.exact(1)),
309 focused_element=conversation_window,
313 simulator_ref: list[
"_StreamSimulator | None"] = [
None]
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
321 conversation_buffer.append(chunk)
323 conversation_buffer.append(chunk)
326 if app
is not None and app.is_running:
330 simulator_ref[0] = simulator
333 def _exit(event: object) ->
None:
334 func =
"build_application._exit"
335 _dbg(
"OC_CHAT.TUI_SPIKE", func,
"Ctrl+Q pressed — exiting", 2)
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()
348 def _cancel_stream(event: object) ->
None:
350 func =
"build_application._cancel_stream"
351 _dbg(
"OC_CHAT.TUI_SPIKE", func,
"Ctrl+C pressed — cancelling stream", 3)
352 if simulator.is_running():
354 status_buffer[-1] =
"Stream cancelled. Ctrl+S to restart, Ctrl+Q to quit."
356 status_buffer[-1] =
"No active stream. Ctrl+S to start, Ctrl+Q to quit."
357 event.app.invalidate()
360 def _scroll_up(event: object) ->
None:
362 info = conversation_window.render_info
364 conversation_window.vertical_scroll = max(
365 0, conversation_window.vertical_scroll - info.window_height + 1
367 event.app.invalidate()
370 def _scroll_down(event: object) ->
None:
371 info = conversation_window.render_info
373 conversation_window.vertical_scroll += info.window_height - 1
374 event.app.invalidate()
377 def _scroll_home(event: object) ->
None:
378 conversation_window.vertical_scroll = 0
379 event.app.invalidate()
382 def _scroll_end(event: object) ->
None:
383 conversation_window.vertical_scroll = 10_000
384 event.app.invalidate()
386 application_kwargs: dict[str, object] = {
390 "mouse_support":
False,
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)
398 _dbg(
"OC_CHAT.TUI_SPIKE", func,
"Application built", 2)
399 return application, simulator
"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)