aline-ai 0.7.1__py3-none-any.whl → 0.7.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aline-ai
3
- Version: 0.7.1
3
+ Version: 0.7.2
4
4
  Summary: Shared AI memory; everyone knows everything in teams
5
5
  Author: Sharemind
6
6
  License: MIT
@@ -1,5 +1,5 @@
1
- aline_ai-0.7.1.dist-info/licenses/LICENSE,sha256=H8wTqV5IF1oHw_HbBtS1PSDU8G_q81yblEIL_JfV8Vo,1077
2
- realign/__init__.py,sha256=_ikWZs8Kc7Dp8xrIk27RttwL5TafxGktkWaWc0WLK68,1623
1
+ aline_ai-0.7.2.dist-info/licenses/LICENSE,sha256=H8wTqV5IF1oHw_HbBtS1PSDU8G_q81yblEIL_JfV8Vo,1077
2
+ realign/__init__.py,sha256=T1uqzYkOUbzoMzR_BVD5kRPO727lyC6k7hF_RudJoi8,1623
3
3
  realign/agent_names.py,sha256=H4oVJMkqg1ZYCk58vD_Jh9apaAHSFJRswa-C9SPdJxc,1171
4
4
  realign/auth.py,sha256=d_1yvCwluN5iIrdgjtuSKpOYAksDzrzNgntKacLVJrw,16583
5
5
  realign/claude_detector.py,sha256=ZLSJacMo6zzQclXByABKA70UNpstxqIv3fPGqdpA934,2792
@@ -7,7 +7,7 @@ realign/cli.py,sha256=PiMUA_sFQ-K7zlIr1Ahs7St8NwcXDG3JKT_8yIqLwZI,40569
7
7
  realign/codex_detector.py,sha256=WGIClvlrFVCqJ5vR9DrKVsp1eJhOShvcaXibTHb0Nfc,6304
8
8
  realign/codex_home.py,sha256=ljkW8uCfQD4cisEJtPNQmIgaR0yEfWSyHwoVQFY-6p4,4374
9
9
  realign/codex_terminal_linker.py,sha256=L2Ha4drlZ7Sbq2jzXyxczOdUY3S5fu1gJqoI5WN9CKk,6211
10
- realign/config.py,sha256=Znfs43AjiK90LGWnArDPWyrE859sdZQAPIb0KAcU3Ig,9252
10
+ realign/config.py,sha256=_loJkoTKszMONgo6Qq3N8VRm_iqvD-7WvXeCsKUgGUE,9478
11
11
  realign/context.py,sha256=8hzgNOg-7_eMW22wt7OM5H9IsmMveKXCv0epG7E0G7w,13917
12
12
  realign/file_lock.py,sha256=kLNm1Rra4TCrTMyPM5fwjVascq-CUz2Bzh9HHKtCKOE,3444
13
13
  realign/hooks.py,sha256=wSSIjS5x9w7fm9LUcL63Lf7bglEfb75dHFja_znKDDQ,65134
@@ -41,19 +41,21 @@ realign/commands/auth.py,sha256=wcs1lUcSXxv75WcGruzyZ3kgi0xXA8W4lNnUwM4a3CI,1173
41
41
  realign/commands/config.py,sha256=nYnu_h2pk7GODcrzrV04K51D-s7v06FlRXHJ0HJ-gvU,6732
42
42
  realign/commands/context.py,sha256=pM2KfZHVkB-ou4nBhFvKSwnYliLBzwN3zerLyBAbhfE,7095
43
43
  realign/commands/doctor.py,sha256=0c1TZuA_cw1CSU0yKMVRU-18uTxdqjXKJ8lP2CTTNSQ,20656
44
- realign/commands/export_shares.py,sha256=b8dpVBx2HkbHVk9pSFXnErlAr0umciAOPpuxvTJyOBI,148467
44
+ realign/commands/export_shares.py,sha256=76VYTB9r6JVx9rLcimwJ1xwpTNeeQU5TFL4SmTbRY54,148590
45
45
  realign/commands/import_shares.py,sha256=Jx_7HVSg7SrGGKLDxsf_UqoStDimw8B26uKkqNFF6t8,33071
46
46
  realign/commands/init.py,sha256=6rBr1LVIrQLbUH_UvoDhkF1qXmMh2xkjNWCYAUz5Tho,35274
47
47
  realign/commands/restore.py,sha256=s2BxQZHxQw9r12NzRVsK20KlGafy5AIoSjWMo5PcnHY,11173
48
48
  realign/commands/search.py,sha256=QlUDzRDD6ebq21LTtLe5-OZM62iwDrDqfbnXbuxfklU,27516
49
- realign/commands/sync_agent.py,sha256=VS_VU-4LdZpUbRKx51Gg0BFXPWZlnyROZAsahWsexIQ,14824
49
+ realign/commands/sync_agent.py,sha256=gzvbqdujBbFdUY0SNyb66OPjp3e-qnqazinH5ZiPFok,14876
50
50
  realign/commands/upgrade.py,sha256=L3PLOUIN5qAQTbkfoVtSsIbbzEezA_xjjk9F1GMVfjw,12781
51
51
  realign/commands/watcher.py,sha256=4WTThIgr-Z5guKh_JqGDcPmerr97XiHrVaaijmckHsA,134350
52
52
  realign/commands/worker.py,sha256=jTu7Pj60nTnn7SsH3oNCNnO6zl4TIFCJVNSC1OoQ_0o,23363
53
53
  realign/dashboard/__init__.py,sha256=QZkHTsGityH8UkF8rmvA3xW7dMXNe0swEWr443qfgCM,128
54
- realign/dashboard/app.py,sha256=IXF9CDbui4zXufRgc6Gagje7Duw5VlewUru4njbA6lQ,8243
54
+ realign/dashboard/app.py,sha256=gp44tyR3dEJdxx36D-HODpbNh56cw4117zY1ina1chw,8182
55
55
  realign/dashboard/clipboard.py,sha256=81frq83E_urqLkwuCvtl0hiTEjavtdQn8kCi72jJWcs,1207
56
56
  realign/dashboard/layout.py,sha256=sZxmFj6QTbkois9MHTvBEMMcnaRVehCDqugdbiFx10k,9072
57
+ realign/dashboard/local_api.py,sha256=Roq74etTJR0uOiHE3uIe7sqVITjS5JGQEF4g0nmUm5Q,4332
58
+ realign/dashboard/state.py,sha256=V7zBKvyDgqdXv68XHxV4T8xf3IhYbI5W33UmYW3_hyM,1139
57
59
  realign/dashboard/terminal_backend.py,sha256=MlDfwtqhftyQK6jDNizQGFjAWIo5Bx2TDpSnP3MCZVM,3375
58
60
  realign/dashboard/tmux_manager.py,sha256=sS6fo7UVPHWxYm1RYtLDPmwsagFh5RO6TRwYd1CuHaI,34581
59
61
  realign/dashboard/backends/__init__.py,sha256=POROX7YKtukYZcLB1pi_kO0sSEpuO3y-hwmF3WIN1Kk,163
@@ -61,7 +63,7 @@ realign/dashboard/backends/iterm2.py,sha256=XYYJT5lrrp4pW_MyEqPZYkRI0qyKUwJlezwM
61
63
  realign/dashboard/backends/kitty.py,sha256=5jdkR1f2PwB8a4SnS3EG6uOQ2XU-PB7-cpKBfIJq3hU,12066
62
64
  realign/dashboard/screens/__init__.py,sha256=MiefFamCYRrzTwQXiCUdybaJaFxlK5XKtLHaSQmqDv0,597
63
65
  realign/dashboard/screens/agent_detail.py,sha256=N-iUC4434C91OcDu4dkQaxS_NXQ5Yl5sqNBb2mTmoBw,10490
64
- realign/dashboard/screens/create_agent.py,sha256=06uiQYvz-Xvn4Xm689o3tdhzb2HQ0gdzAA1WHVEwziM,11706
66
+ realign/dashboard/screens/create_agent.py,sha256=Dy9liP_4fj_zgNafRRJGX2iQJiarHvtVLdghrqMGiLQ,11323
65
67
  realign/dashboard/screens/create_agent_info.py,sha256=K2Rbp4zHVdanPT3Fp82We4qlSAM-0IBZXPLuQuevuME,7838
66
68
  realign/dashboard/screens/create_event.py,sha256=oiQY1zKpUYnQU-5fQLeuZH9BV5NClE5B5XZIVBYG5A8,5506
67
69
  realign/dashboard/screens/event_detail.py,sha256=-pqt3NBoeTXGJKtbndZy-msklwXTeNWMS4H12oMG5ks,20175
@@ -70,8 +72,8 @@ realign/dashboard/screens/session_detail.py,sha256=TBkHqSHyMxsLB2QdZq9m1EoiH8oRV
70
72
  realign/dashboard/screens/share_import.py,sha256=hl2x0yGVycsoUI76AmdZTAV-br3Q6191g5xHHrZ8hOA,6318
71
73
  realign/dashboard/styles/dashboard.tcss,sha256=9W5Tx0lgyGb4HU-z-Kn7gBdexIK0aPe0bkVn2k_AseM,3288
72
74
  realign/dashboard/widgets/__init__.py,sha256=dXsOnbeu_8XhP-6Bu6-R_0LNGqsSM6x7dG7FCDumpa8,460
73
- realign/dashboard/widgets/agents_panel.py,sha256=CGs3qcHGcDDVIpDw1ERb8Jf2t-l--hSY_ufw9SZzM8E,43846
74
- realign/dashboard/widgets/config_panel.py,sha256=eRJRuqImQ8eJIKCEj4O8EvYxI-ht_anrcYbT5JskWyU,15972
75
+ realign/dashboard/widgets/agents_panel.py,sha256=SEzjDFaMdl9bVhKr0XlhMww68pp9pmIu9HqyFfp5Iaw,45209
76
+ realign/dashboard/widgets/config_panel.py,sha256=J6A_rxGVqNu5TMFcWELWgdX1nFCHAjKprFMMp7mBDKo,18203
75
77
  realign/dashboard/widgets/events_table.py,sha256=0cMvE0KdZFBZyvywv7vlt005qsR0aLQnQiMf3ZzK7RY,30218
76
78
  realign/dashboard/widgets/header.py,sha256=0HHCFXX7F3C6HII-WDwOJwWkJrajmKPWmdoMWyOkn9E,1587
77
79
  realign/dashboard/widgets/openable_table.py,sha256=GeJPDEYp0kRHShqvmPMzAePpYXRZHUNqcWNnxqsqxjA,1963
@@ -104,8 +106,8 @@ realign/triggers/next_turn_trigger.py,sha256=-x80_I-WmIjXXzQHEPBykgx_GQW6oKaLDQx
104
106
  realign/triggers/registry.py,sha256=dkIjSd8Bg-hF0nxaO2Fi2K-0Zipqv6vVjc-HYSrA_fY,3656
105
107
  realign/triggers/turn_status.py,sha256=wAZEhXDAmDoX5F-ohWfSnZZ0eA6DAJ9svSPiSv_f6sg,6041
106
108
  realign/triggers/turn_summary.py,sha256=f3hEUshgv9skJ9AbfWpoYs417lsv_HK2A_vpPjgryO4,4467
107
- aline_ai-0.7.1.dist-info/METADATA,sha256=Kj-SGQc0F5dLAvywJrieZbtE93ZkjCqPnozlsSgt_Ds,1597
108
- aline_ai-0.7.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
109
- aline_ai-0.7.1.dist-info/entry_points.txt,sha256=TvYELpMoWsUTcQdMV8tBHxCbEf_LbK4sESqK3r8PM6Y,78
110
- aline_ai-0.7.1.dist-info/top_level.txt,sha256=yIL3s2xv9nf1GwD5n71Aq_JEIV4AfzCIDNKBzewuRm4,8
111
- aline_ai-0.7.1.dist-info/RECORD,,
109
+ aline_ai-0.7.2.dist-info/METADATA,sha256=8dK5fqTKyPuVF0b8Y5I0DMLyaRKF7AlzfQ92oSsWBFM,1597
110
+ aline_ai-0.7.2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
111
+ aline_ai-0.7.2.dist-info/entry_points.txt,sha256=TvYELpMoWsUTcQdMV8tBHxCbEf_LbK4sESqK3r8PM6Y,78
112
+ aline_ai-0.7.2.dist-info/top_level.txt,sha256=yIL3s2xv9nf1GwD5n71Aq_JEIV4AfzCIDNKBzewuRm4,8
113
+ aline_ai-0.7.2.dist-info/RECORD,,
realign/__init__.py CHANGED
@@ -3,7 +3,7 @@
3
3
  import hashlib
4
4
  from pathlib import Path
5
5
 
6
- __version__ = "0.7.1"
6
+ __version__ = "0.7.2"
7
7
 
8
8
 
9
9
  def get_realign_dir(project_root: Path) -> Path:
@@ -3956,6 +3956,9 @@ def export_agent_shares_command(
3956
3956
  "description": event_description,
3957
3957
  }
3958
3958
 
3959
+ # Add agent name to ui_metadata for chat display
3960
+ conversation_data["ui_metadata"]["agent_name"] = agent_info.name
3961
+
3959
3962
  # Add MCP instructions if enabled
3960
3963
  if enable_mcp:
3961
3964
  conversation_data["ui_metadata"]["mcp_instructions"] = {
@@ -383,7 +383,9 @@ def _build_merged_conversation_data(
383
383
  "time": datetime.now(timezone.utc).isoformat(),
384
384
  "event": event_data,
385
385
  "sessions": sessions_data,
386
- "ui_metadata": {},
386
+ "ui_metadata": {
387
+ "agent_name": agent_info.name,
388
+ },
387
389
  }
388
390
 
389
391
  if contributor_token:
realign/config.py CHANGED
@@ -33,6 +33,9 @@ class ReAlignConfig:
33
33
  # Session catch-up settings
34
34
  max_catchup_sessions: int = 3 # Max sessions to auto-import on watcher startup
35
35
 
36
+ # Local API server port (for one-click browser import)
37
+ local_api_port: int = 17280
38
+
36
39
  # Terminal auto-close settings
37
40
  auto_close_stale_terminals: bool = False # Auto-close terminals inactive for 24+ hours
38
41
  stale_terminal_hours: int = 24 # Hours of inactivity before auto-closing
@@ -85,13 +88,14 @@ class ReAlignConfig:
85
88
  "user_name": os.getenv("REALIGN_USER_NAME"),
86
89
  "uid": os.getenv("REALIGN_UID"),
87
90
  "max_catchup_sessions": os.getenv("REALIGN_MAX_CATCHUP_SESSIONS"),
91
+ "local_api_port": os.getenv("ALINE_LOCAL_API_PORT"),
88
92
  "auto_close_stale_terminals": os.getenv("REALIGN_AUTO_CLOSE_STALE_TERMINALS"),
89
93
  "stale_terminal_hours": os.getenv("REALIGN_STALE_TERMINAL_HOURS"),
90
94
  }
91
95
 
92
96
  for key, value in env_overrides.items():
93
97
  if value is not None:
94
- if key in ["summary_max_chars", "max_catchup_sessions", "stale_terminal_hours"]:
98
+ if key in ["summary_max_chars", "max_catchup_sessions", "stale_terminal_hours", "local_api_port"]:
95
99
  config_dict[key] = int(value)
96
100
  elif key in [
97
101
  "redact_on_match",
@@ -139,6 +143,7 @@ class ReAlignConfig:
139
143
  "user_name": self.user_name,
140
144
  "uid": self.uid,
141
145
  "max_catchup_sessions": self.max_catchup_sessions,
146
+ "local_api_port": self.local_api_port,
142
147
  "auto_close_stale_terminals": self.auto_close_stale_terminals,
143
148
  "stale_terminal_hours": self.stale_terminal_hours,
144
149
  }
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,6 +56,8 @@ 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()
59
+ self._local_api_server = None
60
+ self._apply_saved_theme()
81
61
  logger.info(
82
62
  f"AlineDashboard initialized (native_terminal={self._native_terminal_mode})"
83
63
  )
@@ -110,14 +90,14 @@ class AlineDashboard(App):
110
90
  return ["agents", "config"]
111
91
 
112
92
  def on_mount(self) -> None:
113
- """Apply theme based on system settings and watch for changes."""
93
+ """Apply dashboard theme based on saved preference."""
114
94
  logger.info("on_mount() started")
115
95
  try:
116
- self._sync_theme()
117
- # Check for system theme changes every 2 seconds
118
- self.set_interval(2, self._sync_theme)
119
96
  self._quit_confirm_deadline: float | None = None
120
97
 
98
+ # Start local API server for one-click browser import
99
+ self._start_local_api_server()
100
+
121
101
  # Set up side-by-side layout for native terminal mode
122
102
  if self._native_terminal_mode:
123
103
  self._setup_native_terminal_layout()
@@ -127,6 +107,25 @@ class AlineDashboard(App):
127
107
  logger.error(f"on_mount() failed: {e}\n{traceback.format_exc()}")
128
108
  raise
129
109
 
110
+ def _start_local_api_server(self) -> None:
111
+ """Start the local HTTP API server for browser-based agent import."""
112
+ try:
113
+ from ..config import ReAlignConfig
114
+ from .local_api import LocalAPIServer
115
+
116
+ config = ReAlignConfig.load()
117
+ self._local_api_server = LocalAPIServer(port=config.local_api_port)
118
+ self._local_api_server.start()
119
+ except Exception as e:
120
+ logger.warning(f"Could not start local API server: {e}")
121
+
122
+ def _apply_saved_theme(self) -> None:
123
+ theme_choice = str(get_dashboard_state_value("theme", "dark")).strip().lower()
124
+ if theme_choice == "light":
125
+ self.theme = "textual-light"
126
+ else:
127
+ self.theme = "textual-dark"
128
+
130
129
  def _setup_native_terminal_layout(self) -> None:
131
130
  """Set up side-by-side layout for Dashboard and native terminal."""
132
131
  # Skip if using iTerm2 split pane mode (already set up by CLI)
@@ -158,12 +157,6 @@ class AlineDashboard(App):
158
157
  except Exception as e:
159
158
  logger.warning(f"Could not set up native terminal layout: {e}")
160
159
 
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
160
  def action_next_tab(self) -> None:
168
161
  """Switch to next tab."""
169
162
  tabbed_content = self.query_one(TabbedContent)
@@ -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
+
@@ -388,6 +388,15 @@ class AgentsPanel(Container, can_focus=True):
388
388
  classes="agent-share",
389
389
  )
390
390
  )
391
+ # Link button to copy share URL to clipboard
392
+ await row.mount(
393
+ Button(
394
+ "Link",
395
+ id=f"link-{safe_id}",
396
+ name=agent["id"],
397
+ classes="agent-share",
398
+ )
399
+ )
391
400
  else:
392
401
  await row.mount(
393
402
  Button(
@@ -554,6 +563,11 @@ class AgentsPanel(Container, can_focus=True):
554
563
  await self._sync_agent(agent_id)
555
564
  return
556
565
 
566
+ if btn_id.startswith("link-"):
567
+ agent_id = event.button.name or ""
568
+ await self._copy_share_link(agent_id)
569
+ return
570
+
557
571
  if btn_id.startswith("switch-"):
558
572
  terminal_id = event.button.name or ""
559
573
  await self._switch_to_terminal(terminal_id)
@@ -1260,3 +1274,26 @@ class AgentsPanel(Container, can_focus=True):
1260
1274
  else:
1261
1275
  error = result.get("error", "Unknown error")
1262
1276
  self.app.notify(f"Sync failed: {error}", title="Sync", severity="error")
1277
+
1278
+ async def _copy_share_link(self, agent_id: str) -> None:
1279
+ """Copy the share link for an agent to clipboard."""
1280
+ if not agent_id:
1281
+ return
1282
+
1283
+ agent = next((a for a in self._agents if a["id"] == agent_id), None)
1284
+ if not agent:
1285
+ self.app.notify("Agent not found", title="Link", severity="error")
1286
+ return
1287
+
1288
+ share_url = agent.get("share_url")
1289
+ if not share_url:
1290
+ self.app.notify("No share link available", title="Link", severity="warning")
1291
+ return
1292
+
1293
+ copied = copy_text(self.app, share_url)
1294
+ if copied:
1295
+ self.app.notify("Share link copied to clipboard", title="Link", timeout=3)
1296
+ else:
1297
+ self.app.notify(
1298
+ f"Failed to copy. Link: {share_url}", title="Link", severity="warning"
1299
+ )
@@ -7,6 +7,7 @@ from textual.containers import Horizontal
7
7
  from textual.widgets import Button, RadioButton, RadioSet, Static
8
8
 
9
9
  from ..tmux_manager import _run_outer_tmux
10
+ from ..state import get_dashboard_state_value, set_dashboard_state_value
10
11
  from ...auth import (
11
12
  load_credentials,
12
13
  save_credentials,
@@ -113,6 +114,30 @@ class ConfigPanel(Static):
113
114
  width: auto;
114
115
  margin-right: 2;
115
116
  }
117
+
118
+ ConfigPanel .appearance-settings {
119
+ height: auto;
120
+ margin-top: 2;
121
+ }
122
+
123
+ ConfigPanel .appearance-settings .setting-row {
124
+ height: auto;
125
+ }
126
+
127
+ ConfigPanel .appearance-settings .setting-label {
128
+ width: auto;
129
+ }
130
+
131
+ ConfigPanel .appearance-settings RadioSet {
132
+ width: auto;
133
+ height: auto;
134
+ layout: horizontal;
135
+ }
136
+
137
+ ConfigPanel .appearance-settings RadioButton {
138
+ width: auto;
139
+ margin-right: 2;
140
+ }
116
141
  """
117
142
 
118
143
  def __init__(self) -> None:
@@ -131,6 +156,15 @@ class ConfigPanel(Static):
131
156
  yield Static(id="account-email", classes="account-email")
132
157
  yield Button("Login", id="auth-btn", variant="primary")
133
158
 
159
+ # Appearance settings section
160
+ with Static(classes="appearance-settings"):
161
+ yield Static("[bold]Appearance[/bold]", classes="section-title")
162
+ with Horizontal(classes="setting-row"):
163
+ yield Static("Theme:", classes="setting-label")
164
+ with RadioSet(id="theme-radio"):
165
+ yield RadioButton("Dark", id="theme-dark", value=True)
166
+ yield RadioButton("Light", id="theme-light")
167
+
134
168
  # Tmux settings section
135
169
  with Static(classes="tmux-settings"):
136
170
  yield Static("[bold]Tmux Settings[/bold]", classes="section-title")
@@ -160,6 +194,9 @@ class ConfigPanel(Static):
160
194
  # Update account status display
161
195
  self._update_account_status()
162
196
 
197
+ # Sync theme from persisted preference
198
+ self._sync_theme_radio()
199
+
163
200
  # Query and set the actual tmux border resize state
164
201
  self._sync_border_resize_radio()
165
202
 
@@ -184,7 +221,10 @@ class ConfigPanel(Static):
184
221
  """Handle radio set change events."""
185
222
  if self._syncing_radio:
186
223
  return # Ignore events during sync
187
- if event.radio_set.id == "border-resize-radio":
224
+ if event.radio_set.id == "theme-radio":
225
+ theme = "dark" if event.pressed.id == "theme-dark" else "light"
226
+ self._set_theme(theme)
227
+ elif event.radio_set.id == "border-resize-radio":
188
228
  # Check which radio button is selected
189
229
  enabled = event.pressed.id == "border-resize-enabled"
190
230
  self._toggle_border_resize(enabled)
@@ -313,6 +353,28 @@ class ConfigPanel(Static):
313
353
  else:
314
354
  self.app.notify("Failed to logout", title="Account", severity="error")
315
355
 
356
+ def _sync_theme_radio(self) -> None:
357
+ """Sync theme radio buttons with persisted dashboard preference."""
358
+ try:
359
+ theme = str(get_dashboard_state_value("theme", "dark")).strip().lower()
360
+ self._syncing_radio = True
361
+ try:
362
+ if theme == "light":
363
+ radio = self.query_one("#theme-light", RadioButton)
364
+ else:
365
+ radio = self.query_one("#theme-dark", RadioButton)
366
+ radio.value = True
367
+ finally:
368
+ self._syncing_radio = False
369
+ except Exception:
370
+ pass
371
+
372
+ def _set_theme(self, theme: str) -> None:
373
+ theme_value = "light" if theme.strip().lower() == "light" else "dark"
374
+ set_dashboard_state_value("theme", theme_value)
375
+ self.app.theme = "textual-light" if theme_value == "light" else "textual-dark"
376
+ self.app.notify(f"Theme set to {theme_value}", title="Appearance")
377
+
316
378
  def _sync_border_resize_radio(self) -> None:
317
379
  """Query tmux state and sync the radio buttons to match."""
318
380
  try: