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
@@ -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
+ }
@@ -195,11 +195,17 @@ def _session_id_from_transcript_path(transcript_path: str | None) -> str | None:
195
195
 
196
196
  def _load_terminal_state_from_db() -> dict[str, dict[str, str]]:
197
197
  """Load terminal state from database (best-effort)."""
198
+ import time as _time
199
+ t0 = _time.time()
198
200
  try:
199
201
  from ..db import get_database
200
202
 
203
+ t1 = _time.time()
201
204
  db = get_database(read_only=True)
205
+ logger.info(f"[PERF] _load_terminal_state_from_db get_database: {_time.time() - t1:.3f}s")
206
+ t2 = _time.time()
202
207
  agents = db.list_agents(status="active", limit=100)
208
+ logger.info(f"[PERF] _load_terminal_state_from_db list_agents: {_time.time() - t2:.3f}s")
203
209
 
204
210
  out: dict[str, dict[str, str]] = {}
205
211
  for agent in agents:
@@ -510,8 +516,18 @@ def bootstrap_dashboard_into_tmux() -> None:
510
516
  os.execvp("tmux", ["tmux", "-L", OUTER_SOCKET, "attach", "-t", OUTER_SESSION])
511
517
 
512
518
 
519
+ _inner_session_configured = False
520
+
521
+
513
522
  def ensure_inner_session() -> bool:
514
- """Ensure the inner tmux server/session exists (returns True on success)."""
523
+ """Ensure the inner tmux server/session exists (returns True on success).
524
+
525
+ The full configuration (mouse, status bar, border styles, home window setup) is
526
+ only applied once per process lifetime. Subsequent calls just verify the session
527
+ is still alive via a cheap ``has-session`` check.
528
+ """
529
+ global _inner_session_configured
530
+
515
531
  if not (tmux_available() and in_tmux() and managed_env_enabled()):
516
532
  return False
517
533
 
@@ -523,6 +539,13 @@ def ensure_inner_session() -> bool:
523
539
  != 0
524
540
  ):
525
541
  return False
542
+ # Force re-configuration after creating a new session.
543
+ _inner_session_configured = False
544
+
545
+ if _inner_session_configured:
546
+ return True
547
+
548
+ # --- One-time configuration below ---
526
549
 
527
550
  # Ensure the default/home window stays named "home" (tmux auto-rename would otherwise
528
551
  # change it to "zsh"/"opencode" depending on the last foreground command).
@@ -546,6 +569,8 @@ def ensure_inner_session() -> bool:
546
569
  _run_inner_tmux(["set-option", "-g", "pane-border-indicators", "arrows"])
547
570
 
548
571
  _source_aline_tmux_config(_run_inner_tmux)
572
+
573
+ _inner_session_configured = True
549
574
  return True
550
575
 
551
576
 
@@ -632,14 +657,12 @@ def _ensure_inner_home_window() -> None:
632
657
  _run_inner_tmux(["set-option", "-w", "-t", window_id, "allow-rename", "off"])
633
658
 
634
659
  # Mark as internal/no-track so UI can hide it.
660
+ # NOTE: We use _run_inner_tmux directly here instead of set_inner_window_options
661
+ # to avoid recursion: set_inner_window_options → ensure_inner_session →
662
+ # _ensure_inner_home_window → set_inner_window_options.
635
663
  try:
636
- set_inner_window_options(
637
- window_id,
638
- {
639
- OPT_NO_TRACK: "1",
640
- OPT_CREATED_AT: str(time.time()),
641
- },
642
- )
664
+ _run_inner_tmux(["set-option", "-w", "-t", window_id, OPT_NO_TRACK, "1"])
665
+ _run_inner_tmux(["set-option", "-w", "-t", window_id, OPT_CREATED_AT, str(time.time())])
643
666
  except Exception:
644
667
  pass
645
668
 
@@ -687,9 +710,14 @@ def ensure_right_pane(width_percent: int = 50) -> bool:
687
710
 
688
711
 
689
712
  def list_inner_windows() -> list[InnerWindow]:
713
+ import time as _time
714
+ t0 = _time.time()
690
715
  if not ensure_inner_session():
691
716
  return []
717
+ logger.info(f"[PERF] list_inner_windows ensure_inner_session: {_time.time() - t0:.3f}s")
718
+ t1 = _time.time()
692
719
  state = _load_terminal_state()
720
+ logger.info(f"[PERF] list_inner_windows _load_terminal_state: {_time.time() - t1:.3f}s")
693
721
  out = (
694
722
  _run_inner_tmux(
695
723
  [
@@ -785,13 +813,16 @@ def list_inner_windows() -> list[InnerWindow]:
785
813
 
786
814
 
787
815
  def set_inner_window_options(window_id: str, options: dict[str, str]) -> bool:
816
+ import time as _time
788
817
  if not ensure_inner_session():
789
818
  return False
790
819
  ok = True
791
820
  for key, value in options.items():
821
+ t0 = _time.time()
792
822
  # Important: these are per-window (not session-wide) to avoid cross-tab clobbering.
793
823
  if _run_inner_tmux(["set-option", "-w", "-t", window_id, key, value]).returncode != 0:
794
824
  ok = False
825
+ logger.info(f"[PERF] set_inner_window_options {key}: {_time.time() - t0:.3f}s")
795
826
  return ok
796
827
 
797
828
 
@@ -810,15 +841,22 @@ def create_inner_window(
810
841
  context_id: str | None = None,
811
842
  no_track: bool = False,
812
843
  ) -> InnerWindow | None:
844
+ import time as _time
845
+ t0 = _time.time()
846
+ logger.info(f"[PERF] create_inner_window START")
813
847
  if not ensure_right_pane():
814
848
  return None
849
+ logger.info(f"[PERF] create_inner_window ensure_right_pane: {_time.time() - t0:.3f}s")
815
850
 
851
+ t1 = _time.time()
816
852
  existing = list_inner_windows()
853
+ logger.info(f"[PERF] create_inner_window list_inner_windows: {_time.time() - t1:.3f}s")
817
854
  name = _unique_name((w.window_name for w in existing), base_name)
818
855
 
819
856
  # Record creation time before creating the window
820
857
  created_at = time.time()
821
858
 
859
+ t2 = _time.time()
822
860
  proc = _run_inner_tmux(
823
861
  [
824
862
  "new-window",
@@ -833,6 +871,7 @@ def create_inner_window(
833
871
  ],
834
872
  capture=True,
835
873
  )
874
+ logger.info(f"[PERF] create_inner_window new-window: {_time.time() - t2:.3f}s")
836
875
  if proc.returncode != 0:
837
876
  return None
838
877
 
@@ -856,7 +895,9 @@ def create_inner_window(
856
895
  opts[OPT_NO_TRACK] = "1"
857
896
  else:
858
897
  opts.setdefault(OPT_NO_TRACK, "")
898
+ t3 = _time.time()
859
899
  set_inner_window_options(window_id, opts)
900
+ logger.info(f"[PERF] create_inner_window set_options: {_time.time() - t3:.3f}s")
860
901
 
861
902
  _run_inner_tmux(["select-window", "-t", window_id])
862
903
 
@@ -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
  ]