#!/usr/bin/env python3 """ config_tui.py — Interactive ncurses-based configuration editor for meshcore-bot. Features: • Read an existing config.ini or create one from config.ini.example • Resize-aware layout — redraws correctly when terminal is resized • Browse sections and keys with arrow-key navigation • Edit values in-place with a full-featured line editor • Help overlay (?) shows description + example lines for the current key • Validate config against known required/optional keys (v) • Migrate config to newer schema — add missing keys from example (m) • Save (s), quit with unsaved-changes guard (q / Esc) Usage: python3 scripts/config_tui.py [config.ini] python3 scripts/config_tui.py # auto-detects config.ini in project root Navigation: ↑ / ↓ move between keys in current section ← / → or Tab previous / next section Enter edit selected value ? help for current key (description + example from config.ini.example) s save v validate (show errors / warnings) m migrate (add missing keys/sections from example) q / Esc quit (asks to save if unsaved changes) """ import configparser import curses import sys import textwrap from pathlib import Path from typing import Optional # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def find_project_root() -> Path: try: here = Path(__file__).resolve().parent for candidate in [here, here.parent, here.parent.parent]: if (candidate / "pyproject.toml").exists() or (candidate / "meshcore_bot.py").exists(): return candidate return here.parent except Exception: return Path.cwd() def load_config(path: Path) -> configparser.ConfigParser: cfg = configparser.ConfigParser(allow_no_value=True, inline_comment_prefixes=None) cfg.optionxform = str # preserve case if path.exists(): try: cfg.read(str(path), encoding="utf-8") except (configparser.Error, OSError, UnicodeDecodeError) as e: # Return empty config rather than crash; caller may show a warning cfg._load_error = str(e) # type: ignore[attr-defined] return cfg def load_example_comments(example_path: Path) -> dict[str, dict[str, str]]: """Parse config.ini.example — return {section: {key: comment_text}}.""" comments: dict[str, dict[str, str]] = {} if not example_path.exists(): return comments try: current_section = "" pending: list[str] = [] with open(example_path, encoding="utf-8", errors="replace") as fh: for line in fh: stripped = line.strip() if stripped.startswith("[") and stripped.endswith("]"): current_section = stripped[1:-1] comments.setdefault(current_section, {}) pending = [] elif stripped.startswith("#"): pending.append(stripped[1:].strip()) elif "=" in stripped and not stripped.startswith("#"): key = stripped.split("=", 1)[0].strip() if current_section and pending: comments.setdefault(current_section, {})[key] = " ".join(pending) pending = [] else: pending = [] except (OSError, UnicodeDecodeError): pass return comments def load_example_lines(example_path: Path) -> dict[str, dict[str, str]]: """Return {section: {key: 'full raw line from example'}}.""" lines: dict[str, dict[str, str]] = {} if not example_path.exists(): return lines try: current_section = "" with open(example_path, encoding="utf-8", errors="replace") as fh: for line in fh: stripped = line.strip() if stripped.startswith("[") and stripped.endswith("]"): current_section = stripped[1:-1] lines.setdefault(current_section, {}) elif "=" in stripped and not stripped.startswith("#"): key = stripped.split("=", 1)[0].strip() if current_section: lines.setdefault(current_section, {})[key] = stripped except (OSError, UnicodeDecodeError): pass return lines def load_example_keys(example_path: Path) -> dict[str, dict[str, str]]: """Return {section: {key: default_value}} from config.ini.example.""" keys: dict[str, dict[str, str]] = {} if not example_path.exists(): return keys try: cfg = configparser.ConfigParser(allow_no_value=True) cfg.optionxform = str cfg.read(str(example_path), encoding="utf-8") for section in cfg.sections(): try: keys[section] = dict(cfg.items(section)) except configparser.Error: keys[section] = {} except (configparser.Error, OSError, UnicodeDecodeError): pass return keys def validate_config( cfg: configparser.ConfigParser, example_keys: dict[str, dict[str, str]], ) -> list[tuple[str, str, str]]: """Return list of (severity, section, message) issues.""" issues: list[tuple[str, str, str]] = [] try: required = { "Connection": {"connection_type"}, "Bot": {"bot_name"}, } for section, req_keys in required.items(): if not cfg.has_section(section): issues.append(("ERROR", section, f"Section [{section}] is missing")) continue for key in req_keys: if not cfg.has_option(section, key): issues.append(("ERROR", section, f"Required key '{key}' is missing")) for section in cfg.sections(): if section not in example_keys: continue try: section_opts = cfg.options(section) except configparser.Error: continue for key in section_opts: if key not in example_keys.get(section, {}): issues.append(("WARNING", section, f"Unknown key '{key}' (not in example)")) for section in example_keys: if not cfg.has_section(section): issues.append(("INFO", section, f"Optional section [{section}] not configured")) except Exception as e: issues.append(("ERROR", "", f"Validation error: {e}")) if not issues: issues.append(("INFO", "", "Config looks good — no errors found")) return issues def migrate_config( cfg: configparser.ConfigParser, example_keys: dict[str, dict[str, str]], example_comments: dict[str, dict[str, str]], ) -> list[str]: changes: list[str] = [] for section, keys in example_keys.items(): if not cfg.has_section(section): try: cfg.add_section(section) changes.append(f"Added section [{section}]") except configparser.DuplicateSectionError: pass except configparser.Error as e: changes.append(f"Error adding [{section}]: {e}") continue for key, default in keys.items(): if not cfg.has_option(section, key): try: cfg.set(section, key, default or "") changes.append(f"Added [{section}] {key} = {default!r}") except configparser.Error as e: changes.append(f"Error setting [{section}] {key}: {e}") return changes def save_config(cfg: configparser.ConfigParser, path: Path) -> None: try: path.parent.mkdir(parents=True, exist_ok=True) except (OSError, PermissionError) as e: raise OSError(f"Cannot create directory {path.parent}: {e}") from e try: with open(path, "w", encoding="utf-8") as fh: cfg.write(fh) except (OSError, PermissionError) as e: raise OSError(f"Cannot write {path}: {e}") from e # --------------------------------------------------------------------------- # TUI state # --------------------------------------------------------------------------- class TUIState: def __init__( self, cfg: configparser.ConfigParser, path: Path, example_keys: dict[str, dict[str, str]], example_comments: dict[str, dict[str, str]], example_lines: dict[str, dict[str, str]], ) -> None: self.cfg = cfg self.path = path self.example_keys = example_keys self.example_comments = example_comments self.example_lines = example_lines self.dirty = False self.sections: list[str] = [] try: self.sections = cfg.sections() except Exception: self.sections = [] self.section_idx = 0 self.key_idx = 0 self.focus: str = "sections" # "sections" or "keys" self.status_msg = "" self.status_attr = 0 def current_section(self) -> str: if not self.sections or self.section_idx >= len(self.sections): return "" return self.sections[self.section_idx] def current_keys(self) -> list[str]: s = self.current_section() if not s: return [] try: if self.cfg.has_section(s): return list(self.cfg.options(s)) except configparser.Error: pass return [] def clamp(self) -> None: if self.sections: self.section_idx = max(0, min(self.section_idx, len(self.sections) - 1)) else: self.section_idx = 0 keys = self.current_keys() if keys: self.key_idx = max(0, min(self.key_idx, len(keys) - 1)) else: self.key_idx = 0 def set_status(self, msg: str, error: bool = False) -> None: self.status_msg = msg try: self.status_attr = curses.color_pair(3) if error else curses.color_pair(2) except Exception: self.status_attr = 0 # --------------------------------------------------------------------------- # Drawing helpers # --------------------------------------------------------------------------- def _safe_addstr(win, row: int, col: int, text: str, attr: int = 0) -> None: """addstr that silently ignores out-of-bounds writes.""" try: h, w = win.getmaxyx() if row < 0 or row >= h or col < 0 or col >= w: return available = w - col - 1 if available <= 0: return if attr: win.addstr(row, col, text[:available], attr) else: win.addstr(row, col, text[:available]) except curses.error: pass except Exception: pass def _color(pair: int, fallback: int = 0) -> int: """Return color_pair(pair), falling back to fallback on terminals without color.""" try: return curses.color_pair(pair) except Exception: return fallback def draw_header(win, path: Path, dirty: bool) -> None: try: h, w = win.getmaxyx() flag = " [modified]" if dirty else "" header = f" meshcore-bot config — {path.name}{flag}" _safe_addstr(win, 0, 0, header.ljust(w - 1), _color(1) | curses.A_BOLD) except Exception: pass def draw_footer(win, state: TUIState) -> None: try: h, w = win.getmaxyx() if state.status_msg and h > 2: _safe_addstr(win, h - 2, 0, f" {state.status_msg} ".ljust(w - 1), state.status_attr) if state.focus == "sections": help_line = " ↑↓:section Tab/→:keys pane PgUp/Dn:cycle sections Enter:select s:save q:quit" else: help_line = " ↑↓:key Tab/←:sections Enter:edit r:rename a:add d:delete ?:help s:save v:validate m:migrate q:quit" if h > 1: _safe_addstr(win, h - 1, 0, help_line.ljust(w - 1), _color(1)) except Exception: pass def draw_sections_pane(win, state: TUIState, top: int, left: int, height: int, width: int, scroll: int) -> int: """Draw sections list. Returns updated scroll offset.""" try: if state.section_idx < scroll: scroll = state.section_idx if state.section_idx >= scroll + height: scroll = state.section_idx - height + 1 focused = (state.focus == "sections") for i, section in enumerate(state.sections): vis = i - scroll if vis < 0 or vis >= height: continue row = top + vis label = f" [{section}] " if i == state.section_idx: if focused: _safe_addstr(win, row, left, label.ljust(width), _color(4) | curses.A_BOLD) else: # Dimmer selection indicator when pane is not focused _safe_addstr(win, row, left, label.ljust(width), curses.A_BOLD) else: _safe_addstr(win, row, left, label.ljust(width)) except Exception: pass return scroll def draw_keys_pane( win, state: TUIState, top: int, left: int, height: int, width: int, scroll: int ) -> int: """Draw keys pane. Returns updated scroll offset.""" try: keys = state.current_keys() if not keys: _safe_addstr(win, top, left, " (no keys) ") return 0 if state.key_idx < scroll: scroll = state.key_idx if state.key_idx >= scroll + height: scroll = state.key_idx - height + 1 section = state.current_section() ex_sec = state.example_keys.get(section, {}) # Dynamic sections (no fixed keys in example) — never flag keys as unknown dynamic_section = section in state.example_keys and not ex_sec for i, key in enumerate(keys): vis = i - scroll if vis < 0 or vis >= height: continue row = top + vis try: val = state.cfg.get(section, key, fallback="") except configparser.Error: val = "(error)" in_example = dynamic_section or key in ex_sec marker = " " if in_example else "?" line = f"{marker} {key} = {val}" if i == state.key_idx: _safe_addstr(win, row, left, line.ljust(width), _color(5) | curses.A_BOLD) elif not in_example: _safe_addstr(win, row, left, line, _color(3)) else: _safe_addstr(win, row, left, line) except Exception: pass return scroll def draw_status_bar(win, state: TUIState, top: int, left: int, width: int, attr: int = 0) -> None: """Show section count / key count in the keys pane title bar.""" try: section = state.current_section() keys = state.current_keys() n_sec = len(state.sections) n_key = len(keys) focus_marker = ">" if state.focus == "keys" else " " title = f"{focus_marker}[{section}] key {state.key_idx + 1}/{n_key} (section {state.section_idx + 1}/{n_sec}) " _safe_addstr(win, top, left, title.ljust(width), attr or _color(1)) except Exception: pass def draw_hint_line(win, state: TUIState, top: int, left: int, width: int) -> None: """Show a one-line hint for the selected key below the keys pane.""" try: section = state.current_section() keys = state.current_keys() if not keys or state.key_idx >= len(keys): return key = keys[state.key_idx] comment = state.example_comments.get(section, {}).get(key, "") max_w = max(4, width - 3) if comment and len(comment) > max_w: hint = textwrap.shorten(comment, max_w, placeholder="…") elif comment: hint = comment else: hint = "(no description — press ? for full help)" _safe_addstr(win, top, left, f" {hint}", _color(2)) except Exception: pass # --------------------------------------------------------------------------- # Overlays # --------------------------------------------------------------------------- def show_overlay(stdscr, title: str, items: list[tuple[str, str, str]]) -> None: """Generic scrollable list overlay. Items: (tag, section, message).""" try: curses.curs_set(0) except curses.error: pass scroll = 0 while True: try: h, w = stdscr.getmaxyx() except curses.error: return if h < 6 or w < 20: try: stdscr.getch() except curses.error: pass return oh = max(4, min(h - 4, max(10, len(items) + 4))) ow = max(10, min(w - 4, 92)) dy = max(0, (h - oh) // 2) dx = max(0, (w - ow) // 2) # Ensure window fits within screen if dy + oh > h: oh = h - dy if dx + ow > w: ow = w - dx if oh < 4 or ow < 10: try: stdscr.getch() except curses.error: pass return try: win = curses.newwin(oh, ow, dy, dx) except curses.error: try: stdscr.getch() except curses.error: pass return try: win.keypad(True) win.clear() try: win.border() except curses.error: pass _safe_addstr(win, 0, 2, f" {title} (↑↓:scroll q/Esc/Enter:close) ", _color(1)) visible = max(1, oh - 2) for i, (tag, sec, msg) in enumerate(items): vis = i - scroll if vis < 0 or vis >= visible: continue row = 1 + vis if tag == "ERROR": attr = _color(3) elif tag == "WARNING": attr = _color(6) else: attr = _color(2) label = f" [{tag}] {sec}: {msg}" if sec else f" [{tag}] {msg}" _safe_addstr(win, row, 1, label, attr) win.refresh() except curses.error: pass try: ch = win.getch() except curses.error: ch = -1 try: del win stdscr.touchwin() except curses.error: pass if ch == curses.KEY_RESIZE: scroll = 0 continue elif ch in (ord("q"), ord("Q"), 27, 10, 13, curses.KEY_ENTER): break elif ch == curses.KEY_DOWN: scroll = min(scroll + 1, max(0, len(items) - visible)) elif ch == curses.KEY_UP: scroll = max(0, scroll - 1) elif ch == curses.KEY_NPAGE: scroll = min(scroll + visible, max(0, len(items) - visible)) elif ch == curses.KEY_PPAGE: scroll = max(0, scroll - visible) try: stdscr.touchwin() except curses.error: pass def show_key_help(stdscr, state: TUIState) -> None: """Show full help for the currently selected key.""" try: section = state.current_section() keys = state.current_keys() if not keys or state.key_idx >= len(keys): return key = keys[state.key_idx] comment = state.example_comments.get(section, {}).get(key, "(no description available)") example = state.example_lines.get(section, {}).get(key, "") try: current_val = state.cfg.get(section, key, fallback="") except configparser.Error: current_val = "(error reading value)" default_val = state.example_keys.get(section, {}).get(key, "") lines: list[tuple[str, str, str]] = [ ("INFO", "Section", section), ("INFO", "Key", key), ("INFO", "Current value", repr(current_val)), ("INFO", "Default (example)", repr(default_val)), ("INFO", "", ""), ("INFO", "Description", ""), ] for para in textwrap.wrap(comment, 72) or ["(none)"]: lines.append(("INFO", "", f" {para}")) if example: lines.append(("INFO", "", "")) lines.append(("INFO", "Example line", "")) lines.append(("INFO", "", f" {example}")) show_overlay(stdscr, f"Help: [{section}] {key}", lines) except Exception as e: state.set_status(f"Help error: {e}", error=True) # --------------------------------------------------------------------------- # Inline editor # --------------------------------------------------------------------------- _EDIT_VALUE_DEPTH = 0 _EDIT_VALUE_MAX_DEPTH = 20 def edit_value(stdscr, prompt: str, current: str) -> Optional[str]: """One-line editor dialog. Returns new value or None if cancelled.""" global _EDIT_VALUE_DEPTH _EDIT_VALUE_DEPTH += 1 if _EDIT_VALUE_DEPTH > _EDIT_VALUE_MAX_DEPTH: _EDIT_VALUE_DEPTH -= 1 return None try: return _edit_value_impl(stdscr, prompt, current) finally: _EDIT_VALUE_DEPTH -= 1 def _edit_value_impl(stdscr, prompt: str, current: str) -> Optional[str]: while True: try: h, w = stdscr.getmaxyx() except curses.error: return None dw = max(30, min(w - 4, 84)) dh = 6 dy = max(0, (h - dh) // 2) dx = max(0, (w - dw) // 2) if dy + dh > h or dx + dw > w or dh < 4 or dw < 10: return None try: win = curses.newwin(dh, dw, dy, dx) except curses.error: return None try: win.keypad(True) except curses.error: pass try: try: win.border() except curses.error: pass _safe_addstr(win, 0, 2, " Edit Value ", _color(1) | curses.A_BOLD) _safe_addstr(win, 1, 2, prompt[:dw - 4]) _safe_addstr(win, 2, 2, "─" * max(0, dw - 4)) _safe_addstr(win, 4, 2, "Enter=confirm Esc=cancel Ctrl-A=home Ctrl-E=end Ctrl-K=clear", _color(2)) win.refresh() except curses.error: pass buf = list(current) pos = len(buf) inner_w = max(1, dw - 4) result: Optional[str] = None try: curses.curs_set(1) except curses.error: pass def redraw_input() -> None: try: win.move(3, 2) win.clrtoeol() start = max(0, pos - inner_w + 1) display = "".join(buf)[start:start + inner_w] cursor_col = pos - start try: win.addstr(3, 2, display) win.move(3, 2 + cursor_col) except curses.error: pass win.refresh() except curses.error: pass try: redraw_input() except Exception: pass done = False resize_requested = False while not done: try: ch = win.getch() except curses.error: done = True break if ch == curses.KEY_RESIZE: resize_requested = True done = True elif ch in (curses.KEY_ENTER, 10, 13): result = "".join(buf) done = True elif ch == 27: # Esc done = True elif ch in (curses.KEY_BACKSPACE, 127, 8): if pos > 0: buf.pop(pos - 1) pos -= 1 elif ch == curses.KEY_DC: if pos < len(buf): buf.pop(pos) elif ch == curses.KEY_LEFT: pos = max(0, pos - 1) elif ch == curses.KEY_RIGHT: pos = min(len(buf), pos + 1) elif ch == curses.KEY_HOME or ch == 1: # Ctrl-A pos = 0 elif ch == curses.KEY_END or ch == 5: # Ctrl-E pos = len(buf) elif ch == 11: # Ctrl-K — clear to end buf = buf[:pos] elif ch == 21: # Ctrl-U — clear whole line buf = [] pos = 0 elif 32 <= ch < 256: try: buf.insert(pos, chr(ch)) pos += 1 except Exception: pass if not done: try: redraw_input() except Exception: pass try: curses.curs_set(0) except curses.error: pass try: del win stdscr.touchwin() stdscr.refresh() except curses.error: pass if resize_requested: # Terminal resized during edit — restart dialog preserving buffer return edit_value(stdscr, prompt, "".join(buf)) return result # --------------------------------------------------------------------------- # Key management helpers (add / rename / delete) # --------------------------------------------------------------------------- def action_add_key(stdscr, state: TUIState) -> None: """Prompt for a new key name and value, then add it to the current section.""" section = state.current_section() if not section: return new_key = edit_value(stdscr, f"[{section}] New key name", "") if not new_key: state.set_status("Add cancelled — no key name entered") return new_key = new_key.strip() if not new_key: state.set_status("Add cancelled — key name was blank") return if state.cfg.has_option(section, new_key): state.set_status(f"Key '{new_key}' already exists", error=True) return new_val = edit_value(stdscr, f"[{section}] {new_key} (value)", "") if new_val is None: state.set_status("Add cancelled") return try: state.cfg.set(section, new_key, new_val) state.dirty = True # Move selection to the newly added key keys = list(state.cfg.options(section)) try: state.key_idx = keys.index(new_key) except ValueError: pass state.set_status(f"Added [{section}] {new_key}") except configparser.Error as e: state.set_status(f"Cannot add key: {e}", error=True) def action_rename_key(stdscr, state: TUIState) -> None: """Rename the currently selected key (useful to change a scheduled-message time).""" section = state.current_section() keys = state.current_keys() if not keys or state.key_idx >= len(keys): return old_key = keys[state.key_idx] new_key = edit_value(stdscr, f"[{section}] Rename key (was: {old_key})", old_key) if new_key is None: state.set_status("Rename cancelled") return new_key = new_key.strip() if not new_key or new_key == old_key: state.set_status("Rename cancelled — unchanged") return if state.cfg.has_option(section, new_key): state.set_status(f"Key '{new_key}' already exists", error=True) return try: old_val = state.cfg.get(section, old_key, fallback="") except configparser.Error: old_val = "" try: state.cfg.remove_option(section, old_key) state.cfg.set(section, new_key, old_val) state.dirty = True # Re-select the renamed key new_keys = list(state.cfg.options(section)) try: state.key_idx = new_keys.index(new_key) except ValueError: state.key_idx = max(0, min(state.key_idx, len(new_keys) - 1)) state.set_status(f"Renamed '{old_key}' → '{new_key}'") except configparser.Error as e: state.set_status(f"Cannot rename key: {e}", error=True) def action_delete_key(stdscr, state: TUIState) -> None: """Delete the currently selected key after confirmation.""" section = state.current_section() keys = state.current_keys() if not keys or state.key_idx >= len(keys): return key = keys[state.key_idx] if not confirm(stdscr, f"Delete [{section}] {key}?"): state.set_status("Delete cancelled") return try: state.cfg.remove_option(section, key) state.dirty = True new_keys = state.current_keys() state.key_idx = max(0, min(state.key_idx, len(new_keys) - 1)) state.set_status(f"Deleted [{section}] {key}") except configparser.Error as e: state.set_status(f"Cannot delete key: {e}", error=True) # --------------------------------------------------------------------------- # Confirmation dialog # --------------------------------------------------------------------------- def confirm(stdscr, question: str) -> bool: """Y/N prompt. Returns True if user presses y/Y.""" try: h, w = stdscr.getmaxyx() dw = max(20, min(w - 4, 60)) dh = 4 dy = max(0, (h - dh) // 2) dx = max(0, (w - dw) // 2) if dy + dh > h or dx + dw > w: return False try: win = curses.newwin(dh, dw, dy, dx) except curses.error: return False try: try: win.border() except curses.error: pass _safe_addstr(win, 1, 2, question[:dw - 4], _color(6) | curses.A_BOLD) _safe_addstr(win, 2, 2, "Press Y to confirm, any other key to cancel.", _color(2)) win.refresh() ch = win.getch() except curses.error: ch = -1 finally: try: del win stdscr.touchwin() stdscr.refresh() except curses.error: pass return ch in (ord("y"), ord("Y")) except Exception: return False # --------------------------------------------------------------------------- # Main TUI loop # --------------------------------------------------------------------------- def tui_main(stdscr, state: TUIState) -> None: try: curses.curs_set(0) except curses.error: pass try: curses.start_color() curses.use_default_colors() curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE) # header / footer / titles curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_GREEN) # ok / hint / status curses.init_pair(3, curses.COLOR_WHITE, curses.COLOR_RED) # error / unknown key curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_CYAN) # selected section curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_YELLOW) # selected key curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_YELLOW) # warning / confirm except curses.error: pass # monochrome terminal — continue without color try: stdscr.keypad(True) except curses.error: pass # Per-pane scroll offsets sec_scroll = 0 key_scroll = 0 while True: try: stdscr.clear() h, w = stdscr.getmaxyx() except curses.error: continue if h < 12 or w < 50: _safe_addstr(stdscr, 0, 0, "Terminal too small — resize and press any key") try: stdscr.refresh() stdscr.getch() except curses.error: pass continue # ── Layout ────────────────────────────────────────────────────────── # Row 0 : header # Row 1 : section pane title | keys pane title bar # Rows 2..body_h+1 : section list | keys list (body_h rows) # body_h+2 : hint line # h-2 : status message # h-1 : key bindings footer # Total: 1+1+body_h+1+1+1 = body_h+5 = h → body_h = h-5 section_w = max(16, min(26, w // 5)) divider = section_w # column of the vertical divider keys_left = divider + 1 keys_w = max(1, w - keys_left - 1) body_h = max(1, h - 5) # rows of pane content hint_row = body_h + 2 # one row below the last pane row # ── Draw phase — wrapped so a single bad render doesn't abort ─────── try: draw_header(stdscr, state.path, state.dirty) sec_title_attr = (_color(1) | curses.A_BOLD) if state.focus == "sections" else _color(1) keys_title_attr = (_color(1) | curses.A_BOLD) if state.focus == "keys" else _color(1) focus_marker = ">" if state.focus == "sections" else " " _safe_addstr(stdscr, 1, 0, f"{focus_marker}Sections ({len(state.sections)}) ".ljust(section_w), sec_title_attr) draw_status_bar(stdscr, state, 1, keys_left, keys_w, keys_title_attr) for row in range(2, body_h + 2): _safe_addstr(stdscr, row, divider, "│") sec_scroll = draw_sections_pane(stdscr, state, 2, 0, body_h, section_w, sec_scroll) key_scroll = draw_keys_pane(stdscr, state, 2, keys_left, body_h, keys_w, key_scroll) draw_hint_line(stdscr, state, hint_row, 0, w) draw_footer(stdscr, state) except curses.error: pass except Exception: pass try: stdscr.refresh() except curses.error: pass # ── Input ───────────────────────────────────────────────────────────── try: ch = stdscr.getch() except curses.error: continue except KeyboardInterrupt: if state.dirty: try: if confirm(stdscr, "Unsaved changes — quit without saving?"): break except Exception: break else: break continue if ch == curses.KEY_RESIZE: key_scroll = 0 sec_scroll = 0 state.status_msg = "" # ── Tab: toggle focus between panes ───────────────────────────────── elif ch in (ord("\t"), curses.KEY_BTAB): if ch == curses.KEY_BTAB or state.focus == "keys": state.focus = "sections" else: state.focus = "keys" state.status_msg = "" # ── Arrow up/down: navigate within the focused pane ───────────────── elif ch == curses.KEY_UP: if state.focus == "sections": state.section_idx = max(0, state.section_idx - 1) state.key_idx = 0 key_scroll = 0 else: state.key_idx = max(0, state.key_idx - 1) state.status_msg = "" elif ch == curses.KEY_DOWN: if state.focus == "sections": state.section_idx = min(max(0, len(state.sections) - 1), state.section_idx + 1) state.key_idx = 0 key_scroll = 0 else: keys = state.current_keys() if keys: state.key_idx = min(len(keys) - 1, state.key_idx + 1) state.status_msg = "" # ── Left: from keys pane → focus sections pane ────────────────────── elif ch == curses.KEY_LEFT: if state.focus == "keys": state.focus = "sections" else: # Already in sections pane — cycle to previous section state.section_idx = (state.section_idx - 1) % max(1, len(state.sections)) state.key_idx = 0 key_scroll = 0 state.status_msg = "" # ── Right: from sections pane → focus keys pane ───────────────────── elif ch == curses.KEY_RIGHT: if state.focus == "sections": state.focus = "keys" else: # Already in keys pane — cycle to next section state.section_idx = (state.section_idx + 1) % max(1, len(state.sections)) state.key_idx = 0 key_scroll = 0 state.status_msg = "" # ── PgUp/PgDn: cycle sections from either pane ────────────────────── elif ch == curses.KEY_PPAGE: state.section_idx = (state.section_idx - 1) % max(1, len(state.sections)) state.key_idx = 0 key_scroll = 0 state.status_msg = "" elif ch == curses.KEY_NPAGE: state.section_idx = (state.section_idx + 1) % max(1, len(state.sections)) state.key_idx = 0 key_scroll = 0 state.status_msg = "" elif ch in (curses.KEY_HOME,): state.section_idx = 0 state.key_idx = 0 key_scroll = sec_scroll = 0 # ── Enter: on sections pane → move focus to keys; on keys → edit ──── elif ch in (curses.KEY_ENTER, 10, 13): if state.focus == "sections": state.focus = "keys" state.status_msg = "" else: try: section = state.current_section() keys = state.current_keys() if keys and state.key_idx < len(keys): key = keys[state.key_idx] try: current_val = state.cfg.get(section, key, fallback="") except configparser.Error: current_val = "" new_val = edit_value(stdscr, f"[{section}] {key}", current_val) if new_val is not None and new_val != current_val: try: state.cfg.set(section, key, new_val) state.dirty = True state.set_status(f"Updated [{section}] {key}") except configparser.Error as e: state.set_status(f"Cannot set value: {e}", error=True) except Exception as e: state.set_status(f"Edit error: {e}", error=True) elif ch in (ord("?"),): try: show_key_help(stdscr, state) except Exception as e: state.set_status(f"Help error: {e}", error=True) elif ch in (ord("a"), ord("A")): if state.focus == "keys": try: action_add_key(stdscr, state) except Exception as e: state.set_status(f"Add error: {e}", error=True) elif ch in (ord("r"), ord("R")): if state.focus == "keys": try: action_rename_key(stdscr, state) except Exception as e: state.set_status(f"Rename error: {e}", error=True) elif ch in (ord("d"), ord("D"), curses.KEY_DC): if state.focus == "keys": try: action_delete_key(stdscr, state) except Exception as e: state.set_status(f"Delete error: {e}", error=True) elif ch in (ord("s"), ord("S")): try: save_config(state.cfg, state.path) state.dirty = False state.set_status(f"Saved → {state.path}") except Exception as e: state.set_status(f"Save failed: {e}", error=True) elif ch in (ord("v"), ord("V")): try: issues = validate_config(state.cfg, state.example_keys) show_overlay(stdscr, "Validation Results", issues) except Exception as e: state.set_status(f"Validate error: {e}", error=True) elif ch in (ord("m"), ord("M")): try: changes = migrate_config(state.cfg, state.example_keys, state.example_comments) try: state.sections = state.cfg.sections() except Exception: pass if changes: state.dirty = True show_overlay( stdscr, f"Migration — {len(changes)} change(s)", [("INFO", "", c) for c in changes], ) state.set_status(f"Migrated {len(changes)} item(s) — press s to save") else: state.set_status("Config is up-to-date — nothing to migrate") except Exception as e: state.set_status(f"Migrate error: {e}", error=True) elif ch in (ord("q"), ord("Q"), 27): try: if state.dirty: if confirm(stdscr, "Unsaved changes — quit without saving?"): break else: break except Exception: break try: state.clamp() except Exception: pass # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def main() -> None: try: project_root = find_project_root() except Exception: project_root = Path.cwd() config_path = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else project_root / "config.ini" example_path = project_root / "config.ini.example" if not config_path.exists(): if not example_path.exists(): print( f"ERROR: Neither {config_path} nor {example_path} found.\n" "Run this from the meshcore-bot project root.", file=sys.stderr, ) sys.exit(1) try: import shutil print(f"config.ini not found — creating from {example_path.name}…") shutil.copy(str(example_path), str(config_path)) print(f"Created {config_path}") except (OSError, shutil.Error) as e: print(f"ERROR: Could not create config from example: {e}", file=sys.stderr) sys.exit(1) try: cfg = load_config(config_path) except Exception as e: print(f"ERROR: Could not load config: {e}", file=sys.stderr) sys.exit(1) # Warn if the config had a parse error but continue with whatever was loaded load_error = getattr(cfg, "_load_error", None) if load_error: print(f"WARNING: Config parse error (partial load): {load_error}", file=sys.stderr) try: example_keys = load_example_keys(example_path) example_comments = load_example_comments(example_path) example_lines = load_example_lines(example_path) except Exception as e: print(f"WARNING: Could not load example file ({e}); help/migrate features disabled.", file=sys.stderr) example_keys = {} example_comments = {} example_lines = {} try: state = TUIState(cfg, config_path, example_keys, example_comments, example_lines) except Exception as e: print(f"ERROR: Could not initialise TUI state: {e}", file=sys.stderr) sys.exit(1) try: curses.wrapper(tui_main, state) except KeyboardInterrupt: pass except curses.error as e: print(f"ERROR: Terminal error: {e}", file=sys.stderr) sys.exit(1) except Exception as e: print(f"ERROR: Unexpected error: {e}", file=sys.stderr) sys.exit(1) print("Exited config editor.") if state.dirty: try: answer = input("Unsaved changes. Save now? [y/N] ").strip().lower() if answer in ("y", "yes"): save_config(state.cfg, config_path) print(f"Saved to {config_path}") except (KeyboardInterrupt, EOFError): print() # clean newline except Exception as e: print(f"Save failed: {e}", file=sys.stderr) if __name__ == "__main__": main()