aline-ai 0.7.3__py3-none-any.whl → 0.7.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.
@@ -7,13 +7,14 @@ import json as _json
7
7
  import re
8
8
  import shlex
9
9
  import time
10
+ from dataclasses import dataclass
10
11
  from pathlib import Path
11
12
  from typing import Optional
12
13
 
13
14
  from textual import events
14
15
  from textual.app import ComposeResult
15
16
  from textual.containers import Container, Horizontal, Vertical
16
- from textual.widgets import Button, Static
17
+ from textual.widgets import Button, Rule, Static
17
18
  from textual.message import Message
18
19
  from textual.worker import Worker, WorkerState
19
20
  from rich.text import Text
@@ -25,6 +26,54 @@ from ..clipboard import copy_text
25
26
  logger = setup_logger("realign.dashboard.widgets.agents_panel", "dashboard.log")
26
27
 
27
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
+
28
77
  class AgentNameButton(Button):
29
78
  """Button that emits a message on double-click."""
30
79
 
@@ -40,9 +89,178 @@ class AgentNameButton(Button):
40
89
  self.post_message(self.DoubleClicked(self, self.name or ""))
41
90
 
42
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
+ pane_cmd = (pane_current_command or "").strip().lower()
109
+ title_is_relevant = bool(title) and (
110
+ bool(running_agent) or pane_cmd in {"claude", "codex", "opencode"}
111
+ )
112
+
113
+ if title_is_relevant:
114
+ label = f"{prefix}{title}"
115
+ switch_btn: Button = TruncButton(
116
+ label,
117
+ id=f"switch-{term_safe_id}",
118
+ name=terminal_id,
119
+ classes=switch_classes,
120
+ )
121
+ switch_btn.tooltip = title
122
+ elif running_agent:
123
+ provider = (running_agent or "").strip().lower()
124
+ provider_label = (running_agent or "").strip().capitalize()
125
+ if provider in {"codex", "claude"}:
126
+ switch_btn = SpinnerPrefixButton(
127
+ prefix=prefix,
128
+ label=provider_label,
129
+ id=f"switch-{term_safe_id}",
130
+ name=terminal_id,
131
+ classes=f"{switch_classes} spinner",
132
+ )
133
+ else:
134
+ switch_btn = TruncButton(
135
+ f"{prefix}{provider_label}",
136
+ id=f"switch-{term_safe_id}",
137
+ name=terminal_id,
138
+ classes=switch_classes,
139
+ )
140
+ else:
141
+ display = (pane_current_command or "").strip() or "Terminal"
142
+ switch_btn = TruncButton(
143
+ f"{prefix}{display}",
144
+ id=f"switch-{term_safe_id}",
145
+ name=terminal_id,
146
+ classes=switch_classes,
147
+ )
148
+
149
+ self._switch_btn = switch_btn
150
+ self._close_btn = Button(
151
+ "✕",
152
+ id=f"close-{term_safe_id}",
153
+ name=terminal_id,
154
+ variant="error",
155
+ classes="terminal-close",
156
+ )
157
+
158
+ def compose(self) -> ComposeResult:
159
+ yield self._switch_btn
160
+ yield self._close_btn
161
+
162
+
163
+ class _TerminalConnectorWidget(Horizontal):
164
+ def __init__(self) -> None:
165
+ super().__init__(classes="terminal-row")
166
+
167
+ def compose(self) -> ComposeResult:
168
+ yield Button("│", classes="terminal-switch")
169
+
170
+
171
+ class _AgentBlockWidget(Container):
172
+ def __init__(self, *, agent: dict, active_terminal_id: str | None) -> None:
173
+ super().__init__(classes="agent-block")
174
+ self._agent = agent
175
+ self._active_terminal_id = active_terminal_id
176
+
177
+ def compose(self) -> ComposeResult:
178
+ agent = self._agent
179
+ safe_id = AgentsPanel._safe_id(agent["id"])
180
+
181
+ rule = Rule(line_style="solid")
182
+ rule.styles.color = "black"
183
+ yield rule
184
+
185
+ with Horizontal(classes="agent-row"):
186
+ name_label = Text(f"▸ {agent['name']}", style="bold")
187
+ agent_btn = AgentNameButton(
188
+ name_label,
189
+ id=f"agent-{safe_id}",
190
+ name=agent["id"],
191
+ classes="agent-name",
192
+ )
193
+ if agent.get("description"):
194
+ agent_btn.tooltip = agent["description"]
195
+ yield agent_btn
196
+
197
+ if agent.get("share_url"):
198
+ yield Button(
199
+ "Sync",
200
+ id=f"sync-{safe_id}",
201
+ name=agent["id"],
202
+ classes="agent-share",
203
+ )
204
+ yield Button(
205
+ "Link",
206
+ id=f"link-{safe_id}",
207
+ name=agent["id"],
208
+ classes="agent-share",
209
+ )
210
+ else:
211
+ yield Button(
212
+ "Share",
213
+ id=f"share-{safe_id}",
214
+ name=agent["id"],
215
+ classes="agent-share",
216
+ )
217
+
218
+ yield Button(
219
+ "+",
220
+ id=f"create-term-{safe_id}",
221
+ name=agent["id"],
222
+ classes="agent-create",
223
+ )
224
+ yield Button(
225
+ "✕",
226
+ id=f"delete-{safe_id}",
227
+ name=agent["id"],
228
+ variant="error",
229
+ classes="agent-delete",
230
+ )
231
+
232
+ terminals = agent.get("terminals") or []
233
+ if not terminals:
234
+ return
235
+
236
+ with Vertical(classes="terminal-list"):
237
+ for term_idx, term in enumerate(terminals):
238
+ is_last_term = term_idx == len(terminals) - 1
239
+ prefix = "└ " if is_last_term else "├ "
240
+
241
+ terminal_id = term["terminal_id"]
242
+ term_safe_id = AgentsPanel._safe_id(terminal_id)
243
+ is_active = terminal_id == self._active_terminal_id
244
+
245
+ yield _TerminalRowWidget(
246
+ terminal_id=terminal_id,
247
+ term_safe_id=term_safe_id,
248
+ prefix=prefix,
249
+ title=term.get("title", "") or "",
250
+ running_agent=term.get("running_agent"),
251
+ pane_current_command=term.get("pane_current_command"),
252
+ is_active=is_active,
253
+ )
254
+ if not is_last_term:
255
+ yield _TerminalConnectorWidget()
256
+
257
+
43
258
  class AgentsPanel(Container, can_focus=True):
44
259
  """Panel displaying agent profiles with their associated terminals."""
45
260
 
261
+ REFRESH_INTERVAL_SECONDS = 2.0
262
+ SPINNER_INTERVAL_SECONDS = 0.15
263
+
46
264
  DEFAULT_CSS = """
47
265
  AgentsPanel {
48
266
  height: 100%;
@@ -56,10 +274,11 @@ class AgentsPanel(Container, can_focus=True):
56
274
 
57
275
  AgentsPanel .summary {
58
276
  height: auto;
59
- margin: 0 0 1 0;
277
+ margin: 0;
60
278
  padding: 0;
61
279
  background: transparent;
62
280
  border: none;
281
+ align: right middle;
63
282
  }
64
283
 
65
284
  AgentsPanel Button {
@@ -86,15 +305,22 @@ class AgentsPanel(Container, can_focus=True):
86
305
  background: transparent;
87
306
  }
88
307
 
308
+ AgentsPanel Rule {
309
+ margin: 0;
310
+ }
311
+
89
312
  AgentsPanel .agent-row {
313
+ height: 2;
314
+ margin: 0;
315
+ }
316
+
317
+ AgentsPanel .agent-block {
90
318
  height: auto;
91
- min-height: 2;
92
- margin: 0 0 0 0;
93
319
  }
94
320
 
95
321
  AgentsPanel .agent-row Button.agent-name {
96
322
  width: 1fr;
97
- height: 2;
323
+ height: 1;
98
324
  margin: 0;
99
325
  padding: 0 1;
100
326
  text-align: left;
@@ -102,28 +328,28 @@ class AgentsPanel(Container, can_focus=True):
102
328
  }
103
329
 
104
330
  AgentsPanel .agent-row Button.agent-create {
105
- width: auto;
106
- min-width: 8;
107
- height: 2;
108
- margin-left: 1;
109
- padding: 0 1;
331
+ width: 3;
332
+ min-width: 3;
333
+ height: 1;
334
+ margin-left: 0;
335
+ padding: 0;
110
336
  content-align: center middle;
111
337
  }
112
338
 
113
339
  AgentsPanel .agent-row Button.agent-delete {
114
340
  width: 3;
115
341
  min-width: 3;
116
- height: 2;
117
- margin-left: 1;
342
+ height: 1;
343
+ margin-left: 0;
118
344
  padding: 0;
119
345
  content-align: center middle;
120
346
  }
121
347
 
122
348
  AgentsPanel .agent-row Button.agent-share {
123
349
  width: auto;
124
- min-width: 8;
125
- height: 2;
126
- margin-left: 1;
350
+ min-width: 6;
351
+ height: 1;
352
+ margin-left: 0;
127
353
  padding: 0 1;
128
354
  content-align: center middle;
129
355
  }
@@ -137,25 +363,34 @@ class AgentsPanel(Container, can_focus=True):
137
363
  }
138
364
 
139
365
  AgentsPanel .terminal-row {
140
- height: auto;
141
- min-height: 2;
366
+ height: 1;
142
367
  margin: 0;
143
368
  }
144
369
 
145
370
  AgentsPanel .terminal-row Button.terminal-switch {
146
371
  width: 1fr;
147
- height: 2;
372
+ height: 1;
148
373
  margin: 0;
149
374
  padding: 0 1;
150
375
  text-align: left;
151
- content-align: left top;
376
+ content-align: left middle;
377
+ text-opacity: 60%;
378
+ }
379
+
380
+ AgentsPanel .terminal-row Button.terminal-switch:hover {
381
+ text-opacity: 100%;
382
+ }
383
+
384
+ AgentsPanel .terminal-row Button.active-terminal {
385
+ text-opacity: 100%;
386
+ text-style: bold;
152
387
  }
153
388
 
154
389
  AgentsPanel .terminal-row Button.terminal-close {
155
390
  width: 3;
156
391
  min-width: 3;
157
- height: 2;
158
- margin-left: 1;
392
+ height: 1;
393
+ margin: 0;
159
394
  padding: 0;
160
395
  content-align: center middle;
161
396
  }
@@ -165,30 +400,58 @@ class AgentsPanel(Container, can_focus=True):
165
400
  super().__init__()
166
401
  self._refresh_lock = asyncio.Lock()
167
402
  self._agents: list[dict] = []
403
+ self._active_terminal_id: str | None = None
168
404
  self._rendered_fingerprint: str = ""
405
+ self._switch_worker: Optional[Worker] = None
406
+ self._switch_seq: int = 0
169
407
  self._refresh_worker: Optional[Worker] = None
408
+ self._render_worker: Optional[Worker] = None
409
+ self._pending_render: bool = False
410
+ self._pending_render_reason: str | None = None
170
411
  self._share_worker: Optional[Worker] = None
171
412
  self._sync_worker: Optional[Worker] = None
172
413
  self._share_agent_id: Optional[str] = None
173
414
  self._sync_agent_id: Optional[str] = None
174
415
  self._refresh_timer = None
416
+ self._spinner_timer = None
175
417
  self._last_refresh_error_at: float | None = None
418
+ self._last_render_started_at: float | None = None
419
+ self._last_render_completed_at: float | None = None
420
+ self._process_snapshot: _ProcessSnapshot | None = None
176
421
 
177
422
  def compose(self) -> ComposeResult:
178
423
  with Horizontal(classes="summary"):
179
- yield Button("+ Create Agent", id="create-agent", variant="primary")
424
+ yield Button("Add Agent", id="create-agent", variant="primary")
180
425
  with Vertical(id="agents-list", classes="list"):
181
- yield Static("No agents yet. Click 'Create Agent' to add one.")
426
+ yield Static("No agents yet. Click 'Add Agent' to add one.")
427
+
428
+ def on_mount(self) -> None:
429
+ btn = self.query_one("#create-agent", Button)
430
+ btn.styles.border = ("round", "black")
431
+ btn.styles.text_align = "center"
432
+ btn.styles.content_align = ("center", "middle")
182
433
 
183
434
  def on_show(self) -> None:
184
435
  if self._refresh_timer is None:
185
436
  # Refresh frequently, but avoid hammering SQLite/tmux (can cause transient empty UI).
186
- self._refresh_timer = self.set_interval(2.0, self._on_refresh_timer)
437
+ self._refresh_timer = self.set_interval(
438
+ self.REFRESH_INTERVAL_SECONDS, self._on_refresh_timer
439
+ )
187
440
  else:
188
441
  try:
189
442
  self._refresh_timer.resume()
190
443
  except Exception:
191
444
  pass
445
+
446
+ if self._spinner_timer is None:
447
+ self._spinner_timer = self.set_interval(
448
+ self.SPINNER_INTERVAL_SECONDS, self._on_spinner_timer
449
+ )
450
+ else:
451
+ try:
452
+ self._spinner_timer.resume()
453
+ except Exception:
454
+ pass
192
455
  self.refresh_data()
193
456
 
194
457
  def on_hide(self) -> None:
@@ -197,10 +460,24 @@ class AgentsPanel(Container, can_focus=True):
197
460
  self._refresh_timer.pause()
198
461
  except Exception:
199
462
  pass
463
+ if self._spinner_timer is not None:
464
+ try:
465
+ self._spinner_timer.pause()
466
+ except Exception:
467
+ pass
200
468
 
201
469
  def _on_refresh_timer(self) -> None:
202
470
  self.refresh_data()
203
471
 
472
+ def _on_spinner_timer(self) -> None:
473
+ if not self.display:
474
+ return
475
+ try:
476
+ for btn in self.query("Button.spinner"):
477
+ btn.refresh()
478
+ except Exception:
479
+ pass
480
+
204
481
  def refresh_data(self) -> None:
205
482
  if not self.display:
206
483
  return
@@ -213,7 +490,177 @@ class AgentsPanel(Container, can_focus=True):
213
490
  self._collect_agents, thread=True, exit_on_error=False
214
491
  )
215
492
 
216
- def _collect_agents(self) -> list[dict]:
493
+ def diagnostics_state(self) -> dict[str, object]:
494
+ """Small, safe snapshot for watchdog logging (never raises)."""
495
+ state: dict[str, object] = {}
496
+ try:
497
+ state["agents_count"] = int(len(self._agents))
498
+ state["rendered_fingerprint_len"] = int(len(self._rendered_fingerprint))
499
+ state["refresh_worker_state"] = str(
500
+ getattr(getattr(self, "_refresh_worker", None), "state", None)
501
+ )
502
+ state["render_worker_state"] = str(
503
+ getattr(getattr(self, "_render_worker", None), "state", None)
504
+ )
505
+ state["pending_render"] = bool(self._pending_render)
506
+ state["last_refresh_error_at"] = self._last_refresh_error_at
507
+ state["last_render_started_at"] = self._last_render_started_at
508
+ state["last_render_completed_at"] = self._last_render_completed_at
509
+ except Exception:
510
+ pass
511
+ try:
512
+ container = self.query_one("#agents-list", Vertical)
513
+ state["agents_list_children"] = int(len(getattr(container, "children", []) or []))
514
+ except Exception:
515
+ pass
516
+ return state
517
+
518
+ def _ui_blank(self) -> bool:
519
+ try:
520
+ container = self.query_one("#agents-list", Vertical)
521
+ return len(container.children) == 0
522
+ except Exception:
523
+ return False
524
+
525
+ def _schedule_render(self, *, reason: str) -> None:
526
+ if not self.display:
527
+ return
528
+
529
+ if self._render_worker is not None and self._render_worker.state in (
530
+ WorkerState.PENDING,
531
+ WorkerState.RUNNING,
532
+ ):
533
+ self._pending_render = True
534
+ self._pending_render_reason = reason
535
+ return
536
+
537
+ self._pending_render = False
538
+ self._pending_render_reason = None
539
+ self._last_render_started_at = time.monotonic()
540
+ self._render_worker = self.run_worker(
541
+ self._render_agents(), group="agents-render", exit_on_error=False
542
+ )
543
+
544
+ def force_render(self, *, reason: str) -> None:
545
+ """Force a re-render even if the collected data didn't change."""
546
+ # Reset fingerprint so the next refresh doesn't treat the UI as "up to date".
547
+ self._rendered_fingerprint = ""
548
+ if self._agents:
549
+ self._schedule_render(reason=reason)
550
+
551
+ @staticmethod
552
+ def _detect_running_agent(
553
+ pane_pid: int | None, snapshot: _ProcessSnapshot | None
554
+ ) -> str | None:
555
+ """Detect whether a known agent process exists under a tmux pane PID.
556
+
557
+ Uses a cached process snapshot built once per refresh to avoid spawning `ps`
558
+ repeatedly for every terminal row.
559
+ """
560
+ if not pane_pid or snapshot is None:
561
+ return None
562
+
563
+ children = snapshot.children
564
+ comms = snapshot.comms
565
+ stack = [pane_pid]
566
+ seen: set[int] = set()
567
+ while stack:
568
+ p = stack.pop()
569
+ if p in seen:
570
+ continue
571
+ seen.add(p)
572
+ comm = (comms.get(p) or "").lower()
573
+ for kw in ("claude", "codex", "opencode"):
574
+ if kw in comm:
575
+ return kw
576
+ stack.extend(children.get(p, []))
577
+ return None
578
+
579
+ @staticmethod
580
+ def _normalize_tty(tty: str) -> str:
581
+ s = (tty or "").strip()
582
+ if not s:
583
+ return ""
584
+ if s.startswith("/dev/"):
585
+ s = s[len("/dev/") :]
586
+ return s.strip()
587
+
588
+ def _get_process_snapshot(self, *, pane_ttys: set[str]) -> _ProcessSnapshot | None:
589
+ """Return a cached process snapshot scoped to the given TTY set.
590
+
591
+ TTL is aligned with the panel refresh interval so the snapshot is at most one refresh stale.
592
+ """
593
+ ttys = sorted(
594
+ {self._normalize_tty(t) for t in (pane_ttys or set()) if self._normalize_tty(t)}
595
+ )
596
+ if not ttys:
597
+ return None
598
+ tty_key = ",".join(ttys)
599
+
600
+ now = time.monotonic()
601
+ cached = self._process_snapshot
602
+ if (
603
+ cached
604
+ and cached.tty_key == tty_key
605
+ and (now - cached.created_at_monotonic) < float(self.REFRESH_INTERVAL_SECONDS)
606
+ ):
607
+ return cached
608
+
609
+ try:
610
+ import subprocess
611
+
612
+ # Prefer TTY-limited `ps` to keep output small. BSD ps supports comma-separated TTY list.
613
+ result = subprocess.run(
614
+ ["ps", "-o", "pid=,ppid=,comm=", "-t", tty_key],
615
+ capture_output=True,
616
+ text=True,
617
+ timeout=2,
618
+ )
619
+ stdout = result.stdout or ""
620
+ if result.returncode != 0 or not stdout.strip():
621
+ # Fallback: some environments may not accept `-t` list; fall back to a single
622
+ # snapshot of all processes and filter by TTY.
623
+ result = subprocess.run(
624
+ ["ps", "-axo", "pid=,ppid=,tty=,comm="],
625
+ capture_output=True,
626
+ text=True,
627
+ timeout=2,
628
+ )
629
+ stdout = result.stdout or ""
630
+
631
+ allowed = set(ttys)
632
+ filtered_lines: list[str] = []
633
+ for line in stdout.splitlines():
634
+ parts = line.split(None, 3)
635
+ if len(parts) < 4:
636
+ continue
637
+ tty = self._normalize_tty(parts[2])
638
+ if tty and tty in allowed:
639
+ filtered_lines.append(f"{parts[0]} {parts[1]} {parts[3]}")
640
+ stdout = "\n".join(filtered_lines)
641
+
642
+ children: dict[int, list[int]] = {}
643
+ comms: dict[int, str] = {}
644
+ for line in stdout.splitlines():
645
+ parts = line.split(None, 2)
646
+ if len(parts) < 3:
647
+ continue
648
+ try:
649
+ pid, ppid = int(parts[0]), int(parts[1])
650
+ except ValueError:
651
+ continue
652
+ children.setdefault(ppid, []).append(pid)
653
+ comms[pid] = parts[2]
654
+
655
+ snap = _ProcessSnapshot(
656
+ created_at_monotonic=now, tty_key=tty_key, children=children, comms=comms
657
+ )
658
+ self._process_snapshot = snap
659
+ return snap
660
+ except Exception:
661
+ return None
662
+
663
+ def _collect_agents(self) -> dict:
217
664
  """Collect agent info with their terminals."""
218
665
  agents: list[dict] = []
219
666
 
@@ -241,15 +688,41 @@ class AgentsPanel(Container, can_focus=True):
241
688
  l.terminal_id: l for l in latest_links if getattr(l, "terminal_id", None)
242
689
  }
243
690
 
691
+ tmux_windows_ok = True
244
692
  try:
245
693
  tmux_windows = tmux_manager.list_inner_windows()
246
694
  except Exception as e:
247
695
  logger.debug(f"Failed to list tmux windows: {e}")
696
+ tmux_windows_ok = False
248
697
  tmux_windows = []
249
698
  terminal_to_window = {
250
699
  w.terminal_id: w for w in tmux_windows if getattr(w, "terminal_id", None)
251
700
  }
252
701
 
702
+ # If we are running inside the managed tmux dashboard environment, only show terminals
703
+ # that still exist in the inner tmux server. This prevents stale buttons when a user
704
+ # exits a shell directly (tmux window disappears but DB record may still be "active").
705
+ try:
706
+ if tmux_manager.managed_env_enabled() and tmux_windows_ok:
707
+ present = {tid for tid in terminal_to_window.keys() if tid}
708
+ if present:
709
+ active_terminals = [t for t in active_terminals if getattr(t, "id", None) in present]
710
+ else:
711
+ # Inner session exists but no tracked terminals are present.
712
+ active_terminals = []
713
+ except Exception:
714
+ pass
715
+
716
+ pane_ttys: set[str] = set()
717
+ for w in tmux_windows:
718
+ if getattr(w, "pane_pid", None) and getattr(w, "pane_tty", None):
719
+ pane_ttys.add(str(w.pane_tty))
720
+ ps_snapshot = self._get_process_snapshot(pane_ttys=pane_ttys) if pane_ttys else None
721
+
722
+ # Detect currently active terminal
723
+ active_window = next((w for w in tmux_windows if w.active), None)
724
+ active_terminal_id = active_window.terminal_id if active_window else None
725
+
253
726
  # Collect all session_ids for title lookup
254
727
  session_ids: list[str] = []
255
728
  for t in active_terminals:
@@ -260,8 +733,31 @@ class AgentsPanel(Container, can_focus=True):
260
733
  window = terminal_to_window.get(t.id)
261
734
  if window and getattr(window, "session_id", None):
262
735
  session_ids.append(window.session_id)
736
+ continue
737
+ # Fallback: agent record (works even when WindowLink isn't available yet / at all).
738
+ agent_session_id = getattr(t, "session_id", None)
739
+ if agent_session_id:
740
+ session_ids.append(agent_session_id)
263
741
 
264
- titles = self._fetch_session_titles(session_ids)
742
+ titles: dict[str, str] = {}
743
+ session_agent_id: dict[str, str] = {}
744
+ try:
745
+ uniq_session_ids = sorted({sid for sid in session_ids if sid})
746
+ if uniq_session_ids:
747
+ sessions = db.get_sessions_by_ids(uniq_session_ids)
748
+ for s in sessions:
749
+ sid = getattr(s, "id", None)
750
+ if not sid:
751
+ continue
752
+ title = (getattr(s, "session_title", "") or "").strip()
753
+ if title:
754
+ titles[sid] = title
755
+ agent_id = getattr(s, "agent_id", None)
756
+ if agent_id:
757
+ session_agent_id[sid] = agent_id
758
+ except Exception:
759
+ titles = {}
760
+ session_agent_id = {}
265
761
 
266
762
  # Map agent_info.id -> list of terminals
267
763
  agent_to_terminals: dict[str, list[dict]] = {}
@@ -284,12 +780,7 @@ class AgentsPanel(Container, can_focus=True):
284
780
  if not agent_info_id:
285
781
  window = terminal_to_window.get(t.id)
286
782
  if window and getattr(window, "session_id", None):
287
- try:
288
- session = db.get_session_by_id(window.session_id)
289
- except Exception:
290
- session = None
291
- if session:
292
- agent_info_id = session.agent_id
783
+ agent_info_id = session_agent_id.get(window.session_id) or None
293
784
 
294
785
  if agent_info_id:
295
786
  agent_to_terminals.setdefault(agent_info_id, [])
@@ -302,25 +793,32 @@ class AgentsPanel(Container, can_focus=True):
302
793
  else (
303
794
  window.session_id
304
795
  if window and getattr(window, "session_id", None)
305
- else None
796
+ else getattr(t, "session_id", None)
306
797
  )
307
798
  )
308
799
  title = titles.get(session_id, "") if session_id else ""
309
800
 
310
- agent_to_terminals[agent_info_id].append(
311
- {
312
- "terminal_id": t.id,
313
- "session_id": session_id,
314
- "provider": (
315
- link.provider
316
- if link and getattr(link, "provider", None)
317
- else (t.provider or "")
318
- ),
319
- "session_type": t.session_type or "",
320
- "title": title,
321
- "cwd": t.cwd or "",
322
- }
801
+ pane_cmd = window.pane_current_command if window else None
802
+ provider = (
803
+ link.provider
804
+ if link and getattr(link, "provider", None)
805
+ else (t.provider or "")
323
806
  )
807
+ pane_pid = window.pane_pid if window else None
808
+ running_agent = self._detect_running_agent(pane_pid, ps_snapshot)
809
+
810
+ entry = {
811
+ "terminal_id": t.id,
812
+ "window_id": window.window_id if window else None,
813
+ "session_id": session_id,
814
+ "provider": provider,
815
+ "session_type": t.session_type or "",
816
+ "title": title,
817
+ "cwd": t.cwd or "",
818
+ "running_agent": running_agent,
819
+ "pane_current_command": pane_cmd,
820
+ }
821
+ agent_to_terminals[agent_info_id].append(entry)
324
822
 
325
823
  for info in agent_infos:
326
824
  terminals = agent_to_terminals.get(info.id, [])
@@ -335,7 +833,7 @@ class AgentsPanel(Container, can_focus=True):
335
833
  }
336
834
  )
337
835
 
338
- return agents
836
+ return {"agents": agents, "active_terminal_id": active_terminal_id}
339
837
 
340
838
  @staticmethod
341
839
  def _fingerprint(agents: list[dict]) -> str:
@@ -346,6 +844,42 @@ class AgentsPanel(Container, can_focus=True):
346
844
  return ""
347
845
 
348
846
  def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
847
+ # Handle switch worker
848
+ if self._switch_worker is not None and event.worker is self._switch_worker:
849
+ if event.state == WorkerState.SUCCESS:
850
+ result = self._switch_worker.result or {}
851
+ if isinstance(result, dict):
852
+ seq = int(result.get("seq", 0) or 0)
853
+ if seq != self._switch_seq:
854
+ return
855
+ terminal_id = str(result.get("terminal_id", "") or "").strip() or None
856
+ ok = bool(result.get("ok", False))
857
+ if ok and terminal_id:
858
+ self._active_terminal_id = terminal_id
859
+ self._update_active_terminal_ui(terminal_id)
860
+ # Prevent the next refresh from forcing a full re-render just due to
861
+ # active-terminal changes.
862
+ self._rendered_fingerprint = (
863
+ self._fingerprint(self._agents) + f"|active:{self._active_terminal_id}"
864
+ )
865
+ else:
866
+ msg = str(result.get("error", "") or "Failed to switch").strip()
867
+ self.app.notify(msg, title="Agent", severity="error")
868
+ self.refresh_data()
869
+ elif event.state == WorkerState.ERROR:
870
+ err = self._switch_worker.error
871
+ msg = "Failed to switch"
872
+ if isinstance(err, BaseException):
873
+ msg = f"Failed to switch: {err}"
874
+ elif err:
875
+ msg = f"Failed to switch: {err}"
876
+ self.app.notify(msg, title="Agent", severity="error")
877
+ self.refresh_data()
878
+
879
+ if event.state in {WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED}:
880
+ self._switch_worker = None
881
+ return
882
+
349
883
  # Handle refresh worker
350
884
  if self._refresh_worker is not None and event.worker is self._refresh_worker:
351
885
  if event.state == WorkerState.ERROR:
@@ -362,20 +896,70 @@ class AgentsPanel(Container, can_focus=True):
362
896
  logger.warning(f"Agents refresh failed: {err}")
363
897
  return
364
898
  elif event.state == WorkerState.SUCCESS:
365
- self._agents = self._refresh_worker.result or []
899
+ prev_agents = self._agents
900
+ prev_active = self._active_terminal_id
901
+ payload = self._refresh_worker.result or {}
902
+ if isinstance(payload, dict) and "agents" in payload:
903
+ self._agents = payload.get("agents") or []
904
+ self._active_terminal_id = payload.get("active_terminal_id") or None
905
+ else:
906
+ self._agents = payload or []
366
907
  self._last_refresh_error_at = None
367
908
  else:
368
909
  return
369
- fp = self._fingerprint(self._agents)
370
- if fp == self._rendered_fingerprint:
910
+
911
+ # Fast path: only the active terminal changed; update button classes in-place
912
+ # to avoid full re-render flicker / input lag.
913
+ try:
914
+ prev_agents_fp = self._fingerprint(prev_agents)
915
+ new_agents_fp = self._fingerprint(self._agents)
916
+ if (
917
+ prev_agents_fp == new_agents_fp
918
+ and prev_active != self._active_terminal_id
919
+ and not self._ui_blank()
920
+ ):
921
+ self._update_active_terminal_ui(self._active_terminal_id)
922
+ self._rendered_fingerprint = new_agents_fp + f"|active:{self._active_terminal_id}"
923
+ return
924
+ except Exception:
925
+ new_agents_fp = self._fingerprint(self._agents)
926
+
927
+ fp = new_agents_fp + f"|active:{self._active_terminal_id}"
928
+ # Important: the UI can become blank due to cancellation or transient Textual issues.
929
+ # If data didn't change but the widget tree is empty, force a re-render.
930
+ should_render = (fp != self._rendered_fingerprint) or (
931
+ self._agents and self._ui_blank()
932
+ )
933
+ if not should_render:
371
934
  return # nothing changed – skip re-render to avoid flicker
372
935
  self._rendered_fingerprint = fp
373
- self.run_worker(
374
- self._render_agents(),
375
- group="agents-render",
376
- exclusive=True,
377
- exit_on_error=False,
378
- )
936
+ self._schedule_render(reason="refresh")
937
+ return
938
+
939
+ # Handle render worker
940
+ if self._render_worker is not None and event.worker is self._render_worker:
941
+ if event.state == WorkerState.ERROR:
942
+ err = self._render_worker.error
943
+ if isinstance(err, BaseException):
944
+ logger.warning(
945
+ "Agents render failed",
946
+ exc_info=(type(err), err, err.__traceback__),
947
+ )
948
+ else:
949
+ logger.warning(f"Agents render failed: {err}")
950
+ # Force next refresh to re-render even if data is unchanged.
951
+ self._rendered_fingerprint = ""
952
+ elif event.state == WorkerState.SUCCESS:
953
+ self._last_render_completed_at = time.monotonic()
954
+ elif event.state == WorkerState.CANCELLED:
955
+ self._rendered_fingerprint = ""
956
+
957
+ if event.state in {WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED}:
958
+ self._last_render_completed_at = time.monotonic()
959
+ self._render_worker = None
960
+ if self._pending_render:
961
+ reason = self._pending_render_reason or "pending"
962
+ self._schedule_render(reason=reason)
379
963
  return
380
964
 
381
965
  # Handle share worker
@@ -390,112 +974,20 @@ class AgentsPanel(Container, can_focus=True):
390
974
  async with self._refresh_lock:
391
975
  try:
392
976
  container = self.query_one("#agents-list", Vertical)
393
- await container.remove_children()
394
-
395
- if not self._agents:
396
- await container.mount(Static("No agents yet. Click 'Create Agent' to add one."))
397
- return
398
-
399
- for agent in self._agents:
400
- safe_id = self._safe_id(agent["id"])
401
-
402
- # Agent row with name, create button, and delete button
403
- row = Horizontal(classes="agent-row")
404
- await container.mount(row)
405
-
406
- # Agent name button
407
- name_label = Text(agent["name"], style="bold")
408
- terminal_count = len(agent["terminals"])
409
- if terminal_count > 0:
410
- name_label.append(f" ({terminal_count})", style="dim")
411
-
412
- agent_btn = AgentNameButton(
413
- name_label,
414
- id=f"agent-{safe_id}",
415
- name=agent["id"],
416
- classes="agent-name",
417
- )
418
- if agent.get("description"):
419
- agent_btn.tooltip = agent["description"]
420
- await row.mount(agent_btn)
421
-
422
- # Share or Sync button (Sync if agent already has a share_url)
423
- if agent.get("share_url"):
424
- await row.mount(
425
- Button(
426
- "Sync",
427
- id=f"sync-{safe_id}",
428
- name=agent["id"],
429
- classes="agent-share",
430
- )
431
- )
432
- await row.mount(
433
- Button(
434
- "Link",
435
- id=f"link-{safe_id}",
436
- name=agent["id"],
437
- classes="agent-share",
438
- )
439
- )
440
- else:
441
- await row.mount(
442
- Button(
443
- "Share",
444
- id=f"share-{safe_id}",
445
- name=agent["id"],
446
- classes="agent-share",
447
- )
448
- )
449
-
450
- # Create terminal button
451
- await row.mount(
452
- Button(
453
- "+ Term",
454
- id=f"create-term-{safe_id}",
455
- name=agent["id"],
456
- classes="agent-create",
457
- )
458
- )
977
+ with self.app.batch_update():
978
+ await container.remove_children()
459
979
 
460
- # Delete agent button
461
- await row.mount(
462
- Button(
463
- "✕",
464
- id=f"delete-{safe_id}",
465
- name=agent["id"],
466
- variant="error",
467
- classes="agent-delete",
980
+ if not self._agents:
981
+ await container.mount(
982
+ Static("No agents yet. Click 'Add Agent' to add one.")
468
983
  )
469
- )
984
+ return
470
985
 
471
- # Terminal list (indented under agent)
472
- if agent["terminals"]:
473
- term_list = Vertical(classes="terminal-list")
474
- await container.mount(term_list)
475
-
476
- for term in agent["terminals"]:
477
- term_safe_id = self._safe_id(term["terminal_id"])
478
- term_row = Horizontal(classes="terminal-row")
479
- await term_list.mount(term_row)
480
-
481
- label = self._make_terminal_label(term)
482
- await term_row.mount(
483
- Button(
484
- label,
485
- id=f"switch-{term_safe_id}",
486
- name=term["terminal_id"],
487
- classes="terminal-switch",
488
- )
489
- )
490
- await term_row.mount(
491
- Button(
492
- "✕",
493
- id=f"close-{term_safe_id}",
494
- name=term["terminal_id"],
495
- variant="error",
496
- classes="terminal-close",
497
- )
498
- )
986
+ blocks = [
987
+ _AgentBlockWidget(agent=agent, active_terminal_id=self._active_terminal_id)
988
+ for agent in self._agents
989
+ ]
990
+ await container.mount_all(blocks)
499
991
  except Exception:
500
992
  logger.exception("Failed to render agents list")
501
993
  try:
@@ -507,35 +999,11 @@ class AgentsPanel(Container, can_focus=True):
507
999
  except Exception:
508
1000
  pass
509
1001
  return
510
-
511
- def _make_terminal_label(self, term: dict) -> Text:
512
- """Generate label for a terminal."""
513
- provider = term.get("provider", "")
514
- session_id = term.get("session_id", "")
515
- title = term.get("title", "")
516
-
517
- label = Text(no_wrap=True, overflow="ellipsis")
518
-
519
- # First line: title or provider
520
- if title:
521
- label.append(title)
522
- elif provider:
523
- label.append(provider.capitalize())
524
- else:
525
- label.append("Terminal")
526
-
527
- label.append("\n")
528
-
529
- # Second line: [provider] session_id
530
- if provider:
531
- detail = f"[{provider.capitalize()}]"
532
- else:
533
- detail = ""
534
- if session_id:
535
- detail = f"{detail} #{self._short_id(session_id)}"
536
-
537
- label.append(detail, style="dim")
538
- return label
1002
+ except asyncio.CancelledError:
1003
+ # Best-effort: avoid leaving the UI in a permanently blank state.
1004
+ logger.warning("Agents render cancelled")
1005
+ self._rendered_fingerprint = ""
1006
+ return
539
1007
 
540
1008
  @staticmethod
541
1009
  def _short_id(val: str | None) -> str:
@@ -583,6 +1051,21 @@ class AgentsPanel(Container, can_focus=True):
583
1051
  pass
584
1052
  return None
585
1053
 
1054
+ def _update_active_terminal_ui(self, terminal_id: str | None) -> None:
1055
+ """Update just the active-terminal button classes (no full re-render)."""
1056
+ try:
1057
+ for btn in self.query("Button.terminal-switch"):
1058
+ btn_id = btn.id or ""
1059
+ if not btn_id.startswith("switch-"):
1060
+ continue
1061
+ is_active = bool(terminal_id and (btn.name == terminal_id))
1062
+ if is_active:
1063
+ btn.add_class("active-terminal")
1064
+ else:
1065
+ btn.remove_class("active-terminal")
1066
+ except Exception:
1067
+ return
1068
+
586
1069
  async def on_button_pressed(self, event: Button.Pressed) -> None:
587
1070
  btn_id = event.button.id or ""
588
1071
 
@@ -621,7 +1104,7 @@ class AgentsPanel(Container, can_focus=True):
621
1104
 
622
1105
  if btn_id.startswith("switch-"):
623
1106
  terminal_id = event.button.name or ""
624
- await self._switch_to_terminal(terminal_id)
1107
+ self._request_switch_to_terminal(terminal_id)
625
1108
  return
626
1109
 
627
1110
  if btn_id.startswith("close-"):
@@ -734,6 +1217,16 @@ class AgentsPanel(Container, can_focus=True):
734
1217
  """Create a Claude terminal associated with an agent."""
735
1218
  terminal_id = tmux_manager.new_terminal_id()
736
1219
  context_id = tmux_manager.new_context_id("cc")
1220
+ logger.info(
1221
+ "Create terminal requested: provider=claude terminal_id=%s agent_id=%s workspace=%s "
1222
+ "no_track=%s skip_permissions=%s context_id=%s",
1223
+ terminal_id,
1224
+ agent_id,
1225
+ workspace,
1226
+ no_track,
1227
+ skip_permissions,
1228
+ context_id,
1229
+ )
737
1230
 
738
1231
  # Prepare CODEX_HOME so user can run codex in this terminal
739
1232
  try:
@@ -777,6 +1270,13 @@ class AgentsPanel(Container, can_focus=True):
777
1270
  )
778
1271
 
779
1272
  if created:
1273
+ logger.info(
1274
+ "Create terminal success: provider=claude terminal_id=%s window_id=%s",
1275
+ terminal_id,
1276
+ created.window_id,
1277
+ )
1278
+ if not tmux_manager.focus_right_pane():
1279
+ logger.warning("Create terminal: focus_right_pane failed (provider=claude)")
780
1280
  # Store agent association in database with agent_info_id in source
781
1281
  try:
782
1282
  from ...db import get_database
@@ -794,6 +1294,7 @@ class AgentsPanel(Container, can_focus=True):
794
1294
  except Exception:
795
1295
  pass
796
1296
  else:
1297
+ logger.warning("Create terminal failed: provider=claude terminal_id=%s", terminal_id)
797
1298
  self.app.notify("Failed to create terminal", title="Agent", severity="error")
798
1299
 
799
1300
  async def _create_codex_terminal(self, workspace: str, no_track: bool, agent_id: str) -> None:
@@ -821,6 +1322,15 @@ class AgentsPanel(Container, can_focus=True):
821
1322
 
822
1323
  terminal_id = tmux_manager.new_terminal_id()
823
1324
  context_id = tmux_manager.new_context_id("cx")
1325
+ logger.info(
1326
+ "Create terminal requested: provider=codex terminal_id=%s agent_id=%s workspace=%s "
1327
+ "no_track=%s context_id=%s",
1328
+ terminal_id,
1329
+ agent_id,
1330
+ workspace,
1331
+ no_track,
1332
+ context_id,
1333
+ )
824
1334
 
825
1335
  try:
826
1336
  from ...codex_home import prepare_codex_home
@@ -870,13 +1380,30 @@ class AgentsPanel(Container, can_focus=True):
870
1380
  no_track=no_track,
871
1381
  )
872
1382
 
873
- if not created:
1383
+ if created:
1384
+ logger.info(
1385
+ "Create terminal success: provider=codex terminal_id=%s window_id=%s",
1386
+ terminal_id,
1387
+ created.window_id,
1388
+ )
1389
+ if not tmux_manager.focus_right_pane():
1390
+ logger.warning("Create terminal: focus_right_pane failed (provider=codex)")
1391
+ else:
1392
+ logger.warning("Create terminal failed: provider=codex terminal_id=%s", terminal_id)
874
1393
  self.app.notify("Failed to create terminal", title="Agent", severity="error")
875
1394
 
876
1395
  async def _create_opencode_terminal(self, workspace: str, agent_id: str) -> None:
877
1396
  """Create an Opencode terminal associated with an agent."""
878
1397
  terminal_id = tmux_manager.new_terminal_id()
879
1398
  context_id = tmux_manager.new_context_id("oc")
1399
+ logger.info(
1400
+ "Create terminal requested: provider=opencode terminal_id=%s agent_id=%s workspace=%s "
1401
+ "context_id=%s",
1402
+ terminal_id,
1403
+ agent_id,
1404
+ workspace,
1405
+ context_id,
1406
+ )
880
1407
 
881
1408
  # Prepare CODEX_HOME so user can run codex in this terminal
882
1409
  try:
@@ -913,6 +1440,13 @@ class AgentsPanel(Container, can_focus=True):
913
1440
  )
914
1441
 
915
1442
  if created:
1443
+ logger.info(
1444
+ "Create terminal success: provider=opencode terminal_id=%s window_id=%s",
1445
+ terminal_id,
1446
+ created.window_id,
1447
+ )
1448
+ if not tmux_manager.focus_right_pane():
1449
+ logger.warning("Create terminal: focus_right_pane failed (provider=opencode)")
916
1450
  # Store agent association in database
917
1451
  try:
918
1452
  from ...db import get_database
@@ -930,12 +1464,20 @@ class AgentsPanel(Container, can_focus=True):
930
1464
  except Exception:
931
1465
  pass
932
1466
  else:
1467
+ logger.warning("Create terminal failed: provider=opencode terminal_id=%s", terminal_id)
933
1468
  self.app.notify("Failed to create terminal", title="Agent", severity="error")
934
1469
 
935
1470
  async def _create_zsh_terminal(self, workspace: str, agent_id: str) -> None:
936
1471
  """Create a zsh terminal associated with an agent."""
937
1472
  terminal_id = tmux_manager.new_terminal_id()
938
1473
  context_id = tmux_manager.new_context_id("zsh")
1474
+ logger.info(
1475
+ "Create terminal requested: provider=zsh terminal_id=%s agent_id=%s workspace=%s context_id=%s",
1476
+ terminal_id,
1477
+ agent_id,
1478
+ workspace,
1479
+ context_id,
1480
+ )
939
1481
 
940
1482
  # Prepare CODEX_HOME so user can run codex in this terminal
941
1483
  try:
@@ -970,6 +1512,13 @@ class AgentsPanel(Container, can_focus=True):
970
1512
  )
971
1513
 
972
1514
  if created:
1515
+ logger.info(
1516
+ "Create terminal success: provider=zsh terminal_id=%s window_id=%s",
1517
+ terminal_id,
1518
+ created.window_id,
1519
+ )
1520
+ if not tmux_manager.focus_right_pane():
1521
+ logger.warning("Create terminal: focus_right_pane failed (provider=zsh)")
973
1522
  # Store agent association in database
974
1523
  try:
975
1524
  from ...db import get_database
@@ -987,6 +1536,7 @@ class AgentsPanel(Container, can_focus=True):
987
1536
  except Exception:
988
1537
  pass
989
1538
  else:
1539
+ logger.warning("Create terminal failed: provider=zsh terminal_id=%s", terminal_id)
990
1540
  self.app.notify("Failed to create terminal", title="Agent", severity="error")
991
1541
 
992
1542
  def _install_claude_hooks(self, workspace: str) -> None:
@@ -1040,20 +1590,87 @@ class AgentsPanel(Container, can_focus=True):
1040
1590
  except Exception as e:
1041
1591
  self.app.notify(f"Failed: {e}", title="Agent", severity="error")
1042
1592
 
1043
- async def _switch_to_terminal(self, terminal_id: str) -> None:
1593
+ def _request_switch_to_terminal(self, terminal_id: str) -> None:
1594
+ """Switch terminals without blocking the UI thread."""
1595
+ terminal_id = (terminal_id or "").strip()
1044
1596
  if not terminal_id:
1045
1597
  return
1046
1598
 
1047
- window_id = self._find_window(terminal_id)
1048
- if not window_id:
1049
- self.app.notify("Window not found", title="Agent", severity="warning")
1050
- return
1599
+ self._active_terminal_id = terminal_id
1600
+ self._update_active_terminal_ui(terminal_id)
1601
+ self._rendered_fingerprint = (
1602
+ self._fingerprint(self._agents) + f"|active:{self._active_terminal_id}"
1603
+ )
1604
+
1605
+ self._switch_seq += 1
1606
+ seq = self._switch_seq
1607
+
1608
+ if self._switch_worker is not None and self._switch_worker.state in (
1609
+ WorkerState.PENDING,
1610
+ WorkerState.RUNNING,
1611
+ ):
1612
+ try:
1613
+ self._switch_worker.cancel()
1614
+ except Exception:
1615
+ pass
1616
+
1617
+ agents_snapshot = self._agents
1618
+
1619
+ def work() -> dict:
1620
+ # Prefer cached window_id to avoid a blocking tmux scan on every click.
1621
+ window_id = None
1622
+ try:
1623
+ for agent in agents_snapshot:
1624
+ for term in agent.get("terminals") or []:
1625
+ if term.get("terminal_id") == terminal_id:
1626
+ window_id = (term.get("window_id") or "").strip() or None
1627
+ break
1628
+ if window_id:
1629
+ break
1630
+ except Exception:
1631
+ window_id = None
1632
+
1633
+ if not window_id:
1634
+ # Fall back to querying tmux (best-effort).
1635
+ window_id = self._find_window(terminal_id)
1636
+
1637
+ if not window_id:
1638
+ # If the terminal was closed from within the shell (e.g. `exit`), the DB record can
1639
+ # remain "active" briefly. Trigger a refresh so the stale button disappears.
1640
+ try:
1641
+ from ...db import get_database
1642
+
1643
+ db = get_database(read_only=False)
1644
+ db.update_agent(terminal_id, status="inactive")
1645
+ except Exception:
1646
+ pass
1647
+ return {
1648
+ "seq": seq,
1649
+ "terminal_id": terminal_id,
1650
+ "ok": False,
1651
+ "error": "Window not found",
1652
+ }
1051
1653
 
1052
- if tmux_manager.select_inner_window(window_id):
1053
- tmux_manager.focus_right_pane()
1654
+ if not tmux_manager.select_inner_window(window_id):
1655
+ return {
1656
+ "seq": seq,
1657
+ "terminal_id": terminal_id,
1658
+ "window_id": window_id,
1659
+ "ok": False,
1660
+ "error": "Failed to switch",
1661
+ }
1662
+
1663
+ # Prefer showing the terminal after switching (best-effort).
1664
+ if not tmux_manager.focus_right_pane():
1665
+ logger.warning(
1666
+ "Switch terminal: focus_right_pane failed (terminal_id=%s window_id=%s)",
1667
+ terminal_id,
1668
+ window_id,
1669
+ )
1054
1670
  tmux_manager.clear_attention(window_id)
1055
- else:
1056
- self.app.notify("Failed to switch", title="Agent", severity="error")
1671
+ return {"seq": seq, "terminal_id": terminal_id, "window_id": window_id, "ok": True}
1672
+
1673
+ self._switch_worker = self.run_worker(work, thread=True, exit_on_error=False)
1057
1674
 
1058
1675
  async def _close_terminal(self, terminal_id: str) -> None:
1059
1676
  if not terminal_id:
@@ -1312,6 +1929,4 @@ class AgentsPanel(Container, can_focus=True):
1312
1929
  if copied:
1313
1930
  self.app.notify("Share link copied to clipboard", title="Link", timeout=3)
1314
1931
  else:
1315
- self.app.notify(
1316
- f"Failed to copy. Link: {share_url}", title="Link", severity="warning"
1317
- )
1932
+ self.app.notify(f"Failed to copy. Link: {share_url}", title="Link", severity="warning")