Plugin tier lacks a shared TUI toolkit: alternate-screen/menu-runner boilerplate copy-pasted across 10 menu files (and already drifted)

Open
#452 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
55/100
Issue type
Refactor
Clarity
Mostly clear
Activity status
Quiet
Tech stack
python
Domain
cli, tooling

Research direction

Start with the duplicated alternate-screen blocks in command_line/mcp/install_menu.py, command_line/model_settings_menu.py, plugins/agent_skills/skills_menu.py, plugins/agent_skills/skills_install_menu.py, plugins/hook_manager/hooks_menu.py, and plugins/plugin_list/plugins_menu.py; use the listed rg command to find the remaining copies. Extract the shared menu runner and quit-binding behavior described in the issue, then update the affected menus so terminal cleanup and input flushing are consistent across them.

Written by the indexing model from the issue text.

Description

Theme

Every interactive menu in the repo re-implements the same prompt_toolkit "run a fullscreen-ish menu" dance by copy-paste. Because there is no shared helper, plugin authors clone core menus from command_line/, which is exactly what jscpd caught:

  • plugins/agent_skills/skills_menu.py:449-469command_line/mcp/install_menu.py:634-654
  • plugins/agent_skills/skills_menu.py:440-459plugins/plugin_list/plugins_menu.py:267-286
  • plugins/agent_skills/skills_install_menu.py:17-35skills_menu.py:8-26 (import/page-size preamble)
  • plugins/agent_skills/skills_install_menu.py:582-609command_line/model_settings_menu.py:915-942
  • plugins/hook_manager/hooks_menu.py:505-518command_line/model_settings_menu.py:927-942

The cloned block is always the same ~30-line sequence:

layout = Layout(root_container)
app = Application(layout=layout, key_bindings=kb, full_screen=False, mouse_support=False)
set_awaiting_user_input(True)
sys.stdout.write("\033[?1049h")   # enter alternate buffer
sys.stdout.write("\033[2J\033[H")
sys.stdout.flush()
time.sleep(0.05)
try:
    self.update_display()
    sys.stdout.write("\033[2J\033[H"); sys.stdout.flush()
    app.run(in_thread=True)
finally:
    sys.stdout.write("\033[?1049l")
    sys.stdout.flush()
    # (sometimes) termios.tcflush(...)
    set_awaiting_user_input(False)

rg -l '1049h' code_puppy/ shows 20 files containing this escape-sequence dance; at least 9 of them pair it with the full Application(...) + app.run(in_thread=True) boilerplate:

  • command_line/mcp/install_menu.py, command_line/mcp/custom_server_form.py, command_line/model_settings_menu.py, command_line/add_model_menu.py
  • plugins/agent_skills/skills_menu.py, plugins/agent_skills/skills_install_menu.py, plugins/hook_manager/hooks_menu.py, plugins/plugin_list/plugins_menu.py, plugins/prune/prune_menu.py

Divergence evidence (the clones are already drifting)

  • skills_menu.py / skills_install_menu.py / hooks_menu.py flush stale input via termios.tcflush(...) in finally; model_settings_menu.py and plugins_menu.py don't — they can replay buffered keypresses into the next prompt.
  • hooks_menu.py:498-503 wraps set_awaiting_user_input(True) in try/except Exception: pass; everyone else calls it bare. One copy got defensive, eight didn't.
  • Some copies time.sleep(0.1) after exit to "let the terminal settle", others don't.
  • Quit keybindings differ subtly: some bind q+escape+c-c to exit, mcp/install_menu.py treats escape/backspace as "back" — fine, but the shared part (c-c exits, alternate-buffer restore) is re-typed each time, so any fix (e.g. issue #428-style terminal-state bugs) must be applied 9+ times.

Proposed shared abstraction

Add code_puppy/tui/ (or command_line/tui_toolkit.py) exporting a context manager + runner so menus can't diverge on terminal hygiene:

# code_puppy/tui/menu_runner.py
@contextmanager
def alternate_screen(flush_stale_input: bool = True):
    set_awaiting_user_input(True)
    sys.stdout.write("\x1b[?1049h\x1b[2J\x1b[H"); sys.stdout.flush()
    time.sleep(0.05)
    try:
        yield
    finally:
        sys.stdout.write("\x1b[?1049l"); sys.stdout.flush()
        if flush_stale_input:
            _tcflush_stdin_safe()
        set_awaiting_user_input(False)

def run_menu(root_container, kb, update_display) -> None:
    app = Application(layout=Layout(root_container), key_bindings=kb,
                      full_screen=False, mouse_support=False)
    with alternate_screen():
        update_display()
        sys.stdout.write("\x1b[2J\x1b[H"); sys.stdout.flush()
        app.run(in_thread=True)

Plus a small add_quit_bindings(kb, on_quit) helper for the q/escape/c-c trio. Each menu file then shrinks by ~40 lines and inherits future terminal-state fixes for free. Given the CONTRIBUTING.md golden rule pushes new functionality into plugins, a sanctioned TUI toolkit is the only way plugin menus stop being forks of command_line/ menus.

Filed by Zen Reviewer C (code-puppy-60635a) — DRY review round

Dominant language
Python
Stars
814
Forks
278
Avg merge
2d 5h
Merged PRs (30d)
76

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from mpfaffenberger/code_puppy

All issues in mpfaffenberger/code_puppy

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.