aline-ai 0.6.4__py3-none-any.whl → 0.6.5__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.6.4
3
+ Version: 0.6.5
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.6.4.dist-info/licenses/LICENSE,sha256=H8wTqV5IF1oHw_HbBtS1PSDU8G_q81yblEIL_JfV8Vo,1077
2
- realign/__init__.py,sha256=rnvZkVu_IlHB3KpP9LQEl-T4lLSwL0cvZOT9ycHF13o,1623
1
+ aline_ai-0.6.5.dist-info/licenses/LICENSE,sha256=H8wTqV5IF1oHw_HbBtS1PSDU8G_q81yblEIL_JfV8Vo,1077
2
+ realign/__init__.py,sha256=02FiDcPQx1TGbGJO98rtDO7k-JAA9WrZKtygoavnEY8,1623
3
3
  realign/auth.py,sha256=d_1yvCwluN5iIrdgjtuSKpOYAksDzrzNgntKacLVJrw,16583
4
4
  realign/claude_detector.py,sha256=ZLSJacMo6zzQclXByABKA70UNpstxqIv3fPGqdpA934,2792
5
5
  realign/cli.py,sha256=HZ_1Rm50z1oszCwvPAZcAdPt0Gl-dj0S0NMLy2sWu_4,35665
@@ -38,7 +38,7 @@ realign/commands/add.py,sha256=_Xzt9P15mwndA3JvBBVrki8tn9Cc0UP6SiLwM4RS8Nc,27232
38
38
  realign/commands/auth.py,sha256=QrPukpP-ogYEDSwztV0NOYI-HDgn5fPxlCQ1-e2n7gU,11082
39
39
  realign/commands/config.py,sha256=nYnu_h2pk7GODcrzrV04K51D-s7v06FlRXHJ0HJ-gvU,6732
40
40
  realign/commands/context.py,sha256=pM2KfZHVkB-ou4nBhFvKSwnYliLBzwN3zerLyBAbhfE,7095
41
- realign/commands/doctor.py,sha256=CpS8feMMuV2VvqzSCLal7yWDVSQHan5VbC2LbkLHB5Y,18173
41
+ realign/commands/doctor.py,sha256=q5UOrUR5Uai4AxgaeOnK1Hig5I5UX7m3Vt00tPnUllg,18289
42
42
  realign/commands/export_shares.py,sha256=WNOR7FBE2om9qPO_28edZKhs94lyUAcbRgP_kNaDi5M,132574
43
43
  realign/commands/import_shares.py,sha256=HiswLlYHqR0dR3wgB7Rs54_WownqahIs5IdyJOHuot8,25572
44
44
  realign/commands/init.py,sha256=6rBr1LVIrQLbUH_UvoDhkF1qXmMh2xkjNWCYAUz5Tho,35274
@@ -48,10 +48,10 @@ realign/commands/upgrade.py,sha256=L3PLOUIN5qAQTbkfoVtSsIbbzEezA_xjjk9F1GMVfjw,1
48
48
  realign/commands/watcher.py,sha256=4WTThIgr-Z5guKh_JqGDcPmerr97XiHrVaaijmckHsA,134350
49
49
  realign/commands/worker.py,sha256=jTu7Pj60nTnn7SsH3oNCNnO6zl4TIFCJVNSC1OoQ_0o,23363
50
50
  realign/dashboard/__init__.py,sha256=QZkHTsGityH8UkF8rmvA3xW7dMXNe0swEWr443qfgCM,128
51
- realign/dashboard/app.py,sha256=xCYICKoEsvPli8SCUgfPkInCQp6VDrymnVoRhOizYwY,15872
51
+ realign/dashboard/app.py,sha256=aB1pvuJu-qJ94UqNegB4lvIxUzQJovuC82WQjFnQIFc,10464
52
52
  realign/dashboard/layout.py,sha256=sZxmFj6QTbkois9MHTvBEMMcnaRVehCDqugdbiFx10k,9072
53
53
  realign/dashboard/terminal_backend.py,sha256=MlDfwtqhftyQK6jDNizQGFjAWIo5Bx2TDpSnP3MCZVM,3375
54
- realign/dashboard/tmux_manager.py,sha256=1DqMLdgE1m_MHABAilPpfizawk8D8c9QMVmnJ7Au4sg,30963
54
+ realign/dashboard/tmux_manager.py,sha256=Fc6OQbnOO4YV47BnrIkcr0SHnQuSFwUSqhepNkpqKLs,32942
55
55
  realign/dashboard/backends/__init__.py,sha256=POROX7YKtukYZcLB1pi_kO0sSEpuO3y-hwmF3WIN1Kk,163
56
56
  realign/dashboard/backends/iterm2.py,sha256=XYYJT5lrrp4pW_MyEqPZYkRI0qyKUwJlezwMidgnsHc,21390
57
57
  realign/dashboard/backends/kitty.py,sha256=5jdkR1f2PwB8a4SnS3EG6uOQ2XU-PB7-cpKBfIJq3hU,12066
@@ -64,13 +64,13 @@ realign/dashboard/screens/session_detail.py,sha256=TBkHqSHyMxsLB2QdZq9m1EoiH8oRV
64
64
  realign/dashboard/screens/share_import.py,sha256=hl2x0yGVycsoUI76AmdZTAV-br3Q6191g5xHHrZ8hOA,6318
65
65
  realign/dashboard/styles/dashboard.tcss,sha256=ewonevBGLN-dfSsgxUk4VBCPchtxY4rx_vj1u6Ox2Fw,3454
66
66
  realign/dashboard/widgets/__init__.py,sha256=3Pf2_K9obrertgv_psfxradgkI9RXlmjoXYQH7oBKm0,583
67
- realign/dashboard/widgets/config_panel.py,sha256=v9wQIsH9CK7Q6HFz-C14IQ1SYBcJL3RjhV_ngTk0KH0,15723
67
+ realign/dashboard/widgets/config_panel.py,sha256=eRJRuqImQ8eJIKCEj4O8EvYxI-ht_anrcYbT5JskWyU,15972
68
68
  realign/dashboard/widgets/events_table.py,sha256=MKB1G1_xdQCujEhmMz_GKI4hs-PeEiqGEAH7Y3ZGanE,30852
69
69
  realign/dashboard/widgets/header.py,sha256=0HHCFXX7F3C6HII-WDwOJwWkJrajmKPWmdoMWyOkn9E,1587
70
70
  realign/dashboard/widgets/openable_table.py,sha256=GeJPDEYp0kRHShqvmPMzAePpYXRZHUNqcWNnxqsqxjA,1963
71
71
  realign/dashboard/widgets/search_panel.py,sha256=ZNJDfwDSxUFnCeltYQYsQsPJ6t4HDeNWpENoTOoBdVM,8951
72
72
  realign/dashboard/widgets/sessions_table.py,sha256=oMkYhQ55pUGOGYxEXM5P37mpGYA350BK8Rb8fVq9AS4,34008
73
- realign/dashboard/widgets/terminal_panel.py,sha256=VM0eyE8VDldhrc130JjSvCkkcpr5Io0Gx16i5cbdJgM,57854
73
+ realign/dashboard/widgets/terminal_panel.py,sha256=8WX2_EewlyFlxJYokw2akEqkJUjNt_-F8tzE7St3084,60132
74
74
  realign/dashboard/widgets/watcher_panel.py,sha256=emVY1-aot9Dnf5UI9yyNeEmp4d2Gb-lrC28DjkeLjKA,19575
75
75
  realign/dashboard/widgets/worker_panel.py,sha256=F_jKWABuCNmjQgeeuCr4KnFRKdY4CLTNcEXMYwsNaSk,18691
76
76
  realign/db/__init__.py,sha256=65LsNdsq_rkwNC1eg1OAr3HC0ORXtelOh0I8MhNGr-g,3288
@@ -97,8 +97,8 @@ realign/triggers/next_turn_trigger.py,sha256=-x80_I-WmIjXXzQHEPBykgx_GQW6oKaLDQx
97
97
  realign/triggers/registry.py,sha256=dkIjSd8Bg-hF0nxaO2Fi2K-0Zipqv6vVjc-HYSrA_fY,3656
98
98
  realign/triggers/turn_status.py,sha256=wAZEhXDAmDoX5F-ohWfSnZZ0eA6DAJ9svSPiSv_f6sg,6041
99
99
  realign/triggers/turn_summary.py,sha256=f3hEUshgv9skJ9AbfWpoYs417lsv_HK2A_vpPjgryO4,4467
100
- aline_ai-0.6.4.dist-info/METADATA,sha256=NkBV9ESnpMtnqqp3cn6XcEYhJUjtSMndUS5QIpYP5oY,1597
101
- aline_ai-0.6.4.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
102
- aline_ai-0.6.4.dist-info/entry_points.txt,sha256=TvYELpMoWsUTcQdMV8tBHxCbEf_LbK4sESqK3r8PM6Y,78
103
- aline_ai-0.6.4.dist-info/top_level.txt,sha256=yIL3s2xv9nf1GwD5n71Aq_JEIV4AfzCIDNKBzewuRm4,8
104
- aline_ai-0.6.4.dist-info/RECORD,,
100
+ aline_ai-0.6.5.dist-info/METADATA,sha256=RmD0VjSn_0nGStyFKgkNYUL3i2foaqg8UWUT3kOUTOc,1597
101
+ aline_ai-0.6.5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
102
+ aline_ai-0.6.5.dist-info/entry_points.txt,sha256=TvYELpMoWsUTcQdMV8tBHxCbEf_LbK4sESqK3r8PM6Y,78
103
+ aline_ai-0.6.5.dist-info/top_level.txt,sha256=yIL3s2xv9nf1GwD5n71Aq_JEIV4AfzCIDNKBzewuRm4,8
104
+ aline_ai-0.6.5.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.6.4"
6
+ __version__ = "0.6.5"
7
7
 
8
8
 
9
9
  def get_realign_dir(project_root: Path) -> Path:
@@ -308,6 +308,7 @@ def run_doctor(
308
308
  start_if_not_running: bool,
309
309
  verbose: bool,
310
310
  clear_cache: bool,
311
+ auto_fix: bool = False,
311
312
  ) -> int:
312
313
  """
313
314
  Core doctor logic.
@@ -317,6 +318,7 @@ def run_doctor(
317
318
  start_if_not_running: If True and restart_daemons is True, start daemons even if not running.
318
319
  verbose: Print details.
319
320
  clear_cache: Clear Python bytecode cache for the installed package directory.
321
+ auto_fix: If True, automatically fix failed jobs without prompting.
320
322
  """
321
323
  from ..auth import is_logged_in
322
324
  from . import watcher as watcher_cmd
@@ -417,7 +419,7 @@ def run_doctor(
417
419
  console.print(f" [yellow]![/yellow] Found {llm_error_count} turn(s) with LLM API errors")
418
420
 
419
421
  # Ask user if they want to fix
420
- if typer.confirm("\n Do you want to requeue these for regeneration?", default=True):
422
+ if auto_fix or typer.confirm("\n Do you want to requeue these for regeneration?", default=True):
421
423
  requeued_jobs = 0
422
424
  requeued_turns = 0
423
425
 
realign/dashboard/app.py CHANGED
@@ -5,7 +5,6 @@ import subprocess
5
5
  import sys
6
6
  import time
7
7
  import traceback
8
- from pathlib import Path
9
8
 
10
9
  from textual.app import App, ComposeResult
11
10
  from textual.binding import Binding
@@ -16,10 +15,7 @@ from .widgets import (
16
15
  AlineHeader,
17
16
  WatcherPanel,
18
17
  WorkerPanel,
19
- SessionsTable,
20
- EventsTable,
21
18
  ConfigPanel,
22
- SearchPanel,
23
19
  TerminalPanel,
24
20
  )
25
21
 
@@ -71,9 +67,6 @@ class AlineDashboard(App):
71
67
  Binding("n", "page_next", "Next Page", show=False),
72
68
  Binding("p", "page_prev", "Prev Page", show=False),
73
69
  Binding("s", "switch_view", "Switch View", show=False),
74
- Binding("c", "create_event", "Create Event", show=False),
75
- Binding("l", "load_context", "Load Context", show=False),
76
- Binding("y", "share_import", "Share Import", show=False),
77
70
  Binding("ctrl+c", "quit_confirm", "Quit", priority=True),
78
71
  ]
79
72
 
@@ -119,14 +112,8 @@ class AlineDashboard(App):
119
112
  yield WatcherPanel()
120
113
  with TabPane("Worker", id="worker"):
121
114
  yield WorkerPanel()
122
- with TabPane("Contexts", id="sessions"):
123
- yield SessionsTable()
124
- with TabPane("Share", id="events"):
125
- yield EventsTable()
126
115
  with TabPane("Config", id="config"):
127
116
  yield ConfigPanel()
128
- with TabPane("Search", id="search"):
129
- yield SearchPanel()
130
117
  yield Footer()
131
118
  logger.debug("compose() completed successfully")
132
119
  except Exception as e:
@@ -135,8 +122,8 @@ class AlineDashboard(App):
135
122
 
136
123
  def _tab_ids(self) -> list[str]:
137
124
  if self.dev_mode:
138
- return ["terminal", "watcher", "worker", "sessions", "events", "config", "search"]
139
- return ["terminal", "sessions", "events", "config", "search"]
125
+ return ["terminal", "watcher", "worker", "config"]
126
+ return ["terminal", "config"]
140
127
 
141
128
  def on_mount(self) -> None:
142
129
  """Apply theme based on system settings and watch for changes."""
@@ -222,14 +209,8 @@ class AlineDashboard(App):
222
209
  self.query_one(WatcherPanel).refresh_data()
223
210
  elif active_tab_id == "worker":
224
211
  self.query_one(WorkerPanel).refresh_data()
225
- elif active_tab_id == "sessions":
226
- self.query_one(SessionsTable).refresh_data()
227
- elif active_tab_id == "events":
228
- self.query_one(EventsTable).refresh_data()
229
212
  elif active_tab_id == "config":
230
213
  self.query_one(ConfigPanel).refresh_data()
231
- elif active_tab_id == "search":
232
- pass # Search is manual
233
214
  elif active_tab_id == "terminal":
234
215
  await self.query_one(TerminalPanel).refresh_data()
235
216
 
@@ -242,7 +223,6 @@ class AlineDashboard(App):
242
223
  self.query_one(WatcherPanel).action_next_page()
243
224
  elif active_tab_id == "worker":
244
225
  self.query_one(WorkerPanel).action_next_page()
245
- # sessions and events tabs use scrolling instead of pagination
246
226
 
247
227
  def action_page_prev(self) -> None:
248
228
  """Go to previous page in current panel."""
@@ -253,7 +233,6 @@ class AlineDashboard(App):
253
233
  self.query_one(WatcherPanel).action_prev_page()
254
234
  elif active_tab_id == "worker":
255
235
  self.query_one(WorkerPanel).action_prev_page()
256
- # sessions and events tabs use scrolling instead of pagination
257
236
 
258
237
  def action_switch_view(self) -> None:
259
238
  """Switch view in current panel (if supported)."""
@@ -264,10 +243,6 @@ class AlineDashboard(App):
264
243
  self.query_one(WatcherPanel).action_switch_view()
265
244
  elif active_tab_id == "worker":
266
245
  self.query_one(WorkerPanel).action_switch_view()
267
- elif active_tab_id == "sessions":
268
- self.query_one(SessionsTable).action_switch_view()
269
- elif active_tab_id == "events":
270
- self.query_one(EventsTable).action_switch_view()
271
246
 
272
247
  def action_help(self) -> None:
273
248
  """Show help information."""
@@ -286,130 +261,6 @@ class AlineDashboard(App):
286
261
  self._quit_confirm_deadline = now + self._quit_confirm_window_s
287
262
  self.notify("Press Ctrl+C again to quit", title="Quit", timeout=2)
288
263
 
289
- def action_create_event(self) -> None:
290
- """Create an event from selected sessions (Sessions tab only)."""
291
- tabbed_content = self.query_one(TabbedContent)
292
- if tabbed_content.active != "sessions":
293
- self.notify(
294
- "Switch to the Sessions tab to create an event", title="Create Event", timeout=3
295
- )
296
- return
297
-
298
- sessions_panel = self.query_one(SessionsTable)
299
- session_ids = sessions_panel.get_selected_session_ids()
300
- if not session_ids:
301
- self.notify(
302
- "No sessions selected (use space / cmd-click / shift-click)",
303
- title="Create Event",
304
- timeout=3,
305
- )
306
- return
307
-
308
- from .screens import CreateEventScreen
309
-
310
- self.push_screen(CreateEventScreen(session_ids))
311
-
312
- def action_share_import(self) -> None:
313
- """Import a share URL (Events tab only)."""
314
- tabbed_content = self.query_one(TabbedContent)
315
- if tabbed_content.active != "events":
316
- self.notify(
317
- "Switch to the Events tab to import a share", title="Share Import", timeout=3
318
- )
319
- return
320
-
321
- from .screens import ShareImportScreen
322
-
323
- self.push_screen(ShareImportScreen())
324
-
325
- async def action_load_context(self) -> None:
326
- """Load selected sessions/events into the active terminal context (Claude/Codex)."""
327
- tabbed_content = self.query_one(TabbedContent)
328
- active_tab_id = tabbed_content.active
329
-
330
- try:
331
- from . import tmux_manager
332
-
333
- context_id = tmux_manager.get_active_context_id(
334
- allowed_providers={"claude", "codex"}
335
- )
336
- except Exception:
337
- context_id = None
338
-
339
- if not context_id:
340
- self.notify(
341
- "No active context found. Use the Terminal tab and select a Claude ('cc') or Codex terminal.",
342
- title="Load Context",
343
- severity="warning",
344
- timeout=4,
345
- )
346
- return
347
-
348
- sessions: list[str] = []
349
- events: list[str] = []
350
-
351
- if active_tab_id == "sessions":
352
- sessions = self.query_one(SessionsTable).get_selected_session_ids()
353
- if not sessions:
354
- self.notify(
355
- "No sessions selected (use space / cmd-click / shift-click)",
356
- title="Load Context",
357
- severity="warning",
358
- timeout=3,
359
- )
360
- return
361
- elif active_tab_id == "events":
362
- events = self.query_one(EventsTable).get_selected_event_ids()
363
- if not events:
364
- self.notify(
365
- "No events selected (use space / cmd-click / shift-click)",
366
- title="Load Context",
367
- severity="warning",
368
- timeout=3,
369
- )
370
- return
371
- else:
372
- self.notify(
373
- "Switch to Sessions or Events to load selection into context",
374
- title="Load Context",
375
- timeout=3,
376
- )
377
- return
378
-
379
- try:
380
- from ..context import add_context
381
-
382
- add_context(
383
- sessions=sessions or None,
384
- events=events or None,
385
- context_id=context_id,
386
- )
387
- except Exception as e:
388
- self.notify(
389
- f"Failed to load context: {e}",
390
- title="Load Context",
391
- severity="error",
392
- timeout=4,
393
- )
394
- return
395
-
396
- try:
397
- from .widgets.terminal_panel import TerminalPanel
398
-
399
- if TerminalPanel.supported():
400
- await self.query_one(TerminalPanel).refresh_data()
401
- except Exception:
402
- pass
403
-
404
- parts: list[str] = []
405
- if sessions:
406
- parts.append(f"{len(sessions)} sessions")
407
- if events:
408
- parts.append(f"{len(events)} events")
409
- what = ", ".join(parts) if parts else "selection"
410
- self.notify(f"Loaded {what} into {context_id}", title="Load Context", timeout=3)
411
-
412
-
413
264
  def run_dashboard(use_native_terminal: bool | None = None) -> None:
414
265
  """Run the Aline Dashboard.
415
266
 
@@ -195,11 +195,17 @@ def _session_id_from_transcript_path(transcript_path: str | None) -> str | None:
195
195
 
196
196
  def _load_terminal_state_from_db() -> dict[str, dict[str, str]]:
197
197
  """Load terminal state from database (best-effort)."""
198
+ import time as _time
199
+ t0 = _time.time()
198
200
  try:
199
201
  from ..db import get_database
200
202
 
203
+ t1 = _time.time()
201
204
  db = get_database(read_only=True)
205
+ logger.info(f"[PERF] _load_terminal_state_from_db get_database: {_time.time() - t1:.3f}s")
206
+ t2 = _time.time()
202
207
  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")
203
209
 
204
210
  out: dict[str, dict[str, str]] = {}
205
211
  for agent in agents:
@@ -510,8 +516,18 @@ def bootstrap_dashboard_into_tmux() -> None:
510
516
  os.execvp("tmux", ["tmux", "-L", OUTER_SOCKET, "attach", "-t", OUTER_SESSION])
511
517
 
512
518
 
519
+ _inner_session_configured = False
520
+
521
+
513
522
  def ensure_inner_session() -> bool:
514
- """Ensure the inner tmux server/session exists (returns True on success)."""
523
+ """Ensure the inner tmux server/session exists (returns True on success).
524
+
525
+ The full configuration (mouse, status bar, border styles, home window setup) is
526
+ only applied once per process lifetime. Subsequent calls just verify the session
527
+ is still alive via a cheap ``has-session`` check.
528
+ """
529
+ global _inner_session_configured
530
+
515
531
  if not (tmux_available() and in_tmux() and managed_env_enabled()):
516
532
  return False
517
533
 
@@ -523,6 +539,13 @@ def ensure_inner_session() -> bool:
523
539
  != 0
524
540
  ):
525
541
  return False
542
+ # Force re-configuration after creating a new session.
543
+ _inner_session_configured = False
544
+
545
+ if _inner_session_configured:
546
+ return True
547
+
548
+ # --- One-time configuration below ---
526
549
 
527
550
  # Ensure the default/home window stays named "home" (tmux auto-rename would otherwise
528
551
  # change it to "zsh"/"opencode" depending on the last foreground command).
@@ -546,6 +569,8 @@ def ensure_inner_session() -> bool:
546
569
  _run_inner_tmux(["set-option", "-g", "pane-border-indicators", "arrows"])
547
570
 
548
571
  _source_aline_tmux_config(_run_inner_tmux)
572
+
573
+ _inner_session_configured = True
549
574
  return True
550
575
 
551
576
 
@@ -632,14 +657,12 @@ def _ensure_inner_home_window() -> None:
632
657
  _run_inner_tmux(["set-option", "-w", "-t", window_id, "allow-rename", "off"])
633
658
 
634
659
  # Mark as internal/no-track so UI can hide it.
660
+ # NOTE: We use _run_inner_tmux directly here instead of set_inner_window_options
661
+ # to avoid recursion: set_inner_window_options → ensure_inner_session →
662
+ # _ensure_inner_home_window → set_inner_window_options.
635
663
  try:
636
- set_inner_window_options(
637
- window_id,
638
- {
639
- OPT_NO_TRACK: "1",
640
- OPT_CREATED_AT: str(time.time()),
641
- },
642
- )
664
+ _run_inner_tmux(["set-option", "-w", "-t", window_id, OPT_NO_TRACK, "1"])
665
+ _run_inner_tmux(["set-option", "-w", "-t", window_id, OPT_CREATED_AT, str(time.time())])
643
666
  except Exception:
644
667
  pass
645
668
 
@@ -687,9 +710,14 @@ def ensure_right_pane(width_percent: int = 50) -> bool:
687
710
 
688
711
 
689
712
  def list_inner_windows() -> list[InnerWindow]:
713
+ import time as _time
714
+ t0 = _time.time()
690
715
  if not ensure_inner_session():
691
716
  return []
717
+ logger.info(f"[PERF] list_inner_windows ensure_inner_session: {_time.time() - t0:.3f}s")
718
+ t1 = _time.time()
692
719
  state = _load_terminal_state()
720
+ logger.info(f"[PERF] list_inner_windows _load_terminal_state: {_time.time() - t1:.3f}s")
693
721
  out = (
694
722
  _run_inner_tmux(
695
723
  [
@@ -785,13 +813,16 @@ def list_inner_windows() -> list[InnerWindow]:
785
813
 
786
814
 
787
815
  def set_inner_window_options(window_id: str, options: dict[str, str]) -> bool:
816
+ import time as _time
788
817
  if not ensure_inner_session():
789
818
  return False
790
819
  ok = True
791
820
  for key, value in options.items():
821
+ t0 = _time.time()
792
822
  # Important: these are per-window (not session-wide) to avoid cross-tab clobbering.
793
823
  if _run_inner_tmux(["set-option", "-w", "-t", window_id, key, value]).returncode != 0:
794
824
  ok = False
825
+ logger.info(f"[PERF] set_inner_window_options {key}: {_time.time() - t0:.3f}s")
795
826
  return ok
796
827
 
797
828
 
@@ -810,15 +841,22 @@ def create_inner_window(
810
841
  context_id: str | None = None,
811
842
  no_track: bool = False,
812
843
  ) -> InnerWindow | None:
844
+ import time as _time
845
+ t0 = _time.time()
846
+ logger.info(f"[PERF] create_inner_window START")
813
847
  if not ensure_right_pane():
814
848
  return None
849
+ logger.info(f"[PERF] create_inner_window ensure_right_pane: {_time.time() - t0:.3f}s")
815
850
 
851
+ t1 = _time.time()
816
852
  existing = list_inner_windows()
853
+ logger.info(f"[PERF] create_inner_window list_inner_windows: {_time.time() - t1:.3f}s")
817
854
  name = _unique_name((w.window_name for w in existing), base_name)
818
855
 
819
856
  # Record creation time before creating the window
820
857
  created_at = time.time()
821
858
 
859
+ t2 = _time.time()
822
860
  proc = _run_inner_tmux(
823
861
  [
824
862
  "new-window",
@@ -833,6 +871,7 @@ def create_inner_window(
833
871
  ],
834
872
  capture=True,
835
873
  )
874
+ logger.info(f"[PERF] create_inner_window new-window: {_time.time() - t2:.3f}s")
836
875
  if proc.returncode != 0:
837
876
  return None
838
877
 
@@ -856,7 +895,9 @@ def create_inner_window(
856
895
  opts[OPT_NO_TRACK] = "1"
857
896
  else:
858
897
  opts.setdefault(OPT_NO_TRACK, "")
898
+ t3 = _time.time()
859
899
  set_inner_window_options(window_id, opts)
900
+ logger.info(f"[PERF] create_inner_window set_options: {_time.time() - t3:.3f}s")
860
901
 
861
902
  _run_inner_tmux(["select-window", "-t", window_id])
862
903
 
@@ -393,26 +393,32 @@ class ConfigPanel(Static):
393
393
  self.app.notify(f"Error saving setting: {e}", title="Config", severity="error")
394
394
 
395
395
  def _handle_doctor(self) -> None:
396
- """Run aline doctor command in background."""
396
+ """Run aline doctor directly in background thread."""
397
397
  self.app.notify("Running Aline Doctor...", title="Doctor")
398
398
 
399
399
  def do_doctor():
400
400
  try:
401
- import subprocess
402
- result = subprocess.run(
403
- ["aline", "doctor"],
404
- capture_output=True,
405
- text=True,
406
- timeout=60,
407
- )
408
- if result.returncode == 0:
401
+ import contextlib
402
+ import io
403
+ from ...commands.doctor import run_doctor
404
+
405
+ # Suppress Rich console output (would corrupt TUI)
406
+ with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
407
+ exit_code = run_doctor(
408
+ restart_daemons=True,
409
+ start_if_not_running=False,
410
+ verbose=False,
411
+ clear_cache=True,
412
+ auto_fix=True,
413
+ )
414
+
415
+ if exit_code == 0:
409
416
  self.app.call_from_thread(
410
417
  self.app.notify, "Doctor completed successfully", title="Doctor"
411
418
  )
412
419
  else:
413
- error_msg = result.stderr.strip() if result.stderr else "Unknown error"
414
420
  self.app.call_from_thread(
415
- self.app.notify, f"Doctor failed: {error_msg}", title="Doctor", severity="error"
421
+ self.app.notify, "Doctor completed with errors", title="Doctor", severity="error"
416
422
  )
417
423
  except Exception as e:
418
424
  self.app.call_from_thread(
@@ -668,13 +668,17 @@ class TerminalPanel(Container, can_focus=True):
668
668
 
669
669
  async def refresh_data(self) -> None:
670
670
  async with self._refresh_lock:
671
+ t_start = time.time()
671
672
  # Check and close stale terminals if enabled
672
673
  await self._close_stale_terminals_if_enabled()
674
+ logger.debug(f"[PERF] _close_stale_terminals_if_enabled: {time.time() - t_start:.3f}s")
673
675
 
676
+ t_refresh = time.time()
674
677
  if self._is_native_mode():
675
678
  await self._refresh_native_data()
676
679
  else:
677
680
  await self._refresh_tmux_data()
681
+ logger.debug(f"[PERF] total refresh: {time.time() - t_start:.3f}s")
678
682
 
679
683
  async def _close_stale_terminals_if_enabled(self) -> None:
680
684
  """Close terminals that haven't been updated for the configured hours."""
@@ -741,15 +745,13 @@ class TerminalPanel(Container, can_focus=True):
741
745
  logger.error(f"Failed to list native terminals: {e}")
742
746
  return
743
747
 
744
- # Best-effort: bind Codex session ids for native terminals (no watcher required).
745
- try:
746
- for w in windows:
747
- if self._is_codex_window(w) and w.terminal_id:
748
- self._maybe_link_codex_session_for_terminal(
749
- terminal_id=w.terminal_id, created_at=w.created_at
750
- )
751
- except Exception:
752
- pass
748
+ # Yield to event loop to keep UI responsive
749
+ await asyncio.sleep(0)
750
+
751
+ # NOTE: _maybe_link_codex_session_for_terminal is intentionally skipped here
752
+ # because it performs expensive file system scans (find_codex_sessions_for_project)
753
+ # that can take minutes with many session files. Codex session linking is handled
754
+ # by the watcher process instead.
753
755
 
754
756
  active_window_id = next(
755
757
  (w.session_id for w in windows if w.active), None
@@ -765,6 +767,9 @@ class TerminalPanel(Container, can_focus=True):
765
767
  ]
766
768
  titles = self._fetch_claude_session_titles(claude_ids)
767
769
 
770
+ # Yield to event loop after DB query
771
+ await asyncio.sleep(0)
772
+
768
773
  # Get context info
769
774
  context_info_by_context_id: dict[str, tuple[list[str], int, int]] = {}
770
775
  all_context_session_ids: set[str] = set()
@@ -793,6 +798,7 @@ class TerminalPanel(Container, can_focus=True):
793
798
 
794
799
  async def _refresh_tmux_data(self) -> None:
795
800
  """Refresh data using tmux backend."""
801
+ t0 = time.time()
796
802
  try:
797
803
  supported = self.supported()
798
804
  except Exception:
@@ -806,24 +812,29 @@ class TerminalPanel(Container, can_focus=True):
806
812
  except Exception:
807
813
  return
808
814
  windows = [w for w in windows if not self._is_internal_tmux_window(w)]
815
+ logger.debug(f"[PERF] list_inner_windows: {time.time() - t0:.3f}s")
809
816
 
810
- # Best-effort: bind Codex session ids for tmux terminals (no watcher required).
811
- try:
812
- for w in windows:
813
- if self._is_codex_window(w) and not w.session_id and w.terminal_id:
814
- self._maybe_link_codex_session_for_terminal(
815
- terminal_id=w.terminal_id, created_at=w.created_at
816
- )
817
- except Exception:
818
- pass
817
+ # Yield to event loop to keep UI responsive
818
+ await asyncio.sleep(0)
819
+
820
+ # NOTE: _maybe_link_codex_session_for_terminal is intentionally skipped here
821
+ # because it performs expensive file system scans (find_codex_sessions_for_project)
822
+ # that can take minutes with many session files. Codex session linking is handled
823
+ # by the watcher process instead.
819
824
 
820
825
  active_window_id = next((w.window_id for w in windows if w.active), None)
821
826
  if self._expanded_window_id and self._expanded_window_id != active_window_id:
822
827
  self._expanded_window_id = None
823
828
 
829
+ t1 = time.time()
824
830
  session_ids = [w.session_id for w in windows if self._supports_context(w) and w.session_id]
825
831
  titles = self._fetch_claude_session_titles(session_ids)
832
+ logger.debug(f"[PERF] fetch_claude_session_titles: {time.time() - t1:.3f}s")
833
+
834
+ # Yield to event loop after DB query
835
+ await asyncio.sleep(0)
826
836
 
837
+ t2 = time.time()
827
838
  context_info_by_context_id: dict[str, tuple[list[str], int, int]] = {}
828
839
  all_context_session_ids: set[str] = set()
829
840
  for w in windows:
@@ -840,14 +851,21 @@ class TerminalPanel(Container, can_focus=True):
840
851
  event_count,
841
852
  )
842
853
  all_context_session_ids.update(session_ids)
854
+ # Yield periodically during context info gathering
855
+ await asyncio.sleep(0)
856
+ logger.debug(f"[PERF] get_loaded_context_info loop: {time.time() - t2:.3f}s")
843
857
 
858
+ t3 = time.time()
844
859
  if all_context_session_ids:
845
860
  titles.update(self._fetch_claude_session_titles(sorted(all_context_session_ids)))
861
+ logger.debug(f"[PERF] fetch context session titles: {time.time() - t3:.3f}s")
846
862
 
863
+ t4 = time.time()
847
864
  try:
848
865
  await self._render_terminals_tmux(windows, titles, context_info_by_context_id)
849
866
  except Exception:
850
867
  return
868
+ logger.debug(f"[PERF] render_terminals_tmux: {time.time() - t4:.3f}s")
851
869
 
852
870
  def _fetch_claude_session_titles(self, session_ids: list[str]) -> dict[str, str]:
853
871
  # Back-compat hook for tests and older call sites.
@@ -1232,13 +1250,23 @@ class TerminalPanel(Container, can_focus=True):
1232
1250
  return
1233
1251
 
1234
1252
  agent_type, workspace, skip_permissions, no_track = result
1235
- self.run_worker(
1236
- self._create_agent(
1237
- agent_type, workspace, skip_permissions=skip_permissions, no_track=no_track
1238
- ),
1239
- group="terminal-panel-create",
1240
- exclusive=True,
1241
- )
1253
+
1254
+ # Capture self reference for use in the deferred callback
1255
+ panel = self
1256
+
1257
+ # Use app.call_later to defer worker creation until after the modal is dismissed.
1258
+ # This ensures the modal screen is fully closed before the worker starts,
1259
+ # preventing UI update conflicts between modal closing and terminal panel refresh.
1260
+ def start_worker() -> None:
1261
+ panel.run_worker(
1262
+ panel._create_agent(
1263
+ agent_type, workspace, skip_permissions=skip_permissions, no_track=no_track
1264
+ ),
1265
+ group="terminal-panel-create",
1266
+ exclusive=True,
1267
+ )
1268
+
1269
+ self.app.call_later(start_worker)
1242
1270
 
1243
1271
  async def _create_agent(
1244
1272
  self, agent_type: str, workspace: str, *, skip_permissions: bool = False, no_track: bool = False
@@ -1252,7 +1280,14 @@ class TerminalPanel(Container, can_focus=True):
1252
1280
  await self._create_opencode_terminal(workspace)
1253
1281
  elif agent_type == "zsh":
1254
1282
  await self._create_zsh_terminal(workspace)
1255
- await self.refresh_data()
1283
+ # Schedule refresh in a separate worker to avoid blocking UI.
1284
+ # The refresh involves slow synchronous operations (DB queries, file scans)
1285
+ # that would otherwise freeze the dashboard.
1286
+ self.run_worker(
1287
+ self.refresh_data(),
1288
+ group="terminal-panel-refresh",
1289
+ exclusive=True,
1290
+ )
1256
1291
 
1257
1292
  async def _create_claude_terminal(
1258
1293
  self, workspace: str, *, skip_permissions: bool = False, no_track: bool = False
@@ -1495,6 +1530,8 @@ class TerminalPanel(Container, can_focus=True):
1495
1530
 
1496
1531
  async def _create_zsh_terminal(self, workspace: str) -> None:
1497
1532
  """Create a new zsh terminal."""
1533
+ t0 = time.time()
1534
+ logger.info(f"[PERF] _create_zsh_terminal START")
1498
1535
  if self._is_native_mode():
1499
1536
  backend = await self._ensure_native_backend()
1500
1537
  if backend:
@@ -1509,13 +1546,19 @@ class TerminalPanel(Container, can_focus=True):
1509
1546
  self.app.notify(
1510
1547
  "Failed to open zsh terminal", title="Terminal", severity="error"
1511
1548
  )
1549
+ logger.info(f"[PERF] _create_zsh_terminal native END: {time.time() - t0:.3f}s")
1512
1550
  return
1513
1551
 
1514
1552
  # Tmux fallback
1553
+ t1 = time.time()
1515
1554
  command = self._command_in_directory("zsh", workspace)
1555
+ logger.info(f"[PERF] _create_zsh_terminal command ready: {time.time() - t1:.3f}s")
1556
+ t2 = time.time()
1516
1557
  created = tmux_manager.create_inner_window("zsh", command)
1558
+ logger.info(f"[PERF] _create_zsh_terminal create_inner_window: {time.time() - t2:.3f}s")
1517
1559
  if not created:
1518
1560
  self.app.notify("Failed to open zsh terminal", title="Terminal", severity="error")
1561
+ logger.info(f"[PERF] _create_zsh_terminal TOTAL: {time.time() - t0:.3f}s")
1519
1562
 
1520
1563
  async def on_button_pressed(self, event: Button.Pressed) -> None:
1521
1564
  button_id = event.button.id or ""