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.
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
- logger.info(
82
- f"AlineDashboard initialized (native_terminal={self._native_terminal_mode})"
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 system settings and watch for changes."""
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
- DASHBOARD_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
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.info(f"[PERF] _load_terminal_state_from_db get_database: {_time.time() - t1:.3f}s")
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.info(f"[PERF] _load_terminal_state_from_db list_agents: {_time.time() - t2:.3f}s")
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.info(f"[PERF] list_inner_windows ensure_inner_session: {_time.time() - t0:.3f}s")
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.info(f"[PERF] list_inner_windows _load_terminal_state: {_time.time() - t1:.3f}s")
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.info(f"[PERF] set_inner_window_options {key}: {_time.time() - t0:.3f}s")
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.info(f"[PERF] create_inner_window START")
876
+ logger.debug("[PERF] create_inner_window START")
873
877
  if not ensure_right_pane():
874
878
  return None
875
- logger.info(f"[PERF] create_inner_window ensure_right_pane: {_time.time() - t0:.3f}s")
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.info(f"[PERF] create_inner_window list_inner_windows: {_time.time() - t1:.3f}s")
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.info(f"[PERF] create_inner_window new-window: {_time.time() - t2:.3f}s")
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.info(f"[PERF] create_inner_window set_options: {_time.time() - t3:.3f}s")
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