""" views/config_tui.py — curses-based configuration wizard. Run: python -m views.config_tui Or: python views/config_tui.py """ import curses import sys import os # Ensure project root is on path when run directly sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from config.schema import AppConfig, DatabaseConfig, SecurityConfig, ServerConfig, TenorConfig from config.config import load, save # ── field descriptors ────────────────────────────────────── SECTION_FIELDS = [ ("server", "Server", [ ("host", "Host", str, "0.0.0.0"), ("port", "Port", int, 8080), ("debug", "Debug mode", bool, False), ]), ("security", "Security", [ ("secret", "Encryption secret", str, "default_secret_change_me"), ("salt", "Encryption salt", str, "default_salt_change_me"), ("admin_token", "Admin token", str, ""), ("rate_limit", "Rate limit (msg)", int, 5), ("rate_window", "Rate window (sec)", int, 10), ("max_message_length", "Max msg length", int, 2000), ]), ("db", "Database", [ ("engine", "Engine (sqlite|mariadb)", str, "sqlite"), ("sqlite_path", "SQLite path", str, "chat.db"), ("mariadb_host", "MariaDB host", str, "localhost"), ("mariadb_port", "MariaDB port", int, 3306), ("mariadb_user", "MariaDB user", str, "chat"), ("mariadb_password", "MariaDB password", str, "chat"), ("mariadb_database", "MariaDB database", str, "chat"), ]), ("tenor", "Tenor API", [ ("api_key", "API key", str, ""), ]), ] # ── helpers ──────────────────────────────────────────────── def draw_menu(std, items, selected, top, height, width, title): """Draw a scrollable list and return the new top index.""" std.attron(curses.A_REVERSE) std.addstr(0, 0, f" {title} ".center(width, "─")) std.attroff(curses.A_REVERSE) n = len(items) visible = height - 2 # header + status line # clamp top if selected < top: top = selected if selected >= top + visible: top = selected - visible + 1 top = max(0, min(top, n - visible)) for i in range(visible): idx = top + i if idx >= n: break line = f" {items[idx]}" if idx == selected: std.attron(curses.A_REVERSE) std.addstr(1 + i, 0, line.ljust(width)) std.attroff(curses.A_REVERSE) else: std.addstr(1 + i, 0, line.ljust(width)) # status bar std.attron(curses.A_REVERSE) std.addstr(height - 1, 0, f" {selected+1}/{n} ↑↓ scroll ← back ".ljust(width)) std.attroff(curses.A_REVERSE) return top def edit_field(std, label, value, field_type): """Inline edit a field value. Returns new value or None if cancelled.""" h, w = std.getmaxyx() prompt = f" {label}: " # clear a couple lines for y in range(2): std.addstr(h // 2 + y, 0, " " * w) std.addstr(h // 2, 0, prompt) # pre-fill with current value start = len(prompt) curses.echo() curses.curs_set(1) raw = std.getstr(h // 2, start, w - start - 2).decode("utf-8", errors="replace").strip() curses.noecho() curses.curs_set(0) if not raw: # keep old value return value if field_type == bool: return raw.lower() in ("1", "true", "yes", "y") if field_type == int: try: return int(raw) except ValueError: return value return raw def run_config_tui(std, cfg: AppConfig) -> AppConfig | None: curses.curs_set(0) curses.use_default_colors() h, w = std.getmaxyx() if h < 12 or w < 50: std.addstr(0, 0, "Terminal too small (min 50x12)") std.refresh() std.getch() return None # section list on the left section_names = [s[1] for s in SECTION_FIELDS] # display names section_data = [(s[0], s[2]) for s in SECTION_FIELDS] # (attr_name, fields) sel_section = 0 sel_field = 0 top_section = 0 while True: std.erase() h, w = std.getmaxyx() left_w = min(20, w // 3) # draw sections sname = section_names[sel_section] draw_menu(std, section_names, sel_section, top_section, h, left_w, "Sections") # draw field list on the right sec_attr, fields = section_data[sel_section] current_obj = getattr(cfg, sec_attr) field_labels = [f"{f[1]}: {getattr(current_obj, f[0])}" for f in fields] draw_menu(std, field_labels, sel_field, 0, h, w - left_w - 1, sname) key = std.getch() if key == curses.KEY_UP: sel_field = max(0, sel_field - 1) elif key == curses.KEY_DOWN: sel_field = min(len(fields) - 1, sel_field + 1) elif key == curses.KEY_LEFT: sel_section = max(0, sel_section - 1) sel_field = 0 top_section = 0 elif key == curses.KEY_RIGHT: sel_section = min(len(section_names) - 1, sel_section + 1) sel_field = 0 top_section = 0 elif key == ord("\n") or key == ord(" "): # edit selected field field_name, field_label, field_type, _ = fields[sel_field] old = getattr(current_obj, field_name) new = edit_field(std, field_label, old, field_type) if new is not None: setattr(current_obj, field_name, new) elif key == ord("\t") or key == ord("s") or key == ord("S"): # Save save(cfg) return cfg elif key == ord("q") or key == 27: # ESC return None def main_std(std): cfg = load() result = run_config_tui(std, cfg) if result: save(result) msg = " ✓ Config saved!" else: msg = " ✗ Cancelled." h, w = std.getmaxyx() std.addstr(h - 1, 0, msg.ljust(w - 1)) std.refresh() std.getch() def main(): curses.wrapper(main_std) if __name__ == "__main__": main()