aline-ai 0.6.5__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 (38) hide show
  1. {aline_ai-0.6.5.dist-info → aline_ai-0.6.6.dist-info}/METADATA +1 -1
  2. {aline_ai-0.6.5.dist-info → aline_ai-0.6.6.dist-info}/RECORD +38 -31
  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/export_shares.py +297 -0
  14. realign/commands/search.py +58 -29
  15. realign/dashboard/app.py +9 -9
  16. realign/dashboard/clipboard.py +54 -0
  17. realign/dashboard/screens/__init__.py +4 -0
  18. realign/dashboard/screens/agent_detail.py +333 -0
  19. realign/dashboard/screens/create_agent_info.py +133 -0
  20. realign/dashboard/screens/event_detail.py +6 -27
  21. realign/dashboard/styles/dashboard.tcss +67 -0
  22. realign/dashboard/widgets/__init__.py +2 -0
  23. realign/dashboard/widgets/agents_panel.py +1129 -0
  24. realign/dashboard/widgets/events_table.py +4 -27
  25. realign/dashboard/widgets/sessions_table.py +4 -27
  26. realign/dashboard/widgets/terminal_panel.py +40 -5
  27. realign/db/base.py +27 -0
  28. realign/db/locks.py +4 -0
  29. realign/db/schema.py +53 -2
  30. realign/db/sqlite_db.py +185 -2
  31. realign/events/agent_summarizer.py +157 -0
  32. realign/events/session_summarizer.py +25 -0
  33. realign/watcher_core.py +60 -3
  34. realign/worker_core.py +24 -1
  35. {aline_ai-0.6.5.dist-info → aline_ai-0.6.6.dist-info}/WHEEL +0 -0
  36. {aline_ai-0.6.5.dist-info → aline_ai-0.6.6.dist-info}/entry_points.txt +0 -0
  37. {aline_ai-0.6.5.dist-info → aline_ai-0.6.6.dist-info}/licenses/LICENSE +0 -0
  38. {aline_ai-0.6.5.dist-info → aline_ai-0.6.6.dist-info}/top_level.txt +0 -0
@@ -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
+ )
@@ -0,0 +1,133 @@
1
+ """Create agent info modal for the dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from typing import Optional
7
+
8
+ from textual.app import ComposeResult
9
+ from textual.binding import Binding
10
+ from textual.containers import Container, Horizontal
11
+ from textual.screen import ModalScreen
12
+ from textual.widgets import Button, Input, Label, Static
13
+
14
+ from ...logging_config import setup_logger
15
+
16
+ logger = setup_logger("realign.dashboard.screens.create_agent_info", "dashboard.log")
17
+
18
+
19
+ class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
20
+ """Modal to create a new agent profile.
21
+
22
+ Returns a dict with agent info on success, None on cancel.
23
+ """
24
+
25
+ BINDINGS = [
26
+ Binding("escape", "close", "Close", show=False),
27
+ ]
28
+
29
+ DEFAULT_CSS = """
30
+ CreateAgentInfoScreen {
31
+ align: center middle;
32
+ }
33
+
34
+ CreateAgentInfoScreen #create-agent-info-root {
35
+ width: 60;
36
+ height: auto;
37
+ max-height: 80%;
38
+ padding: 1 2;
39
+ background: $background;
40
+ border: solid $accent;
41
+ }
42
+
43
+ CreateAgentInfoScreen #create-agent-info-title {
44
+ height: auto;
45
+ margin-bottom: 1;
46
+ text-style: bold;
47
+ }
48
+
49
+ CreateAgentInfoScreen .section-label {
50
+ height: auto;
51
+ margin-top: 1;
52
+ margin-bottom: 0;
53
+ color: $text-muted;
54
+ }
55
+
56
+ CreateAgentInfoScreen Input {
57
+ height: auto;
58
+ margin-top: 0;
59
+ }
60
+
61
+ CreateAgentInfoScreen #buttons {
62
+ height: auto;
63
+ margin-top: 2;
64
+ align: right middle;
65
+ }
66
+
67
+ CreateAgentInfoScreen #buttons Button {
68
+ margin-left: 1;
69
+ }
70
+ """
71
+
72
+ def compose(self) -> ComposeResult:
73
+ with Container(id="create-agent-info-root"):
74
+ yield Static("Create Agent Profile", id="create-agent-info-title")
75
+
76
+ yield Label("Name", classes="section-label")
77
+ yield Input(placeholder="Agent name (leave blank for random)", id="agent-name")
78
+
79
+ yield Label("Description", classes="section-label")
80
+ yield Input(placeholder="Optional description", id="agent-description")
81
+
82
+ with Horizontal(id="buttons"):
83
+ yield Button("Cancel", id="cancel")
84
+ yield Button("Create", id="create", variant="primary")
85
+
86
+ def on_mount(self) -> None:
87
+ self.query_one("#agent-name", Input).focus()
88
+
89
+ def action_close(self) -> None:
90
+ self.dismiss(None)
91
+
92
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
93
+ button_id = event.button.id or ""
94
+
95
+ if button_id == "cancel":
96
+ self.dismiss(None)
97
+ return
98
+
99
+ if button_id == "create":
100
+ await self._create_agent()
101
+ return
102
+
103
+ async def on_input_submitted(self, event: Input.Submitted) -> None:
104
+ """Handle enter key in input fields."""
105
+ await self._create_agent()
106
+
107
+ async def _create_agent(self) -> None:
108
+ """Create the agent profile."""
109
+ try:
110
+ from ...agent_names import generate_agent_name
111
+ from ...db import get_database
112
+
113
+ name_input = self.query_one("#agent-name", Input).value.strip()
114
+ description = self.query_one("#agent-description", Input).value.strip()
115
+
116
+ # Generate random name if not provided
117
+ name = name_input or generate_agent_name()
118
+
119
+ agent_id = str(uuid.uuid4())
120
+
121
+ db = get_database(read_only=False)
122
+ record = db.get_or_create_agent_info(agent_id, name=name)
123
+ if description:
124
+ record = db.update_agent_info(agent_id, description=description)
125
+
126
+ self.dismiss({
127
+ "id": record.id,
128
+ "name": record.name,
129
+ "description": record.description or "",
130
+ })
131
+ except Exception as e:
132
+ logger.error(f"Failed to create agent: {e}")
133
+ self.app.notify(f"Failed to create agent: {e}", severity="error")
@@ -6,9 +6,6 @@ import contextlib
6
6
  from datetime import datetime
7
7
  import io
8
8
  import json
9
- import os
10
- import shutil
11
- import subprocess
12
9
  from typing import Optional
13
10
 
14
11
  from rich.markup import escape
@@ -20,6 +17,7 @@ from textual.worker import Worker, WorkerState
20
17
  from textual.widgets import DataTable, Static, TextArea
21
18
 
22
19
  from ..widgets.openable_table import OpenableDataTable
20
+ from ..clipboard import copy_text
23
21
 
24
22
 
25
23
  def _format_dt(dt: object) -> str:
@@ -246,36 +244,17 @@ class EventDetailScreen(ModalScreen):
246
244
  pass
247
245
 
248
246
  if exit_code == 0 and share_link:
247
+ from ..clipboard import copy_text
248
+
249
249
  if not slack_message:
250
250
  slack_message = getattr(self._event, "slack_message", None) if self._event else None
251
251
 
252
252
  if slack_message:
253
- copy_text = str(slack_message) + "\n\n" + str(share_link)
253
+ text_to_copy = str(slack_message) + "\n\n" + str(share_link)
254
254
  else:
255
- copy_text = str(share_link)
255
+ text_to_copy = str(share_link)
256
256
 
257
- copied = False
258
- if os.environ.get("TMUX") and shutil.which("pbcopy"):
259
- try:
260
- copied = (
261
- subprocess.run(
262
- ["pbcopy"],
263
- input=copy_text,
264
- text=True,
265
- capture_output=False,
266
- check=False,
267
- ).returncode
268
- == 0
269
- )
270
- except Exception:
271
- copied = False
272
-
273
- if not copied:
274
- try:
275
- self.app.copy_to_clipboard(copy_text)
276
- copied = True
277
- except Exception:
278
- copied = False
257
+ copied = copy_text(self.app, text_to_copy)
279
258
 
280
259
  suffix = " (copied)" if copied else ""
281
260
  self.app.notify(f"Share link: {share_link}{suffix}", title="Share", timeout=6)
@@ -228,3 +228,70 @@ TerminalPanel .context-sessions Static {
228
228
  text-align: left;
229
229
  content-align: left middle;
230
230
  }
231
+
232
+ /* Agents tab: compact layout matching terminal panel */
233
+ AgentsPanel {
234
+ padding: 0 1;
235
+ }
236
+
237
+ AgentsPanel:focus {
238
+ border: none;
239
+ }
240
+
241
+ AgentsPanel .summary {
242
+ background: transparent;
243
+ border: none;
244
+ padding: 0;
245
+ margin: 0 0 1 0;
246
+ }
247
+
248
+ AgentsPanel .list {
249
+ background: transparent;
250
+ border: none;
251
+ padding: 0;
252
+ }
253
+
254
+ AgentsPanel Button {
255
+ min-width: 0;
256
+ background: transparent;
257
+ border: none;
258
+ padding: 0 1;
259
+ }
260
+
261
+ AgentsPanel Button:hover {
262
+ background: $surface-lighten-1;
263
+ }
264
+
265
+ AgentsPanel .agent-row Button.agent-name {
266
+ text-align: left;
267
+ content-align: left middle;
268
+ }
269
+
270
+ AgentsPanel .agent-row Button.agent-create {
271
+ width: auto;
272
+ min-width: 8;
273
+ }
274
+
275
+ AgentsPanel .agent-row Button.agent-delete {
276
+ padding: 0;
277
+ width: 3;
278
+ min-width: 3;
279
+ }
280
+
281
+ AgentsPanel .terminal-list {
282
+ background: transparent;
283
+ border: none;
284
+ padding: 0;
285
+ margin: 0 0 1 2;
286
+ }
287
+
288
+ AgentsPanel .terminal-row Button.terminal-switch {
289
+ text-align: left;
290
+ content-align: left top;
291
+ }
292
+
293
+ AgentsPanel .terminal-row Button.terminal-close {
294
+ padding: 0;
295
+ width: 3;
296
+ min-width: 3;
297
+ }
@@ -9,6 +9,7 @@ from .config_panel import ConfigPanel
9
9
  from .search_panel import SearchPanel
10
10
  from .openable_table import OpenableDataTable
11
11
  from .terminal_panel import TerminalPanel
12
+ from .agents_panel import AgentsPanel
12
13
 
13
14
  __all__ = [
14
15
  "AlineHeader",
@@ -20,4 +21,5 @@ __all__ = [
20
21
  "SearchPanel",
21
22
  "OpenableDataTable",
22
23
  "TerminalPanel",
24
+ "AgentsPanel",
23
25
  ]