Python · 7371 bytes Raw Blame History
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 )