aline-ai 0.6.3__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.
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,128 +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 Claude terminal context."""
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_claude_context_id()
334
- except Exception:
335
- context_id = None
336
-
337
- if not context_id:
338
- self.notify(
339
- "No active Claude context found. Use the Terminal tab and select a 'cc' terminal (New cc).",
340
- title="Load Context",
341
- severity="warning",
342
- timeout=4,
343
- )
344
- return
345
-
346
- sessions: list[str] = []
347
- events: list[str] = []
348
-
349
- if active_tab_id == "sessions":
350
- sessions = self.query_one(SessionsTable).get_selected_session_ids()
351
- if not sessions:
352
- self.notify(
353
- "No sessions selected (use space / cmd-click / shift-click)",
354
- title="Load Context",
355
- severity="warning",
356
- timeout=3,
357
- )
358
- return
359
- elif active_tab_id == "events":
360
- events = self.query_one(EventsTable).get_selected_event_ids()
361
- if not events:
362
- self.notify(
363
- "No events selected (use space / cmd-click / shift-click)",
364
- title="Load Context",
365
- severity="warning",
366
- timeout=3,
367
- )
368
- return
369
- else:
370
- self.notify(
371
- "Switch to Sessions or Events to load selection into context",
372
- title="Load Context",
373
- timeout=3,
374
- )
375
- return
376
-
377
- try:
378
- from ..context import add_context
379
-
380
- add_context(
381
- sessions=sessions or None,
382
- events=events or None,
383
- context_id=context_id,
384
- )
385
- except Exception as e:
386
- self.notify(
387
- f"Failed to load context: {e}",
388
- title="Load Context",
389
- severity="error",
390
- timeout=4,
391
- )
392
- return
393
-
394
- try:
395
- from .widgets.terminal_panel import TerminalPanel
396
-
397
- if TerminalPanel.supported():
398
- await self.query_one(TerminalPanel).refresh_data()
399
- except Exception:
400
- pass
401
-
402
- parts: list[str] = []
403
- if sessions:
404
- parts.append(f"{len(sessions)} sessions")
405
- if events:
406
- parts.append(f"{len(events)} events")
407
- what = ", ".join(parts) if parts else "selection"
408
- self.notify(f"Loaded {what} into {context_id}", title="Load Context", timeout=3)
409
-
410
-
411
264
  def run_dashboard(use_native_terminal: bool | None = None) -> None:
412
265
  """Run the Aline Dashboard.
413
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,14 +516,43 @@ 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
 
518
534
  if _run_inner_tmux(["has-session", "-t", INNER_SESSION]).returncode != 0:
519
- if _run_inner_tmux(["new-session", "-d", "-s", INNER_SESSION]).returncode != 0:
535
+ # Create a stable "home" window so user-created terminals can use names like "zsh"
536
+ # without always becoming "zsh-2".
537
+ if (
538
+ _run_inner_tmux(["new-session", "-d", "-s", INNER_SESSION, "-n", "home"]).returncode
539
+ != 0
540
+ ):
520
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 ---
549
+
550
+ # Ensure the default/home window stays named "home" (tmux auto-rename would otherwise
551
+ # change it to "zsh"/"opencode" depending on the last foreground command).
552
+ try:
553
+ _ensure_inner_home_window()
554
+ except Exception:
555
+ pass
521
556
 
522
557
  # Dedicated inner server; safe to enable mouse globally there.
523
558
  _run_inner_tmux(["set-option", "-g", "mouse", "on"])
@@ -534,9 +569,104 @@ def ensure_inner_session() -> bool:
534
569
  _run_inner_tmux(["set-option", "-g", "pane-border-indicators", "arrows"])
535
570
 
536
571
  _source_aline_tmux_config(_run_inner_tmux)
572
+
573
+ _inner_session_configured = True
537
574
  return True
538
575
 
539
576
 
577
+ def _ensure_inner_home_window() -> None:
578
+ """Ensure the inner session has a reserved, non-renaming 'home' window (best-effort)."""
579
+ if _run_inner_tmux(["has-session", "-t", INNER_SESSION]).returncode != 0:
580
+ return
581
+
582
+ out = (
583
+ _run_inner_tmux(
584
+ [
585
+ "list-windows",
586
+ "-t",
587
+ INNER_SESSION,
588
+ "-F",
589
+ "#{window_id}\t#{window_index}\t#{window_name}\t#{"
590
+ + OPT_TERMINAL_ID
591
+ + "}\t#{"
592
+ + OPT_PROVIDER
593
+ + "}\t#{"
594
+ + OPT_SESSION_TYPE
595
+ + "}\t#{"
596
+ + OPT_CONTEXT_ID
597
+ + "}\t#{"
598
+ + OPT_CREATED_AT
599
+ + "}\t#{"
600
+ + OPT_NO_TRACK
601
+ + "}",
602
+ ],
603
+ capture=True,
604
+ ).stdout
605
+ or ""
606
+ )
607
+
608
+ candidates: list[tuple[str, int, str, str, str, str, str, str, str]] = []
609
+ for line in _parse_lines(out):
610
+ parts = (line.split("\t", 8) + [""] * 9)[:9]
611
+ window_id = parts[0]
612
+ try:
613
+ window_index = int(parts[1])
614
+ except Exception:
615
+ window_index = 9999
616
+ window_name = parts[2]
617
+ terminal_id = parts[3]
618
+ provider = parts[4]
619
+ session_type = parts[5]
620
+ context_id = parts[6]
621
+ created_at = parts[7]
622
+ no_track = parts[8]
623
+
624
+ # Pick an unmanaged window (the default one created by `new-session`) as "home".
625
+ unmanaged = (
626
+ not (terminal_id or "").strip()
627
+ and not (provider or "").strip()
628
+ and not (session_type or "").strip()
629
+ and not (context_id or "").strip()
630
+ and not (created_at or "").strip()
631
+ )
632
+ if unmanaged:
633
+ candidates.append(
634
+ (
635
+ window_id,
636
+ window_index,
637
+ window_name,
638
+ terminal_id,
639
+ provider,
640
+ session_type,
641
+ context_id,
642
+ created_at,
643
+ no_track,
644
+ )
645
+ )
646
+
647
+ if not candidates:
648
+ return
649
+
650
+ # Prefer the first window (index 0) if present.
651
+ candidates.sort(key=lambda t: t[1])
652
+ window_id = candidates[0][0]
653
+
654
+ # Rename to "home" and prevent tmux auto-renaming it based on foreground command.
655
+ _run_inner_tmux(["rename-window", "-t", window_id, "home"])
656
+ _run_inner_tmux(["set-option", "-w", "-t", window_id, "automatic-rename", "off"])
657
+ _run_inner_tmux(["set-option", "-w", "-t", window_id, "allow-rename", "off"])
658
+
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.
663
+ try:
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())])
666
+ except Exception:
667
+ pass
668
+
669
+
540
670
  def ensure_right_pane(width_percent: int = 50) -> bool:
541
671
  """Create the right-side pane (terminal area) if it doesn't exist.
542
672
 
@@ -580,9 +710,14 @@ def ensure_right_pane(width_percent: int = 50) -> bool:
580
710
 
581
711
 
582
712
  def list_inner_windows() -> list[InnerWindow]:
713
+ import time as _time
714
+ t0 = _time.time()
583
715
  if not ensure_inner_session():
584
716
  return []
717
+ logger.info(f"[PERF] list_inner_windows ensure_inner_session: {_time.time() - t0:.3f}s")
718
+ t1 = _time.time()
585
719
  state = _load_terminal_state()
720
+ logger.info(f"[PERF] list_inner_windows _load_terminal_state: {_time.time() - t1:.3f}s")
586
721
  out = (
587
722
  _run_inner_tmux(
588
723
  [
@@ -678,13 +813,16 @@ def list_inner_windows() -> list[InnerWindow]:
678
813
 
679
814
 
680
815
  def set_inner_window_options(window_id: str, options: dict[str, str]) -> bool:
816
+ import time as _time
681
817
  if not ensure_inner_session():
682
818
  return False
683
819
  ok = True
684
820
  for key, value in options.items():
821
+ t0 = _time.time()
685
822
  # Important: these are per-window (not session-wide) to avoid cross-tab clobbering.
686
823
  if _run_inner_tmux(["set-option", "-w", "-t", window_id, key, value]).returncode != 0:
687
824
  ok = False
825
+ logger.info(f"[PERF] set_inner_window_options {key}: {_time.time() - t0:.3f}s")
688
826
  return ok
689
827
 
690
828
 
@@ -701,16 +839,24 @@ def create_inner_window(
701
839
  terminal_id: str | None = None,
702
840
  provider: str | None = None,
703
841
  context_id: str | None = None,
842
+ no_track: bool = False,
704
843
  ) -> InnerWindow | None:
844
+ import time as _time
845
+ t0 = _time.time()
846
+ logger.info(f"[PERF] create_inner_window START")
705
847
  if not ensure_right_pane():
706
848
  return None
849
+ logger.info(f"[PERF] create_inner_window ensure_right_pane: {_time.time() - t0:.3f}s")
707
850
 
851
+ t1 = _time.time()
708
852
  existing = list_inner_windows()
853
+ logger.info(f"[PERF] create_inner_window list_inner_windows: {_time.time() - t1:.3f}s")
709
854
  name = _unique_name((w.window_name for w in existing), base_name)
710
855
 
711
856
  # Record creation time before creating the window
712
857
  created_at = time.time()
713
858
 
859
+ t2 = _time.time()
714
860
  proc = _run_inner_tmux(
715
861
  [
716
862
  "new-window",
@@ -725,6 +871,7 @@ def create_inner_window(
725
871
  ],
726
872
  capture=True,
727
873
  )
874
+ logger.info(f"[PERF] create_inner_window new-window: {_time.time() - t2:.3f}s")
728
875
  if proc.returncode != 0:
729
876
  return None
730
877
 
@@ -744,7 +891,13 @@ def create_inner_window(
744
891
  opts.setdefault(OPT_SESSION_TYPE, "")
745
892
  opts.setdefault(OPT_SESSION_ID, "")
746
893
  opts.setdefault(OPT_TRANSCRIPT_PATH, "")
894
+ if no_track:
895
+ opts[OPT_NO_TRACK] = "1"
896
+ else:
897
+ opts.setdefault(OPT_NO_TRACK, "")
898
+ t3 = _time.time()
747
899
  set_inner_window_options(window_id, opts)
900
+ logger.info(f"[PERF] create_inner_window set_options: {_time.time() - t3:.3f}s")
748
901
 
749
902
  _run_inner_tmux(["select-window", "-t", window_id])
750
903
 
@@ -784,6 +937,16 @@ def clear_attention(window_id: str) -> bool:
784
937
 
785
938
  def get_active_claude_context_id() -> str | None:
786
939
  """Return the active inner tmux window's Claude ALINE_CONTEXT_ID (if any)."""
940
+ return get_active_context_id(allowed_providers={"claude"})
941
+
942
+
943
+ def get_active_codex_context_id() -> str | None:
944
+ """Return the active inner tmux window's Codex ALINE_CONTEXT_ID (if any)."""
945
+ return get_active_context_id(allowed_providers={"codex"})
946
+
947
+
948
+ def get_active_context_id(*, allowed_providers: set[str] | None = None) -> str | None:
949
+ """Return the active inner tmux window's ALINE_CONTEXT_ID (optionally filtered by provider)."""
787
950
  try:
788
951
  windows = list_inner_windows()
789
952
  except Exception:
@@ -793,9 +956,12 @@ def get_active_claude_context_id() -> str | None:
793
956
  if active is None:
794
957
  return None
795
958
 
796
- is_claude = (active.provider == "claude") or (active.session_type == "claude")
797
- if not is_claude:
798
- return None
959
+ if allowed_providers is not None:
960
+ allowed = {str(p).strip() for p in allowed_providers if str(p).strip()}
961
+ provider = (active.provider or "").strip()
962
+ session_type = (active.session_type or "").strip()
963
+ if provider not in allowed and session_type not in allowed:
964
+ return None
799
965
 
800
966
  context_id = (active.context_id or "").strip()
801
967
  return context_id or None
@@ -89,6 +89,30 @@ class ConfigPanel(Static):
89
89
  height: auto;
90
90
  margin-top: 2;
91
91
  }
92
+
93
+ ConfigPanel .terminal-settings {
94
+ height: auto;
95
+ margin-top: 2;
96
+ }
97
+
98
+ ConfigPanel .terminal-settings .setting-row {
99
+ height: auto;
100
+ }
101
+
102
+ ConfigPanel .terminal-settings .setting-label {
103
+ width: auto;
104
+ }
105
+
106
+ ConfigPanel .terminal-settings RadioSet {
107
+ width: auto;
108
+ height: auto;
109
+ layout: horizontal;
110
+ }
111
+
112
+ ConfigPanel .terminal-settings RadioButton {
113
+ width: auto;
114
+ margin-right: 2;
115
+ }
92
116
  """
93
117
 
94
118
  def __init__(self) -> None:
@@ -97,6 +121,7 @@ class ConfigPanel(Static):
97
121
  self._syncing_radio: bool = False # Flag to prevent recursive radio updates
98
122
  self._login_in_progress: bool = False # Track login state
99
123
  self._refresh_timer = None # Timer for auto-refresh
124
+ self._auto_close_stale_enabled: bool = False # Track auto-close setting
100
125
 
101
126
  def compose(self) -> ComposeResult:
102
127
  """Compose the config panel layout."""
@@ -115,6 +140,15 @@ class ConfigPanel(Static):
115
140
  yield RadioButton("Enabled", id="border-resize-enabled", value=True)
116
141
  yield RadioButton("Disabled", id="border-resize-disabled")
117
142
 
143
+ # Terminal settings section
144
+ with Static(classes="terminal-settings"):
145
+ yield Static("[bold]Terminal Settings[/bold]", classes="section-title")
146
+ with Horizontal(classes="setting-row"):
147
+ yield Static("Auto-close stale terminals (24h):", classes="setting-label")
148
+ with RadioSet(id="auto-close-stale-radio"):
149
+ yield RadioButton("Enabled", id="auto-close-stale-enabled")
150
+ yield RadioButton("Disabled", id="auto-close-stale-disabled", value=True)
151
+
118
152
  # Tools section
119
153
  with Static(classes="tools-section"):
120
154
  yield Static("[bold]Tools[/bold]", classes="section-title")
@@ -129,6 +163,9 @@ class ConfigPanel(Static):
129
163
  # Query and set the actual tmux border resize state
130
164
  self._sync_border_resize_radio()
131
165
 
166
+ # Sync auto-close stale terminals setting from config
167
+ self._sync_auto_close_stale_radio()
168
+
132
169
  # Start timer to periodically refresh account status (every 5 seconds)
133
170
  self._refresh_timer = self.set_interval(5.0, self._update_account_status)
134
171
 
@@ -151,6 +188,9 @@ class ConfigPanel(Static):
151
188
  # Check which radio button is selected
152
189
  enabled = event.pressed.id == "border-resize-enabled"
153
190
  self._toggle_border_resize(enabled)
191
+ elif event.radio_set.id == "auto-close-stale-radio":
192
+ enabled = event.pressed.id == "auto-close-stale-enabled"
193
+ self._toggle_auto_close_stale(enabled)
154
194
 
155
195
  def _update_account_status(self) -> None:
156
196
  """Update the account status display."""
@@ -318,27 +358,67 @@ class ConfigPanel(Static):
318
358
  except Exception as e:
319
359
  self.app.notify(f"Error toggling border resize: {e}", title="Tmux", severity="error")
320
360
 
361
+ def _sync_auto_close_stale_radio(self) -> None:
362
+ """Sync radio buttons with config file setting."""
363
+ try:
364
+ config = ReAlignConfig.load()
365
+ is_enabled = config.auto_close_stale_terminals
366
+ self._auto_close_stale_enabled = is_enabled
367
+
368
+ # Update radio buttons without triggering the toggle action
369
+ self._syncing_radio = True
370
+ try:
371
+ if is_enabled:
372
+ radio = self.query_one("#auto-close-stale-enabled", RadioButton)
373
+ else:
374
+ radio = self.query_one("#auto-close-stale-disabled", RadioButton)
375
+ radio.value = True
376
+ finally:
377
+ self._syncing_radio = False
378
+ except Exception:
379
+ pass
380
+
381
+ def _toggle_auto_close_stale(self, enabled: bool) -> None:
382
+ """Enable or disable auto-close stale terminals setting."""
383
+ try:
384
+ config = ReAlignConfig.load()
385
+ config.auto_close_stale_terminals = enabled
386
+ config.save()
387
+ self._auto_close_stale_enabled = enabled
388
+ if enabled:
389
+ self.app.notify("Auto-close stale terminals enabled", title="Terminal")
390
+ else:
391
+ self.app.notify("Auto-close stale terminals disabled", title="Terminal")
392
+ except Exception as e:
393
+ self.app.notify(f"Error saving setting: {e}", title="Config", severity="error")
394
+
321
395
  def _handle_doctor(self) -> None:
322
- """Run aline doctor command in background."""
396
+ """Run aline doctor directly in background thread."""
323
397
  self.app.notify("Running Aline Doctor...", title="Doctor")
324
398
 
325
399
  def do_doctor():
326
400
  try:
327
- import subprocess
328
- result = subprocess.run(
329
- ["aline", "doctor"],
330
- capture_output=True,
331
- text=True,
332
- timeout=60,
333
- )
334
- 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:
335
416
  self.app.call_from_thread(
336
417
  self.app.notify, "Doctor completed successfully", title="Doctor"
337
418
  )
338
419
  else:
339
- error_msg = result.stderr.strip() if result.stderr else "Unknown error"
340
420
  self.app.call_from_thread(
341
- self.app.notify, f"Doctor failed: {error_msg}", title="Doctor", severity="error"
421
+ self.app.notify, "Doctor completed with errors", title="Doctor", severity="error"
342
422
  )
343
423
  except Exception as e:
344
424
  self.app.call_from_thread(
@@ -17,7 +17,7 @@ from textual.binding import Binding
17
17
  from textual.containers import Container, Horizontal, Vertical
18
18
  from textual.reactive import reactive
19
19
  from textual.worker import Worker, WorkerState
20
- from textual.widgets import Button, DataTable, Static
20
+ from textual.widgets import Button, DataTable, Select, Static
21
21
 
22
22
  from ...logging_config import setup_logger
23
23
  from .openable_table import OpenableDataTable