aline-ai 0.7.1__py3-none-any.whl → 0.7.3__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.7.1.dist-info → aline_ai-0.7.3.dist-info}/METADATA +1 -1
- {aline_ai-0.7.1.dist-info → aline_ai-0.7.3.dist-info}/RECORD +17 -15
- realign/__init__.py +1 -1
- realign/commands/export_shares.py +191 -65
- realign/commands/sync_agent.py +55 -1
- realign/config.py +6 -1
- realign/dashboard/app.py +28 -36
- realign/dashboard/local_api.py +122 -0
- realign/dashboard/screens/create_agent.py +2 -11
- realign/dashboard/state.py +41 -0
- realign/dashboard/tmux_manager.py +15 -14
- realign/dashboard/widgets/agents_panel.py +264 -209
- realign/dashboard/widgets/config_panel.py +63 -1
- {aline_ai-0.7.1.dist-info → aline_ai-0.7.3.dist-info}/WHEEL +0 -0
- {aline_ai-0.7.1.dist-info → aline_ai-0.7.3.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.7.1.dist-info → aline_ai-0.7.3.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.7.1.dist-info → aline_ai-0.7.3.dist-info}/top_level.txt +0 -0
realign/dashboard/app.py
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
"""Aline Dashboard - Main Application."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
-
import subprocess
|
|
5
|
-
import sys
|
|
6
4
|
import time
|
|
7
5
|
import traceback
|
|
8
6
|
|
|
@@ -16,6 +14,7 @@ from .widgets import (
|
|
|
16
14
|
ConfigPanel,
|
|
17
15
|
AgentsPanel,
|
|
18
16
|
)
|
|
17
|
+
from .state import get_dashboard_state_value
|
|
19
18
|
|
|
20
19
|
# Environment variable to control terminal mode
|
|
21
20
|
ENV_TERMINAL_MODE = "ALINE_TERMINAL_MODE"
|
|
@@ -24,27 +23,6 @@ ENV_TERMINAL_MODE = "ALINE_TERMINAL_MODE"
|
|
|
24
23
|
logger = setup_logger("realign.dashboard", "dashboard.log")
|
|
25
24
|
|
|
26
25
|
|
|
27
|
-
def _detect_system_dark_mode() -> bool:
|
|
28
|
-
"""Detect if the system is in dark mode.
|
|
29
|
-
|
|
30
|
-
On macOS, checks AppleInterfaceStyle via defaults command.
|
|
31
|
-
Returns True for dark mode, False for light mode.
|
|
32
|
-
"""
|
|
33
|
-
if sys.platform != "darwin":
|
|
34
|
-
return True # Default to dark on non-macOS
|
|
35
|
-
|
|
36
|
-
try:
|
|
37
|
-
result = subprocess.run(
|
|
38
|
-
["defaults", "read", "-g", "AppleInterfaceStyle"],
|
|
39
|
-
capture_output=True,
|
|
40
|
-
text=True,
|
|
41
|
-
timeout=1,
|
|
42
|
-
)
|
|
43
|
-
return result.stdout.strip().lower() == "dark"
|
|
44
|
-
except Exception:
|
|
45
|
-
return True # Default to dark on error
|
|
46
|
-
|
|
47
|
-
|
|
48
26
|
def _monotonic() -> float:
|
|
49
27
|
"""Small wrapper so tests can patch without affecting global time.monotonic()."""
|
|
50
28
|
return time.monotonic()
|
|
@@ -78,9 +56,9 @@ class AlineDashboard(App):
|
|
|
78
56
|
super().__init__()
|
|
79
57
|
self.use_native_terminal = use_native_terminal
|
|
80
58
|
self._native_terminal_mode = self._detect_native_mode()
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
)
|
|
59
|
+
self._local_api_server = None
|
|
60
|
+
self._apply_saved_theme()
|
|
61
|
+
logger.info(f"AlineDashboard initialized (native_terminal={self._native_terminal_mode})")
|
|
84
62
|
|
|
85
63
|
def _detect_native_mode(self) -> bool:
|
|
86
64
|
"""Detect if native terminal mode should be used."""
|
|
@@ -110,14 +88,14 @@ class AlineDashboard(App):
|
|
|
110
88
|
return ["agents", "config"]
|
|
111
89
|
|
|
112
90
|
def on_mount(self) -> None:
|
|
113
|
-
"""Apply theme based on
|
|
91
|
+
"""Apply dashboard theme based on saved preference."""
|
|
114
92
|
logger.info("on_mount() started")
|
|
115
93
|
try:
|
|
116
|
-
self._sync_theme()
|
|
117
|
-
# Check for system theme changes every 2 seconds
|
|
118
|
-
self.set_interval(2, self._sync_theme)
|
|
119
94
|
self._quit_confirm_deadline: float | None = None
|
|
120
95
|
|
|
96
|
+
# Start local API server for one-click browser import
|
|
97
|
+
self._start_local_api_server()
|
|
98
|
+
|
|
121
99
|
# Set up side-by-side layout for native terminal mode
|
|
122
100
|
if self._native_terminal_mode:
|
|
123
101
|
self._setup_native_terminal_layout()
|
|
@@ -127,6 +105,25 @@ class AlineDashboard(App):
|
|
|
127
105
|
logger.error(f"on_mount() failed: {e}\n{traceback.format_exc()}")
|
|
128
106
|
raise
|
|
129
107
|
|
|
108
|
+
def _start_local_api_server(self) -> None:
|
|
109
|
+
"""Start the local HTTP API server for browser-based agent import."""
|
|
110
|
+
try:
|
|
111
|
+
from ..config import ReAlignConfig
|
|
112
|
+
from .local_api import LocalAPIServer
|
|
113
|
+
|
|
114
|
+
config = ReAlignConfig.load()
|
|
115
|
+
self._local_api_server = LocalAPIServer(port=config.local_api_port)
|
|
116
|
+
self._local_api_server.start()
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.warning(f"Could not start local API server: {e}")
|
|
119
|
+
|
|
120
|
+
def _apply_saved_theme(self) -> None:
|
|
121
|
+
theme_choice = str(get_dashboard_state_value("theme", "dark")).strip().lower()
|
|
122
|
+
if theme_choice == "light":
|
|
123
|
+
self.theme = "textual-light"
|
|
124
|
+
else:
|
|
125
|
+
self.theme = "textual-dark"
|
|
126
|
+
|
|
130
127
|
def _setup_native_terminal_layout(self) -> None:
|
|
131
128
|
"""Set up side-by-side layout for Dashboard and native terminal."""
|
|
132
129
|
# Skip if using iTerm2 split pane mode (already set up by CLI)
|
|
@@ -158,12 +155,6 @@ class AlineDashboard(App):
|
|
|
158
155
|
except Exception as e:
|
|
159
156
|
logger.warning(f"Could not set up native terminal layout: {e}")
|
|
160
157
|
|
|
161
|
-
def _sync_theme(self) -> None:
|
|
162
|
-
"""Sync app theme with system theme."""
|
|
163
|
-
target_theme = "textual-dark" if _detect_system_dark_mode() else "textual-light"
|
|
164
|
-
if self.theme != target_theme:
|
|
165
|
-
self.theme = target_theme
|
|
166
|
-
|
|
167
158
|
def action_next_tab(self) -> None:
|
|
168
159
|
"""Switch to next tab."""
|
|
169
160
|
tabbed_content = self.query_one(TabbedContent)
|
|
@@ -211,6 +202,7 @@ class AlineDashboard(App):
|
|
|
211
202
|
self._quit_confirm_deadline = now + self._quit_confirm_window_s
|
|
212
203
|
self.notify("Press Ctrl+C again to quit", title="Quit", timeout=2)
|
|
213
204
|
|
|
205
|
+
|
|
214
206
|
def run_dashboard(use_native_terminal: bool | None = None) -> None:
|
|
215
207
|
"""Run the Aline Dashboard.
|
|
216
208
|
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Local HTTP API server for one-click agent import from browser."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
6
|
+
|
|
7
|
+
from ..logging_config import setup_logger
|
|
8
|
+
|
|
9
|
+
logger = setup_logger("realign.dashboard.local_api", "local_api.log")
|
|
10
|
+
|
|
11
|
+
ALLOWED_ORIGINS = [
|
|
12
|
+
"https://realign-server.vercel.app",
|
|
13
|
+
"http://localhost:3000",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LocalAPIHandler(BaseHTTPRequestHandler):
|
|
18
|
+
"""Handle local API requests from the browser."""
|
|
19
|
+
|
|
20
|
+
def _set_cors_headers(self) -> bool:
|
|
21
|
+
"""Set CORS headers. Returns True if origin is allowed."""
|
|
22
|
+
origin = self.headers.get("Origin", "")
|
|
23
|
+
if origin in ALLOWED_ORIGINS:
|
|
24
|
+
self.send_header("Access-Control-Allow-Origin", origin)
|
|
25
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
26
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
27
|
+
return True
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
def _send_json(self, status: int, data: dict) -> None:
|
|
31
|
+
self.send_response(status)
|
|
32
|
+
self.send_header("Content-Type", "application/json")
|
|
33
|
+
self._set_cors_headers()
|
|
34
|
+
self.end_headers()
|
|
35
|
+
self.wfile.write(json.dumps(data).encode())
|
|
36
|
+
|
|
37
|
+
def do_OPTIONS(self) -> None:
|
|
38
|
+
"""Handle CORS preflight."""
|
|
39
|
+
self.send_response(204)
|
|
40
|
+
self._set_cors_headers()
|
|
41
|
+
self.end_headers()
|
|
42
|
+
|
|
43
|
+
def do_GET(self) -> None:
|
|
44
|
+
if self.path == "/api/health":
|
|
45
|
+
self._send_json(200, {"status": "ok"})
|
|
46
|
+
else:
|
|
47
|
+
self._send_json(404, {"error": "not found"})
|
|
48
|
+
|
|
49
|
+
def do_POST(self) -> None:
|
|
50
|
+
if self.path == "/api/import-agent":
|
|
51
|
+
self._handle_import_agent()
|
|
52
|
+
else:
|
|
53
|
+
self._send_json(404, {"error": "not found"})
|
|
54
|
+
|
|
55
|
+
def _handle_import_agent(self) -> None:
|
|
56
|
+
try:
|
|
57
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
58
|
+
body = json.loads(self.rfile.read(length)) if length else {}
|
|
59
|
+
except (json.JSONDecodeError, ValueError):
|
|
60
|
+
self._send_json(400, {"error": "invalid JSON body"})
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
share_url = body.get("share_url")
|
|
64
|
+
if not share_url:
|
|
65
|
+
self._send_json(400, {"error": "share_url is required"})
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
password = body.get("password")
|
|
69
|
+
|
|
70
|
+
logger.info(f"Import agent request: {share_url}")
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
from ..commands.import_shares import import_agent_from_share
|
|
74
|
+
|
|
75
|
+
result = import_agent_from_share(share_url, password=password)
|
|
76
|
+
if result.get("success"):
|
|
77
|
+
logger.info(
|
|
78
|
+
f"Agent imported: {result.get('agent_name')} "
|
|
79
|
+
f"({result.get('sessions_imported')} sessions)"
|
|
80
|
+
)
|
|
81
|
+
self._send_json(200, result)
|
|
82
|
+
else:
|
|
83
|
+
logger.warning(f"Import failed: {result.get('error')}")
|
|
84
|
+
self._send_json(422, result)
|
|
85
|
+
except Exception as e:
|
|
86
|
+
logger.error(f"Import agent error: {e}", exc_info=True)
|
|
87
|
+
self._send_json(500, {"success": False, "error": str(e)})
|
|
88
|
+
|
|
89
|
+
def log_message(self, format: str, *args) -> None:
|
|
90
|
+
"""Suppress default stderr logging; use our logger instead."""
|
|
91
|
+
logger.debug(f"HTTP {args[0] if args else ''}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class LocalAPIServer:
|
|
95
|
+
"""Manages the local HTTP API server in a daemon thread."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, port: int = 17280):
|
|
98
|
+
self.port = port
|
|
99
|
+
self._server: HTTPServer | None = None
|
|
100
|
+
self._thread: threading.Thread | None = None
|
|
101
|
+
|
|
102
|
+
def start(self) -> bool:
|
|
103
|
+
"""Start the server. Returns True on success."""
|
|
104
|
+
try:
|
|
105
|
+
self._server = HTTPServer(("127.0.0.1", self.port), LocalAPIHandler)
|
|
106
|
+
self._thread = threading.Thread(
|
|
107
|
+
target=self._server.serve_forever, daemon=True
|
|
108
|
+
)
|
|
109
|
+
self._thread.start()
|
|
110
|
+
logger.info(f"Local API server started on http://127.0.0.1:{self.port}")
|
|
111
|
+
return True
|
|
112
|
+
except OSError as e:
|
|
113
|
+
logger.warning(f"Failed to start local API server on port {self.port}: {e}")
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
def stop(self) -> None:
|
|
117
|
+
"""Stop the server."""
|
|
118
|
+
if self._server:
|
|
119
|
+
self._server.shutdown()
|
|
120
|
+
self._server = None
|
|
121
|
+
self._thread = None
|
|
122
|
+
logger.info("Local API server stopped")
|
|
@@ -15,9 +15,7 @@ from textual.containers import Container, Horizontal, Vertical
|
|
|
15
15
|
from textual.screen import ModalScreen
|
|
16
16
|
from textual.widgets import Button, Label, RadioButton, RadioSet, Static
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
# State file for storing last workspace path
|
|
20
|
-
DASHBOARD_STATE_FILE = Path.home() / ".aline" / "dashboard_state.json"
|
|
18
|
+
from ..state import DASHBOARD_STATE_FILE, set_dashboard_state_value
|
|
21
19
|
|
|
22
20
|
|
|
23
21
|
def _load_last_workspace() -> str:
|
|
@@ -80,14 +78,7 @@ def _save_claude_tracking_mode(mode: str) -> None:
|
|
|
80
78
|
def _save_state(key: str, value: str) -> None:
|
|
81
79
|
"""Save a key-value pair to the state file."""
|
|
82
80
|
try:
|
|
83
|
-
|
|
84
|
-
state = {}
|
|
85
|
-
if DASHBOARD_STATE_FILE.exists():
|
|
86
|
-
with open(DASHBOARD_STATE_FILE, "r", encoding="utf-8") as f:
|
|
87
|
-
state = json.load(f)
|
|
88
|
-
state[key] = value
|
|
89
|
-
with open(DASHBOARD_STATE_FILE, "w", encoding="utf-8") as f:
|
|
90
|
-
json.dump(state, f, indent=2)
|
|
81
|
+
set_dashboard_state_value(key, value)
|
|
91
82
|
except Exception:
|
|
92
83
|
pass
|
|
93
84
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Small persistence helpers for dashboard UI state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DASHBOARD_STATE_FILE = Path.home() / ".aline" / "dashboard_state.json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_dashboard_state() -> dict[str, Any]:
|
|
14
|
+
try:
|
|
15
|
+
if not DASHBOARD_STATE_FILE.exists():
|
|
16
|
+
return {}
|
|
17
|
+
with open(DASHBOARD_STATE_FILE, "r", encoding="utf-8") as f:
|
|
18
|
+
data = json.load(f)
|
|
19
|
+
return data if isinstance(data, dict) else {}
|
|
20
|
+
except Exception:
|
|
21
|
+
return {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def save_dashboard_state(state: dict[str, Any]) -> None:
|
|
25
|
+
try:
|
|
26
|
+
DASHBOARD_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
with open(DASHBOARD_STATE_FILE, "w", encoding="utf-8") as f:
|
|
28
|
+
json.dump(state, f, indent=2)
|
|
29
|
+
except Exception:
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_dashboard_state_value(key: str, default: Any) -> Any: # noqa: ANN401
|
|
34
|
+
return load_dashboard_state().get(key, default)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def set_dashboard_state_value(key: str, value: Any) -> None: # noqa: ANN401
|
|
38
|
+
state = load_dashboard_state()
|
|
39
|
+
state[key] = value
|
|
40
|
+
save_dashboard_state(state)
|
|
41
|
+
|
|
@@ -196,16 +196,17 @@ def _session_id_from_transcript_path(transcript_path: str | None) -> str | None:
|
|
|
196
196
|
def _load_terminal_state_from_db() -> dict[str, dict[str, str]]:
|
|
197
197
|
"""Load terminal state from database (best-effort)."""
|
|
198
198
|
import time as _time
|
|
199
|
+
|
|
199
200
|
t0 = _time.time()
|
|
200
201
|
try:
|
|
201
202
|
from ..db import get_database
|
|
202
203
|
|
|
203
204
|
t1 = _time.time()
|
|
204
205
|
db = get_database(read_only=True)
|
|
205
|
-
logger.
|
|
206
|
+
logger.debug(f"[PERF] _load_terminal_state_from_db get_database: {_time.time() - t1:.3f}s")
|
|
206
207
|
t2 = _time.time()
|
|
207
208
|
agents = db.list_agents(status="active", limit=100)
|
|
208
|
-
logger.
|
|
209
|
+
logger.debug(f"[PERF] _load_terminal_state_from_db list_agents: {_time.time() - t2:.3f}s")
|
|
209
210
|
|
|
210
211
|
out: dict[str, dict[str, str]] = {}
|
|
211
212
|
for agent in agents:
|
|
@@ -711,13 +712,14 @@ def ensure_right_pane(width_percent: int = 50) -> bool:
|
|
|
711
712
|
|
|
712
713
|
def list_inner_windows() -> list[InnerWindow]:
|
|
713
714
|
import time as _time
|
|
715
|
+
|
|
714
716
|
t0 = _time.time()
|
|
715
717
|
if not ensure_inner_session():
|
|
716
718
|
return []
|
|
717
|
-
logger.
|
|
719
|
+
logger.debug(f"[PERF] list_inner_windows ensure_inner_session: {_time.time() - t0:.3f}s")
|
|
718
720
|
t1 = _time.time()
|
|
719
721
|
state = _load_terminal_state()
|
|
720
|
-
logger.
|
|
722
|
+
logger.debug(f"[PERF] list_inner_windows _load_terminal_state: {_time.time() - t1:.3f}s")
|
|
721
723
|
out = (
|
|
722
724
|
_run_inner_tmux(
|
|
723
725
|
[
|
|
@@ -840,6 +842,7 @@ def list_inner_windows() -> list[InnerWindow]:
|
|
|
840
842
|
|
|
841
843
|
def set_inner_window_options(window_id: str, options: dict[str, str]) -> bool:
|
|
842
844
|
import time as _time
|
|
845
|
+
|
|
843
846
|
if not ensure_inner_session():
|
|
844
847
|
return False
|
|
845
848
|
ok = True
|
|
@@ -848,7 +851,7 @@ def set_inner_window_options(window_id: str, options: dict[str, str]) -> bool:
|
|
|
848
851
|
# Important: these are per-window (not session-wide) to avoid cross-tab clobbering.
|
|
849
852
|
if _run_inner_tmux(["set-option", "-w", "-t", window_id, key, value]).returncode != 0:
|
|
850
853
|
ok = False
|
|
851
|
-
logger.
|
|
854
|
+
logger.debug(f"[PERF] set_inner_window_options {key}: {_time.time() - t0:.3f}s")
|
|
852
855
|
return ok
|
|
853
856
|
|
|
854
857
|
|
|
@@ -868,15 +871,16 @@ def create_inner_window(
|
|
|
868
871
|
no_track: bool = False,
|
|
869
872
|
) -> InnerWindow | None:
|
|
870
873
|
import time as _time
|
|
874
|
+
|
|
871
875
|
t0 = _time.time()
|
|
872
|
-
logger.
|
|
876
|
+
logger.debug("[PERF] create_inner_window START")
|
|
873
877
|
if not ensure_right_pane():
|
|
874
878
|
return None
|
|
875
|
-
logger.
|
|
879
|
+
logger.debug(f"[PERF] create_inner_window ensure_right_pane: {_time.time() - t0:.3f}s")
|
|
876
880
|
|
|
877
881
|
t1 = _time.time()
|
|
878
882
|
existing = list_inner_windows()
|
|
879
|
-
logger.
|
|
883
|
+
logger.debug(f"[PERF] create_inner_window list_inner_windows: {_time.time() - t1:.3f}s")
|
|
880
884
|
name = _unique_name((w.window_name for w in existing), base_name)
|
|
881
885
|
|
|
882
886
|
# Record creation time before creating the window
|
|
@@ -897,7 +901,7 @@ def create_inner_window(
|
|
|
897
901
|
],
|
|
898
902
|
capture=True,
|
|
899
903
|
)
|
|
900
|
-
logger.
|
|
904
|
+
logger.debug(f"[PERF] create_inner_window new-window: {_time.time() - t2:.3f}s")
|
|
901
905
|
if proc.returncode != 0:
|
|
902
906
|
return None
|
|
903
907
|
|
|
@@ -923,7 +927,7 @@ def create_inner_window(
|
|
|
923
927
|
opts.setdefault(OPT_NO_TRACK, "")
|
|
924
928
|
t3 = _time.time()
|
|
925
929
|
set_inner_window_options(window_id, opts)
|
|
926
|
-
logger.
|
|
930
|
+
logger.debug(f"[PERF] create_inner_window set_options: {_time.time() - t3:.3f}s")
|
|
927
931
|
|
|
928
932
|
_run_inner_tmux(["select-window", "-t", window_id])
|
|
929
933
|
|
|
@@ -947,10 +951,7 @@ def select_inner_window(window_id: str) -> bool:
|
|
|
947
951
|
def focus_right_pane() -> bool:
|
|
948
952
|
"""Focus the right pane (terminal area) in the outer tmux layout."""
|
|
949
953
|
return (
|
|
950
|
-
_run_outer_tmux(
|
|
951
|
-
["select-pane", "-t", f"{OUTER_SESSION}:{OUTER_WINDOW}.1"]
|
|
952
|
-
).returncode
|
|
953
|
-
== 0
|
|
954
|
+
_run_outer_tmux(["select-pane", "-t", f"{OUTER_SESSION}:{OUTER_WINDOW}.1"]).returncode == 0
|
|
954
955
|
)
|
|
955
956
|
|
|
956
957
|
|