Files
goose/scripts/diagnostics-viewer.py
Douwe Osinga f09ad21eba Diagnostic files copying (#7209)
Co-authored-by: Douwe Osinga <douwe@squareup.com>
2026-02-13 13:50:42 +00:00

823 lines
27 KiB
Python
Executable File

#!/usr/bin/env -S uv run --quiet --script
# /// script
# dependencies = ["textual>=0.87.0", "pyperclip"]
# ///
"""
WARNING: entirely vibe coded. use as a throwaway tool
Diagnostics Viewer - Browse and inspect Goose diagnostics bundles.
Scans for diagnostics zip files, displays their sessions, and provides
an interactive viewer for examining session data, logs, and other files.
"""
import json
import sys
import zipfile
from pathlib import Path
from typing import Optional, Any
import pyperclip
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Static, Tree, ListView, ListItem, Label, Input
from textual.containers import Horizontal, Vertical, VerticalScroll, Container
from textual.binding import Binding
from textual.message import Message
from textual.screen import ModalScreen
def truncate_string(s: str, max_len: int = 100, edge_len: int = 35) -> str:
"""Truncate a string if it's longer than max_len."""
if len(s) <= max_len:
return s
omitted = len(s) - (2 * edge_len)
return f"{s[:edge_len]}[{omitted} more]{s[-edge_len:]}"
class JsonTreeView(Tree):
"""A tree widget for displaying collapsible JSON."""
BINDINGS = [
Binding("ctrl+o", "toggle_all", "Toggle All", show=True),
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.json_data = None
self.show_root = False
self.all_expanded = False
def load_json(self, data: Any, label: str = "JSON"):
"""Load JSON data into the tree."""
self.json_data = data
self.clear()
self.root.label = label
self._build_tree(self.root, data)
# Expand all nodes by default
self.root.expand_all()
def action_toggle_all(self):
"""Toggle expansion of all nodes."""
self.all_expanded = not self.all_expanded
if self.all_expanded:
self.root.expand_all()
else:
self.root.collapse_all()
self.root.expand() # Keep root expanded
def on_tree_node_selected(self, event: Tree.NodeSelected):
"""Handle node selection - show modal for truncated strings."""
node = event.node
# Check if this is a truncated string node
if node.data and isinstance(node.data, dict) and node.data.get("truncated"):
key = node.data["key"]
value = node.data["value"]
# Show the full string in a modal
title = f"Full String Value for '{key}'"
self.app.push_screen(TextViewerModal(title, value))
# Prevent default tree expansion behavior
event.stop()
def _build_tree(self, node, data, max_depth=10, current_depth=0):
"""Recursively build the tree from JSON data."""
if current_depth > max_depth:
node.add_leaf("[dim]...[/dim]")
return
if isinstance(data, dict):
for key, value in data.items():
if isinstance(value, (dict, list)) and value:
# Expand first level by default
child = node.add(f"[cyan]{key}[/cyan]: {{...}}" if isinstance(value, dict) else f"[cyan]{key}[/cyan]: [...]", expand=(current_depth == 0))
child.data = {"key": key, "value": value, "type": type(value).__name__, "expandable": False}
self._build_tree(child, value, max_depth, current_depth + 1)
elif isinstance(value, str):
truncated = truncate_string(value)
if truncated != value:
# Make truncated strings expandable
child = node.add(f"[cyan]{key}[/cyan]: [green]\"{truncated}\"[/green]", expand=False)
child.data = {"key": key, "value": value, "type": "str", "truncated": True, "expandable": True}
child.allow_expand = False # Don't show expand icon initially
else:
node.add_leaf(f"[cyan]{key}[/cyan]: [green]\"{value}\"[/green]")
elif isinstance(value, bool):
# Check bool before int/float since bool is a subclass of int
node.add_leaf(f"[cyan]{key}[/cyan]: [magenta]{str(value).lower()}[/magenta]")
elif isinstance(value, (int, float)):
node.add_leaf(f"[cyan]{key}[/cyan]: [yellow]{value}[/yellow]")
elif value is None:
node.add_leaf(f"[cyan]{key}[/cyan]: [dim]null[/dim]")
else:
node.add_leaf(f"[cyan]{key}[/cyan]: {value}")
elif isinstance(data, list):
for i, item in enumerate(data):
if isinstance(item, (dict, list)) and item:
# Expand first level by default
child = node.add(f"[yellow]{i}[/yellow]: {{...}}" if isinstance(item, dict) else f"[yellow]{i}[/yellow]: [...]", expand=(current_depth == 0))
child.data = {"key": i, "value": item, "type": type(item).__name__, "expandable": False}
self._build_tree(child, item, max_depth, current_depth + 1)
elif isinstance(item, str):
truncated = truncate_string(item)
if truncated != item:
# Make truncated strings expandable
child = node.add(f"[yellow]{i}[/yellow]: [green]\"{truncated}\"[/green]", expand=False)
child.data = {"key": i, "value": item, "type": "str", "truncated": True, "expandable": True}
child.allow_expand = False # Don't show expand icon initially
else:
node.add_leaf(f"[yellow]{i}[/yellow]: [green]\"{item}\"[/green]")
elif isinstance(item, bool):
# Check bool before int/float since bool is a subclass of int
node.add_leaf(f"[yellow]{i}[/yellow]: [magenta]{str(item).lower()}[/magenta]")
elif isinstance(item, (int, float)):
node.add_leaf(f"[yellow]{i}[/yellow]: [yellow]{item}[/yellow]")
elif item is None:
node.add_leaf(f"[yellow]{i}[/yellow]: [dim]null[/dim]")
else:
node.add_leaf(f"[yellow]{i}[/yellow]: {item}")
class TextViewerModal(ModalScreen):
"""Modal screen for viewing long text strings."""
BINDINGS = [
Binding("escape,q,enter", "dismiss", "Close", show=True),
Binding("c", "copy", "Copy", show=True),
]
def __init__(self, title: str, text: str):
super().__init__()
self.title = title
self.text = text
def compose(self) -> ComposeResult:
"""Compose the modal content."""
with Vertical(id="modal-container"):
yield Static(f"[bold]{self.title}[/bold]", id="modal-title")
with VerticalScroll(id="modal-scroll"):
yield Static(self.text, id="modal-text")
yield Static("[dim]Press C to copy, Escape/Q/Enter to close[/dim]", id="modal-footer")
def action_dismiss(self):
"""Dismiss the modal."""
self.app.pop_screen()
def action_copy(self):
"""Copy the text to clipboard."""
pyperclip.copy(self.text)
self.notify("Copied to clipboard")
class SearchOverlay(Container):
"""Search overlay widget."""
def __init__(self):
super().__init__()
self.display = False
def compose(self) -> ComposeResult:
with Horizontal(id="search-container"):
yield Static("Search: ", id="search-label")
yield Input(placeholder="Type to search...", id="search-input")
yield Static("", id="search-results")
class DiagnosticsSession:
"""Represents a diagnostics bundle."""
def __init__(self, zip_path: Path):
self.zip_path = zip_path
self.name = "Unknown Session"
self.session_id = zip_path.stem
self.created_at = zip_path.stat().st_mtime
self._load_session_name()
def _load_session_name(self):
"""Extract session name from session.json."""
try:
with zipfile.ZipFile(self.zip_path, 'r') as zf:
# Find session.json
for name in zf.namelist():
if name.endswith('session.json'):
with zf.open(name) as f:
data = json.load(f)
self.name = data.get('name', 'Unknown Session')
self.session_id = data.get('id', self.zip_path.stem)
break
except Exception as e:
self.name = f"Error loading: {e}"
def get_file_list(self) -> list[str]:
"""Get list of files in the zip, sorted with system.txt first."""
try:
with zipfile.ZipFile(self.zip_path, 'r') as zf:
files = zf.namelist()
# Sort: system.txt first, then session.json, then alphabetically
def sort_key(f):
if f.endswith('system.txt'):
return (0, f)
elif f.endswith('session.json'):
return (1, f)
elif f.endswith('config.yaml'):
return (2, f)
else:
return (3, f)
return sorted(files, key=sort_key)
except Exception:
return []
def read_file(self, filename: str) -> Optional[str]:
"""Read a file from the zip.
Returns:
File content as string, or None if file cannot be read.
"""
try:
with zipfile.ZipFile(self.zip_path, 'r') as zf:
with zf.open(filename) as f:
return f.read().decode('utf-8', errors='replace')
except Exception:
# File not found or cannot be read
return None
class FileContentPane(Vertical):
"""A pane that shows either JSON tree or plain text."""
def __init__(self, title: str):
super().__init__()
self.title = title
self.content_type = "empty"
self.json_data = None
self.text_content = ""
def compose(self) -> ComposeResult:
"""Compose the pane content."""
if self.content_type == "json":
tree = JsonTreeView(self.title)
if self.json_data is not None:
tree.load_json(self.json_data, self.title)
yield tree
elif self.content_type == "text":
with VerticalScroll():
yield Static(self.text_content)
else:
yield Static("[dim]No content[/dim]")
def set_json(self, data: Any):
"""Set JSON content."""
self.content_type = "json"
self.json_data = data
def set_text(self, text: str):
"""Set text content."""
self.content_type = "text"
self.text_content = text
class FileViewer(Vertical):
"""Widget for viewing file contents."""
def __init__(self):
super().__init__()
self.current_session = None
self.current_filename = None
self.current_part = None
def compose(self) -> ComposeResult:
"""Create child widgets."""
with Vertical(id="content-area"):
yield Static("[dim]Select a file to view[/dim]")
yield SearchOverlay()
def update_content(self, session: DiagnosticsSession, filename: str, part: str = None):
"""Update the viewer with new file content.
Args:
session: The diagnostics session
filename: The file to display
part: For JSONL files, either "request" or "responses"
"""
self.current_session = session
self.current_filename = filename
self.current_part = part
content = session.read_file(filename)
if content is None:
self._show_plain(filename, f"[red]Error: Could not read file '{filename}'[/red]")
return
# Check if this is a JSONL file
if filename.endswith('.jsonl') and part:
self._show_jsonl(filename, content, part)
elif filename.endswith('.json'):
self._show_json(filename, content)
else:
self._show_plain(filename, content)
# Auto-focus the content
self.post_message(self.ContentReady())
def _show_jsonl(self, filename: str, content: str, part: str):
"""Show JSONL file - either request or responses part."""
lines = [line.strip() for line in content.strip().split('\n') if line.strip()]
# Parse lines
request_data = None
responses = []
if len(lines) > 0:
try:
request_data = json.loads(lines[0])
except json.JSONDecodeError:
# Skip malformed request line; diagnostics may be truncated or corrupted
pass
for i in range(1, len(lines)):
try:
responses.append(json.loads(lines[i]))
except json.JSONDecodeError:
# Skip individual malformed response lines; show only valid JSON entries
pass
# Show content
content_area = self.query_one("#content-area", Vertical)
content_area.remove_children()
if part == "request" and request_data:
tree = JsonTreeView(f"{filename} - request")
tree.load_json(request_data, f"{filename} - request")
content_area.mount(tree)
elif part == "responses" and responses:
tree = JsonTreeView(f"{filename} - responses")
if len(responses) == 1:
tree.load_json(responses[0], f"{filename} - response")
else:
tree.load_json(responses, f"{filename} - responses")
content_area.mount(tree)
else:
content_area.mount(Static("[red]No data available for this part[/red]"))
def _show_json(self, filename: str, content: str):
"""Show JSON file with collapsible tree."""
# Show content
content_area = self.query_one("#content-area", Vertical)
content_area.remove_children()
tree = JsonTreeView(filename)
try:
data = json.loads(content)
tree.load_json(data, filename)
except json.JSONDecodeError as e:
tree.root.add_leaf(f"[red]Error parsing JSON: {e}[/red]")
content_area.mount(tree)
def _show_plain(self, filename: str, content: str):
"""Show plain text content."""
# Show content
content_area = self.query_one("#content-area", Vertical)
content_area.remove_children()
# Create and mount the scroll container with the content
scroll = VerticalScroll()
content_area.mount(scroll)
scroll.mount(Static(content))
def focus_content(self):
"""Focus the content area."""
try:
# Try to focus a tree if present
tree = self.query_one(JsonTreeView)
tree.focus()
except Exception:
# No JsonTreeView present (e.g., showing plain text), which is fine
pass
def action_search(self):
"""Show search overlay.
TODO: Implement actual search functionality - currently just shows UI.
"""
overlay = self.query_one(SearchOverlay)
overlay.display = not overlay.display
if overlay.display:
search_input = overlay.query_one("#search-input", Input)
search_input.focus()
class ContentReady(Message):
"""Message sent when content is ready to be focused."""
pass
class SessionViewer(Vertical):
"""Widget for viewing a diagnostics session."""
BINDINGS = [
Binding("ctrl+f,cmd+f", "search", "Search", show=True),
Binding("c", "copy_file", "Copy file", show=True),
]
def __init__(self, session: DiagnosticsSession):
super().__init__()
self.session = session
def compose(self) -> ComposeResult:
"""Create child widgets."""
yield Static(f"[bold yellow]Session: {self.session.name}[/bold yellow]", id="session-title")
with Horizontal(id="main-content"):
# Left side: File browser
with Vertical(id="file-browser"):
yield Static("[bold]Files:[/bold]")
tree = Tree("Files", id="file-tree")
tree.show_root = False
# Build file tree
files = self.session.get_file_list()
# Group by directory
dirs = {}
for file in files:
parts = file.split('/')
is_jsonl = file.endswith('.jsonl')
if len(parts) == 1:
# Root file
if is_jsonl:
# Add two entries for JSONL files
tree.root.add_leaf(f"{file} - request", data={"file": file, "part": "request"})
tree.root.add_leaf(f"{file} - responses", data={"file": file, "part": "responses"})
else:
tree.root.add_leaf(file, data={"file": file, "part": None})
else:
# File in directory
dir_name = parts[0]
if dir_name not in dirs:
dirs[dir_name] = tree.root.add(dir_name, expand=True)
file_name = '/'.join(parts[1:])
if is_jsonl:
# Add two entries for JSONL files
dirs[dir_name].add_leaf(f"{file_name} - request", data={"file": file, "part": "request"})
dirs[dir_name].add_leaf(f"{file_name} - responses", data={"file": file, "part": "responses"})
else:
dirs[dir_name].add_leaf(file_name, data={"file": file, "part": None})
yield tree
# Right side: File viewer
yield FileViewer()
def on_mount(self):
"""Handle mount event."""
# Show system.txt by default and select it in tree
files = self.session.get_file_list()
system_file = next((f for f in files if f.endswith('system.txt')), None)
if system_file:
viewer = self.query_one(FileViewer)
viewer.update_content(self.session, system_file)
# Select the first node in the tree
tree = self.query_one("#file-tree", Tree)
if tree.root.children:
tree.select_node(tree.root.children[0])
# Focus the tree initially
tree = self.query_one("#file-tree", Tree)
tree.focus()
def on_tree_node_selected(self, event: Tree.NodeSelected):
"""Handle file selection."""
# Only handle selections from the file tree, not the JSON tree
if event.control.id != "file-tree":
return
# Make sure it's a file (has dict data), not a directory
if event.node.data and isinstance(event.node.data, dict) and event.node.parent:
viewer = self.query_one(FileViewer)
file_path = event.node.data["file"]
part = event.node.data["part"]
viewer.update_content(self.session, file_path, part)
def on_file_viewer_content_ready(self, event: FileViewer.ContentReady):
"""Handle content ready event by focusing the viewer."""
viewer = self.query_one(FileViewer)
viewer.focus_content()
def action_search(self):
"""Toggle search in the file viewer."""
viewer = self.query_one(FileViewer)
viewer.action_search()
def action_copy_file(self):
"""Copy the current file content to clipboard."""
viewer = self.query_one(FileViewer)
if not viewer.current_session or not viewer.current_filename:
self.app.notify("No file selected")
return
content = viewer.current_session.read_file(viewer.current_filename)
if content is None:
self.app.notify("Could not read file")
return
# For JSONL files with a part, extract just that part and pretty-format
if viewer.current_filename.endswith('.jsonl') and viewer.current_part:
lines = [line.strip() for line in content.strip().split('\n') if line.strip()]
if viewer.current_part == "request" and lines:
try:
data = json.loads(lines[0])
content = json.dumps(data, indent=2)
except json.JSONDecodeError:
content = lines[0]
elif viewer.current_part == "responses" and len(lines) > 1:
try:
responses = [json.loads(line) for line in lines[1:]]
if len(responses) == 1:
content = json.dumps(responses[0], indent=2)
else:
content = json.dumps(responses, indent=2)
except json.JSONDecodeError:
content = '\n'.join(lines[1:])
# Pretty-format regular JSON files too
elif viewer.current_filename.endswith('.json'):
try:
data = json.loads(content)
content = json.dumps(data, indent=2)
except json.JSONDecodeError:
pass
pyperclip.copy(content)
self.app.notify("Copied to clipboard")
def on_key(self, event):
"""Handle left/right navigation between panels."""
if event.key == "left":
tree = self.query_one("#file-tree", Tree)
tree.focus()
elif event.key == "right":
viewer = self.query_one(FileViewer)
viewer.focus_content()
class SessionList(Vertical):
"""Widget for listing available sessions."""
def __init__(self, sessions: list[DiagnosticsSession]):
super().__init__()
self.sessions = sessions
def compose(self) -> ComposeResult:
"""Create child widgets."""
yield Static("[bold yellow]Available Diagnostics Sessions[/bold yellow]\n")
if not self.sessions:
yield Static("[red]No diagnostics files found[/red]")
else:
yield Static(f"[dim]Found {len(self.sessions)} session(s)[/dim]\n")
yield ListView(id="session-list")
def on_mount(self):
"""Populate the list after mounting."""
list_view = self.query_one(ListView)
for session in self.sessions:
item = ListItem(
Label(f"{session.name}\n[dim]{session.zip_path.name}[/dim]"),
name=session.zip_path.name
)
list_view.append(item)
class DiagnosticsApp(App):
"""Diagnostics viewer application."""
# Disable command palette (Ctrl+\)
ENABLE_COMMAND_PALETTE = False
CSS = """
Screen {
background: $surface;
}
/* Modal styles */
TextViewerModal {
align: center middle;
}
#modal-container {
width: 80%;
height: 80%;
background: $surface;
border: thick $primary;
padding: 1;
}
#modal-title {
background: $primary;
color: $text;
padding: 1;
text-align: center;
dock: top;
}
#modal-scroll {
height: 1fr;
border: solid $accent;
padding: 1;
margin: 1 0;
}
#modal-text {
width: 100%;
}
#modal-footer {
text-align: center;
dock: bottom;
}
#session-title {
padding: 1;
background: $primary;
color: $text;
text-align: center;
height: 3;
}
#main-content {
height: 100%;
}
#file-browser {
width: 30%;
border-right: solid $primary;
padding: 1;
}
FileViewer {
width: 70%;
height: 100%;
}
#content-area {
height: 100%;
padding: 1;
}
JsonTreeView {
height: 100%;
scrollbar-gutter: stable;
}
#search-container {
height: 3;
background: $panel;
padding: 1;
border-top: solid $primary;
}
#search-label {
width: auto;
margin-right: 1;
}
#search-input {
width: 1fr;
margin-right: 1;
}
#search-results {
width: auto;
}
SearchOverlay {
height: auto;
}
#session-list {
height: 100%;
}
ListView {
background: $surface;
}
ListItem {
padding: 1;
}
ListItem:hover {
background: $primary 30%;
}
Tree {
height: 100%;
}
Tree:focus {
border: solid $accent;
}
"""
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("escape", "back", "Back to list"),
Binding("ctrl+f,cmd+f", "search", "Search", show=False),
]
def __init__(self, diagnostics_dir: Path):
super().__init__()
self.diagnostics_dir = diagnostics_dir
self.sessions = []
self.current_view = None
def compose(self) -> ComposeResult:
"""Create child widgets."""
yield Header()
yield Footer()
def on_mount(self):
"""Handle mount event."""
self.title = "Goose Diagnostics Viewer"
self.scan_diagnostics()
self.show_session_list()
def scan_diagnostics(self):
"""Scan for diagnostics zip files."""
self.sessions = []
# Find all diagnostics zip files
for zip_path in self.diagnostics_dir.glob("diagnostics*.zip"):
session = DiagnosticsSession(zip_path)
self.sessions.append(session)
# Sort by creation time (newest first)
self.sessions.sort(key=lambda s: s.created_at, reverse=True)
def show_session_list(self):
"""Show the session list view."""
if self.current_view:
self.current_view.remove()
self.current_view = SessionList(self.sessions)
self.mount(self.current_view)
def show_session_viewer(self, session: DiagnosticsSession):
"""Show the session viewer."""
if self.current_view:
self.current_view.remove()
self.current_view = SessionViewer(session)
self.mount(self.current_view)
def on_list_view_selected(self, event: ListView.Selected):
"""Handle session selection."""
# Find the session by zip name
session_name = event.item.name
session = next((s for s in self.sessions if s.zip_path.name == session_name), None)
if session:
self.show_session_viewer(session)
def action_back(self):
"""Go back to session list."""
if isinstance(self.current_view, SessionViewer):
self.show_session_list()
def action_quit(self):
"""Quit the application."""
self.exit()
def action_search(self):
"""Toggle search."""
if isinstance(self.current_view, SessionViewer):
self.current_view.action_search()
def main():
"""Main entry point."""
# Get diagnostics directory from args or use default
if len(sys.argv) > 1:
diagnostics_dir = Path(sys.argv[1]).expanduser()
else:
diagnostics_dir = Path.home() / "Downloads"
if not diagnostics_dir.exists():
print(f"Error: Directory '{diagnostics_dir}' not found", file=sys.stderr)
sys.exit(1)
app = DiagnosticsApp(diagnostics_dir)
app.run()
if __name__ == "__main__":
main()