aline-ai 0.7.2__py3-none-any.whl → 0.7.4__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.
@@ -6,13 +6,15 @@ import asyncio
6
6
  import json as _json
7
7
  import re
8
8
  import shlex
9
+ import time
10
+ from dataclasses import dataclass
9
11
  from pathlib import Path
10
12
  from typing import Optional
11
13
 
12
14
  from textual import events
13
15
  from textual.app import ComposeResult
14
16
  from textual.containers import Container, Horizontal, Vertical
15
- from textual.widgets import Button, Static
17
+ from textual.widgets import Button, Rule, Static
16
18
  from textual.message import Message
17
19
  from textual.worker import Worker, WorkerState
18
20
  from rich.text import Text
@@ -24,6 +26,54 @@ from ..clipboard import copy_text
24
26
  logger = setup_logger("realign.dashboard.widgets.agents_panel", "dashboard.log")
25
27
 
26
28
 
29
+ @dataclass(frozen=True)
30
+ class _ProcessSnapshot:
31
+ created_at_monotonic: float
32
+ tty_key: str
33
+ children: dict[int, list[int]]
34
+ comms: dict[int, str]
35
+
36
+
37
+ class TruncButton(Button):
38
+ """Button that truncates its label with '...' based on actual widget width."""
39
+
40
+ def __init__(self, full_text: str, **kwargs) -> None:
41
+ super().__init__(full_text, **kwargs)
42
+ self._full_text = full_text
43
+
44
+ def render(self) -> Text:
45
+ width = self.size.width
46
+ gutter = self.styles.gutter
47
+ avail = width - gutter.width
48
+ text = self._full_text
49
+ if avail > 3 and len(text) > avail:
50
+ return Text(text[: avail - 3] + "...")
51
+ return Text(text)
52
+
53
+ class SpinnerPrefixButton(Button):
54
+ """Button label with an animated spinner prefix, truncated to widget width."""
55
+
56
+ _FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
57
+
58
+ def __init__(self, *, prefix: str, label: str, **kwargs) -> None:
59
+ self._prefix = prefix
60
+ self._label = label
61
+ super().__init__(f"{prefix}{label}", **kwargs)
62
+
63
+ def render(self) -> Text:
64
+ width = self.size.width
65
+ gutter = self.styles.gutter
66
+ avail = width - gutter.width
67
+
68
+ idx = int(time.monotonic() * 8) % len(self._FRAMES)
69
+ spinner = self._FRAMES[idx]
70
+ text = f"{self._prefix}{spinner} {self._label}"
71
+
72
+ if avail > 3 and len(text) > avail:
73
+ return Text(text[: avail - 3] + "...")
74
+ return Text(text)
75
+
76
+
27
77
  class AgentNameButton(Button):
28
78
  """Button that emits a message on double-click."""
29
79
 
@@ -39,9 +89,173 @@ class AgentNameButton(Button):
39
89
  self.post_message(self.DoubleClicked(self, self.name or ""))
40
90
 
41
91
 
92
+ class _TerminalRowWidget(Horizontal):
93
+ def __init__(
94
+ self,
95
+ *,
96
+ terminal_id: str,
97
+ term_safe_id: str,
98
+ prefix: str,
99
+ title: str,
100
+ running_agent: str | None,
101
+ pane_current_command: str | None,
102
+ is_active: bool,
103
+ ) -> None:
104
+ super().__init__(classes="terminal-row")
105
+
106
+ switch_classes = "terminal-switch active-terminal" if is_active else "terminal-switch"
107
+
108
+ if title:
109
+ label = f"{prefix}{title}"
110
+ switch_btn: Button = TruncButton(
111
+ label,
112
+ id=f"switch-{term_safe_id}",
113
+ name=terminal_id,
114
+ classes=switch_classes,
115
+ )
116
+ switch_btn.tooltip = title
117
+ elif running_agent:
118
+ provider = (running_agent or "").strip().lower()
119
+ provider_label = (running_agent or "").strip().capitalize()
120
+ if provider in {"codex", "claude"}:
121
+ switch_btn = SpinnerPrefixButton(
122
+ prefix=prefix,
123
+ label=provider_label,
124
+ id=f"switch-{term_safe_id}",
125
+ name=terminal_id,
126
+ classes=f"{switch_classes} spinner",
127
+ )
128
+ else:
129
+ switch_btn = TruncButton(
130
+ f"{prefix}{provider_label}",
131
+ id=f"switch-{term_safe_id}",
132
+ name=terminal_id,
133
+ classes=switch_classes,
134
+ )
135
+ else:
136
+ display = (pane_current_command or "").strip() or "Terminal"
137
+ switch_btn = TruncButton(
138
+ f"{prefix}{display}",
139
+ id=f"switch-{term_safe_id}",
140
+ name=terminal_id,
141
+ classes=switch_classes,
142
+ )
143
+
144
+ self._switch_btn = switch_btn
145
+ self._close_btn = Button(
146
+ "✕",
147
+ id=f"close-{term_safe_id}",
148
+ name=terminal_id,
149
+ variant="error",
150
+ classes="terminal-close",
151
+ )
152
+
153
+ def compose(self) -> ComposeResult:
154
+ yield self._switch_btn
155
+ yield self._close_btn
156
+
157
+
158
+ class _TerminalConnectorWidget(Horizontal):
159
+ def __init__(self) -> None:
160
+ super().__init__(classes="terminal-row")
161
+
162
+ def compose(self) -> ComposeResult:
163
+ yield Button("│", classes="terminal-switch")
164
+
165
+
166
+ class _AgentBlockWidget(Container):
167
+ def __init__(self, *, agent: dict, active_terminal_id: str | None) -> None:
168
+ super().__init__(classes="agent-block")
169
+ self._agent = agent
170
+ self._active_terminal_id = active_terminal_id
171
+
172
+ def compose(self) -> ComposeResult:
173
+ agent = self._agent
174
+ safe_id = AgentsPanel._safe_id(agent["id"])
175
+
176
+ rule = Rule(line_style="solid")
177
+ rule.styles.color = "black"
178
+ yield rule
179
+
180
+ with Horizontal(classes="agent-row"):
181
+ name_label = Text(f"▸ {agent['name']}", style="bold")
182
+ agent_btn = AgentNameButton(
183
+ name_label,
184
+ id=f"agent-{safe_id}",
185
+ name=agent["id"],
186
+ classes="agent-name",
187
+ )
188
+ if agent.get("description"):
189
+ agent_btn.tooltip = agent["description"]
190
+ yield agent_btn
191
+
192
+ if agent.get("share_url"):
193
+ yield Button(
194
+ "Sync",
195
+ id=f"sync-{safe_id}",
196
+ name=agent["id"],
197
+ classes="agent-share",
198
+ )
199
+ yield Button(
200
+ "Link",
201
+ id=f"link-{safe_id}",
202
+ name=agent["id"],
203
+ classes="agent-share",
204
+ )
205
+ else:
206
+ yield Button(
207
+ "Share",
208
+ id=f"share-{safe_id}",
209
+ name=agent["id"],
210
+ classes="agent-share",
211
+ )
212
+
213
+ yield Button(
214
+ "+",
215
+ id=f"create-term-{safe_id}",
216
+ name=agent["id"],
217
+ classes="agent-create",
218
+ )
219
+ yield Button(
220
+ "✕",
221
+ id=f"delete-{safe_id}",
222
+ name=agent["id"],
223
+ variant="error",
224
+ classes="agent-delete",
225
+ )
226
+
227
+ terminals = agent.get("terminals") or []
228
+ if not terminals:
229
+ return
230
+
231
+ with Vertical(classes="terminal-list"):
232
+ for term_idx, term in enumerate(terminals):
233
+ is_last_term = term_idx == len(terminals) - 1
234
+ prefix = "└ " if is_last_term else "├ "
235
+
236
+ terminal_id = term["terminal_id"]
237
+ term_safe_id = AgentsPanel._safe_id(terminal_id)
238
+ is_active = terminal_id == self._active_terminal_id
239
+
240
+ yield _TerminalRowWidget(
241
+ terminal_id=terminal_id,
242
+ term_safe_id=term_safe_id,
243
+ prefix=prefix,
244
+ title=term.get("title", "") or "",
245
+ running_agent=term.get("running_agent"),
246
+ pane_current_command=term.get("pane_current_command"),
247
+ is_active=is_active,
248
+ )
249
+ if not is_last_term:
250
+ yield _TerminalConnectorWidget()
251
+
252
+
42
253
  class AgentsPanel(Container, can_focus=True):
43
254
  """Panel displaying agent profiles with their associated terminals."""
44
255
 
256
+ REFRESH_INTERVAL_SECONDS = 2.0
257
+ SPINNER_INTERVAL_SECONDS = 0.15
258
+
45
259
  DEFAULT_CSS = """
46
260
  AgentsPanel {
47
261
  height: 100%;
@@ -55,10 +269,11 @@ class AgentsPanel(Container, can_focus=True):
55
269
 
56
270
  AgentsPanel .summary {
57
271
  height: auto;
58
- margin: 0 0 1 0;
272
+ margin: 0;
59
273
  padding: 0;
60
274
  background: transparent;
61
275
  border: none;
276
+ align: right middle;
62
277
  }
63
278
 
64
279
  AgentsPanel Button {
@@ -85,15 +300,22 @@ class AgentsPanel(Container, can_focus=True):
85
300
  background: transparent;
86
301
  }
87
302
 
303
+ AgentsPanel Rule {
304
+ margin: 0;
305
+ }
306
+
88
307
  AgentsPanel .agent-row {
308
+ height: 2;
309
+ margin: 0;
310
+ }
311
+
312
+ AgentsPanel .agent-block {
89
313
  height: auto;
90
- min-height: 2;
91
- margin: 0 0 0 0;
92
314
  }
93
315
 
94
316
  AgentsPanel .agent-row Button.agent-name {
95
317
  width: 1fr;
96
- height: 2;
318
+ height: 1;
97
319
  margin: 0;
98
320
  padding: 0 1;
99
321
  text-align: left;
@@ -101,28 +323,28 @@ class AgentsPanel(Container, can_focus=True):
101
323
  }
102
324
 
103
325
  AgentsPanel .agent-row Button.agent-create {
104
- width: auto;
105
- min-width: 8;
106
- height: 2;
107
- margin-left: 1;
108
- padding: 0 1;
326
+ width: 3;
327
+ min-width: 3;
328
+ height: 1;
329
+ margin-left: 0;
330
+ padding: 0;
109
331
  content-align: center middle;
110
332
  }
111
333
 
112
334
  AgentsPanel .agent-row Button.agent-delete {
113
335
  width: 3;
114
336
  min-width: 3;
115
- height: 2;
116
- margin-left: 1;
337
+ height: 1;
338
+ margin-left: 0;
117
339
  padding: 0;
118
340
  content-align: center middle;
119
341
  }
120
342
 
121
343
  AgentsPanel .agent-row Button.agent-share {
122
344
  width: auto;
123
- min-width: 8;
124
- height: 2;
125
- margin-left: 1;
345
+ min-width: 6;
346
+ height: 1;
347
+ margin-left: 0;
126
348
  padding: 0 1;
127
349
  content-align: center middle;
128
350
  }
@@ -136,25 +358,34 @@ class AgentsPanel(Container, can_focus=True):
136
358
  }
137
359
 
138
360
  AgentsPanel .terminal-row {
139
- height: auto;
140
- min-height: 2;
361
+ height: 1;
141
362
  margin: 0;
142
363
  }
143
364
 
144
365
  AgentsPanel .terminal-row Button.terminal-switch {
145
366
  width: 1fr;
146
- height: 2;
367
+ height: 1;
147
368
  margin: 0;
148
369
  padding: 0 1;
149
370
  text-align: left;
150
- content-align: left top;
371
+ content-align: left middle;
372
+ text-opacity: 60%;
373
+ }
374
+
375
+ AgentsPanel .terminal-row Button.terminal-switch:hover {
376
+ text-opacity: 100%;
377
+ }
378
+
379
+ AgentsPanel .terminal-row Button.active-terminal {
380
+ text-opacity: 100%;
381
+ text-style: bold;
151
382
  }
152
383
 
153
384
  AgentsPanel .terminal-row Button.terminal-close {
154
385
  width: 3;
155
386
  min-width: 3;
156
- height: 2;
157
- margin-left: 1;
387
+ height: 1;
388
+ margin: 0;
158
389
  padding: 0;
159
390
  content-align: center middle;
160
391
  }
@@ -164,28 +395,58 @@ class AgentsPanel(Container, can_focus=True):
164
395
  super().__init__()
165
396
  self._refresh_lock = asyncio.Lock()
166
397
  self._agents: list[dict] = []
398
+ self._active_terminal_id: str | None = None
167
399
  self._rendered_fingerprint: str = ""
400
+ self._switch_worker: Optional[Worker] = None
401
+ self._switch_seq: int = 0
168
402
  self._refresh_worker: Optional[Worker] = None
403
+ self._render_worker: Optional[Worker] = None
404
+ self._pending_render: bool = False
405
+ self._pending_render_reason: str | None = None
169
406
  self._share_worker: Optional[Worker] = None
170
407
  self._sync_worker: Optional[Worker] = None
171
408
  self._share_agent_id: Optional[str] = None
172
409
  self._sync_agent_id: Optional[str] = None
173
410
  self._refresh_timer = None
411
+ self._spinner_timer = None
412
+ self._last_refresh_error_at: float | None = None
413
+ self._last_render_started_at: float | None = None
414
+ self._last_render_completed_at: float | None = None
415
+ self._process_snapshot: _ProcessSnapshot | None = None
174
416
 
175
417
  def compose(self) -> ComposeResult:
176
418
  with Horizontal(classes="summary"):
177
- yield Button("+ Create Agent", id="create-agent", variant="primary")
419
+ yield Button("Add Agent", id="create-agent", variant="primary")
178
420
  with Vertical(id="agents-list", classes="list"):
179
- yield Static("No agents yet. Click 'Create Agent' to add one.")
421
+ yield Static("No agents yet. Click 'Add Agent' to add one.")
422
+
423
+ def on_mount(self) -> None:
424
+ btn = self.query_one("#create-agent", Button)
425
+ btn.styles.border = ("round", "black")
426
+ btn.styles.text_align = "center"
427
+ btn.styles.content_align = ("center", "middle")
180
428
 
181
429
  def on_show(self) -> None:
182
430
  if self._refresh_timer is None:
183
- self._refresh_timer = self.set_interval(1.0, self._on_refresh_timer)
431
+ # Refresh frequently, but avoid hammering SQLite/tmux (can cause transient empty UI).
432
+ self._refresh_timer = self.set_interval(
433
+ self.REFRESH_INTERVAL_SECONDS, self._on_refresh_timer
434
+ )
184
435
  else:
185
436
  try:
186
437
  self._refresh_timer.resume()
187
438
  except Exception:
188
439
  pass
440
+
441
+ if self._spinner_timer is None:
442
+ self._spinner_timer = self.set_interval(
443
+ self.SPINNER_INTERVAL_SECONDS, self._on_spinner_timer
444
+ )
445
+ else:
446
+ try:
447
+ self._spinner_timer.resume()
448
+ except Exception:
449
+ pass
189
450
  self.refresh_data()
190
451
 
191
452
  def on_hide(self) -> None:
@@ -194,10 +455,24 @@ class AgentsPanel(Container, can_focus=True):
194
455
  self._refresh_timer.pause()
195
456
  except Exception:
196
457
  pass
458
+ if self._spinner_timer is not None:
459
+ try:
460
+ self._spinner_timer.pause()
461
+ except Exception:
462
+ pass
197
463
 
198
464
  def _on_refresh_timer(self) -> None:
199
465
  self.refresh_data()
200
466
 
467
+ def _on_spinner_timer(self) -> None:
468
+ if not self.display:
469
+ return
470
+ try:
471
+ for btn in self.query("Button.spinner"):
472
+ btn.refresh()
473
+ except Exception:
474
+ pass
475
+
201
476
  def refresh_data(self) -> None:
202
477
  if not self.display:
203
478
  return
@@ -210,102 +485,350 @@ class AgentsPanel(Container, can_focus=True):
210
485
  self._collect_agents, thread=True, exit_on_error=False
211
486
  )
212
487
 
213
- def _collect_agents(self) -> list[dict]:
214
- """Collect agent info with their terminals."""
215
- agents = []
488
+ def diagnostics_state(self) -> dict[str, object]:
489
+ """Small, safe snapshot for watchdog logging (never raises)."""
490
+ state: dict[str, object] = {}
216
491
  try:
217
- from ...db import get_database
492
+ state["agents_count"] = int(len(self._agents))
493
+ state["rendered_fingerprint_len"] = int(len(self._rendered_fingerprint))
494
+ state["refresh_worker_state"] = str(
495
+ getattr(getattr(self, "_refresh_worker", None), "state", None)
496
+ )
497
+ state["render_worker_state"] = str(
498
+ getattr(getattr(self, "_render_worker", None), "state", None)
499
+ )
500
+ state["pending_render"] = bool(self._pending_render)
501
+ state["last_refresh_error_at"] = self._last_refresh_error_at
502
+ state["last_render_started_at"] = self._last_render_started_at
503
+ state["last_render_completed_at"] = self._last_render_completed_at
504
+ except Exception:
505
+ pass
506
+ try:
507
+ container = self.query_one("#agents-list", Vertical)
508
+ state["agents_list_children"] = int(len(getattr(container, "children", []) or []))
509
+ except Exception:
510
+ pass
511
+ return state
218
512
 
219
- db = get_database(read_only=True)
220
- agent_infos = db.list_agent_info()
513
+ def _ui_blank(self) -> bool:
514
+ try:
515
+ container = self.query_one("#agents-list", Vertical)
516
+ return len(container.children) == 0
517
+ except Exception:
518
+ return False
519
+
520
+ def _schedule_render(self, *, reason: str) -> None:
521
+ if not self.display:
522
+ return
523
+
524
+ if self._render_worker is not None and self._render_worker.state in (
525
+ WorkerState.PENDING,
526
+ WorkerState.RUNNING,
527
+ ):
528
+ self._pending_render = True
529
+ self._pending_render_reason = reason
530
+ return
531
+
532
+ self._pending_render = False
533
+ self._pending_render_reason = None
534
+ self._last_render_started_at = time.monotonic()
535
+ self._render_worker = self.run_worker(
536
+ self._render_agents(), group="agents-render", exit_on_error=False
537
+ )
538
+
539
+ def force_render(self, *, reason: str) -> None:
540
+ """Force a re-render even if the collected data didn't change."""
541
+ # Reset fingerprint so the next refresh doesn't treat the UI as "up to date".
542
+ self._rendered_fingerprint = ""
543
+ if self._agents:
544
+ self._schedule_render(reason=reason)
545
+
546
+ @staticmethod
547
+ def _detect_running_agent(
548
+ pane_pid: int | None, snapshot: _ProcessSnapshot | None
549
+ ) -> str | None:
550
+ """Detect whether a known agent process exists under a tmux pane PID.
551
+
552
+ Uses a cached process snapshot built once per refresh to avoid spawning `ps`
553
+ repeatedly for every terminal row.
554
+ """
555
+ if not pane_pid or snapshot is None:
556
+ return None
557
+
558
+ children = snapshot.children
559
+ comms = snapshot.comms
560
+ stack = [pane_pid]
561
+ seen: set[int] = set()
562
+ while stack:
563
+ p = stack.pop()
564
+ if p in seen:
565
+ continue
566
+ seen.add(p)
567
+ comm = (comms.get(p) or "").lower()
568
+ for kw in ("claude", "codex", "opencode"):
569
+ if kw in comm:
570
+ return kw
571
+ stack.extend(children.get(p, []))
572
+ return None
573
+
574
+ @staticmethod
575
+ def _normalize_tty(tty: str) -> str:
576
+ s = (tty or "").strip()
577
+ if not s:
578
+ return ""
579
+ if s.startswith("/dev/"):
580
+ s = s[len("/dev/") :]
581
+ return s.strip()
582
+
583
+ def _get_process_snapshot(self, *, pane_ttys: set[str]) -> _ProcessSnapshot | None:
584
+ """Return a cached process snapshot scoped to the given TTY set.
585
+
586
+ TTL is aligned with the panel refresh interval so the snapshot is at most one refresh stale.
587
+ """
588
+ ttys = sorted(
589
+ {self._normalize_tty(t) for t in (pane_ttys or set()) if self._normalize_tty(t)}
590
+ )
591
+ if not ttys:
592
+ return None
593
+ tty_key = ",".join(ttys)
594
+
595
+ now = time.monotonic()
596
+ cached = self._process_snapshot
597
+ if (
598
+ cached
599
+ and cached.tty_key == tty_key
600
+ and (now - cached.created_at_monotonic) < float(self.REFRESH_INTERVAL_SECONDS)
601
+ ):
602
+ return cached
603
+
604
+ try:
605
+ import subprocess
606
+
607
+ # Prefer TTY-limited `ps` to keep output small. BSD ps supports comma-separated TTY list.
608
+ result = subprocess.run(
609
+ ["ps", "-o", "pid=,ppid=,comm=", "-t", tty_key],
610
+ capture_output=True,
611
+ text=True,
612
+ timeout=2,
613
+ )
614
+ stdout = result.stdout or ""
615
+ if result.returncode != 0 or not stdout.strip():
616
+ # Fallback: some environments may not accept `-t` list; fall back to a single
617
+ # snapshot of all processes and filter by TTY.
618
+ result = subprocess.run(
619
+ ["ps", "-axo", "pid=,ppid=,tty=,comm="],
620
+ capture_output=True,
621
+ text=True,
622
+ timeout=2,
623
+ )
624
+ stdout = result.stdout or ""
625
+
626
+ allowed = set(ttys)
627
+ filtered_lines: list[str] = []
628
+ for line in stdout.splitlines():
629
+ parts = line.split(None, 3)
630
+ if len(parts) < 4:
631
+ continue
632
+ tty = self._normalize_tty(parts[2])
633
+ if tty and tty in allowed:
634
+ filtered_lines.append(f"{parts[0]} {parts[1]} {parts[3]}")
635
+ stdout = "\n".join(filtered_lines)
636
+
637
+ children: dict[int, list[int]] = {}
638
+ comms: dict[int, str] = {}
639
+ for line in stdout.splitlines():
640
+ parts = line.split(None, 2)
641
+ if len(parts) < 3:
642
+ continue
643
+ try:
644
+ pid, ppid = int(parts[0]), int(parts[1])
645
+ except ValueError:
646
+ continue
647
+ children.setdefault(ppid, []).append(pid)
648
+ comms[pid] = parts[2]
649
+
650
+ snap = _ProcessSnapshot(
651
+ created_at_monotonic=now, tty_key=tty_key, children=children, comms=comms
652
+ )
653
+ self._process_snapshot = snap
654
+ return snap
655
+ except Exception:
656
+ return None
657
+
658
+ def _collect_agents(self) -> dict:
659
+ """Collect agent info with their terminals."""
660
+ agents: list[dict] = []
661
+
662
+ from ...db import get_database
663
+
664
+ # Dashboard should prefer correctness/stability over ultra-low lock timeouts.
665
+ db = get_database(read_only=True, connect_timeout_seconds=2.0)
666
+
667
+ # Critical: if this fails, let it surface as a worker ERROR so we can keep
668
+ # the last rendered UI instead of flashing an empty agent list.
669
+ agent_infos = db.list_agent_info()
670
+
671
+ # Best-effort: missing pieces should degrade gracefully (names still render).
672
+ try:
221
673
  active_terminals = db.list_agents(status="active", limit=1000)
674
+ except Exception as e:
675
+ logger.debug(f"Failed to list active terminals: {e}")
676
+ active_terminals = []
222
677
 
223
- # Latest window links per terminal (V23)
678
+ try:
224
679
  latest_links = db.list_latest_window_links(limit=2000)
225
- link_by_terminal = {l.terminal_id: l for l in latest_links if l.terminal_id}
680
+ except Exception:
681
+ latest_links = []
682
+ link_by_terminal = {
683
+ l.terminal_id: l for l in latest_links if getattr(l, "terminal_id", None)
684
+ }
226
685
 
227
- # Get tmux windows to retrieve window id and fallback session_id
686
+ tmux_windows_ok = True
687
+ try:
228
688
  tmux_windows = tmux_manager.list_inner_windows()
229
- terminal_to_window = {
230
- w.terminal_id: w for w in tmux_windows if w.terminal_id
231
- }
689
+ except Exception as e:
690
+ logger.debug(f"Failed to list tmux windows: {e}")
691
+ tmux_windows_ok = False
692
+ tmux_windows = []
693
+ terminal_to_window = {
694
+ w.terminal_id: w for w in tmux_windows if getattr(w, "terminal_id", None)
695
+ }
232
696
 
233
- # Collect all session_ids for title lookup
234
- session_ids: list[str] = []
235
- for t in active_terminals:
236
- link = link_by_terminal.get(t.id)
237
- if link and link.session_id:
238
- session_ids.append(link.session_id)
239
- continue
697
+ # If we are running inside the managed tmux dashboard environment, only show terminals
698
+ # that still exist in the inner tmux server. This prevents stale buttons when a user
699
+ # exits a shell directly (tmux window disappears but DB record may still be "active").
700
+ try:
701
+ if tmux_manager.managed_env_enabled() and tmux_windows_ok:
702
+ present = {tid for tid in terminal_to_window.keys() if tid}
703
+ if present:
704
+ active_terminals = [t for t in active_terminals if getattr(t, "id", None) in present]
705
+ else:
706
+ # Inner session exists but no tracked terminals are present.
707
+ active_terminals = []
708
+ except Exception:
709
+ pass
710
+
711
+ pane_ttys: set[str] = set()
712
+ for w in tmux_windows:
713
+ if getattr(w, "pane_pid", None) and getattr(w, "pane_tty", None):
714
+ pane_ttys.add(str(w.pane_tty))
715
+ ps_snapshot = self._get_process_snapshot(pane_ttys=pane_ttys) if pane_ttys else None
716
+
717
+ # Detect currently active terminal
718
+ active_window = next((w for w in tmux_windows if w.active), None)
719
+ active_terminal_id = active_window.terminal_id if active_window else None
720
+
721
+ # Collect all session_ids for title lookup
722
+ session_ids: list[str] = []
723
+ for t in active_terminals:
724
+ link = link_by_terminal.get(t.id)
725
+ if link and getattr(link, "session_id", None):
726
+ session_ids.append(link.session_id)
727
+ continue
728
+ window = terminal_to_window.get(t.id)
729
+ if window and getattr(window, "session_id", None):
730
+ session_ids.append(window.session_id)
731
+ continue
732
+ # Fallback: agent record (works even when WindowLink isn't available yet / at all).
733
+ agent_session_id = getattr(t, "session_id", None)
734
+ if agent_session_id:
735
+ session_ids.append(agent_session_id)
736
+
737
+ titles: dict[str, str] = {}
738
+ session_agent_id: dict[str, str] = {}
739
+ try:
740
+ uniq_session_ids = sorted({sid for sid in session_ids if sid})
741
+ if uniq_session_ids:
742
+ sessions = db.get_sessions_by_ids(uniq_session_ids)
743
+ for s in sessions:
744
+ sid = getattr(s, "id", None)
745
+ if not sid:
746
+ continue
747
+ title = (getattr(s, "session_title", "") or "").strip()
748
+ if title:
749
+ titles[sid] = title
750
+ agent_id = getattr(s, "agent_id", None)
751
+ if agent_id:
752
+ session_agent_id[sid] = agent_id
753
+ except Exception:
754
+ titles = {}
755
+ session_agent_id = {}
756
+
757
+ # Map agent_info.id -> list of terminals
758
+ agent_to_terminals: dict[str, list[dict]] = {}
759
+ for t in active_terminals:
760
+ # Find which agent_info this terminal belongs to
761
+ agent_info_id = None
762
+
763
+ link = link_by_terminal.get(t.id)
764
+
765
+ # Method 1: Check source field for "agent:{agent_info_id}" format
766
+ source = t.source or ""
767
+ if source.startswith("agent:"):
768
+ agent_info_id = source[6:]
769
+
770
+ # Method 2: WindowLink agent_id
771
+ if not agent_info_id and link and getattr(link, "agent_id", None):
772
+ agent_info_id = link.agent_id
773
+
774
+ # Method 3: Fallback - check tmux window's session.agent_id
775
+ if not agent_info_id:
240
776
  window = terminal_to_window.get(t.id)
241
- if window and window.session_id:
242
- session_ids.append(window.session_id)
243
-
244
- titles = self._fetch_session_titles(session_ids)
245
-
246
- # Map agent_info.id -> list of terminals
247
- agent_to_terminals: dict[str, list[dict]] = {}
248
- for t in active_terminals:
249
- # Find which agent_info this terminal belongs to
250
- agent_info_id = None
251
-
252
- link = link_by_terminal.get(t.id)
253
-
254
- # Method 1: Check source field for "agent:{agent_info_id}" format
255
- source = t.source or ""
256
- if source.startswith("agent:"):
257
- agent_info_id = source[6:]
258
-
259
- # Method 2: WindowLink agent_id
260
- if not agent_info_id and link and link.agent_id:
261
- agent_info_id = link.agent_id
262
-
263
- # Method 3: Fallback - check tmux window's session.agent_id
264
- if not agent_info_id:
265
- window = terminal_to_window.get(t.id)
266
- if window and window.session_id:
267
- session = db.get_session_by_id(window.session_id)
268
- if session:
269
- agent_info_id = session.agent_id
270
-
271
- if agent_info_id:
272
- if agent_info_id not in agent_to_terminals:
273
- agent_to_terminals[agent_info_id] = []
274
-
275
- # Get session_id from windowlink (preferred) or tmux window
276
- window = terminal_to_window.get(t.id)
277
- session_id = (
278
- link.session_id if link and link.session_id else (window.session_id if window else None)
279
- )
280
- title = titles.get(session_id, "") if session_id else ""
281
-
282
- agent_to_terminals[agent_info_id].append(
283
- {
284
- "terminal_id": t.id,
285
- "session_id": session_id,
286
- "provider": link.provider if link and link.provider else (t.provider or ""),
287
- "session_type": t.session_type or "",
288
- "title": title,
289
- "cwd": t.cwd or "",
290
- }
777
+ if window and getattr(window, "session_id", None):
778
+ agent_info_id = session_agent_id.get(window.session_id) or None
779
+
780
+ if agent_info_id:
781
+ agent_to_terminals.setdefault(agent_info_id, [])
782
+
783
+ # Get session_id from windowlink (preferred) or tmux window
784
+ window = terminal_to_window.get(t.id)
785
+ session_id = (
786
+ link.session_id
787
+ if link and getattr(link, "session_id", None)
788
+ else (
789
+ window.session_id
790
+ if window and getattr(window, "session_id", None)
791
+ else getattr(t, "session_id", None)
291
792
  )
793
+ )
794
+ title = titles.get(session_id, "") if session_id else ""
292
795
 
293
- for info in agent_infos:
294
- terminals = agent_to_terminals.get(info.id, [])
295
- agents.append(
296
- {
297
- "id": info.id,
298
- "name": info.name,
299
- "description": info.description or "",
300
- "terminals": terminals,
301
- "share_url": getattr(info, "share_url", None),
302
- "last_synced_at": getattr(info, "last_synced_at", None),
303
- }
796
+ pane_cmd = window.pane_current_command if window else None
797
+ provider = (
798
+ link.provider
799
+ if link and getattr(link, "provider", None)
800
+ else (t.provider or "")
304
801
  )
305
- except Exception as e:
306
- logger.debug(f"Failed to collect agents: {e}")
307
- return agents
802
+ pane_pid = window.pane_pid if window else None
803
+ running_agent = self._detect_running_agent(pane_pid, ps_snapshot)
804
+
805
+ entry = {
806
+ "terminal_id": t.id,
807
+ "window_id": window.window_id if window else None,
808
+ "session_id": session_id,
809
+ "provider": provider,
810
+ "session_type": t.session_type or "",
811
+ "title": title,
812
+ "cwd": t.cwd or "",
813
+ "running_agent": running_agent,
814
+ "pane_current_command": pane_cmd,
815
+ }
816
+ agent_to_terminals[agent_info_id].append(entry)
817
+
818
+ for info in agent_infos:
819
+ terminals = agent_to_terminals.get(info.id, [])
820
+ agents.append(
821
+ {
822
+ "id": info.id,
823
+ "name": info.name,
824
+ "description": info.description or "",
825
+ "terminals": terminals,
826
+ "share_url": getattr(info, "share_url", None),
827
+ "last_synced_at": getattr(info, "last_synced_at", None),
828
+ }
829
+ )
308
830
 
831
+ return {"agents": agents, "active_terminal_id": active_terminal_id}
309
832
 
310
833
  @staticmethod
311
834
  def _fingerprint(agents: list[dict]) -> str:
@@ -316,21 +839,122 @@ class AgentsPanel(Container, can_focus=True):
316
839
  return ""
317
840
 
318
841
  def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
842
+ # Handle switch worker
843
+ if self._switch_worker is not None and event.worker is self._switch_worker:
844
+ if event.state == WorkerState.SUCCESS:
845
+ result = self._switch_worker.result or {}
846
+ if isinstance(result, dict):
847
+ seq = int(result.get("seq", 0) or 0)
848
+ if seq != self._switch_seq:
849
+ return
850
+ terminal_id = str(result.get("terminal_id", "") or "").strip() or None
851
+ ok = bool(result.get("ok", False))
852
+ if ok and terminal_id:
853
+ self._active_terminal_id = terminal_id
854
+ self._update_active_terminal_ui(terminal_id)
855
+ # Prevent the next refresh from forcing a full re-render just due to
856
+ # active-terminal changes.
857
+ self._rendered_fingerprint = (
858
+ self._fingerprint(self._agents) + f"|active:{self._active_terminal_id}"
859
+ )
860
+ else:
861
+ msg = str(result.get("error", "") or "Failed to switch").strip()
862
+ self.app.notify(msg, title="Agent", severity="error")
863
+ self.refresh_data()
864
+ elif event.state == WorkerState.ERROR:
865
+ err = self._switch_worker.error
866
+ msg = "Failed to switch"
867
+ if isinstance(err, BaseException):
868
+ msg = f"Failed to switch: {err}"
869
+ elif err:
870
+ msg = f"Failed to switch: {err}"
871
+ self.app.notify(msg, title="Agent", severity="error")
872
+ self.refresh_data()
873
+
874
+ if event.state in {WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED}:
875
+ self._switch_worker = None
876
+ return
877
+
319
878
  # Handle refresh worker
320
879
  if self._refresh_worker is not None and event.worker is self._refresh_worker:
321
880
  if event.state == WorkerState.ERROR:
322
- self._agents = []
881
+ # Keep the last successfully-rendered list on refresh errors to avoid
882
+ # flashing an empty Agents tab during transient tmux/SQLite hiccups.
883
+ self._last_refresh_error_at = time.monotonic()
884
+ err = self._refresh_worker.error
885
+ if isinstance(err, BaseException):
886
+ logger.warning(
887
+ "Agents refresh failed",
888
+ exc_info=(type(err), err, err.__traceback__),
889
+ )
890
+ else:
891
+ logger.warning(f"Agents refresh failed: {err}")
892
+ return
323
893
  elif event.state == WorkerState.SUCCESS:
324
- self._agents = self._refresh_worker.result or []
894
+ prev_agents = self._agents
895
+ prev_active = self._active_terminal_id
896
+ payload = self._refresh_worker.result or {}
897
+ if isinstance(payload, dict) and "agents" in payload:
898
+ self._agents = payload.get("agents") or []
899
+ self._active_terminal_id = payload.get("active_terminal_id") or None
900
+ else:
901
+ self._agents = payload or []
902
+ self._last_refresh_error_at = None
325
903
  else:
326
904
  return
327
- fp = self._fingerprint(self._agents)
328
- if fp == self._rendered_fingerprint:
905
+
906
+ # Fast path: only the active terminal changed; update button classes in-place
907
+ # to avoid full re-render flicker / input lag.
908
+ try:
909
+ prev_agents_fp = self._fingerprint(prev_agents)
910
+ new_agents_fp = self._fingerprint(self._agents)
911
+ if (
912
+ prev_agents_fp == new_agents_fp
913
+ and prev_active != self._active_terminal_id
914
+ and not self._ui_blank()
915
+ ):
916
+ self._update_active_terminal_ui(self._active_terminal_id)
917
+ self._rendered_fingerprint = new_agents_fp + f"|active:{self._active_terminal_id}"
918
+ return
919
+ except Exception:
920
+ new_agents_fp = self._fingerprint(self._agents)
921
+
922
+ fp = new_agents_fp + f"|active:{self._active_terminal_id}"
923
+ # Important: the UI can become blank due to cancellation or transient Textual issues.
924
+ # If data didn't change but the widget tree is empty, force a re-render.
925
+ should_render = (fp != self._rendered_fingerprint) or (
926
+ self._agents and self._ui_blank()
927
+ )
928
+ if not should_render:
329
929
  return # nothing changed – skip re-render to avoid flicker
330
930
  self._rendered_fingerprint = fp
331
- self.run_worker(
332
- self._render_agents(), group="agents-render", exclusive=True
333
- )
931
+ self._schedule_render(reason="refresh")
932
+ return
933
+
934
+ # Handle render worker
935
+ if self._render_worker is not None and event.worker is self._render_worker:
936
+ if event.state == WorkerState.ERROR:
937
+ err = self._render_worker.error
938
+ if isinstance(err, BaseException):
939
+ logger.warning(
940
+ "Agents render failed",
941
+ exc_info=(type(err), err, err.__traceback__),
942
+ )
943
+ else:
944
+ logger.warning(f"Agents render failed: {err}")
945
+ # Force next refresh to re-render even if data is unchanged.
946
+ self._rendered_fingerprint = ""
947
+ elif event.state == WorkerState.SUCCESS:
948
+ self._last_render_completed_at = time.monotonic()
949
+ elif event.state == WorkerState.CANCELLED:
950
+ self._rendered_fingerprint = ""
951
+
952
+ if event.state in {WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED}:
953
+ self._last_render_completed_at = time.monotonic()
954
+ self._render_worker = None
955
+ if self._pending_render:
956
+ reason = self._pending_render_reason or "pending"
957
+ self._schedule_render(reason=reason)
334
958
  return
335
959
 
336
960
  # Handle share worker
@@ -345,146 +969,36 @@ class AgentsPanel(Container, can_focus=True):
345
969
  async with self._refresh_lock:
346
970
  try:
347
971
  container = self.query_one("#agents-list", Vertical)
348
- except Exception:
349
- return
350
-
351
- await container.remove_children()
352
-
353
- if not self._agents:
354
- await container.mount(
355
- Static("No agents yet. Click 'Create Agent' to add one.")
356
- )
357
- return
972
+ with self.app.batch_update():
973
+ await container.remove_children()
358
974
 
359
- for agent in self._agents:
360
- safe_id = self._safe_id(agent["id"])
361
-
362
- # Agent row with name, create button, and delete button
363
- row = Horizontal(classes="agent-row")
364
- await container.mount(row)
365
-
366
- # Agent name button
367
- name_label = Text(agent["name"], style="bold")
368
- terminal_count = len(agent["terminals"])
369
- if terminal_count > 0:
370
- name_label.append(f" ({terminal_count})", style="dim")
371
-
372
- await row.mount(
373
- AgentNameButton(
374
- name_label,
375
- id=f"agent-{safe_id}",
376
- name=agent["id"],
377
- classes="agent-name",
378
- )
379
- )
380
-
381
- # Share or Sync button (Sync if agent already has a share_url)
382
- if agent.get("share_url"):
383
- await row.mount(
384
- Button(
385
- "Sync",
386
- id=f"sync-{safe_id}",
387
- name=agent["id"],
388
- classes="agent-share",
975
+ if not self._agents:
976
+ await container.mount(
977
+ Static("No agents yet. Click 'Add Agent' to add one.")
389
978
  )
390
- )
391
- # Link button to copy share URL to clipboard
392
- await row.mount(
393
- Button(
394
- "Link",
395
- id=f"link-{safe_id}",
396
- name=agent["id"],
397
- classes="agent-share",
398
- )
399
- )
400
- else:
401
- await row.mount(
402
- Button(
403
- "Share",
404
- id=f"share-{safe_id}",
405
- name=agent["id"],
406
- classes="agent-share",
407
- )
408
- )
979
+ return
409
980
 
410
- # Create terminal button
411
- await row.mount(
412
- Button(
413
- "+ Term",
414
- id=f"create-term-{safe_id}",
415
- name=agent["id"],
416
- classes="agent-create",
417
- )
418
- )
419
-
420
- # Delete agent button
421
- await row.mount(
422
- Button(
423
- "✕",
424
- id=f"delete-{safe_id}",
425
- name=agent["id"],
426
- variant="error",
427
- classes="agent-delete",
981
+ blocks = [
982
+ _AgentBlockWidget(agent=agent, active_terminal_id=self._active_terminal_id)
983
+ for agent in self._agents
984
+ ]
985
+ await container.mount_all(blocks)
986
+ except Exception:
987
+ logger.exception("Failed to render agents list")
988
+ try:
989
+ container = self.query_one("#agents-list", Vertical)
990
+ await container.remove_children()
991
+ await container.mount(
992
+ Static("Agents UI error (see ~/.aline/.logs/dashboard.log)")
428
993
  )
429
- )
430
-
431
- # Terminal list (indented under agent)
432
- if agent["terminals"]:
433
- term_list = Vertical(classes="terminal-list")
434
- await container.mount(term_list)
435
-
436
- for term in agent["terminals"]:
437
- term_safe_id = self._safe_id(term["terminal_id"])
438
- term_row = Horizontal(classes="terminal-row")
439
- await term_list.mount(term_row)
440
-
441
- label = self._make_terminal_label(term)
442
- await term_row.mount(
443
- Button(
444
- label,
445
- id=f"switch-{term_safe_id}",
446
- name=term["terminal_id"],
447
- classes="terminal-switch",
448
- )
449
- )
450
- await term_row.mount(
451
- Button(
452
- "✕",
453
- id=f"close-{term_safe_id}",
454
- name=term["terminal_id"],
455
- variant="error",
456
- classes="terminal-close",
457
- )
458
- )
459
-
460
- def _make_terminal_label(self, term: dict) -> Text:
461
- """Generate label for a terminal."""
462
- provider = term.get("provider", "")
463
- session_id = term.get("session_id", "")
464
- title = term.get("title", "")
465
-
466
- label = Text(no_wrap=True, overflow="ellipsis")
467
-
468
- # First line: title or provider
469
- if title:
470
- label.append(title)
471
- elif provider:
472
- label.append(provider.capitalize())
473
- else:
474
- label.append("Terminal")
475
-
476
- label.append("\n")
477
-
478
- # Second line: [provider] session_id
479
- if provider:
480
- detail = f"[{provider.capitalize()}]"
481
- else:
482
- detail = ""
483
- if session_id:
484
- detail = f"{detail} #{self._short_id(session_id)}"
485
-
486
- label.append(detail, style="dim")
487
- return label
994
+ except Exception:
995
+ pass
996
+ return
997
+ except asyncio.CancelledError:
998
+ # Best-effort: avoid leaving the UI in a permanently blank state.
999
+ logger.warning("Agents render cancelled")
1000
+ self._rendered_fingerprint = ""
1001
+ return
488
1002
 
489
1003
  @staticmethod
490
1004
  def _short_id(val: str | None) -> str:
@@ -532,6 +1046,21 @@ class AgentsPanel(Container, can_focus=True):
532
1046
  pass
533
1047
  return None
534
1048
 
1049
+ def _update_active_terminal_ui(self, terminal_id: str | None) -> None:
1050
+ """Update just the active-terminal button classes (no full re-render)."""
1051
+ try:
1052
+ for btn in self.query("Button.terminal-switch"):
1053
+ btn_id = btn.id or ""
1054
+ if not btn_id.startswith("switch-"):
1055
+ continue
1056
+ is_active = bool(terminal_id and (btn.name == terminal_id))
1057
+ if is_active:
1058
+ btn.add_class("active-terminal")
1059
+ else:
1060
+ btn.remove_class("active-terminal")
1061
+ except Exception:
1062
+ return
1063
+
535
1064
  async def on_button_pressed(self, event: Button.Pressed) -> None:
536
1065
  btn_id = event.button.id or ""
537
1066
 
@@ -570,7 +1099,7 @@ class AgentsPanel(Container, can_focus=True):
570
1099
 
571
1100
  if btn_id.startswith("switch-"):
572
1101
  terminal_id = event.button.name or ""
573
- await self._switch_to_terminal(terminal_id)
1102
+ self._request_switch_to_terminal(terminal_id)
574
1103
  return
575
1104
 
576
1105
  if btn_id.startswith("close-"):
@@ -613,9 +1142,7 @@ class AgentsPanel(Container, can_focus=True):
613
1142
  if result:
614
1143
  if result.get("imported"):
615
1144
  n = result.get("sessions_imported", 0)
616
- self.app.notify(
617
- f"Imported: {result.get('name')} ({n} sessions)", title="Agent"
618
- )
1145
+ self.app.notify(f"Imported: {result.get('name')} ({n} sessions)", title="Agent")
619
1146
  else:
620
1147
  self.app.notify(f"Created: {result.get('name')}", title="Agent")
621
1148
  self.refresh_data()
@@ -653,11 +1180,10 @@ class AgentsPanel(Container, can_focus=True):
653
1180
 
654
1181
  # Create the terminal with agent association
655
1182
  self.run_worker(
656
- self._do_create_terminal(
657
- agent_type, workspace, skip_permissions, no_track, agent_id
658
- ),
1183
+ self._do_create_terminal(agent_type, workspace, skip_permissions, no_track, agent_id),
659
1184
  group="terminal-create",
660
1185
  exclusive=True,
1186
+ exit_on_error=False,
661
1187
  )
662
1188
 
663
1189
  async def _do_create_terminal(
@@ -670,9 +1196,7 @@ class AgentsPanel(Container, can_focus=True):
670
1196
  ) -> None:
671
1197
  """Actually create the terminal with agent association."""
672
1198
  if agent_type == "claude":
673
- await self._create_claude_terminal(
674
- workspace, skip_permissions, no_track, agent_id
675
- )
1199
+ await self._create_claude_terminal(workspace, skip_permissions, no_track, agent_id)
676
1200
  elif agent_type == "codex":
677
1201
  await self._create_codex_terminal(workspace, no_track, agent_id)
678
1202
  elif agent_type == "opencode":
@@ -688,6 +1212,16 @@ class AgentsPanel(Container, can_focus=True):
688
1212
  """Create a Claude terminal associated with an agent."""
689
1213
  terminal_id = tmux_manager.new_terminal_id()
690
1214
  context_id = tmux_manager.new_context_id("cc")
1215
+ logger.info(
1216
+ "Create terminal requested: provider=claude terminal_id=%s agent_id=%s workspace=%s "
1217
+ "no_track=%s skip_permissions=%s context_id=%s",
1218
+ terminal_id,
1219
+ agent_id,
1220
+ workspace,
1221
+ no_track,
1222
+ skip_permissions,
1223
+ context_id,
1224
+ )
691
1225
 
692
1226
  # Prepare CODEX_HOME so user can run codex in this terminal
693
1227
  try:
@@ -731,6 +1265,13 @@ class AgentsPanel(Container, can_focus=True):
731
1265
  )
732
1266
 
733
1267
  if created:
1268
+ logger.info(
1269
+ "Create terminal success: provider=claude terminal_id=%s window_id=%s",
1270
+ terminal_id,
1271
+ created.window_id,
1272
+ )
1273
+ if not tmux_manager.focus_right_pane():
1274
+ logger.warning("Create terminal: focus_right_pane failed (provider=claude)")
734
1275
  # Store agent association in database with agent_info_id in source
735
1276
  try:
736
1277
  from ...db import get_database
@@ -748,13 +1289,10 @@ class AgentsPanel(Container, can_focus=True):
748
1289
  except Exception:
749
1290
  pass
750
1291
  else:
751
- self.app.notify(
752
- "Failed to create terminal", title="Agent", severity="error"
753
- )
1292
+ logger.warning("Create terminal failed: provider=claude terminal_id=%s", terminal_id)
1293
+ self.app.notify("Failed to create terminal", title="Agent", severity="error")
754
1294
 
755
- async def _create_codex_terminal(
756
- self, workspace: str, no_track: bool, agent_id: str
757
- ) -> None:
1295
+ async def _create_codex_terminal(self, workspace: str, no_track: bool, agent_id: str) -> None:
758
1296
  """Create a Codex terminal associated with an agent."""
759
1297
  try:
760
1298
  from ...db import get_database
@@ -779,6 +1317,15 @@ class AgentsPanel(Container, can_focus=True):
779
1317
 
780
1318
  terminal_id = tmux_manager.new_terminal_id()
781
1319
  context_id = tmux_manager.new_context_id("cx")
1320
+ logger.info(
1321
+ "Create terminal requested: provider=codex terminal_id=%s agent_id=%s workspace=%s "
1322
+ "no_track=%s context_id=%s",
1323
+ terminal_id,
1324
+ agent_id,
1325
+ workspace,
1326
+ no_track,
1327
+ context_id,
1328
+ )
782
1329
 
783
1330
  try:
784
1331
  from ...codex_home import prepare_codex_home
@@ -817,9 +1364,7 @@ class AgentsPanel(Container, can_focus=True):
817
1364
  except Exception:
818
1365
  pass
819
1366
 
820
- command = self._command_in_directory(
821
- tmux_manager.zsh_run_and_keep_open("codex"), workspace
822
- )
1367
+ command = self._command_in_directory(tmux_manager.zsh_run_and_keep_open("codex"), workspace)
823
1368
 
824
1369
  created = tmux_manager.create_inner_window(
825
1370
  "codex",
@@ -830,15 +1375,30 @@ class AgentsPanel(Container, can_focus=True):
830
1375
  no_track=no_track,
831
1376
  )
832
1377
 
833
- if not created:
834
- self.app.notify(
835
- "Failed to create terminal", title="Agent", severity="error"
1378
+ if created:
1379
+ logger.info(
1380
+ "Create terminal success: provider=codex terminal_id=%s window_id=%s",
1381
+ terminal_id,
1382
+ created.window_id,
836
1383
  )
1384
+ if not tmux_manager.focus_right_pane():
1385
+ logger.warning("Create terminal: focus_right_pane failed (provider=codex)")
1386
+ else:
1387
+ logger.warning("Create terminal failed: provider=codex terminal_id=%s", terminal_id)
1388
+ self.app.notify("Failed to create terminal", title="Agent", severity="error")
837
1389
 
838
1390
  async def _create_opencode_terminal(self, workspace: str, agent_id: str) -> None:
839
1391
  """Create an Opencode terminal associated with an agent."""
840
1392
  terminal_id = tmux_manager.new_terminal_id()
841
1393
  context_id = tmux_manager.new_context_id("oc")
1394
+ logger.info(
1395
+ "Create terminal requested: provider=opencode terminal_id=%s agent_id=%s workspace=%s "
1396
+ "context_id=%s",
1397
+ terminal_id,
1398
+ agent_id,
1399
+ workspace,
1400
+ context_id,
1401
+ )
842
1402
 
843
1403
  # Prepare CODEX_HOME so user can run codex in this terminal
844
1404
  try:
@@ -875,6 +1435,13 @@ class AgentsPanel(Container, can_focus=True):
875
1435
  )
876
1436
 
877
1437
  if created:
1438
+ logger.info(
1439
+ "Create terminal success: provider=opencode terminal_id=%s window_id=%s",
1440
+ terminal_id,
1441
+ created.window_id,
1442
+ )
1443
+ if not tmux_manager.focus_right_pane():
1444
+ logger.warning("Create terminal: focus_right_pane failed (provider=opencode)")
878
1445
  # Store agent association in database
879
1446
  try:
880
1447
  from ...db import get_database
@@ -892,14 +1459,20 @@ class AgentsPanel(Container, can_focus=True):
892
1459
  except Exception:
893
1460
  pass
894
1461
  else:
895
- self.app.notify(
896
- "Failed to create terminal", title="Agent", severity="error"
897
- )
1462
+ logger.warning("Create terminal failed: provider=opencode terminal_id=%s", terminal_id)
1463
+ self.app.notify("Failed to create terminal", title="Agent", severity="error")
898
1464
 
899
1465
  async def _create_zsh_terminal(self, workspace: str, agent_id: str) -> None:
900
1466
  """Create a zsh terminal associated with an agent."""
901
1467
  terminal_id = tmux_manager.new_terminal_id()
902
1468
  context_id = tmux_manager.new_context_id("zsh")
1469
+ logger.info(
1470
+ "Create terminal requested: provider=zsh terminal_id=%s agent_id=%s workspace=%s context_id=%s",
1471
+ terminal_id,
1472
+ agent_id,
1473
+ workspace,
1474
+ context_id,
1475
+ )
903
1476
 
904
1477
  # Prepare CODEX_HOME so user can run codex in this terminal
905
1478
  try:
@@ -934,6 +1507,13 @@ class AgentsPanel(Container, can_focus=True):
934
1507
  )
935
1508
 
936
1509
  if created:
1510
+ logger.info(
1511
+ "Create terminal success: provider=zsh terminal_id=%s window_id=%s",
1512
+ terminal_id,
1513
+ created.window_id,
1514
+ )
1515
+ if not tmux_manager.focus_right_pane():
1516
+ logger.warning("Create terminal: focus_right_pane failed (provider=zsh)")
937
1517
  # Store agent association in database
938
1518
  try:
939
1519
  from ...db import get_database
@@ -951,9 +1531,8 @@ class AgentsPanel(Container, can_focus=True):
951
1531
  except Exception:
952
1532
  pass
953
1533
  else:
954
- self.app.notify(
955
- "Failed to create terminal", title="Agent", severity="error"
956
- )
1534
+ logger.warning("Create terminal failed: provider=zsh terminal_id=%s", terminal_id)
1535
+ self.app.notify("Failed to create terminal", title="Agent", severity="error")
957
1536
 
958
1537
  def _install_claude_hooks(self, workspace: str) -> None:
959
1538
  """Install Claude hooks for a workspace."""
@@ -980,12 +1559,8 @@ class AgentsPanel(Container, can_focus=True):
980
1559
 
981
1560
  project_root = Path(workspace)
982
1561
  install_stop_hook(get_stop_settings_path(project_root), quiet=True)
983
- install_user_prompt_submit_hook(
984
- get_submit_settings_path(project_root), quiet=True
985
- )
986
- install_permission_request_hook(
987
- get_permission_settings_path(project_root), quiet=True
988
- )
1562
+ install_user_prompt_submit_hook(get_submit_settings_path(project_root), quiet=True)
1563
+ install_permission_request_hook(get_permission_settings_path(project_root), quiet=True)
989
1564
  except Exception:
990
1565
  pass
991
1566
 
@@ -1010,20 +1585,87 @@ class AgentsPanel(Container, can_focus=True):
1010
1585
  except Exception as e:
1011
1586
  self.app.notify(f"Failed: {e}", title="Agent", severity="error")
1012
1587
 
1013
- async def _switch_to_terminal(self, terminal_id: str) -> None:
1588
+ def _request_switch_to_terminal(self, terminal_id: str) -> None:
1589
+ """Switch terminals without blocking the UI thread."""
1590
+ terminal_id = (terminal_id or "").strip()
1014
1591
  if not terminal_id:
1015
1592
  return
1016
1593
 
1017
- window_id = self._find_window(terminal_id)
1018
- if not window_id:
1019
- self.app.notify("Window not found", title="Agent", severity="warning")
1020
- return
1594
+ self._active_terminal_id = terminal_id
1595
+ self._update_active_terminal_ui(terminal_id)
1596
+ self._rendered_fingerprint = (
1597
+ self._fingerprint(self._agents) + f"|active:{self._active_terminal_id}"
1598
+ )
1599
+
1600
+ self._switch_seq += 1
1601
+ seq = self._switch_seq
1602
+
1603
+ if self._switch_worker is not None and self._switch_worker.state in (
1604
+ WorkerState.PENDING,
1605
+ WorkerState.RUNNING,
1606
+ ):
1607
+ try:
1608
+ self._switch_worker.cancel()
1609
+ except Exception:
1610
+ pass
1611
+
1612
+ agents_snapshot = self._agents
1613
+
1614
+ def work() -> dict:
1615
+ # Prefer cached window_id to avoid a blocking tmux scan on every click.
1616
+ window_id = None
1617
+ try:
1618
+ for agent in agents_snapshot:
1619
+ for term in agent.get("terminals") or []:
1620
+ if term.get("terminal_id") == terminal_id:
1621
+ window_id = (term.get("window_id") or "").strip() or None
1622
+ break
1623
+ if window_id:
1624
+ break
1625
+ except Exception:
1626
+ window_id = None
1021
1627
 
1022
- if tmux_manager.select_inner_window(window_id):
1023
- tmux_manager.focus_right_pane()
1628
+ if not window_id:
1629
+ # Fall back to querying tmux (best-effort).
1630
+ window_id = self._find_window(terminal_id)
1631
+
1632
+ if not window_id:
1633
+ # If the terminal was closed from within the shell (e.g. `exit`), the DB record can
1634
+ # remain "active" briefly. Trigger a refresh so the stale button disappears.
1635
+ try:
1636
+ from ...db import get_database
1637
+
1638
+ db = get_database(read_only=False)
1639
+ db.update_agent(terminal_id, status="inactive")
1640
+ except Exception:
1641
+ pass
1642
+ return {
1643
+ "seq": seq,
1644
+ "terminal_id": terminal_id,
1645
+ "ok": False,
1646
+ "error": "Window not found",
1647
+ }
1648
+
1649
+ if not tmux_manager.select_inner_window(window_id):
1650
+ return {
1651
+ "seq": seq,
1652
+ "terminal_id": terminal_id,
1653
+ "window_id": window_id,
1654
+ "ok": False,
1655
+ "error": "Failed to switch",
1656
+ }
1657
+
1658
+ # Prefer showing the terminal after switching (best-effort).
1659
+ if not tmux_manager.focus_right_pane():
1660
+ logger.warning(
1661
+ "Switch terminal: focus_right_pane failed (terminal_id=%s window_id=%s)",
1662
+ terminal_id,
1663
+ window_id,
1664
+ )
1024
1665
  tmux_manager.clear_attention(window_id)
1025
- else:
1026
- self.app.notify("Failed to switch", title="Agent", severity="error")
1666
+ return {"seq": seq, "terminal_id": terminal_id, "window_id": window_id, "ok": True}
1667
+
1668
+ self._switch_worker = self.run_worker(work, thread=True, exit_on_error=False)
1027
1669
 
1028
1670
  async def _close_terminal(self, terminal_id: str) -> None:
1029
1671
  if not terminal_id:
@@ -1064,14 +1706,10 @@ class AgentsPanel(Container, can_focus=True):
1064
1706
  db = get_database(read_only=True)
1065
1707
  sessions = db.get_sessions_by_agent_id(agent_id)
1066
1708
  if not sessions:
1067
- self.app.notify(
1068
- "Agent has no sessions to share", title="Share", severity="warning"
1069
- )
1709
+ self.app.notify("Agent has no sessions to share", title="Share", severity="warning")
1070
1710
  return
1071
1711
  except Exception as e:
1072
- self.app.notify(
1073
- f"Failed to check sessions: {e}", title="Share", severity="error"
1074
- )
1712
+ self.app.notify(f"Failed to check sessions: {e}", title="Share", severity="error")
1075
1713
  return
1076
1714
 
1077
1715
  # Store agent_id for the worker callback
@@ -1128,9 +1766,7 @@ class AgentsPanel(Container, can_focus=True):
1128
1766
  try:
1129
1767
  match = re.search(r"\{.*\}", output, re.DOTALL)
1130
1768
  if match:
1131
- result["json"] = json_module.loads(
1132
- match.group(0), strict=False
1133
- )
1769
+ result["json"] = json_module.loads(match.group(0), strict=False)
1134
1770
  except Exception:
1135
1771
  result["json"] = None
1136
1772
 
@@ -1165,9 +1801,7 @@ class AgentsPanel(Container, can_focus=True):
1165
1801
  share_link = payload.get("share_link") or payload.get("share_url")
1166
1802
  if not share_link:
1167
1803
  share_link = result.get("share_link_guess")
1168
- slack_message = (
1169
- payload.get("slack_message") if isinstance(payload, dict) else None
1170
- )
1804
+ slack_message = payload.get("slack_message") if isinstance(payload, dict) else None
1171
1805
  if not slack_message:
1172
1806
  try:
1173
1807
  from ...db import get_database
@@ -1190,17 +1824,13 @@ class AgentsPanel(Container, can_focus=True):
1190
1824
  copied = copy_text(self.app, text_to_copy)
1191
1825
 
1192
1826
  suffix = " (copied)" if copied else ""
1193
- self.app.notify(
1194
- f"Share link: {share_link}{suffix}", title="Share", timeout=6
1195
- )
1827
+ self.app.notify(f"Share link: {share_link}{suffix}", title="Share", timeout=6)
1196
1828
  elif exit_code == 0:
1197
1829
  self.app.notify("Share completed", title="Share", timeout=3)
1198
1830
  else:
1199
1831
  extra = result.get("stderr") or ""
1200
1832
  suffix = f": {extra}" if extra else ""
1201
- self.app.notify(
1202
- f"Share failed (exit {exit_code}){suffix}", title="Share", timeout=6
1203
- )
1833
+ self.app.notify(f"Share failed (exit {exit_code}){suffix}", title="Share", timeout=6)
1204
1834
 
1205
1835
  async def _sync_agent(self, agent_id: str) -> None:
1206
1836
  """Sync all sessions for an agent with remote share."""
@@ -1294,6 +1924,4 @@ class AgentsPanel(Container, can_focus=True):
1294
1924
  if copied:
1295
1925
  self.app.notify("Share link copied to clipboard", title="Link", timeout=3)
1296
1926
  else:
1297
- self.app.notify(
1298
- f"Failed to copy. Link: {share_url}", title="Link", severity="warning"
1299
- )
1927
+ self.app.notify(f"Failed to copy. Link: {share_url}", title="Link", severity="warning")