feat(config-settings): replace the ANSI renderer with a curses front-end #141

Merged
rootiest merged 5 commits from feat/config-settings-curses-tui into main 2026-09-09 19:54:12 +00:00
Owner

Replaces the hand-rolled ANSI renderer behind config-settings with a Python curses front-end, and adds a filter that reaches sub-categories.

config-settings is now a launcher for scripts/config-settings-tui.py. The seven fish files that did cursor arithmetic are gone, along with the golden harness that had to pin their byte-exact output.

Why

Thirty-five commits went into the old renderer, and four of them fixed the same bug in different disguises: 608b022 (fish's read prompt shifted the cursor), 4210f3b (a shadow warning on stderr shifted the cursor), 93fc5e0 and 3c4f720 (line wrap broke the erase height). All four have one root cause — the panel was drawn inline in the scrollback and navigated with relative cursor moves (\e[<n>A), which makes cursor position global mutable state that any stray byte can corrupt.

curses uses the alternate screen with absolute addressing, so that entire class is unreachable. It also diffs its virtual screen against the physical one and emits only changed cells, which is what __config_settings_diff_redraw.fish was reimplementing by hand.

Architecture

The TUI is a child process, so it can neither read the session's global variables nor write them:

fish  --(__config_settings_state)-->  --state file  -->  TUI
fish  <--(source)-------------------  --emit  file  <--  TUI

Every edit is emitted as a call to __config_settings_apply or __config_settings_set_value, so list splitting, the SCROLLBACK_HISTORY_* export mirror, __fish_user_dots_link and the shadow-warning suppression all stay in the fish helpers that already owned them. The sub-category taxonomy is not duplicated in Python — it travels in the state dump, still sourced from __config_settings_subcats. The category, Sponge and Paths row tables move into Python, consolidating the two copies the fish renderers kept.

What changed for the user

New — the filter reaches sub-categories. / filters the current page on label and description. On the Universal and Session pages it also searches every category's sub-categories, listing hits as Category › Sub, so a sub-category can be toggled without drilling into its parent first.

│ PAGES          │ ▸ Integrations › History-logs              [ DEFAULT ]│
│ ▸ Universal    │     hist, logs                                       │
│   Session      │ ──────────────────────────────────────────────────── │
│   Sponge       │   Logging                                  [ DEFAULT ]│
│   Paths        │     scrollback capture & paru/yay AUR wrappers        │
│                │ ──────────────────────────────────────────────────── │
│ FILTER         │   Logging › Pkg-logs                       [ DEFAULT ]│
│ /log_          │     paru/yay AUR log wrappers                        │

Also new: a ? help overlay, mouse selection, a page sidebar, and a drill-down page that leads with the category's own toggle.

Behaviour change — edits apply on exit, not per keypress. This is forced by the child-process boundary, not a preference: a child cannot reach into its parent shell to set a global. The status bar shows a pending count, and q applies.

New dependency — python3 with curses. That module is stdlib on Arch, Fedora and a full Debian/Ubuntu python3; python3-minimal alone does not carry _curses. The launcher checks for both and names what is missing rather than failing inside the renderer. Called out in the README's Installation section.

Removed

File Lines
functions/__config_settings_draw{,_subcat,_value}.fish 434
functions/__config_settings_{frame,pagetab,read_key,diff_redraw}.fish 344
tests/test-config-settings-render.fish + its golden 5279

Gone with them: the four width tiers, the wrap-aware erase arithmetic, the stty/dd/od raw key reader, the panel-height bookkeeping and the hand-written redraw differ. Net: 1147 insertions, 6475 deletions, with more features than before.

The golden harness is deliberately not replaced. It existed because the fish renderer's field widths, dash counts and pad targets were hand-tuned arithmetic that any refactor had to reproduce byte-for-byte. curses owns that now, so there is nothing left to pin. The seam that does need pinning — dump in, fish script out — is covered instead.

Verification

  • 416/416 assertions in tests/run-tests.fish (the golden suite's cases removed, 7 new ones added covering the state dump, the emitted script, and the no-TTY guard)
  • 76/76 in docs/verify-manual.py
  • 227/227 files pass the syntax lint
  • Live end-to-end in a sandbox $HOME: the Universal page writes universal scope only (U1/G0), the Session page global scope only (U0/G1), exit 0
  • Rendered in a PTY at 50, 62, 100 and 120 columns; emitted scripts checked for a toggle, a filtered sub-category, an inline value edit, and the Dots-link relink

Notes for review

  • functions/__config_settings_state.fish discovers variable names with set --names and a prefix filter rather than carrying a list, so a new sub-category needs no edit there — only in __config_settings_subcats. The conf.d registry data table shares the op_ prefix and is explicitly excluded; there is a test for that.
  • The launcher refuses to open on an empty state dump. An empty dump would not fail loudly — every row would render as DEFAULT, which is indistinguishable from a config where nothing is set. That is a wrong answer rather than a missing one.
  • docs/fish-config.md and .1 are regenerated by CI on push, so they are not in this branch.
Replaces the hand-rolled ANSI renderer behind `config-settings` with a Python `curses` front-end, and adds a filter that reaches sub-categories. `config-settings` is now a launcher for `scripts/config-settings-tui.py`. The seven fish files that did cursor arithmetic are gone, along with the golden harness that had to pin their byte-exact output. ## Why Thirty-five commits went into the old renderer, and four of them fixed the same bug in different disguises: `608b022` (fish's `read` prompt shifted the cursor), `4210f3b` (a shadow warning on stderr shifted the cursor), `93fc5e0` and `3c4f720` (line wrap broke the erase height). All four have one root cause — the panel was drawn inline in the scrollback and navigated with relative cursor moves (`\e[<n>A`), which makes cursor position global mutable state that any stray byte can corrupt. curses uses the alternate screen with absolute addressing, so that entire class is unreachable. It also diffs its virtual screen against the physical one and emits only changed cells, which is what `__config_settings_diff_redraw.fish` was reimplementing by hand. ## Architecture The TUI is a child process, so it can neither read the session's global variables nor write them: ``` fish --(__config_settings_state)--> --state file --> TUI fish <--(source)------------------- --emit file <-- TUI ``` Every edit is emitted as a call to `__config_settings_apply` or `__config_settings_set_value`, so list splitting, the `SCROLLBACK_HISTORY_*` export mirror, `__fish_user_dots_link` and the shadow-warning suppression all stay in the fish helpers that already owned them. The sub-category taxonomy is **not** duplicated in Python — it travels in the state dump, still sourced from `__config_settings_subcats`. The category, Sponge and Paths row tables move into Python, consolidating the two copies the fish renderers kept. ## What changed for the user **New — the filter reaches sub-categories.** `/` filters the current page on label and description. On the Universal and Session pages it also searches every category's sub-categories, listing hits as `Category › Sub`, so a sub-category can be toggled without drilling into its parent first. ``` │ PAGES │ ▸ Integrations › History-logs [ DEFAULT ]│ │ ▸ Universal │ hist, logs │ │ Session │ ──────────────────────────────────────────────────── │ │ Sponge │ Logging [ DEFAULT ]│ │ Paths │ scrollback capture & paru/yay AUR wrappers │ │ │ ──────────────────────────────────────────────────── │ │ FILTER │ Logging › Pkg-logs [ DEFAULT ]│ │ /log_ │ paru/yay AUR log wrappers │ ``` Also new: a `?` help overlay, mouse selection, a page sidebar, and a drill-down page that leads with the category's own toggle. **Behaviour change — edits apply on exit, not per keypress.** This is forced by the child-process boundary, not a preference: a child cannot reach into its parent shell to set a global. The status bar shows a pending count, and `q` applies. **New dependency — `python3` with `curses`.** That module is stdlib on Arch, Fedora and a full Debian/Ubuntu `python3`; `python3-minimal` alone does not carry `_curses`. The launcher checks for both and names what is missing rather than failing inside the renderer. Called out in the README's Installation section. ## Removed | File | Lines | |---|---| | `functions/__config_settings_draw{,_subcat,_value}.fish` | 434 | | `functions/__config_settings_{frame,pagetab,read_key,diff_redraw}.fish` | 344 | | `tests/test-config-settings-render.fish` + its golden | 5279 | Gone with them: the four width tiers, the wrap-aware erase arithmetic, the `stty`/`dd`/`od` raw key reader, the panel-height bookkeeping and the hand-written redraw differ. Net: 1147 insertions, 6475 deletions, with more features than before. The golden harness is deliberately not replaced. It existed because the fish renderer's field widths, dash counts and pad targets were hand-tuned arithmetic that any refactor had to reproduce byte-for-byte. curses owns that now, so there is nothing left to pin. The seam that does need pinning — dump in, fish script out — is covered instead. ## Verification - `416/416` assertions in `tests/run-tests.fish` (the golden suite's cases removed, 7 new ones added covering the state dump, the emitted script, and the no-TTY guard) - `76/76` in `docs/verify-manual.py` - `227/227` files pass the syntax lint - Live end-to-end in a sandbox `$HOME`: the Universal page writes universal scope only (`U1/G0`), the Session page global scope only (`U0/G1`), exit 0 - Rendered in a PTY at 50, 62, 100 and 120 columns; emitted scripts checked for a toggle, a filtered sub-category, an inline value edit, and the Dots-link relink ## Notes for review - `functions/__config_settings_state.fish` discovers variable names with `set --names` and a prefix filter rather than carrying a list, so a new sub-category needs no edit there — only in `__config_settings_subcats`. The `conf.d` registry data table shares the `op_` prefix and is explicitly excluded; there is a test for that. - The launcher refuses to open on an empty state dump. An empty dump would not fail loudly — every row would render as `DEFAULT`, which is indistinguishable from a config where nothing is set. That is a wrong answer rather than a missing one. - `docs/fish-config.md` and `.1` are regenerated by CI on push, so they are not in this branch.
rootiest added 4 commits 2026-09-09 19:37:25 +00:00
Add scripts/config-settings-tui.py, a stdlib-curses prototype of the
config-settings interface, plus a gate that runs its --self-test.

The backend is stubbed: values live in an in-memory dict and nothing is
read from or written to fish variables. This is here to evaluate the
render engine and the interaction model before committing to a rewrite.

Why curses rather than more ANSI arithmetic:

  - No flicker, structurally. curses diffs its virtual screen against the
    physical one and emits only the changed cells, which is what
    __config_settings_diff_redraw.fish reimplements by hand.
  - Alternate screen plus absolute addressing. Stray output cannot desync
    the display, so the bug class behind 608b022 (fish's read prompt),
    4210f3b (a shadow warning on stderr), 93fc5e0 and 3c4f720 (line wrap
    breaking the erase height) cannot occur at all.
  - Resize is a repaint rather than wrap-factor arithmetic.
  - Overlays, panes, live filtering and mouse input cost a few lines each.

The layout departs from the current single panel: a page sidebar with a
live filter on the left, a scrolling detail pane on the right, a help
overlay on '?', and mouse selection. Sub-category drill-down, the
tri-state badges and the Sponge/Paths value rows all carry over.

Dependency note: python3 with the curses module. That is stdlib on Arch,
Fedora and a full Debian/Ubuntu python3; python3-minimal alone does not
carry _curses, so the test asserts the import.

The prototype is not wired into config-settings and nothing existing
changed.
config-settings is now a launcher for scripts/config-settings-tui.py,
drawn with Python's stdlib curses. The seven fish files that hand-rolled
the ANSI renderer are gone, along with the golden harness that had to pin
their byte-exact output.

The TUI is a child process, so it can neither read the session's global
variables nor write them. State goes in as a dump from the new
__config_settings_state; the edits come back as a fish script that
config-settings sources, which is what lets the Session page's `set -g`
land in the caller's shell instead of in a child that is about to exit.
Every edit is emitted as a call to __config_settings_apply or
__config_settings_set_value, so list splitting, the SCROLLBACK_HISTORY_*
export mirror and the shadow-warning suppression all stay in the fish
layer that already owned them.

The consequence, and the one behaviour change: edits are applied in one
batch on exit rather than on each keypress. The status bar shows a
pending count.

New: `/` filters the current page, and on the Universal and Session pages
it reaches into every category's sub-categories, listing hits as
"Category › Sub" so a sub-category can be toggled without drilling into
its parent first. Also a `?` help overlay, mouse selection, and a
drill-down page that leads with the category's own toggle.

Gone with the renderer: the four width tiers, the wrap-aware erase
arithmetic, the stty/dd/od raw key reader, the panel-height bookkeeping
and the hand-written redraw differ. curses owns all of it, and the alt
screen plus absolute addressing makes the desync class behind 608b022,
4210f3b, 93fc5e0 and 3c4f720 unreachable.

The sub-category taxonomy is NOT duplicated in Python: it travels in the
state dump, still sourced from __config_settings_subcats. The category,
Sponge and Paths row tables move into Python, consolidating the two
copies the fish renderers kept.

Dependency: python3 with curses. Stdlib on Arch, Fedora and a full
Debian/Ubuntu python3; python3-minimal alone lacks _curses. The launcher
checks for both and names what is missing. Called out in the README.

Verified: 416/416 assertions, plus a live end-to-end in a sandbox HOME
confirming the Universal page writes universal scope only (U1/G0) and the
Session page global scope only (U0/G1).
test_concat_section_five_stays_verbatim forbids backticks inside
generated Section 5 entries -- function headers are rendered verbatim, so
inline code markup there breaks the man page.
An empty dump does not fail loudly: the TUI renders every row as
DEFAULT, which is indistinguishable from a config where nothing is set.
That is a wrong answer rather than a missing one -- the user would be
looking at ON rows reported as DEFAULT -- so the launcher now checks the
dump is non-empty and bails with a message instead. The taxonomy alone
guarantees output on any working checkout. Also guard a failed mktemp,
which would otherwise send the dump to /state and hand an empty path to
rm -rf.

README: mention that / searches sub-categories across every category.
rootiest added 1 commit 2026-09-09 19:41:43 +00:00
The guard added in 208ad95 had no test. This is the failure worth
covering, because it is the one that does not announce itself: with an
empty dump every row renders as DEFAULT, indistinguishable from a config
where nothing is set, so the user toggles from a false baseline.

Reaching the guard needs a real terminal -- the isatty check sits in
front of it -- so the case runs fish under a pty via python3's stdlib
pty module, which this suite already depends on. An empty
__config_settings_state is shadowed in to fake the failure.

The pty reader's 15s deadline is load-bearing rather than defensive: if
the guard regresses, config-settings does not fail, it opens the TUI and
blocks on getch(), so an unbounded read would hang the suite instead of
failing it. Verified both ways -- passes with the guard, and with the
guard removed the deadline fires and the case fails with a legible
excerpt rather than a screenful of escape sequences.
rootiest merged commit 90aaa6dcda into main 2026-09-09 19:54:12 +00:00
rootiest deleted branch feat/config-settings-curses-tui 2026-09-09 19:54:12 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: rootiest/fish-config#141