fixes to linting

This commit is contained in:
bryan
2026-02-02 12:52:11 -08:00
parent b033c56ae5
commit ca7f6d3514
8 changed files with 195 additions and 188 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.6
rev: v0.14.14
hooks:
- id: ruff
name: ruff lint (core)
+18 -19
View File
@@ -244,29 +244,27 @@ def cmd_run(args: argparse.Namespace) -> int:
# Run the agent (with TUI or standard)
if getattr(args, "tui", False):
from framework.tui.app import AdenTUI
# Test Minimal App to isolate crash source
# from textual.app import App, ComposeResult
# from textual.widgets import Label, Header, Footer
# class MinimalTUI(App):
# CSS = "Screen { layout: vertical; }"
# def compose(self) -> ComposeResult:
# yield Header()
# yield Label("Minimal TUI Mode - Debugging")
# yield Footer()
# We need to run inside the existing loop or use proper async handling
# Since cmd_run is called from sync main(), we need to use asyncio.run or similar
# But wait, result = asyncio.run(...) is below.
pass # Placeholder to indicate logic insertion point
async def run_with_tui():
# Initialize TUI
from textual.app import App, ComposeResult
from textual.widgets import Label, Header, Footer
try:
# Load runner FRESH inside the loop to ensure strict loop affinity
print("DEBUG: Loading AgentRunner inside TUI loop...")
@@ -285,11 +283,10 @@ def cmd_run(args: argparse.Namespace) -> int:
if runner._agent_runtime is None:
runner._setup()
print("DEBUG: AgentRuntime setup forced inside TUI loop")
app = AdenTUI(runner._agent_runtime)
print("DEBUG: AdenTUI initialized with runtime in cli.py")
# Define agent task
# Define agent task
async def run_agent_task():
@@ -300,23 +297,25 @@ def cmd_run(args: argparse.Namespace) -> int:
except Exception as e:
# Log error to TUI if possible
if hasattr(app, "log_handler"):
import logging
logging.error(f"Agent Execution Failed: {e}", exc_info=True)
import logging
logging.error(f"Agent Execution Failed: {e}", exc_info=True)
finally:
# Don't auto-exit, let user inspect result
pass
# Start agent task in background
agent_task = asyncio.create_task(run_agent_task())
_agent_task = asyncio.create_task(run_agent_task())
# Start TUI (blocks this coroutine until quit)
await app.run_async()
except Exception as e:
import traceback
traceback.print_exc()
print(f"TUI crashed during init/run: {e}")
logging.error(f"TUI Crash: {e}", exc_info=True)
# Cleanup
# if agent_task and not agent_task.done():
# agent_task.cancel()
@@ -324,14 +323,14 @@ def cmd_run(args: argparse.Namespace) -> int:
# await agent_task
# except asyncio.CancelledError:
# pass
# Return result if completed
# if agent_task.done() and not agent_task.cancelled():
# return agent_task.result()
return None
result = asyncio.run(run_with_tui())
if result is None:
# TUI quit before completion or error
print("TUI session ended.")
+9 -7
View File
@@ -573,13 +573,15 @@ class AgentRunner:
# If TUI enabled but no entry points (single-entry agent), create default
if not entry_points and self.enable_tui and self.graph.entry_node:
logger.info("Creating default entry point for TUI")
entry_points.append(EntryPointSpec(
id="default",
name="Default",
entry_node=self.graph.entry_node,
trigger_type="manual",
isolation_level="shared",
))
entry_points.append(
EntryPointSpec(
id="default",
name="Default",
entry_node=self.graph.entry_node,
trigger_type="manual",
isolation_level="shared",
)
)
# Create AgentRuntime with all entry points
self._agent_runtime = create_agent_runtime(
+81 -74
View File
@@ -1,16 +1,19 @@
from textual.app import App, ComposeResult
from textual.widgets import Label, Header, Footer
from textual.containers import Container, Horizontal, Vertical
from textual.binding import Binding
from framework.runtime.agent_runtime import AgentRuntime
from framework.tui.widgets.log_pane import LogPane
from framework.tui.widgets.graph_view import GraphOverview
from framework.tui.widgets.chat_repl import ChatRepl
import logging
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Footer, Label
from framework.runtime.agent_runtime import AgentRuntime
from framework.tui.widgets.chat_repl import ChatRepl
from framework.tui.widgets.graph_view import GraphOverview
from framework.tui.widgets.log_pane import LogPane
class StaticHeader(Container):
"""Custom static header that replaces standard Header widget."""
DEFAULT_CSS = """
StaticHeader {
dock: top;
@@ -21,10 +24,11 @@ class StaticHeader(Container):
align: center middle;
}
"""
def compose(self) -> ComposeResult:
yield Label(self.app.title)
class AdenTUI(App):
TITLE = "Aden TUI Dashboard"
COMMAND_PALETTE_BINDING = "ctrl+o"
@@ -40,7 +44,7 @@ class AdenTUI(App):
layout: vertical;
background: $surface;
}
#graph-overview-container {
height: 40%;
background: $panel;
@@ -61,7 +65,7 @@ class AdenTUI(App):
border-left: tall $primary;
padding: 0;
}
#chat-history {
height: 1fr;
width: 100%;
@@ -70,52 +74,52 @@ class AdenTUI(App):
scrollbar-background: $panel;
scrollbar-color: $primary;
}
TextArea {
background: $surface;
border: none;
scrollbar-background: $panel;
scrollbar-color: $primary;
}
Input {
background: $surface;
border: tall $primary;
margin-top: 1;
}
Input:focus {
border: tall $accent;
}
StaticHeader {
background: $primary;
color: $text;
text-style: bold;
height: 1;
}
/* Force height 1 even if tall class is added (prevents expansion) */
StaticHeader.-tall {
height: 1;
}
StaticHeader > .header--title {
text-style: bold;
}
/* Hide the clock icon and top-left icon/button */
Header .header--clock, StaticHeader .header--clock,
Header .header--icon, StaticHeader .header--icon {
display: none !important;
}
Footer {
background: $panel;
color: $text-muted;
}
"""
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("ctrl+s", "screenshot", "Screenshot (SVG)", show=True, priority=True),
@@ -126,25 +130,25 @@ class AdenTUI(App):
def __init__(self, runtime: AgentRuntime):
with open("tui_debug.log", "a") as f:
f.write("DEBUG: AdenTUI.__init__ started\n")
print("DEBUG: AdenTUI.__init__ called")
super().__init__()
self.runtime = runtime
self.log_pane = LogPane()
self.graph_view = GraphOverview(runtime)
self.chat_repl = ChatRepl(runtime)
self.is_ready = False
with open("tui_debug.log", "a") as f:
f.write("DEBUG: Widgets initialized\n")
def compose(self) -> ComposeResult:
with open("tui_debug.log", "a") as f:
f.write("DEBUG: compose() called\n")
yield StaticHeader()
yield Horizontal(
Vertical(
Container(self.log_pane, id="log-pane-container"),
@@ -153,9 +157,9 @@ class AdenTUI(App):
),
Container(self.chat_repl, id="chat-repl-container"),
)
yield Footer()
with open("tui_debug.log", "a") as f:
f.write("DEBUG: compose() complete\n")
@@ -163,26 +167,26 @@ class AdenTUI(App):
"""Called when app starts."""
with open("tui_debug.log", "a") as f:
f.write("DEBUG: on_mount() called\n")
self.title = "Aden TUI Dashboard"
# Add logging setup
self._setup_logging_queue()
# Set ready immediately so _poll_logs can process messages
self.is_ready = True
# Add event subscription with delay to ensure TUI is fully initialized
self.call_later(self._init_runtime_connection)
# Delay initial log messages until layout is fully rendered
def write_initial_logs():
logging.info("TUI Dashboard initialized successfully")
logging.info("Waiting for agent execution to start...")
# Wait for layout to be fully rendered before writing logs
self.set_timer(0.2, write_initial_logs)
with open("tui_debug.log", "a") as f:
f.write("DEBUG: on_mount() complete\n")
@@ -191,31 +195,31 @@ class AdenTUI(App):
try:
import queue
from logging.handlers import QueueHandler
self.log_queue = queue.Queue()
self.queue_handler = QueueHandler(self.log_queue)
self.queue_handler.setLevel(logging.INFO)
# Get root logger
root_logger = logging.getLogger()
# Remove ALL existing handlers to prevent stdout output
# This is critical - StreamHandlers cause text to appear in header
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
# Add ONLY our queue handler
root_logger.addHandler(self.queue_handler)
root_logger.setLevel(logging.INFO)
# Suppress LiteLLM logging completely
litellm_logger = logging.getLogger("LiteLLM")
litellm_logger.setLevel(logging.CRITICAL) # Only show critical errors
litellm_logger.propagate = False # Don't propagate to root logger
# Start polling
self.set_interval(0.1, self._poll_logs)
with open("tui_debug.log", "a") as f:
f.write("DEBUG: Logging setup complete\n")
except Exception as e:
@@ -226,7 +230,7 @@ class AdenTUI(App):
"""Poll the log queue and update UI."""
if not self.is_ready:
return
try:
count = 0
while not self.log_queue.empty():
@@ -234,11 +238,11 @@ class AdenTUI(App):
# Filter out framework/library logs
if record.name.startswith(("textual", "LiteLLM", "litellm")):
continue
msg = logging.Formatter().format(record)
self.log_pane.write_log(msg)
count += 1
if count > 0:
with open("tui_debug.log", "a") as f:
f.write(f"DEBUG: _poll_logs processed {count} messages\n")
@@ -251,7 +255,7 @@ class AdenTUI(App):
try:
with open("tui_debug.log", "a") as f:
f.write("DEBUG: _init_runtime_connection called\n")
# Use call_soon_threadsafe wrapper for the handler
def safe_event_handler(event):
"""Thread-safe event handler wrapper."""
@@ -261,18 +265,16 @@ class AdenTUI(App):
except Exception as e:
with open("tui_debug.log", "a") as f:
f.write(f"ERROR in safe_event_handler: {e}\n")
self.runtime.subscribe_to_events(
event_types=[],
handler=safe_event_handler
)
self.runtime.subscribe_to_events(event_types=[], handler=safe_event_handler)
with open("tui_debug.log", "a") as f:
f.write("DEBUG: Event subscription complete\n")
except Exception as e:
with open("tui_debug.log", "a") as f:
f.write(f"ERROR in _init_runtime_connection: {e}\n")
import traceback
traceback.print_exc()
def _handle_event_sync(self, event) -> None:
@@ -280,94 +282,99 @@ class AdenTUI(App):
try:
with open("tui_debug.log", "a") as f:
f.write(f"DEBUG: _handle_event_sync called with event: {event}\n")
if not self.is_ready:
with open("tui_debug.log", "a") as f:
f.write("DEBUG: App not ready, skipping event\n")
return
# Update graph view
if hasattr(event, "type"):
with open("tui_debug.log", "a") as f:
f.write(f"DEBUG: Event has type: {event.type}\n")
if hasattr(event.type, "value"):
event_type = event.type.value
with open("tui_debug.log", "a") as f:
f.write(f"DEBUG: Event type value: {event_type}\n")
if event_type.startswith(("execution_", "node_")):
self.graph_view.update_execution(event)
with open("tui_debug.log", "a") as f:
f.write(f"DEBUG: Handled event {event_type}\n")
except Exception as e:
with open("tui_debug.log", "a") as f:
f.write(f"ERROR in _handle_event_sync: {e}\n")
import traceback
with open("tui_debug.log", "a") as f:
f.write(f"{traceback.format_exc()}\n")
def save_png_screenshot(self, filename: str | None = None) -> str:
"""Save a screenshot of the current screen as SVG (viewable in browsers).
Note: Saves as SVG format since PNG conversion requires system libraries.
SVG files can be opened in any web browser or converted to PNG using online tools.
Args:
filename: Optional filename for the screenshot. If None, generates timestamp-based name.
Returns:
Path to the saved SVG file.
"""
from datetime import datetime
from pathlib import Path
# Create screenshots directory
screenshots_dir = Path("screenshots")
screenshots_dir.mkdir(exist_ok=True)
# Generate filename if not provided
if filename is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"tui_screenshot_{timestamp}.svg"
# Ensure .svg extension
if not filename.endswith('.svg'):
filename += '.svg'
if not filename.endswith(".svg"):
filename += ".svg"
# Full path
filepath = screenshots_dir / filename
# Temporarily hide borders for cleaner screenshot
chat_container = self.query_one("#chat-repl-container")
original_chat_border = chat_container.styles.border_left
chat_container.styles.border_left = ("none", "transparent")
# Hide all Input widget borders
input_widgets = self.query("Input")
original_input_borders = []
for input_widget in input_widgets:
original_input_borders.append(input_widget.styles.border)
input_widget.styles.border = ("none", "transparent")
try:
# Get SVG data from Textual and save it
svg_data = self.export_screenshot()
filepath.write_text(svg_data, encoding='utf-8')
filepath.write_text(svg_data, encoding="utf-8")
finally:
# Restore the original borders
chat_container.styles.border_left = original_chat_border
for i, input_widget in enumerate(input_widgets):
input_widget.styles.border = original_input_borders[i]
return str(filepath)
def action_screenshot(self) -> None:
"""Take a screenshot (bound to Ctrl+S)."""
try:
filepath = self.save_png_screenshot()
self.notify(f"Screenshot saved: {filepath} (SVG - open in browser)", severity="information", timeout=5)
self.notify(
f"Screenshot saved: {filepath} (SVG - open in browser)",
severity="information",
timeout=5,
)
except Exception as e:
self.notify(f"Screenshot failed: {e}", severity="error", timeout=5)
+4 -5
View File
@@ -2,12 +2,13 @@
Logging Handler for TUI.
"""
from typing import TYPE_CHECKING
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from framework.tui.app import AdenTUI
class TUILogHandler(logging.Handler):
"""Redirects logging records to the TUI LogPane."""
@@ -20,16 +21,14 @@ class TUILogHandler(logging.Handler):
# Avoid infinite recursion by ignoring textual logs
if record.name.startswith("textual"):
return
try:
if not hasattr(self.app, "is_ready") or not self.app.is_ready:
return
msg = self.format(record)
# We need to schedule the update on the main thread
self.app.call_later(
self.app.log_pane.write_log, msg
)
self.app.call_later(self.app.log_pane.write_log, msg)
except Exception:
# If app is closed or error, fallback
pass
+45 -43
View File
@@ -2,11 +2,12 @@
Chat / REPL Widget - Uses TextArea for reliable display.
"""
from textual.widgets import Input, TextArea
from textual.containers import Vertical
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.widgets import Input, TextArea
from framework.runtime.agent_runtime import AgentRuntime
import logging
class ChatRepl(Vertical):
"""Widget for interactive chat/REPL."""
@@ -17,7 +18,7 @@ class ChatRepl(Vertical):
height: 100%;
layout: vertical;
}
ChatRepl > TextArea {
width: 100%;
height: 1fr;
@@ -26,7 +27,7 @@ class ChatRepl(Vertical):
scrollbar-background: $panel;
scrollbar-color: $primary;
}
ChatRepl > Input {
width: 100%;
height: auto;
@@ -35,7 +36,7 @@ class ChatRepl(Vertical):
border: tall $primary;
margin-top: 1;
}
ChatRepl > Input:focus {
border: tall $accent;
}
@@ -49,7 +50,7 @@ class ChatRepl(Vertical):
# Use TextArea (read-only) like LogPane
yield TextArea("", id="chat-history", read_only=True)
yield Input(placeholder="Enter input for agent...", id="chat-input")
def on_mount(self) -> None:
"""Add welcome message when widget mounts."""
history = self.query_one("#chat-history", TextArea)
@@ -60,92 +61,93 @@ class ChatRepl(Vertical):
async def on_input_submitted(self, message: Input.Submitted) -> None:
"""Handle input submission."""
with open("tui_debug.log", "a") as f:
f.write(f"DEBUG: on_input_submitted called\n")
f.write("DEBUG: on_input_submitted called\n")
user_input = message.value.strip()
with open("tui_debug.log", "a") as f:
f.write(f"DEBUG: ChatREPL input: '{user_input}'\n")
if not user_input:
return
# Get chat history
with open("tui_debug.log", "a") as f:
f.write(f"DEBUG: Getting chat history\n")
f.write("DEBUG: Getting chat history\n")
history = self.query_one("#chat-history", TextArea)
# Display user message
with open("tui_debug.log", "a") as f:
f.write(f"DEBUG: Adding user message\n")
f.write("DEBUG: Adding user message\n")
current_text = history.text
history.load_text(f"{current_text}\nYou: {user_input}\n")
with open("tui_debug.log", "a") as f:
f.write(f"DEBUG: User message added\n")
f.write("DEBUG: User message added\n")
# Clear input
message.input.value = ""
# Execute agent
try:
with open("tui_debug.log", "a") as f:
f.write(f"DEBUG: Starting agent execution\n")
f.write("DEBUG: Starting agent execution\n")
# Show processing
current_text = history.text
history.load_text(f"{current_text}Agent is processing...\n")
with open("tui_debug.log", "a") as f:
f.write(f"DEBUG: Processing message shown\n")
f.write("DEBUG: Processing message shown\n")
# Get entry point
entry_points = self.runtime.get_entry_points()
if not entry_points:
current_text = history.text
history.load_text(f"{current_text}Error: No entry points\n")
return
with open("tui_debug.log", "a") as f:
f.write(f"DEBUG: Calling trigger_and_wait\n")
f.write("DEBUG: Calling trigger_and_wait\n")
# Execute
result = await self.runtime.trigger_and_wait(
entry_point_id=entry_points[0].id,
input_data={"input_string": user_input},
timeout=30.0
timeout=30.0,
)
with open("tui_debug.log", "a") as f:
f.write(f"DEBUG: Got result: {result}\n")
# Remove "processing" line and display result
lines = history.text.split('\n')
lines = [line for line in lines if 'processing' not in line.lower()]
lines = history.text.split("\n")
lines = [line for line in lines if "processing" not in line.lower()]
# Display result
if result and result.success and result.output:
output_str = str(result.output.get('output_string', result.output))
output_str = str(result.output.get("output_string", result.output))
lines.append(f"Agent: {output_str}")
with open("tui_debug.log", "a") as f:
f.write(f"DEBUG: Added success result\n")
f.write("DEBUG: Added success result\n")
elif result and result.error:
lines.append(f"Error: {result.error}")
else:
lines.append("No result")
history.load_text('\n'.join(lines) + '\n')
history.load_text("\n".join(lines) + "\n")
with open("tui_debug.log", "a") as f:
f.write(f"DEBUG: Execution complete\n")
f.write("DEBUG: Execution complete\n")
except Exception as e:
with open("tui_debug.log", "a") as f:
f.write(f"ERROR: Exception in handler: {e}\n")
import traceback
f.write(f"{traceback.format_exc()}\n")
current_text = history.text
lines = current_text.split('\n')
lines = [line for line in lines if 'processing' not in line.lower()]
lines = current_text.split("\n")
lines = [line for line in lines if "processing" not in line.lower()]
lines.append(f"Error: {str(e)}")
history.load_text('\n'.join(lines) + '\n')
history.load_text("\n".join(lines) + "\n")
+28 -26
View File
@@ -2,22 +2,24 @@
Graph/Tree Overview Widget - Displays real agent graph structure.
"""
from textual.widgets import Tree, Static, RichLog
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.widgets import RichLog
from framework.runtime.agent_runtime import AgentRuntime
from framework.runtime.event_bus import EventType
class GraphOverview(Vertical):
"""Widget to display Agent execution graph/tree with real data."""
DEFAULT_CSS = """
GraphOverview {
width: 100%;
height: 100%;
background: $panel;
}
GraphOverview > RichLog {
width: 100%;
height: 100%;
@@ -27,37 +29,37 @@ class GraphOverview(Vertical):
scrollbar-color: $primary;
}
"""
def __init__(self, runtime: AgentRuntime):
super().__init__()
self.runtime = runtime
self.active_node = None
self.execution_path = []
def compose(self) -> ComposeResult:
# Use RichLog for formatted output
yield RichLog(id="graph-display", highlight=True, markup=True)
def on_mount(self) -> None:
"""Display initial graph structure."""
self._display_graph()
def _display_graph(self) -> None:
"""Display the graph structure with nodes and entry points."""
display = self.query_one("#graph-display", RichLog)
# Clear and display header
display.clear()
display.write("[bold cyan]Agent Graph Structure[/bold cyan]\n")
# Get graph from runtime
graph = self.runtime.graph
# Display graph info
display.write(f"[dim]Graph ID:[/dim] {graph.id}")
display.write(f"[dim]Goal:[/dim] {self.runtime.goal.description[:50]}...")
display.write("")
# Display entry points
entry_points = self.runtime.get_entry_points()
if entry_points:
@@ -66,14 +68,14 @@ class GraphOverview(Vertical):
display.write(f"{ep.name} → [cyan]{ep.entry_node}[/cyan]")
else:
display.write(f"[bold]Entry Node:[/bold] [cyan]{graph.entry_node}[/cyan]")
display.write("")
# Display nodes
display.write(f"[bold]Nodes ({len(graph.nodes)}):[/bold]")
for node in graph.nodes:
node_type = node.type if hasattr(node, 'type') else 'unknown'
node_type = node.type if hasattr(node, "type") else "unknown"
# Highlight active node
if self.active_node == node.id:
display.write(f" ▶ [bold green]{node.id}[/bold green] ({node_type})")
@@ -81,50 +83,50 @@ class GraphOverview(Vertical):
display.write(f" ✓ [dim]{node.id}[/dim] ({node_type})")
else:
display.write(f"{node.id} ({node_type})")
display.write("")
# Display terminal nodes
if graph.terminal_nodes:
display.write(f"[bold]Terminal Nodes:[/bold]")
display.write("[bold]Terminal Nodes:[/bold]")
for node_id in graph.terminal_nodes:
display.write(f" • [yellow]{node_id}[/yellow]")
# Display execution status
if self.active_node:
display.write("")
display.write(f"[bold green]Currently Executing:[/bold green] {self.active_node}")
if self.execution_path:
display.write(f"[dim]Path:[/dim] {''.join(self.execution_path[-5:])}")
def update_active_node(self, node_id: str) -> None:
"""Update the currently active node."""
self.active_node = node_id
if node_id not in self.execution_path:
self.execution_path.append(node_id)
self._display_graph()
def update_execution(self, event) -> None:
"""Update the displayed node status based on event."""
display = self.query_one("#graph-display", RichLog)
if event.type == EventType.NODE_STARTED:
node_id = event.data.get("node_id")
if node_id:
self.update_active_node(node_id)
elif event.type == EventType.NODE_COMPLETED:
node_id = event.data.get("node_id")
if node_id and node_id == self.active_node:
self.active_node = None
self._display_graph()
elif event.type == EventType.EXECUTION_COMPLETED:
display.write("")
display.write("[bold green]✓ Execution Complete![/bold green]")
self.active_node = None
elif event.type == EventType.EXECUTION_FAILED:
display.write("")
error = event.data.get("error", "Unknown error")
+9 -13
View File
@@ -2,19 +2,20 @@
Log Pane Widget - Uses RichLog for reliable rendering.
"""
from textual.widgets import RichLog
from textual.app import ComposeResult
from textual.containers import Container
from textual.widgets import RichLog
class LogPane(Container):
"""Widget to display logs with reliable rendering."""
DEFAULT_CSS = """
LogPane {
width: 100%;
height: 100%;
}
LogPane > RichLog {
width: 100%;
height: 100%;
@@ -27,12 +28,7 @@ class LogPane(Container):
def compose(self) -> ComposeResult:
# RichLog is designed for log display and doesn't have TextArea's rendering issues
yield RichLog(
id="main-log",
highlight=True,
markup=True,
auto_scroll=True
)
yield RichLog(id="main-log", highlight=True, markup=True, auto_scroll=True)
def write_log(self, message: str) -> None:
"""Write a log message to the log pane."""
@@ -40,16 +36,16 @@ class LogPane(Container):
# Check if widget is mounted
if not self.is_mounted:
return
log = self.query_one("#main-log", RichLog)
# Check if log is mounted
if not log.is_mounted:
return
# Write message - RichLog handles rendering correctly
log.write(message)
except Exception as e:
# Widget might not be ready
with open("tui_debug.log", "a") as f: