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,7 +7,7 @@ from typing import Optional
7
7
 
8
8
  from textual.app import ComposeResult
9
9
  from textual.binding import Binding
10
- from textual.containers import Container, Horizontal, Vertical
10
+ from textual.containers import Container, Horizontal
11
11
  from textual.screen import ModalScreen
12
12
  from textual.widgets import Button, Input, Label, Static
13
13
  from textual.worker import Worker, WorkerState
@@ -34,7 +34,8 @@ class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
34
34
  }
35
35
 
36
36
  CreateAgentInfoScreen #create-agent-info-root {
37
- width: 65;
37
+ width: 100%;
38
+ max-width: 65;
38
39
  height: auto;
39
40
  max-height: 80%;
40
41
  padding: 1 2;
@@ -57,15 +58,6 @@ class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
57
58
 
58
59
  CreateAgentInfoScreen Input {
59
60
  margin-top: 0;
60
- border: none;
61
- }
62
-
63
- CreateAgentInfoScreen #or-separator {
64
- height: auto;
65
- margin-top: 1;
66
- margin-bottom: 0;
67
- text-align: center;
68
- color: $text-muted;
69
61
  }
70
62
 
71
63
  CreateAgentInfoScreen #import-status {
@@ -74,23 +66,13 @@ class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
74
66
  color: $text-muted;
75
67
  }
76
68
 
77
- CreateAgentInfoScreen #create-buttons {
69
+ CreateAgentInfoScreen #add-buttons {
78
70
  height: auto;
79
71
  margin-top: 1;
80
72
  align: right middle;
81
73
  }
82
74
 
83
- CreateAgentInfoScreen #import-buttons {
84
- height: auto;
85
- margin-top: 1;
86
- align: right middle;
87
- }
88
-
89
- CreateAgentInfoScreen #create-buttons Button {
90
- margin-left: 1;
91
- }
92
-
93
- CreateAgentInfoScreen #import-buttons Button {
75
+ CreateAgentInfoScreen #add-buttons Button {
94
76
  margin-left: 1;
95
77
  }
96
78
  """
@@ -103,44 +85,37 @@ class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
103
85
 
104
86
  def compose(self) -> ComposeResult:
105
87
  with Container(id="create-agent-info-root"):
106
- yield Static("Create Agent Profile", id="create-agent-info-title")
107
-
108
- # --- Create New section ---
109
- yield Label("Name", classes="section-label")
110
- yield Input(placeholder=self._default_name, id="agent-name")
88
+ yield Static("Add Agent", id="create-agent-info-title")
111
89
 
112
- with Horizontal(id="create-buttons"):
113
- yield Button("Cancel", id="cancel")
114
- yield Button("Create", id="create", variant="primary")
115
-
116
- # --- Separator ---
117
- yield Static("-- Or --", id="or-separator")
118
-
119
- # --- Import from Link section ---
120
- yield Label("Import from Link", classes="section-label")
121
- yield Input(placeholder="https://realign-server.vercel.app/share/...", id="share-url")
122
-
123
- yield Label("Password (optional)", classes="section-label")
124
- yield Input(placeholder="Leave blank if not password-protected", id="share-password", password=True)
90
+ yield Label("Name or Share Link", classes="section-label")
91
+ yield Input(placeholder=self._default_name, id="agent-input")
125
92
 
126
93
  yield Static("", id="import-status")
127
94
 
128
- with Horizontal(id="import-buttons"):
129
- yield Button("Import", id="import", variant="primary")
95
+ with Horizontal(id="add-buttons"):
96
+ yield Button("Cancel", id="cancel")
97
+ yield Button("Add", id="add", variant="primary")
130
98
 
131
99
  def on_mount(self) -> None:
132
- self.query_one("#agent-name", Input).focus()
100
+ agent_input = self.query_one("#agent-input", Input)
101
+ agent_input.styles.border = ("round", "black")
102
+ agent_input.focus()
103
+ for btn in (self.query_one("#cancel", Button), self.query_one("#add", Button)):
104
+ btn.styles.border = ("round", "black")
105
+ btn.styles.text_align = "center"
106
+ btn.styles.content_align = ("center", "middle")
133
107
 
134
108
  def action_close(self) -> None:
135
109
  self.dismiss(None)
136
110
 
137
111
  def _set_busy(self, busy: bool) -> None:
138
- self.query_one("#agent-name", Input).disabled = busy
139
- self.query_one("#share-url", Input).disabled = busy
140
- self.query_one("#share-password", Input).disabled = busy
141
- self.query_one("#create", Button).disabled = busy
112
+ self.query_one("#agent-input", Input).disabled = busy
113
+ self.query_one("#add", Button).disabled = busy
142
114
  self.query_one("#cancel", Button).disabled = busy
143
- self.query_one("#import", Button).disabled = busy
115
+
116
+ def _is_share_link(self, value: str) -> bool:
117
+ """Check if the input looks like a share link."""
118
+ return "/share/" in value
144
119
 
145
120
  async def on_button_pressed(self, event: Button.Pressed) -> None:
146
121
  button_id = event.button.id or ""
@@ -149,28 +124,29 @@ class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
149
124
  self.dismiss(None)
150
125
  return
151
126
 
152
- if button_id == "create":
153
- await self._create_agent()
154
- return
155
-
156
- if button_id == "import":
157
- await self._import_agent()
127
+ if button_id == "add":
128
+ await self._add_agent()
158
129
  return
159
130
 
160
131
  async def on_input_submitted(self, event: Input.Submitted) -> None:
161
132
  """Handle enter key in input fields."""
162
- input_id = event.input.id or ""
163
- if input_id == "agent-name":
164
- await self._create_agent()
165
- elif input_id in ("share-url", "share-password"):
166
- await self._import_agent()
133
+ if event.input.id == "agent-input":
134
+ await self._add_agent()
167
135
 
168
- async def _create_agent(self) -> None:
136
+ async def _add_agent(self) -> None:
137
+ """Create a new agent or import from link based on input content."""
138
+ value = self.query_one("#agent-input", Input).value.strip()
139
+
140
+ if self._is_share_link(value):
141
+ await self._import_agent(value)
142
+ else:
143
+ await self._create_agent(value)
144
+
145
+ async def _create_agent(self, name_input: str) -> None:
169
146
  """Create the agent profile."""
170
147
  try:
171
148
  from ...db import get_database
172
149
 
173
- name_input = self.query_one("#agent-name", Input).value.strip()
174
150
  name = name_input or self._default_name
175
151
 
176
152
  agent_id = str(uuid.uuid4())
@@ -187,21 +163,8 @@ class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
187
163
  logger.error(f"Failed to create agent: {e}")
188
164
  self.app.notify(f"Failed to create agent: {e}", severity="error")
189
165
 
190
- async def _import_agent(self) -> None:
166
+ async def _import_agent(self, share_url: str) -> None:
191
167
  """Import an agent from a share link."""
192
- share_url = self.query_one("#share-url", Input).value.strip()
193
- password = self.query_one("#share-password", Input).value.strip() or None
194
-
195
- if not share_url:
196
- self.app.notify("Please enter a share URL", severity="warning")
197
- self.query_one("#share-url", Input).focus()
198
- return
199
-
200
- if "/share/" not in share_url:
201
- self.app.notify("Invalid share URL format", severity="warning")
202
- self.query_one("#share-url", Input).focus()
203
- return
204
-
205
168
  status = self.query_one("#import-status", Static)
206
169
  status.update("Importing...")
207
170
  self._set_busy(True)
@@ -209,7 +172,7 @@ class CreateAgentInfoScreen(ModalScreen[Optional[dict]]):
209
172
  def do_import() -> dict:
210
173
  from ...commands.import_shares import import_agent_from_share
211
174
 
212
- return import_agent_from_share(share_url, password=password)
175
+ return import_agent_from_share(share_url, password=None)
213
176
 
214
177
  self._import_worker = self.run_worker(do_import, thread=True, exit_on_error=False)
215
178
 
@@ -14,6 +14,7 @@ import shutil
14
14
  import stat
15
15
  import subprocess
16
16
  import sys
17
+ import threading
17
18
  import time
18
19
  import traceback
19
20
  import uuid
@@ -47,6 +48,10 @@ OPT_ATTENTION = "@aline_attention"
47
48
  OPT_CREATED_AT = "@aline_created_at"
48
49
  OPT_NO_TRACK = "@aline_no_track"
49
50
 
51
+ # Default outer layout preferences (tmux mode).
52
+ # Note: tmux "pixels" are terminal cell columns; Textual has no notion of pixels.
53
+ DEFAULT_DASHBOARD_PANE_WIDTH_COLS = 45
54
+
50
55
 
51
56
  @dataclass(frozen=True)
52
57
  class InnerWindow:
@@ -62,6 +67,9 @@ class InnerWindow:
62
67
  attention: str | None = None # "permission_request", "stop", or None
63
68
  created_at: float | None = None # Unix timestamp when window was created
64
69
  no_track: bool = False # Whether tracking is disabled for this terminal
70
+ pane_pid: int | None = None # PID of the initial process in the pane
71
+ pane_current_command: str | None = None # Foreground process in the pane
72
+ pane_tty: str | None = None # Controlling TTY for processes in the pane
65
73
 
66
74
 
67
75
  def tmux_available() -> bool:
@@ -93,26 +101,31 @@ def tmux_version() -> tuple[int, int] | None:
93
101
  return int(match.group(1)), int(match.group(2))
94
102
 
95
103
 
96
- def _run_tmux(args: Sequence[str], *, capture: bool = False) -> subprocess.CompletedProcess[str]:
97
- return subprocess.run(
98
- ["tmux", *args],
99
- text=True,
100
- capture_output=capture,
101
- check=False,
102
- )
104
+ def _run_tmux(
105
+ args: Sequence[str], *, capture: bool = False, timeout_s: float | None = None
106
+ ) -> subprocess.CompletedProcess[str]:
107
+ kwargs: dict[str, object] = {
108
+ "text": True,
109
+ "capture_output": capture,
110
+ "check": False,
111
+ }
112
+ # Keep keyword list minimal for test fakes that don't accept extra kwargs.
113
+ if timeout_s is not None:
114
+ kwargs["timeout"] = timeout_s
115
+ return subprocess.run(["tmux", *args], **kwargs) # type: ignore[arg-type]
103
116
 
104
117
 
105
118
  def _run_outer_tmux(
106
- args: Sequence[str], *, capture: bool = False
119
+ args: Sequence[str], *, capture: bool = False, timeout_s: float | None = None
107
120
  ) -> subprocess.CompletedProcess[str]:
108
121
  """Run tmux commands against the dedicated outer server socket."""
109
- return _run_tmux(["-L", OUTER_SOCKET, *args], capture=capture)
122
+ return _run_tmux(["-L", OUTER_SOCKET, *args], capture=capture, timeout_s=timeout_s)
110
123
 
111
124
 
112
125
  def _run_inner_tmux(
113
- args: Sequence[str], *, capture: bool = False
126
+ args: Sequence[str], *, capture: bool = False, timeout_s: float | None = None
114
127
  ) -> subprocess.CompletedProcess[str]:
115
- return _run_tmux(["-L", INNER_SOCKET, *args], capture=capture)
128
+ return _run_tmux(["-L", INNER_SOCKET, *args], capture=capture, timeout_s=timeout_s)
116
129
 
117
130
 
118
131
  def _python_dashboard_command() -> str:
@@ -256,6 +269,12 @@ def _load_terminal_state_from_json() -> dict[str, dict[str, str]]:
256
269
  return {}
257
270
 
258
271
 
272
+ _TERMINAL_STATE_CACHE_LOCK = threading.Lock()
273
+ _TERMINAL_STATE_CACHE: dict[str, dict[str, str]] | None = None
274
+ _TERMINAL_STATE_CACHE_AT: float = 0.0
275
+ _TERMINAL_STATE_CACHE_TTL_S: float = 1.5
276
+
277
+
259
278
  def _load_terminal_state() -> dict[str, dict[str, str]]:
260
279
  """Load terminal state.
261
280
 
@@ -265,6 +284,13 @@ def _load_terminal_state() -> dict[str, dict[str, str]]:
265
284
 
266
285
  Merges both sources, with DB taking precedence.
267
286
  """
287
+ global _TERMINAL_STATE_CACHE, _TERMINAL_STATE_CACHE_AT
288
+ now = time.monotonic()
289
+ with _TERMINAL_STATE_CACHE_LOCK:
290
+ cache = _TERMINAL_STATE_CACHE
291
+ if cache is not None and (now - _TERMINAL_STATE_CACHE_AT) <= _TERMINAL_STATE_CACHE_TTL_S:
292
+ return dict(cache)
293
+
268
294
  # Phase 1: Load from database
269
295
  db_state = _load_terminal_state_from_db()
270
296
 
@@ -275,6 +301,10 @@ def _load_terminal_state() -> dict[str, dict[str, str]]:
275
301
  result = dict(json_state)
276
302
  result.update(db_state)
277
303
 
304
+ with _TERMINAL_STATE_CACHE_LOCK:
305
+ _TERMINAL_STATE_CACHE = dict(result)
306
+ _TERMINAL_STATE_CACHE_AT = time.monotonic()
307
+
278
308
  return result
279
309
 
280
310
 
@@ -471,6 +501,7 @@ def bootstrap_dashboard_into_tmux() -> None:
471
501
 
472
502
  # Enable mouse for the managed session only.
473
503
  _run_outer_tmux(["set-option", "-t", OUTER_SESSION, "mouse", "on"])
504
+ _disable_outer_border_resize()
474
505
 
475
506
  # Disable status bar for cleaner UI (Aline sessions only).
476
507
  _run_outer_tmux(["set-option", "-t", OUTER_SESSION, "status", "off"])
@@ -506,6 +537,9 @@ def bootstrap_dashboard_into_tmux() -> None:
506
537
  )
507
538
  _run_outer_tmux(["select-window", "-t", f"{OUTER_SESSION}:{OUTER_WINDOW}"])
508
539
 
540
+ # Best-effort: enforce a stable dashboard pane width if the terminal pane already exists.
541
+ _set_outer_dashboard_pane_width(DEFAULT_DASHBOARD_PANE_WIDTH_COLS)
542
+
509
543
  # Sanity-check before exec'ing into tmux attach. If this fails, fall back to non-tmux mode.
510
544
  ready = _run_outer_tmux(["has-session", "-t", OUTER_SESSION], capture=True)
511
545
  if ready.returncode != 0:
@@ -691,6 +725,7 @@ def ensure_right_pane(width_percent: int = 50) -> bool:
691
725
  )
692
726
  panes = _parse_lines(panes_out)
693
727
  if len(panes) >= 2:
728
+ _set_outer_dashboard_pane_width(DEFAULT_DASHBOARD_PANE_WIDTH_COLS)
694
729
  return True
695
730
 
696
731
  # Split from the dashboard pane to keep it on the left.
@@ -705,9 +740,144 @@ def ensure_right_pane(width_percent: int = 50) -> bool:
705
740
  f"{OUTER_SESSION}:{OUTER_WINDOW}.0",
706
741
  "-d",
707
742
  attach_cmd,
708
- ]
743
+ ],
744
+ capture=True,
709
745
  )
710
- return split.returncode == 0
746
+ if split.returncode != 0:
747
+ detail = (split.stderr or split.stdout or "").strip()
748
+ if detail:
749
+ logger.warning(f"ensure_right_pane split-window failed: {detail}")
750
+ if split.returncode == 0:
751
+ _set_outer_dashboard_pane_width(DEFAULT_DASHBOARD_PANE_WIDTH_COLS)
752
+ return True
753
+ return False
754
+
755
+
756
+ def ensure_right_pane_ready(width_percent: int = 50) -> bool:
757
+ """Ensure the right pane exists and is attached to the inner tmux session."""
758
+ try:
759
+ ok = ensure_right_pane(width_percent)
760
+ except TypeError:
761
+ # Tests and some callers monkeypatch ensure_right_pane() as a no-arg lambda.
762
+ ok = ensure_right_pane()
763
+ if not ok:
764
+ return False
765
+
766
+ # Best-effort: enforce a stable dashboard pane width whenever we touch the outer layout.
767
+ _set_outer_dashboard_pane_width(DEFAULT_DASHBOARD_PANE_WIDTH_COLS)
768
+
769
+ attach_cmd = shlex.join(["tmux", "-L", INNER_SOCKET, "attach", "-t", INNER_SESSION])
770
+
771
+ # If the right pane exists but isn't running `tmux attach`, it may look "blank" or stale.
772
+ try:
773
+ proc = _run_outer_tmux(
774
+ [
775
+ "display-message",
776
+ "-p",
777
+ "-t",
778
+ f"{OUTER_SESSION}:{OUTER_WINDOW}.1",
779
+ "#{pane_current_command}",
780
+ ],
781
+ capture=True,
782
+ )
783
+ current_cmd = (proc.stdout or "").strip()
784
+ except Exception:
785
+ current_cmd = ""
786
+
787
+ if current_cmd and current_cmd != "tmux":
788
+ respawn = _run_outer_tmux(
789
+ [
790
+ "respawn-pane",
791
+ "-k",
792
+ "-t",
793
+ f"{OUTER_SESSION}:{OUTER_WINDOW}.1",
794
+ attach_cmd,
795
+ ],
796
+ capture=True,
797
+ )
798
+ if respawn.returncode != 0:
799
+ detail = (respawn.stderr or respawn.stdout or "").strip()
800
+ if detail:
801
+ logger.warning(f"ensure_right_pane_ready respawn failed: {detail}")
802
+ return False
803
+
804
+ return True
805
+
806
+
807
+ def _disable_outer_border_resize() -> None:
808
+ """Disable mouse drag-to-resize on pane borders (outer dashboard tmux server only)."""
809
+ try:
810
+ # tmux enables border resizing via MouseDrag1Border. Unbind it on our dedicated server
811
+ # to avoid accidental resizing when selecting text near the pane divider.
812
+ for key in ("MouseDrag1Border", "MouseDown1Border", "MouseDragEnd1Border", "MouseUp1Border"):
813
+ _run_outer_tmux(["unbind-key", "-T", "root", key], capture=True)
814
+ except Exception:
815
+ return
816
+
817
+
818
+ def _set_outer_dashboard_pane_width(width_cols: int) -> None:
819
+ """Best-effort: size the left dashboard pane to a fixed width (in terminal columns)."""
820
+ try:
821
+ w = int(width_cols)
822
+ if w <= 0:
823
+ return
824
+ except Exception:
825
+ return
826
+
827
+ try:
828
+ # Avoid assuming pane indexes are 0/1. Determine the leftmost pane by geometry.
829
+ panes_out = (
830
+ _run_outer_tmux(
831
+ [
832
+ "list-panes",
833
+ "-t",
834
+ f"{OUTER_SESSION}:{OUTER_WINDOW}",
835
+ "-F",
836
+ "#{pane_id}\t#{pane_left}\t#{pane_index}",
837
+ ],
838
+ capture=True,
839
+ timeout_s=0.2,
840
+ ).stdout
841
+ or ""
842
+ )
843
+ panes: list[tuple[int, int, str]] = []
844
+ for line in _parse_lines(panes_out):
845
+ parts = line.split("\t")
846
+ if len(parts) < 2:
847
+ continue
848
+ pane_id = (parts[0] or "").strip()
849
+ if not pane_id:
850
+ continue
851
+ try:
852
+ pane_left = int((parts[1] or "0").strip())
853
+ except Exception:
854
+ continue
855
+ pane_index = 0
856
+ if len(parts) > 2 and (parts[2] or "").strip():
857
+ try:
858
+ pane_index = int(parts[2].strip())
859
+ except Exception:
860
+ pane_index = 0
861
+ panes.append((pane_left, pane_index, pane_id))
862
+
863
+ # Only enforce widths when there are two panes (dashboard + terminal).
864
+ if len(panes) < 2:
865
+ return
866
+
867
+ panes.sort(key=lambda t: (t[0], t[1]))
868
+ leftmost_pane_id = panes[0][2]
869
+ _run_outer_tmux(
870
+ ["resize-pane", "-t", leftmost_pane_id, "-x", str(w)],
871
+ capture=True,
872
+ timeout_s=0.2,
873
+ )
874
+ except Exception:
875
+ return
876
+
877
+
878
+ def enforce_outer_dashboard_pane_width(width_cols: int = DEFAULT_DASHBOARD_PANE_WIDTH_COLS) -> None:
879
+ """Best-effort: enforce the fixed dashboard pane width for the outer tmux layout."""
880
+ _set_outer_dashboard_pane_width(width_cols)
711
881
 
712
882
 
713
883
  def list_inner_windows() -> list[InnerWindow]:
@@ -745,7 +915,7 @@ def list_inner_windows() -> list[InnerWindow]:
745
915
  + OPT_CREATED_AT
746
916
  + "}\t#{"
747
917
  + OPT_NO_TRACK
748
- + "}",
918
+ + "}\t#{pane_pid}\t#{pane_current_command}\t#{pane_tty}",
749
919
  ],
750
920
  capture=True,
751
921
  ).stdout
@@ -775,6 +945,15 @@ def list_inner_windows() -> list[InnerWindow]:
775
945
  pass
776
946
  no_track_str = parts[11] if len(parts) > 11 and parts[11] else None
777
947
  no_track = no_track_str == "1"
948
+ pane_pid_str = parts[12] if len(parts) > 12 and parts[12] else None
949
+ pane_pid: int | None = None
950
+ if pane_pid_str:
951
+ try:
952
+ pane_pid = int(pane_pid_str)
953
+ except ValueError:
954
+ pass
955
+ pane_current_command = parts[13] if len(parts) > 13 and parts[13] else None
956
+ pane_tty = parts[14] if len(parts) > 14 and parts[14] else None
778
957
 
779
958
  if terminal_id:
780
959
  persisted = state.get(terminal_id) or {}
@@ -833,6 +1012,9 @@ def list_inner_windows() -> list[InnerWindow]:
833
1012
  attention=attention,
834
1013
  created_at=created_at,
835
1014
  no_track=no_track,
1015
+ pane_pid=pane_pid,
1016
+ pane_current_command=pane_current_command,
1017
+ pane_tty=pane_tty,
836
1018
  )
837
1019
  )
838
1020
  # Sort by creation time (newest first). Windows without created_at go to the bottom.
@@ -840,6 +1022,33 @@ def list_inner_windows() -> list[InnerWindow]:
840
1022
  return windows
841
1023
 
842
1024
 
1025
+ def list_outer_panes(*, timeout_s: float = 0.2) -> list[str]:
1026
+ """List panes in the outer dashboard window (best-effort).
1027
+
1028
+ Intended for lightweight watchdog checks; returns tab-delimited lines in the same
1029
+ format as `collect_tmux_debug_state()["outer_panes"]["stdout"]`.
1030
+ """
1031
+ if not tmux_available():
1032
+ return []
1033
+ try:
1034
+ proc = _run_outer_tmux(
1035
+ [
1036
+ "list-panes",
1037
+ "-t",
1038
+ f"{OUTER_SESSION}:{OUTER_WINDOW}",
1039
+ "-F",
1040
+ "#{pane_index}\t#{pane_active}\t#{pane_current_command}\t#{pane_pid}\t#{pane_tty}",
1041
+ ],
1042
+ capture=True,
1043
+ timeout_s=timeout_s,
1044
+ )
1045
+ except Exception:
1046
+ return []
1047
+ if proc.returncode != 0:
1048
+ return []
1049
+ return [ln for ln in (proc.stdout or "").splitlines() if ln.strip()]
1050
+
1051
+
843
1052
  def set_inner_window_options(window_id: str, options: dict[str, str]) -> bool:
844
1053
  import time as _time
845
1054
 
@@ -874,7 +1083,8 @@ def create_inner_window(
874
1083
 
875
1084
  t0 = _time.time()
876
1085
  logger.debug("[PERF] create_inner_window START")
877
- if not ensure_right_pane():
1086
+ if not ensure_right_pane_ready():
1087
+ logger.warning("create_inner_window: right pane unavailable")
878
1088
  return None
879
1089
  logger.debug(f"[PERF] create_inner_window ensure_right_pane: {_time.time() - t0:.3f}s")
880
1090
 
@@ -903,6 +1113,9 @@ def create_inner_window(
903
1113
  )
904
1114
  logger.debug(f"[PERF] create_inner_window new-window: {_time.time() - t2:.3f}s")
905
1115
  if proc.returncode != 0:
1116
+ detail = (proc.stderr or proc.stdout or "").strip()
1117
+ if detail:
1118
+ logger.warning(f"create_inner_window new-window failed: {detail}")
906
1119
  return None
907
1120
 
908
1121
  created = _parse_lines(proc.stdout or "")
@@ -943,16 +1156,26 @@ def create_inner_window(
943
1156
 
944
1157
 
945
1158
  def select_inner_window(window_id: str) -> bool:
946
- if not ensure_right_pane():
1159
+ if not ensure_right_pane_ready():
1160
+ return False
1161
+ proc = _run_inner_tmux(["select-window", "-t", window_id], capture=True)
1162
+ if proc.returncode != 0:
1163
+ detail = (proc.stderr or proc.stdout or "").strip()
1164
+ if detail:
1165
+ logger.warning(f"select_inner_window failed ({window_id}): {detail}")
947
1166
  return False
948
- return _run_inner_tmux(["select-window", "-t", window_id]).returncode == 0
1167
+ return True
949
1168
 
950
1169
 
951
1170
  def focus_right_pane() -> bool:
952
1171
  """Focus the right pane (terminal area) in the outer tmux layout."""
953
- return (
954
- _run_outer_tmux(["select-pane", "-t", f"{OUTER_SESSION}:{OUTER_WINDOW}.1"]).returncode == 0
955
- )
1172
+ proc = _run_outer_tmux(["select-pane", "-t", f"{OUTER_SESSION}:{OUTER_WINDOW}.1"], capture=True)
1173
+ if proc.returncode != 0:
1174
+ detail = (proc.stderr or proc.stdout or "").strip()
1175
+ if detail:
1176
+ logger.warning(f"focus_right_pane failed: {detail}")
1177
+ return False
1178
+ return True
956
1179
 
957
1180
 
958
1181
  def clear_attention(window_id: str) -> bool:
@@ -992,3 +1215,94 @@ def get_active_context_id(*, allowed_providers: set[str] | None = None) -> str |
992
1215
 
993
1216
  context_id = (active.context_id or "").strip()
994
1217
  return context_id or None
1218
+
1219
+
1220
+ def collect_tmux_debug_state() -> dict[str, object]:
1221
+ """Best-effort snapshot of tmux state for diagnosing blank/stuck panes.
1222
+
1223
+ This must be non-intrusive: it should not create sessions or change state.
1224
+ """
1225
+
1226
+ def _cap(proc: subprocess.CompletedProcess[str] | None) -> dict[str, object]:
1227
+ if proc is None:
1228
+ return {}
1229
+
1230
+ def _trim(s: str | None) -> str:
1231
+ text = (s or "").strip()
1232
+ if len(text) > 4000:
1233
+ return text[:4000] + "…(truncated)"
1234
+ return text
1235
+
1236
+ return {
1237
+ "rc": int(getattr(proc, "returncode", -1)),
1238
+ "stdout": _trim(getattr(proc, "stdout", "")),
1239
+ "stderr": _trim(getattr(proc, "stderr", "")),
1240
+ }
1241
+
1242
+ state: dict[str, object] = {
1243
+ "tmux_available": tmux_available(),
1244
+ "in_tmux": in_tmux(),
1245
+ "managed_env": managed_env_enabled(),
1246
+ "outer_socket": OUTER_SOCKET,
1247
+ "inner_socket": INNER_SOCKET,
1248
+ "outer_session": OUTER_SESSION,
1249
+ "outer_window": OUTER_WINDOW,
1250
+ "inner_session": INNER_SESSION,
1251
+ }
1252
+
1253
+ if not tmux_available():
1254
+ return state
1255
+
1256
+ try:
1257
+ state["outer_has_session"] = _cap(
1258
+ _run_outer_tmux(["has-session", "-t", OUTER_SESSION], capture=True, timeout_s=0.5)
1259
+ )
1260
+ state["outer_panes"] = _cap(
1261
+ _run_outer_tmux(
1262
+ [
1263
+ "list-panes",
1264
+ "-t",
1265
+ f"{OUTER_SESSION}:{OUTER_WINDOW}",
1266
+ "-F",
1267
+ "#{pane_index}\t#{pane_active}\t#{pane_current_command}\t#{pane_pid}\t#{pane_tty}",
1268
+ ],
1269
+ capture=True,
1270
+ timeout_s=0.5,
1271
+ )
1272
+ )
1273
+ except Exception:
1274
+ pass
1275
+
1276
+ try:
1277
+ state["inner_has_session"] = _cap(
1278
+ _run_inner_tmux(["has-session", "-t", INNER_SESSION], capture=True, timeout_s=0.5)
1279
+ )
1280
+ state["inner_windows"] = _cap(
1281
+ _run_inner_tmux(
1282
+ [
1283
+ "list-windows",
1284
+ "-t",
1285
+ INNER_SESSION,
1286
+ "-F",
1287
+ "#{window_id}\t#{window_index}\t#{window_name}\t#{window_active}\t#{"
1288
+ + OPT_TERMINAL_ID
1289
+ + "}\t#{"
1290
+ + OPT_PROVIDER
1291
+ + "}\t#{"
1292
+ + OPT_SESSION_TYPE
1293
+ + "}\t#{"
1294
+ + OPT_SESSION_ID
1295
+ + "}\t#{"
1296
+ + OPT_CONTEXT_ID
1297
+ + "}\t#{"
1298
+ + OPT_ATTENTION
1299
+ + "}",
1300
+ ],
1301
+ capture=True,
1302
+ timeout_s=0.5,
1303
+ )
1304
+ )
1305
+ except Exception:
1306
+ pass
1307
+
1308
+ return state