aline-ai 0.5.3__py3-none-any.whl → 0.5.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.
Files changed (80) hide show
  1. {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/METADATA +1 -1
  2. aline_ai-0.5.5.dist-info/RECORD +93 -0
  3. realign/__init__.py +1 -1
  4. realign/adapters/antigravity.py +28 -20
  5. realign/adapters/base.py +46 -50
  6. realign/adapters/claude.py +14 -14
  7. realign/adapters/codex.py +7 -7
  8. realign/adapters/gemini.py +11 -11
  9. realign/adapters/registry.py +14 -10
  10. realign/claude_detector.py +2 -2
  11. realign/claude_hooks/__init__.py +3 -3
  12. realign/claude_hooks/permission_request_hook.py +35 -0
  13. realign/claude_hooks/permission_request_hook_installer.py +31 -32
  14. realign/claude_hooks/stop_hook.py +4 -1
  15. realign/claude_hooks/stop_hook_installer.py +30 -31
  16. realign/cli.py +24 -0
  17. realign/codex_detector.py +11 -11
  18. realign/commands/add.py +361 -35
  19. realign/commands/config.py +3 -12
  20. realign/commands/context.py +3 -1
  21. realign/commands/export_shares.py +86 -127
  22. realign/commands/import_shares.py +145 -155
  23. realign/commands/init.py +166 -30
  24. realign/commands/restore.py +18 -6
  25. realign/commands/search.py +14 -42
  26. realign/commands/upgrade.py +155 -11
  27. realign/commands/watcher.py +98 -219
  28. realign/commands/worker.py +29 -6
  29. realign/config.py +25 -20
  30. realign/context.py +1 -3
  31. realign/dashboard/app.py +4 -4
  32. realign/dashboard/screens/create_event.py +3 -1
  33. realign/dashboard/screens/event_detail.py +14 -6
  34. realign/dashboard/screens/session_detail.py +3 -1
  35. realign/dashboard/screens/share_import.py +7 -3
  36. realign/dashboard/tmux_manager.py +91 -22
  37. realign/dashboard/widgets/config_panel.py +85 -1
  38. realign/dashboard/widgets/events_table.py +3 -1
  39. realign/dashboard/widgets/header.py +1 -0
  40. realign/dashboard/widgets/search_panel.py +37 -27
  41. realign/dashboard/widgets/sessions_table.py +24 -15
  42. realign/dashboard/widgets/terminal_panel.py +207 -17
  43. realign/dashboard/widgets/watcher_panel.py +6 -2
  44. realign/dashboard/widgets/worker_panel.py +10 -1
  45. realign/db/__init__.py +1 -1
  46. realign/db/base.py +5 -15
  47. realign/db/locks.py +0 -1
  48. realign/db/migration.py +82 -76
  49. realign/db/schema.py +2 -6
  50. realign/db/sqlite_db.py +23 -41
  51. realign/events/__init__.py +0 -1
  52. realign/events/event_summarizer.py +27 -15
  53. realign/events/session_summarizer.py +29 -15
  54. realign/file_lock.py +1 -0
  55. realign/hooks.py +150 -60
  56. realign/logging_config.py +12 -15
  57. realign/mcp_server.py +30 -51
  58. realign/mcp_watcher.py +0 -1
  59. realign/models/event.py +29 -20
  60. realign/prompts/__init__.py +7 -7
  61. realign/prompts/presets.py +15 -11
  62. realign/redactor.py +99 -59
  63. realign/triggers/__init__.py +9 -9
  64. realign/triggers/antigravity_trigger.py +30 -28
  65. realign/triggers/base.py +4 -3
  66. realign/triggers/claude_trigger.py +104 -85
  67. realign/triggers/codex_trigger.py +15 -5
  68. realign/triggers/gemini_trigger.py +57 -47
  69. realign/triggers/next_turn_trigger.py +3 -1
  70. realign/triggers/registry.py +6 -2
  71. realign/triggers/turn_status.py +3 -1
  72. realign/watcher_core.py +306 -131
  73. realign/watcher_daemon.py +8 -8
  74. realign/worker_core.py +3 -1
  75. realign/worker_daemon.py +3 -1
  76. aline_ai-0.5.3.dist-info/RECORD +0 -93
  77. {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/WHEEL +0 -0
  78. {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/entry_points.txt +0 -0
  79. {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/licenses/LICENSE +0 -0
  80. {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/top_level.txt +0 -0
@@ -6,7 +6,9 @@ from typing import Optional
6
6
 
7
7
  from textual.app import ComposeResult
8
8
  from textual.containers import Horizontal
9
- from textual.widgets import Button, DataTable, Input, Static
9
+ from textual.widgets import Button, DataTable, Input, Static, Switch
10
+
11
+ from ..tmux_manager import _run_outer_tmux, OUTER_SESSION
10
12
 
11
13
 
12
14
  class ConfigPanel(Static):
@@ -51,6 +53,27 @@ class ConfigPanel(Static):
51
53
  ConfigPanel .button-row Button {
52
54
  margin-right: 1;
53
55
  }
56
+
57
+ ConfigPanel .tmux-settings {
58
+ height: auto;
59
+ margin-top: 1;
60
+ padding: 1;
61
+ border: solid $secondary;
62
+ }
63
+
64
+ ConfigPanel .tmux-settings .setting-row {
65
+ height: 3;
66
+ align: left middle;
67
+ }
68
+
69
+ ConfigPanel .tmux-settings .setting-label {
70
+ width: auto;
71
+ margin-right: 1;
72
+ }
73
+
74
+ ConfigPanel .tmux-settings Switch {
75
+ width: auto;
76
+ }
54
77
  """
55
78
 
56
79
  def __init__(self) -> None:
@@ -58,6 +81,8 @@ class ConfigPanel(Static):
58
81
  self._config_path: Optional[Path] = None
59
82
  self._config_data: dict = {}
60
83
  self._selected_key: Optional[str] = None
84
+ self._border_resize_enabled: bool = True # Track tmux border resize state
85
+ self._syncing_switch: bool = False # Flag to prevent recursive switch updates
61
86
 
62
87
  def compose(self) -> ComposeResult:
63
88
  """Compose the config panel layout."""
@@ -71,6 +96,13 @@ class ConfigPanel(Static):
71
96
  yield Button("Save", id="save-btn", variant="primary")
72
97
  yield Button("Reload", id="reload-btn", variant="default")
73
98
 
99
+ # Tmux settings section
100
+ with Static(classes="tmux-settings"):
101
+ yield Static("[bold]Tmux Settings[/bold]", classes="section-title")
102
+ with Horizontal(classes="setting-row"):
103
+ yield Static("Allow border resize:", classes="setting-label")
104
+ yield Switch(value=True, id="border-resize-switch")
105
+
74
106
  def on_mount(self) -> None:
75
107
  """Set up the panel on mount."""
76
108
  table = self.query_one("#config-table", DataTable)
@@ -80,6 +112,9 @@ class ConfigPanel(Static):
80
112
  # Load initial data
81
113
  self.refresh_data()
82
114
 
115
+ # Query and set the actual tmux border resize state
116
+ self._sync_border_resize_switch()
117
+
83
118
  def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
84
119
  """Handle row selection in the config table."""
85
120
  table = self.query_one("#config-table", DataTable)
@@ -114,6 +149,55 @@ class ConfigPanel(Static):
114
149
  self.refresh_data()
115
150
  self.app.notify("Configuration reloaded", title="Config")
116
151
 
152
+ def on_switch_changed(self, event: Switch.Changed) -> None:
153
+ """Handle switch toggle events."""
154
+ if self._syncing_switch:
155
+ return # Ignore events during sync
156
+ if event.switch.id == "border-resize-switch":
157
+ self._toggle_border_resize(event.value)
158
+
159
+ def _sync_border_resize_switch(self) -> None:
160
+ """Query tmux state and sync the switch to match."""
161
+ try:
162
+ # Check if MouseDrag1Border is bound by listing keys
163
+ result = _run_outer_tmux(["list-keys", "-T", "root"], capture=True)
164
+ output = result.stdout or ""
165
+
166
+ # If MouseDrag1Border is in the output, resize is enabled
167
+ is_enabled = "MouseDrag1Border" in output
168
+ self._border_resize_enabled = is_enabled
169
+
170
+ # Update switch without triggering the toggle action
171
+ self._syncing_switch = True
172
+ try:
173
+ switch = self.query_one("#border-resize-switch", Switch)
174
+ switch.value = is_enabled
175
+ finally:
176
+ self._syncing_switch = False
177
+ except Exception:
178
+ # If we can't query, assume enabled (default tmux behavior)
179
+ pass
180
+
181
+ def _toggle_border_resize(self, enabled: bool) -> None:
182
+ """Enable or disable tmux border resize functionality."""
183
+ try:
184
+ if enabled:
185
+ # Re-enable border resize by binding MouseDrag1Border to default resize behavior
186
+ _run_outer_tmux([
187
+ "bind", "-n", "MouseDrag1Border", "resize-pane", "-M"
188
+ ])
189
+ self._border_resize_enabled = True
190
+ self.app.notify("Border resize enabled", title="Tmux")
191
+ else:
192
+ # Disable border resize by unbinding MouseDrag1Border
193
+ _run_outer_tmux([
194
+ "unbind", "-n", "MouseDrag1Border"
195
+ ])
196
+ self._border_resize_enabled = False
197
+ self.app.notify("Border resize disabled", title="Tmux")
198
+ except Exception as e:
199
+ self.app.notify(f"Error toggling border resize: {e}", title="Tmux", severity="error")
200
+
117
201
  def _save_config(self) -> None:
118
202
  """Save the edited configuration."""
119
203
  if not self._selected_key:
@@ -563,7 +563,9 @@ class EventsTable(Container, can_focus=True):
563
563
  selected_event_id = None
564
564
  try:
565
565
  if table.row_count > 0:
566
- selected_event_id = str(table.coordinate_to_cell_key(table.cursor_coordinate)[0].value)
566
+ selected_event_id = str(
567
+ table.coordinate_to_cell_key(table.cursor_coordinate)[0].value
568
+ )
567
569
  except Exception:
568
570
  selected_event_id = None
569
571
  if self.wrap_mode != self._last_wrap_mode:
@@ -23,6 +23,7 @@ class AlineHeader(Static):
23
23
  """Get the Aline version."""
24
24
  try:
25
25
  from importlib.metadata import version
26
+
26
27
  return version("aline-ai")
27
28
  except Exception:
28
29
  return "0.4.1"
@@ -115,7 +115,9 @@ class SearchPanel(Static):
115
115
  session_id = result.get("session_id") or ""
116
116
  turn_id = result.get("turn_id") or ""
117
117
  if session_id:
118
- self.app.push_screen(SessionDetailScreen(session_id, initial_turn_id=turn_id or None))
118
+ self.app.push_screen(
119
+ SessionDetailScreen(session_id, initial_turn_id=turn_id or None)
120
+ )
119
121
  return
120
122
 
121
123
  def on_input_submitted(self, event: Input.Submitted) -> None:
@@ -156,30 +158,32 @@ class SearchPanel(Static):
156
158
 
157
159
  # Search based on type
158
160
  if self._search_type in ("all", "event"):
159
- events = db.search_events(
160
- self._query, limit=20, use_regex=True, ignore_case=True
161
- )
161
+ events = db.search_events(self._query, limit=20, use_regex=True, ignore_case=True)
162
162
  for event in events:
163
- self._results.append({
164
- "type": "Event",
165
- "id": self._shorten_id(event.id),
166
- "full_id": event.id,
167
- "title": (event.title or "(no title)")[:60],
168
- "context": event.event_type or "-",
169
- })
163
+ self._results.append(
164
+ {
165
+ "type": "Event",
166
+ "id": self._shorten_id(event.id),
167
+ "full_id": event.id,
168
+ "title": (event.title or "(no title)")[:60],
169
+ "context": event.event_type or "-",
170
+ }
171
+ )
170
172
 
171
173
  if self._search_type in ("all", "session"):
172
174
  sessions = db.search_sessions(
173
175
  self._query, limit=20, use_regex=True, ignore_case=True
174
176
  )
175
177
  for session in sessions:
176
- self._results.append({
177
- "type": "Session",
178
- "id": self._shorten_id(session.id),
179
- "full_id": session.id,
180
- "title": (session.session_title or "(no title)")[:60],
181
- "context": session.session_type or "-",
182
- })
178
+ self._results.append(
179
+ {
180
+ "type": "Session",
181
+ "id": self._shorten_id(session.id),
182
+ "full_id": session.id,
183
+ "title": (session.session_title or "(no title)")[:60],
184
+ "context": session.session_type or "-",
185
+ }
186
+ )
183
187
 
184
188
  if self._search_type in ("all", "turn"):
185
189
  turns = db.search_conversations(
@@ -188,14 +192,18 @@ class SearchPanel(Static):
188
192
  for turn in turns:
189
193
  session_id = turn.get("session_id", "")
190
194
  turn_id = turn.get("turn_id", "")
191
- self._results.append({
192
- "type": "Turn",
193
- "id": self._shorten_id(turn_id),
194
- "turn_id": turn_id,
195
- "session_id": session_id,
196
- "title": (turn.get("title") or turn.get("summary") or "(no title)")[:60],
197
- "context": f"{self._shorten_id(session_id)} Turn #{turn.get('turn_number', '-')}",
198
- })
195
+ self._results.append(
196
+ {
197
+ "type": "Turn",
198
+ "id": self._shorten_id(turn_id),
199
+ "turn_id": turn_id,
200
+ "session_id": session_id,
201
+ "title": (turn.get("title") or turn.get("summary") or "(no title)")[
202
+ :60
203
+ ],
204
+ "context": f"{self._shorten_id(session_id)} • Turn #{turn.get('turn_number', '-')}",
205
+ }
206
+ )
199
207
 
200
208
  self._update_display()
201
209
 
@@ -232,7 +240,9 @@ class SearchPanel(Static):
232
240
 
233
241
  summary_text = f"Found {len(self._results)} results for '{self._query}'"
234
242
  if self._search_type == "all":
235
- summary_text += f" (Events: {event_count}, Sessions: {session_count}, Turns: {turn_count})"
243
+ summary_text += (
244
+ f" (Events: {event_count}, Sessions: {session_count}, Turns: {turn_count})"
245
+ )
236
246
 
237
247
  summary.update(f"[dim]{summary_text}[/dim]")
238
248
  else:
@@ -461,14 +461,16 @@ class SessionsTable(Container, can_focus=True):
461
461
  total_sessions = int(row[0]) if row else 0
462
462
 
463
463
  # Get stats
464
- stats_row = conn.execute("""
464
+ stats_row = conn.execute(
465
+ """
465
466
  SELECT
466
467
  COUNT(*) AS total,
467
468
  COUNT(CASE WHEN session_type = 'claude' THEN 1 END) AS claude,
468
469
  COUNT(CASE WHEN session_type = 'codex' THEN 1 END) AS codex,
469
470
  COUNT(CASE WHEN session_type = 'gemini' THEN 1 END) AS gemini
470
471
  FROM sessions
471
- """).fetchone()
472
+ """
473
+ ).fetchone()
472
474
 
473
475
  stats = {
474
476
  "total": stats_row[0] if stats_row else 0,
@@ -479,7 +481,8 @@ class SessionsTable(Container, can_focus=True):
479
481
 
480
482
  # Get paginated sessions
481
483
  offset = (int(page) - 1) * int(rows_per_page)
482
- rows = conn.execute("""
484
+ rows = conn.execute(
485
+ """
483
486
  SELECT
484
487
  s.id,
485
488
  s.session_type,
@@ -490,7 +493,9 @@ class SessionsTable(Container, can_focus=True):
490
493
  FROM sessions s
491
494
  ORDER BY s.last_activity_at DESC
492
495
  LIMIT ? OFFSET ?
493
- """, (int(rows_per_page), int(offset))).fetchall()
496
+ """,
497
+ (int(rows_per_page), int(offset)),
498
+ ).fetchall()
494
499
 
495
500
  for i, row in enumerate(rows):
496
501
  session_id = row[0]
@@ -517,16 +522,18 @@ class SessionsTable(Container, can_focus=True):
517
522
  except Exception:
518
523
  activity_str = last_activity
519
524
 
520
- sessions.append({
521
- "index": offset + i + 1,
522
- "id": session_id,
523
- "short_id": self._shorten_session_id(session_id),
524
- "source": source,
525
- "project": project,
526
- "turns": turn_count,
527
- "title": title,
528
- "last_activity": activity_str,
529
- })
525
+ sessions.append(
526
+ {
527
+ "index": offset + i + 1,
528
+ "id": session_id,
529
+ "short_id": self._shorten_session_id(session_id),
530
+ "source": source,
531
+ "project": project,
532
+ "turns": turn_count,
533
+ "title": title,
534
+ "last_activity": activity_str,
535
+ }
536
+ )
530
537
  except Exception:
531
538
  total_sessions = 0
532
539
  stats = {}
@@ -553,7 +560,9 @@ class SessionsTable(Container, can_focus=True):
553
560
  selected_session_id: Optional[str] = None
554
561
  try:
555
562
  if table.row_count > 0:
556
- selected_session_id = str(table.coordinate_to_cell_key(table.cursor_coordinate)[0].value)
563
+ selected_session_id = str(
564
+ table.coordinate_to_cell_key(table.cursor_coordinate)[0].value
565
+ )
557
566
  except Exception:
558
567
  selected_session_id = None
559
568
  if self.wrap_mode != self._last_wrap_mode:
@@ -13,16 +13,126 @@ import re
13
13
  import shlex
14
14
  import subprocess
15
15
  from pathlib import Path
16
+ from typing import Callable
16
17
 
17
18
  from textual.app import ComposeResult
18
19
  from textual.containers import Container, Horizontal, Vertical, VerticalScroll
20
+ from textual.message import Message
19
21
  from textual.widgets import Button, Static
20
22
  from rich.text import Text
21
23
 
22
24
  from .. import tmux_manager
23
25
 
24
26
 
27
+ # Signal directory for permission request notifications
28
+ PERMISSION_SIGNAL_DIR = Path.home() / ".aline" / ".signals" / "permission_request"
29
+
30
+
31
+ class _SignalFileWatcher:
32
+ """Watches for new signal files in the permission_request directory.
33
+
34
+ Uses OS-native file watching via asyncio when available,
35
+ otherwise falls back to checking directory mtime.
36
+ """
37
+
38
+ def __init__(self, callback: Callable[[], None]) -> None:
39
+ self._callback = callback
40
+ self._running = False
41
+ self._task: asyncio.Task | None = None
42
+ self._last_mtime: float = 0
43
+ self._seen_files: set[str] = set()
44
+
45
+ def start(self) -> None:
46
+ if self._running:
47
+ return
48
+ self._running = True
49
+ # Initialize seen files
50
+ self._scan_existing_files()
51
+ self._task = asyncio.create_task(self._watch_loop())
52
+
53
+ def stop(self) -> None:
54
+ self._running = False
55
+ if self._task:
56
+ self._task.cancel()
57
+ self._task = None
58
+
59
+ def _scan_existing_files(self) -> None:
60
+ """Record existing signal files so we only react to new ones."""
61
+ try:
62
+ if PERMISSION_SIGNAL_DIR.exists():
63
+ self._seen_files = {
64
+ f.name for f in PERMISSION_SIGNAL_DIR.iterdir() if f.suffix == ".signal"
65
+ }
66
+ except Exception:
67
+ self._seen_files = set()
68
+
69
+ async def _watch_loop(self) -> None:
70
+ """Watch for new signal files using directory mtime checks."""
71
+ try:
72
+ while self._running:
73
+ # Wait a bit before checking (reduces CPU usage)
74
+ await asyncio.sleep(0.5)
75
+
76
+ if not self._running:
77
+ break
78
+
79
+ try:
80
+ if not PERMISSION_SIGNAL_DIR.exists():
81
+ continue
82
+
83
+ # Check if directory was modified
84
+ current_mtime = PERMISSION_SIGNAL_DIR.stat().st_mtime
85
+ if current_mtime <= self._last_mtime:
86
+ continue
87
+ self._last_mtime = current_mtime
88
+
89
+ # Check for new signal files
90
+ current_files = {
91
+ f.name for f in PERMISSION_SIGNAL_DIR.iterdir() if f.suffix == ".signal"
92
+ }
93
+ new_files = current_files - self._seen_files
94
+
95
+ if new_files:
96
+ self._seen_files = current_files
97
+ # New signal file detected - trigger callback
98
+ self._callback()
99
+ # Clean up old signal files (keep last 10)
100
+ self._cleanup_old_signals()
101
+
102
+ except Exception:
103
+ pass # Ignore errors, keep watching
104
+
105
+ except asyncio.CancelledError:
106
+ pass
107
+
108
+ def _cleanup_old_signals(self) -> None:
109
+ """Remove old signal files to prevent directory from growing."""
110
+ try:
111
+ if not PERMISSION_SIGNAL_DIR.exists():
112
+ return
113
+ files = sorted(
114
+ PERMISSION_SIGNAL_DIR.glob("*.signal"),
115
+ key=lambda f: f.stat().st_mtime,
116
+ reverse=True,
117
+ )
118
+ # Keep only the 10 most recent
119
+ for f in files[10:]:
120
+ try:
121
+ f.unlink()
122
+ except Exception:
123
+ pass
124
+ except Exception:
125
+ pass
126
+
127
+
25
128
  class TerminalPanel(Container, can_focus=True):
129
+ """Terminal controls panel with permission request notifications."""
130
+
131
+ class PermissionRequestDetected(Message):
132
+ """Posted when a new permission request signal file is detected."""
133
+
134
+ pass
135
+
26
136
  DEFAULT_CSS = """
27
137
  TerminalPanel {
28
138
  height: 100%;
@@ -179,18 +289,37 @@ class TerminalPanel(Container, can_focus=True):
179
289
  super().__init__()
180
290
  self._refresh_lock = asyncio.Lock()
181
291
  self._expanded_window_id: str | None = None
292
+ self._signal_watcher: _SignalFileWatcher | None = None
182
293
 
183
294
  def compose(self) -> ComposeResult:
184
295
  controls_enabled = self.supported()
185
296
  with Horizontal(classes="summary"):
186
- yield Button("+ Claude", id="new-cc", variant="primary", disabled=not controls_enabled)
187
- yield Button("+ Codex", id="new-codex", variant="primary", disabled=not controls_enabled)
297
+ yield Button(
298
+ "+ Claude",
299
+ id="new-cc",
300
+ variant="primary",
301
+ disabled=not controls_enabled,
302
+ )
303
+ yield Button(
304
+ "+ Codex",
305
+ id="new-codex",
306
+ variant="primary",
307
+ disabled=not controls_enabled,
308
+ )
309
+ yield Button(
310
+ "+ Opencode",
311
+ id="new-opencode",
312
+ variant="primary",
313
+ disabled=not controls_enabled,
314
+ )
188
315
  yield Button("+ zsh", id="new-zsh", variant="primary", disabled=not controls_enabled)
189
316
  yield Button("↻", id="refresh")
190
317
  yield Static("", id="status", classes="status")
191
318
  with Vertical(id="terminals", classes="list"):
192
319
  if controls_enabled:
193
- yield Static("No terminals yet. Click 'New cc' / 'New codex' to open the right pane.")
320
+ yield Static(
321
+ "No terminals yet. Click 'New cc' / 'New codex' to open the right pane."
322
+ )
194
323
  else:
195
324
  yield Static(self._support_message())
196
325
 
@@ -205,6 +334,40 @@ class TerminalPanel(Container, can_focus=True):
205
334
  exclusive=True,
206
335
  )
207
336
  )
337
+ # Start watching for permission request signals
338
+ self._start_signal_watcher()
339
+
340
+ def on_hide(self) -> None:
341
+ # Stop watching when panel is hidden
342
+ self._stop_signal_watcher()
343
+
344
+ def _start_signal_watcher(self) -> None:
345
+ """Start watching for permission request signal files."""
346
+ if self._signal_watcher is not None:
347
+ return
348
+ self._signal_watcher = _SignalFileWatcher(self._on_permission_signal)
349
+ self._signal_watcher.start()
350
+
351
+ def _stop_signal_watcher(self) -> None:
352
+ """Stop watching for permission request signal files."""
353
+ if self._signal_watcher is not None:
354
+ self._signal_watcher.stop()
355
+ self._signal_watcher = None
356
+
357
+ def _on_permission_signal(self) -> None:
358
+ """Called when a new permission request signal is detected."""
359
+ # Post message to trigger refresh on the main thread
360
+ self.post_message(self.PermissionRequestDetected())
361
+
362
+ def on_terminal_panel_permission_request_detected(
363
+ self, event: PermissionRequestDetected
364
+ ) -> None:
365
+ """Handle permission request detection - refresh the terminal list."""
366
+ self.run_worker(
367
+ self.refresh_data(),
368
+ group="terminal-panel-refresh",
369
+ exclusive=True,
370
+ )
208
371
 
209
372
  async def refresh_data(self) -> None:
210
373
  async with self._refresh_lock:
@@ -239,10 +402,16 @@ class TerminalPanel(Container, can_focus=True):
239
402
  for w in windows:
240
403
  if not self._is_claude_window(w) or not w.context_id:
241
404
  continue
242
- session_ids, session_count, event_count = self._get_loaded_context_info(w.context_id)
405
+ session_ids, session_count, event_count = self._get_loaded_context_info(
406
+ w.context_id
407
+ )
243
408
  if not session_ids and session_count == 0 and event_count == 0:
244
409
  continue
245
- context_info_by_context_id[w.context_id] = (session_ids, session_count, event_count)
410
+ context_info_by_context_id[w.context_id] = (
411
+ session_ids,
412
+ session_count,
413
+ event_count,
414
+ )
246
415
  all_context_session_ids.update(session_ids)
247
416
 
248
417
  if all_context_session_ids:
@@ -390,12 +559,7 @@ class TerminalPanel(Container, can_focus=True):
390
559
  )
391
560
  )
392
561
 
393
- if (
394
- w.active
395
- and self._is_claude_window(w)
396
- and w.context_id
397
- and expanded
398
- ):
562
+ if w.active and self._is_claude_window(w) and w.context_id and expanded:
399
563
  ctx = VerticalScroll(id=f"ctx-{safe}", classes="context-sessions")
400
564
  await container.mount(ctx)
401
565
  if loaded_ids:
@@ -454,7 +618,9 @@ class TerminalPanel(Container, can_focus=True):
454
618
  loaded_count = raw_sessions + raw_events
455
619
  detail_line = f"{detail_line} | loaded context: {loaded_count}"
456
620
  else:
457
- detail_line = f"{detail_line} · {self._format_context_summary(raw_sessions, raw_events)}"
621
+ detail_line = (
622
+ f"{detail_line} · {self._format_context_summary(raw_sessions, raw_events)}"
623
+ )
458
624
  details.append(detail_line, style="dim not bold")
459
625
  return details
460
626
 
@@ -485,7 +651,7 @@ class TerminalPanel(Container, can_focus=True):
485
651
  # Use osascript to invoke macOS native folder picker
486
652
  default_path_escaped = default_path.replace('"', '\\"')
487
653
  prompt_escaped = prompt.replace('"', '\\"')
488
- script = f'''
654
+ script = f"""
489
655
  set defaultFolder to POSIX file "{default_path_escaped}" as alias
490
656
  try
491
657
  set selectedFolder to choose folder with prompt "{prompt_escaped}" default location defaultFolder
@@ -493,7 +659,7 @@ class TerminalPanel(Container, can_focus=True):
493
659
  on error
494
660
  return ""
495
661
  end try
496
- '''
662
+ """
497
663
  try:
498
664
  proc = await asyncio.get_event_loop().run_in_executor(
499
665
  None,
@@ -570,7 +736,9 @@ class TerminalPanel(Container, can_focus=True):
570
736
  ok_global_permission = ensure_permission_request_hook_installed(quiet=True)
571
737
 
572
738
  project_root = Path(workspace)
573
- ok_project_stop = install_stop_hook(get_stop_settings_path(project_root), quiet=True)
739
+ ok_project_stop = install_stop_hook(
740
+ get_stop_settings_path(project_root), quiet=True
741
+ )
574
742
  ok_project_submit = install_user_prompt_submit_hook(
575
743
  get_submit_settings_path(project_root), quiet=True
576
744
  )
@@ -579,8 +747,12 @@ class TerminalPanel(Container, can_focus=True):
579
747
  )
580
748
 
581
749
  all_hooks_ok = (
582
- ok_global_stop and ok_global_submit and ok_global_permission
583
- and ok_project_stop and ok_project_submit and ok_project_permission
750
+ ok_global_stop
751
+ and ok_global_submit
752
+ and ok_global_permission
753
+ and ok_project_stop
754
+ and ok_project_submit
755
+ and ok_project_permission
584
756
  )
585
757
  if not all_hooks_ok:
586
758
  self.app.notify(
@@ -620,6 +792,24 @@ class TerminalPanel(Container, can_focus=True):
620
792
  await self.refresh_data()
621
793
  return
622
794
 
795
+ if button_id == "new-opencode":
796
+ workspace = await self._select_workspace("Select workspace for Opencode")
797
+ if not workspace:
798
+ return
799
+
800
+ command = self._command_in_directory(
801
+ tmux_manager.zsh_run_and_keep_open("opencode"), workspace
802
+ )
803
+ created = tmux_manager.create_inner_window("opencode", command)
804
+ if not created:
805
+ self.app.notify(
806
+ "Failed to open opencode terminal",
807
+ title="Terminal",
808
+ severity="error",
809
+ )
810
+ await self.refresh_data()
811
+ return
812
+
623
813
  if button_id == "new-zsh":
624
814
  created = tmux_manager.create_inner_window("zsh", "zsh")
625
815
  if not created:
@@ -389,7 +389,9 @@ class WatcherPanel(Container, can_focus=True):
389
389
  except Exception:
390
390
  return []
391
391
 
392
- def _collect_recent_sessions_page(self, *, page: int, rows_per_page: int) -> tuple[list[dict], int]:
392
+ def _collect_recent_sessions_page(
393
+ self, *, page: int, rows_per_page: int
394
+ ) -> tuple[list[dict], int]:
393
395
  """Collect one page of recent sessions from the database."""
394
396
  try:
395
397
  from ...db import get_database
@@ -477,7 +479,9 @@ class WatcherPanel(Container, can_focus=True):
477
479
  if exists:
478
480
  paths_lines.append(f" [green]●[/green] {name}: {path}")
479
481
  else:
480
- paths_lines.append(f" [yellow]○[/yellow] {name}: {path} [dim](not found)[/dim]")
482
+ paths_lines.append(
483
+ f" [yellow]○[/yellow] {name}: {path} [dim](not found)[/dim]"
484
+ )
481
485
  else:
482
486
  paths_lines.append(f" [dim]○ {name}: {path} (disabled)[/dim]")
483
487
  paths_widget.update("\n".join(paths_lines))