Files
meshcore-bot/scripts/config_tui.py
T
Stacy Olivas 7679eb7e2c fix: ruff and mypy compliance across utility scripts and tests
Fix ruff violations (import sort, unused imports, whitespace) in
backup_database.py, generate_website.py, meshcore_bot.py,
migrate_webviewer_db.py, packet_capture_service.py, version_info.py,
scripts/config_tui.py, scripts/update_todos.py, and validate_config.py.

Fix mypy stale type-ignore and remove unused imports in test files.
Add is_radio_zombie/is_radio_offline mock attributes to command_manager
test fixtures to prevent truthy Mock early returns.
2026-04-14 10:02:36 -07:00

1246 lines
44 KiB
Python
Executable File

#!/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()