| 1 | """Approval bar widget for command confirmation (Claude Code style).""" |
| 2 | |
| 3 | from rich import box |
| 4 | from rich.console import Group |
| 5 | from rich.panel import Panel |
| 6 | from rich.text import Text |
| 7 | from textual.app import ComposeResult |
| 8 | from textual.binding import Binding |
| 9 | from textual.message import Message |
| 10 | from textual.widget import Widget |
| 11 | from textual.widgets import Static |
| 12 | |
| 13 | from ...utils.file_mutations import render_file_mutation_preview |
| 14 | |
| 15 | |
| 16 | class ApprovalBar(Widget, can_focus=True): |
| 17 | """Inline approval bar that appears above the input when confirmation is needed. |
| 18 | |
| 19 | Shows: [tool_name] command_preview [Y]es [n]o [e]dit |
| 20 | |
| 21 | Similar to Claude Code's approval flow. |
| 22 | """ |
| 23 | |
| 24 | BINDINGS = [ |
| 25 | Binding("y", "approve", "Yes", show=False, priority=True), |
| 26 | Binding("n", "reject", "No", show=False, priority=True), |
| 27 | Binding("e", "edit", "Edit", show=False, priority=True), |
| 28 | Binding("escape", "reject", "Cancel", show=False, priority=True), |
| 29 | ] |
| 30 | |
| 31 | DEFAULT_CSS = """ |
| 32 | ApprovalBar { |
| 33 | height: auto; |
| 34 | max-height: 20; |
| 35 | display: none; |
| 36 | padding: 0 1; |
| 37 | background: $warning 15%; |
| 38 | border-top: tall $warning; |
| 39 | border-bottom: tall $warning; |
| 40 | } |
| 41 | |
| 42 | ApprovalBar.visible { |
| 43 | display: block; |
| 44 | } |
| 45 | |
| 46 | ApprovalBar #approval-content { |
| 47 | width: 100%; |
| 48 | } |
| 49 | """ |
| 50 | |
| 51 | class Approved(Message): |
| 52 | """Message sent when user approves the action.""" |
| 53 | pass |
| 54 | |
| 55 | class Rejected(Message): |
| 56 | """Message sent when user rejects the action.""" |
| 57 | pass |
| 58 | |
| 59 | class EditRequested(Message): |
| 60 | """Message sent when user wants to edit the command.""" |
| 61 | def __init__(self, command: str) -> None: |
| 62 | self.command = command |
| 63 | super().__init__() |
| 64 | |
| 65 | def __init__(self, *args, **kwargs) -> None: |
| 66 | super().__init__(*args, **kwargs) |
| 67 | self._tool_name: str = "" |
| 68 | self._command_preview: str = "" |
| 69 | self._full_command: str = "" |
| 70 | self._preview: dict | None = None |
| 71 | # Make this widget focusable from the start |
| 72 | self.can_focus = True |
| 73 | |
| 74 | def compose(self) -> ComposeResult: |
| 75 | yield Static("", id="approval-content", markup=False) |
| 76 | |
| 77 | def show_approval( |
| 78 | self, |
| 79 | tool_name: str, |
| 80 | message: str, |
| 81 | details: str = "", |
| 82 | *, |
| 83 | preview: dict | None = None, |
| 84 | ) -> None: |
| 85 | """Show the approval bar with a pending action. |
| 86 | |
| 87 | Args: |
| 88 | tool_name: Name of the tool (e.g., "bash", "write") |
| 89 | message: Short message about the action |
| 90 | details: Command or details to show (and potentially edit) |
| 91 | """ |
| 92 | self._tool_name = tool_name |
| 93 | self._full_command = details |
| 94 | self._preview = preview |
| 95 | |
| 96 | preview_text = details if details else message |
| 97 | content = self.query_one("#approval-content", Static) |
| 98 | if tool_name == "bash": |
| 99 | header = Text("Bash", style="bold yellow") |
| 100 | command = Text(preview_text or "(empty command)") |
| 101 | content.update( |
| 102 | Group( |
| 103 | header, |
| 104 | Panel( |
| 105 | command, |
| 106 | title="Command", |
| 107 | border_style="yellow", |
| 108 | box=box.SQUARE, |
| 109 | expand=True, |
| 110 | ), |
| 111 | _approval_controls(), |
| 112 | ) |
| 113 | ) |
| 114 | elif self._preview: |
| 115 | header = Text(f"Approve {_label_for_tool(tool_name)}", style="bold yellow") |
| 116 | content.update( |
| 117 | Group( |
| 118 | header, |
| 119 | render_file_mutation_preview( |
| 120 | self._preview, |
| 121 | border_style="yellow", |
| 122 | title="Preview", |
| 123 | max_lines=20, |
| 124 | max_chars=2_500, |
| 125 | ), |
| 126 | _approval_controls(), |
| 127 | ) |
| 128 | ) |
| 129 | else: |
| 130 | if len(preview_text) > 400: |
| 131 | preview_text = preview_text[:397] + "..." |
| 132 | header = Text(f"Approve {_label_for_tool(tool_name)}", style="bold yellow") |
| 133 | content.update( |
| 134 | Group( |
| 135 | header, |
| 136 | Panel( |
| 137 | Text(preview_text or "(no details)"), |
| 138 | title="Details", |
| 139 | border_style="yellow", |
| 140 | box=box.SQUARE, |
| 141 | expand=True, |
| 142 | ), |
| 143 | _approval_controls(), |
| 144 | ) |
| 145 | ) |
| 146 | |
| 147 | # Show the bar |
| 148 | self.add_class("visible") |
| 149 | |
| 150 | try: |
| 151 | with open("/tmp/loader_debug.log", "a") as f: |
| 152 | f.write( |
| 153 | f"[approval-bar] show_approval: tool={tool_name}, " |
| 154 | f"visible=True, scheduling focus...\n" |
| 155 | ) |
| 156 | except Exception: |
| 157 | pass |
| 158 | |
| 159 | # Use a short timer to let Textual complete the layout pass |
| 160 | # before attempting focus. call_after_refresh is too early. |
| 161 | self.set_timer(0.15, self._deferred_focus) |
| 162 | |
| 163 | def _deferred_focus(self) -> None: |
| 164 | """Attempt focus after layout has settled.""" |
| 165 | self.focus() |
| 166 | try: |
| 167 | with open("/tmp/loader_debug.log", "a") as f: |
| 168 | f.write(f"[approval-bar] deferred focus: has_focus={self.has_focus}\n") |
| 169 | except Exception: |
| 170 | pass |
| 171 | if not self.has_focus: |
| 172 | # Last resort: try scrolling into view and focusing again |
| 173 | self.scroll_visible() |
| 174 | self.focus() |
| 175 | |
| 176 | def hide_approval(self) -> None: |
| 177 | """Hide the approval bar.""" |
| 178 | self.remove_class("visible") |
| 179 | self._tool_name = "" |
| 180 | self._command_preview = "" |
| 181 | self._full_command = "" |
| 182 | self._preview = None |
| 183 | |
| 184 | def action_approve(self) -> None: |
| 185 | """Handle 'y' key - approve the action.""" |
| 186 | try: |
| 187 | with open("/tmp/loader_debug.log", "a") as f: |
| 188 | f.write("[approval-bar] action_approve called, posting Approved message\n") |
| 189 | except Exception: |
| 190 | pass |
| 191 | self.post_message(self.Approved()) |
| 192 | self.hide_approval() |
| 193 | |
| 194 | def action_reject(self) -> None: |
| 195 | """Handle 'n' or escape - reject the action.""" |
| 196 | try: |
| 197 | with open("/tmp/loader_debug.log", "a") as f: |
| 198 | f.write("[approval-bar] action_reject called, posting Rejected message\n") |
| 199 | except Exception: |
| 200 | pass |
| 201 | self.post_message(self.Rejected()) |
| 202 | self.hide_approval() |
| 203 | |
| 204 | def action_edit(self) -> None: |
| 205 | """Handle 'e' key - edit the command.""" |
| 206 | try: |
| 207 | with open("/tmp/loader_debug.log", "a") as f: |
| 208 | f.write("[approval-bar] action_edit called, posting EditRequested message\n") |
| 209 | except Exception: |
| 210 | pass |
| 211 | self.post_message(self.EditRequested(self._full_command)) |
| 212 | self.hide_approval() |
| 213 | |
| 214 | |
| 215 | def _label_for_tool(tool_name: str) -> str: |
| 216 | return { |
| 217 | "write": "Write", |
| 218 | "edit": "Edit", |
| 219 | "patch": "Patch", |
| 220 | "bash": "Bash", |
| 221 | }.get(tool_name, tool_name) |
| 222 | |
| 223 | |
| 224 | def _approval_controls() -> Text: |
| 225 | return Text.assemble( |
| 226 | ("[Y]", "bold green"), |
| 227 | ("es ",), |
| 228 | ("[n]", "bold red"), |
| 229 | ("o ",), |
| 230 | ("[e]", "bold"), |
| 231 | ("dit",), |
| 232 | ) |