aline-ai 0.5.4__py3-none-any.whl → 0.5.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 (82) hide show
  1. {aline_ai-0.5.4.dist-info → aline_ai-0.5.6.dist-info}/METADATA +1 -1
  2. aline_ai-0.5.6.dist-info/RECORD +95 -0
  3. realign/__init__.py +1 -1
  4. realign/adapters/antigravity.py +28 -20
  5. realign/adapters/base.py +46 -50
  6. realign/adapters/claude.py +14 -14
  7. realign/adapters/codex.py +7 -7
  8. realign/adapters/gemini.py +11 -11
  9. realign/adapters/registry.py +14 -10
  10. realign/claude_detector.py +2 -2
  11. realign/claude_hooks/__init__.py +3 -3
  12. realign/claude_hooks/permission_request_hook_installer.py +31 -32
  13. realign/claude_hooks/stop_hook.py +4 -1
  14. realign/claude_hooks/stop_hook_installer.py +30 -31
  15. realign/cli.py +23 -4
  16. realign/codex_detector.py +11 -11
  17. realign/commands/add.py +88 -65
  18. realign/commands/config.py +3 -12
  19. realign/commands/context.py +3 -1
  20. realign/commands/export_shares.py +86 -127
  21. realign/commands/import_shares.py +145 -155
  22. realign/commands/init.py +166 -30
  23. realign/commands/restore.py +18 -6
  24. realign/commands/search.py +14 -42
  25. realign/commands/upgrade.py +155 -11
  26. realign/commands/watcher.py +98 -219
  27. realign/commands/worker.py +29 -6
  28. realign/config.py +25 -20
  29. realign/context.py +1 -3
  30. realign/dashboard/app.py +34 -24
  31. realign/dashboard/screens/__init__.py +10 -1
  32. realign/dashboard/screens/create_agent.py +244 -0
  33. realign/dashboard/screens/create_event.py +3 -1
  34. realign/dashboard/screens/event_detail.py +14 -6
  35. realign/dashboard/screens/help_screen.py +114 -0
  36. realign/dashboard/screens/session_detail.py +3 -1
  37. realign/dashboard/screens/share_import.py +7 -3
  38. realign/dashboard/tmux_manager.py +54 -9
  39. realign/dashboard/widgets/config_panel.py +85 -1
  40. realign/dashboard/widgets/events_table.py +314 -70
  41. realign/dashboard/widgets/header.py +2 -1
  42. realign/dashboard/widgets/search_panel.py +37 -27
  43. realign/dashboard/widgets/sessions_table.py +404 -85
  44. realign/dashboard/widgets/terminal_panel.py +155 -175
  45. realign/dashboard/widgets/watcher_panel.py +6 -2
  46. realign/dashboard/widgets/worker_panel.py +10 -1
  47. realign/db/__init__.py +1 -1
  48. realign/db/base.py +5 -15
  49. realign/db/locks.py +0 -1
  50. realign/db/migration.py +82 -76
  51. realign/db/schema.py +2 -6
  52. realign/db/sqlite_db.py +23 -41
  53. realign/events/__init__.py +0 -1
  54. realign/events/event_summarizer.py +27 -15
  55. realign/events/session_summarizer.py +29 -15
  56. realign/file_lock.py +1 -0
  57. realign/hooks.py +150 -60
  58. realign/logging_config.py +12 -15
  59. realign/mcp_server.py +30 -51
  60. realign/mcp_watcher.py +0 -1
  61. realign/models/event.py +29 -20
  62. realign/prompts/__init__.py +7 -7
  63. realign/prompts/presets.py +15 -11
  64. realign/redactor.py +99 -59
  65. realign/triggers/__init__.py +9 -9
  66. realign/triggers/antigravity_trigger.py +30 -28
  67. realign/triggers/base.py +4 -3
  68. realign/triggers/claude_trigger.py +104 -85
  69. realign/triggers/codex_trigger.py +15 -5
  70. realign/triggers/gemini_trigger.py +57 -47
  71. realign/triggers/next_turn_trigger.py +3 -1
  72. realign/triggers/registry.py +6 -2
  73. realign/triggers/turn_status.py +3 -1
  74. realign/watcher_core.py +306 -131
  75. realign/watcher_daemon.py +8 -8
  76. realign/worker_core.py +3 -1
  77. realign/worker_daemon.py +3 -1
  78. aline_ai-0.5.4.dist-info/RECORD +0 -93
  79. {aline_ai-0.5.4.dist-info → aline_ai-0.5.6.dist-info}/WHEEL +0 -0
  80. {aline_ai-0.5.4.dist-info → aline_ai-0.5.6.dist-info}/entry_points.txt +0 -0
  81. {aline_ai-0.5.4.dist-info → aline_ai-0.5.6.dist-info}/licenses/LICENSE +0 -0
  82. {aline_ai-0.5.4.dist-info → aline_ai-0.5.6.dist-info}/top_level.txt +0 -0
@@ -87,7 +87,11 @@ def worker_status_command(verbose: bool = False, *, json_output: bool = False) -
87
87
  # Show job counts if DB available
88
88
  try:
89
89
  dbi = get_database()
90
- db_path = Path(str(getattr(dbi, "db_path", ""))).expanduser() if isinstance(dbi, SQLiteDatabase) else None
90
+ db_path = (
91
+ Path(str(getattr(dbi, "db_path", ""))).expanduser()
92
+ if isinstance(dbi, SQLiteDatabase)
93
+ else None
94
+ )
91
95
  if isinstance(dbi, SQLiteDatabase):
92
96
  counts = dbi.get_job_counts()
93
97
  turn_jobs = dbi.list_jobs(limit=30, kinds=["turn_summary"])
@@ -116,6 +120,7 @@ def worker_status_command(verbose: bool = False, *, json_output: bool = False) -
116
120
  job_kinds = {}
117
121
 
118
122
  if json_output:
123
+
119
124
  def _session_id_from_job(job: dict) -> str:
120
125
  payload = job.get("payload") or {}
121
126
  session_id = str(payload.get("session_id") or "").strip()
@@ -133,7 +138,11 @@ def worker_status_command(verbose: bool = False, *, json_output: bool = False) -
133
138
  session_type_by_id: dict[str, str] = {}
134
139
  if isinstance(dbi, SQLiteDatabase):
135
140
  session_ids = sorted(
136
- {sid for sid in (_session_id_from_job(j) for j in (turn_jobs + session_jobs)) if sid}
141
+ {
142
+ sid
143
+ for sid in (_session_id_from_job(j) for j in (turn_jobs + session_jobs))
144
+ if sid
145
+ }
137
146
  )
138
147
  if session_ids:
139
148
  for s in dbi.get_sessions_by_ids(session_ids):
@@ -196,7 +205,12 @@ def worker_status_command(verbose: bool = False, *, json_output: bool = False) -
196
205
  }
197
206
 
198
207
  data = {
199
- "worker": {"status": status.lower(), "running": is_running, "pid": pid, "mode": mode},
208
+ "worker": {
209
+ "status": status.lower(),
210
+ "running": is_running,
211
+ "pid": pid,
212
+ "mode": mode,
213
+ },
200
214
  "db_path": str(db_path) if db_path else None,
201
215
  "job_counts": counts,
202
216
  "job_kinds": job_kinds,
@@ -206,7 +220,9 @@ def worker_status_command(verbose: bool = False, *, json_output: bool = False) -
206
220
  print(json.dumps(data, ensure_ascii=True))
207
221
  return 0
208
222
 
209
- console.print(f"[bold]Worker:[/bold] {status}" + (f" (PID: {pid}, mode: {mode})" if pid else ""))
223
+ console.print(
224
+ f"[bold]Worker:[/bold] {status}" + (f" (PID: {pid}, mode: {mode})" if pid else "")
225
+ )
210
226
  if db_path:
211
227
  console.print(f"[dim]DB:[/dim] {db_path}")
212
228
  if counts:
@@ -225,6 +241,7 @@ def worker_status_command(verbose: bool = False, *, json_output: bool = False) -
225
241
  console.print(f"[dim]Job kinds:[/dim] {pairs}")
226
242
 
227
243
  try:
244
+
228
245
  def fmt_status(s: str) -> tuple[str, str]:
229
246
  s = (s or "").lower()
230
247
  if s == "processing":
@@ -254,7 +271,11 @@ def worker_status_command(verbose: bool = False, *, json_output: bool = False) -
254
271
  session_type_by_id: dict[str, str] = {}
255
272
  if isinstance(dbi, SQLiteDatabase):
256
273
  session_ids = sorted(
257
- {sid for sid in (_session_id_from_job(j) for j in (turn_jobs + session_jobs)) if sid}
274
+ {
275
+ sid
276
+ for sid in (_session_id_from_job(j) for j in (turn_jobs + session_jobs))
277
+ if sid
278
+ }
258
279
  )
259
280
  if session_ids:
260
281
  for s in dbi.get_sessions_by_ids(session_ids):
@@ -501,7 +522,9 @@ def worker_stop_command() -> int:
501
522
  pid, mode = all_processes[0]
502
523
  console.print(f"[cyan]Stopping worker (PID: {pid}, mode: {mode})...[/cyan]")
503
524
  else:
504
- console.print(f"[cyan]Found {len(all_processes)} worker processes, stopping all...[/cyan]")
525
+ console.print(
526
+ f"[cyan]Found {len(all_processes)} worker processes, stopping all...[/cyan]"
527
+ )
505
528
 
506
529
  for pid, _mode in all_processes:
507
530
  try:
realign/config.py CHANGED
@@ -12,36 +12,38 @@ class ReAlignConfig:
12
12
  """ReAlign configuration."""
13
13
 
14
14
  summary_max_chars: int = 500
15
- redact_on_match: bool = False # Default: disable redaction (can be enabled in config)
15
+ redact_on_match: bool = False # Default: disable redaction (can be enabled in config)
16
16
  hooks_installation: str = "repo"
17
17
  sqlite_db_path: str = "~/.aline/db/aline.db" # Path to SQLite database
18
18
  use_LLM: bool = True
19
- llm_provider: str = "auto" # LLM provider: "auto", "claude", or "openai"
19
+ llm_provider: str = "auto" # LLM provider: "auto", "claude", or "openai"
20
20
  auto_detect_claude: bool = True # Enable Claude Code session auto-detection
21
- auto_detect_codex: bool = True # Enable Codex session auto-detection
22
- auto_detect_gemini: bool = True # Enable Gemini CLI session auto-detection
23
- auto_detect_antigravity: bool = False # Enable Antigravity IDE brain artifact monitoring
24
- mcp_auto_commit: bool = True # Enable watcher auto-commit after each user request completes
21
+ auto_detect_codex: bool = True # Enable Codex session auto-detection
22
+ auto_detect_gemini: bool = True # Enable Gemini CLI session auto-detection
23
+ auto_detect_antigravity: bool = False # Enable Antigravity IDE brain artifact monitoring
24
+ mcp_auto_commit: bool = True # Enable watcher auto-commit after each user request completes
25
25
  enable_temp_turn_titles: bool = True # Generate temporary turn titles on user prompt submit
26
- share_backend_url: str = "https://realign-server.vercel.app" # Backend URL for interactive share export
26
+ share_backend_url: str = (
27
+ "https://realign-server.vercel.app" # Backend URL for interactive share export
28
+ )
27
29
 
28
30
  # User identity (V9)
29
- user_name: str = "" # User's display name (set during init)
30
- user_id: str = "" # User's UUID (generated from MAC address)
31
+ user_name: str = "" # User's display name (set during init)
32
+ user_id: str = "" # User's UUID (generated from MAC address)
31
33
 
32
34
  # Session catch-up settings
33
- max_catchup_sessions: int = 3 # Max sessions to auto-import on watcher startup
35
+ max_catchup_sessions: int = 3 # Max sessions to auto-import on watcher startup
34
36
 
35
37
  # LLM API Keys
36
- anthropic_api_key: Optional[str] = None # Anthropic API key (set in config, not environment)
37
- openai_api_key: Optional[str] = None # OpenAI API key (set in config, not environment)
38
+ anthropic_api_key: Optional[str] = None # Anthropic API key (set in config, not environment)
39
+ openai_api_key: Optional[str] = None # OpenAI API key (set in config, not environment)
38
40
 
39
41
  # LLM Model Configuration
40
42
  llm_anthropic_model: str = "claude-3-5-haiku-20241022" # Claude model to use
41
- llm_openai_model: str = "gpt-4o-mini" # OpenAI model to use
42
- llm_openai_use_responses: bool = False # Use OpenAI Responses API for reasoning models
43
- llm_max_tokens: int = 1000 # Default max tokens
44
- llm_temperature: float = 0.0 # Default temperature (0.0 = deterministic)
43
+ llm_openai_model: str = "gpt-4o-mini" # OpenAI model to use
44
+ llm_openai_use_responses: bool = False # Use OpenAI Responses API for reasoning models
45
+ llm_max_tokens: int = 1000 # Default max tokens
46
+ llm_temperature: float = 0.0 # Default temperature (0.0 = deterministic)
45
47
 
46
48
  @classmethod
47
49
  def load(cls, config_path: Optional[Path] = None) -> "ReAlignConfig":
@@ -49,12 +51,13 @@ class ReAlignConfig:
49
51
  if config_path is None:
50
52
  # Default to new location: ~/.aline/config.yaml
51
53
  config_path = Path.home() / ".aline" / "config.yaml"
52
-
54
+
53
55
  # Check for legacy config and migrate if needed
54
56
  legacy_path = Path.home() / ".config" / "realign" / "config.yaml"
55
57
  if not config_path.exists() and legacy_path.exists():
56
58
  try:
57
59
  import shutil
60
+
58
61
  config_path.parent.mkdir(parents=True, exist_ok=True)
59
62
  shutil.move(legacy_path, config_path)
60
63
  # Try to remove empty legacy directory
@@ -172,10 +175,11 @@ def generate_user_id() -> str:
172
175
  str: User UUID as a string
173
176
  """
174
177
  import uuid
178
+
175
179
  try:
176
180
  mac = uuid.getnode()
177
181
  # Use MAC address with DNS namespace to generate UUID5
178
- namespace = uuid.UUID('6ba7b810-9dad-11d1-80b4-00c04fd430c8') # DNS namespace
182
+ namespace = uuid.UUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8") # DNS namespace
179
183
  user_uuid = uuid.uuid5(namespace, str(mac))
180
184
  return str(user_uuid)
181
185
  except Exception:
@@ -194,8 +198,9 @@ def generate_random_username() -> str:
194
198
  """
195
199
  import random
196
200
  import string
197
- letters = ''.join(random.choices(string.ascii_lowercase, k=3))
198
- digits = ''.join(random.choices(string.digits, k=3))
201
+
202
+ letters = "".join(random.choices(string.ascii_lowercase, k=3))
203
+ digits = "".join(random.choices(string.digits, k=3))
199
204
  return letters + digits
200
205
 
201
206
 
realign/context.py CHANGED
@@ -245,9 +245,7 @@ def add_context(
245
245
  entry = config.contexts[existing_idx]
246
246
  # Add new sessions/events (merge, deduplicate)
247
247
  if sessions:
248
- entry.context_sessions = list(
249
- set(entry.context_sessions) | set(sessions)
250
- )
248
+ entry.context_sessions = list(set(entry.context_sessions) | set(sessions))
251
249
  if events:
252
250
  entry.context_events = list(set(entry.context_events) | set(events))
253
251
  entry.loaded_at = datetime.utcnow().isoformat() + "Z"
realign/dashboard/app.py CHANGED
@@ -54,44 +54,56 @@ 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
- with TabbedContent(initial=tab_ids[0] if tab_ids else "watcher"):
77
- with TabPane("Watcher", id="watcher"):
78
- yield WatcherPanel()
79
- with TabPane("Worker", id="worker"):
80
- yield WorkerPanel()
81
- with TabPane("Sessions", id="sessions"):
85
+ with TabbedContent(initial=tab_ids[0] if tab_ids else "terminal"):
86
+ with TabPane("Agents", id="terminal"):
87
+ yield TerminalPanel()
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"):
82
94
  yield SessionsTable()
83
- with TabPane("Events", id="events"):
95
+ with TabPane("Share", id="events"):
84
96
  yield EventsTable()
85
97
  with TabPane("Config", id="config"):
86
98
  yield ConfigPanel()
87
99
  with TabPane("Search", id="search"):
88
100
  yield SearchPanel()
89
- with TabPane("Terminal", id="terminal"):
90
- yield TerminalPanel()
91
101
  yield Footer()
92
102
 
93
103
  def _tab_ids(self) -> list[str]:
94
- return ["watcher", "worker", "sessions", "events", "config", "search", "terminal"]
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))
@@ -121,7 +121,9 @@ class CreateEventScreen(ModalScreen):
121
121
 
122
122
  self.query_one("#create", Button).disabled = True
123
123
  self.query_one("#cancel", Button).disabled = True
124
- self.app.notify("Generating event (aline watcher event generate)...", title="Create Event", timeout=2)
124
+ self.app.notify(
125
+ "Generating event (aline watcher event generate)...", title="Create Event", timeout=2
126
+ )
125
127
 
126
128
  self._worker = self.run_worker(work, thread=True, exit_on_error=False)
127
129
 
@@ -284,7 +284,9 @@ class EventDetailScreen(ModalScreen):
284
284
  else:
285
285
  extra = result.get("stderr") or ""
286
286
  suffix = f": {extra}" if extra else ""
287
- self.app.notify(f"Share export failed (exit {exit_code}){suffix}", title="Share", timeout=6)
287
+ self.app.notify(
288
+ f"Share export failed (exit {exit_code}){suffix}", title="Share", timeout=6
289
+ )
288
290
 
289
291
  def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
290
292
  if event.data_table.id != "event-sessions-table":
@@ -365,7 +367,7 @@ class EventDetailScreen(ModalScreen):
365
367
  "antigravity": "Antigravity",
366
368
  }
367
369
  source = source_map.get(session_type, session_type)
368
- project = (workspace.split("/")[-1] if workspace else "-")
370
+ project = workspace.split("/")[-1] if workspace else "-"
369
371
 
370
372
  self._sessions.append(
371
373
  {
@@ -374,7 +376,9 @@ class EventDetailScreen(ModalScreen):
374
376
  "source": source,
375
377
  "project": project,
376
378
  "turns": int(turn_count or 0),
377
- "total_turns": int(total_turns or 0) if total_turns is not None else None,
379
+ "total_turns": (
380
+ int(total_turns or 0) if total_turns is not None else None
381
+ ),
378
382
  "title": title,
379
383
  "session_summary": session_summary,
380
384
  "last_activity": last_activity,
@@ -397,7 +401,7 @@ class EventDetailScreen(ModalScreen):
397
401
  "antigravity": "Antigravity",
398
402
  }
399
403
  source = source_map.get(session_type, session_type)
400
- project = (str(workspace).split("/")[-1] if workspace else "-")
404
+ project = str(workspace).split("/")[-1] if workspace else "-"
401
405
 
402
406
  self._sessions.append(
403
407
  {
@@ -467,7 +471,9 @@ class EventDetailScreen(ModalScreen):
467
471
  selected_session_id: Optional[str] = None
468
472
  try:
469
473
  if table.row_count > 0:
470
- selected_session_id = str(table.coordinate_to_cell_key(table.cursor_coordinate)[0].value)
474
+ selected_session_id = str(
475
+ table.coordinate_to_cell_key(table.cursor_coordinate)[0].value
476
+ )
471
477
  except Exception:
472
478
  selected_session_id = None
473
479
  table.clear()
@@ -529,7 +535,9 @@ class EventDetailScreen(ModalScreen):
529
535
  "gemini": "Gemini",
530
536
  "antigravity": "Antigravity",
531
537
  }
532
- source = source_map.get(record_type or "", record_type or session.get("source") or "unknown")
538
+ source = source_map.get(
539
+ record_type or "", record_type or session.get("source") or "unknown"
540
+ )
533
541
 
534
542
  title = record_title or session.get("title") or "(no title)"
535
543
  summary = record_summary or session.get("session_summary") or ""