aline-ai 0.5.5__py3-none-any.whl → 0.5.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aline-ai
3
- Version: 0.5.5
3
+ Version: 0.5.7
4
4
  Summary: Shared AI memory; everyone knows everything in teams
5
5
  Author: Sharemind
6
6
  License: MIT
@@ -1,7 +1,7 @@
1
- aline_ai-0.5.5.dist-info/licenses/LICENSE,sha256=H8wTqV5IF1oHw_HbBtS1PSDU8G_q81yblEIL_JfV8Vo,1077
2
- realign/__init__.py,sha256=bcfFHKLZs3gqPRuWwo5pAfBK0K2BuS6pB2bwjqmpoCQ,1623
1
+ aline_ai-0.5.7.dist-info/licenses/LICENSE,sha256=H8wTqV5IF1oHw_HbBtS1PSDU8G_q81yblEIL_JfV8Vo,1077
2
+ realign/__init__.py,sha256=yVsHlHyZpDRPyaFzYXTltDHKp6XXpiIGe-TlrznKALo,1623
3
3
  realign/claude_detector.py,sha256=ZLSJacMo6zzQclXByABKA70UNpstxqIv3fPGqdpA934,2792
4
- realign/cli.py,sha256=j_G_FcwbTgCOE0c2kHjX_dma6IdcPcB-maYjr82y1XE,30050
4
+ realign/cli.py,sha256=yeq_a3Peoqx8N13Jo2etjJtbTCZYpuqwoMMyAPdrANs,30569
5
5
  realign/codex_detector.py,sha256=N9ulgMgvTzDfXE4s4vLd6OoS0hT7R6h2bDFFXWa-2hE,4183
6
6
  realign/config.py,sha256=lIKZqeOwYc_gHo760lYYX6PnapuKrCWGqT5SA8-PbeA,12044
7
7
  realign/context.py,sha256=S1YEUn5HWSDTerDDMsSsRV871IZxoaxDjPTPI2z6-Xs,9976
@@ -19,7 +19,7 @@ realign/worker_daemon.py,sha256=LpJbQDY0Z4AMtq0LmpxvFeQM4puuoGDRBayKRafvKhc,3574
19
19
  realign/adapters/__init__.py,sha256=bpDm5aBxMdq4OA_beYahoUb4zfNaq3KOG6KghQJruRc,827
20
20
  realign/adapters/antigravity.py,sha256=geaYxAEswpgsVtERqsQ1OwvPFsy5tRkyjx2yQ-Uq9nM,5461
21
21
  realign/adapters/base.py,sha256=2IdAZKGjg5gPB3YLf_8r3V4XAdbK7fHpj06GjjsYEFY,7409
22
- realign/adapters/claude.py,sha256=sZD-W8nAOF-2jWwgEmJpnT2EpbVbF0NfSbnnW1X0wtg,5060
22
+ realign/adapters/claude.py,sha256=ksTRwC5Z8AzUcB21LFjx6DETP08cv__fjgBzm-TeZdI,5444
23
23
  realign/adapters/codex.py,sha256=5ex3zJ5Hpb_StV2CcBSHVhHleygZxzVJjYsWw8qK1Bc,2051
24
24
  realign/adapters/gemini.py,sha256=NvtXQPWUtEY-DaAAMvLGvQW4FalTG-g0pD514HYnzF0,2540
25
25
  realign/adapters/registry.py,sha256=yM6nf9nGTJ1vaK2Uixp-VacseK7PmxZkCdKedmWI8MA,3255
@@ -37,29 +37,31 @@ realign/commands/config.py,sha256=nYnu_h2pk7GODcrzrV04K51D-s7v06FlRXHJ0HJ-gvU,67
37
37
  realign/commands/context.py,sha256=pM2KfZHVkB-ou4nBhFvKSwnYliLBzwN3zerLyBAbhfE,7095
38
38
  realign/commands/export_shares.py,sha256=Djy1aO7MoU1_ewzn6CZ43oNhSEEonV3sTkSQbHgiaKI,135806
39
39
  realign/commands/import_shares.py,sha256=ukX8huvLvEM5g0qEIoqrV1-imz1g-r0Jj2FqD-ojrIA,25297
40
- realign/commands/init.py,sha256=0wjutL1DxA0hYMt1aCfgUSf91ZBExXCXFrwuOyQNHLg,32060
40
+ realign/commands/init.py,sha256=ef-q3Qz5D_0Eqld8qjtX26X2QrovBSYcva3uAjiJuwk,33015
41
41
  realign/commands/restore.py,sha256=s2BxQZHxQw9r12NzRVsK20KlGafy5AIoSjWMo5PcnHY,11173
42
42
  realign/commands/search.py,sha256=RUdseQsjy-SNfKFkGLWrE4IhxkzgkW9IIxAX33XnCHk,24589
43
43
  realign/commands/upgrade.py,sha256=L3PLOUIN5qAQTbkfoVtSsIbbzEezA_xjjk9F1GMVfjw,12781
44
44
  realign/commands/watcher.py,sha256=fWL3kaRkqE03-NtFLaXlx93hJAQrAuNPSoYhOyQZfq8,136273
45
45
  realign/commands/worker.py,sha256=K1DG1uZ--ebKwklHCyIFdN_axoLjL9Onx8Naq-DOZBs,23078
46
46
  realign/dashboard/__init__.py,sha256=QZkHTsGityH8UkF8rmvA3xW7dMXNe0swEWr443qfgCM,128
47
- realign/dashboard/app.py,sha256=J1oQz63dpH1aEScUsjLRi32mwg3dywm1hSm-gquAk8U,11936
47
+ realign/dashboard/app.py,sha256=LPgDXCqXHtJXzgFiwJvWolLJZ34EU4gejLrBIrgEk4Y,12233
48
48
  realign/dashboard/tmux_manager.py,sha256=DdCiumQ7YQZnje5VfOQ60585C0X6Va_AhBQi_zmhE0Y,24035
49
- realign/dashboard/screens/__init__.py,sha256=x42K31sqL5KVMtufOnZjG8LnFN7hQyN5-z8CySqbwlM,304
49
+ realign/dashboard/screens/__init__.py,sha256=US6sAmQs5VVkH2tFkH_z0WDT4H8cVhLL-JckfSR1yQY,446
50
+ realign/dashboard/screens/create_agent.py,sha256=ugEs3IHrT7FsbuMEwyrqY3eoylp_pbftw42_Fu07tF4,7419
50
51
  realign/dashboard/screens/create_event.py,sha256=oiQY1zKpUYnQU-5fQLeuZH9BV5NClE5B5XZIVBYG5A8,5506
51
52
  realign/dashboard/screens/event_detail.py,sha256=OLaL3-FgAohDdzVlfuUw5yh2SR49IHIpCtiqXJhBTc0,20992
53
+ realign/dashboard/screens/help_screen.py,sha256=Icrcvbgyz49R2tBiu8vBZ4CLm6iYclv_-FTa2pCFRRQ,3398
52
54
  realign/dashboard/screens/session_detail.py,sha256=gfpUIhMO00ecMlMyzpkxDdvGb9zhESEvxwrJvqLuHOI,9603
53
55
  realign/dashboard/screens/share_import.py,sha256=hl2x0yGVycsoUI76AmdZTAV-br3Q6191g5xHHrZ8hOA,6318
54
56
  realign/dashboard/styles/dashboard.tcss,sha256=9sSIs3r4V8eeTwCK56s7fnYxjMEuASP8EcmK1fhpUmA,3454
55
57
  realign/dashboard/widgets/__init__.py,sha256=3Pf2_K9obrertgv_psfxradgkI9RXlmjoXYQH7oBKm0,583
56
58
  realign/dashboard/widgets/config_panel.py,sha256=Afezfd6nvHo0Q44IS2UZTPJsYmHbqzjx7bi5jWrCDPA,11182
57
- realign/dashboard/widgets/events_table.py,sha256=zzX08U2zSNnGX925BA1Bksem-xk2Nd_ujo61rbqGIm8,21904
58
- realign/dashboard/widgets/header.py,sha256=1I8XUf_2qbmpMDuteSvcG3amqeQ4nWa_8Zfev0dXXNI,1583
59
+ realign/dashboard/widgets/events_table.py,sha256=OG9RjwU4c50-RUMmdhXzmIMnYrt6_mCP1GNQWDAX95s,30368
60
+ realign/dashboard/widgets/header.py,sha256=0HHCFXX7F3C6HII-WDwOJwWkJrajmKPWmdoMWyOkn9E,1587
59
61
  realign/dashboard/widgets/openable_table.py,sha256=GeJPDEYp0kRHShqvmPMzAePpYXRZHUNqcWNnxqsqxjA,1963
60
62
  realign/dashboard/widgets/search_panel.py,sha256=ZNJDfwDSxUFnCeltYQYsQsPJ6t4HDeNWpENoTOoBdVM,8951
61
- realign/dashboard/widgets/sessions_table.py,sha256=syM0CjP79Xd9HuJ2ttCa4rwON2q3jXmROjcFtJ0hA9w,22069
62
- realign/dashboard/widgets/terminal_panel.py,sha256=a0-XQ7IUVhC8rQAhZd9gA3N7ue87Q74jLqGzEPFWntU,30897
63
+ realign/dashboard/widgets/sessions_table.py,sha256=toHE96RLwddqXE9Ykocy6loqoGld_6gFawLwdiiJ2cA,32877
64
+ realign/dashboard/widgets/terminal_panel.py,sha256=uXgPcgjWaQ2tTD6Mx6ikCXzq6wYqh-ft0Bait83_DKE,28290
63
65
  realign/dashboard/widgets/watcher_panel.py,sha256=O_mdDacgc87xA-5KEfta53Ik_Xsk_B2OfwenMOTtGw8,19722
64
66
  realign/dashboard/widgets/worker_panel.py,sha256=F_jKWABuCNmjQgeeuCr4KnFRKdY4CLTNcEXMYwsNaSk,18691
65
67
  realign/db/__init__.py,sha256=-1d-Zc4IOUVokbdTXi3R-bIwlkFEPAz_qTHAdcsdp6g,1870
@@ -86,8 +88,8 @@ realign/triggers/next_turn_trigger.py,sha256=BpP0PWn4mU1MZd6mv89jWcjs8Jtv0zEWapW
86
88
  realign/triggers/registry.py,sha256=cb-AVLbYB2pqwfWL3q1DQxLv4kOw7g7m-GshTdfFESc,3827
87
89
  realign/triggers/turn_status.py,sha256=wAZEhXDAmDoX5F-ohWfSnZZ0eA6DAJ9svSPiSv_f6sg,6041
88
90
  realign/triggers/turn_summary.py,sha256=f3hEUshgv9skJ9AbfWpoYs417lsv_HK2A_vpPjgryO4,4467
89
- aline_ai-0.5.5.dist-info/METADATA,sha256=iIDlswhdjHF_CCaONwDX0iRFFZ1VvNxxabYxzKeTWC8,1597
90
- aline_ai-0.5.5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
91
- aline_ai-0.5.5.dist-info/entry_points.txt,sha256=TvYELpMoWsUTcQdMV8tBHxCbEf_LbK4sESqK3r8PM6Y,78
92
- aline_ai-0.5.5.dist-info/top_level.txt,sha256=yIL3s2xv9nf1GwD5n71Aq_JEIV4AfzCIDNKBzewuRm4,8
93
- aline_ai-0.5.5.dist-info/RECORD,,
91
+ aline_ai-0.5.7.dist-info/METADATA,sha256=SG0PLhxEgWQIrjE3rCiSk9DFURyfEkxXteRKqdFhijU,1597
92
+ aline_ai-0.5.7.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
93
+ aline_ai-0.5.7.dist-info/entry_points.txt,sha256=TvYELpMoWsUTcQdMV8tBHxCbEf_LbK4sESqK3r8PM6Y,78
94
+ aline_ai-0.5.7.dist-info/top_level.txt,sha256=yIL3s2xv9nf1GwD5n71Aq_JEIV4AfzCIDNKBzewuRm4,8
95
+ aline_ai-0.5.7.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.5.5"
6
+ __version__ = "0.5.7"
7
7
 
8
8
 
9
9
  def get_realign_dir(project_root: Path) -> Path:
@@ -76,14 +76,13 @@ class ClaudeAdapter(SessionAdapter):
76
76
  project_path_str = "/" + parent_name[1:].replace("-", "/")
77
77
  project_path = Path(project_path_str)
78
78
 
79
- # Validation: check if path exists
80
- # We relax this slightly: if it's a valid-looking path, we return it,
81
- # but ideally we check existence.
79
+ # If path exists locally, return it immediately
82
80
  if project_path.exists():
83
81
  return project_path
84
82
 
85
83
  # Fallback: read cwd from the session JSONL (more reliable when the encoded
86
84
  # directory name is ambiguous due to '-' in real paths).
85
+ cwd_path: Optional[Path] = None
87
86
  try:
88
87
  with session_file.open("r", encoding="utf-8") as f:
89
88
  for i, line in enumerate(f):
@@ -98,13 +97,20 @@ class ClaudeAdapter(SessionAdapter):
98
97
  continue
99
98
  cwd = obj.get("cwd")
100
99
  if isinstance(cwd, str) and cwd.strip():
101
- path = Path(cwd.strip())
102
- if path.exists():
103
- return path
100
+ cwd_path = Path(cwd.strip())
101
+ # If cwd exists locally, prefer it
102
+ if cwd_path.exists():
103
+ return cwd_path
104
+ break # Found cwd, stop searching
104
105
  except Exception:
105
106
  pass
106
107
 
107
- return None
108
+ # Return the best available path even if it doesn't exist locally.
109
+ # This allows importing sessions from other machines (e.g., SWEBench).
110
+ # Prefer cwd from JSONL as it's more accurate than path decoding.
111
+ if cwd_path is not None:
112
+ return cwd_path
113
+ return project_path
108
114
 
109
115
  except Exception:
110
116
  return None
realign/cli.py CHANGED
@@ -19,12 +19,19 @@ console = Console()
19
19
 
20
20
 
21
21
  @app.callback()
22
- def main(ctx: typer.Context):
22
+ def main(
23
+ ctx: typer.Context,
24
+ dev: bool = typer.Option(False, "--dev", help="Enable developer mode (shows Watcher and Worker tabs)"),
25
+ ):
23
26
  """
24
27
  Aline CLI - Shared AI Memory for teams.
25
28
 
26
29
  Run 'aline' without arguments to open the interactive dashboard.
27
30
  """
31
+ # Store dev mode in context for subcommands
32
+ ctx.ensure_object(dict)
33
+ ctx.obj["dev"] = dev
34
+
28
35
  if ctx.invoked_subcommand is None:
29
36
  # Check for updates before launching dashboard
30
37
  from .commands.upgrade import check_and_prompt_update
@@ -40,7 +47,7 @@ def main(ctx: typer.Context):
40
47
 
41
48
  from .dashboard.app import AlineDashboard
42
49
 
43
- dashboard = AlineDashboard()
50
+ dashboard = AlineDashboard(dev_mode=dev)
44
51
  dashboard.run()
45
52
 
46
53
 
@@ -809,7 +816,10 @@ def version():
809
816
 
810
817
 
811
818
  @app.command()
812
- def dashboard():
819
+ def dashboard(
820
+ ctx: typer.Context,
821
+ dev: bool = typer.Option(False, "--dev", help="Enable developer mode (shows Watcher and Worker tabs)"),
822
+ ):
813
823
  """Open the interactive TUI dashboard."""
814
824
  from .dashboard.tmux_manager import bootstrap_dashboard_into_tmux
815
825
 
@@ -817,7 +827,9 @@ def dashboard():
817
827
 
818
828
  from .dashboard.app import AlineDashboard
819
829
 
820
- dash = AlineDashboard()
830
+ # Use dev flag from this command or inherit from parent context
831
+ dev_mode = dev or (ctx.obj.get("dev", False) if ctx.obj else False)
832
+ dash = AlineDashboard(dev_mode=dev_mode)
821
833
  dash.run()
822
834
 
823
835
 
realign/commands/init.py CHANGED
@@ -18,10 +18,14 @@ console = Console()
18
18
 
19
19
  # tmux config template for Aline-managed dashboard sessions.
20
20
  # Stored at ~/.aline/tmux/tmux.conf and sourced by the dashboard tmux bootstrap.
21
+ # Bump this version when the tmux config changes to trigger auto-update on `aline init`.
22
+ _TMUX_CONFIG_VERSION = 2
23
+
24
+
21
25
  def _get_tmux_config() -> str:
22
26
  """Generate tmux config with Type-to-Exit bindings."""
23
- conf = r"""# Aline tmux config
24
- #
27
+ conf = f"# Aline tmux config (v{_TMUX_CONFIG_VERSION})\n"
28
+ conf += r"""#
25
29
  # Goal: make mouse selection copy to the system clipboard (macOS Terminal friendly).
26
30
  # - Drag-select text with the mouse; when you release, it is copied to the clipboard.
27
31
  # - Paste anywhere with Cmd+V.
@@ -448,24 +452,46 @@ def _initialize_prompts_directory() -> None:
448
452
  file_path.write_text(content, encoding="utf-8")
449
453
 
450
454
 
455
+ def _get_tmux_config_version(content: str) -> int:
456
+ """Extract version number from tmux config content. Returns 0 if not found."""
457
+ # Look for "# Aline tmux config (vN)" pattern
458
+ match = re.search(r"# Aline tmux config \(v(\d+)\)", content)
459
+ if match:
460
+ return int(match.group(1))
461
+ # Old configs without version marker are version 1
462
+ if "# Aline tmux config" in content:
463
+ return 1
464
+ return 0
465
+
466
+
451
467
  def _initialize_tmux_config() -> Path:
452
- """Initialize ~/.aline/tmux/tmux.conf (best-effort, no overwrite)."""
468
+ """Initialize ~/.aline/tmux/tmux.conf with auto-update on version change."""
453
469
  tmux_conf_path = Path.home() / ".aline" / "tmux" / "tmux.conf"
454
470
  tmux_conf_path.parent.mkdir(parents=True, exist_ok=True)
471
+
455
472
  if not tmux_conf_path.exists():
456
473
  tmux_conf_path.write_text(_get_tmux_config(), encoding="utf-8")
457
474
  return tmux_conf_path
458
475
 
459
- # Best-effort repair for older Aline-generated configs that used unquoted `#` keys.
460
- # tmux parses `#` as a comment delimiter, turning `bind ... # ...` into `bind ...` (invalid).
476
+ # Check existing config
461
477
  try:
462
478
  existing = tmux_conf_path.read_text(encoding="utf-8")
463
479
  except Exception:
464
480
  return tmux_conf_path
465
481
 
482
+ # Only manage Aline-generated configs
466
483
  if "# Aline tmux config" not in existing:
467
484
  return tmux_conf_path
468
485
 
486
+ # Check version and update if outdated
487
+ existing_version = _get_tmux_config_version(existing)
488
+ if existing_version < _TMUX_CONFIG_VERSION:
489
+ # Auto-update to latest config
490
+ tmux_conf_path.write_text(_get_tmux_config(), encoding="utf-8")
491
+ return tmux_conf_path
492
+
493
+ # Best-effort repair for older Aline-generated configs that used unquoted `#` keys.
494
+ # tmux parses `#` as a comment delimiter, turning `bind ... # ...` into `bind ...` (invalid).
469
495
  repaired = existing
470
496
  repaired = _TMUX_CONF_REPAIR_TILDE_KEY_RE.sub(r"\1\\~\2\\~", repaired)
471
497
  repaired = _TMUX_CONF_REPAIR_KEY_NEEDS_QUOTE_RE.sub(r'\1"\2"\3"\2"', repaired)
realign/dashboard/app.py CHANGED
@@ -54,35 +54,45 @@ class AlineDashboard(App):
54
54
  TITLE = "Aline Dashboard"
55
55
 
56
56
  BINDINGS = [
57
- Binding("r", "refresh", "Refresh"),
57
+ Binding("r", "refresh", "Refresh", show=False),
58
58
  Binding("?", "help", "Help"),
59
- Binding("tab", "next_tab", "Next Tab", priority=True),
60
- Binding("shift+tab", "prev_tab", "Prev Tab", priority=True),
61
- Binding("n", "page_next", "Next Page"),
62
- Binding("p", "page_prev", "Prev Page"),
63
- Binding("s", "switch_view", "Switch View"),
64
- Binding("c", "create_event", "Create Event"),
65
- Binding("l", "load_context", "Load Context"),
66
- Binding("y", "share_import", "Share Import"),
59
+ Binding("tab", "next_tab", "Next Tab", priority=True, show=False),
60
+ Binding("shift+tab", "prev_tab", "Prev Tab", priority=True, show=False),
61
+ Binding("n", "page_next", "Next Page", show=False),
62
+ Binding("p", "page_prev", "Prev Page", show=False),
63
+ Binding("s", "switch_view", "Switch View", show=False),
64
+ Binding("c", "create_event", "Create Event", show=False),
65
+ Binding("l", "load_context", "Load Context", show=False),
66
+ Binding("y", "share_import", "Share Import", show=False),
67
67
  Binding("ctrl+c", "quit_confirm", "Quit", priority=True),
68
68
  ]
69
69
 
70
70
  _quit_confirm_window_s: float = 1.2
71
71
 
72
+ def __init__(self, dev_mode: bool = False):
73
+ """Initialize the dashboard.
74
+
75
+ Args:
76
+ dev_mode: If True, shows developer tabs (Watcher, Worker).
77
+ """
78
+ super().__init__()
79
+ self.dev_mode = dev_mode
80
+
72
81
  def compose(self) -> ComposeResult:
73
82
  """Compose the dashboard layout."""
74
83
  yield AlineHeader()
75
84
  tab_ids = self._tab_ids()
76
85
  with TabbedContent(initial=tab_ids[0] if tab_ids else "terminal"):
77
- with TabPane("Terminal", id="terminal"):
86
+ with TabPane("Agents", id="terminal"):
78
87
  yield TerminalPanel()
79
- with TabPane("Watcher", id="watcher"):
80
- yield WatcherPanel()
81
- with TabPane("Worker", id="worker"):
82
- yield WorkerPanel()
83
- with TabPane("Sessions", id="sessions"):
88
+ if self.dev_mode:
89
+ with TabPane("Watcher", id="watcher"):
90
+ yield WatcherPanel()
91
+ with TabPane("Worker", id="worker"):
92
+ yield WorkerPanel()
93
+ with TabPane("Contexts", id="sessions"):
84
94
  yield SessionsTable()
85
- with TabPane("Events", id="events"):
95
+ with TabPane("Share", id="events"):
86
96
  yield EventsTable()
87
97
  with TabPane("Config", id="config"):
88
98
  yield ConfigPanel()
@@ -91,7 +101,9 @@ class AlineDashboard(App):
91
101
  yield Footer()
92
102
 
93
103
  def _tab_ids(self) -> list[str]:
94
- return ["terminal", "watcher", "worker", "sessions", "events", "config", "search"]
104
+ if self.dev_mode:
105
+ return ["terminal", "watcher", "worker", "sessions", "events", "config", "search"]
106
+ return ["terminal", "sessions", "events", "config", "search"]
95
107
 
96
108
  def on_mount(self) -> None:
97
109
  """Apply theme based on system settings and watch for changes."""
@@ -190,11 +202,9 @@ class AlineDashboard(App):
190
202
 
191
203
  def action_help(self) -> None:
192
204
  """Show help information."""
193
- self.notify(
194
- "Tab/Shift+Tab: Switch tabs | n/p: Page | Enter/dblclick: Open | space: Toggle select | c: Create event | l: Load ctx | s: Switch view | r: Refresh | Ctrl+C Ctrl+C: Quit",
195
- title="Keyboard Shortcuts",
196
- timeout=5,
197
- )
205
+ from .screens import HelpScreen
206
+
207
+ self.push_screen(HelpScreen())
198
208
 
199
209
  def action_quit_confirm(self) -> None:
200
210
  """Quit only when Ctrl+C is pressed twice quickly."""
@@ -3,6 +3,15 @@
3
3
  from .session_detail import SessionDetailScreen
4
4
  from .event_detail import EventDetailScreen
5
5
  from .create_event import CreateEventScreen
6
+ from .create_agent import CreateAgentScreen
6
7
  from .share_import import ShareImportScreen
8
+ from .help_screen import HelpScreen
7
9
 
8
- __all__ = ["SessionDetailScreen", "EventDetailScreen", "CreateEventScreen", "ShareImportScreen"]
10
+ __all__ = [
11
+ "SessionDetailScreen",
12
+ "EventDetailScreen",
13
+ "CreateEventScreen",
14
+ "CreateAgentScreen",
15
+ "ShareImportScreen",
16
+ "HelpScreen",
17
+ ]
@@ -0,0 +1,244 @@
1
+ """Create agent modal for the dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from textual.app import ComposeResult
13
+ from textual.binding import Binding
14
+ from textual.containers import Container, Horizontal, Vertical
15
+ from textual.screen import ModalScreen
16
+ from textual.widgets import Button, Label, RadioButton, RadioSet, Static
17
+
18
+
19
+ # State file for storing last workspace path
20
+ DASHBOARD_STATE_FILE = Path.home() / ".aline" / "dashboard_state.json"
21
+
22
+
23
+ def _load_last_workspace() -> str:
24
+ """Load the last used workspace path from state file."""
25
+ try:
26
+ if DASHBOARD_STATE_FILE.exists():
27
+ with open(DASHBOARD_STATE_FILE, "r", encoding="utf-8") as f:
28
+ state = json.load(f)
29
+ path = state.get("last_workspace", "")
30
+ if path and os.path.isdir(path):
31
+ return path
32
+ except Exception:
33
+ pass
34
+ # Default to current working directory or home
35
+ try:
36
+ return os.getcwd()
37
+ except Exception:
38
+ return str(Path.home())
39
+
40
+
41
+ def _save_last_workspace(path: str) -> None:
42
+ """Save the last used workspace path to state file."""
43
+ try:
44
+ DASHBOARD_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
45
+ state = {}
46
+ if DASHBOARD_STATE_FILE.exists():
47
+ with open(DASHBOARD_STATE_FILE, "r", encoding="utf-8") as f:
48
+ state = json.load(f)
49
+ state["last_workspace"] = path
50
+ with open(DASHBOARD_STATE_FILE, "w", encoding="utf-8") as f:
51
+ json.dump(state, f, indent=2)
52
+ except Exception:
53
+ pass
54
+
55
+
56
+ class CreateAgentScreen(ModalScreen[Optional[tuple[str, str]]]):
57
+ """Modal to create a new agent terminal.
58
+
59
+ Returns a tuple of (agent_type, workspace_path) on success, None on cancel.
60
+ """
61
+
62
+ BINDINGS = [
63
+ Binding("escape", "close", "Close", show=False),
64
+ ]
65
+
66
+ DEFAULT_CSS = """
67
+ CreateAgentScreen {
68
+ align: center middle;
69
+ }
70
+
71
+ CreateAgentScreen #create-agent-root {
72
+ width: 60;
73
+ height: auto;
74
+ max-height: 80%;
75
+ padding: 1 2;
76
+ background: $background;
77
+ border: solid $accent;
78
+ }
79
+
80
+ CreateAgentScreen #create-agent-title {
81
+ height: auto;
82
+ margin-bottom: 1;
83
+ text-style: bold;
84
+ }
85
+
86
+ CreateAgentScreen .section-label {
87
+ height: auto;
88
+ margin-top: 1;
89
+ margin-bottom: 0;
90
+ color: $text-muted;
91
+ }
92
+
93
+ CreateAgentScreen RadioSet {
94
+ height: auto;
95
+ margin: 0;
96
+ padding: 0;
97
+ border: none;
98
+ background: transparent;
99
+ }
100
+
101
+ CreateAgentScreen RadioButton {
102
+ height: auto;
103
+ padding: 0;
104
+ margin: 0;
105
+ background: transparent;
106
+ }
107
+
108
+ CreateAgentScreen #workspace-section {
109
+ height: auto;
110
+ margin-top: 1;
111
+ }
112
+
113
+ CreateAgentScreen #workspace-row {
114
+ height: auto;
115
+ margin-top: 0;
116
+ }
117
+
118
+ CreateAgentScreen #workspace-path {
119
+ width: 1fr;
120
+ height: auto;
121
+ padding: 0 1;
122
+ background: $surface;
123
+ border: solid $primary-lighten-2;
124
+ }
125
+
126
+ CreateAgentScreen #browse-btn {
127
+ width: auto;
128
+ min-width: 10;
129
+ margin-left: 1;
130
+ }
131
+
132
+ CreateAgentScreen #buttons {
133
+ height: auto;
134
+ margin-top: 2;
135
+ align: right middle;
136
+ }
137
+
138
+ CreateAgentScreen #buttons Button {
139
+ margin-left: 1;
140
+ }
141
+ """
142
+
143
+ def __init__(self) -> None:
144
+ super().__init__()
145
+ self._workspace_path = _load_last_workspace()
146
+
147
+ def compose(self) -> ComposeResult:
148
+ with Container(id="create-agent-root"):
149
+ yield Static("Create New Agent", id="create-agent-title")
150
+
151
+ yield Label("Agent Type", classes="section-label")
152
+ with RadioSet(id="agent-type"):
153
+ yield RadioButton("Claude", id="type-claude", value=True)
154
+ yield RadioButton("Codex", id="type-codex")
155
+ yield RadioButton("Opencode", id="type-opencode")
156
+ yield RadioButton("zsh", id="type-zsh")
157
+
158
+ with Vertical(id="workspace-section"):
159
+ yield Label("Workspace", classes="section-label")
160
+ with Horizontal(id="workspace-row"):
161
+ yield Static(self._workspace_path, id="workspace-path")
162
+ yield Button("Browse", id="browse-btn", variant="default")
163
+
164
+ with Horizontal(id="buttons"):
165
+ yield Button("Cancel", id="cancel")
166
+ yield Button("Create", id="create", variant="primary")
167
+
168
+ def on_mount(self) -> None:
169
+ self.query_one("#create", Button).focus()
170
+
171
+ def action_close(self) -> None:
172
+ self.dismiss(None)
173
+
174
+ def _update_workspace_display(self) -> None:
175
+ """Update the workspace path display."""
176
+ self.query_one("#workspace-path", Static).update(self._workspace_path)
177
+
178
+ async def _select_workspace(self) -> str | None:
179
+ """Open macOS folder picker and return selected path, or None if cancelled."""
180
+ default_path = self._workspace_path
181
+ default_path_escaped = default_path.replace('"', '\\"')
182
+ prompt = "Select workspace folder"
183
+ prompt_escaped = prompt.replace('"', '\\"')
184
+ script = f"""
185
+ set defaultFolder to POSIX file "{default_path_escaped}" as alias
186
+ try
187
+ set selectedFolder to choose folder with prompt "{prompt_escaped}" default location defaultFolder
188
+ return POSIX path of selectedFolder
189
+ on error
190
+ return ""
191
+ end try
192
+ """
193
+ try:
194
+ proc = await asyncio.get_event_loop().run_in_executor(
195
+ None,
196
+ lambda: subprocess.run(
197
+ ["osascript", "-e", script],
198
+ capture_output=True,
199
+ text=True,
200
+ timeout=120,
201
+ ),
202
+ )
203
+ result = (proc.stdout or "").strip()
204
+ if result and os.path.isdir(result):
205
+ return result
206
+ return None
207
+ except Exception:
208
+ return None
209
+
210
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
211
+ button_id = event.button.id or ""
212
+
213
+ if button_id == "cancel":
214
+ self.dismiss(None)
215
+ return
216
+
217
+ if button_id == "browse-btn":
218
+ new_path = await self._select_workspace()
219
+ if new_path:
220
+ self._workspace_path = new_path
221
+ self._update_workspace_display()
222
+ return
223
+
224
+ if button_id == "create":
225
+ # Get selected agent type
226
+ radio_set = self.query_one("#agent-type", RadioSet)
227
+ pressed_button = radio_set.pressed_button
228
+ if pressed_button is None:
229
+ self.app.notify("Please select an agent type", severity="warning")
230
+ return
231
+
232
+ agent_type_map = {
233
+ "type-claude": "claude",
234
+ "type-codex": "codex",
235
+ "type-opencode": "opencode",
236
+ "type-zsh": "zsh",
237
+ }
238
+ agent_type = agent_type_map.get(pressed_button.id or "", "claude")
239
+
240
+ # Save the workspace path for next time
241
+ _save_last_workspace(self._workspace_path)
242
+
243
+ # Return the result
244
+ self.dismiss((agent_type, self._workspace_path))