Skip to content

feat: add TUI mode (code-puppy -t) via Textual#544

Open
mpfaffenberger wants to merge 9 commits into
mainfrom
feature/add-tui
Open

feat: add TUI mode (code-puppy -t) via Textual#544
mpfaffenberger wants to merge 9 commits into
mainfrom
feature/add-tui

Conversation

@mpfaffenberger

@mpfaffenberger mpfaffenberger commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Replacement for #530. The original PR was accidentally marked merged by a direct branch push, then cleanly removed from main. This PR restores the same TUI review diff; main does not currently contain the TUI commit.

Summary

Demos

https://github.com/mpfaffenberger/code_puppy/releases/download/pr-530-assets/puppy-tui.mp4

Spinner picker

spinner

Ask user question

ask-user-question

Steer

steer

Dangerous command dialog

dangerous-command

Vim support

vim-support
  • Adds a full Textual TUI (--tui flag) as a permanent dual-mode alongside the classic prompt_toolkit UI — same plugin/hook system, no cutover needed
  • Ports all classic screens (model, agent, plugins, hooks, skills, set, theme, etc.) to native Textual ModalScreens across Phases 0–4
  • Fixes 4 post-rebase parity gaps: spinner catalogue wiring, turn-boundary hooks (wiggum/herdr), live sub-agent panel, and trust ceremony in /plugins; 249 TUI tests pass, 8 new behavior tests added

What's included

TUI Foundation (Phases 0–4)

  • Scaffold Textual UI behind ui_mode flag; 3-layer resolver + /ui command
  • Renderer parity via capture bridge (reuses classic Rich formatting verbatim)
  • Real agent turns, cancel/steer, interactive modals, shell passthrough, live token streaming, prompt completion
  • Full menu kit: /model, /agent, /set, /diff, /colors, /autosave_load, /mcp, /judges, /add_model, /uc, /plugins, /hooks, /skills, /prune, /btw, /theme → all Textual ModalScreens
  • Vim editing mode for the prompt box
  • Legacy queue bridge; onboarding slide deck; drag-drop attachment chips + PDF support
  • Batch render pipeline to eliminate firehose stutter

Parity gap fixes (rebased onto main @ 0.0.605)

  • Gap code puppy author #3: _tick_spinner/_render_spinner now read live catalogue frames via _current_frames_and_interval() instead of deprecated shim; bare /spinner redirected with available names; /set spinner_style gets a FilterableListScreen picker instead of free-text input
  • Gap Feature/surprise 3 #4: _run_agent_turn fires on_interactive_turn_end + on_interactive_turn_cancel; ports classic's while True continuation loop so /goal and /wiggum work end-to-end in the TUI
  • Gap Request for stylesheet support #1: New subagent_panel_lines_changed hook + #subagent-panel Static widget — live per-sub-agent rows appear above the spinner while sub-agents are running
  • Gap Request support for URI resource access #2: /plugins TUI screen rewritten — shows untrusted/changed project plugins; new TrustCeremonyScreen (Textual ModalScreen) with file listing + trust confirmation

Test plan

  • checkout from this PR's branch
  • run ./scripts/code-puppy-dev -t
  • smoke test: submit prompts, check streaming response, Ctrl+Q to quit
  • /spinner zoomies → thinking spinner should show zoomies animation; /spinner alone → shows available names
  • /set → search "spinner" → Enter → spinner picker dropdown with names + descriptions
  • /plugins → project plugins in untrusted state should appear; Enter opens trust ceremony
  • try all the other slash commands
  • Fan out a sub-agent task → #subagent-panel widget appears above spinner with live rows
  • uv run pytest -q tests/tui/ → 249 passed

Notes

  • separation of tools calls output from the agent response, is by design, keeping agent response center stage, the rest is just noise.
  • the response is streaming formatted markdown ... including syntax highlighting, tables, headers, the whole 9 yards.
  • the TUI mode can also be run in a web browser, just run:
./scripts/code-puppy-dev --serve
# click on the link (e.g. http://localhost:8000)

@mpfaffenberger

Copy link
Copy Markdown
Owner Author

@dudeinthemirror — heads up: I accidentally merged the original PR (#530) while pushing another set of commits to main. I reverted that accidental merge from main and opened this replacement PR (#544) so the TUI work can continue through the normal review process. Sorry for the churn!

@dudeinthemirror
dudeinthemirror force-pushed the feature/add-tui branch 7 times, most recently from 3d68620 to 6924b8f Compare July 19, 2026 18:59
dudeinthemirror added 7 commits July 20, 2026 14:25
TUI Foundation (Phases 0–2)

  • Scaffold Textual UI behind ui_mode flag (Phase 0)
  • Add 3-layer ui_mode resolver + /ui command
  • Renderer parity via capture bridge (Phase 1)
  • Run real agent turns from the prompt (Phase 2a)
  • Cancel + steer control plane (Phase 2b)
  • Interactive request modals (Phase 2d)
  • Shell passthrough + live token streaming (Phase 2 polish)
  • Prompt completion for /command and @path (Phase 2e)

TUI Screens & Menus (Phase 3)

  • Menu kit + /model as a ModalScreen (Phase 3 foundation)
  • /agent switcher as a ModalScreen
  • /set as a two-step ModalScreen flow
  • Autosave load picker as a ModalScreen
  • FormScreen multi-field form kit
  • /diff color picker with swatches
  • /colors banner-color picker
  • /mcp install flow as Textual screens
  • /judges CRUD as Textual screens
  • /add_model manual form
  • Long-tail completers for /model, /agent, /mcp args
  • /uc tool browser as Textual screens
  • Onboarding slide deck + first-run hook
  • register_screen plugin hook (Phase 3 complete)

Phase 4: Legacy Bridge & Bus

  • Bridge the legacy queue + route approval via the bus
  • Route the last direct-stdout banners through the queue

Chat Surface & Streaming

  • Echo submitted prompt into scrollback
  • Show prompt echo as a tool-call-style banner
  • Unify scrollback into one flow; stream response inline
  • Stop dropping the first chunk of streamed response
  • Stop dropping characters in streamed responses
  • Stream ThinkingPart blocks into dim Static widgets
  • Animated  is thinking... spinner with token counter
  • Match classic spinner animation speed (0.05s/frame)
  • Show AGENT RESPONSE banner above agent's reply
  • Keep streamed response below tool output via inline anchor

UI Polish & Fixes

  • Minimalist chat surface (borderless log + prompt top rule)
  • Blend completion panel + footer into window background
  • Status bar + framed PROMPT turns
  • /history command + Ctrl+R, status line in footer row
  • Two-panel /diff picker with live syntax-highlighted preview
  • Two-panel /colors picker + configurable PROMPT banner
  • Two-panel /autosave_load picker with session preview
  • Two-panel /agent picker with details preview + actions
  • Two-panel /model_settings editor
  • Two-panel /set and /uc screens
  • Serve Textual UI to a browser via textual-serve
  • Framed status bar in footer row
  • Richer list modal + bare menu commands open on first Enter

Ports of Classic Commands to TUI

  • /plugins → Textual ModalScreen
  • /hooks → Textual ModalScreen
  • /skills → Textual ModalScreen browser
  • /prune → Textual multi-select ModalScreen
  • /theme → points to command palette in TUI
  • Port ask_user_question to split-panel QuestionModal
  • /btw → Textual ModalScreen

Vim Mode

  • Vim editing mode for the prompt box

Bug Fixes

  • Stop agent key-listener racing Textual; native Ctrl+X
  • Insert newline on Shift+Enter / Ctrl+J
  • Scrollbar-safe capture width + responsive /tools modal
  • Form buttons show labels + add Esc hint
  • Open no-arg menu commands on single Enter
  • Surface plugin custom commands in completion
  • Mirror classic statusline exactly in TUI status bar
  • Clean up /plugins row labels (drop truncated [buil] tag)
  • Flush uncommitted 'Other' text on question modal submit
  • Show CODE PUPPY logo + help banner at TUI startup
  • Move 'cooper is ready' to bottom of startup banner

Attachments

  • Drag-drop/paste attachment chips + PDF support

Performance

  • Batch render-pipeline writes to kill firehose stutter

Config & UI Mode

  • ui_mode setting takes interactive/tui (not true/false)
  • -i/--interactive overrides ui_mode=tui from config

TUI Parity Gap Fixes (rebased onto main @ 0.0.605)

All 249 TUI tests pass; 8 new behavior tests added
(tests/tui/test_parity_gaps.py).

Gap #3 — Spinner catalogue wiring:
  • tui/app.py: _tick_spinner/_render_spinner now read
_current_frames_and_interval()
    from puppy_spinner instead of the deprecated static FRAMES shim —
custom /spinner
    choices now animate correctly in the TUI
  • puppy_spinner/register_callbacks.py: add register_screen guard so
bare /spinner
    shows available names inline instead of launching the prompt_toolkit
picker
    (which would corrupt the Textual screen)
  • /set spinner_style: replace free-text input with a live
FilterableListScreen
    showing all spinner names, first frame sample, and description —
Enter to
    pick, Esc to cancel (tui/screens/set_picker.py)

Gap #4 — Turn-boundary hooks:
  • tui/app.py: _run_agent_turn now fires on_interactive_turn_end
(success + error)
    and on_interactive_turn_cancel (result=None path) — unblocks wiggum
retry/judge
    loops and herdr idle-state tracking in the TUI
  • Ports classic's while-True continuation loop: if a plugin returns
    {"prompt": ..., "delay": ...} the TUI re-runs with the new prompt,
making
    /goal and /wiggum work end-to-end
  • action_cancel_turn fires on_interactive_turn_cancel before
cancelling the worker

Gap #1 — Sub-agent live panel:
  • callbacks.py: register new subagent_panel_lines_changed phase
  • subagent_panel/register_callbacks.py: _push_panel fires the new hook
in addition
    to the classic bottom-bar push
  • tui/app.py: add #subagent-panel Static widget above the spinner;
register/
    unregister _on_subagent_panel_lines_changed on mount/unmount;
thread-safe routing
    via call_from_thread or direct call when already on the event loop

Gap #2 — Trust ceremony in /plugins:
  • plugins_tui.py: complete rewrite — _load_rows now merges
get_project_plugin_status()
    for untrusted/changed project plugins; new TrustCeremonyScreen
(Textual ModalScreen)
    requires typing 'trust' before activating; _toggle branches on
status across all
    five states (loaded, disabled, untrusted, changed, error)

Other

  • Namespaced markdown commands + exec: directive + // normalization
  • scripts/upload_pr_video.py to attach large videos to PRs
  • Docs: revise TUI plan to permanent dual-mode (no cutover)
  • Docs: add Current Status & Resume Guide to the TUI plan

Follow-up: /queue + /spinner native TUI screens (register_screen audit)

A per-plugin register_screen audit found the last interactive-picker
plugins still missing (or faking) a real Textual screen. Both now have
native ModalScreens over their existing data layers; classic mode is
unchanged. Supersedes the inline-names stopgap noted under Gap #3 above.

Gap #5 (new) - /queue interactive menu was dead in the TUI:
  - steer_queue's /queue used prompt_toolkit (arrow_select_async /
    PromptSession) driven by suspended_run_ui; with no run-UI to suspend
    in Textual it no-oped / fought the screen. It had only a custom_command
    hook, no register_screen.
  - Add steer_queue/queue_tui.py: QueueScreen (view/add/edit/delete) over
    the same PauseController steer-queue; add/edit reuse the shared
    tui/screens/form.py FormScreen (DRY). Wired via register_screen.
  - Documented as Gap #5 (FIXED) in docs/TUI_PARITY_GAPS.md.

Gap #3 (completed) - real /spinner picker replaces the text redirect:
  - The earlier register_screen guard only printed "use /spinner <name>" -
    graceful degradation, not parity.
  - Add puppy_spinner/spinner_tui.py: SpinnerScreen, a native live-preview
    modal that animates each spinner at its own speed, with a speed dial
    (-/+), i to init spinners.json, and Enter/OptionSelected to apply.
    Reads/writes the same catalogue / set_active layer and reuses the
    classic picker's _step_interval speed-grid math (DRY).
  - Fix Enter wiring: OptionList swallows Enter for OptionSelected, so the
    screen-level binding never fired - handle on_option_list_option_selected.
  - Part (b) confirmed already fixed (thinking-spinner reads active style);
    Gap #3 now fully closed in docs/TUI_PARITY_GAPS.md.

Also:
  - tui/app.py submit_prompt now persists submissions to the shared
    prompt-history file (HistoryStore) so /history reflects TUI prompts;
    classic got this free via prompt_toolkit FileHistory, the Textual
    prompt had no such hook.

Tests: +15 behavior tests
  - tests/plugins/test_steer_queue_tui.py (7)
  - tests/plugins/test_puppy_spinner_tui.py (8)

/queue UX improvements + idle queue drain for TUI

Ctrl+G shortcut:
  - Add Ctrl+G binding to tui/app.py BINDINGS + action_queue() delegating
    to get_menu_opener("queue") — appears in the Textual Footer alongside
    ^t steer. Key chosen as ctrl+u was already claimed by TextArea
    (delete-to-line-start).

Queue modal UX fixes:
  - Hint text "↑/↓ move" was wrong (arrows only navigate); corrected to
    "↑/↓ select".
  - Add Shift+↑ / Shift+↓ to reorder prompts in the queue (swap adjacent
    items via replace_pending_steer_queued); hint updated to
    "SHIFT+↑/↓ reorder". 4 new tests.

Idle queue drain in TUI:
  - After each successful turn _set_busy(False) now calls
    _maybe_run_next_queued() which pops and auto-submits the next queued
    prompt — mirrors cli_runner.py's idle loop. Only fires on clean
    completion (not on cancel/exception). 2 new tests.

Queued-prompt PROMPT banner:
  - Prompts injected between turns by the runtime (prepare_queued_steer_
    injection in _run_signals.py) now show a proper PROMPT banner in the
    TUI, identical to manually-typed prompts.
  - Root cause of previous attempt (bus polling delay): PromptEchoMessage
    via get_message_bus().emit() had ~10 ms latency vs stream deltas
    enqueued synchronously; banner arrived after the response.
  - Fix: prepare_queued_steer_injection now returns (content, echo_text)
    tuple; _runtime._do_run fires await on_stream_event("prompt_echo",
    {"text": echo_text}) before _follow_up_run() — synchronous, ordered
    before next turn's deltas by construction.
  - _on_stream_event handles "prompt_echo" by calling _finalize_stream()
    (closes the previous turn's open markdown widget) then
    enqueue_render(("prompt_echo", text)); _render_loop calls
    _echo_prompt(text). Without _finalize_stream(), the previous turn's
    _md_stream was still open and the ObjC response was appended into
    hello-world's widget with the banner landing after the combined blob.
  - Add PromptEchoMessage to messages.py (kept for future use; current
    between-turns path uses stream_event instead of the bus).
  - Tests: +2 idle-drain parity tests in test_parity_gaps.py, updated
    test_runtime_queued_steer.py for new return type.
Add the Green Screen palette, improve Purple Puppy contrast, paginate the theme picker, and make prompts plus Termflow markdown and syntax highlighting follow the active theme. Also align shutdown callbacks with their awaited async contract.
Add ~/.code_puppy/models.json (MODELS_FILE) to ModelFactory's extra
model sources, loaded first so extra_models.json and the OAuth catalogues
still win on name collisions -- the more specific/ephemeral source
overrides the broad user catalogue.
_start_keyboard_listener called the removed
_key_listeners.set_escape_handler, which crashed the moment any shell
command ran (TUI and classic) and meant the Ctrl+X/Ctrl+B shell chords
were never registered. Replace it with the _register_shell_chords() call
the docstring already promised and _stop_keyboard_listener was already
unregistering. Re-add the Ctrl+X chord hints to the classic startup
help.

Repair the accompanying test suite:
- Fix wrong mock patch targets that silently no-opped: get_current_agent
  in tests/mcp/conftest.py (patched the source, not the from-imported
  name) and emit_system_message in the cli_runner startup-help test.
- Align stale expectations with deliberate design changes: empty shipped
  models.json (model/summarization resolve to None), the theme thinking-
  banner icon, MCP session-binding messaging, and the new
  PromptEchoMessage messaging export.
The wait-loop broke on 'cooper123', which appears immediately in the
echoed '$ echo cooper123' command line, before the command output and
the 'Done' completion marker render. On slow CI runners it bailed early
and failed the 'Done' assertion. Wait on the 'Done' terminal marker
instead.
#6)

Rebasing feature/add-tui onto main pulled in two sub-agent-panel commits
(b9f5d12 removed the plugin's fixed row cap; 153063b re-added a
terminal-height clamp only inside the classic BarPainterMixin). _push_panel()
fans the same UNCAPPED line list to both the classic bottom bar (clamped) and
the TUI's subagent_panel_lines_changed hook (NOT clamped), so the TUI's Static
silently clipped overflow agents past its max-height with no indicator.

- Extract the clamp into a shared bar_painters.clamp_panel_lines(lines, budget)
  helper (single source of truth for the "... +N more" overflow behavior);
  BarPainterMixin._visible_panel_lines() now delegates to it (behavior
  unchanged).
- TUI _apply_subagent_panel() runs the uncapped list through the SAME helper
  with a height-relative budget (_subagent_panel_budget() = terminal_height -
  RESERVE, floored at MIN_ROWS), pinning widget max-height to the painted row
  count. Panel sits above #log (height:1fr), so growth only steals transcript
  rows, never the prompt/footer -- mirrors classic's height-scaling instead of
  a stingy fixed cap.

Tests: clamp_panel_lines contract + TUI overflow/no-overflow/height-scaling.
Docs: TUI_PARITY_GAPS.md Gap #6 (FIXED) + docs/manual-tests/subagent-panel.md.

Also fix two flaky/broken CI tests surfaced on this branch:

- conftest isolate_global_state_between_tests now repoints the
  customizable_commands plugin's module-level CONFIG_DIR (captured by value at
  import) alongside _COMMAND_DIRECTORIES[0], and restores both on teardown.
  Previously only the latter was overridden, so
  test_global_directory_in_command_directories saw the two drift apart and
  failed its os.path.join(rc.CONFIG_DIR, "commands") == [0] assertion.
- test_user_approval_routes_through_bus_in_tui now polls for the #opt-0 button
  node before clicking instead of clicking the instant the ConfirmModal lands
  on the screen stack (its buttons compose a pump-cycle later), fixing an
  intermittent textual NoMatches under CI load.
dudeinthemirror added 2 commits July 20, 2026 14:54
Post-rebase follow-up: after replaying the TUI branch onto latest main,
an audit of the classic-vs-TUI model-settings parity surfaced a bug where
retry overrides silently no-opped in the Textual UI.

The retry settings (retry_main_strategy, retry_main_max_attempts,
retry_subagent_strategy, retry_subagent_max_attempts) live in the shared
SETTING_DEFINITIONS, so the TUI displays them -- but its _save_setting /
_reset_setting helpers fell through to the generic set_model_setting store
instead of the dedicated retry_model_ namespace that ModelSettingsMenu
(and the retry engine) actually read. Edits were written to a key nothing
consumes, so they never took effect.

Reuse the classic _write_per_model_retry helper (DRY) rather than
reimplementing the namespace logic. Add tests locking in all three
routing backends (global setter / retry namespace / generic per-model).
- tests/mcp/conftest.py: remove unused ExitStack import (ruff F401)
- tests/plugins/test_chatgpt_oauth_utils.py: update expected supported_settings
  for gpt-5.6* models to include reasoning_context and reasoning_mode,
  matching source behavior in chatgpt_oauth/utils.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant