#!/usr/bin/env python3 # Copyright (C) 2026 Rootiest # SPDX-License-Identifier: AGPL-3.0-or-later # # SYNOPSIS # config-settings-tui.py [--state ] [--emit ] [--self-test] # # DESCRIPTION # Front-end for `config-settings`, rendered with Python's stdlib `curses`. # This process never touches fish state directly. It reads a state dump on # the way in and writes a fish script of the edits on the way out; the # `config-settings` function sources that script, so `set -g` lands in the # caller's shell rather than in a child that is about to exit. # # fish --(__config_settings_state)--> --state file --> this TUI # fish <--(source)------------------- --emit file <-- this TUI # # Every edit is emitted as a call to an existing helper -- # __config_settings_apply for scope toggles, __config_settings_set_value for # the Sponge and Paths rows -- so list splitting, the SCROLLBACK_HISTORY_* # export mirror and the shadow-warning suppression all stay in the fish # layer that already owns them. Nothing is applied until the TUI exits. # # The sub-category taxonomy is NOT duplicated here: it arrives in the state # dump, sourced from __config_settings_subcats. The category, Sponge and # Paths row tables do live here, consolidated from the three fish renderers # this replaces. # # ARGUMENTS # --state State dump to read. Without it, the dump is obtained by # running `fish -c __config_settings_state`. # --emit Write the resulting fish script here. Without it, the # script is printed to stdout after the TUI exits, applying # nothing -- useful for inspecting a session by hand. # --self-test Exercise the pure logic with no terminal and exit non-zero # on failure. Needs no TTY and no fish. # # EXIT STATUS # 0 Clean exit, or --self-test passed # 1 --self-test failed, or the state dump could not be obtained # # EXAMPLE # config-settings # the normal entry point # ./scripts/config-settings-tui.py # standalone, dry run # ./scripts/config-settings-tui.py --self-test import os import subprocess import sys from typing import NamedTuple # ncurses reads ESCDELAY at init; 25ms makes bare Esc feel instant instead of # the 1s default. Must be set before curses is imported and initialised. os.environ.setdefault("ESCDELAY", "25") import curses # noqa: E402 RS, US = "\x1e", "\x1f" # record / unit separators used by the state dump # ╭──────────────────────────────────────────────────────────────────────────╮ # │ Row model │ # ╰──────────────────────────────────────────────────────────────────────────╯ class Row(NamedTuple): label: str # display label kind: str # tri | bool | int | list | path hint: str # shown in the value cell while the variable is unset desc: str # one-line description under the label var: str # fish variable name this row edits parent: str # owning category label, "" for a top-level row default: str # what ← / a blank inline edit resets the row to TRI = ("", "on", "off") # DEFAULT → ON → OFF BOOLS = ("", "true", "false") # Sponge/Paths convention # Category rows for the Universal and Session pages. Consolidated from # __config_settings_draw.fish (descriptions) and config-settings.fish # (variable names), which this file replaces. CATEGORIES = [ Row("Aliases", "tri", "", "shadows: ls→eza, cat→bat, cd→z, rm→trash", "__fish_config_op_aliases", "", ""), Row("Auto-exec", "tri", "", "Fisher bootstrap, themes, py-venv activate", "__fish_config_op_autoexec", "", ""), Row("Overrides", "tri", "", "vi-mode, bang-bang, PAGER, CDPATH, starship", "__fish_config_op_overrides", "", ""), Row("Integrations", "tri", "", "Kitty/WezTerm tab/split fns, notifications", "__fish_config_op_integrations", "", ""), Row("Logging", "tri", "", "scrollback capture & paru/yay AUR wrappers", "__fish_config_op_logging", "", ""), Row("Greeting", "tri", "", "fish_greeting & first-run welcome banner", "__fish_config_op_greeting", "", ""), Row("Master", "tri", "", "master off-switch: overrides all categories", "__fish_config_opinionated", "", ""), ] # Value rows. Variables, types and reset targets mirror the sponge_/paths_ # tables that config-settings.fish carried; the reset targets are load-bearing # (sponge reads sponge_delay and sponge_successful_exit_codes with no fallback, # so those must never be left unset, while the path rows tolerate being unset). SPONGE = [ Row("Delay", "int", "2", "entries kept before a failed command is purged", "sponge_delay", "", "2"), Row("Purge@exit", "bool", "false", "only purge history on shell exit", "sponge_purge_only_on_exit", "", ""), Row("Allow prev", "bool", "true", "keep commands that previously succeeded", "sponge_allow_previously_successful", "", ""), Row("OK codes", "list", "0", "exit codes treated as success", "sponge_successful_exit_codes", "", "0"), Row("Extra secret", "list", "(none)", "extra patterns scrubbed from history", "__fish_sponge_extra_sensitive", "", ""), ] PATHS = [ Row("Log dir", "path", "~/.terminal_history", "scrollback capture directory", "__fish_scrollback_history_dir", "", ""), Row("Log max", "int", "100", "max scrollback files retained", "__fish_scrollback_history_max_files", "", ""), Row("Dots path", "path", "(default)", "user-dots source directory", "__fish_user_dots_path", "", ""), Row("Dots link", "bool", "on", "symlink ~/.config/.user-dots/fish", "__fish_user_dots_symlink", "", ""), ] PAGES = ["Universal", "Session", "Sponge", "Paths"] SCOPES = {"Universal": "universal", "Session": "session", "Sponge": "universal", "Paths": "universal"} # Changing this variable has to re-run the linker, exactly as the fish # implementation did on every ←/→ over the Dots link row. DOTS_SYMLINK = "__fish_user_dots_symlink" def subcat_var(category_var, slug): """Sub-category variable name, matching config-settings.fish's derivation.""" return category_var + "_" + slug.replace("-", "_") # ╭──────────────────────────────────────────────────────────────────────────╮ # │ State dump │ # ╰──────────────────────────────────────────────────────────────────────────╯ class State: """Parsed __config_settings_state output plus the pending edit set. Two record types, RS-separated, fields US-separated: var a variable that is set sub