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:
/home/user/lt/aesthetics/TUI.md— design rules, §17–§19 cover the ncurses model./home/user/lt/aesthetics/lib/tui_ncurses/tui_ncurses.c— reference implementation./home/user/lt/aesthetics/samples/ncurses/ncurses-tui.c— caller-pattern reference./home/user/lt/aesthetics/plans/tui-ncurses-library.md— library design rationale.- Existing migration target: previous continuous migration plan at
plans/continuous_tui.md.
1. Goal
Replace every continuous-style call site with the ncurses library so that nostr_terminal gains:
- True pinned header/footer panes (no header repaint flicker, no scrollback pollution).
- Hybrid keyboard model: arrow-key highlight + shortcut keys + Enter, all handled by the library.
- Library-owned menu/table loops;
ntonly dispatches on the returned index. - Modal dialogs (
tuin_confirm,tuin_prompt,tuin_notice) replacing inline prompts. - Robust crash/exit cleanup via
endwin()fromatexit/signal handlers. - Automatic
KEY_RESIZEhandling 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(andlibncurseswif we want wide-char in the future). - Static-build complexity:
Dockerfile.alpine-muslwill needncurses-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
-
Vendor the library. Copy
/home/user/lt/aesthetics/lib/tui_ncurses/tui_ncurses.h/home/user/lt/aesthetics/lib/tui_ncurses/tui_ncurses.cintoresources/tui_ncurses/.
-
Update
CMakeLists.txt:- Add
find_package(Curses REQUIRED)(orpkg_check_modules(NCURSES REQUIRED ncurses)). - Add
resources/tui_ncurses/tui_ncurses.cto thenttarget sources. - Add
resources/tui_ncursestotarget_include_directories. - Append
${CURSES_LIBRARIES}(orncurses) totarget_link_libraries. - For the static path (the
if(CMAKE_EXE_LINKER_FLAGS MATCHES "-static")branch), usencursesas a static lib.
- Add
-
Verify Alpine static build. Update
Dockerfile.alpine-muslto installncurses-static(andncurses-dev). -
Sanity test that
tui_ncurses.ccompiles standalone before anysrc/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.
-
Replace
include/tui.hwith 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 -
Delete
src/tui.centirely. 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 newsrc/nt_tui_adapter.c. -
Create
src/nt_tui_adapter.cimplementingnt_print,nt_run_external_editor,nt_frame,nt_status.nt_print: keep a staticint g_body_rowadvanced per call;wattrset(body, A_NORMAL);mvwprintw(body, g_body_row++, 0, ...);wnoutrefresh(body); doupdate();. Honor body height; clamp / scroll wheng_body_row >= getmaxy(body).nt_run_external_editor:tuin_cleanup()→ spawn →tuin_init(); the library'sg_initializedguard makes re-init idempotent.
Phase 2 — Core entry point
-
menu_maininsrc/main.c: replace thetui_begin_frame/tui_print_menu_item/tui_end_frame_with_promptblock with a staticTuiMenuItem MAIN_ITEMS[], persistentTuiMenuState, andtuin_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'}, }; -
Splash screen. Replace
tui_show_splashwith an ncurses-native version: render header with a "> Splash" breadcrumb, draw centered ASCII title intotuin_body_window(), footer ="Press any key to continue", thentuin_get_key(). -
menu_show_loaded_events(src/main.c): rewrite as a custom body-window screen with simple PgUp/PgDn scrolling. Usetuin_render_header+nt_printcalls +tuin_get_keyloop, exit ontuin_is_escape_key. -
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
-
Resize verification. Walk every screen and confirm header/footer reflow correctly. The library handles this in
tuin_get_keyand insidetuin_*_run; custom body loops must calltuin_render_header/tuin_render_footerafter aKEY_RESIZE. -
Crash cleanup.
tuin_init()registersSIGINT/SIGTERM/SIGSEGVhandlers callingtuin_cleanup(). Confirm by sending a SIGSEGV/SIGINT mid-session — terminal must remain usable. -
Editor bracketing. Confirm
nt_run_external_editorcleanly toggles curses on everymenu_write/menu_diaryinvocation. -
Docs.
- Rewrite
docs/tui_style.mdto describe the ncurses model: pinned chrome, library-owned loops, modal dialogs,NT_HKmacro,nt_printadapter. - Update
README.md: addlibncursesruntime requirement, mention loss of scrollback, screenshot refresh.
- Rewrite
-
Static build. Run the Alpine musl Docker build with
ncurses-static; fix any link order issues (-lncurses -ltinfomay be needed depending on distro). -
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
ntbuilds against system ncurses (dynamic) and againstncurses-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 frommenu_writeandmenu_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 insrc/. docs/tui_style.mddescribes the new conventions;README.mdnotes the new dependency.
9. Suggested implementation order
- Phase 0 — vendoring + CMake + Alpine static.
- Phase 1 — adapter shim (compiles but unused).
- Phase 2 —
main.cand splash (smallest end-to-end slice that proves the migration works). - Phase 3 — simple menus (login, profile, tweet, write).
- Phase 4 — tabular menus (relays first as the canonical reference, then follows/posts/notifications/todo/diary/ecash).
- Phase 5 — streaming screens (dm, live, ai).
- Phase 6 — docs, static build, version bump, release.
Each phase ends with a clean cmake --build and a manual smoke test before moving on.