command-gate 0.2.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.
Files changed (63) hide show
  1. cgate/__init__.py +26 -0
  2. cgate/__main__.py +8 -0
  3. cgate/_version.py +24 -0
  4. cgate/cli/__init__.py +1 -0
  5. cgate/cli/_console.py +17 -0
  6. cgate/cli/connections.py +212 -0
  7. cgate/cli/history.py +191 -0
  8. cgate/cli/install.py +182 -0
  9. cgate/cli/main.py +115 -0
  10. cgate/cli/mcp.py +197 -0
  11. cgate/cli/uninstall.py +403 -0
  12. cgate/cli/update.py +538 -0
  13. cgate/cli/watch.py +20 -0
  14. cgate/connections/__init__.py +1 -0
  15. cgate/connections/auth.py +93 -0
  16. cgate/connections/detect.py +78 -0
  17. cgate/connections/store.py +88 -0
  18. cgate/core/__init__.py +1 -0
  19. cgate/core/path_env.py +218 -0
  20. cgate/core/paths.py +35 -0
  21. cgate/core/update_log.py +36 -0
  22. cgate/db/__init__.py +1 -0
  23. cgate/db/batches.py +111 -0
  24. cgate/db/commands.py +191 -0
  25. cgate/db/connection.py +104 -0
  26. cgate/db/mode.py +74 -0
  27. cgate/db/rows.py +99 -0
  28. cgate/db/schema.py +54 -0
  29. cgate/db/server_settings.py +105 -0
  30. cgate/db/types.py +77 -0
  31. cgate/executor/__init__.py +7 -0
  32. cgate/executor/base.py +71 -0
  33. cgate/executor/selector.py +61 -0
  34. cgate/executor/ssh.py +157 -0
  35. cgate/executor/winrm.py +129 -0
  36. cgate/helper/__init__.py +10 -0
  37. cgate/helper/__main__.py +112 -0
  38. cgate/helper/waiter.py +123 -0
  39. cgate/mcp_installer.py +161 -0
  40. cgate/mcp_server/__init__.py +6 -0
  41. cgate/mcp_server/__main__.py +6 -0
  42. cgate/mcp_server/auto_resolution.py +80 -0
  43. cgate/mcp_server/server.py +271 -0
  44. cgate/mcp_server/tools.py +351 -0
  45. cgate/risk.py +129 -0
  46. cgate/update.py +713 -0
  47. cgate/watch/__init__.py +7 -0
  48. cgate/watch/app.py +560 -0
  49. cgate/watch/approval.py +237 -0
  50. cgate/watch/command_detail_modal.py +68 -0
  51. cgate/watch/history_modal.py +242 -0
  52. cgate/watch/mode_modal.py +110 -0
  53. cgate/watch/queue.py +106 -0
  54. cgate/watch/render.py +156 -0
  55. cgate/watch/server_settings_modal.py +179 -0
  56. cgate/watch/session.py +40 -0
  57. cgate/watch/theme.py +32 -0
  58. cgate/watch/widgets.py +35 -0
  59. command_gate-0.2.4.dist-info/METADATA +204 -0
  60. command_gate-0.2.4.dist-info/RECORD +63 -0
  61. command_gate-0.2.4.dist-info/WHEEL +4 -0
  62. command_gate-0.2.4.dist-info/entry_points.txt +2 -0
  63. command_gate-0.2.4.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,7 @@
1
+ """Interactive approval TUI for `cgate watch` (spec §Approval queue)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from cgate.watch.session import run_watch_session
6
+
7
+ __all__ = ["run_watch_session"]
cgate/watch/app.py ADDED
@@ -0,0 +1,560 @@
1
+ """Full-screen Textual dashboard for `cgate watch` (spec §Approval queue).
2
+
3
+ Replaces the earlier linear y/n/a/r prompt with a live view: a sidebar shows
4
+ the FIFO batch queue, the main panel shows the active batch's commands, and
5
+ approvals run in a worker thread so the UI stays responsive during exec.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import sqlite3
12
+ from typing import TYPE_CHECKING, ClassVar
13
+
14
+ from rich.markup import escape as escape_markup
15
+ from textual.app import App, ComposeResult
16
+ from textual.binding import Binding
17
+ from textual.containers import Horizontal, Vertical
18
+ from textual.widgets import Footer, ListItem, ListView, Static
19
+ from typing_extensions import override
20
+
21
+ from cgate import __version__
22
+ from cgate.db.mode import AppModeNotSetError, Mode
23
+ from cgate.watch.approval import (
24
+ CommandDisappearedError,
25
+ ConnectionNotFoundError,
26
+ execute_and_finalize,
27
+ mark_approved,
28
+ reject_one,
29
+ )
30
+ from cgate.watch.command_detail_modal import CommandDetailModal
31
+ from cgate.watch.history_modal import HistoryModal
32
+ from cgate.watch.mode_modal import ModeModal, mode_markup
33
+ from cgate.watch.queue import (
34
+ count_pending_commands,
35
+ count_waiting,
36
+ pending_commands_in_batch,
37
+ select_active_batch,
38
+ )
39
+ from cgate.watch.render import format_batch_header, format_queue_summary, server_badge
40
+ from cgate.watch.server_settings_modal import ServerSettingsModal
41
+ from cgate.watch.theme import CGATE_THEME
42
+ from cgate.watch.widgets import CommandRow
43
+
44
+ if TYPE_CHECKING:
45
+ from textual.binding import BindingType
46
+
47
+ from cgate.connections.store import ConnectionsRepo
48
+ from cgate.db.batches import BatchesRepo
49
+ from cgate.db.commands import CommandsRepo
50
+ from cgate.db.connection import Database
51
+ from cgate.db.mode import AppModeRepo
52
+ from cgate.db.server_settings import ServerSettingsRepo
53
+ from cgate.db.types import Batch, BatchId, Command, CommandId, Connection
54
+
55
+ _POLL_INTERVAL_SECONDS: float = 1.5
56
+
57
+
58
+ class BatchRow(ListItem):
59
+ """One pending batch in the queue sidebar, carrying its id for selection."""
60
+
61
+ batch_id: BatchId
62
+
63
+ def __init__(self, batch: Batch, *, active: bool) -> None:
64
+ """Render the marker/title and remember which batch this row is."""
65
+ marker = "▶" if active else " "
66
+ style = f"bold {CGATE_THEME.primary}" if active else "dim"
67
+ super().__init__(
68
+ Static(f"{marker} [{style}]{escape_markup(batch.title)}[/{style}]", markup=True)
69
+ )
70
+ self.batch_id = batch.id
71
+
72
+
73
+ class QueueSidebar(Vertical):
74
+ """Left rail listing pending batches, FIFO order, active one marked.
75
+
76
+ Enter on a highlighted row pins that batch as active regardless of
77
+ FIFO order (`WatchApp.on_list_view_selected`), so a human can jump
78
+ the queue and prioritize approving something further down.
79
+ """
80
+
81
+ _rendered: tuple[tuple[BatchId, str, bool], ...] = ()
82
+
83
+ @override
84
+ def compose(self) -> ComposeResult:
85
+ """Build the static title, a usage hint, and the batch list view."""
86
+ yield Static("[bold]Queue[/bold]", classes="sidebar-title")
87
+ yield Static("[dim]↑/↓ Enter = jump to a batch[/dim]", classes="sidebar-hint")
88
+ yield ListView(id="queue-list")
89
+
90
+ def refresh_queue(self, pending: list[Batch], active_id: BatchId | None) -> None:
91
+ """Rebuild the list view, keeping the same row highlighted if it still exists.
92
+
93
+ Runs on every poll tick (every `_POLL_INTERVAL_SECONDS`), not just
94
+ on user action. Two things follow from that:
95
+
96
+ - It must not reset a human's cursor mid-navigation -- restoring
97
+ the highlighted batch by id (falling back to the first row)
98
+ keeps ↑/↓ usable even while the queue is refreshing live.
99
+ - The overwhelmingly common tick is "nothing changed"; clearing
100
+ and rebuilding the list view anyway made every row visibly
101
+ flash every `_POLL_INTERVAL_SECONDS` for no reason. Skipping
102
+ the rebuild whenever the rendered snapshot is identical fixes
103
+ that without touching the polling itself.
104
+ """
105
+ snapshot = tuple((batch.id, batch.title, batch.id == active_id) for batch in pending)
106
+ if snapshot == self._rendered:
107
+ return
108
+ self._rendered = snapshot
109
+ list_view = self.query_one("#queue-list", ListView)
110
+ highlighted = list_view.highlighted_child
111
+ highlighted_id = highlighted.batch_id if isinstance(highlighted, BatchRow) else None
112
+ _ = list_view.clear()
113
+ for batch in pending:
114
+ _ = list_view.append(BatchRow(batch, active=batch.id == active_id))
115
+ if pending:
116
+ restore_index = next(
117
+ (i for i, batch in enumerate(pending) if batch.id == highlighted_id), 0
118
+ )
119
+ list_view.index = restore_index
120
+
121
+
122
+ class ModeHeader(Horizontal):
123
+ """Top bar: app title on the left, global mode indicator on the right."""
124
+
125
+ @override
126
+ def compose(self) -> ComposeResult:
127
+ """Build the title static and the right-aligned mode indicator."""
128
+ yield Static(f"🔐 [bold]cgate watch[/bold] [dim]v{__version__}[/dim]", id="mode-title")
129
+ yield Static(id="mode-indicator")
130
+
131
+ def show_mode(self, mode: Mode, *, auto_allowed: int, total: int) -> None:
132
+ """Render the mode label; only AUTO also shows the auto-allowed count."""
133
+ text = f"MODE: {mode_markup(mode)}"
134
+ if mode is Mode.AUTO:
135
+ text += f" [dim]|[/dim] {auto_allowed} servers auto-allowed (of {total})"
136
+ _ = self.query_one("#mode-indicator", Static).update(text)
137
+
138
+
139
+ class ServersSidebar(Vertical):
140
+ """Left-rail section listing every connection with its auto-approve flag."""
141
+
142
+ _rendered: tuple[tuple[str, bool], ...] = ()
143
+
144
+ @override
145
+ def compose(self) -> ComposeResult:
146
+ """Build the static title and the servers list view."""
147
+ yield Static("[bold]Servers[/bold]", classes="sidebar-title")
148
+ yield ListView(id="servers-list")
149
+
150
+ def refresh_servers(self, rows: list[tuple[Connection, bool]]) -> None:
151
+ """Replace the list view contents with the current connections and flags.
152
+
153
+ Skips the rebuild when nothing changed since the last poll tick --
154
+ see `QueueSidebar.refresh_queue` for why that matters.
155
+ """
156
+ snapshot = tuple((connection.alias, auto_allowed) for connection, auto_allowed in rows)
157
+ if snapshot == self._rendered:
158
+ return
159
+ self._rendered = snapshot
160
+ list_view = self.query_one("#servers-list", ListView)
161
+ _ = list_view.clear()
162
+ for connection, auto_allowed in rows:
163
+ checkbox = "[✓]" if auto_allowed else "[ ]"
164
+ badge = server_badge(connection.server_type)
165
+ _ = list_view.append(
166
+ ListItem(Static(f"{connection.alias} {badge} {checkbox}", markup=True))
167
+ )
168
+
169
+
170
+ class ActivePanel(Vertical):
171
+ """Main pane: the active batch's header and its command rows.
172
+
173
+ Enter on a highlighted command row opens its full-detail modal
174
+ (`WatchApp.on_list_view_selected`) -- the untruncated result, the
175
+ agent's `reason`, and who/what approved it.
176
+ """
177
+
178
+ _shown_batch_id: BatchId | None = None
179
+
180
+ @override
181
+ def compose(self) -> ComposeResult:
182
+ """Build the header static, a usage hint, and the row list view."""
183
+ yield Static(id="active-header")
184
+ yield Static("[dim]Enter on a command = view full result[/dim]", classes="sidebar-hint")
185
+ yield ListView(id="rows")
186
+
187
+ def show_idle(self) -> None:
188
+ """Show the empty-queue placeholder and drop any stale rows."""
189
+ self._shown_batch_id = None
190
+ _ = self.query_one("#active-header", Static).update(
191
+ "[dim]No pending batches — waiting for new proposals…[/dim]"
192
+ )
193
+ _ = self.query_one("#rows", ListView).clear()
194
+
195
+ def show_batch(self, batch: Batch, commands_in_batch: list[Command]) -> None:
196
+ """Render one batch's header, updating existing rows in place when possible."""
197
+ _ = self.query_one("#active-header", Static).update(format_batch_header(batch))
198
+ rows = self.query_one("#rows", ListView)
199
+ if batch.id != self._shown_batch_id:
200
+ self._shown_batch_id = batch.id
201
+ _ = rows.clear()
202
+ for command in commands_in_batch:
203
+ _ = rows.append(ListItem(CommandRow(command)))
204
+ if commands_in_batch:
205
+ rows.index = 0
206
+ return
207
+ existing = {row.command_id: row for row in rows.query(CommandRow)}
208
+ for command in commands_in_batch:
209
+ row = existing.get(command.id)
210
+ if row is not None:
211
+ row.update_command(command)
212
+ else:
213
+ _ = rows.append(ListItem(CommandRow(command)))
214
+
215
+
216
+ class WatchApp(App[None]):
217
+ """Approval dashboard: sidebar queue + active-batch panel, keys y/n/a/r/h/q."""
218
+
219
+ CSS: ClassVar[str] = """
220
+ Screen { background: $surface; }
221
+ ModeHeader { dock: top; height: 1; background: $panel; padding: 0 1; }
222
+ #mode-title { width: auto; color: $primary; }
223
+ #mode-indicator { width: 1fr; text-align: right; }
224
+ #body { height: 1fr; }
225
+ #sidebar { width: 36; border-right: solid $panel; }
226
+ QueueSidebar { padding: 1; height: 1fr; }
227
+ ServersSidebar { padding: 1; height: auto; max-height: 45%; border-top: solid $panel; }
228
+ .sidebar-title { margin-bottom: 1; }
229
+ .sidebar-hint { margin-bottom: 1; }
230
+ ActivePanel { padding: 1 2; }
231
+ #active-header { margin-bottom: 1; }
232
+ #rows { height: 1fr; }
233
+ CommandRow { margin-bottom: 1; }
234
+ #waiting-notice { padding: 0 2; }
235
+ """
236
+
237
+ BINDINGS: ClassVar[list[BindingType]] = [
238
+ Binding("y", "approve_one", "Approve"),
239
+ Binding("n", "reject_one", "Reject"),
240
+ Binding("a", "approve_all", "Approve all"),
241
+ Binding("r", "reject_all", "Reject all"),
242
+ Binding("m", "toggle_mode", "Mode"),
243
+ Binding("s", "server_settings", "Servers"),
244
+ Binding("h", "history", "History"),
245
+ Binding("q", "quit", "Quit"),
246
+ ]
247
+
248
+ _db: Database # class-level annotation required by strict mode
249
+ _batches: BatchesRepo # class-level annotation required by strict mode
250
+ _commands: CommandsRepo # class-level annotation required by strict mode
251
+ _connections: ConnectionsRepo # class-level annotation required by strict mode
252
+ _mode: AppModeRepo # class-level annotation required by strict mode
253
+ _server_settings: ServerSettingsRepo # class-level annotation required by strict mode
254
+ _busy: bool
255
+ _pinned_batch_id: BatchId | None
256
+
257
+ def __init__( # noqa: PLR0913 - signature follows the required repository DI boundary
258
+ self,
259
+ *,
260
+ db: Database,
261
+ batches: BatchesRepo,
262
+ commands: CommandsRepo,
263
+ connections: ConnectionsRepo,
264
+ mode: AppModeRepo,
265
+ server_settings: ServerSettingsRepo,
266
+ ) -> None:
267
+ """Store the repository collaborators used to read and mutate the queue."""
268
+ super().__init__()
269
+ self.register_theme(CGATE_THEME)
270
+ self.theme = CGATE_THEME.name # pyright: ignore[reportUnannotatedClassAttribute]
271
+ self._db = db
272
+ self._batches = batches
273
+ self._commands = commands
274
+ self._connections = connections
275
+ self._mode = mode
276
+ self._server_settings = server_settings
277
+ self._busy = False
278
+ self._pinned_batch_id = None
279
+
280
+ @override
281
+ def compose(self) -> ComposeResult:
282
+ """Lay out mode header, waiting notice, sidebar + active panel, and footer."""
283
+ yield ModeHeader()
284
+ yield Static(id="waiting-notice")
285
+ with Horizontal(id="body"):
286
+ with Vertical(id="sidebar"):
287
+ yield QueueSidebar()
288
+ yield ServersSidebar()
289
+ yield ActivePanel()
290
+ yield Footer()
291
+
292
+ def on_mount(self) -> None:
293
+ """Render the initial state and start polling for external queue changes."""
294
+ self._refresh()
295
+ _ = self.set_interval(_POLL_INTERVAL_SECONDS, self._refresh)
296
+
297
+ def _render_notice(self, text: str, sub_title: str) -> None:
298
+ """Update the waiting-notice and sub_title without ever raising.
299
+
300
+ Best-effort: if the widget tree is already gone (e.g. `query_one`
301
+ raises during teardown), this swallows rather than raising, since
302
+ Textual would otherwise print its own traceback on top of the one
303
+ being reported.
304
+ """
305
+ try:
306
+ notice = self.query_one("#waiting-notice", Static)
307
+ _ = notice.update(text)
308
+ self.sub_title = sub_title # pyright: ignore[reportUnannotatedClassAttribute]
309
+ except Exception:
310
+ pass
311
+
312
+ def _render_db_error(self, exc: sqlite3.Error) -> None:
313
+ """Render a database error on the TUI without raising (issue #12).
314
+
315
+ Textual's widget-exception handler runs `_handle_exception` →
316
+ `_fatal_error` → prints a raw
317
+ `rich.traceback.Traceback(show_locals=True)` on `_shutdown()`,
318
+ bypassing the `sqlite3.Error` boundary in `cli/main.py`. Every
319
+ DB-touching method on this app must funnel errors through this
320
+ helper so the user sees a clean in-UI message and stays in the
321
+ dashboard; the next polling tick (or the user's next keypress)
322
+ will retry the read automatically.
323
+ """
324
+ error = CGATE_THEME.error
325
+ self._render_notice(
326
+ (
327
+ f"[{error}]Could not read the database:[/{error}] {exc}\n"
328
+ "[dim]Check that no other cgate process is locking "
329
+ "cgate.db. The next read will retry automatically.[/dim]"
330
+ ),
331
+ "database error",
332
+ )
333
+
334
+ def _render_approval_error(self, exc: Exception) -> None:
335
+ """Render an approval failure on the TUI without raising or crashing.
336
+
337
+ `approve_one` can also raise `ConnectionNotFoundError` (the
338
+ connection was removed after the command was queued) or
339
+ `CommandDisappearedError`, on top of `sqlite3.Error`. Left
340
+ uncaught inside a Textual action, either would crash the whole
341
+ dashboard the same way issue #12 did -- over a single bad
342
+ command. The busy flag is still released by `_approve`'s
343
+ `finally`, so the rest of the queue stays fully usable.
344
+ """
345
+ error = CGATE_THEME.error
346
+ self._render_notice(
347
+ f"[{error}]Could not approve this command:[/{error}] {exc}", "approval error"
348
+ )
349
+
350
+ def _global_mode(self) -> Mode:
351
+ """Return the persisted global mode, defaulting to PROPOSE when unset."""
352
+ try:
353
+ return self._mode.get().mode
354
+ except AppModeNotSetError:
355
+ return Mode.PROPOSE
356
+
357
+ def _refresh_mode_and_servers(self) -> None:
358
+ """Update the mode header and the servers sidebar from the database."""
359
+ mode = self._global_mode()
360
+ connections = self._connections.list_all()
361
+ rows = [
362
+ (connection, self._server_settings.get_or_default(connection.alias).auto_allowed)
363
+ for connection in connections
364
+ ]
365
+ auto_allowed = sum(1 for _, allowed in rows if allowed)
366
+ self.query_one(ModeHeader).show_mode(mode, auto_allowed=auto_allowed, total=len(rows))
367
+ self.query_one(ServersSidebar).refresh_servers(rows)
368
+
369
+ def _refresh(self) -> None:
370
+ """Re-read the queue from the database and update every widget from it."""
371
+ try:
372
+ self._refresh_mode_and_servers()
373
+ pending = self._batches.list_pending()
374
+ active = select_active_batch(pending, self._pinned_batch_id)
375
+ self.query_one(QueueSidebar).refresh_queue(pending, active.id if active else None)
376
+ pending_total = count_pending_commands(pending, self._commands)
377
+ notice = self.query_one("#waiting-notice", Static)
378
+ _ = notice.update(
379
+ format_queue_summary(
380
+ pending_commands=pending_total,
381
+ waiting_batches=count_waiting(self._batches),
382
+ )
383
+ )
384
+ panel = self.query_one(ActivePanel)
385
+ if active is None:
386
+ panel.show_idle()
387
+ # Reactive[str] on the base class; reassigning it is the documented
388
+ # Textual pattern, but basedpyright wants a same-interval annotation.
389
+ self.sub_title = "no pending batches" # pyright: ignore[reportUnannotatedClassAttribute]
390
+ return
391
+ commands_in_batch = self._commands.list_for_batch(active.id)
392
+ panel.show_batch(active, commands_in_batch)
393
+ pending_here = len(pending_commands_in_batch(commands_in_batch))
394
+ self.sub_title = f"{pending_here} pending"
395
+ except sqlite3.Error as exc:
396
+ self._render_db_error(exc)
397
+
398
+ def _first_pending(self) -> Command | None:
399
+ """Return the active batch's next pending command, or None.
400
+
401
+ "Active" honors a pinned batch (`select_active_batch`) the same
402
+ way `_refresh` does, so y/n/a/r act on whatever the human picked
403
+ in the queue sidebar rather than always the FIFO-oldest one.
404
+
405
+ Returns ``None`` on a DB error too -- action handlers already
406
+ treat ``None`` as "no-op", so the user's keypress becomes a
407
+ silent skip while the error message stays on screen.
408
+ """
409
+ try:
410
+ pending = self._batches.list_pending()
411
+ active = select_active_batch(pending, self._pinned_batch_id)
412
+ if active is None:
413
+ return None
414
+ commands_in_batch = self._commands.list_for_batch(active.id)
415
+ remaining = pending_commands_in_batch(commands_in_batch)
416
+ return remaining[0] if remaining else None
417
+ except sqlite3.Error as exc:
418
+ self._render_db_error(exc)
419
+ return None
420
+
421
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
422
+ """Pin a batch chosen in the queue sidebar, or open a command's full detail."""
423
+ if event.list_view.id == "queue-list" and isinstance(event.item, BatchRow):
424
+ self._pinned_batch_id = event.item.batch_id
425
+ self._refresh()
426
+ elif event.list_view.id == "rows":
427
+ row = event.item.query_one(CommandRow)
428
+ self._show_command_detail(row.command_id)
429
+
430
+ def _show_command_detail(self, command_id: CommandId) -> None:
431
+ """Open the full-detail modal for one command; best-effort on a DB error."""
432
+ try:
433
+ command = self._commands.get(command_id)
434
+ except sqlite3.Error as exc:
435
+ self._render_db_error(exc)
436
+ return
437
+ if command is not None:
438
+ _ = self.push_screen(CommandDetailModal(command))
439
+
440
+ async def action_approve_one(self) -> None:
441
+ """Approve and execute the active batch's next pending command."""
442
+ if self._busy:
443
+ return
444
+ command = self._first_pending()
445
+ if command is None:
446
+ return
447
+ await self._approve(command.id)
448
+
449
+ def action_reject_one(self) -> None:
450
+ """Reject the active batch's next pending command."""
451
+ if self._busy:
452
+ return
453
+ command = self._first_pending()
454
+ if command is None:
455
+ return
456
+ try:
457
+ _ = reject_one(commands=self._commands, batches=self._batches, command_id=command.id)
458
+ except sqlite3.Error as exc:
459
+ self._render_db_error(exc)
460
+ return
461
+ self._refresh()
462
+
463
+ async def action_approve_all(self) -> None:
464
+ """Approve and execute every pending command in the active batch, in order."""
465
+ if self._busy:
466
+ return
467
+ while (command := self._first_pending()) is not None:
468
+ await self._approve(command.id)
469
+
470
+ def action_reject_all(self) -> None:
471
+ """Reject every pending command in the active batch."""
472
+ if self._busy:
473
+ return
474
+ while (command := self._first_pending()) is not None:
475
+ try:
476
+ _ = reject_one(
477
+ commands=self._commands,
478
+ batches=self._batches,
479
+ command_id=command.id,
480
+ )
481
+ except sqlite3.Error as exc:
482
+ self._render_db_error(exc)
483
+ return
484
+ self._refresh()
485
+
486
+ def action_toggle_mode(self) -> None:
487
+ """Open the confirmation modal and flip the global mode on confirm."""
488
+
489
+ def _after(confirmed: bool | None) -> None: # noqa: FBT001 - Textual push_screen callback signature
490
+ if confirmed:
491
+ self._refresh()
492
+
493
+ self.push_screen(ModeModal(mode_repo=self._mode), _after)
494
+
495
+ def action_server_settings(self) -> None:
496
+ """Open the per-server auto-approve editor and refresh on save."""
497
+
498
+ def _after(saved: bool | None) -> None: # noqa: FBT001 - Textual push_screen callback signature
499
+ if saved:
500
+ self._refresh()
501
+
502
+ self.push_screen(
503
+ ServerSettingsModal(
504
+ connections=self._connections,
505
+ settings=self._server_settings,
506
+ mode_repo=self._mode,
507
+ ),
508
+ _after,
509
+ )
510
+
511
+ def action_history(self) -> None:
512
+ """Open the read-only browser for resolved batches."""
513
+ _ = self.push_screen(HistoryModal(batches=self._batches, commands=self._commands))
514
+
515
+ async def _approve(self, command_id: CommandId) -> None:
516
+ """Mark approved, refresh (so the queue shows it's running), then execute.
517
+
518
+ Split into two off-thread calls instead of one so the human sees
519
+ the "approved, running" state (status glyph ◐, plus the
520
+ "running…" hint in `format_command_line`) the moment `y` is
521
+ pressed -- not just silence for however long the executor's
522
+ timeout allows, which reads as the dashboard having frozen.
523
+ """
524
+ self._busy = True
525
+ try:
526
+ try:
527
+ _ = await asyncio.to_thread(
528
+ mark_approved,
529
+ commands=self._commands,
530
+ connections=self._connections,
531
+ command_id=command_id,
532
+ )
533
+ except sqlite3.Error as exc:
534
+ self._render_db_error(exc)
535
+ return
536
+ except ConnectionNotFoundError as exc:
537
+ self._render_approval_error(exc)
538
+ return
539
+ self._refresh()
540
+ try:
541
+ _ = await asyncio.to_thread(
542
+ execute_and_finalize,
543
+ db=self._db,
544
+ commands=self._commands,
545
+ connections=self._connections,
546
+ batches=self._batches,
547
+ command_id=command_id,
548
+ )
549
+ except sqlite3.Error as exc:
550
+ self._render_db_error(exc)
551
+ return
552
+ except CommandDisappearedError as exc:
553
+ self._render_approval_error(exc)
554
+ return
555
+ finally:
556
+ self._busy = False
557
+ self._refresh()
558
+
559
+
560
+ __all__ = ["WatchApp"]