aline-ai 0.6.5__py3-none-any.whl → 0.6.7__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.5.dist-info → aline_ai-0.6.7.dist-info}/METADATA +1 -1
- {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.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 +43 -1
- realign/claude_hooks/user_prompt_submit_hook.py +3 -0
- realign/cli.py +62 -0
- realign/codex_detector.py +18 -3
- realign/codex_home.py +65 -16
- realign/codex_terminal_linker.py +18 -7
- realign/commands/agent.py +109 -0
- realign/commands/doctor.py +74 -1
- realign/commands/export_shares.py +448 -0
- realign/commands/import_shares.py +203 -1
- realign/commands/search.py +58 -29
- realign/commands/sync_agent.py +347 -0
- realign/dashboard/app.py +9 -9
- 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 +244 -0
- realign/dashboard/screens/event_detail.py +6 -27
- realign/dashboard/styles/dashboard.tcss +22 -28
- realign/dashboard/tmux_manager.py +36 -10
- realign/dashboard/widgets/__init__.py +2 -2
- realign/dashboard/widgets/agents_panel.py +1248 -0
- realign/dashboard/widgets/events_table.py +4 -27
- realign/dashboard/widgets/sessions_table.py +4 -27
- realign/db/base.py +69 -0
- realign/db/locks.py +4 -0
- realign/db/schema.py +111 -2
- realign/db/sqlite_db.py +360 -2
- realign/events/agent_summarizer.py +157 -0
- realign/events/session_summarizer.py +25 -0
- realign/watcher_core.py +193 -5
- realign/worker_core.py +59 -1
- realign/dashboard/widgets/terminal_panel.py +0 -1653
- {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/WHEEL +0 -0
- {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.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,244 @@
|
|
|
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, Vertical
|
|
11
|
+
from textual.screen import ModalScreen
|
|
12
|
+
from textual.widgets import Button, Input, Label, Static
|
|
13
|
+
from textual.worker import Worker, WorkerState
|
|
14
|
+
|
|
15
|
+
from ...logging_config import setup_logger
|
|
16
|
+
|
|
17
|
+
logger = setup_logger("realign.dashboard.screens.create_agent_info", "dashboard.log")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
|
|
21
|
+
"""Modal to create a new agent profile or import from a share link.
|
|
22
|
+
|
|
23
|
+
Both options are shown together; the user picks one.
|
|
24
|
+
Returns a dict with agent info on success, None on cancel.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
BINDINGS = [
|
|
28
|
+
Binding("escape", "close", "Close", show=False),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
DEFAULT_CSS = """
|
|
32
|
+
CreateAgentInfoScreen {
|
|
33
|
+
align: center middle;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
CreateAgentInfoScreen #create-agent-info-root {
|
|
37
|
+
width: 65;
|
|
38
|
+
height: auto;
|
|
39
|
+
max-height: 80%;
|
|
40
|
+
padding: 1 2;
|
|
41
|
+
background: $background;
|
|
42
|
+
border: solid $accent;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
CreateAgentInfoScreen #create-agent-info-title {
|
|
46
|
+
height: auto;
|
|
47
|
+
margin-bottom: 1;
|
|
48
|
+
text-style: bold;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
CreateAgentInfoScreen .section-label {
|
|
52
|
+
height: auto;
|
|
53
|
+
margin-top: 1;
|
|
54
|
+
margin-bottom: 0;
|
|
55
|
+
color: $text-muted;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
CreateAgentInfoScreen Input {
|
|
59
|
+
margin-top: 0;
|
|
60
|
+
border: none;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
CreateAgentInfoScreen #or-separator {
|
|
64
|
+
height: auto;
|
|
65
|
+
margin-top: 1;
|
|
66
|
+
margin-bottom: 0;
|
|
67
|
+
text-align: center;
|
|
68
|
+
color: $text-muted;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
CreateAgentInfoScreen #import-status {
|
|
72
|
+
height: auto;
|
|
73
|
+
margin-top: 1;
|
|
74
|
+
color: $text-muted;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
CreateAgentInfoScreen #create-buttons {
|
|
78
|
+
height: auto;
|
|
79
|
+
margin-top: 1;
|
|
80
|
+
align: right middle;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
CreateAgentInfoScreen #import-buttons {
|
|
84
|
+
height: auto;
|
|
85
|
+
margin-top: 1;
|
|
86
|
+
align: right middle;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
CreateAgentInfoScreen #create-buttons Button {
|
|
90
|
+
margin-left: 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
CreateAgentInfoScreen #import-buttons Button {
|
|
94
|
+
margin-left: 1;
|
|
95
|
+
}
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self) -> None:
|
|
99
|
+
super().__init__()
|
|
100
|
+
self._import_worker: Optional[Worker] = None
|
|
101
|
+
from ...agent_names import generate_agent_name
|
|
102
|
+
self._default_name: str = generate_agent_name()
|
|
103
|
+
|
|
104
|
+
def compose(self) -> ComposeResult:
|
|
105
|
+
with Container(id="create-agent-info-root"):
|
|
106
|
+
yield Static("Create Agent Profile", id="create-agent-info-title")
|
|
107
|
+
|
|
108
|
+
# --- Create New section ---
|
|
109
|
+
yield Label("Name", classes="section-label")
|
|
110
|
+
yield Input(placeholder=self._default_name, id="agent-name")
|
|
111
|
+
|
|
112
|
+
with Horizontal(id="create-buttons"):
|
|
113
|
+
yield Button("Cancel", id="cancel")
|
|
114
|
+
yield Button("Create", id="create", variant="primary")
|
|
115
|
+
|
|
116
|
+
# --- Separator ---
|
|
117
|
+
yield Static("-- Or --", id="or-separator")
|
|
118
|
+
|
|
119
|
+
# --- Import from Link section ---
|
|
120
|
+
yield Label("Import from Link", classes="section-label")
|
|
121
|
+
yield Input(placeholder="https://realign-server.vercel.app/share/...", id="share-url")
|
|
122
|
+
|
|
123
|
+
yield Label("Password (optional)", classes="section-label")
|
|
124
|
+
yield Input(placeholder="Leave blank if not password-protected", id="share-password", password=True)
|
|
125
|
+
|
|
126
|
+
yield Static("", id="import-status")
|
|
127
|
+
|
|
128
|
+
with Horizontal(id="import-buttons"):
|
|
129
|
+
yield Button("Import", id="import", variant="primary")
|
|
130
|
+
|
|
131
|
+
def on_mount(self) -> None:
|
|
132
|
+
self.query_one("#agent-name", Input).focus()
|
|
133
|
+
|
|
134
|
+
def action_close(self) -> None:
|
|
135
|
+
self.dismiss(None)
|
|
136
|
+
|
|
137
|
+
def _set_busy(self, busy: bool) -> None:
|
|
138
|
+
self.query_one("#agent-name", Input).disabled = busy
|
|
139
|
+
self.query_one("#share-url", Input).disabled = busy
|
|
140
|
+
self.query_one("#share-password", Input).disabled = busy
|
|
141
|
+
self.query_one("#create", Button).disabled = busy
|
|
142
|
+
self.query_one("#cancel", Button).disabled = busy
|
|
143
|
+
self.query_one("#import", Button).disabled = busy
|
|
144
|
+
|
|
145
|
+
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
146
|
+
button_id = event.button.id or ""
|
|
147
|
+
|
|
148
|
+
if button_id == "cancel":
|
|
149
|
+
self.dismiss(None)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
if button_id == "create":
|
|
153
|
+
await self._create_agent()
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
if button_id == "import":
|
|
157
|
+
await self._import_agent()
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
161
|
+
"""Handle enter key in input fields."""
|
|
162
|
+
input_id = event.input.id or ""
|
|
163
|
+
if input_id == "agent-name":
|
|
164
|
+
await self._create_agent()
|
|
165
|
+
elif input_id in ("share-url", "share-password"):
|
|
166
|
+
await self._import_agent()
|
|
167
|
+
|
|
168
|
+
async def _create_agent(self) -> None:
|
|
169
|
+
"""Create the agent profile."""
|
|
170
|
+
try:
|
|
171
|
+
from ...db import get_database
|
|
172
|
+
|
|
173
|
+
name_input = self.query_one("#agent-name", Input).value.strip()
|
|
174
|
+
name = name_input or self._default_name
|
|
175
|
+
|
|
176
|
+
agent_id = str(uuid.uuid4())
|
|
177
|
+
|
|
178
|
+
db = get_database(read_only=False)
|
|
179
|
+
record = db.get_or_create_agent_info(agent_id, name=name)
|
|
180
|
+
|
|
181
|
+
self.dismiss({
|
|
182
|
+
"id": record.id,
|
|
183
|
+
"name": record.name,
|
|
184
|
+
"description": record.description or "",
|
|
185
|
+
})
|
|
186
|
+
except Exception as e:
|
|
187
|
+
logger.error(f"Failed to create agent: {e}")
|
|
188
|
+
self.app.notify(f"Failed to create agent: {e}", severity="error")
|
|
189
|
+
|
|
190
|
+
async def _import_agent(self) -> None:
|
|
191
|
+
"""Import an agent from a share link."""
|
|
192
|
+
share_url = self.query_one("#share-url", Input).value.strip()
|
|
193
|
+
password = self.query_one("#share-password", Input).value.strip() or None
|
|
194
|
+
|
|
195
|
+
if not share_url:
|
|
196
|
+
self.app.notify("Please enter a share URL", severity="warning")
|
|
197
|
+
self.query_one("#share-url", Input).focus()
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
if "/share/" not in share_url:
|
|
201
|
+
self.app.notify("Invalid share URL format", severity="warning")
|
|
202
|
+
self.query_one("#share-url", Input).focus()
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
status = self.query_one("#import-status", Static)
|
|
206
|
+
status.update("Importing...")
|
|
207
|
+
self._set_busy(True)
|
|
208
|
+
|
|
209
|
+
def do_import() -> dict:
|
|
210
|
+
from ...commands.import_shares import import_agent_from_share
|
|
211
|
+
|
|
212
|
+
return import_agent_from_share(share_url, password=password)
|
|
213
|
+
|
|
214
|
+
self._import_worker = self.run_worker(do_import, thread=True, exit_on_error=False)
|
|
215
|
+
|
|
216
|
+
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
217
|
+
if self._import_worker is None or event.worker is not self._import_worker:
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
status = self.query_one("#import-status", Static)
|
|
221
|
+
|
|
222
|
+
if event.state == WorkerState.ERROR:
|
|
223
|
+
err = self._import_worker.error if self._import_worker else "Unknown error"
|
|
224
|
+
status.update(f"Error: {err}")
|
|
225
|
+
self._set_busy(False)
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
if event.state != WorkerState.SUCCESS:
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
result = self._import_worker.result if self._import_worker else {}
|
|
232
|
+
if not result or not result.get("success"):
|
|
233
|
+
error_msg = (result or {}).get("error", "Import failed")
|
|
234
|
+
status.update(f"Error: {error_msg}")
|
|
235
|
+
self._set_busy(False)
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
self.dismiss({
|
|
239
|
+
"id": result["agent_id"],
|
|
240
|
+
"name": result["agent_name"],
|
|
241
|
+
"description": result.get("agent_description", ""),
|
|
242
|
+
"imported": True,
|
|
243
|
+
"sessions_imported": result.get("sessions_imported", 0),
|
|
244
|
+
})
|
|
@@ -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
|
-
|
|
253
|
+
text_to_copy = str(slack_message) + "\n\n" + str(share_link)
|
|
254
254
|
else:
|
|
255
|
-
|
|
255
|
+
text_to_copy = str(share_link)
|
|
256
256
|
|
|
257
|
-
copied =
|
|
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)
|