aline-ai 0.7.3__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.
@@ -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,173 @@ 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
+ 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
+
43
253
  class AgentsPanel(Container, can_focus=True):
44
254
  """Panel displaying agent profiles with their associated terminals."""
45
255
 
256
+ REFRESH_INTERVAL_SECONDS = 2.0
257
+ SPINNER_INTERVAL_SECONDS = 0.15
258
+
46
259
  DEFAULT_CSS = """
47
260
  AgentsPanel {
48
261
  height: 100%;
@@ -56,10 +269,11 @@ class AgentsPanel(Container, can_focus=True):
56
269
 
57
270
  AgentsPanel .summary {
58
271
  height: auto;
59
- margin: 0 0 1 0;
272
+ margin: 0;
60
273
  padding: 0;
61
274
  background: transparent;
62
275
  border: none;
276
+ align: right middle;
63
277
  }
64
278
 
65
279
  AgentsPanel Button {
@@ -86,15 +300,22 @@ class AgentsPanel(Container, can_focus=True):
86
300
  background: transparent;
87
301
  }
88
302
 
303
+ AgentsPanel Rule {
304
+ margin: 0;
305
+ }
306
+
89
307
  AgentsPanel .agent-row {
308
+ height: 2;
309
+ margin: 0;
310
+ }
311
+
312
+ AgentsPanel .agent-block {
90
313
  height: auto;
91
- min-height: 2;
92
- margin: 0 0 0 0;
93
314
  }
94
315
 
95
316
  AgentsPanel .agent-row Button.agent-name {
96
317
  width: 1fr;
97
- height: 2;
318
+ height: 1;
98
319
  margin: 0;
99
320
  padding: 0 1;
100
321
  text-align: left;
@@ -102,28 +323,28 @@ class AgentsPanel(Container, can_focus=True):
102
323
  }
103
324
 
104
325
  AgentsPanel .agent-row Button.agent-create {
105
- width: auto;
106
- min-width: 8;
107
- height: 2;
108
- margin-left: 1;
109
- padding: 0 1;
326
+ width: 3;
327
+ min-width: 3;
328
+ height: 1;
329
+ margin-left: 0;
330
+ padding: 0;
110
331
  content-align: center middle;
111
332
  }
112
333
 
113
334
  AgentsPanel .agent-row Button.agent-delete {
114
335
  width: 3;
115
336
  min-width: 3;
116
- height: 2;
117
- margin-left: 1;
337
+ height: 1;
338
+ margin-left: 0;
118
339
  padding: 0;
119
340
  content-align: center middle;
120
341
  }
121
342
 
122
343
  AgentsPanel .agent-row Button.agent-share {
123
344
  width: auto;
124
- min-width: 8;
125
- height: 2;
126
- margin-left: 1;
345
+ min-width: 6;
346
+ height: 1;
347
+ margin-left: 0;
127
348
  padding: 0 1;
128
349
  content-align: center middle;
129
350
  }
@@ -137,25 +358,34 @@ class AgentsPanel(Container, can_focus=True):
137
358
  }
138
359
 
139
360
  AgentsPanel .terminal-row {
140
- height: auto;
141
- min-height: 2;
361
+ height: 1;
142
362
  margin: 0;
143
363
  }
144
364
 
145
365
  AgentsPanel .terminal-row Button.terminal-switch {
146
366
  width: 1fr;
147
- height: 2;
367
+ height: 1;
148
368
  margin: 0;
149
369
  padding: 0 1;
150
370
  text-align: left;
151
- 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;
152
382
  }
153
383
 
154
384
  AgentsPanel .terminal-row Button.terminal-close {
155
385
  width: 3;
156
386
  min-width: 3;
157
- height: 2;
158
- margin-left: 1;
387
+ height: 1;
388
+ margin: 0;
159
389
  padding: 0;
160
390
  content-align: center middle;
161
391
  }
@@ -165,30 +395,58 @@ class AgentsPanel(Container, can_focus=True):
165
395
  super().__init__()
166
396
  self._refresh_lock = asyncio.Lock()
167
397
  self._agents: list[dict] = []
398
+ self._active_terminal_id: str | None = None
168
399
  self._rendered_fingerprint: str = ""
400
+ self._switch_worker: Optional[Worker] = None
401
+ self._switch_seq: int = 0
169
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
170
406
  self._share_worker: Optional[Worker] = None
171
407
  self._sync_worker: Optional[Worker] = None
172
408
  self._share_agent_id: Optional[str] = None
173
409
  self._sync_agent_id: Optional[str] = None
174
410
  self._refresh_timer = None
411
+ self._spinner_timer = None
175
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
176
416
 
177
417
  def compose(self) -> ComposeResult:
178
418
  with Horizontal(classes="summary"):
179
- yield Button("+ Create Agent", id="create-agent", variant="primary")
419
+ yield Button("Add Agent", id="create-agent", variant="primary")
180
420
  with Vertical(id="agents-list", classes="list"):
181
- 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")
182
428
 
183
429
  def on_show(self) -> None:
184
430
  if self._refresh_timer is None:
185
431
  # 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)
432
+ self._refresh_timer = self.set_interval(
433
+ self.REFRESH_INTERVAL_SECONDS, self._on_refresh_timer
434
+ )
187
435
  else:
188
436
  try:
189
437
  self._refresh_timer.resume()
190
438
  except Exception:
191
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
192
450
  self.refresh_data()
193
451
 
194
452
  def on_hide(self) -> None:
@@ -197,10 +455,24 @@ class AgentsPanel(Container, can_focus=True):
197
455
  self._refresh_timer.pause()
198
456
  except Exception:
199
457
  pass
458
+ if self._spinner_timer is not None:
459
+ try:
460
+ self._spinner_timer.pause()
461
+ except Exception:
462
+ pass
200
463
 
201
464
  def _on_refresh_timer(self) -> None:
202
465
  self.refresh_data()
203
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
+
204
476
  def refresh_data(self) -> None:
205
477
  if not self.display:
206
478
  return
@@ -213,7 +485,177 @@ class AgentsPanel(Container, can_focus=True):
213
485
  self._collect_agents, thread=True, exit_on_error=False
214
486
  )
215
487
 
216
- def _collect_agents(self) -> list[dict]:
488
+ def diagnostics_state(self) -> dict[str, object]:
489
+ """Small, safe snapshot for watchdog logging (never raises)."""
490
+ state: dict[str, object] = {}
491
+ try:
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
512
+
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:
217
659
  """Collect agent info with their terminals."""
218
660
  agents: list[dict] = []
219
661
 
@@ -241,15 +683,41 @@ class AgentsPanel(Container, can_focus=True):
241
683
  l.terminal_id: l for l in latest_links if getattr(l, "terminal_id", None)
242
684
  }
243
685
 
686
+ tmux_windows_ok = True
244
687
  try:
245
688
  tmux_windows = tmux_manager.list_inner_windows()
246
689
  except Exception as e:
247
690
  logger.debug(f"Failed to list tmux windows: {e}")
691
+ tmux_windows_ok = False
248
692
  tmux_windows = []
249
693
  terminal_to_window = {
250
694
  w.terminal_id: w for w in tmux_windows if getattr(w, "terminal_id", None)
251
695
  }
252
696
 
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
+
253
721
  # Collect all session_ids for title lookup
254
722
  session_ids: list[str] = []
255
723
  for t in active_terminals:
@@ -260,8 +728,31 @@ class AgentsPanel(Container, can_focus=True):
260
728
  window = terminal_to_window.get(t.id)
261
729
  if window and getattr(window, "session_id", None):
262
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)
263
736
 
264
- titles = self._fetch_session_titles(session_ids)
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 = {}
265
756
 
266
757
  # Map agent_info.id -> list of terminals
267
758
  agent_to_terminals: dict[str, list[dict]] = {}
@@ -284,12 +775,7 @@ class AgentsPanel(Container, can_focus=True):
284
775
  if not agent_info_id:
285
776
  window = terminal_to_window.get(t.id)
286
777
  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
778
+ agent_info_id = session_agent_id.get(window.session_id) or None
293
779
 
294
780
  if agent_info_id:
295
781
  agent_to_terminals.setdefault(agent_info_id, [])
@@ -302,25 +788,32 @@ class AgentsPanel(Container, can_focus=True):
302
788
  else (
303
789
  window.session_id
304
790
  if window and getattr(window, "session_id", None)
305
- else None
791
+ else getattr(t, "session_id", None)
306
792
  )
307
793
  )
308
794
  title = titles.get(session_id, "") if session_id else ""
309
795
 
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
- }
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 "")
323
801
  )
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)
324
817
 
325
818
  for info in agent_infos:
326
819
  terminals = agent_to_terminals.get(info.id, [])
@@ -335,7 +828,7 @@ class AgentsPanel(Container, can_focus=True):
335
828
  }
336
829
  )
337
830
 
338
- return agents
831
+ return {"agents": agents, "active_terminal_id": active_terminal_id}
339
832
 
340
833
  @staticmethod
341
834
  def _fingerprint(agents: list[dict]) -> str:
@@ -346,6 +839,42 @@ class AgentsPanel(Container, can_focus=True):
346
839
  return ""
347
840
 
348
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
+
349
878
  # Handle refresh worker
350
879
  if self._refresh_worker is not None and event.worker is self._refresh_worker:
351
880
  if event.state == WorkerState.ERROR:
@@ -362,20 +891,70 @@ class AgentsPanel(Container, can_focus=True):
362
891
  logger.warning(f"Agents refresh failed: {err}")
363
892
  return
364
893
  elif event.state == WorkerState.SUCCESS:
365
- 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 []
366
902
  self._last_refresh_error_at = None
367
903
  else:
368
904
  return
369
- fp = self._fingerprint(self._agents)
370
- 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:
371
929
  return # nothing changed – skip re-render to avoid flicker
372
930
  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
- )
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)
379
958
  return
380
959
 
381
960
  # Handle share worker
@@ -390,112 +969,20 @@ class AgentsPanel(Container, can_focus=True):
390
969
  async with self._refresh_lock:
391
970
  try:
392
971
  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
- )
972
+ with self.app.batch_update():
973
+ await container.remove_children()
459
974
 
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",
975
+ if not self._agents:
976
+ await container.mount(
977
+ Static("No agents yet. Click 'Add Agent' to add one.")
468
978
  )
469
- )
979
+ return
470
980
 
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
- )
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)
499
986
  except Exception:
500
987
  logger.exception("Failed to render agents list")
501
988
  try:
@@ -507,35 +994,11 @@ class AgentsPanel(Container, can_focus=True):
507
994
  except Exception:
508
995
  pass
509
996
  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
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
539
1002
 
540
1003
  @staticmethod
541
1004
  def _short_id(val: str | None) -> str:
@@ -583,6 +1046,21 @@ class AgentsPanel(Container, can_focus=True):
583
1046
  pass
584
1047
  return None
585
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
+
586
1064
  async def on_button_pressed(self, event: Button.Pressed) -> None:
587
1065
  btn_id = event.button.id or ""
588
1066
 
@@ -621,7 +1099,7 @@ class AgentsPanel(Container, can_focus=True):
621
1099
 
622
1100
  if btn_id.startswith("switch-"):
623
1101
  terminal_id = event.button.name or ""
624
- await self._switch_to_terminal(terminal_id)
1102
+ self._request_switch_to_terminal(terminal_id)
625
1103
  return
626
1104
 
627
1105
  if btn_id.startswith("close-"):
@@ -734,6 +1212,16 @@ class AgentsPanel(Container, can_focus=True):
734
1212
  """Create a Claude terminal associated with an agent."""
735
1213
  terminal_id = tmux_manager.new_terminal_id()
736
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
+ )
737
1225
 
738
1226
  # Prepare CODEX_HOME so user can run codex in this terminal
739
1227
  try:
@@ -777,6 +1265,13 @@ class AgentsPanel(Container, can_focus=True):
777
1265
  )
778
1266
 
779
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)")
780
1275
  # Store agent association in database with agent_info_id in source
781
1276
  try:
782
1277
  from ...db import get_database
@@ -794,6 +1289,7 @@ class AgentsPanel(Container, can_focus=True):
794
1289
  except Exception:
795
1290
  pass
796
1291
  else:
1292
+ logger.warning("Create terminal failed: provider=claude terminal_id=%s", terminal_id)
797
1293
  self.app.notify("Failed to create terminal", title="Agent", severity="error")
798
1294
 
799
1295
  async def _create_codex_terminal(self, workspace: str, no_track: bool, agent_id: str) -> None:
@@ -821,6 +1317,15 @@ class AgentsPanel(Container, can_focus=True):
821
1317
 
822
1318
  terminal_id = tmux_manager.new_terminal_id()
823
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
+ )
824
1329
 
825
1330
  try:
826
1331
  from ...codex_home import prepare_codex_home
@@ -870,13 +1375,30 @@ class AgentsPanel(Container, can_focus=True):
870
1375
  no_track=no_track,
871
1376
  )
872
1377
 
873
- if not created:
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,
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)
874
1388
  self.app.notify("Failed to create terminal", title="Agent", severity="error")
875
1389
 
876
1390
  async def _create_opencode_terminal(self, workspace: str, agent_id: str) -> None:
877
1391
  """Create an Opencode terminal associated with an agent."""
878
1392
  terminal_id = tmux_manager.new_terminal_id()
879
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
+ )
880
1402
 
881
1403
  # Prepare CODEX_HOME so user can run codex in this terminal
882
1404
  try:
@@ -913,6 +1435,13 @@ class AgentsPanel(Container, can_focus=True):
913
1435
  )
914
1436
 
915
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)")
916
1445
  # Store agent association in database
917
1446
  try:
918
1447
  from ...db import get_database
@@ -930,12 +1459,20 @@ class AgentsPanel(Container, can_focus=True):
930
1459
  except Exception:
931
1460
  pass
932
1461
  else:
1462
+ logger.warning("Create terminal failed: provider=opencode terminal_id=%s", terminal_id)
933
1463
  self.app.notify("Failed to create terminal", title="Agent", severity="error")
934
1464
 
935
1465
  async def _create_zsh_terminal(self, workspace: str, agent_id: str) -> None:
936
1466
  """Create a zsh terminal associated with an agent."""
937
1467
  terminal_id = tmux_manager.new_terminal_id()
938
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
+ )
939
1476
 
940
1477
  # Prepare CODEX_HOME so user can run codex in this terminal
941
1478
  try:
@@ -970,6 +1507,13 @@ class AgentsPanel(Container, can_focus=True):
970
1507
  )
971
1508
 
972
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)")
973
1517
  # Store agent association in database
974
1518
  try:
975
1519
  from ...db import get_database
@@ -987,6 +1531,7 @@ class AgentsPanel(Container, can_focus=True):
987
1531
  except Exception:
988
1532
  pass
989
1533
  else:
1534
+ logger.warning("Create terminal failed: provider=zsh terminal_id=%s", terminal_id)
990
1535
  self.app.notify("Failed to create terminal", title="Agent", severity="error")
991
1536
 
992
1537
  def _install_claude_hooks(self, workspace: str) -> None:
@@ -1040,20 +1585,87 @@ class AgentsPanel(Container, can_focus=True):
1040
1585
  except Exception as e:
1041
1586
  self.app.notify(f"Failed: {e}", title="Agent", severity="error")
1042
1587
 
1043
- 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()
1044
1591
  if not terminal_id:
1045
1592
  return
1046
1593
 
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
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
1627
+
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
+ }
1051
1648
 
1052
- if tmux_manager.select_inner_window(window_id):
1053
- tmux_manager.focus_right_pane()
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
+ )
1054
1665
  tmux_manager.clear_attention(window_id)
1055
- else:
1056
- 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)
1057
1669
 
1058
1670
  async def _close_terminal(self, terminal_id: str) -> None:
1059
1671
  if not terminal_id:
@@ -1312,6 +1924,4 @@ class AgentsPanel(Container, can_focus=True):
1312
1924
  if copied:
1313
1925
  self.app.notify("Share link copied to clipboard", title="Link", timeout=3)
1314
1926
  else:
1315
- self.app.notify(
1316
- f"Failed to copy. Link: {share_url}", title="Link", severity="warning"
1317
- )
1927
+ self.app.notify(f"Failed to copy. Link: {share_url}", title="Link", severity="warning")