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.
Files changed (41) hide show
  1. {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/METADATA +1 -1
  2. {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/RECORD +41 -34
  3. realign/__init__.py +1 -1
  4. realign/agent_names.py +79 -0
  5. realign/claude_hooks/stop_hook.py +3 -0
  6. realign/claude_hooks/terminal_state.py +11 -0
  7. realign/claude_hooks/user_prompt_submit_hook.py +3 -0
  8. realign/cli.py +62 -0
  9. realign/codex_detector.py +1 -1
  10. realign/codex_home.py +46 -15
  11. realign/codex_terminal_linker.py +18 -7
  12. realign/commands/agent.py +109 -0
  13. realign/commands/doctor.py +3 -1
  14. realign/commands/export_shares.py +297 -0
  15. realign/commands/search.py +58 -29
  16. realign/dashboard/app.py +9 -158
  17. realign/dashboard/clipboard.py +54 -0
  18. realign/dashboard/screens/__init__.py +4 -0
  19. realign/dashboard/screens/agent_detail.py +333 -0
  20. realign/dashboard/screens/create_agent_info.py +133 -0
  21. realign/dashboard/screens/event_detail.py +6 -27
  22. realign/dashboard/styles/dashboard.tcss +67 -0
  23. realign/dashboard/tmux_manager.py +49 -8
  24. realign/dashboard/widgets/__init__.py +2 -0
  25. realign/dashboard/widgets/agents_panel.py +1129 -0
  26. realign/dashboard/widgets/config_panel.py +17 -11
  27. realign/dashboard/widgets/events_table.py +4 -27
  28. realign/dashboard/widgets/sessions_table.py +4 -27
  29. realign/dashboard/widgets/terminal_panel.py +109 -31
  30. realign/db/base.py +27 -0
  31. realign/db/locks.py +4 -0
  32. realign/db/schema.py +53 -2
  33. realign/db/sqlite_db.py +185 -2
  34. realign/events/agent_summarizer.py +157 -0
  35. realign/events/session_summarizer.py +25 -0
  36. realign/watcher_core.py +60 -3
  37. realign/worker_core.py +24 -1
  38. {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/WHEEL +0 -0
  39. {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/entry_points.txt +0 -0
  40. {aline_ai-0.6.4.dist-info → aline_ai-0.6.6.dist-info}/licenses/LICENSE +0 -0
  41. {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
- SearchPanel,
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 "terminal"):
115
- with TabPane("Agents", id="terminal"):
116
- yield TerminalPanel(use_native_terminal=self.use_native_terminal)
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 ["terminal", "watcher", "worker", "sessions", "events", "config", "search"]
139
- return ["terminal", "sessions", "events", "config", "search"]
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 == "watcher":
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
+ )