import { css, html, LitElement, TemplateResult } from "lit"; import { property, state } from "lit/decorators.js"; import { ResizableDrawerController } from "../lib/resizable-drawer-controller.ts"; import type { RuntimeTelemetryMarkerResult } from "@commontools/runner"; import { isRecord } from "@commontools/utils/types"; import type { DebuggerController } from "../lib/debugger-controller.ts"; import "./SchedulerGraphView.ts"; // Register x-scheduler-graph component import type { Logger, LoggerBreakdown } from "@commontools/utils/logger"; /** * Hierarchical topic definitions for filtering telemetry events. * Topics can have subtopics for more granular filtering. */ export const TOPIC_HIERARCHY = { scheduler: { label: "Scheduler", icon: "⚙️", color: "#3b82f6", // blue subtopics: { run: { label: "Run", pattern: "scheduler.run" }, invocation: { label: "Invocation", pattern: "scheduler.invocation" }, }, }, storage: { label: "Storage", icon: "💾", color: "#10b981", // green subtopics: { push: { label: "Push", pattern: "storage.push" }, pull: { label: "Pull", pattern: "storage.pull" }, connection: { label: "Connection", pattern: "storage.connection" }, subscription: { label: "Subscriptions", pattern: "storage.subscription" }, }, }, cells: { label: "Cells", icon: "📝", color: "#8b5cf6", // violet subtopics: { update: { label: "Update", pattern: "cell.update" }, }, }, } as const; export type TopicKey = keyof typeof TOPIC_HIERARCHY; export type SubtopicKey = keyof typeof TOPIC_HIERARCHY[T]["subtopics"]; /** * Shell Debugger view for monitoring RuntimeTelemetry events in real-time. * * Provides a developer tool interface showing: * - All telemetry events with timestamps and details * - Topic-based filtering for focused debugging * - Search functionality for event content * - Event expansion for detailed inspection * - Performance metrics and statistics * * Features a resizable drawer interface similar to the Inspector * but focused on telemetry events rather than storage operations. */ export class XDebuggerView extends LitElement { static override styles = css` :host { position: fixed; bottom: 0; left: 0; right: 0; z-index: 50; } .debugger-container { background-color: #0f172a; /* slate-900 */ color: white; box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.5); border-top: 1px solid #334155; /* slate-700 */ font-size: 0.75rem; display: flex; flex-direction: column; transition: transform 0.3s ease-in-out; } .debugger-container[hidden] { transform: translateY(100%); } .resize-handle { height: 1.5rem; width: 100%; cursor: ns-resize; display: flex; align-items: center; justify-content: center; border-bottom: 1px solid #334155; /* slate-700 */ flex-shrink: 0; } .resize-grip { width: 4rem; height: 0.25rem; background-color: #475569; /* slate-600 */ border-radius: 9999px; } .header-container { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 1rem; border-bottom: 1px solid #334155; /* slate-700 */ background-color: #1e293b; /* slate-800 */ } .title { font-weight: 600; font-size: 0.875rem; color: #cbd5e1; /* slate-300 */ display: flex; align-items: center; gap: 0.5rem; } .title-icon { font-size: 1rem; } .stats { display: flex; gap: 1rem; font-size: 0.6875rem; color: #94a3b8; /* slate-400 */ } .stat { display: flex; align-items: center; gap: 0.25rem; } .stat-label { opacity: 0.7; } .stat-value { font-family: monospace; color: #cbd5e1; /* slate-300 */ } .toolbar-container { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 1rem; border-bottom: 1px solid #334155; /* slate-700 */ gap: 1rem; } .topics-filter { display: flex; gap: 0.25rem; flex-wrap: wrap; } .topic-button-group { position: relative; display: inline-flex; } .topic-toggle { padding: 0.25rem 0.5rem; background-color: #1e293b; /* slate-800 */ border: 1px solid #334155; /* slate-700 */ border-radius: 0.375rem 0 0 0.375rem; font-size: 0.6875rem; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; gap: 0.25rem; color: #64748b; /* slate-500 */ } .topic-toggle:hover { background-color: #334155; /* slate-700 */ } .topic-toggle.active { background-color: var(--topic-color); border-color: var(--topic-color); color: white; opacity: 0.9; } .topic-toggle.partial { background-color: var(--topic-color); background-image: repeating-linear-gradient( 45deg, transparent, transparent 2px, rgba(0, 0, 0, 0.15) 2px, rgba(0, 0, 0, 0.15) 4px ); border-color: var(--topic-color); color: white; opacity: 0.9; } .dropdown-trigger { padding: 0.25rem 0.375rem; background-color: #334155; /* Default gray background */ border: 1px solid #334155; /* slate-700 */ border-left: none; border-radius: 0 0.375rem 0.375rem 0; font-size: 0.5rem; cursor: pointer; transition: all 0.2s; color: #94a3b8; /* slate-400 */ } .dropdown-trigger:hover { background-color: #475569; /* slate-600 on hover */ } .topic-toggle.active + .dropdown-trigger, .topic-toggle.partial + .dropdown-trigger { border-color: var(--topic-color); background-color: var(--topic-color); filter: brightness(0.8); /* Slightly darker than main button */ color: white; } .subtopic-dropdown { position: absolute; top: 100%; left: 0; margin-top: 0.25rem; background-color: #1e293b; /* slate-800 */ border: 1px solid #334155; /* slate-700 */ border-radius: 0.375rem; padding: 0.5rem; min-width: 10rem; z-index: 100; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); } .subtopic-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem; font-size: 0.6875rem; color: #cbd5e1; /* slate-300 */ cursor: pointer; border-radius: 0.25rem; } .subtopic-item:hover { background-color: #334155; /* slate-700 */ } .subtopic-checkbox { width: 0.875rem; height: 0.875rem; accent-color: var(--topic-color); } .topic-icon { font-size: 0.75rem; } .controls { display: flex; align-items: center; gap: 0.5rem; } .search-container { position: relative; } .search-input { width: 12rem; padding: 0.25rem 0.5rem; font-size: 0.6875rem; background-color: #1e293b; /* slate-800 */ border: 1px solid #334155; /* slate-700 */ border-radius: 0.375rem; color: white; outline: none; } .search-input:focus { border-color: #3b82f6; /* blue-500 */ } .search-input.has-value { border-color: #3b82f6; /* blue-500 */ } .clear-search { position: absolute; right: 0.25rem; top: 50%; transform: translateY(-50%); background: none; border: none; color: #94a3b8; /* slate-400 */ cursor: pointer; padding: 0.125rem; line-height: 1; } .clear-search:hover { color: white; } .action-button { background-color: #334155; /* slate-700 */ color: #94a3b8; /* slate-400 */ border: none; padding: 0.25rem 0.5rem; border-radius: 0.375rem; font-size: 0.6875rem; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; gap: 0.25rem; } .action-button:hover { background-color: #475569; /* slate-600 */ color: white; } .content-area { flex: 1; overflow: auto; padding: 0.5rem; font-family: monospace; } .content-area.resizing { pointer-events: none; } .empty-state { color: #64748b; /* slate-500 */ font-style: italic; text-align: center; padding: 2rem; font-size: 0.875rem; } .events-list { display: flex; flex-direction: column; gap: 0.125rem; } .event-item { padding: 0.375rem 0.5rem; background-color: #1e293b; /* slate-800 */ border-radius: 0.375rem; border: 1px solid #334155; /* slate-700 */ transition: all 0.2s; cursor: pointer; } .event-item:hover { background-color: #334155; /* slate-700 */ border-color: #475569; /* slate-600 */ } .event-item.expanded { background-color: #334155; /* slate-700 */ } .event-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem; } .event-main { display: flex; align-items: flex-start; gap: 0.5rem; flex: 1; } .event-icon { font-size: 0.875rem; margin-top: 0.125rem; } .event-content { flex: 1; min-width: 0; } .event-type { font-weight: 600; font-size: 0.75rem; color: #e2e8f0; /* slate-200 */ margin-bottom: 0.125rem; } .event-details { font-size: 0.6875rem; color: #94a3b8; /* slate-400 */ display: flex; flex-wrap: wrap; gap: 0.5rem; } .event-detail { display: flex; align-items: center; gap: 0.25rem; } .event-detail-label { opacity: 0.7; } .event-detail-value { color: #cbd5e1; /* slate-300 */ font-family: monospace; } .event-time { font-size: 0.6875rem; color: #64748b; /* slate-500 */ white-space: nowrap; } .event-expanded { margin-top: 0.5rem; padding: 0.5rem; background-color: #0f172a; /* slate-900 */ border-radius: 0.25rem; position: relative; } .event-expanded pre { margin: 0; font-size: 0.6875rem; color: #cbd5e1; /* slate-300 */ overflow: auto; max-height: 12rem; } .event-expanded.full-height pre { max-height: none; } .json-controls { position: absolute; top: 0.25rem; right: 0.25rem; display: flex; gap: 0.25rem; } .json-control-btn { background-color: #334155; /* slate-700 */ color: white; border: none; padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.625rem; cursor: pointer; opacity: 0.8; transition: opacity 0.2s; } .json-control-btn:hover { opacity: 1; background-color: #475569; /* slate-600 */ } .paused-indicator { background-color: #dc2626; /* red-600 */ color: white; padding: 0.125rem 0.5rem; border-radius: 0.25rem; font-size: 0.6875rem; font-weight: 600; animation: pulse 2s infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } } .tabs-container { display: flex; gap: 0; border-bottom: 1px solid #334155; /* slate-700 */ background-color: #1e293b; /* slate-800 */ padding: 0 1rem; } .tab-button { padding: 0.5rem 1rem; background: none; border: none; border-bottom: 2px solid transparent; font-family: monospace; font-size: 0.75rem; color: #94a3b8; /* slate-400 */ cursor: pointer; transition: all 0.2s; } .tab-button:hover { color: #cbd5e1; /* slate-300 */ background-color: rgba(255, 255, 255, 0.05); } .tab-button.active { color: #e2e8f0; /* slate-200 */ border-bottom-color: #3b82f6; /* blue-500 */ } .watch-list { display: flex; flex-direction: column; gap: 0.125rem; } .watch-item { display: grid; grid-template-columns: 1fr auto auto auto; gap: 0.75rem; padding: 0.375rem 0.5rem; background-color: #1e293b; /* slate-800 */ border-radius: 0.375rem; border: 1px solid #334155; /* slate-700 */ align-items: center; font-size: 0.6875rem; } .watch-item:hover { background-color: #334155; /* slate-700 */ } .watch-label { color: #cbd5e1; /* slate-300 */ font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .watch-value { color: #94a3b8; /* slate-400 */ font-family: monospace; max-width: 20rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .watch-updates { color: #64748b; /* slate-500 */ font-family: monospace; text-align: right; min-width: 4rem; } .unwatch-button { background-color: #334155; /* slate-700 */ color: #94a3b8; /* slate-400 */ border: none; padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.625rem; cursor: pointer; transition: all 0.2s; font-family: monospace; } .unwatch-button:hover { background-color: #dc2626; /* red-600 */ color: white; } .watch-empty { color: #64748b; /* slate-500 */ font-style: italic; text-align: center; padding: 2rem; font-size: 0.75rem; line-height: 1.5; } /* Loggers pane styles */ .loggers-toolbar { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; border-bottom: 1px solid #334155; background-color: #1e293b; } .loggers-total { margin-left: auto; color: #94a3b8; font-family: monospace; font-size: 0.75rem; } .loggers-empty { color: #64748b; font-style: italic; text-align: center; padding: 2rem; font-size: 0.75rem; } .loggers-list { display: flex; flex-direction: column; gap: 0.125rem; padding: 0.5rem; overflow-y: auto; } .logger-item { background-color: #1e293b; border-radius: 0.375rem; border: 1px solid #334155; overflow: hidden; } .logger-item.disabled { opacity: 0.5; } .logger-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.5rem; cursor: pointer; font-size: 0.75rem; } .logger-header:hover { background-color: #334155; } .logger-expand { color: #64748b; font-size: 0.625rem; width: 1rem; text-align: center; } .logger-name { color: #e2e8f0; font-family: monospace; flex: 1; } .logger-count { color: #94a3b8; font-family: monospace; } .logger-toggle { background: none; border: none; cursor: pointer; font-size: 0.875rem; padding: 0 0.25rem; } .logger-toggle.on { color: #10b981; } .logger-toggle.off { color: #64748b; } .logger-controls { display: flex; align-items: center; gap: 0.25rem; margin-left: auto; } .logger-level { background-color: #1e293b; color: #94a3b8; border: 1px solid #334155; border-radius: 3px; font-size: 0.625rem; padding: 0.125rem 0.25rem; cursor: pointer; } .logger-level:hover { border-color: #475569; } .logger-keys { padding: 0.25rem 0.5rem 0.5rem 1.5rem; border-top: 1px solid #334155; background-color: #0f172a; } .logger-key { display: flex; align-items: center; justify-content: space-between; padding: 0.125rem 0; font-size: 0.6875rem; font-family: monospace; } .key-name { color: #cbd5e1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; } .key-counts { display: flex; gap: 0.5rem; color: #64748b; } .count-debug { color: #6b7280; } .count-info { color: #10b981; } .count-warn { color: #eab308; } .count-error { color: #ef4444; } .count-total { color: #94a3b8; font-weight: 500; } .delta { font-size: 0.625rem; margin-left: 0.25rem; } .delta.positive { color: #10b981; } .delta.negative { color: #3b82f6; } `; @property({ type: Boolean }) visible = false; @property({ attribute: false }) telemetryMarkers: RuntimeTelemetryMarkerResult[] = []; @property({ attribute: false }) debuggerController?: DebuggerController; @state() private _activeTab: "events" | "watch" | "scheduler" | "loggers" = "events"; @state() private activeSubtopics = new Set(); // Logger stats tracking @state() private loggerBaseline: Record | null = null; @state() private loggerSample: Record | null = null; @state() private expandedLoggers = new Set(); @state() private openDropdowns = new Set(); @state() private searchText = ""; @state() private expandedEvents = new Set(); @state() private fullHeightEvents = new Set(); @state() private isPaused = false; @state() private pausedMarkers: RuntimeTelemetryMarkerResult[] = []; private resizeController = new ResizableDrawerController(this, { initialHeight: 300, minHeight: 150, maxHeightFactor: 0.8, resizeDirection: "up", storageKey: "debuggerDrawerHeight", }); override connectedCallback() { super.connectedCallback(); document.addEventListener("keydown", this.handleKeyDown); document.addEventListener("click", this.handleDocumentClick); // Initialize with all subtopics active this.initializeAllSubtopics(); } override disconnectedCallback() { document.removeEventListener("keydown", this.handleKeyDown); document.removeEventListener("click", this.handleDocumentClick); super.disconnectedCallback(); } override updated(changedProperties: Map) { super.updated(changedProperties); // When new markers come in and we're not paused, update the paused markers if (changedProperties.has("telemetryMarkers") && !this.isPaused) { this.pausedMarkers = [...this.telemetryMarkers]; } } private handleKeyDown = (e: KeyboardEvent) => { // Clear search on Escape if (e.key === "Escape" && this.searchText) { e.preventDefault(); this.searchText = ""; } // Toggle pause on Space when debugger is visible if (e.key === " " && this.visible && e.target === document.body) { e.preventDefault(); this.togglePause(); } }; private initializeAllSubtopics() { const allSubtopics = new Set(); for (const [topicKey, topic] of Object.entries(TOPIC_HIERARCHY)) { for (const subtopicKey of Object.keys(topic.subtopics)) { allSubtopics.add(`${topicKey}.${subtopicKey}`); } } this.activeSubtopics = allSubtopics; } private handleDocumentClick = (e: Event) => { // Close dropdowns when clicking outside const target = e.target as HTMLElement; if (!target.closest(".topic-button-group")) { this.openDropdowns = new Set(); } }; private toggleDropdown(topicKey: TopicKey, e: Event) { e.stopPropagation(); const newDropdowns = new Set(this.openDropdowns); if (newDropdowns.has(topicKey)) { newDropdowns.delete(topicKey); } else { // Close other dropdowns newDropdowns.clear(); newDropdowns.add(topicKey); } this.openDropdowns = newDropdowns; } private toggleTopic(topicKey: TopicKey) { const topic = TOPIC_HIERARCHY[topicKey]; const subtopicKeys = Object.keys(topic.subtopics); const fullKeys = subtopicKeys.map((sk) => `${topicKey}.${sk}`); // Check current state const activeCount = fullKeys.filter((k) => this.activeSubtopics.has(k)).length; const newSubtopics = new Set(this.activeSubtopics); if (activeCount === 0) { // None active -> activate all fullKeys.forEach((k) => newSubtopics.add(k)); } else { // Some or all active -> deactivate all fullKeys.forEach((k) => newSubtopics.delete(k)); } this.activeSubtopics = newSubtopics; } private toggleSubtopic(topicKey: TopicKey, subtopicKey: string) { const fullKey = `${topicKey}.${subtopicKey}`; const newSubtopics = new Set(this.activeSubtopics); if (newSubtopics.has(fullKey)) { newSubtopics.delete(fullKey); } else { newSubtopics.add(fullKey); } this.activeSubtopics = newSubtopics; } private getTopicState(topicKey: TopicKey): "active" | "partial" | "inactive" { const topic = TOPIC_HIERARCHY[topicKey]; const subtopicKeys = Object.keys(topic.subtopics); const fullKeys = subtopicKeys.map((sk) => `${topicKey}.${sk}`); const activeCount = fullKeys.filter((k) => this.activeSubtopics.has(k)).length; if (activeCount === 0) return "inactive"; if (activeCount === subtopicKeys.length) return "active"; return "partial"; } private toggleAllTopics() { if (this.activeSubtopics.size > 0) { // Some selected, deselect all this.activeSubtopics = new Set(); } else { // None selected, select all this.initializeAllSubtopics(); } } private clearEvents() { // Dispatch event to clear telemetry in runtime this.dispatchEvent( new CustomEvent("clear-telemetry", { bubbles: true, composed: true, }), ); // Clear local state this.pausedMarkers = []; this.expandedEvents.clear(); this.fullHeightEvents.clear(); } private togglePause() { this.isPaused = !this.isPaused; if (!this.isPaused) { // When unpausing, update to latest markers this.pausedMarkers = [...this.telemetryMarkers]; } } private toggleEventExpand(index: number) { const newSet = new Set(this.expandedEvents); if (newSet.has(index)) { newSet.delete(index); // Also remove from full height when collapsing const fullHeightSet = new Set(this.fullHeightEvents); fullHeightSet.delete(index); this.fullHeightEvents = fullHeightSet; } else { newSet.add(index); } this.expandedEvents = newSet; } private toggleJsonFullHeight(index: number) { const newSet = new Set(this.fullHeightEvents); if (newSet.has(index)) { newSet.delete(index); } else { newSet.add(index); } this.fullHeightEvents = newSet; } private async copyJson(data: RuntimeTelemetryMarkerResult) { try { const jsonString = JSON.stringify(data, null, 2); await navigator.clipboard.writeText(jsonString); } catch (err) { console.error("Failed to copy JSON:", err); } } private formatTime(timestamp: number): string { const date = new Date(timestamp); return `${date.toLocaleTimeString()}.${ date.getMilliseconds().toString().padStart(3, "0") }`; } private getEventIcon(marker: RuntimeTelemetryMarkerResult): string { const type = marker.type; // Try to find a matching topic for (const [_topicKey, topic] of Object.entries(TOPIC_HIERARCHY)) { for (const [_subtopicKey, subtopic] of Object.entries(topic.subtopics)) { if (type.startsWith(subtopic.pattern)) { return topic.icon; } } } // Default icon return "📊"; } private getEventColor(marker: RuntimeTelemetryMarkerResult): string { const type = marker.type; // Try to find a matching topic for (const [_topicKey, topic] of Object.entries(TOPIC_HIERARCHY)) { for (const [_subtopicKey, subtopic] of Object.entries(topic.subtopics)) { if (type.startsWith(subtopic.pattern)) { return topic.color; } } } // Default color return "#64748b"; } private matchesActiveTopics(marker: RuntimeTelemetryMarkerResult): boolean { if (this.activeSubtopics.size === 0) return false; const type = marker.type; // Check if the event matches any active subtopic for (const [topicKey, topic] of Object.entries(TOPIC_HIERARCHY)) { for (const [subtopicKey, subtopic] of Object.entries(topic.subtopics)) { const fullKey = `${topicKey}.${subtopicKey}`; if ( this.activeSubtopics.has(fullKey) && type.startsWith(subtopic.pattern) ) { return true; } } } return false; } private matchesSearch(marker: RuntimeTelemetryMarkerResult): boolean { if (!this.searchText) return true; const searchLower = this.searchText.toLowerCase(); // Use truncated stringify to avoid serializing huge objects on every search const markerStr = this.safeJsonStringify(marker, 5000).toLowerCase(); return markerStr.includes(searchLower); } private getFilteredEvents(): RuntimeTelemetryMarkerResult[] { const markers = this.isPaused ? this.pausedMarkers : this.telemetryMarkers; return markers.filter((marker) => this.matchesActiveTopics(marker) && this.matchesSearch(marker) ); } private renderEventDetails( marker: RuntimeTelemetryMarkerResult, ): TemplateResult[] { const details = []; // Extract key-value pairs from the marker (excluding type and timeStamp) const { type, timeStamp: _, ...rest } = marker; // Special handling for different event types if (type === "scheduler.run" || type === "scheduler.invocation") { const eventData = rest as Record; const actionId = typeof eventData.actionId === "string" ? eventData.actionId : undefined; const handlerId = typeof eventData.handlerId === "string" ? eventData.handlerId : undefined; const info = isRecord(eventData.actionInfo) ? eventData.actionInfo : isRecord(eventData.handlerInfo) ? eventData.handlerInfo : undefined; const idLabel = actionId ? "action" : "handler"; const idValue = actionId ?? handlerId; if (idValue) { details.push(html`
${idLabel}: ${idValue}
`); } if (info) { if (typeof info.recipeName === "string") { details.push(html`
pattern: ${info.recipeName}
`); } if (typeof info.moduleName === "string") { details.push(html`
module: ${info.moduleName}
`); } if (Array.isArray(info.reads) && info.reads.length > 0) { details.push(html`
reads: ${info.reads .length} dependencies
`); } if (Array.isArray(info.writes) && info.writes.length > 0) { details.push(html`
writes: ${info.writes .length} outputs
`); } } if (eventData.error) { details.push(html`
error: ${eventData .error}
`); } } else if (type === "cell.update") { const change = (rest as Record).change; if (isRecord(change)) { if (isRecord(change.address)) { if (change.address?.id) { details.push(html`
cell: ${change.address.id}
`); } if (change.address?.path) { details.push(html`
path: ${(change.address .path as string[]).join( "/", )}
`); } if (change.address?.type) { details.push(html`
type: ${change.address.type}
`); } } // Show a summary of the change const hasBeforeAfter = change.before !== undefined || change.after !== undefined; if (hasBeforeAfter) { details.push(html`
change: ${change.before === undefined ? "created" : change.after === undefined ? "deleted" : "updated"}
`); } } } else { // Default rendering for other event types for (const [key, value] of Object.entries(rest)) { if (value !== undefined && value !== null) { details.push(html`
${key}: ${typeof value === "string" ? value : typeof value === "boolean" ? value.toString() : typeof value === "number" ? value.toString() : this.safeJsonStringify(value, 100)}
`); } } } return details; } private formatValue(value: unknown): string { if (value === null) return "null"; if (value === undefined) return "undefined"; if (typeof value === "string") return `"${value}"`; if (typeof value === "number" || typeof value === "boolean") { return String(value); } // For objects/arrays, truncate JSON representation const json = this.safeJsonStringify(value, 60); return json; } /** * Safely stringify a value with size limits to prevent context blowout. * Truncates large strings, arrays, and objects. */ private safeJsonStringify( value: unknown, maxLength: number, indent?: number, ): string { const truncatedValue = this.truncateValue(value, 3); // Max depth 3 try { const json = JSON.stringify(truncatedValue, null, indent); if (json.length > maxLength) { return json.slice(0, maxLength - 3) + "..."; } return json; } catch { return "[Unable to serialize]"; } } /** * Recursively truncate a value to prevent huge objects from being serialized. * Replaces functions, large strings, large arrays, and deep objects with summaries. */ private truncateValue(value: unknown, maxDepth: number): unknown { if (maxDepth <= 0) { return "[...]"; } if (value === null || value === undefined) { return value; } if (typeof value === "function") { // Serialize functions as objects with name + all enumerable properties const fn = value as unknown as & { name?: string } & Record; const result: Record = { name: fn.name || "[anonymous]", }; // Copy enumerable properties (actions often have metadata attached) for (const key of Object.keys(fn)) { result[key] = this.truncateValue(fn[key], maxDepth - 1); } return result; } if (typeof value === "string") { if (value.length > 200) { return value.slice(0, 197) + "..."; } return value; } if (typeof value === "number" || typeof value === "boolean") { return value; } if (Array.isArray(value)) { if (value.length > 10) { return [ ...value.slice(0, 5).map((v) => this.truncateValue(v, maxDepth - 1)), `[... ${value.length - 5} more items]`, ]; } return value.map((v) => this.truncateValue(v, maxDepth - 1)); } if (typeof value === "object") { const obj = value as Record; const keys = Object.keys(obj); // Skip huge objects entirely (likely cell values or function metadata) if (keys.length > 20) { return `[Object with ${keys.length} keys]`; } const result: Record = {}; for (const key of keys) { result[key] = this.truncateValue(obj[key], maxDepth - 1); } return result; } return String(value); } private getCellLabel( watch: { label?: string; cellLink: { id: string } }, ): string { if (watch.label) return watch.label; // Generate short ID from full ID const id = watch.cellLink.id; const shortId = id.split(":").pop()?.slice(-6) ?? "???"; return `#${shortId}`; } // ============================================================ // Logger stats methods // ============================================================ private getLoggerRegistry(): Record { const global = globalThis as unknown as { commontools?: { logger?: Record }; }; return global.commontools?.logger ?? {}; } private getLoggerBreakdown(): Record { const global = globalThis as unknown as { commontools?: { getLoggerCountsBreakdown?: () => Record< string, LoggerBreakdown | number >; }; }; return global.commontools?.getLoggerCountsBreakdown?.() ?? { total: 0 }; } private getBreakdownTotal( breakdown: Record | null, ): number { if (!breakdown) return 0; const total = breakdown.total; return typeof total === "number" ? total : 0; } private sampleLoggerCounts(): void { this.loggerSample = this.getLoggerBreakdown(); } private resetBaseline(): void { this.loggerBaseline = this.getLoggerBreakdown(); this.sampleLoggerCounts(); } private toggleLogger(name: string): void { const registry = this.getLoggerRegistry(); const logger = registry[name]; if (logger) { logger.disabled = !logger.disabled; this.requestUpdate(); } } private setLoggerLevel( name: string, level: "debug" | "info" | "warn" | "error", ): void { const registry = this.getLoggerRegistry(); const logger = registry[name]; if (logger) { logger.level = level; this.requestUpdate(); } } private toggleExpandLogger(name: string): void { if (this.expandedLoggers.has(name)) { this.expandedLoggers.delete(name); } else { this.expandedLoggers.add(name); } this.requestUpdate(); } private getDelta(current: number, baseline: number | undefined): number { return current - (baseline ?? 0); } private formatDelta(delta: number): string { if (delta === 0) return "0"; return delta > 0 ? `+${delta}` : `${delta}`; } private renderLoggers(): TemplateResult { const sample = this.loggerSample ?? this.getLoggerBreakdown(); const baseline = this.loggerBaseline; const registry = this.getLoggerRegistry(); const loggerNames = Object.keys(sample).filter((k) => k !== "total"); if (loggerNames.length === 0) { return html`
No loggers registered yet.
`; } const sampleTotal = this.getBreakdownTotal(sample); const baselineTotal = this.getBreakdownTotal(baseline); const totalDelta = this.getDelta(sampleTotal, baselineTotal); return html`
Total: ${sampleTotal} ${baseline ? html` (${this.formatDelta(totalDelta)}) ` : ""}
${loggerNames.map((name) => { const loggerData = sample[name] as LoggerBreakdown; const baselineData = baseline?.[name] as LoggerBreakdown | undefined; const logger = registry[name]; const isExpanded = this.expandedLoggers.has(name); const isDisabled = logger?.disabled ?? false; const currentLevel = logger?.level ?? "info"; const delta = this.getDelta(loggerData.total, baselineData?.total); return html`
${isExpanded ? "▼" : "▶"} ${name} ${loggerData.total} ${baseline ? html` (${this.formatDelta(delta)}) ` : ""}
${isExpanded ? html`
${Object.entries(loggerData) .filter(([k]) => k !== "total") .sort((a, b) => (b[1] as { total: number }).total - (a[1] as { total: number }).total ) .map(([key, counts]) => { const c = counts as { debug: number; info: number; warn: number; error: number; total: number; }; const baselineCounts = baselineData?.[key] as | typeof c | undefined; const keyDelta = this.getDelta( c.total, baselineCounts?.total, ); return html`
${key} ${c .debug} ${c .info} ${c .warn} ${c .error} = ${c.total} ${baseline ? html` (${this.formatDelta( keyDelta, )}) ` : ""}
`; })}
` : ""}
`; })}
`; } private renderTabs() { return html`
`; } private renderWatchList() { const watchedCells = this.debuggerController?.getWatchedCells() ?? []; if (watchedCells.length === 0) { return html`
No cells being watched.
Hold Alt and hover over a ct-cell-context to access watch controls.
`; } return html`
${watchedCells.map((watch) => html`
${this.getCellLabel(watch)}
${this.formatValue( watch.lastValue, )}
${watch.updateCount} updates
` )}
`; } private renderEvents() { const events = this.getFilteredEvents(); if (events.length === 0) { return html`
${this.searchText || this.activeSubtopics.size === 0 ? "No events matching filters" : "No telemetry events yet"}
`; } // Show newest first const reversedEvents = [...events].reverse(); return html`
${reversedEvents.map((marker, index) => { const actualIndex = events.length - 1 - index; const isExpanded = this.expandedEvents.has(actualIndex); const color = this.getEventColor(marker); return html`
${this.getEventIcon(marker)}
${marker.type}
${this.renderEventDetails(marker)}
${this.formatTime(marker.timeStamp)}
${isExpanded ? html`
${this.safeJsonStringify(marker, 10000, 2)}
` : ""}
`; })}
`; } override render() { const containerStyle = `height: ${this.resizeController.drawerHeight}px`; const allEvents = this.isPaused ? this.pausedMarkers : this.telemetryMarkers; const filteredCount = this.visible ? this.getFilteredEvents().length : 0; return html` ${this.visible ? html`
🐛 Shell Debugger ${this.isPaused ? html` PAUSED ` : ""}
Events: ${filteredCount} / ${allEvents .length}
Filters: ${this.activeSubtopics.size}
${this.renderTabs()} ${this._activeTab === "scheduler" ? html` ` : this._activeTab === "loggers" ? html`
${this.renderLoggers()}
` : this._activeTab === "events" ? html`
${Object.entries(TOPIC_HIERARCHY).map(([key, topic]) => { const topicKey = key as TopicKey; const state = this.getTopicState(topicKey); const subtopicKeys = Object.keys(topic.subtopics); const hasDropdown = subtopicKeys.length > 0; // Show dropdown even for single subtopic const isDropdownOpen = this.openDropdowns.has(topicKey); return html`
${hasDropdown ? html` ${isDropdownOpen ? html`
${Object.entries(topic.subtopics).map( ([subKey, subtopic]) => { const fullKey = `${topicKey}.${subKey}`; const isChecked = this.activeSubtopics .has( fullKey, ); return html` `; }, )}
` : ""} ` : ""}
`; })}
${this.searchText ? html` ` : ""}
${this.renderEvents()}
` : html`
${this.renderWatchList()}
`}
` : ""} `; } } globalThis.customElements.define("x-debugger-view", XDebuggerView);