Files
meshcore-bot/scripts/config_tui.py
Stacy Olivas da1e68f500 infra: ncurses config TUI via scripts/config_tui.py
Add scripts/config_tui.py: browse, edit, save, validate, and migrate
config.ini interactively. Key bindings: r rename key, a add key+value,
d/Delete remove key with confirmation. Dynamic sections such as
[Scheduled_Messages] suppress the unknown-key ? marker. Access via
make config or CONFIG=path make config.
2026-03-17 18:07:18 -07:00

1249 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 os
import re
import sys
import textwrap
from pathlib import Path
from typing import Dict, List, Optional, Tuple
# ---------------------------------------------------------------------------
# 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, IOError, 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 (IOError, 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 (IOError, 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, IOError, 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 (IOError, OSError, PermissionError) as e:
raise IOError(f"Cannot create directory {path.parent}: {e}") from e
try:
with open(path, "w", encoding="utf-8") as fh:
cfg.write(fh)
except (IOError, OSError, PermissionError) as e:
raise IOError(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 (IOError, 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()