Skip to content

Commit 81f8a7d

Browse files
Port ask_user_question TUI to termflow
The Rich-markup renderers were always the real UI here -- prompt_toolkit just wrapped their ANSI output. They now return raw ANSI strings, gain a left-panel header renderer, and QuestionTUI drives everything with a plain read_key loop per the headless-widget recipe (injectable key_source/output/size/use_alt_screen). The inactivity timeout rides the resize-poll heartbeat instead of a background asyncio task; Tab-peek drops out of the alt screen and waits for a key. QuestionUIState, the models, the handler, and the async picker seam are untouched. Old prompt_toolkit key-event tests replaced by scripted full-journey drives (fit-by-construction asserted on every painted line).
1 parent 5100fd6 commit 81f8a7d

6 files changed

Lines changed: 500 additions & 1181 deletions

File tree

code_puppy/tools/ask_user_question/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""Ask User Question tool for code-puppy.
22
33
This tool allows agents to ask users interactive multiple-choice questions
4-
through a terminal TUI interface. Uses prompt_toolkit for the split-panel
5-
UI similar to the /colors command.
4+
through a terminal TUI interface. Termflow split-panel UI: question
5+
headers on the left, the current question and its options on the right.
66
"""
77

88
from .handler import ask_user_question

code_puppy/tools/ask_user_question/renderers.py

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import shutil
1111
from typing import TYPE_CHECKING
1212

13-
from prompt_toolkit.formatted_text import ANSI
1413
from rich.console import Console
1514
from rich.markup import escape as rich_escape
1615

@@ -23,6 +22,7 @@
2322
BORDER_DOUBLE,
2423
CHECK_MARK,
2524
CURSOR_POINTER,
25+
CURSOR_TRIANGLE,
2626
HELP_BORDER_WIDTH,
2727
MAX_READABLE_WIDTH,
2828
OTHER_OPTION_DESCRIPTION,
@@ -42,7 +42,7 @@ def render_question_panel(
4242
state: QuestionUIState,
4343
colors: RichColors | None = None,
4444
available_width: int | None = None,
45-
) -> ANSI:
45+
) -> str:
4646
"""Render the right panel with the current question.
4747
4848
Wraps the inner renderer in a guard so a Rich markup error in user-supplied
@@ -62,14 +62,14 @@ def render_question_panel(
6262
f"[render error: {type(exc).__name__}: {exc}]",
6363
markup=False,
6464
)
65-
return ANSI(buffer.getvalue())
65+
return buffer.getvalue()
6666

6767

6868
def _render_question_panel_unsafe(
6969
state: QuestionUIState,
7070
colors: RichColors | None,
7171
available_width: int | None,
72-
) -> ANSI:
72+
) -> str:
7373
"""Actual rendering implementation. Caller wraps this in a guard."""
7474
if colors is None:
7575
colors = get_rich_colors()
@@ -191,10 +191,10 @@ def _render_question_panel_unsafe(
191191
remaining = state.get_time_remaining()
192192
console.print()
193193
console.print(
194-
f"{pad}[{colors.timeout_warning}] Timeout in {remaining}s - press any key to continue[/{colors.timeout_warning}]"
194+
f"{pad}[{colors.timeout_warning}] Timeout in {remaining}s - press any key to continue[/{colors.timeout_warning}]"
195195
)
196196

197-
return ANSI(buffer.getvalue())
197+
return buffer.getvalue()
198198

199199

200200
# Help overlay shortcut data: (section_name, [(primary_key, alt_key_or_None, description), ...])
@@ -234,7 +234,7 @@ def _render_question_panel_unsafe(
234234

235235
def _render_help_overlay(
236236
console: Console, buffer: io.StringIO, colors: RichColors
237-
) -> ANSI:
237+
) -> str:
238238
"""Render the help overlay using data-driven approach."""
239239
pad = PANEL_CONTENT_PADDING
240240
border = colors.help_border
@@ -270,7 +270,64 @@ def _render_help_overlay(
270270
)
271271
console.print(border_line)
272272

273-
return ANSI(buffer.getvalue())
273+
return buffer.getvalue()
274+
275+
276+
def render_header_panel(
277+
state: QuestionUIState,
278+
colors: RichColors | None = None,
279+
width: int = 30,
280+
) -> str:
281+
"""Render the left panel: question headers with progress checkmarks."""
282+
if colors is None:
283+
colors = get_rich_colors()
284+
buffer = io.StringIO()
285+
console = Console(
286+
file=buffer,
287+
force_terminal=True,
288+
width=max(width, 10),
289+
legacy_windows=False,
290+
color_system="truecolor",
291+
no_color=False,
292+
)
293+
pad = PANEL_CONTENT_PADDING
294+
console.print(f"{pad}[{colors.header}]Questions[/{colors.header}]")
295+
console.print()
296+
for i, question in enumerate(state.questions):
297+
is_current = i == state.current_question_index
298+
is_answered = state.is_question_answered(i)
299+
cursor = f"{CURSOR_TRIANGLE} " if is_current else " "
300+
status = f"{CHECK_MARK} " if is_answered else " "
301+
header = rich_escape(question.header)
302+
if is_answered:
303+
style = colors.selected
304+
elif is_current:
305+
style = colors.cursor
306+
else:
307+
style = colors.description
308+
if style:
309+
console.print(f"{pad}{cursor}[{style}]{status}{header}[/{style}]")
310+
else:
311+
console.print(f"{pad}{cursor}{status}{header}")
312+
console.print()
313+
console.print(
314+
f"{pad}[{colors.help_key}]{ARROW_LEFT}{ARROW_RIGHT}[/{colors.help_key}]"
315+
f"[{colors.description}] Switch question[/{colors.description}]"
316+
)
317+
console.print(
318+
f"{pad}[{colors.help_key}]{ARROW_UP}{ARROW_DOWN}[/{colors.help_key}]"
319+
f"[{colors.description}] Navigate options[/{colors.description}]"
320+
)
321+
console.print()
322+
console.print(
323+
f"{pad}[{colors.help_key}]Ctrl+S[/{colors.help_key}]"
324+
f"[{colors.description}] Submit[/{colors.description}]"
325+
)
326+
console.print(
327+
f"{pad}[{colors.help_key}]Tab[/{colors.help_key}]"
328+
f"[{colors.description}] Peek behind[/{colors.description}]"
329+
)
330+
return buffer.getvalue()
274331

275332

276333
def _render_option(

code_puppy/tools/ask_user_question/terminal_ui.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Terminal UI for ask_user_question tool.
22
3-
Uses prompt_toolkit for a split-panel TUI similar to the /colors command.
3+
Termflow split-panel TUI (state machine here, event loop in tui_loop).
44
Left panel (20%): Question headers/tabs
55
Right panel (80%): Current question with options
66
@@ -320,8 +320,8 @@ async def interactive_question_picker(
320320
state.timeout_seconds = timeout_seconds
321321
set_awaiting_user_input(True)
322322

323-
# Suspend the agent-runtime key listener so prompt_toolkit owns stdin:
324-
# racing readers swallow ~half the keystrokes. Use the REFCOUNTED variant
323+
# Suspend the agent-runtime key listener so the widget owns stdin:
324+
# racing readers swallow ~half the keystrokes. Use the REFCOUNTED variant --
325325
# tui_loop nests suspended_run_ui() here, and a raw resume would wake the
326326
# listener while an outer suspension still held stdin. Local import for standalone callers.
327327
from code_puppy.agents._key_listeners import suspended_key_listener
@@ -330,7 +330,7 @@ async def interactive_question_picker(
330330
with suspended_key_listener():
331331
from .tui_loop import run_question_tui
332332

333-
# prompt_toolkit manages alt screen via full_screen=True
333+
# The widget manages raw mode + alt screen itself.
334334
return await run_question_tui(state)
335335
finally:
336336
set_awaiting_user_input(False)

0 commit comments

Comments
 (0)