aline-ai 0.6.4__py3-none-any.whl → 0.6.6__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.
- {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/METADATA +1 -1
- {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/RECORD +41 -34
- realign/__init__.py +1 -1
- realign/agent_names.py +79 -0
- realign/claude_hooks/stop_hook.py +3 -0
- realign/claude_hooks/terminal_state.py +11 -0
- realign/claude_hooks/user_prompt_submit_hook.py +3 -0
- realign/cli.py +62 -0
- realign/codex_detector.py +1 -1
- realign/codex_home.py +46 -15
- realign/codex_terminal_linker.py +18 -7
- realign/commands/agent.py +109 -0
- realign/commands/doctor.py +3 -1
- realign/commands/export_shares.py +297 -0
- realign/commands/search.py +58 -29
- realign/dashboard/app.py +9 -158
- realign/dashboard/clipboard.py +54 -0
- realign/dashboard/screens/__init__.py +4 -0
- realign/dashboard/screens/agent_detail.py +333 -0
- realign/dashboard/screens/create_agent_info.py +133 -0
- realign/dashboard/screens/event_detail.py +6 -27
- realign/dashboard/styles/dashboard.tcss +67 -0
- realign/dashboard/tmux_manager.py +49 -8
- realign/dashboard/widgets/__init__.py +2 -0
- realign/dashboard/widgets/agents_panel.py +1129 -0
- realign/dashboard/widgets/config_panel.py +17 -11
- realign/dashboard/widgets/events_table.py +4 -27
- realign/dashboard/widgets/sessions_table.py +4 -27
- realign/dashboard/widgets/terminal_panel.py +109 -31
- realign/db/base.py +27 -0
- realign/db/locks.py +4 -0
- realign/db/schema.py +53 -2
- realign/db/sqlite_db.py +185 -2
- realign/events/agent_summarizer.py +157 -0
- realign/events/session_summarizer.py +25 -0
- realign/watcher_core.py +60 -3
- realign/worker_core.py +24 -1
- {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/WHEEL +0 -0
- {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/top_level.txt +0 -0
realign/dashboard/app.py
CHANGED
|
@@ -5,7 +5,6 @@ import subprocess
|
|
|
5
5
|
import sys
|
|
6
6
|
import time
|
|
7
7
|
import traceback
|
|
8
|
-
from pathlib import Path
|
|
9
8
|
|
|
10
9
|
from textual.app import App, ComposeResult
|
|
11
10
|
from textual.binding import Binding
|
|
@@ -16,11 +15,8 @@ from .widgets import (
|
|
|
16
15
|
AlineHeader,
|
|
17
16
|
WatcherPanel,
|
|
18
17
|
WorkerPanel,
|
|
19
|
-
SessionsTable,
|
|
20
|
-
EventsTable,
|
|
21
18
|
ConfigPanel,
|
|
22
|
-
|
|
23
|
-
TerminalPanel,
|
|
19
|
+
AgentsPanel,
|
|
24
20
|
)
|
|
25
21
|
|
|
26
22
|
# Environment variable to control terminal mode
|
|
@@ -71,9 +67,6 @@ class AlineDashboard(App):
|
|
|
71
67
|
Binding("n", "page_next", "Next Page", show=False),
|
|
72
68
|
Binding("p", "page_prev", "Prev Page", show=False),
|
|
73
69
|
Binding("s", "switch_view", "Switch View", show=False),
|
|
74
|
-
Binding("c", "create_event", "Create Event", show=False),
|
|
75
|
-
Binding("l", "load_context", "Load Context", show=False),
|
|
76
|
-
Binding("y", "share_import", "Share Import", show=False),
|
|
77
70
|
Binding("ctrl+c", "quit_confirm", "Quit", priority=True),
|
|
78
71
|
]
|
|
79
72
|
|
|
@@ -111,22 +104,16 @@ class AlineDashboard(App):
|
|
|
111
104
|
try:
|
|
112
105
|
yield AlineHeader()
|
|
113
106
|
tab_ids = self._tab_ids()
|
|
114
|
-
with TabbedContent(initial=tab_ids[0] if tab_ids else "
|
|
115
|
-
with TabPane("Agents", id="
|
|
116
|
-
yield
|
|
107
|
+
with TabbedContent(initial=tab_ids[0] if tab_ids else "agents"):
|
|
108
|
+
with TabPane("Agents", id="agents"):
|
|
109
|
+
yield AgentsPanel()
|
|
117
110
|
if self.dev_mode:
|
|
118
111
|
with TabPane("Watcher", id="watcher"):
|
|
119
112
|
yield WatcherPanel()
|
|
120
113
|
with TabPane("Worker", id="worker"):
|
|
121
114
|
yield WorkerPanel()
|
|
122
|
-
with TabPane("Contexts", id="sessions"):
|
|
123
|
-
yield SessionsTable()
|
|
124
|
-
with TabPane("Share", id="events"):
|
|
125
|
-
yield EventsTable()
|
|
126
115
|
with TabPane("Config", id="config"):
|
|
127
116
|
yield ConfigPanel()
|
|
128
|
-
with TabPane("Search", id="search"):
|
|
129
|
-
yield SearchPanel()
|
|
130
117
|
yield Footer()
|
|
131
118
|
logger.debug("compose() completed successfully")
|
|
132
119
|
except Exception as e:
|
|
@@ -135,8 +122,8 @@ class AlineDashboard(App):
|
|
|
135
122
|
|
|
136
123
|
def _tab_ids(self) -> list[str]:
|
|
137
124
|
if self.dev_mode:
|
|
138
|
-
return ["
|
|
139
|
-
return ["
|
|
125
|
+
return ["agents", "watcher", "worker", "config"]
|
|
126
|
+
return ["agents", "config"]
|
|
140
127
|
|
|
141
128
|
def on_mount(self) -> None:
|
|
142
129
|
"""Apply theme based on system settings and watch for changes."""
|
|
@@ -218,20 +205,14 @@ class AlineDashboard(App):
|
|
|
218
205
|
tabbed_content = self.query_one(TabbedContent)
|
|
219
206
|
active_tab_id = tabbed_content.active
|
|
220
207
|
|
|
221
|
-
if active_tab_id == "
|
|
208
|
+
if active_tab_id == "agents":
|
|
209
|
+
self.query_one(AgentsPanel).refresh_data()
|
|
210
|
+
elif active_tab_id == "watcher":
|
|
222
211
|
self.query_one(WatcherPanel).refresh_data()
|
|
223
212
|
elif active_tab_id == "worker":
|
|
224
213
|
self.query_one(WorkerPanel).refresh_data()
|
|
225
|
-
elif active_tab_id == "sessions":
|
|
226
|
-
self.query_one(SessionsTable).refresh_data()
|
|
227
|
-
elif active_tab_id == "events":
|
|
228
|
-
self.query_one(EventsTable).refresh_data()
|
|
229
214
|
elif active_tab_id == "config":
|
|
230
215
|
self.query_one(ConfigPanel).refresh_data()
|
|
231
|
-
elif active_tab_id == "search":
|
|
232
|
-
pass # Search is manual
|
|
233
|
-
elif active_tab_id == "terminal":
|
|
234
|
-
await self.query_one(TerminalPanel).refresh_data()
|
|
235
216
|
|
|
236
217
|
def action_page_next(self) -> None:
|
|
237
218
|
"""Go to next page in current panel."""
|
|
@@ -242,7 +223,6 @@ class AlineDashboard(App):
|
|
|
242
223
|
self.query_one(WatcherPanel).action_next_page()
|
|
243
224
|
elif active_tab_id == "worker":
|
|
244
225
|
self.query_one(WorkerPanel).action_next_page()
|
|
245
|
-
# sessions and events tabs use scrolling instead of pagination
|
|
246
226
|
|
|
247
227
|
def action_page_prev(self) -> None:
|
|
248
228
|
"""Go to previous page in current panel."""
|
|
@@ -253,7 +233,6 @@ class AlineDashboard(App):
|
|
|
253
233
|
self.query_one(WatcherPanel).action_prev_page()
|
|
254
234
|
elif active_tab_id == "worker":
|
|
255
235
|
self.query_one(WorkerPanel).action_prev_page()
|
|
256
|
-
# sessions and events tabs use scrolling instead of pagination
|
|
257
236
|
|
|
258
237
|
def action_switch_view(self) -> None:
|
|
259
238
|
"""Switch view in current panel (if supported)."""
|
|
@@ -264,10 +243,6 @@ class AlineDashboard(App):
|
|
|
264
243
|
self.query_one(WatcherPanel).action_switch_view()
|
|
265
244
|
elif active_tab_id == "worker":
|
|
266
245
|
self.query_one(WorkerPanel).action_switch_view()
|
|
267
|
-
elif active_tab_id == "sessions":
|
|
268
|
-
self.query_one(SessionsTable).action_switch_view()
|
|
269
|
-
elif active_tab_id == "events":
|
|
270
|
-
self.query_one(EventsTable).action_switch_view()
|
|
271
246
|
|
|
272
247
|
def action_help(self) -> None:
|
|
273
248
|
"""Show help information."""
|
|
@@ -286,130 +261,6 @@ class AlineDashboard(App):
|
|
|
286
261
|
self._quit_confirm_deadline = now + self._quit_confirm_window_s
|
|
287
262
|
self.notify("Press Ctrl+C again to quit", title="Quit", timeout=2)
|
|
288
263
|
|
|
289
|
-
def action_create_event(self) -> None:
|
|
290
|
-
"""Create an event from selected sessions (Sessions tab only)."""
|
|
291
|
-
tabbed_content = self.query_one(TabbedContent)
|
|
292
|
-
if tabbed_content.active != "sessions":
|
|
293
|
-
self.notify(
|
|
294
|
-
"Switch to the Sessions tab to create an event", title="Create Event", timeout=3
|
|
295
|
-
)
|
|
296
|
-
return
|
|
297
|
-
|
|
298
|
-
sessions_panel = self.query_one(SessionsTable)
|
|
299
|
-
session_ids = sessions_panel.get_selected_session_ids()
|
|
300
|
-
if not session_ids:
|
|
301
|
-
self.notify(
|
|
302
|
-
"No sessions selected (use space / cmd-click / shift-click)",
|
|
303
|
-
title="Create Event",
|
|
304
|
-
timeout=3,
|
|
305
|
-
)
|
|
306
|
-
return
|
|
307
|
-
|
|
308
|
-
from .screens import CreateEventScreen
|
|
309
|
-
|
|
310
|
-
self.push_screen(CreateEventScreen(session_ids))
|
|
311
|
-
|
|
312
|
-
def action_share_import(self) -> None:
|
|
313
|
-
"""Import a share URL (Events tab only)."""
|
|
314
|
-
tabbed_content = self.query_one(TabbedContent)
|
|
315
|
-
if tabbed_content.active != "events":
|
|
316
|
-
self.notify(
|
|
317
|
-
"Switch to the Events tab to import a share", title="Share Import", timeout=3
|
|
318
|
-
)
|
|
319
|
-
return
|
|
320
|
-
|
|
321
|
-
from .screens import ShareImportScreen
|
|
322
|
-
|
|
323
|
-
self.push_screen(ShareImportScreen())
|
|
324
|
-
|
|
325
|
-
async def action_load_context(self) -> None:
|
|
326
|
-
"""Load selected sessions/events into the active terminal context (Claude/Codex)."""
|
|
327
|
-
tabbed_content = self.query_one(TabbedContent)
|
|
328
|
-
active_tab_id = tabbed_content.active
|
|
329
|
-
|
|
330
|
-
try:
|
|
331
|
-
from . import tmux_manager
|
|
332
|
-
|
|
333
|
-
context_id = tmux_manager.get_active_context_id(
|
|
334
|
-
allowed_providers={"claude", "codex"}
|
|
335
|
-
)
|
|
336
|
-
except Exception:
|
|
337
|
-
context_id = None
|
|
338
|
-
|
|
339
|
-
if not context_id:
|
|
340
|
-
self.notify(
|
|
341
|
-
"No active context found. Use the Terminal tab and select a Claude ('cc') or Codex terminal.",
|
|
342
|
-
title="Load Context",
|
|
343
|
-
severity="warning",
|
|
344
|
-
timeout=4,
|
|
345
|
-
)
|
|
346
|
-
return
|
|
347
|
-
|
|
348
|
-
sessions: list[str] = []
|
|
349
|
-
events: list[str] = []
|
|
350
|
-
|
|
351
|
-
if active_tab_id == "sessions":
|
|
352
|
-
sessions = self.query_one(SessionsTable).get_selected_session_ids()
|
|
353
|
-
if not sessions:
|
|
354
|
-
self.notify(
|
|
355
|
-
"No sessions selected (use space / cmd-click / shift-click)",
|
|
356
|
-
title="Load Context",
|
|
357
|
-
severity="warning",
|
|
358
|
-
timeout=3,
|
|
359
|
-
)
|
|
360
|
-
return
|
|
361
|
-
elif active_tab_id == "events":
|
|
362
|
-
events = self.query_one(EventsTable).get_selected_event_ids()
|
|
363
|
-
if not events:
|
|
364
|
-
self.notify(
|
|
365
|
-
"No events selected (use space / cmd-click / shift-click)",
|
|
366
|
-
title="Load Context",
|
|
367
|
-
severity="warning",
|
|
368
|
-
timeout=3,
|
|
369
|
-
)
|
|
370
|
-
return
|
|
371
|
-
else:
|
|
372
|
-
self.notify(
|
|
373
|
-
"Switch to Sessions or Events to load selection into context",
|
|
374
|
-
title="Load Context",
|
|
375
|
-
timeout=3,
|
|
376
|
-
)
|
|
377
|
-
return
|
|
378
|
-
|
|
379
|
-
try:
|
|
380
|
-
from ..context import add_context
|
|
381
|
-
|
|
382
|
-
add_context(
|
|
383
|
-
sessions=sessions or None,
|
|
384
|
-
events=events or None,
|
|
385
|
-
context_id=context_id,
|
|
386
|
-
)
|
|
387
|
-
except Exception as e:
|
|
388
|
-
self.notify(
|
|
389
|
-
f"Failed to load context: {e}",
|
|
390
|
-
title="Load Context",
|
|
391
|
-
severity="error",
|
|
392
|
-
timeout=4,
|
|
393
|
-
)
|
|
394
|
-
return
|
|
395
|
-
|
|
396
|
-
try:
|
|
397
|
-
from .widgets.terminal_panel import TerminalPanel
|
|
398
|
-
|
|
399
|
-
if TerminalPanel.supported():
|
|
400
|
-
await self.query_one(TerminalPanel).refresh_data()
|
|
401
|
-
except Exception:
|
|
402
|
-
pass
|
|
403
|
-
|
|
404
|
-
parts: list[str] = []
|
|
405
|
-
if sessions:
|
|
406
|
-
parts.append(f"{len(sessions)} sessions")
|
|
407
|
-
if events:
|
|
408
|
-
parts.append(f"{len(events)} events")
|
|
409
|
-
what = ", ".join(parts) if parts else "selection"
|
|
410
|
-
self.notify(f"Loaded {what} into {context_id}", title="Load Context", timeout=3)
|
|
411
|
-
|
|
412
|
-
|
|
413
264
|
def run_dashboard(use_native_terminal: bool | None = None) -> None:
|
|
414
265
|
"""Run the Aline Dashboard.
|
|
415
266
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Clipboard helpers for the dashboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _run_copy(command: list[str], text: str) -> bool:
|
|
11
|
+
try:
|
|
12
|
+
return (
|
|
13
|
+
subprocess.run(
|
|
14
|
+
command,
|
|
15
|
+
input=text,
|
|
16
|
+
text=True,
|
|
17
|
+
capture_output=False,
|
|
18
|
+
check=False,
|
|
19
|
+
).returncode
|
|
20
|
+
== 0
|
|
21
|
+
)
|
|
22
|
+
except Exception:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def copy_text(app, text: str) -> bool:
|
|
27
|
+
if not text:
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
if shutil.which("pbcopy"):
|
|
31
|
+
if _run_copy(["pbcopy"], text):
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
if os.name == "nt" and shutil.which("clip"):
|
|
35
|
+
if _run_copy(["clip"], text):
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
if shutil.which("wl-copy"):
|
|
39
|
+
if _run_copy(["wl-copy"], text):
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
if shutil.which("xclip"):
|
|
43
|
+
if _run_copy(["xclip", "-selection", "clipboard"], text):
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
if shutil.which("xsel"):
|
|
47
|
+
if _run_copy(["xsel", "--clipboard", "--input"], text):
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
app.copy_to_clipboard(text)
|
|
52
|
+
return True
|
|
53
|
+
except Exception:
|
|
54
|
+
return False
|
|
@@ -2,16 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
from .session_detail import SessionDetailScreen
|
|
4
4
|
from .event_detail import EventDetailScreen
|
|
5
|
+
from .agent_detail import AgentDetailScreen
|
|
5
6
|
from .create_event import CreateEventScreen
|
|
6
7
|
from .create_agent import CreateAgentScreen
|
|
8
|
+
from .create_agent_info import CreateAgentInfoScreen
|
|
7
9
|
from .share_import import ShareImportScreen
|
|
8
10
|
from .help_screen import HelpScreen
|
|
9
11
|
|
|
10
12
|
__all__ = [
|
|
11
13
|
"SessionDetailScreen",
|
|
12
14
|
"EventDetailScreen",
|
|
15
|
+
"AgentDetailScreen",
|
|
13
16
|
"CreateEventScreen",
|
|
14
17
|
"CreateAgentScreen",
|
|
18
|
+
"CreateAgentInfoScreen",
|
|
15
19
|
"ShareImportScreen",
|
|
16
20
|
"HelpScreen",
|
|
17
21
|
]
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Agent detail modal for the dashboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from rich.markup import escape
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.containers import Container, Vertical
|
|
12
|
+
from textual.screen import ModalScreen
|
|
13
|
+
from textual.widgets import DataTable, Static
|
|
14
|
+
|
|
15
|
+
from ..widgets.openable_table import OpenableDataTable
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _format_dt(dt: object) -> str:
|
|
19
|
+
if isinstance(dt, datetime):
|
|
20
|
+
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
21
|
+
if dt is None:
|
|
22
|
+
return "-"
|
|
23
|
+
return str(dt)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _format_relative_time(dt: datetime) -> str:
|
|
27
|
+
now = datetime.now()
|
|
28
|
+
diff = now - dt
|
|
29
|
+
seconds = diff.total_seconds()
|
|
30
|
+
|
|
31
|
+
if seconds < 60:
|
|
32
|
+
return "just now"
|
|
33
|
+
if seconds < 3600:
|
|
34
|
+
mins = int(seconds / 60)
|
|
35
|
+
return f"{mins}m ago"
|
|
36
|
+
if seconds < 86400:
|
|
37
|
+
hours = int(seconds / 3600)
|
|
38
|
+
return f"{hours}h ago"
|
|
39
|
+
days = int(seconds / 86400)
|
|
40
|
+
return f"{days}d ago"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _format_iso_relative(dt: Optional[datetime]) -> str:
|
|
44
|
+
if not dt:
|
|
45
|
+
return "-"
|
|
46
|
+
try:
|
|
47
|
+
return _format_relative_time(dt)
|
|
48
|
+
except Exception:
|
|
49
|
+
return _format_dt(dt)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _shorten_id(val: str | None) -> str:
|
|
53
|
+
if not val:
|
|
54
|
+
return "-"
|
|
55
|
+
if len(val) <= 20:
|
|
56
|
+
return val
|
|
57
|
+
return f"{val[:8]}...{val[-8:]}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AgentDetailScreen(ModalScreen):
|
|
61
|
+
"""Modal that shows agent details and its sessions."""
|
|
62
|
+
|
|
63
|
+
BINDINGS = [
|
|
64
|
+
Binding("escape", "close", "Close", show=False),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
DEFAULT_CSS = """
|
|
68
|
+
AgentDetailScreen {
|
|
69
|
+
align: center middle;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
AgentDetailScreen #agent-detail-root {
|
|
73
|
+
width: 95%;
|
|
74
|
+
height: 95%;
|
|
75
|
+
padding: 1;
|
|
76
|
+
background: $background;
|
|
77
|
+
border: none;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
AgentDetailScreen #agent-meta {
|
|
81
|
+
height: auto;
|
|
82
|
+
margin-bottom: 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
AgentDetailScreen #agent-detail-body {
|
|
86
|
+
height: 1fr;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
AgentDetailScreen #agent-description {
|
|
90
|
+
height: auto;
|
|
91
|
+
margin-bottom: 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
AgentDetailScreen #agent-sessions-table {
|
|
95
|
+
width: 1fr;
|
|
96
|
+
height: 1fr;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
AgentDetailScreen #agent-session-preview {
|
|
100
|
+
width: 1fr;
|
|
101
|
+
height: auto;
|
|
102
|
+
margin-top: 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
AgentDetailScreen #agent-hint {
|
|
106
|
+
height: 1;
|
|
107
|
+
margin-top: 1;
|
|
108
|
+
color: $text-muted;
|
|
109
|
+
text-align: right;
|
|
110
|
+
}
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, agent_id: str) -> None:
|
|
114
|
+
super().__init__()
|
|
115
|
+
self.agent_id = agent_id
|
|
116
|
+
self._load_error: Optional[str] = None
|
|
117
|
+
self._agent_info = None
|
|
118
|
+
self._sessions: list[dict] = []
|
|
119
|
+
self._session_record_cache: dict[str, object] = {}
|
|
120
|
+
self._initialized: bool = False
|
|
121
|
+
|
|
122
|
+
def compose(self) -> ComposeResult:
|
|
123
|
+
with Container(id="agent-detail-root"):
|
|
124
|
+
yield Static(id="agent-meta")
|
|
125
|
+
with Vertical(id="agent-detail-body"):
|
|
126
|
+
yield Static(id="agent-description")
|
|
127
|
+
sessions_table = OpenableDataTable(id="agent-sessions-table")
|
|
128
|
+
sessions_table.add_columns(
|
|
129
|
+
"#",
|
|
130
|
+
"Session ID",
|
|
131
|
+
"Source",
|
|
132
|
+
"Project",
|
|
133
|
+
"Turns",
|
|
134
|
+
"Title",
|
|
135
|
+
"Last Activity",
|
|
136
|
+
)
|
|
137
|
+
sessions_table.cursor_type = "row"
|
|
138
|
+
yield sessions_table
|
|
139
|
+
yield Static(id="agent-session-preview")
|
|
140
|
+
yield Static(
|
|
141
|
+
"Click: select Enter/dblclick: open Esc: close",
|
|
142
|
+
id="agent-hint",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def on_show(self) -> None:
|
|
146
|
+
self.call_later(self._ensure_initialized)
|
|
147
|
+
|
|
148
|
+
def _ensure_initialized(self) -> None:
|
|
149
|
+
if self._initialized:
|
|
150
|
+
return
|
|
151
|
+
self._initialized = True
|
|
152
|
+
|
|
153
|
+
sessions_table = self.query_one("#agent-sessions-table", DataTable)
|
|
154
|
+
self._load_data()
|
|
155
|
+
self._update_display()
|
|
156
|
+
|
|
157
|
+
if sessions_table.row_count > 0:
|
|
158
|
+
sessions_table.focus()
|
|
159
|
+
|
|
160
|
+
def action_close(self) -> None:
|
|
161
|
+
self.app.pop_screen()
|
|
162
|
+
|
|
163
|
+
def on_openable_data_table_row_activated(
|
|
164
|
+
self, event: OpenableDataTable.RowActivated
|
|
165
|
+
) -> None:
|
|
166
|
+
if event.data_table.id != "agent-sessions-table":
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
session_id = str(event.row_key.value)
|
|
170
|
+
if not session_id:
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
from .session_detail import SessionDetailScreen
|
|
174
|
+
|
|
175
|
+
self.app.push_screen(SessionDetailScreen(session_id))
|
|
176
|
+
|
|
177
|
+
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
178
|
+
if event.data_table.id != "agent-sessions-table":
|
|
179
|
+
return
|
|
180
|
+
self._update_session_preview(str(event.row_key.value))
|
|
181
|
+
|
|
182
|
+
def _load_data(self) -> None:
|
|
183
|
+
try:
|
|
184
|
+
from ...db import get_database
|
|
185
|
+
|
|
186
|
+
db = get_database()
|
|
187
|
+
self._agent_info = db.get_agent_info(self.agent_id)
|
|
188
|
+
sessions = db.get_sessions_by_agent_id(self.agent_id, limit=1000)
|
|
189
|
+
|
|
190
|
+
source_map = {
|
|
191
|
+
"claude": "Claude",
|
|
192
|
+
"codex": "Codex",
|
|
193
|
+
"gemini": "Gemini",
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
self._sessions = []
|
|
197
|
+
for s in sessions:
|
|
198
|
+
session_id = str(s.id)
|
|
199
|
+
session_type = getattr(s, "session_type", None) or "unknown"
|
|
200
|
+
workspace = getattr(s, "workspace_path", None)
|
|
201
|
+
title = getattr(s, "session_title", None) or "(no title)"
|
|
202
|
+
last_activity = getattr(s, "last_activity_at", None)
|
|
203
|
+
turns = int(getattr(s, "total_turns", 0) or 0)
|
|
204
|
+
|
|
205
|
+
project = str(workspace).split("/")[-1] if workspace else "-"
|
|
206
|
+
source = source_map.get(session_type, session_type)
|
|
207
|
+
|
|
208
|
+
self._sessions.append(
|
|
209
|
+
{
|
|
210
|
+
"id": session_id,
|
|
211
|
+
"short_id": _shorten_id(session_id),
|
|
212
|
+
"source": source,
|
|
213
|
+
"project": project,
|
|
214
|
+
"turns": turns,
|
|
215
|
+
"title": title,
|
|
216
|
+
"last_activity": last_activity,
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
self._sessions.sort(
|
|
221
|
+
key=lambda item: item.get("last_activity") or datetime.min, reverse=True
|
|
222
|
+
)
|
|
223
|
+
self._load_error = None
|
|
224
|
+
except Exception as e:
|
|
225
|
+
self._agent_info = None
|
|
226
|
+
self._sessions = []
|
|
227
|
+
self._session_record_cache = {}
|
|
228
|
+
self._load_error = str(e)
|
|
229
|
+
|
|
230
|
+
def _update_display(self) -> None:
|
|
231
|
+
meta = self.query_one("#agent-meta", Static)
|
|
232
|
+
description = self.query_one("#agent-description", Static)
|
|
233
|
+
preview = self.query_one("#agent-session-preview", Static)
|
|
234
|
+
|
|
235
|
+
if self._load_error:
|
|
236
|
+
meta.update(f"[red]Failed to load agent {self.agent_id}:[/red] {self._load_error}")
|
|
237
|
+
description.update("")
|
|
238
|
+
preview.update("")
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
name = getattr(self._agent_info, "name", None) if self._agent_info else None
|
|
242
|
+
desc = getattr(self._agent_info, "description", None) if self._agent_info else None
|
|
243
|
+
created_at = getattr(self._agent_info, "created_at", None) if self._agent_info else None
|
|
244
|
+
updated_at = getattr(self._agent_info, "updated_at", None) if self._agent_info else None
|
|
245
|
+
|
|
246
|
+
meta_lines = [
|
|
247
|
+
f"[bold]Agent[/bold] {self.agent_id}",
|
|
248
|
+
f"[dim]Name:[/dim] {escape(name) if name else '(no name)'}",
|
|
249
|
+
f"[dim]Created:[/dim] {_format_dt(created_at)} [dim]Updated:[/dim] {_format_dt(updated_at)}",
|
|
250
|
+
f"[dim]Sessions:[/dim] {len(self._sessions)}",
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
meta.update("\n".join(meta_lines))
|
|
254
|
+
description.update(desc or "(no description)")
|
|
255
|
+
preview.update("")
|
|
256
|
+
|
|
257
|
+
table = self.query_one("#agent-sessions-table", DataTable)
|
|
258
|
+
selected_session_id: Optional[str] = None
|
|
259
|
+
try:
|
|
260
|
+
if table.row_count > 0:
|
|
261
|
+
selected_session_id = str(
|
|
262
|
+
table.coordinate_to_cell_key(table.cursor_coordinate)[0].value
|
|
263
|
+
)
|
|
264
|
+
except Exception:
|
|
265
|
+
selected_session_id = None
|
|
266
|
+
table.clear()
|
|
267
|
+
|
|
268
|
+
for idx, s in enumerate(self._sessions, 1):
|
|
269
|
+
title_cell = s["title"]
|
|
270
|
+
if len(title_cell) > 40:
|
|
271
|
+
title_cell = title_cell[:40] + "..."
|
|
272
|
+
|
|
273
|
+
last_activity_str = _format_iso_relative(s["last_activity"])
|
|
274
|
+
|
|
275
|
+
table.add_row(
|
|
276
|
+
str(idx),
|
|
277
|
+
s["short_id"],
|
|
278
|
+
s["source"],
|
|
279
|
+
s["project"],
|
|
280
|
+
str(s["turns"]),
|
|
281
|
+
title_cell,
|
|
282
|
+
last_activity_str,
|
|
283
|
+
key=s["id"],
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if table.row_count > 0:
|
|
287
|
+
if selected_session_id:
|
|
288
|
+
try:
|
|
289
|
+
table.cursor_coordinate = (table.get_row_index(selected_session_id), 0)
|
|
290
|
+
except Exception:
|
|
291
|
+
table.cursor_coordinate = (0, 0)
|
|
292
|
+
else:
|
|
293
|
+
table.cursor_coordinate = (0, 0)
|
|
294
|
+
|
|
295
|
+
row_key = table.coordinate_to_cell_key(table.cursor_coordinate)[0]
|
|
296
|
+
self._update_session_preview(str(row_key.value))
|
|
297
|
+
|
|
298
|
+
def _get_session_record(self, session_id: str) -> Optional[object]:
|
|
299
|
+
if session_id in self._session_record_cache:
|
|
300
|
+
return self._session_record_cache[session_id]
|
|
301
|
+
try:
|
|
302
|
+
from ...db import get_database
|
|
303
|
+
|
|
304
|
+
db = get_database()
|
|
305
|
+
record = db.get_session_by_id(session_id)
|
|
306
|
+
self._session_record_cache[session_id] = record
|
|
307
|
+
return record
|
|
308
|
+
except Exception:
|
|
309
|
+
self._session_record_cache[session_id] = None
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
def _update_session_preview(self, session_id: str) -> None:
|
|
313
|
+
preview = self.query_one("#agent-session-preview", Static)
|
|
314
|
+
if not session_id:
|
|
315
|
+
preview.update("")
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
record = self._get_session_record(session_id)
|
|
319
|
+
if not record:
|
|
320
|
+
preview.update("[dim]No session details available.[/dim]")
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
title = getattr(record, "session_title", None) or "(no title)"
|
|
324
|
+
summary = getattr(record, "session_summary", None) or "(no summary)"
|
|
325
|
+
|
|
326
|
+
preview.update(
|
|
327
|
+
"\n".join(
|
|
328
|
+
[
|
|
329
|
+
f"[bold]Title:[/bold] {escape(title)}",
|
|
330
|
+
f"[bold]Summary:[/bold] {escape(summary)}",
|
|
331
|
+
]
|
|
332
|
+
)
|
|
333
|
+
)
|