# mypy: ignore-errors
import logging
import os
import queue
import sys
import threading
import tkinter as tk
import traceback
from enum import Enum, auto
from pathlib import Path
from typing import Literal
from serena import constants
from serena.util.logging import MemoryLogHandler
log = logging.getLogger(__name__)
class LogLevel(Enum):
DEBUG = auto()
INFO = auto()
WARNING = auto()
ERROR = auto()
DEFAULT = auto()
class GuiLogViewer:
"""
A class that creates a Tkinter GUI for displaying log messages in a separate thread.
The log viewer supports coloring based on log levels (DEBUG, INFO, WARNING, ERROR).
It can also highlight tool names in boldface when they appear in log messages.
"""
def __init__(
self,
mode: Literal["dashboard", "error"],
title="Log Viewer",
memory_log_handler: MemoryLogHandler | None = None,
width=800,
height=600,
):
"""
:param mode: the mode; if "dashboard", run a dashboard with logs and some control options; if "error", run
a simple error log viewer (for fatal exceptions)
:param title: the window title
:param memory_log_handler: an optional log handler from which to obtain log messages; If not provided,
must pass the instance to a `GuiLogViewerHandler` to add log messages.
:param width: the initial window width
:param height: the initial window height
"""
self.mode = mode
self.title = title
self.width = width
self.height = height
self.message_queue = queue.Queue()
self.running = False
self.log_thread = None
self.menubar: tk.Menu | None = None
self.tool_names = [] # List to store tool names for highlighting
# Define colors for different log levels
self.log_colors = {
LogLevel.DEBUG: "#808080", # Gray
LogLevel.INFO: "#000000", # Black
LogLevel.WARNING: "#FF8C00", # Dark Orange
LogLevel.ERROR: "#FF0000", # Red
LogLevel.DEFAULT: "#000000", # Black
}
if memory_log_handler is not None:
for msg in memory_log_handler.get_log_messages():
self.message_queue.put(msg)
memory_log_handler.add_emit_callback(lambda msg: self.message_queue.put(msg))
def start(self):
"""Start the log viewer in a separate thread."""
if not self.running:
self.log_thread = threading.Thread(target=self.run_gui)
self.log_thread.daemon = True
self.log_thread.start()
return True
return False
def stop(self):
"""Stop the log viewer."""
if self.running:
# Add a sentinel value to the queue to signal the GUI to exit
self.message_queue.put(None)
return True
return False
def set_tool_names(self, tool_names):
"""
Set or update the list of tool names to be highlighted in log messages.
Args:
tool_names (list): A list of tool name strings to highlight
"""
self.tool_names = tool_names
def set_dashboard_url(self, url: str) -> None:
def copy_url():
self.root.clipboard_clear()
self.root.clipboard_append(url)
log.info(f"Copied dashboard URL to clipboard: {url}")
if self.menubar is not None:
dashboard_menu = tk.Menu(self.menubar, tearoff=0)
dashboard_menu.add_command(label="Copy URL", command=copy_url) # type: ignore
self.menubar.add_cascade(label="Dashboard", menu=dashboard_menu)
def add_log(self, message):
"""
Add a log message to the viewer.
Args:
message (str): The log message to display
"""
self.message_queue.put(message)
def _determine_log_level(self, message):
"""
Determine the log level from the message.
Args:
message (str): The log message
Returns:
LogLevel: The determined log level
"""
message_upper = message.upper()
if message_upper.startswith("DEBUG"):
return LogLevel.DEBUG
elif message_upper.startswith("INFO"):
return LogLevel.INFO
elif message_upper.startswith("WARNING"):
return LogLevel.WARNING
elif message_upper.startswith("ERROR"):
return LogLevel.ERROR
else:
return LogLevel.DEFAULT
def _process_queue(self):
"""Process messages from the queue and update the text widget."""
try:
while not self.message_queue.empty():
message = self.message_queue.get_nowait()
# Check for sentinel value to exit
if message is None:
self.root.quit()
return
# Check if scrollbar is at the bottom before adding new text
# Get current scroll position
current_position = self.text_widget.yview()
# If near the bottom (allowing for small floating point differences)
was_at_bottom = current_position[1] > 0.99
log_level = self._determine_log_level(message)
# Insert the message at the end of the text with appropriate log level tag
self.text_widget.configure(state=tk.NORMAL)
# Find tool names in the message and highlight them
if self.tool_names:
# Capture start position (before insertion)
start_index = self.text_widget.index("end-1c")
# Insert the message
self.text_widget.insert(tk.END, message + "\n", log_level.name)
# Convert start index to line/char format
line, char = map(int, start_index.split("."))
# Search for tool names in the message string directly
for tool_name in self.tool_names:
start_offset = 0
while True:
found_at = message.find(tool_name, start_offset)
if found_at == -1:
break
# Calculate line/column from offset
offset_line = line
offset_char = char
for c in message[:found_at]:
if c == "\n":
offset_line += 1
offset_char = 0
else:
offset_char += 1
# Construct index positions
start_pos = f"{offset_line}.{offset_char}"
end_pos = f"{offset_line}.{offset_char + len(tool_name)}"
# Add tag to highlight the tool name
self.text_widget.tag_add("TOOL_NAME", start_pos, end_pos)
start_offset = found_at + len(tool_name)
else:
# No tool names to highlight, just insert the message
self.text_widget.insert(tk.END, message + "\n", log_level.name)
self.text_widget.configure(state=tk.DISABLED)
# Auto-scroll to the bottom only if it was already at the bottom
if was_at_bottom:
self.text_widget.see(tk.END)
# Schedule to check the queue again
if self.running:
self.root.after(100, self._process_queue)
except Exception as e:
print(f"Error processing message queue: {e}", file=sys.stderr)
if self.running:
self.root.after(100, self._process_queue)
def run_gui(self):
"""Run the GUI"""
self.running = True
try:
# Set app id (avoid app being lumped together with other Python-based apps in Windows taskbar)
if sys.platform == "win32":
import ctypes
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("oraios.serena")
self.root = tk.Tk()
self.root.title(self.title)
self.root.geometry(f"{self.width}x{self.height}")
# Make the window resizable
self.root.columnconfigure(0, weight=1)
# We now have two rows - one for logo and one for text
self.root.rowconfigure(0, weight=0) # Logo row
self.root.rowconfigure(1, weight=1) # Text content row
dashboard_path = Path(constants.SERENA_DASHBOARD_DIR)
# Load and display the logo image
try:
# construct path relative to path of this file
image_path = dashboard_path / "serena-logs.png"
self.logo_image = tk.PhotoImage(file=image_path)
# Create a label to display the logo
self.logo_label = tk.Label(self.root, image=self.logo_image)
self.logo_label.grid(row=0, column=0, sticky="ew")
except Exception as e:
print(f"Error loading logo image: {e}", file=sys.stderr)
# Create frame to hold text widget and scrollbars
frame = tk.Frame(self.root)
frame.grid(row=1, column=0, sticky="nsew")
frame.columnconfigure(0, weight=1)
frame.rowconfigure(0, weight=1)
# Create horizontal scrollbar
h_scrollbar = tk.Scrollbar(frame, orient=tk.HORIZONTAL)
h_scrollbar.grid(row=1, column=0, sticky="ew")
# Create vertical scrollbar
v_scrollbar = tk.Scrollbar(frame, orient=tk.VERTICAL)
v_scrollbar.grid(row=0, column=1, sticky="ns")
# Create text widget with horizontal scrolling
self.text_widget = tk.Text(
frame, wrap=tk.NONE, width=self.width, height=self.height, xscrollcommand=h_scrollbar.set, yscrollcommand=v_scrollbar.set
)
self.text_widget.grid(row=0, column=0, sticky="nsew")
self.text_widget.configure(state=tk.DISABLED) # Make it read-only
# Configure scrollbars
h_scrollbar.config(command=self.text_widget.xview)
v_scrollbar.config(command=self.text_widget.yview)
# Configure tags for different log levels with appropriate colors
for level, color in self.log_colors.items():
self.text_widget.tag_configure(level.name, foreground=color)
# Configure tag for tool names
self.text_widget.tag_configure("TOOL_NAME", background="#ffff00")
# Set up the queue processing
self.root.after(100, self._process_queue)
# Handle window close event depending on mode
if self.mode == "dashboard":
self.root.protocol("WM_DELETE_WINDOW", lambda: self.root.iconify())
else:
self.root.protocol("WM_DELETE_WINDOW", self.stop)
# Create menu bar
if self.mode == "dashboard":
self.menubar = tk.Menu(self.root)
server_menu = tk.Menu(self.menubar, tearoff=0)
server_menu.add_command(label="Shutdown", command=self._shutdown_server) # type: ignore
self.menubar.add_cascade(label="Server", menu=server_menu)
self.root.config(menu=self.menubar)
# Configure icons
icon_16 = tk.PhotoImage(file=dashboard_path / "serena-icon-16.png")
icon_32 = tk.PhotoImage(file=dashboard_path / "serena-icon-32.png")
icon_48 = tk.PhotoImage(file=dashboard_path / "serena-icon-48.png")
self.root.iconphoto(False, icon_48, icon_32, icon_16)
# Start the Tkinter event loop
self.root.mainloop()
except Exception as e:
print(f"Error in GUI thread: {e}", file=sys.stderr)
finally:
self.running = False
def _shutdown_server(self) -> None:
log.info("Shutting down Serena")
# noinspection PyUnresolvedReferences
# noinspection PyProtectedMember
os._exit(0)
class GuiLogViewerHandler(logging.Handler):
"""
A logging handler that sends log records to a ThreadedLogViewer instance.
This handler can be integrated with Python's standard logging module
to direct log entries to a GUI log viewer.
"""
def __init__(
self,
log_viewer: GuiLogViewer,
level=logging.NOTSET,
format_string: str | None = "%(levelname)-5s %(asctime)-15s %(name)s:%(funcName)s:%(lineno)d - %(message)s",
):
"""
Initialize the handler with a ThreadedLogViewer instance.
Args:
log_viewer: A ThreadedLogViewer instance that will display the logs
level: The logging level (default: NOTSET which captures all logs)
format_string: the format string
"""
super().__init__(level)
self.log_viewer = log_viewer
self.formatter = logging.Formatter(format_string)
# Start the log viewer if it's not already running
if not self.log_viewer.running:
self.log_viewer.start()
@classmethod
def is_instance_registered(cls) -> bool:
for h in logging.Logger.root.handlers:
if isinstance(h, cls):
return True
return False
def emit(self, record):
"""
Emit a log record to the ThreadedLogViewer.
Args:
record: The log record to emit
"""
try:
# Format the record according to the formatter
msg = self.format(record)
# Convert the level name to a standard format for the viewer
level_prefix = record.levelname
# Add the appropriate prefix if it's not already there
if not msg.startswith(level_prefix):
msg = f"{level_prefix}: {msg}"
self.log_viewer.add_log(msg)
except Exception:
self.handleError(record)
def close(self):
"""
Close the handler and optionally stop the log viewer.
"""
# We don't automatically stop the log viewer here as it might
# be used by other handlers or directly by the application
super().close()
def stop_viewer(self):
"""
Explicitly stop the associated log viewer.
"""
if self.log_viewer.running:
self.log_viewer.stop()
def show_fatal_exception(e: Exception):
"""
Makes sure the given exception is shown in the GUI log viewer,
either an existing instance or a new one.
:param e: the exception to display
"""
# show in new window in main thread (user must close it)
log_viewer = GuiLogViewer("error")
exc_info = "".join(traceback.format_exception(type(e), e, e.__traceback__))
log_viewer.add_log(f"ERROR Fatal exception: {e}\n{exc_info}")
log_viewer.run_gui()