agent-observatory-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1299 @@
1
+ """Agent-first terminal UI. Raw evidence lives in scrollable inspectors."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import json
6
+ import os
7
+ import shlex
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ import webbrowser
13
+ from pathlib import Path
14
+
15
+ from rich.cells import cell_len, get_character_cell_size
16
+ from rich.segment import Segment
17
+ from rich.syntax import Syntax
18
+ from rich.table import Table
19
+ from rich.text import Text
20
+ from textual import on, work
21
+ from textual.app import App, ComposeResult
22
+ from textual.binding import Binding
23
+ from textual.containers import Horizontal, Vertical
24
+ from textual.screen import Screen
25
+ from textual.strip import Strip
26
+ from textual.widgets import Button, Checkbox, DataTable, Footer, Input, RichLog, Select, Static, TabbedContent, TabPane, Tree
27
+
28
+ from .terminal import clean, number, trace_name
29
+
30
+
31
+ def cell(value, style=''):
32
+ return Text(clean(value), style=style, overflow='ellipsis', no_wrap=True)
33
+
34
+
35
+ def age(stamp):
36
+ if not stamp:
37
+ return 'unknown'
38
+ seconds=max(0,int(time.time()-float(stamp)/1e9))
39
+ return f'{seconds}s ago' if seconds<60 else f'{seconds//60}m ago' if seconds<3600 else f'{seconds//3600}h ago'
40
+
41
+
42
+ def timing(value):
43
+ try:
44
+ if value is None:
45
+ return 'not reported'
46
+ value=float(value)
47
+ return f'{value:.0f} ms' if value<1000 else f'{value/1000:.2f} s' if value<60000 else f'{value/60000:.1f} min'
48
+ except (ValueError,TypeError):
49
+ return 'not reported'
50
+
51
+
52
+ def quantity(value, singular, plural=None):
53
+ return f"{value} {singular if value==1 else (plural or singular+'s')}"
54
+
55
+
56
+ def when(stamp):
57
+ value=time.localtime(stamp/1e9)
58
+ return time.strftime('%H:%M:%S' if value[:3]==time.localtime()[:3] else '%m-%d %H:%M',value)
59
+
60
+
61
+ def table_when(stamp):
62
+ value=time.localtime(stamp/1e9)
63
+ return time.strftime('%H:%M:%S' if value[:3]==time.localtime()[:3] else '%m-%d',value)
64
+
65
+
66
+ def status(value):
67
+ label={'OK':'OK','UNSET':'Observed','RUNNING':'In progress','ERROR':'Error','FAILED':'Failed','INCOMPLETE':'Incomplete',
68
+ 'success':'OK','started':'Started','completed':'Completed','observed':'Observed','reported':'Reported','error':'Error'}.get(str(value),str(value).title())
69
+ color='#ff8b92' if str(value).lower() in {'error','failed','denied','blocked','incomplete'} else '#6ee7c2' if str(value).lower() in {'success','ok','completed'} else '#8bbdff' if str(value).lower() in {'running','started'} else '#8b9bb0'
70
+ return cell(label,color)
71
+
72
+
73
+ def decode(value):
74
+ # Local logs often wrap the same event in several JSON-encoded transport
75
+ # envelopes. Unwrap those so newlines and typed text blocks reach the UI.
76
+ for _ in range(24):
77
+ if not isinstance(value,str):
78
+ break
79
+ try:
80
+ parsed=json.loads(value)
81
+ except (ValueError,TypeError):
82
+ break
83
+ if isinstance(parsed,(dict,list)):
84
+ value=parsed
85
+ elif isinstance(parsed,str) and parsed.lstrip().startswith(('{','[')):
86
+ # Some event producers JSON-encode the payload, then JSON-encode
87
+ # that encoded string again inside their text block.
88
+ value=parsed
89
+ else:
90
+ break
91
+ return value
92
+
93
+
94
+ def decode_nested(value,depth=0):
95
+ """Decode JSON strings inside arbitrarily nested telemetry fields."""
96
+ value=decode(value)
97
+ if depth>=64:return value
98
+ if isinstance(value,dict):
99
+ return {key:decode_nested(child,depth+1) for key,child in value.items()}
100
+ if isinstance(value,list):
101
+ return [decode_nested(child,depth+1) for child in value]
102
+ return value
103
+
104
+
105
+ def shell_command(value):
106
+ """Remove the login-shell wrapper from commands captured from a PTY."""
107
+ command=str(value).strip()
108
+ try:
109
+ args=shlex.split(command)
110
+ except ValueError:
111
+ return command
112
+ if len(args)>=3 and Path(args[0]).name in {'sh','bash','zsh','fish'} and args[1] in {'-c','-lc','-ilc'}:
113
+ return args[2]
114
+ return command
115
+
116
+
117
+ def heredoc_script(command):
118
+ """Return the language and body for a captured interpreter heredoc."""
119
+ lines=command.splitlines()
120
+ if len(lines)<3:
121
+ return None
122
+ try:
123
+ header=shlex.split(lines[0])
124
+ except ValueError:
125
+ return None
126
+ interpreter=Path(header[0]).name.lower() if header else ''
127
+ delimiter_arg=next((part for part in header if part.startswith('<<')),None)
128
+ if not delimiter_arg:
129
+ return None
130
+ delimiter=delimiter_arg[2:].strip("'\"")
131
+ if not delimiter or lines[-1].strip()!=delimiter:
132
+ return None
133
+ if interpreter.startswith('python'):
134
+ language,title='python','Python script'
135
+ elif interpreter in {'node','nodejs','deno','bun'}:
136
+ language,title='javascript','JavaScript script'
137
+ elif interpreter in {'bash','sh','zsh','fish'}:
138
+ language,title='bash','Shell script'
139
+ else:
140
+ language,title='text',f'{interpreter or "Interpreter"} script'
141
+ return language,title,'\n'.join(lines[1:-1])
142
+
143
+
144
+ def embedded_json(text):
145
+ """Split prose from complete JSON objects embedded in a text record."""
146
+ decoder=json.JSONDecoder()
147
+ segments=[]
148
+ cursor=0
149
+ index=0
150
+ while index<len(text):
151
+ if text[index] not in '{[':
152
+ index+=1
153
+ continue
154
+ try:
155
+ value,end=decoder.raw_decode(text,index)
156
+ except (ValueError,TypeError):
157
+ index+=1
158
+ continue
159
+ if not isinstance(value,(dict,list)) or end<=cursor:
160
+ index+=1
161
+ continue
162
+ if text[cursor:index]:segments.append(('text',text[cursor:index]))
163
+ segments.append(('json',value))
164
+ cursor=end
165
+ index=end
166
+ if not segments:return [('text',text)]
167
+ if text[cursor:]:segments.append(('text',text[cursor:]))
168
+ return segments
169
+
170
+
171
+ def format_json(value):
172
+ """Return indented JSON and multiline string fields for readable display."""
173
+ text_values=[]
174
+ def prepare(item,path=''):
175
+ if isinstance(item,dict):
176
+ return {key:prepare(child,f'{path}.{key}' if path else str(key)) for key,child in item.items()}
177
+ if isinstance(item,list):
178
+ return [prepare(child,f'{path}[{index}]') for index,child in enumerate(item)]
179
+ if isinstance(item,str) and '\n' in item:
180
+ text_values.append((path or 'Text value',item))
181
+ line_count=len(item.splitlines())
182
+ return f'[multiline text · {line_count} lines shown below]'
183
+ return item
184
+
185
+ return json.dumps(prepare(value),ensure_ascii=False,indent=2),text_values
186
+
187
+
188
+ def write_json(log,value):
189
+ """Render indented JSON and move multiline string values into readable blocks."""
190
+ formatted,text_values=format_json(value)
191
+ log.write(Syntax(formatted,'json',word_wrap=True,background_color='#101720'))
192
+ for path,content in text_values:
193
+ log.write(Text(f'\n{path} · {len(content.splitlines())} lines',style='bold #92b9eb'))
194
+ log.write(Text(content))
195
+
196
+
197
+ def copy_event_text(item):
198
+ """Build a readable plain-text export of one selected activity or MCP event."""
199
+ is_mcp=bool(item.get('server') or item.get('tool') or item.get('domain')=='mcp')
200
+ title=f"{item.get('server') or 'MCP'} / {item.get('tool') or 'tool'}" if is_mcp else headline(item)
201
+ lines=[title,f"Agent: {item.get('service') or 'Not reported'}",
202
+ f"Type: {item.get('domain') or ('mcp' if is_mcp else 'Not reported')}",
203
+ f"Result: {item.get('result') or item.get('status') or 'Not reported'}",
204
+ f"When: {when(item['time_unix_nano']) if item.get('time_unix_nano') else 'Not reported'}",
205
+ f"Duration: {timing(item.get('duration_ms'))}"]
206
+ if item.get('trace_id'):lines.append(f"Trace ID: {item['trace_id']}")
207
+ if item.get('span_id'):lines.append(f"Span ID: {item['span_id']}")
208
+ lines.append('\nRecorded content')
209
+ def append_json(value):
210
+ formatted,text_values=format_json(value)
211
+ lines.append(formatted)
212
+ for path,content in text_values:lines.extend((f'\n{path}',content))
213
+ if is_mcp:
214
+ details={key:item.get(key) for key in ('server','tool','method','arguments','output') if item.get(key) is not None}
215
+ else:
216
+ details=decode_nested(item.get('detail') or '')
217
+ if isinstance(details,(dict,list)):
218
+ append_json(details)
219
+ elif isinstance(details,str):
220
+ for kind,content in embedded_json(details):
221
+ if kind=='json':append_json(content)
222
+ elif content.strip():lines.append(content.strip('\n'))
223
+ elif details:
224
+ lines.append(str(details))
225
+ return '\n'.join(lines)
226
+
227
+
228
+ async def write_system_clipboard(text):
229
+ """Write to the desktop clipboard when a native clipboard tool is available."""
230
+ candidates=[]
231
+ if sys.platform=='darwin' and shutil.which('pbcopy'):
232
+ candidates.append((['pbcopy'],'macOS'))
233
+ elif os.name=='nt' and shutil.which('clip.exe'):
234
+ candidates.append((['clip.exe'],'Windows'))
235
+ elif sys.platform.startswith('linux'):
236
+ if os.environ.get('WAYLAND_DISPLAY') and shutil.which('wl-copy'):
237
+ candidates.append((['wl-copy'],'Wayland'))
238
+ if os.environ.get('DISPLAY') and shutil.which('xclip'):
239
+ candidates.append((['xclip','-selection','clipboard','-i'],'X11'))
240
+ if os.environ.get('DISPLAY') and shutil.which('xsel'):
241
+ candidates.append((['xsel','--clipboard','--input'],'X11'))
242
+ for command,label in candidates:
243
+ try:
244
+ # Clipboard owners such as xclip fork a background process. Captured
245
+ # pipes can stay open in that child even after the command succeeds.
246
+ process=await asyncio.create_subprocess_exec(*command,stdin=subprocess.PIPE,
247
+ stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
248
+ except OSError:
249
+ continue
250
+ try:
251
+ await asyncio.wait_for(process.communicate(text.encode('utf-8')),timeout=3)
252
+ except (asyncio.TimeoutError,asyncio.CancelledError) as exc:
253
+ if process.returncode is None:
254
+ try:process.kill()
255
+ except ProcessLookupError:pass
256
+ await process.wait()
257
+ if isinstance(exc,asyncio.CancelledError):raise
258
+ continue
259
+ if process.returncode==0:
260
+ return label
261
+ return None
262
+
263
+
264
+ def headline(item):
265
+ domain=item.get('domain') or 'tool'
266
+ subject=str(item.get('subject') or item.get('action') or 'Observed activity')
267
+ if domain=='tool' and subject.casefold() in {'exec','execute','shell'}:
268
+ details=decode_nested(item.get('detail') or '')
269
+ def find_command(value):
270
+ if isinstance(value,dict):
271
+ for key,child in value.items():
272
+ if str(key).casefold() in {'command','cmd'} and isinstance(child,str):
273
+ return child
274
+ for child in value.values():
275
+ found=find_command(child)
276
+ if found:return found
277
+ elif isinstance(value,list):
278
+ for child in value:
279
+ found=find_command(child)
280
+ if found:return found
281
+ return None
282
+ command=find_command(details)
283
+ if command:
284
+ command=shell_command(command)
285
+ script=heredoc_script(command)
286
+ if script:return script[1]
287
+ first=command.splitlines()[0] if command.splitlines() else command
288
+ return clean(first)
289
+ if domain=='shell':
290
+ try:
291
+ args=shlex.split(subject)
292
+ if len(args)>=3 and args[1] in {'-lc','-c','-ilc'}:
293
+ subject=args[2]
294
+ except ValueError:
295
+ pass
296
+ first=subject.splitlines()[0] if subject.splitlines() else 'Shell command'
297
+ if ' - <<' in first and first.startswith(('python','python3')):
298
+ return 'Run Python script'
299
+ return clean(first)
300
+ if domain=='file':
301
+ return f"{str(item.get('action') or 'Change').title()} {Path(subject).name}"
302
+ if domain=='mcp':
303
+ return f"{item.get('mcp_server') or subject} / {item.get('mcp_tool') or item.get('action')}"
304
+ if domain=='message':
305
+ return ('User: ' if item.get('action')=='user' else 'Agent: ')+subject
306
+ if domain=='model':
307
+ return 'Model usage · '+subject
308
+ if domain=='agent':
309
+ return {'processing':'Model processing','started':'Turn started','completed':'Turn completed','interrupted':'Turn interrupted','compaction':'Context compacted'}.get(item.get('action'),subject)
310
+ return subject if item.get('action') in {'call','result','execute_tool'} else f"{item.get('action') or domain} · {subject}"
311
+
312
+
313
+ def sources(snapshot, include_reviews=False):
314
+ connections={item['source_id']:item for item in snapshot.agents}
315
+ result=[]
316
+ for entry in snapshot.local_status.get('sessions',[]):
317
+ connection=connections.pop(entry['source_id'],{})
318
+ review=entry.get('service')=='codex-review' or entry.get('model')=='codex-auto-review'
319
+ if review and not include_reviews:
320
+ continue
321
+ last=entry.get('last_activity') or {}
322
+ stamp=last.get('time_unix_nano') or 0
323
+ recent=bool(last and time.time()-stamp/1e9<35)
324
+ result.append({**entry,'id':entry['source_id'],'review':review,'recent':recent,'stamp':stamp,
325
+ 'state':'Recent' if recent else 'Idle','spans':connection.get('span_count',0)})
326
+ for entry in connections.values():
327
+ if 'local-session-log' in entry.get('integrations',[]):
328
+ continue
329
+ result.append({**entry,'id':entry['source_id'],'session_id':None,'project':'SDK / OTLP','model':'',
330
+ 'runtime':entry.get('service'),'task':'Task not reported','recent':entry['state']=='running',
331
+ 'state':'Connected' if entry['state']=='running' else 'Finished' if entry['state']=='stopped' else 'Offline',
332
+ 'stamp':entry.get('last_seen_ns',0),'spans':entry.get('span_count',0),'review':False})
333
+ return sorted(result,key=lambda item:(not item['recent'],-item['stamp'],item['id']))
334
+
335
+
336
+ class ResultsTable(DataTable):
337
+ """Size columns to the panel while keeping the selected record on resize."""
338
+ def configure(self, columns):
339
+ self.column_specs=columns
340
+ self.column_widths=()
341
+ self.fit_columns()
342
+
343
+ def on_resize(self):
344
+ self.fit_columns()
345
+
346
+ def fit_columns(self):
347
+ specs=getattr(self,'column_specs',None)
348
+ if not specs:return
349
+ available=max(0,self.scrollable_content_region.width-2*len(specs)-1)
350
+ widths=[minimum for _,minimum,_ in specs]
351
+ spare=max(0,available-sum(widths))
352
+ weight=sum(weight for _,_,weight in specs)
353
+ for index,(_,_,share) in enumerate(specs):
354
+ widths[index]+=int(spare*share/weight)
355
+ widths[max(range(len(specs)),key=lambda i:specs[i][2])]+=max(0,available-sum(widths))
356
+ widths=tuple(widths)
357
+ if widths==self.column_widths:return
358
+ rows=[(key,self.get_row(key)) for key in self.rows]
359
+ selected=self.cursor_row
360
+ scroll=self.scroll_offset
361
+ self.clear(columns=True)
362
+ for index,((label,_,_),width) in enumerate(zip(specs,widths)):
363
+ self.add_column(label,width=width,key=str(index))
364
+ for key,values in rows:self.add_row(*values,key=key)
365
+ self.column_widths=widths
366
+ if rows:self.move_cursor(row=min(selected,len(rows)-1),scroll=False)
367
+ self.scroll_to(x=0,y=scroll.y,animate=False)
368
+
369
+
370
+ class SearchInput(Input):
371
+ """Use familiar select-all behavior in dashboard search fields."""
372
+ BINDINGS=[Binding('ctrl+a','select_all','Select all',show=False)]
373
+
374
+
375
+ class SelectableRichLog(RichLog):
376
+ """Selectable evidence with visible highlighting and plain-text extraction."""
377
+ def render_line(self,y):
378
+ source_y=self.scroll_offset.y+y
379
+ width=self.scrollable_content_region.width
380
+ if source_y>=len(self.lines):
381
+ # Map empty space to the end, so dragging below the text is safe.
382
+ last=len(self.lines)-1
383
+ return Strip.blank(width,self.rich_style).apply_offsets(
384
+ len(self.lines[last].text) if last>=0 else 0,max(0,last))
385
+ line=self.lines[source_y].apply_style(self.rich_style)
386
+ selection=self.text_selection
387
+ span=selection.get_span(source_y) if selection else None
388
+ if span:
389
+ start,end=span
390
+ text=line.text
391
+ start=cell_len(text[:start])
392
+ end=line.cell_length if end==-1 else cell_len(text[:end])
393
+ if end>start:
394
+ selected=line.crop(start,end)
395
+ highlight=self.screen.get_component_rich_style('screen--selection')
396
+ selected=Strip(Segment.apply_style(selected,post_style=highlight),selected.cell_length)
397
+ line=Strip.join([line.crop(0,start),selected,line.crop(end)])
398
+ cells=characters=0
399
+ for character in line.text:
400
+ if cells>=self.scroll_offset.x:break
401
+ cells+=get_character_cell_size(character)
402
+ characters+=1
403
+ return line.crop_extend(self.scroll_offset.x,self.scroll_offset.x+width,self.rich_style).apply_offsets(characters,source_y)
404
+
405
+ @property
406
+ def plain_text(self):
407
+ return '\n'.join(line.text.rstrip() for line in self.lines)
408
+
409
+ def get_selection(self,selection):
410
+ if not self.lines:return None
411
+ selected=[]
412
+ for y,line in enumerate(self.lines):
413
+ span=selection.get_span(y)
414
+ if span is not None:
415
+ start,end=span
416
+ selected.append(line.text[start:None if end==-1 else end].rstrip())
417
+ return '\n'.join(selected),'\n'
418
+
419
+ def clear(self):
420
+ # A selection from a previous event must never copy the next event.
421
+ if self.is_mounted and self in self.screen.selections:
422
+ self.screen.clear_selection()
423
+ return super().clear()
424
+
425
+
426
+ class ObservatoryScreen(Screen):
427
+ """Copy pointer-selected text through the desktop clipboard when possible."""
428
+ def action_copy_text(self):
429
+ selected=self.get_selected_text()
430
+ if selected is None:
431
+ self.app.notify('Drag across text to select it, then press Ctrl+C.')
432
+ return
433
+ self.app.copy_to_clipboard(selected)
434
+
435
+
436
+ class ObservatoryApp(App):
437
+ TITLE='Agent Observatory'
438
+ AUTO_FOCUS='#agent-table'
439
+ CSS='''
440
+ Screen { background: #0d1117; color: #dce4ed; }
441
+ Screen > .screen--selection { background: #315a73; color: #ffffff; }
442
+ * { scrollbar-color: #405566; scrollbar-color-hover: #6b8995; scrollbar-color-active: #6ee7c2; scrollbar-background: #101720; scrollbar-background-hover: #101720; scrollbar-background-active: #101720; }
443
+ #masthead { height: 2; padding: 0 2; align-vertical: middle; }
444
+ #brand { width: 1fr; color: #e6edf3; text-style: bold; text-wrap: nowrap; }
445
+ #connection { width: auto; color: #6ee7c2; text-align: right; text-wrap: nowrap; }
446
+ #summary { height: 3; margin: 0 2 1 2; }
447
+ .stat { width: 1fr; border-left: solid #303b4b; padding: 0 2; text-wrap: nowrap; text-overflow: ellipsis; }
448
+ #stat-sessions { border-left: solid #6ee7c2; }
449
+ #controls { height: 3; margin: 0 2; align-vertical: middle; }
450
+ #scope-label { width: 7; height: 1; margin-top: 1; color: #8b9bb0; }
451
+ #scope { width: 37; margin-right: 1; }
452
+ SelectCurrent { background: #161e29; border: tall #263345; }
453
+ Select:focus > SelectCurrent { border: tall #6ee7c2; background-tint: transparent; }
454
+ SelectOverlay { background: #161e29; border: tall #6ee7c2; }
455
+ #controls-spacer { width: 1fr; }
456
+ #reviews { width: 14; border: none; background: #0d1117; margin-top: 1; color: #8b9bb0; }
457
+ Checkbox > .toggle--button { color: #56667b; background: #161e29; }
458
+ Checkbox.-on > .toggle--button { color: #6ee7c2; }
459
+ Button { height: 3; min-width: 9; background: #161e29; color: #b5c6d9; border: tall #263345; text-style: none; }
460
+ Button:hover { background: #213345; border: tall #6ee7c2; }
461
+ Button:focus { border: tall #6ee7c2; text-style: bold; }
462
+ Button:disabled { color: #56667b; border: tall #1d2734; background: #101720; }
463
+ #pause { width: 12; }
464
+ #pages { height: 1fr; margin: 0 2; }
465
+ Tabs { height: 3; padding-top: 1; }
466
+ Tab { color: #8b9bb0; padding: 0 2; }
467
+ Tab.-active { color: #6ee7c2; text-style: bold; }
468
+ Tabs:focus .-active { background: #183a35; color: #9ff3d9; }
469
+ Underline > .underline--bar { color: #6ee7c2; background: #263345; }
470
+ TabPane { padding: 0; }
471
+ ContentSwitcher { height: 1fr; }
472
+ .hint { height: 2; color: #8b9bb0; }
473
+ .box { border: round #303b4b; border-title-color: #b5c6d9; border-title-style: bold; background: #101720; padding: 0 1; }
474
+ .box:focus-within { border: round #537784; border-title-color: #6ee7c2; }
475
+ .results { height: 1fr; }
476
+ .searchbar { height: 3; align-vertical: middle; }
477
+ .view-search { width: 1fr; margin-right: 1; padding: 0 1; background: #161e29; border: tall #263345; }
478
+ .view-search:focus { border: tall #6ee7c2; background-tint: transparent; }
479
+ Input > .input--placeholder { color: #8b9bb0; }
480
+ Input > .input--cursor { background: #6ee7c2; color: #0d1117; }
481
+ Input > .input--selection { background: #315a73; color: #ffffff; }
482
+ .clear-search { width: 9; }
483
+ .error-filter { width: 12; margin-right: 1; }
484
+ .error-filter.-on { color: #ff8b92; border: tall #ff8b92; background: #2d2028; }
485
+ .search-status { height: 1; color: #8b9bb0; padding: 0 1; }
486
+ .history-controls { height: 1; margin-top: 1; }
487
+ .history-controls Static { width: 1fr; color: #8b9bb0; }
488
+ .history-controls Button { min-width: 8; width: auto; height: 1; margin-left: 1; border: none; }
489
+ .history-controls Button:focus { background: #203d47; color: #ffffff; }
490
+ #history-back { width: 10; margin-right: 1; }
491
+ DataTable { background: #101720; height: 1fr; scrollbar-size: 1 1; }
492
+ DataTable:focus { background-tint: transparent; }
493
+ DataTable > .datatable--header { background: #1a2533; color: #a4b7cb; text-style: bold; }
494
+ DataTable > .datatable--cursor { background: #203d47; color: #eefdf8; text-style: bold; }
495
+ DataTable:blur > .datatable--cursor { background: #1d2d3b; color: #dce4ed; text-style: none; }
496
+ DataTable > .datatable--hover { background: #1a2b38; }
497
+ DataTable > .datatable--odd-row { background: #131d29; }
498
+ DataTable > .datatable--even-row { background: #101720; }
499
+ RichLog { background: #101720; color: #dce4ed; scrollbar-size: 1 1; }
500
+ RichLog:focus { background-tint: transparent; }
501
+ #agent-table { height: 1fr; min-height: 3; }
502
+ #agent-bottom { height: 14; margin-top: 1; }
503
+ #agent-info { width: 1fr; margin-right: 1; }
504
+ #agent-detail { width: 100%; height: 1fr; }
505
+ #agent-open { height: 3; width: 100%; }
506
+ #agent-actions { width: 1fr; }
507
+ .split { height: 1fr; }
508
+ .list { width: 3fr; margin-right: 1; }
509
+ .detail { width: 2fr; }
510
+ .detail-log { height: 1fr; }
511
+ .detail-button { height: 3; width: 100%; }
512
+ .inspector-actions { height: 3; }
513
+ .inspector-actions Button { width: 1fr; }
514
+ .expand-button { min-width: 12; }
515
+ #trace-detail { width: 2fr; }
516
+ #span-tree { height: 1fr; min-height: 4; background: #101720; scrollbar-size: 1 1; }
517
+ #span-info { height: 10; border-top: solid #303b4b; }
518
+ #health { height: 1; padding: 0 2; color: #8b9bb0; }
519
+ Footer { background: #161e29; color: #8b9bb0; }
520
+ FooterKey { background: #20313e; color: #6ee7c2; }
521
+ FooterKey > .footer-key--key { background: #20313e; color: #6ee7c2; }
522
+ FooterKey > .footer-key--description { background: #161e29; color: #b5c6d9; }
523
+ FooterLabel { background: #161e29; color: #b5c6d9; }
524
+ .narrow #masthead { padding: 0 1; }
525
+ .narrow #summary { margin: 0 1; }
526
+ .narrow .stat { padding: 0 1; }
527
+ .narrow #controls { margin: 0 1; }
528
+ .narrow #scope { width: 29; }
529
+ .narrow #pages { margin: 0 1; }
530
+ .narrow Tab { padding: 0 1; }
531
+ .narrow #agent-bottom { height: 12; }
532
+ .narrow .split { layout: vertical; }
533
+ .narrow .list { width: 100%; height: 1fr; margin-right: 0; margin-bottom: 1; }
534
+ .narrow .detail, .narrow #trace-detail { width: 100%; height: 1fr; }
535
+ .compact #masthead { height: 1; }
536
+ .compact #summary { margin-bottom: 0; }
537
+ .compact Tabs { height: 2; padding-top: 0; }
538
+ .compact #agent-bottom, .compact .detail, .compact #trace-detail, .compact #host-detail { display: none; }
539
+ .compact .list { width: 100%; height: 1fr; margin: 0; }
540
+ .expanded .results { display: none; }
541
+ .expanded #agent-bottom { display: block; height: 1fr; margin: 0; }
542
+ .expanded .detail, .expanded #trace-detail, .expanded #host-detail { display: block; width: 100%; height: 1fr; }
543
+ .expanded #agent-info { width: 100%; margin: 0; }
544
+ .expanded #agent-actions { display: none; }
545
+ .expanded #span-info { height: 2fr; }
546
+ .inspecting #summary, .inspecting #controls { display: none; }
547
+ '''
548
+ BINDINGS=[Binding('/','search','Search'),Binding('e','expand','Expand'),Binding('t','open_trace','Trace'),
549
+ Binding('c','copy','Copy'), Binding('alt+left','history_back','Back',show=False),
550
+ Binding('[','history("newer")','Newer',show=False),Binding(']','history("older")','Older',show=False),
551
+ Binding('l','history("latest")','Latest',show=False),
552
+ Binding('p','pause','Pause'),Binding('a','all','Reset',show=False),Binding('v','reviews','Reviews',show=False),
553
+ Binding('b','browser','Web'),Binding('q','quit','Quit'),Binding('escape','back','Back',show=False),
554
+ Binding('s','focus_agent','Focus agent',show=False),Binding('1','page("agents")','Agents',show=False),
555
+ Binding('2','page("activity")','Activity',show=False),Binding('3','page("mcp")','MCP',show=False),
556
+ Binding('4','page("traces")','Traces',show=False),Binding('5','page("host")','Host',show=False),Binding('6','page("output")','Output',show=False)]
557
+
558
+ def __init__(self,feed,agent=None,owns_receiver=False):
559
+ super().__init__()
560
+ self.feed,self.agent,self.owns_receiver=feed,agent,owns_receiver
561
+ self.data=feed.snapshot
562
+ self.last_update=0
563
+ self.table_data={}
564
+ self.table_signatures={}
565
+ self.inspector_signatures={}
566
+ self.selected_source=None
567
+ self.selected_activity=None
568
+ self.selected_mcp=None
569
+ self.scope_options=None
570
+ self.scope_value='all'
571
+ self.span_signature=None
572
+ self.search_timers={}
573
+ self.refresh_timer=None
574
+ self.search_queries={page:'' for page in ('agents','activity','mcp','traces','host')}
575
+ self.errors_only=set()
576
+ self.navigation_history=[]
577
+
578
+ def results(self,page,table,placeholder,classes=''):
579
+ """Keep a view's filter, result count, and rows in one panel."""
580
+ controls=[SearchInput(placeholder=placeholder,id='search-'+page,classes='view-search')]
581
+ if page in {'activity','mcp'}:
582
+ controls.append(Button('Errors',id='errors-'+page,classes='error-filter',tooltip='Show only failed calls in the current results'))
583
+ controls.append(Button('Clear',id='clear-'+page,classes='clear-search',disabled=True))
584
+ footer=[]
585
+ if page in {'activity','mcp','traces'}:
586
+ footer=[Horizontal(Static('Latest',id='history-label-'+page),
587
+ Button('Newer',id='newer-'+page,compact=True,disabled=True),
588
+ Button('Older',id='older-'+page,compact=True,disabled=True),
589
+ Button('Latest',id='latest-'+page,compact=True,disabled=True),classes='history-controls')]
590
+ return Vertical(
591
+ Horizontal(*controls,classes='searchbar'),
592
+ Static('Loading…',id='mcp-hint' if page=='mcp' else 'search-status-'+page,classes='search-status',markup=False),
593
+ ResultsTable(id=table,cursor_type='row',zebra_stripes=True),
594
+ *footer,
595
+ id='results-'+page,classes='box results '+classes)
596
+
597
+ def compose(self)->ComposeResult:
598
+ with Horizontal(id='masthead'):
599
+ yield Static('◈ OBSERVATORY',id='brand')
600
+ yield Static('○ Connecting',id='connection',markup=False)
601
+ with Horizontal(id='summary'):
602
+ for name in ('sessions','processes','mcp-count','usage'):
603
+ yield Static('',id='stat-'+name,classes='stat',markup=False)
604
+ with Horizontal(id='controls'):
605
+ yield Button('← Back',id='history-back',disabled=True,tooltip='Return to the event you opened · Alt+Left')
606
+ yield Static('Scope',id='scope-label')
607
+ yield Select([('All agent sessions','all')],value='all',allow_blank=False,id='scope')
608
+ yield Static('',id='controls-spacer')
609
+ yield Checkbox('Reviews',id='reviews')
610
+ yield Button('Pause',id='pause')
611
+ with TabbedContent(initial='agents',id='pages'):
612
+ with TabPane('1 Agents',id='agents'):
613
+ yield self.results('agents','agent-table','Filter agents by task, project, model, or session…')
614
+ with Horizontal(id='agent-bottom'):
615
+ with Vertical(id='agent-info'):
616
+ yield SelectableRichLog(id='agent-detail',classes='box',min_width=1,wrap=True,markup=False,auto_scroll=False)
617
+ yield Button(Text('Session activity · s'),id='agent-open')
618
+ yield ResultsTable(id='agent-actions',classes='box',cursor_type='row',zebra_stripes=True)
619
+ with TabPane('2 Activity',id='activity'):
620
+ with Horizontal(classes='split'):
621
+ yield self.results('activity','activity-table','Filter activity by command, message, tool, or path…','list')
622
+ with Vertical(id='activity-inspector',classes='detail box'):
623
+ yield SelectableRichLog(id='activity-detail',classes='detail-log',min_width=1,wrap=True,markup=False,auto_scroll=False)
624
+ with Horizontal(classes='inspector-actions'):
625
+ yield Button(Text('Open trace · t'),id='activity-trace')
626
+ yield Button(Text('Copy event · c'),id='activity-copy')
627
+ yield Button(Text('Expand · e'),id='expand-activity',classes='expand-button')
628
+ with TabPane('3 MCP Calls',id='mcp'):
629
+ with Horizontal(classes='split'):
630
+ yield self.results('mcp','mcp-table','Filter MCP calls by server, tool, arguments, or result…','list')
631
+ with Vertical(id='mcp-inspector',classes='detail box'):
632
+ yield SelectableRichLog(id='mcp-detail',classes='detail-log',min_width=1,wrap=True,markup=False,auto_scroll=False)
633
+ with Horizontal(classes='inspector-actions'):
634
+ yield Button(Text('Open trace · t'),id='mcp-trace')
635
+ yield Button(Text('Copy call · c'),id='mcp-copy')
636
+ yield Button(Text('Expand · e'),id='expand-mcp',classes='expand-button')
637
+ with TabPane('4 Traces',id='traces'):
638
+ with Horizontal(classes='split'):
639
+ yield self.results('traces','trace-table','Filter traces by task, model, service, or ID…','list')
640
+ with Vertical(id='trace-detail',classes='box'):
641
+ yield Tree('Select a trace',id='span-tree')
642
+ yield SelectableRichLog(id='span-info',min_width=1,wrap=True,markup=False,auto_scroll=False)
643
+ with TabPane('5 Host',id='host'):
644
+ yield self.results('host','host-table','Filter processes by name, PID, project, or state…')
645
+ yield SelectableRichLog(id='host-detail',classes='box',min_width=1,wrap=True,markup=False,auto_scroll=False)
646
+ if self.agent:
647
+ with TabPane('6 Output',id='output'):
648
+ yield Static('stdout / stderr from the agent launched by this cockpit.',id='output-state',classes='hint')
649
+ yield SelectableRichLog(id='agent-output',classes='box',min_width=1,wrap=True,markup=False,auto_scroll=True)
650
+ yield Static('Connecting to receiver…',id='health',markup=False)
651
+ yield Footer()
652
+
653
+ def get_default_screen(self):
654
+ return ObservatoryScreen()
655
+
656
+ def on_mount(self):
657
+ columns={
658
+ 'agent-table':[('Agent',15,1),('Project',10,1),('Status',8,0),('Current / last task',20,5),('Seen',7,0)],
659
+ 'agent-actions':[('Time',8,0),('Type',7,0),('Recent actions',15,1)],
660
+ 'activity-table':[('Time',8,0),('Type',7,0),('Result',9,0),('Action',20,1)],
661
+ 'mcp-table':[('Time',8,0),('Agent',11,1),('Server',9,1),('Tool',14,3),('Result',7,0),('Latency',7,0)],
662
+ 'trace-table':[('Result',11,0),('Task / run',20,1),('Spans',5,0),('Tokens in / out',15,0)],
663
+ 'host-table':[('Process',10,1),('PID',7,0),('Project',12,2),('State',8,1),('RAM',9,0),('CPU total',9,0)]}
664
+ for name,cols in columns.items():
665
+ self.query_one('#'+name,ResultsTable).configure(cols)
666
+ for name,title in [('results-agents','Agent sessions'),('agent-detail','Session overview'),('agent-actions','Recent work'),('results-activity','Activity stream'),('activity-inspector','Event detail'),('results-mcp','MCP calls'),('mcp-inspector','Call detail'),('results-traces','Traces'),('trace-detail','Execution tree'),('results-host','Agent processes'),('host-detail','Collection status')]:
667
+ self.query_one('#'+name).border_title=title
668
+ for page in self.search_queries:
669
+ self.query_one('#results-'+page).border_subtitle='↑↓ select · Enter inspect'
670
+ self.query_one('#search-'+page).tooltip='Search this view · / to focus · Esc to return'
671
+ self.query_one('#reviews').tooltip='Include internal approval review sessions'
672
+ self.query_one('#pause').tooltip='Pause display updates · collection continues'
673
+ for log in self.query(SelectableRichLog):
674
+ log.tooltip='Drag to select text · Ctrl+C copies selection · c copies details'
675
+ self.query_one('#host-detail').styles.height=8
676
+ self.update_layout()
677
+ if not self.feed.thread.is_alive():self.feed.thread.start()
678
+ self.refresh_timer=self.set_interval(.3,self.refresh_data)
679
+ self.query_one('#agent-table',DataTable).focus()
680
+
681
+ def on_unmount(self):
682
+ if self.refresh_timer:self.refresh_timer.stop()
683
+ for timer in self.search_timers.values():timer.stop()
684
+
685
+ def on_resize(self,event):
686
+ self.update_layout(event.size)
687
+ self.call_after_refresh(self.reflow_inspectors)
688
+
689
+ def reflow_inspectors(self):
690
+ self.screen.clear_selection()
691
+ positions={log.id:log.scroll_offset for log in self.query(SelectableRichLog)}
692
+ self.inspector_signatures.clear()
693
+ self.last_update=-1
694
+ self.refresh_data()
695
+ trees=self.query('#span-tree')
696
+ if trees and trees.first().cursor_node:
697
+ self.show_span(trees.first().cursor_node.data)
698
+ def restore():
699
+ for name,position in positions.items():
700
+ self.query_one('#'+name).scroll_to(x=position.x,y=position.y,animate=False)
701
+ self.call_after_refresh(restore)
702
+
703
+ def update_layout(self,size=None):
704
+ size=size or self.size
705
+ self.screen.set_class(size.width<140,'narrow')
706
+ self.screen.set_class(size.height<36,'compact')
707
+
708
+ def stat(self,name,value,label,detail):
709
+ text=Text(label.upper()+'\n',style='#8b9bb0',no_wrap=True,overflow='ellipsis')
710
+ text.append(str(value),style='bold #e6edf3')
711
+ text.append('\n'+detail,style='#8b9bb0')
712
+ self.query_one('#stat-'+name,Static).update(text)
713
+
714
+ def fill(self,name,rows,preserve=None):
715
+ """Preserve row identity and scroll position while live values change."""
716
+ signature=tuple((key,tuple(str(value) for value in values)) for key,values,record in rows)
717
+ self.table_data[name]={key:record for key,values,record in rows}
718
+ if self.table_signatures.get(name)==signature:return
719
+ self.table_signatures[name]=signature
720
+ table=self.query_one('#'+name,DataTable)
721
+ selected=preserve
722
+ if table.row_count:
723
+ try:
724
+ if not selected:selected=table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value
725
+ except Exception:pass
726
+ scroll=table.scroll_offset
727
+ old_index=table.cursor_row
728
+ with table.prevent(DataTable.RowHighlighted):
729
+ if [key.value for key in table.rows]==[key for key,_,_ in rows]:
730
+ for key,values,_ in rows:
731
+ for column,value in zip(table.columns,values):table.update_cell(key,column,value)
732
+ else:
733
+ table.clear()
734
+ for key,values,record in rows:table.add_row(*values,key=key)
735
+ if rows:
736
+ index=next((index for index,(key,_,__) in enumerate(rows) if key==selected),0)
737
+ table.move_cursor(row=index,scroll=False)
738
+ table.scroll_to(x=scroll.x,y=max(0,scroll.y+index-old_index),animate=False)
739
+
740
+ def search_status(self,page,count,noun,plural=None):
741
+ query=self.search_queries[page]
742
+ waiting=page in {'activity','mcp','traces'} and self.data.queries.get(page,'')!=query
743
+ text='Filtering…' if waiting else quantity(count,('matching '+noun) if query else noun,
744
+ ('matching '+plural) if query and plural else plural)
745
+ if not waiting and query and not count:text+=' · Clear to remove this filter'
746
+ if page in self.errors_only:text+=' · errors only'
747
+ self.query_one('#search-status-'+page,Static).update(text)
748
+
749
+ def visible_results(self,page,items):
750
+ if self.data.queries.get(page,'')!=self.search_queries[page]:return []
751
+ if page in self.errors_only:
752
+ return [item for item in items if str(item.get('status') or item.get('result') or '').lower() in {'error','failed','denied','blocked','policy_denied','incomplete'}]
753
+ return items
754
+
755
+ def refresh_data(self):
756
+ # Timer messages can already be queued when child widgets unmount.
757
+ if not self.query('#health'):return
758
+ data=self.feed.snapshot
759
+ connection='Ⅱ Paused' if self.feed.paused else '○ Reconnecting' if data.error else '● Connected'
760
+ endpoint=self.feed.api.endpoint.removeprefix('http://').removeprefix('https://')
761
+ self.query_one('#connection',Static).update(Text(connection+' '+endpoint,
762
+ style='#f2c779' if self.feed.paused else '#ff8b92' if data.error else '#6ee7c2'))
763
+ health=Text(data.error,style='#ff8b92') if data.error else Text(
764
+ f"{'Display paused' if self.feed.paused else 'Updated '+age(int(data.updated*1e9))} · {self.scope_label()} · {'Reviews included' if self.feed.include_reviews else 'Reviews hidden'}",
765
+ style='#8b9bb0')
766
+ self.query_one('#health',Static).update(health)
767
+ if self.screen.selections:
768
+ self.query_one('#health',Static).update('Text selected · Ctrl+C to copy · click to clear selection and resume display updates')
769
+ return
770
+ if data.updated==self.last_update:return
771
+ self.last_update=data.updated;self.data=data
772
+ if self.agent:
773
+ lines=list(self.agent.lines)
774
+ if getattr(self,'output_lines',None)!=lines:
775
+ self.output_lines=lines
776
+ output=self.query_one('#agent-output',RichLog);output.clear();output.write(Text('\n'.join(lines)))
777
+ code=self.agent.process.poll()
778
+ self.query_one('#output-state',Static).update('Agent process running · stdout / stderr' if code is None else f'Agent process exited with code {code} · captured stdout / stderr')
779
+ agents=sources(data,self.feed.include_reviews)
780
+ work=sources(data,False)
781
+ recent=sum(item['recent'] for item in work)
782
+ reviews=sum(entry.get('service')=='codex-review' for entry in data.local_status.get('sessions',[]))
783
+ processes=(data.local_status.get('runtime') or {}).get('processes',[])
784
+ live=sum(not str(item.get('state','')).startswith(('Z','X')) for item in processes)
785
+ self.stat('sessions',len(work),'Agent sessions',f'{recent} recent · {len(work)-recent} idle / offline')
786
+ self.stat('processes',live,'Host processes','PIDs on this host')
787
+ servers={item.get('server') for item in data.mcp if item.get('server') and item.get('server')!='Not reported'}
788
+ operations={(item.get('server'),item.get('tool')) for item in data.mcp if item.get('tool') and item.get('tool')!='Not reported'}
789
+ mcp_errors=sum(str(item.get('status','')).upper() in {'ERROR','FAILED'} for item in data.mcp)
790
+ self.stat('mcp-count',quantity(len(data.mcp),'call')+('' if self.size.width<110 else ' · '+quantity(len(servers),'server')),'MCP in scope',
791
+ f'{quantity(len(servers),"server")} · {quantity(mcp_errors,"error")}' if self.size.width<110 else f'{quantity(len(operations),"tool")} · {quantity(mcp_errors,"error")}')
792
+ self.stat('usage',number(data.totals.get('input_tokens'))+' / '+number(data.totals.get('output_tokens')),'Tokens in / out','Includes cached input')
793
+ self.query_one('#stat-sessions').tooltip=f'{reviews} approval reviews · Recent means a session record changed within 35 seconds'
794
+ self.query_one('#stat-usage').tooltip='Recorded token totals; repeated and cached input is included. This is not a billable cost estimate.'
795
+ options=[('All agent sessions','all')]+[(f"{item['runtime']} · {item.get('project') or 'SDK'} · {(item.get('session_id') or item['id'])[-6:]}",item['id']) for item in agents]
796
+ if options!=self.scope_options:
797
+ picker=self.query_one('#scope',Select);old=self.scope_value
798
+ picker.set_options(options);picker.value=old if any(value==old for _,value in options) else 'all';self.scope_options=options
799
+ self.source_map={item['id']:item for item in agents}
800
+ agent_query=self.search_queries['agents'].casefold()
801
+ visible_agents=[item for item in agents if (self.scope_value=='all' or item['id']==self.scope_value) and
802
+ (not agent_query or agent_query in ' '.join(str(item.get(key) or '') for key in ('runtime','service','project','task','model','session_id','id','state')).casefold())]
803
+ self.fill('agent-table',[(item['id'],[cell(item['runtime']+' · '+(item.get('session_id') or item['id'])[-6:]),cell(item.get('project') or '—','#a4b7cb'),cell(('● ' if item['recent'] else '○ ')+item['state'],'#6ee7c2' if item['recent'] else '#8b9bb0'),cell(item.get('task') or 'Task not reported'),cell(age(item['stamp']),'#8b9bb0')],item) for item in visible_agents])
804
+ self.search_status('agents',len(visible_agents),'session')
805
+ if self.selected_source not in {item['id'] for item in visible_agents}:self.selected_source=visible_agents[0]['id'] if visible_agents else None
806
+ self.show_agent()
807
+ activity_items=self.visible_results('activity',data.activity_results if data.activity_results is not None else data.activity)
808
+ activity_items=self.hold_selected('activity',activity_items,self.selected_activity,'source_id')
809
+ activity=[(item.get('id') or item['source_id'],[cell(table_when(item['time_unix_nano'])),cell(item['domain'].upper(),'#92b9eb'),status(item.get('result')),cell(headline(item))],item) for item in activity_items]
810
+ self.fill('activity-table',activity)
811
+ self.search_status('activity',len(activity),'event')
812
+ if self.selected_activity not in self.table_data.get('activity-table',{}):self.selected_activity=activity[0][0] if activity else None
813
+ self.show_activity()
814
+ mcp_items=self.visible_results('mcp',data.mcp_results if data.mcp_results is not None else data.mcp)
815
+ mcp_items=self.hold_selected('mcp',mcp_items,self.selected_mcp,'id')
816
+ self.fill('mcp-table',[(item['id'],[cell(table_when(item['time_unix_nano']),'#8b9bb0'),cell((item.get('service') or 'Unknown')+' · '+str(item.get('session_id') or '—')[-6:]),cell(item['server'],'#a4b7cb'),cell(item['tool']),status(item['status']),cell('—' if item['duration_ms'] is None else timing(item['duration_ms']))],item) for item in mcp_items])
817
+ if self.selected_mcp not in self.table_data.get('mcp-table',{}):self.selected_mcp=mcp_items[0]['id'] if mcp_items else None
818
+ self.show_mcp()
819
+ if not data.mcp_available:
820
+ mcp_hint='This receiver version does not expose MCP operations. Update and restart the receiver.'
821
+ elif data.queries.get('mcp','')!=self.search_queries['mcp']:
822
+ mcp_hint='Filtering MCP operations…'
823
+ elif mcp_items:
824
+ server_count=len({item.get('server') for item in mcp_items})
825
+ error_count=sum(str(item.get('status','')).upper() in {'ERROR','FAILED'} for item in mcp_items)
826
+ mcp_hint=f"{quantity(len(mcp_items),'matching operation' if self.search_queries['mcp'] else 'operation')} · {quantity(server_count,'server')} · {quantity(error_count,'error')}"
827
+ elif self.search_queries['mcp']:
828
+ mcp_hint='No matching MCP operations · Clear to show all calls in this scope'
829
+ else:
830
+ mcp_hint='No MCP operations in this scope · General tool activity is available in Activity'
831
+ if 'mcp' in self.errors_only:mcp_hint=f"{quantity(len(mcp_items),'failed call')} · errors only · current results"
832
+ self.query_one('#mcp-hint',Static).update(mcp_hint)
833
+ traces=self.visible_results('traces',data.traces[:])
834
+ detail=data.detail
835
+ if self.feed.pinned_trace and detail.get('summary') and not any(item['trace_id']==detail['trace_id'] for item in traces):traces.insert(0,detail['summary'])
836
+ self.fill('trace-table',[(item['trace_id'],[cell('Active · errors','yellow') if item.get('active') and item['status']=='ERROR' else cell('Error','red') if item['status']=='ERROR' else status(item['status']),cell(trace_name(item)),cell(item['span_count']),cell(number(item['input_tokens'])+' / '+number(item['output_tokens']))],item) for item in traces],preserve=self.feed.selected_trace)
837
+ self.search_status('traces',len(traces),'trace')
838
+ for page in ('activity','mcp','traces'):
839
+ paging=data.pagination.get(page,{})
840
+ page_number=paging.get('page',1)
841
+ self.query_one('#history-label-'+page,Static).update('Latest' if page_number==1 else f'History · page {page_number}')
842
+ self.query_one('#older-'+page,Button).disabled=not paging.get('next_cursor')
843
+ self.query_one('#newer-'+page,Button).disabled=page_number==1
844
+ self.query_one('#latest-'+page,Button).disabled=page_number==1
845
+ self.query_one('#older-'+page).tooltip='Load older saved records · ]' if paging.get('supported') else 'Restart the updated receiver to enable history paging'
846
+ self.show_spans()
847
+ host_query=self.search_queries['host'].casefold()
848
+ visible_processes=[item for item in processes if not host_query or host_query in ' '.join(str(item.get(key) or '') for key in ('name','pid','parent_pid','project','state')).casefold()]
849
+ self.fill('host-table',[(str(item['pid']),[cell(item['name']),cell(item['pid']),cell(item.get('project') or '—'),cell(item['state']),cell(f"{item['memory_rss_kib']/1024:.0f} MiB"),cell(f"{item['cpu_seconds']:.1f} s")],item) for item in visible_processes])
850
+ self.search_status('host',len(visible_processes),'process','processes')
851
+ runtime=data.local_status.get('runtime') or {}
852
+ self.inspect('host-detail','Host & collection status',[
853
+ ('Host',runtime.get('host','Not reported')),('Platform',str(runtime.get('os',''))+' / '+str(runtime.get('architecture',''))),
854
+ ('Load average',', '.join(str(value) for value in runtime.get('load_average',[])) or 'Unavailable'),
855
+ ('Sources',f"{data.local_status.get('sources',0)} recent files · lookback {data.local_status.get('lookback_hours',24)}h · read errors {data.local_status.get('read_errors',0)}"),
856
+ ('Coverage','Source logs can be partial. Costs and model-call latency are unavailable unless reported. CPU is cumulative process time.')],[])
857
+
858
+ def scope_label(self):
859
+ item=getattr(self,'source_map',{}).get(self.scope_value)
860
+ return f"{item['runtime']} / {item.get('project') or 'SDK'}" if item else 'all agent sessions'
861
+
862
+ def hold_selected(self,page,items,selected,key):
863
+ """Keep the event being read when new arrivals push it off the live page."""
864
+ current=self.table_data.get(page+'-table',{}).get(selected)
865
+ if current and selected not in {item.get('id') or item[key] for item in items}:
866
+ return [*items,current]
867
+ return items
868
+
869
+ def inspect(self,name,title,fields,sections):
870
+ signature=(title,str(fields),str(sections))
871
+ if self.inspector_signatures.get(name)==signature:return
872
+ previous=self.inspector_signatures.get(name)
873
+ self.inspector_signatures[name]=signature
874
+ log=self.query_one('#'+name,RichLog)
875
+ scroll=log.scroll_offset
876
+ log.clear();log.write(Text(title,style='bold #e6edf3'))
877
+ table=Table.grid(padding=(0,2));table.add_column(style='#8b9bb0',no_wrap=True);table.add_column()
878
+ for label,value in fields:table.add_row(Text(label),Text(str('Not reported' if value is None or value=='' else value)))
879
+ log.write(table)
880
+ for label,value in sections:self.write_evidence(log,label,value)
881
+ if previous and previous[0]==title and previous[2]==signature[2]:
882
+ log.scroll_to(y=scroll.y,animate=False)
883
+
884
+ def write_evidence(self,log,label,value,depth=0):
885
+ if value is None or value=='':return
886
+ value=decode_nested(value)
887
+ if isinstance(value,str) and label.casefold() in {'recorded content','text','output','response'}:
888
+ segments=embedded_json(value)
889
+ if any(kind=='json' for kind,_ in segments):
890
+ log.write(Text('\n'+label.title(),style='bold #92b9eb'))
891
+ for kind,content in segments:
892
+ if kind=='text':
893
+ if content.strip():log.write(Text(content.strip('\n')))
894
+ else:
895
+ write_json(log,content)
896
+ return
897
+ # Native log adapters may wrap each piece of the same record in a
898
+ # one-key `recorded_content` envelope. Unwrap that without consuming
899
+ # the JSON nesting budget or repeating the label at every level.
900
+ if isinstance(value,dict) and len(value)==1:
901
+ key,child=next(iter(value.items()))
902
+ if key.lower() in {'recorded_content','content','payload'}:
903
+ self.write_evidence(log,label,child,depth);return
904
+ if key.lower()=='wrapper':
905
+ self.write_evidence(log,label,child,depth+1);return
906
+ if depth==0 and isinstance(value,(dict,list)) and value:
907
+ log.write(Text('\n'+label.upper(),style='bold #6ee7c2'))
908
+ if label.lower()=='arguments':
909
+ write_json(log,value)
910
+ return
911
+ if isinstance(value,(dict,list)) and not value:
912
+ log.write(Text('\n'+label.title(),style='bold #92b9eb'))
913
+ log.write(Text('No entries' if isinstance(value,list) else 'Empty object'))
914
+ return
915
+ if isinstance(value,dict) and value.get('type') in {'text','input_text','output_text'} and 'text' in value:
916
+ self.write_evidence(log,label,value['text'],depth+1);return
917
+ # Any structured value nested in captured evidence is easier to scan
918
+ # as JSON than as a sequence of flattened label/value rows. This also
919
+ # handles JSON strings stored under fields such as `output` or `text`.
920
+ if depth and isinstance(value,(dict,list)):
921
+ write_json(log,value)
922
+ return
923
+ if depth<64 and isinstance(value,dict):
924
+ for key,child in value.items():
925
+ self.write_evidence(log,str(key).replace('_',' '),child,depth+1)
926
+ return
927
+ if depth<64 and isinstance(value,list):
928
+ for child in value:self.write_evidence(log,label,child,depth+1)
929
+ return
930
+ if isinstance(value,(dict,list)):
931
+ log.write(Text('\n'+label.title(),style='bold #92b9eb'))
932
+ write_json(log,value)
933
+ return
934
+ log.write(Text('\n'+label.title(),style='bold #92b9eb'))
935
+ if label.lower()=='command':
936
+ command=shell_command(value)
937
+ script=heredoc_script(command)
938
+ if script:
939
+ language,title,body=script
940
+ log.write(Text(title,style='bold #92b9eb'))
941
+ log.write(Syntax(body or '# Empty script',language,word_wrap=True,background_color='#101720'))
942
+ return
943
+ log.write(Syntax(command,'bash',word_wrap=True,background_color='#101720'))
944
+ elif label.lower() in {'diff','patch','unified diff','code'}:
945
+ lexer='diff' if 'diff' in label.lower() or label.lower()=='patch' else 'python' if label.lower()=='code' else 'bash'
946
+ log.write(Syntax(str(value),lexer,word_wrap=True,background_color='#101720'))
947
+ else:log.write(Text(str(value)))
948
+
949
+ def show_agent(self):
950
+ item=getattr(self,'source_map',{}).get(self.selected_source)
951
+ if not item:
952
+ self.query_one('#agent-open',Button).disabled=True
953
+ self.fill('agent-actions',[])
954
+ if self.search_queries['agents']:
955
+ self.inspect('agent-detail','No matching agent sessions',[],[('Search','Clear the filter in Agent sessions to show all sessions in this scope.')])
956
+ else:
957
+ self.inspect('agent-detail','No agent sessions found',[],[('Next step','Local Codex and Claude Code sessions are discovered automatically. Python SDK agents can use the capture launcher.')])
958
+ return
959
+ self.query_one('#agent-open',Button).disabled=False
960
+ summary=next((entry for entry in self.data.sessions if item.get('session_id') and entry.get('session_id')==item.get('session_id')),{})
961
+ latest=item.get('last_activity') or {}
962
+ self.inspect('agent-detail',clean(item.get('task') or 'Task not reported'),[
963
+ ('Agent',f"{item['runtime']} · {item.get('project') or 'SDK'}"),
964
+ ('Latest',headline(latest) if latest else 'Not reported'),
965
+ ('Model',item.get('model') or 'Not reported'),('Seen',f"{item['state']} · {age(item.get('stamp'))}"),
966
+ ('Work',f"{summary.get('trace_count',0)} traces · {item.get('spans',0)} spans · {number(summary.get('input_tokens'))} in / {number(summary.get('output_tokens'))} out"),
967
+ ('Cost','Not reported' if not summary or summary.get('unknown_cost_runs') else f"${summary.get('known_cost_usd',0):.4f} known")],
968
+ [('Session ID',item.get('session_id') or item['id'])])
969
+ actions=[event for event in self.data.activity if event.get('session_id')==item.get('session_id')] if item.get('session_id') else [event for event in self.data.activity if event.get('service')==item.get('service')]
970
+ meaningful=[event for event in actions if event.get('domain') in {'shell','file','mcp','browser','model','message'}]
971
+ actions=sorted(meaningful or actions,key=lambda event:event.get('time_unix_nano',0),reverse=True)
972
+ self.fill('agent-actions',[(event.get('id') or event['source_id'],[cell(table_when(event['time_unix_nano'])),cell(event['domain'].upper()),cell(headline(event))],event) for event in actions[:30]])
973
+
974
+ def show_activity(self):
975
+ item=self.table_data.get('activity-table',{}).get(self.selected_activity)
976
+ self.query_one('#activity-trace',Button).disabled=not bool(item and item.get('trace_id'))
977
+ if not item:
978
+ if 'activity' in self.errors_only:
979
+ self.inspect('activity-detail','No failed events',[],[('Errors filter','No failures in the current results. Turn off Errors to see all activity.')]);return
980
+ self.inspect('activity-detail','No activity in this scope',[],[('Scope','Choose another agent session or clear the search to view recent activity.')]);return
981
+ details=decode(item.get('detail') or '')
982
+ sections=list(details.items()) if isinstance(details,dict) else [('Recorded content',details)]
983
+ self.inspect('activity-detail',headline(item),[('Agent',item.get('service')),('Type',item.get('domain')),('Result',str(status(item.get('result')))),
984
+ ('When',when(item['time_unix_nano'])),('Duration',timing(item.get('duration_ms')))],
985
+ sections+[('Source',item.get('source')),('Trace ID',item.get('trace_id')),('Span ID',item.get('span_id'))])
986
+
987
+ def show_mcp(self):
988
+ item=self.table_data.get('mcp-table',{}).get(self.selected_mcp)
989
+ self.query_one('#mcp-trace',Button).disabled=not bool(item and item.get('trace_id'))
990
+ if not item:
991
+ if not self.data.mcp_available:
992
+ title='MCP inventory unavailable'
993
+ note='The connected receiver is too old to provide this endpoint. Update and restart the receiver.'
994
+ elif self.data.queries.get('mcp','')!=self.search_queries['mcp']:
995
+ title='Filtering MCP operations'
996
+ note='Waiting for matching calls from the receiver.'
997
+ elif 'mcp' in self.errors_only:
998
+ title='No failed MCP calls'
999
+ note='No failures in the current results. Turn off Errors to see all calls.'
1000
+ elif self.search_queries['mcp']:
1001
+ title='No matching MCP operations'
1002
+ note='Clear Search or choose another agent session. Search also matches server, tool, arguments, and result.'
1003
+ else:
1004
+ title='No MCP operations in this scope'
1005
+ note='Calls identified as Model Context Protocol appear here. Other tool activity remains in Activity.'
1006
+ self.inspect('mcp-detail',title,[('Scope',self.scope_label())],[('How to proceed',note)])
1007
+ return
1008
+ self.inspect('mcp-detail',item['server']+' / '+item['tool'],[
1009
+ ('Result',str(status(item.get('status')))+' · '+timing(item.get('duration_ms'))),
1010
+ ('Agent',(item.get('service') or 'Not reported')+' · '+str(item.get('session_id') or 'Unknown session')[-12:]),
1011
+ ('Method',item.get('method') or 'Not reported'),('Observed',when(item.get('time_unix_nano') or 0))],
1012
+ [('Arguments',item.get('arguments')),('Response',item.get('output')),
1013
+ ('Evidence',item.get('source')),('Session ID',item.get('session_id')),
1014
+ ('Trace ID',item.get('trace_id') or 'Not linked'),('Span ID',item.get('span_id') or 'Not linked')])
1015
+
1016
+ def show_spans(self):
1017
+ detail=self.data.detail
1018
+ if not detail.get('trace_id'):
1019
+ tree=self.query_one('#span-tree',Tree);tree.clear();tree.root.set_label('No trace selected')
1020
+ self.query_one('#span-info',RichLog).clear();self.span_signature=None
1021
+ return
1022
+ if detail.get('trace_id')!=self.feed.selected_trace:return
1023
+ spans=detail.get('spans',[])
1024
+ signature=(detail.get('trace_id'),tuple((item['span_id'],item['status'],item['end_unix_nano'],str(item['attributes'])) for item in spans))
1025
+ if signature==self.span_signature:return
1026
+ self.span_signature=signature
1027
+ tree=self.query_one('#span-tree',Tree)
1028
+ selected=tree.cursor_node.data.get('span_id') if tree.cursor_node and tree.cursor_node.data else None
1029
+ tree.clear()
1030
+ tree.root.set_label(Text('Trace '+str(detail.get('trace_id',''))[:12],style='bold'))
1031
+ nodes={};pending=list(spans)
1032
+ for _ in range(len(spans)+1):
1033
+ remaining=[]
1034
+ for item in pending:
1035
+ parent=item.get('parent_span_id')
1036
+ if parent and parent not in nodes and any(span['span_id']==parent for span in spans):remaining.append(item);continue
1037
+ attrs=item['attributes'];kind='MCP' if attrs.get('mcp.server.name') or attrs.get('mcp.tool.name') else 'MODEL' if attrs.get('gen_ai.operation.name')=='chat' or 'gen_ai.usage.input_tokens' in attrs else 'TOOL' if attrs.get('tool.name') else 'RUN'
1038
+ duration=None if attrs.get('observatory.duration_unknown') else (item['end_unix_nano']-item['start_unix_nano'])/1e6
1039
+ label=Text(f"{kind:5} ",style='#92b9eb');label.append(clean(item['name']));label.append(' · '+timing(duration),style='dim')
1040
+ if item['status']=='ERROR':label.append(' · ERROR',style='bold red')
1041
+ nodes[item['span_id']]=(nodes.get(parent) or tree.root).add(label,data=item,expand=True)
1042
+ if not remaining:break
1043
+ if len(remaining)==len(pending):
1044
+ for item in remaining:nodes[item['span_id']]=tree.root.add(Text(item['name']),data=item)
1045
+ break
1046
+ pending=remaining
1047
+ tree.root.expand()
1048
+ self.query_one('#trace-detail').border_subtitle=f"{len(spans)} / {detail.get('span_page',{}).get('trace_total',len(spans))} spans loaded"
1049
+ if getattr(self,'jump_span',None) in nodes:
1050
+ self.call_after_refresh(tree.move_cursor,nodes[self.jump_span]);self.jump_span=None
1051
+ elif nodes:
1052
+ self.call_after_refresh(tree.move_cursor,nodes.get(selected) or next(iter(nodes.values())))
1053
+
1054
+ def valid_row(self,event):
1055
+ table=event.data_table
1056
+ if not table.row_count:return None
1057
+ try:
1058
+ if table.coordinate_to_cell_key(table.cursor_coordinate).row_key!=event.row_key:return None
1059
+ except Exception:return None
1060
+ return self.table_data.get(table.id,{}).get(event.row_key.value)
1061
+
1062
+ @on(DataTable.RowHighlighted)
1063
+ def row_highlighted(self,event):
1064
+ item=self.valid_row(event)
1065
+ if not item:return
1066
+ name=event.data_table.id
1067
+ if name=='agent-table':self.selected_source=item['id'];self.show_agent()
1068
+ elif name=='activity-table':self.selected_activity=event.row_key.value;self.show_activity()
1069
+ elif name=='mcp-table':self.selected_mcp=item['id'];self.show_mcp()
1070
+ elif name=='trace-table':
1071
+ if self.feed.pinned_trace and self.data.detail.get('trace_id')!=self.feed.selected_trace:return
1072
+ if self.feed.selected_trace!=item['trace_id']:
1073
+ self.feed.selected_trace=item['trace_id'];self.feed.pinned_trace=True;self.feed.request_refresh()
1074
+ self.feed.selected_span_id=None
1075
+ self.query_one('#span-tree',Tree).root.set_label(Text('Loading selected trace…'))
1076
+
1077
+ @on(DataTable.RowSelected)
1078
+ def row_selected(self,event):
1079
+ item=self.valid_row(event)
1080
+ if not item:return
1081
+ if event.data_table.id!='agent-actions' and self.screen.has_class('compact'):
1082
+ self.action_expand()
1083
+ return
1084
+ if event.data_table.id=='agent-table':
1085
+ self.selected_source=item['id'];self.show_agent();self.query_one('#agent-detail',RichLog).focus()
1086
+ elif event.data_table.id=='agent-actions':
1087
+ self.selected_activity=item.get('id') or item['source_id'];self.action_page('activity');self.show_activity()
1088
+ elif event.data_table.id=='activity-table':self.query_one('#activity-detail',RichLog).focus()
1089
+ elif event.data_table.id=='mcp-table':self.query_one('#mcp-detail',RichLog).focus()
1090
+ elif event.data_table.id=='trace-table':self.query_one('#span-tree',Tree).focus()
1091
+ elif event.data_table.id=='host-table':self.query_one('#host-detail',RichLog).focus()
1092
+
1093
+ @on(Tree.NodeHighlighted,'#span-tree')
1094
+ def span_highlighted(self,event):
1095
+ self.show_span(event.node.data)
1096
+
1097
+ def show_span(self,span):
1098
+ if not span:return
1099
+ attrs=span['attributes']
1100
+ self.inspect('span-info',span['name'],[('Span',span['span_id']),('Parent',span.get('parent_span_id') or 'Root'),
1101
+ ('Status',span['status']),('Service',span['service'])],[(key,value) for key,value in attrs.items()])
1102
+
1103
+ @on(Select.Changed,'#scope')
1104
+ def scope_changed(self,event):
1105
+ if event.value==Select.BLANK or event.value==self.scope_value:return
1106
+ self.scope_value=str(event.value);item=getattr(self,'source_map',{}).get(self.scope_value)
1107
+ self.selected_activity=self.selected_mcp=None
1108
+ self.feed.session_id=item.get('session_id') or '' if item else ''
1109
+ self.feed.service=item.get('service') or '' if item else ''
1110
+ self.feed.selected_trace=None;self.feed.pinned_trace=False;self.feed.request_refresh()
1111
+ self.feed.selected_span_id=None
1112
+ self.last_update=-1;self.refresh_data()
1113
+
1114
+ @on(Input.Changed,'.view-search')
1115
+ def search_changed(self,event):
1116
+ page=event.input.id.removeprefix('search-')
1117
+ value=event.value.strip()
1118
+ self.query_one('#clear-'+page,Button).disabled=not event.value
1119
+ if self.search_queries[page]==value:return
1120
+ self.search_queries[page]=value
1121
+ if page=='activity':self.selected_activity=None
1122
+ if page=='mcp':self.selected_mcp=None
1123
+ if page in self.search_timers:self.search_timers.pop(page).stop()
1124
+ def apply():
1125
+ if not self.query('#health'):return
1126
+ attr={'activity':'activity_query','mcp':'mcp_query','traces':'query'}.get(page)
1127
+ if attr:
1128
+ setattr(self.feed,attr,value)
1129
+ if page=='traces':
1130
+ self.feed.selected_trace=None;self.feed.selected_span_id=None;self.feed.pinned_trace=False
1131
+ self.feed.request_refresh()
1132
+ self.last_update=-1;self.refresh_data()
1133
+ if page in {'activity','mcp','traces'}:self.search_timers[page]=self.set_timer(.3,apply)
1134
+ else:apply()
1135
+ self.last_update=-1;self.refresh_data()
1136
+
1137
+ @on(Checkbox.Changed,'#reviews')
1138
+ def reviews_changed(self,event):
1139
+ self.selected_activity=self.selected_mcp=None
1140
+ self.feed.include_reviews=event.value;self.feed.selected_trace=None;self.feed.pinned_trace=False;self.feed.request_refresh()
1141
+ self.feed.selected_span_id=None
1142
+
1143
+ @on(TabbedContent.TabActivated,'#pages')
1144
+ def page_activated(self,event):
1145
+ self.screen.set_class(event.pane.has_class('expanded'),'inspecting')
1146
+
1147
+ @on(Button.Pressed)
1148
+ def button_pressed(self,event):
1149
+ if event.button.id=='pause':self.action_pause()
1150
+ elif event.button.id=='history-back':self.action_history_back()
1151
+ elif event.button.id.startswith(('older-','newer-','latest-')):
1152
+ direction,page=event.button.id.split('-',1)
1153
+ self.action_history(direction,page)
1154
+ elif event.button.id.startswith('expand-'):self.action_expand()
1155
+ elif event.button.id.startswith('errors-'):
1156
+ page=event.button.id.removeprefix('errors-')
1157
+ if page=='activity':self.selected_activity=None
1158
+ if page=='mcp':self.selected_mcp=None
1159
+ if page in self.errors_only:self.errors_only.remove(page)
1160
+ else:self.errors_only.add(page)
1161
+ event.button.set_class(page in self.errors_only,'-on')
1162
+ event.button.label='Errors ●' if page in self.errors_only else 'Errors'
1163
+ self.last_update=-1;self.refresh_data()
1164
+ elif event.button.id.startswith('clear-'):
1165
+ field=self.query_one('#search-'+event.button.id.removeprefix('clear-'),Input)
1166
+ field.value='';field.focus()
1167
+ elif event.button.id=='agent-open':self.action_focus_agent()
1168
+ elif event.button.id in {'activity-trace','mcp-trace'}:self.action_open_trace()
1169
+ elif event.button.id in {'activity-copy','mcp-copy'}:self.action_copy()
1170
+
1171
+ def action_pause(self):
1172
+ self.feed.paused=not self.feed.paused;self.query_one('#pause',Button).label='Resume' if self.feed.paused else 'Pause';self.feed.request_refresh();self.refresh_data()
1173
+
1174
+ def action_search(self):
1175
+ page=self.query_one('#pages',TabbedContent).active
1176
+ if page in self.search_queries:
1177
+ self.collapse(page)
1178
+ self.query_one('#search-'+page,Input).focus()
1179
+ def action_back(self):
1180
+ if self.screen.selections:
1181
+ self.screen.clear_selection();return
1182
+ page=self.query_one('#pages',TabbedContent).active
1183
+ if isinstance(self.focused,Input) or self.query_one('#'+page).has_class('expanded'):
1184
+ self.action_page(page)
1185
+ elif self.navigation_history:
1186
+ self.action_history_back()
1187
+ else:self.action_page(page)
1188
+
1189
+ def remember_location(self):
1190
+ page=self.query_one('#pages',TabbedContent).active
1191
+ self.navigation_history.append({'page':page,'expanded':self.query_one('#'+page).has_class('expanded'),
1192
+ 'focus':self.focused.id if self.focused else None,
1193
+ 'scroll':{widget.id:widget.scroll_offset for widget in self.query('#'+page+' RichLog, #'+page+' DataTable')}})
1194
+ self.query_one('#history-back',Button).disabled=False
1195
+
1196
+ def action_history_back(self):
1197
+ if not self.navigation_history:return
1198
+ location=self.navigation_history.pop()
1199
+ self.action_page(location['page'])
1200
+ if location['expanded']:self.action_expand()
1201
+ def restore():
1202
+ if location['focus']:self.query_one('#'+location['focus']).focus()
1203
+ for name,position in location['scroll'].items():
1204
+ self.query_one('#'+name).scroll_to(x=position.x,y=position.y,animate=False)
1205
+ self.call_after_refresh(restore)
1206
+ self.query_one('#history-back',Button).disabled=not self.navigation_history
1207
+
1208
+ def action_history(self,direction,page=None):
1209
+ page=page or self.query_one('#pages',TabbedContent).active
1210
+ if page not in self.feed.history:return
1211
+ self.screen.clear_selection()
1212
+ old=self.selected_activity,self.selected_mcp
1213
+ if page=='activity':self.selected_activity=None
1214
+ if page=='mcp':self.selected_mcp=None
1215
+ if not self.feed.navigate_history(page,direction):
1216
+ self.selected_activity,self.selected_mcp=old
1217
+ return
1218
+ self.last_update=-1;self.refresh_data()
1219
+
1220
+ def copy_to_clipboard(self,text):
1221
+ """Route all copy actions, including search inputs, to the OS clipboard."""
1222
+ self._clipboard=text # Keep Textual's in-app paste buffer in sync.
1223
+ if text:self._copy_in_background(text)
1224
+
1225
+ @work(group='clipboard',exclusive=True)
1226
+ async def _copy_in_background(self,text):
1227
+ method=await write_system_clipboard(text)
1228
+ if method:
1229
+ self.notify(f'Copied to {method} clipboard.')
1230
+ else:
1231
+ super().copy_to_clipboard(text)
1232
+ self.notify('Copy sent to terminal; clipboard support depends on your terminal.')
1233
+
1234
+ def action_copy(self):
1235
+ selected=self.screen.get_selected_text()
1236
+ if selected:
1237
+ self.copy_to_clipboard(selected);return
1238
+ page=self.query_one('#pages',TabbedContent).active
1239
+ if page=='mcp':
1240
+ item=self.table_data.get('mcp-table',{}).get(self.selected_mcp)
1241
+ elif page=='activity':
1242
+ item=self.table_data.get('activity-table',{}).get(self.selected_activity)
1243
+ else:
1244
+ name={'agents':'agent-detail','traces':'span-info','host':'host-detail','output':'agent-output'}[page]
1245
+ self.copy_to_clipboard(self.query_one('#'+name,SelectableRichLog).plain_text)
1246
+ return
1247
+ if not item:
1248
+ self.notify('Select an activity or MCP call to copy.')
1249
+ return
1250
+ self.copy_to_clipboard(copy_event_text(item))
1251
+ def collapse(self,page):
1252
+ self.query_one('#'+page,TabPane).remove_class('expanded')
1253
+ self.screen.remove_class('inspecting')
1254
+ for button in self.query('#'+page+' .expand-button'):button.label=Text('Expand · e')
1255
+ self.call_after_refresh(self.reflow_inspectors)
1256
+ def action_expand(self):
1257
+ page=self.query_one('#pages',TabbedContent).active
1258
+ if page not in self.search_queries:return
1259
+ pane=self.query_one('#'+page,TabPane)
1260
+ if pane.has_class('expanded'):
1261
+ self.action_back();return
1262
+ pane.add_class('expanded')
1263
+ self.screen.add_class('inspecting')
1264
+ self.call_after_refresh(self.reflow_inspectors)
1265
+ for button in self.query('#'+page+' .expand-button'):button.label=Text('Back · e')
1266
+ target={'agents':'agent-detail','activity':'activity-detail','mcp':'mcp-detail','traces':'span-tree','host':'host-detail'}[page]
1267
+ self.query_one('#'+target).focus()
1268
+ self.notify('Expanded detail · Esc to return',timeout=2)
1269
+ def action_focus_agent(self):
1270
+ if self.selected_source:
1271
+ self.query_one('#scope',Select).value=self.selected_source
1272
+ self.action_page('activity')
1273
+ def action_reviews(self):self.query_one('#reviews',Checkbox).toggle()
1274
+ def action_all(self):
1275
+ self.selected_activity=self.selected_mcp=None
1276
+ self.query_one('#scope',Select).value='all'
1277
+ self.errors_only.clear()
1278
+ for button in self.query('.error-filter'):
1279
+ button.remove_class('-on');button.label='Errors'
1280
+ for page in self.search_queries:self.query_one('#search-'+page,Input).value=''
1281
+ self.last_update=-1;self.refresh_data()
1282
+ def action_page(self,page):
1283
+ if page=='output' and not self.agent:return
1284
+ self.screen.clear_selection()
1285
+ self.collapse(page)
1286
+ self.query_one('#pages',TabbedContent).active=page
1287
+ target={'agents':'agent-table','activity':'activity-table','mcp':'mcp-table','traces':'trace-table','host':'host-table','output':'agent-output'}[page]
1288
+ self.query_one('#'+target).focus()
1289
+ def action_browser(self):webbrowser.open(self.feed.api.endpoint)
1290
+ def action_open_trace(self):
1291
+ page=self.query_one('#pages',TabbedContent).active
1292
+ if page not in {'activity','mcp'}:return
1293
+ item=self.table_data.get('mcp-table',{}).get(self.selected_mcp) if page=='mcp' else self.table_data.get('activity-table',{}).get(self.selected_activity)
1294
+ if not item or not item.get('trace_id'):
1295
+ self.notify('This action has no linked trace.');return
1296
+ self.remember_location()
1297
+ self.jump_span=item.get('span_id')
1298
+ self.feed.selected_span_id=self.jump_span
1299
+ self.feed.selected_trace=item['trace_id'];self.feed.pinned_trace=True;self.feed.request_refresh();self.action_page('traces')