Files
nostr_terminal/plans/ncurses_tui_migration.md
T

18 KiB

Migration Plan: tui_continuous → tui_ncurses

Migrate nostr_terminal from its current continuous-style TUI (custom code in src/tui.c modeled after /home/user/lt/aesthetics/lib/tui_continuous/tui_continuous.h) to the ncurses-based component library /home/user/lt/aesthetics/lib/tui_ncurses/tui_ncurses.h.

Reference inputs:


1. Goal

Replace every continuous-style call site with the ncurses library so that nostr_terminal gains:

  1. True pinned header/footer panes (no header repaint flicker, no scrollback pollution).
  2. Hybrid keyboard model: arrow-key highlight + shortcut keys + Enter, all handled by the library.
  3. Library-owned menu/table loops; nt only dispatches on the returned index.
  4. Modal dialogs (tuin_confirm, tuin_prompt, tuin_notice) replacing inline prompts.
  5. Robust crash/exit cleanup via endwin() from atexit/signal handlers.
  6. Automatic KEY_RESIZE handling inside the library loops.

Trade-offs accepted:

  • Loss of scrollback. Ncurses uses the alternate screen buffer; previous frames are gone on exit.
  • New runtime dependency: libncurses (and libncursesw if we want wide-char in the future).
  • Static-build complexity: Dockerfile.alpine-musl will need ncurses-static.

2. Architectural shift summary

Concern Current (src/tui.c, continuous-style) Target (tui_ncurses)
Refresh model Newline-fill repaint, scrollback preserved Pinned WINDOWs, wnoutrefresh + doupdate
Input loop ownership Caller owns (tui_get_key, manual switch) Library owns (tuin_menu_run returns index)
Header Re-emitted per frame True pinned WINDOW, never scrolls
Selection Shortcut keys only Highlight bar (Up/Down/Enter) and shortcut keys
Inline prompt tui_get_line() after content tuin_prompt() modal popup
Confirm Custom [y/n] loop tuin_confirm() modal popup
Hotkey markup ^_X^: ANSI converter ANSI \033[4mX\033[0m baked into label, plus TuiMenuItem.shortcut
Resize handling SIGWINCH flag + caller polls Library catches KEY_RESIZE and repaints
Dependency Pure libc + termios + ANSI libncurses

3. High-level file impact map

graph TD
    A[CMakeLists.txt] -->|add Curses dep| A1[link -lncurses]
    B[resources/tui_ncurses/<br>tui_ncurses.c, .h] -->|new vendored lib| B1[compiled into nt]
    C[src/tui.c] -->|delete| X((removed))
    D[include/tui.h] -->|replace with thin shim| D1[adapter macros + nt_print]
    E[src/main.c] -->|rewrite menu_main loop| E1[tuin_menu_run dispatch]
    F[src/menu_login.c] -->|rewrite| F1[tuin_prompt for fields]
    G[src/menu_*.c x14] -->|rewrite| G1[tuin_menu_run / tuin_table_run]
    H[src/editor.c] -->|bracket spawn| H1[cleanup, run, init]
    I[Dockerfile.alpine-musl] -->|add ncurses-static| I1[static build OK]
    J[docs/tui_style.md] -->|update| J1[ncurses conventions]
    K[README.md] -->|update| K1[runtime dep note]

4. Phase breakdown

Phase 0 — Build system & vendoring

  1. Vendor the library. Copy

  2. Update CMakeLists.txt:

    • Add find_package(Curses REQUIRED) (or pkg_check_modules(NCURSES REQUIRED ncurses)).
    • Add resources/tui_ncurses/tui_ncurses.c to the nt target sources.
    • Add resources/tui_ncurses to target_include_directories.
    • Append ${CURSES_LIBRARIES} (or ncurses) to target_link_libraries.
    • For the static path (the if(CMAKE_EXE_LINKER_FLAGS MATCHES "-static") branch), use ncurses as a static lib.
  3. Verify Alpine static build. Update Dockerfile.alpine-musl to install ncurses-static (and ncurses-dev).

  4. Sanity test that tui_ncurses.c compiles standalone before any src/ changes.

Phase 1 — Adapter layer

Goal: keep call-site churn minimal where possible. Replace include/tui.h with a thin shim that re-exports the ncurses API plus a few helpers tailored to nt.

  1. Replace include/tui.h with a shim:

    #ifndef TUI_H
    #define TUI_H
    #include "tui_ncurses.h"
    
    /* Hotkey label helpers — ANSI underline first char.
     * Use as: NT_HK("W", "rite")  ->  "\033[4mW\033[0mrite" */
    #define NT_HK(first, rest) "\033[4m" first "\033[0m" rest
    
    /* printf-like writer into the body window; appends newline.
     * Tracks an internal cursor row so successive calls stack. */
    void nt_print(const char *fmt, ...);
    void nt_print_reset(void);   /* reset body cursor to top + werase */
    
    /* Bracket external editor spawn. Caller passes a function pointer
     * that does the spawn; we cleanup curses before, re-init after. */
    int nt_run_external_editor(int (*spawn)(void *user), void *user);
    
    /* Convenience: standard frame for nt with current version + breadcrumb. */
    TuiFrame nt_frame(const char *breadcrumb);
    
    /* Convenience: standard footer status (logged-in user, etc.). */
    TuiStatus nt_status(void);
    
    #endif
    
  2. Delete src/tui.c entirely. Its functions either disappear (tui_print_menu_item, tui_begin_frame, tui_end_frame_with_prompt, tui_show_splash, tui_render_top_frame) or become trivial wrappers in a new src/nt_tui_adapter.c.

  3. Create src/nt_tui_adapter.c implementing nt_print, nt_run_external_editor, nt_frame, nt_status.

    • nt_print: keep a static int g_body_row advanced per call; wattrset(body, A_NORMAL); mvwprintw(body, g_body_row++, 0, ...); wnoutrefresh(body); doupdate();. Honor body height; clamp / scroll when g_body_row >= getmaxy(body).
    • nt_run_external_editor: tuin_cleanup() → spawn → tuin_init(); the library's g_initialized guard makes re-init idempotent.

Phase 2 — Core entry point

  1. menu_main in src/main.c: replace the tui_begin_frame / tui_print_menu_item / tui_end_frame_with_prompt block with a static TuiMenuItem MAIN_ITEMS[], persistent TuiMenuState, and tuin_menu_run. Dispatch on the returned index.

    static const TuiMenuItem MAIN_ITEMS[] = {
        {"Write",           'w'},
        {"Tweet",           't'},
        {"Profile",         'p'},
        {"Relays",          'r'},
        {"Follows",         'f'},
        {"Kind/event dump", 'k'},
        {"Notifications",   'n'},
        {"Blogs/posts",     'b'},
        {"Live feeds",      'l'},
        {"Message",         'm'},
        {"Todo",            'd'},
        {"Journal",         'j'},
        {"Ai",              'a'},
        {"Ecash",           'e'},
        {"Quit",            'q'},
    };
    
  2. Splash screen. Replace tui_show_splash with an ncurses-native version: render header with a "> Splash" breadcrumb, draw centered ASCII title into tuin_body_window(), footer = "Press any key to continue", then tuin_get_key().

  3. menu_show_loaded_events (src/main.c): rewrite as a custom body-window screen with simple PgUp/PgDn scrolling. Use tuin_render_header + nt_print calls + tuin_get_key loop, exit on tuin_is_escape_key.

  4. tui_set_window_title (OSC 2) is still useful and orthogonal to ncurses — keep it, move into the adapter file.

Phase 3 — Simple menus

Files where the current pattern is "print a few items, read one line, dispatch":

File Strategy
src/menu_login.c tuin_menu_run for method choice; tuin_prompt per credential field; tuin_notice for errors; tuin_confirm for "save key?"
src/menu_profile.c tuin_menu_run for field selection; tuin_prompt with default value pre-filled; tuin_confirm before publish
src/menu_tweet.c tuin_prompt for body (or bracketed editor); tuin_confirm to publish
src/menu_write.c nt_run_external_editor for compose; tuin_confirm to publish; tuin_notice on success/failure

Phase 4 — Tabular & list screens

Files with row-based content; convert to tuin_table_run:

File Strategy
src/menu_relays.c tuin_table_run returns selected relay index; sub-actions (NIP-11 fetch, RW toggle, delete) via secondary tuin_menu_run; NIP-11 detail via custom body-window scroll screen
src/menu_follows.c tuin_table_run for follow list; tuin_prompt to add npub; tuin_confirm to remove; tuin_confirm before publish
src/menu_posts.c tuin_table_run for blog list; selecting a row opens a body-window scroll screen for content
src/menu_notifications.c tuin_table_run with PgUp/PgDn paging; row selection opens detail body screen
src/menu_todo.c tuin_table_run; CRUD via tuin_prompt/tuin_confirm
src/menu_diary.c tuin_table_run for entry list; compose via nt_run_external_editor
src/menu_ecash.c nested tuin_menu_run (wallet → mints → proofs); tuin_prompt for amounts; tuin_confirm before send/receive

Phase 5 — Streaming/live screens (highest effort)

These need custom body-window loops with non-blocking input:

File Strategy
src/menu_dm.c tuin_table_run for inbox/threads; thread view = body-window scroll screen with nodelay(body, TRUE) for new-message poll; compose via tuin_prompt or editor
src/menu_live.c Custom body-window loop: werase → render current event buffer → wnoutrefresh → doupdate → tuin_get_key (configure wtimeout for periodic refresh); exit on escape
src/menu_ai.c Same pattern as live: stream tokens into body window, periodic redraw, escape to abort

For these, use:

WINDOW *body = (WINDOW *)tuin_body_window();
wtimeout(body, 100);  /* 100 ms tick */
for (;;) {
    /* refresh streaming state */
    werase(body);
    /* render */
    wnoutrefresh(body);
    tuin_render_footer(&status);
    doupdate();

    int k = wgetch(body);
    if (k == ERR) continue;                     /* timeout, redraw */
    if (k == KEY_RESIZE) { /* library handles */ continue; }
    if (tuin_is_escape_key(k)) break;
    /* handle other keys */
}

Phase 6 — Cross-cutting & release

  1. Resize verification. Walk every screen and confirm header/footer reflow correctly. The library handles this in tuin_get_key and inside tuin_*_run; custom body loops must call tuin_render_header/tuin_render_footer after a KEY_RESIZE.

  2. Crash cleanup. tuin_init() registers SIGINT/SIGTERM/SIGSEGV handlers calling tuin_cleanup(). Confirm by sending a SIGSEGV/SIGINT mid-session — terminal must remain usable.

  3. Editor bracketing. Confirm nt_run_external_editor cleanly toggles curses on every menu_write / menu_diary invocation.

  4. Docs.

    • Rewrite docs/tui_style.md to describe the ncurses model: pinned chrome, library-owned loops, modal dialogs, NT_HK macro, nt_print adapter.
    • Update README.md: add libncurses runtime requirement, mention loss of scrollback, screenshot refresh.
  5. Static build. Run the Alpine musl Docker build with ncurses-static; fix any link order issues (-lncurses -ltinfo may be needed depending on distro).

  6. Version bump. This is a breaking UX + dependency change — bump minor (or major) version via increment_and_push.sh.


5. Per-call-site translation table

Old API (include/tui.h) New equivalent
tui_view_t TuiFrame (with app_name, app_version, breadcrumb)
tui_init() tuin_init()
tui_cleanup() tuin_cleanup()
tui_install_resize_handler() (built into tuin_init)
tui_consume_resize_flag() (handled by library)
tui_set_app_title() Set TuiFrame.app_name per call
tui_clear_screen() werase(tuin_body_window())
tui_print(fmt, ...) nt_print(fmt, ...) (adapter)
tui_print_hr() mvwhline(body, row, 0, '=', w)
tui_print_centered(s) manual mvwprintw(body, row, (w-len)/2, ...)
tui_render_top_frame(view) tuin_render_header(&frame)
tui_centered_menu_start_col() n/a (library positions menu items)
tui_print_menu_item(label) put in TuiMenuItem.label array passed to tuin_menu_run
tui_begin_frame(view) tuin_render_header(&frame); werase(body)
tui_end_frame_with_prompt(p, buf, n) tuin_prompt(p, "", buf, n)
tui_show_splash() custom render + tuin_get_key()
tui_get_line(p, buf, n) tuin_prompt(p, default, buf, n)
tui_get_key() tuin_get_key()
tui_get_terminal_size(&w, &h) TuiSize s = tuin_terminal_size();
tui_set_window_title(s) keep as-is (OSC 2 escape, orthogonal to ncurses)
^_X^: markup NT_HK("X", "rest") macro → "\033[4mX\033[0mrest"

6. Risks & mitigations

Risk Mitigation
Loss of scrollback breaks "scroll back to see previous output" UX Document in README; provide Kind/event dump and similar as proper scrollable body screens
Static build fails (no ncurses-static in image) Update Dockerfile.alpine-musl early in Phase 0 and verify before Phase 2
Editor spawn corrupts terminal state nt_run_external_editor must tuin_cleanup() before execvp and tuin_init() after; test with vim, nano, $EDITOR empty
Streaming screens miss keystrokes Use wtimeout(body, N) rather than nodelay to balance responsiveness vs CPU
Hotkey collisions in long menus Audit MAIN_ITEMS shortcuts (15 entries) — currently w/t/p/r/f/k/n/b/l/m/d/j/a/e/q are unique, OK
tuin_menu_run doesn't render hotkey hints automatically Bake ANSI underline into labels via NT_HK macro; library's tuin_draw_styled_text already parses \033[4m...\033[0m
Breaking change for users on screen/tmux without 256-color ncurses degrades gracefully; verify on TERM=xterm, screen-256color, linux

7. Estimated complexity per file

File Complexity Reason
src/tui.c trivial delete
include/tui.h trivial replace with shim
src/main.c medium menu_main loop + splash + events dump
src/menu_write.c low mostly editor bracket
src/menu_tweet.c low one prompt + confirm
src/menu_login.c medium multi-method, error display
src/menu_profile.c medium multi-field form
src/menu_relays.c high table + NIP-11 sub-view + publish flow
src/menu_follows.c medium table + add/remove
src/menu_posts.c high table + scrollable detail
src/menu_notifications.c medium paged table
src/menu_todo.c medium table + CRUD
src/menu_diary.c medium table + editor bracket
src/menu_ai.c high streaming body-window loop
src/menu_ecash.c medium nested menus + prompts
src/menu_dm.c high inbox table + thread view + compose
src/menu_live.c high streaming feed loop
CMakeLists.txt low add Curses package + link
Dockerfile.alpine-musl low add ncurses-static
docs/tui_style.md low rewrite section
README.md trivial add dep note

8. Acceptance criteria

  • nt builds against system ncurses (dynamic) and against ncurses-static (Alpine musl Docker).
  • Every screen previously available is reachable, with equivalent or better UX.
  • Up/Down + Enter, plus existing single-key shortcuts, both work on every menu.
  • Resize during any screen does not corrupt layout.
  • Ctrl+C / kill -TERM / a forced segfault all leave the terminal usable (no stray raw-mode).
  • External editor (vim/nano) launches cleanly from menu_write and menu_diary, returns to a redrawn screen.
  • No references to tui_print_menu_item, tui_begin_frame, tui_end_frame_with_prompt, tui_view_t, ^_X^: markup remain in src/.
  • docs/tui_style.md describes the new conventions; README.md notes the new dependency.

9. Suggested implementation order

  1. Phase 0 — vendoring + CMake + Alpine static.
  2. Phase 1 — adapter shim (compiles but unused).
  3. Phase 2 — main.c and splash (smallest end-to-end slice that proves the migration works).
  4. Phase 3 — simple menus (login, profile, tweet, write).
  5. Phase 4 — tabular menus (relays first as the canonical reference, then follows/posts/notifications/todo/diary/ecash).
  6. Phase 5 — streaming screens (dm, live, ai).
  7. Phase 6 — docs, static build, version bump, release.

Each phase ends with a clean cmake --build and a manual smoke test before moving on.