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.
- {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/METADATA +1 -1
- aline_ai-0.5.5.dist-info/RECORD +93 -0
- realign/__init__.py +1 -1
- realign/adapters/antigravity.py +28 -20
- realign/adapters/base.py +46 -50
- realign/adapters/claude.py +14 -14
- realign/adapters/codex.py +7 -7
- realign/adapters/gemini.py +11 -11
- realign/adapters/registry.py +14 -10
- realign/claude_detector.py +2 -2
- realign/claude_hooks/__init__.py +3 -3
- realign/claude_hooks/permission_request_hook.py +35 -0
- realign/claude_hooks/permission_request_hook_installer.py +31 -32
- realign/claude_hooks/stop_hook.py +4 -1
- realign/claude_hooks/stop_hook_installer.py +30 -31
- realign/cli.py +24 -0
- realign/codex_detector.py +11 -11
- realign/commands/add.py +361 -35
- realign/commands/config.py +3 -12
- realign/commands/context.py +3 -1
- realign/commands/export_shares.py +86 -127
- realign/commands/import_shares.py +145 -155
- realign/commands/init.py +166 -30
- realign/commands/restore.py +18 -6
- realign/commands/search.py +14 -42
- realign/commands/upgrade.py +155 -11
- realign/commands/watcher.py +98 -219
- realign/commands/worker.py +29 -6
- realign/config.py +25 -20
- realign/context.py +1 -3
- realign/dashboard/app.py +4 -4
- realign/dashboard/screens/create_event.py +3 -1
- realign/dashboard/screens/event_detail.py +14 -6
- realign/dashboard/screens/session_detail.py +3 -1
- realign/dashboard/screens/share_import.py +7 -3
- realign/dashboard/tmux_manager.py +91 -22
- realign/dashboard/widgets/config_panel.py +85 -1
- realign/dashboard/widgets/events_table.py +3 -1
- realign/dashboard/widgets/header.py +1 -0
- realign/dashboard/widgets/search_panel.py +37 -27
- realign/dashboard/widgets/sessions_table.py +24 -15
- realign/dashboard/widgets/terminal_panel.py +207 -17
- realign/dashboard/widgets/watcher_panel.py +6 -2
- realign/dashboard/widgets/worker_panel.py +10 -1
- realign/db/__init__.py +1 -1
- realign/db/base.py +5 -15
- realign/db/locks.py +0 -1
- realign/db/migration.py +82 -76
- realign/db/schema.py +2 -6
- realign/db/sqlite_db.py +23 -41
- realign/events/__init__.py +0 -1
- realign/events/event_summarizer.py +27 -15
- realign/events/session_summarizer.py +29 -15
- realign/file_lock.py +1 -0
- realign/hooks.py +150 -60
- realign/logging_config.py +12 -15
- realign/mcp_server.py +30 -51
- realign/mcp_watcher.py +0 -1
- realign/models/event.py +29 -20
- realign/prompts/__init__.py +7 -7
- realign/prompts/presets.py +15 -11
- realign/redactor.py +99 -59
- realign/triggers/__init__.py +9 -9
- realign/triggers/antigravity_trigger.py +30 -28
- realign/triggers/base.py +4 -3
- realign/triggers/claude_trigger.py +104 -85
- realign/triggers/codex_trigger.py +15 -5
- realign/triggers/gemini_trigger.py +57 -47
- realign/triggers/next_turn_trigger.py +3 -1
- realign/triggers/registry.py +6 -2
- realign/triggers/turn_status.py +3 -1
- realign/watcher_core.py +306 -131
- realign/watcher_daemon.py +8 -8
- realign/worker_core.py +3 -1
- realign/worker_daemon.py +3 -1
- aline_ai-0.5.3.dist-info/RECORD +0 -93
- {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/WHEEL +0 -0
- {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.5.3.dist-info → aline_ai-0.5.5.dist-info}/licenses/LICENSE +0 -0
- {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(
|
|
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:
|
|
@@ -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(
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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 +=
|
|
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
|
-
"""
|
|
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
|
-
""",
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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(
|
|
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(
|
|
187
|
-
|
|
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(
|
|
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(
|
|
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] = (
|
|
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 =
|
|
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(
|
|
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
|
|
583
|
-
and
|
|
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(
|
|
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(
|
|
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))
|