tenseleyflow/loader / fea4bce

Browse files

feat: add approval bar and slash command suggestions

**Approval Bar (Claude Code style):**
- Inline approval bar above input for confirmations
- Shows: [tool] command_preview [Y]es [n]o [e]dit
- Press 'Y' to approve, 'n' to reject, 'e' to edit
- Edit puts command in input field for modification
- Replaces modal dialog with cleaner inline UX

**Slash Command Suggestions:**
- Shadow text suggestions for /help, /model, /clear, /exit
- Shows completion in placeholder: "help (Tab to complete)"
- Press Tab to accept suggestion

**Other Improvements:**
- Fix "Show full output" toggle visibility
- Hide completion check messages from users
- Refocus input after rejection
Authored by espadonne
SHA
fea4bce833145b56810e11a3da1e1d52e26b7759
Parents
5453736
Tree
a1af50b

5 changed files

StatusFile+-
M src/loader/ui/app.py 80 53
M src/loader/ui/widgets/__init__.py 4 0
A src/loader/ui/widgets/approval_bar.py 144 0
M src/loader/ui/widgets/input_area.py 76 5
M src/loader/ui/widgets/tool_widget.py 8 4
src/loader/ui/app.pymodified
@@ -1,5 +1,6 @@
11
 """Main Textual application for Loader TUI."""
22
 
3
+import asyncio
34
 import time
45
 from pathlib import Path
56
 
@@ -9,7 +10,7 @@ from textual.app import App, ComposeResult
910
 from textual.binding import Binding
1011
 from textual.containers import Container, ScrollableContainer
1112
 from textual.reactive import reactive
12
-from textual.widgets import Footer, Static
13
+from textual.widgets import Footer, Input, Static
1314
 from textual import work
1415
 from textual.worker import Worker, get_current_worker
1516
 
@@ -35,7 +36,7 @@ from .adapter import (
3536
     ToolCallStarted,
3637
     VerificationPerformed,
3738
 )
38
-from .widgets import ConfirmationModal, DiffWidget, InputArea, StatusLine, StreamingText, ToolCallWidget
39
+from .widgets import ApprovalBar, ConfirmationModal, DiffWidget, InputArea, StatusLine, StreamingText, ToolCallWidget
3940
 
4041
 
4142
 class LoaderApp(App):
@@ -69,6 +70,9 @@ class LoaderApp(App):
6970
         self._streamed_content: bool = False  # Track if any content was streamed
7071
         self._tool_widget_queue: list[ToolCallWidget] = []  # Queue of pending tool widgets
7172
         self._timer_handle = None
73
+        # Approval bar state
74
+        self._pending_confirmation: asyncio.Future | None = None
75
+        self._pending_command: str = ""
7276
 
7377
     def _debug_log(self, message: str) -> None:
7478
         """Write debug message to log file."""
@@ -81,6 +85,7 @@ class LoaderApp(App):
8185
     def compose(self) -> ComposeResult:
8286
         yield Container(
8387
             ScrollableContainer(id="message-area"),
88
+            ApprovalBar(id="approval-bar"),
8489
             InputArea(id="input-area"),
8590
             StatusLine(id="status-line"),
8691
             id="main-container",
@@ -210,49 +215,51 @@ class LoaderApp(App):
210215
         """Show help message with available commands."""
211216
         help_text = """[bold]Available Commands:[/bold]
212217
 
213
-[cyan]/help[/cyan], [cyan]/h[/cyan]        Show this help message
214
-[cyan]/exit[/cyan], [cyan]/q[/cyan]        Exit the application
215
-[cyan]/clear[/cyan], [cyan]/c[/cyan]       Clear the conversation
216
-[cyan]/model[/cyan] [dim]<name>[/dim]   Switch to a different model
217
-[cyan]/models[/cyan]         List available models
218
+[cyan]/help[/cyan], [cyan]/h[/cyan]          Show this help message
219
+[cyan]/exit[/cyan], [cyan]/q[/cyan]          Exit the application
220
+[cyan]/clear[/cyan], [cyan]/c[/cyan]         Clear the conversation
221
+[cyan]/model[/cyan], [cyan]/models[/cyan]    Open model selector (fzf-style)
222
+[cyan]/model[/cyan] [dim]<name>[/dim]     Switch to a specific model
218223
 
219224
 [bold]Shortcuts:[/bold]
220
-[dim]Ctrl+C[/dim]          Exit
221
-[dim]Ctrl+L[/dim]          Clear conversation"""
225
+[dim]Ctrl+C[/dim]            Exit
226
+[dim]Ctrl+L[/dim]            Clear conversation"""
222227
         self._add_message(help_text)
223228
 
224229
     def _handle_model_command(self, args: str) -> None:
225
-        """Handle /model command - switch or list models."""
230
+        """Handle /model command - switch or show selector."""
226231
         if not args:
227
-            # List available models
228
-            self._list_models()
232
+            # Show interactive model selector
233
+            self._show_model_selector()
229234
         else:
230
-            # Switch to specified model
235
+            # Switch to specified model directly
231236
             self._switch_model(args.strip())
232237
 
233
-    def _list_models(self) -> None:
234
-        """List available Ollama models."""
235
-        # Use Textual's worker to run async code
236
-        self.run_worker(self._fetch_and_display_models())
238
+    def _show_model_selector(self) -> None:
239
+        """Show interactive model selection modal."""
240
+        # Use Textual's worker to fetch models then show modal
241
+        self.run_worker(self._fetch_and_show_selector())
242
+
243
+    async def _fetch_and_show_selector(self) -> None:
244
+        """Fetch models and show the selection modal."""
245
+        from .widgets import ModelSelectModal
237246
 
238
-    async def _fetch_and_display_models(self) -> None:
239
-        """Fetch and display available models (async worker)."""
240247
         try:
241248
             models = []
242249
             if hasattr(self.agent.backend, "list_models"):
243250
                 models = await self.agent.backend.list_models()
244251
 
245252
             if models:
246
-                lines = ["[bold]Available Models:[/bold]", ""]
247253
                 current = self.agent.backend.model if hasattr(self.agent.backend, "model") else ""
248
-                for m in models:
249
-                    name = m.get("name", "")
250
-                    size_mb = m.get("size", 0) / (1024 * 1024)
251
-                    marker = "[green]●[/green]" if name == current else "[dim]○[/dim]"
252
-                    lines.append(f"  {marker} [cyan]{name}[/cyan] [dim]({size_mb:.0f}MB)[/dim]")
253
-                lines.append("")
254
-                lines.append("[dim]Use /model <name> to switch[/dim]")
255
-                self._add_message("\n".join(lines))
254
+
255
+                def on_select(selected: str | None) -> None:
256
+                    if selected:
257
+                        self._switch_model(selected)
258
+
259
+                # Schedule on main thread - workers can use call_later
260
+                self.call_later(
261
+                    lambda: self.push_screen(ModelSelectModal(models, current), on_select)
262
+                )
256263
             else:
257264
                 self._add_message("[yellow]No models found. Is Ollama running?[/yellow]")
258265
         except Exception as e:
@@ -281,13 +288,45 @@ class LoaderApp(App):
281288
         message: str,
282289
         details: str,
283290
     ) -> bool:
284
-        """Show confirmation modal and wait for user response."""
285
-        modal = ConfirmationModal(
286
-            tool_name=tool_name,
287
-            message=message,
288
-            details=details,
289
-        )
290
-        return await self.push_screen_wait(modal)
291
+        """Show approval bar and wait for user response."""
292
+        # Create a future to wait on
293
+        loop = asyncio.get_event_loop()
294
+        self._pending_confirmation = loop.create_future()
295
+        self._pending_command = details
296
+
297
+        # Show the approval bar
298
+        approval_bar = self.query_one("#approval-bar", ApprovalBar)
299
+        self.call_later(lambda: approval_bar.show_approval(tool_name, message, details))
300
+
301
+        # Wait for user response
302
+        try:
303
+            return await self._pending_confirmation
304
+        finally:
305
+            self._pending_confirmation = None
306
+            self._pending_command = ""
307
+
308
+    def on_approval_bar_approved(self, event: ApprovalBar.Approved) -> None:
309
+        """Handle approval from the bar."""
310
+        if self._pending_confirmation and not self._pending_confirmation.done():
311
+            self._pending_confirmation.set_result(True)
312
+
313
+    def on_approval_bar_rejected(self, event: ApprovalBar.Rejected) -> None:
314
+        """Handle rejection from the bar."""
315
+        if self._pending_confirmation and not self._pending_confirmation.done():
316
+            self._pending_confirmation.set_result(False)
317
+        # Refocus input
318
+        self.query_one(InputArea).focus_input()
319
+
320
+    def on_approval_bar_edit_requested(self, event: ApprovalBar.EditRequested) -> None:
321
+        """Handle edit request - put command in input for editing."""
322
+        if self._pending_confirmation and not self._pending_confirmation.done():
323
+            self._pending_confirmation.set_result(False)
324
+        # Put the command in the input field for editing
325
+        input_area = self.query_one(InputArea)
326
+        input_widget = input_area.query_one("#user-input", Input)
327
+        input_widget.value = event.command
328
+        input_widget.cursor_position = len(event.command)
329
+        input_area.focus_input()
291330
 
292331
     @work(exclusive=True)
293332
     async def run_agent(self, user_input: str) -> str:
@@ -487,11 +526,9 @@ class LoaderApp(App):
487526
 
488527
     def on_steering_received(self, message: SteeringReceived) -> None:
489528
         """Handle steering message being processed by agent."""
490
-        # The steering message was already displayed when sent,
491
-        # this confirms it was injected into the agent's context
492
-        self._add_message(
493
-            "[dim italic]↪ Steering message injected, agent will incorporate it...[/dim italic]"
494
-        )
529
+        # Don't display anything - auto-steering is internal
530
+        # Only show if this was user-initiated (but we don't track that currently)
531
+        pass
495532
 
496533
     # Reasoning event handlers
497534
     def on_decomposition_created(self, message: DecompositionCreated) -> None:
@@ -592,19 +629,9 @@ class LoaderApp(App):
592629
 
593630
     def on_completion_check_performed(self, message: CompletionCheckPerformed) -> None:
594631
         """Handle task completion check."""
595
-        check = message.completion_check
596
-        if check and not check.is_complete:
597
-            lines = ["[bold yellow]⚡ Task not complete yet![/bold yellow]"]
598
-            if check.accomplished:
599
-                lines.append(f"  [green]Done:[/green] {', '.join(check.accomplished[:3])}")
600
-            if check.suggested_next_steps:
601
-                lines.append("  [yellow]Next steps:[/yellow]")
602
-                for step in check.suggested_next_steps[:2]:
603
-                    lines.append(f"    → {step}")
604
-            lines.append("  [italic]Continuing...[/italic]")
605
-            self._add_message("\n".join(lines), "completion-check")
606
-        else:
607
-            self._add_message(f"[dim]{escape(message.content)}[/dim]")
632
+        # Don't display anything - completion checks are internal steering
633
+        # The agent will continue automatically if needed
634
+        pass
608635
 
609636
     def on_rollback_tracked(self, message: RollbackTracked) -> None:
610637
         """Handle rollback action tracking (verbose mode only)."""
src/loader/ui/widgets/__init__.pymodified
@@ -6,12 +6,16 @@ from .tool_widget import ToolCallWidget
66
 from .diff_widget import DiffWidget
77
 from .streaming import StreamingText
88
 from .confirmation import ConfirmationModal
9
+from .model_select import ModelSelectModal
10
+from .approval_bar import ApprovalBar
911
 
1012
 __all__ = [
13
+    "ApprovalBar",
1114
     "InputArea",
1215
     "StatusLine",
1316
     "ToolCallWidget",
1417
     "DiffWidget",
1518
     "StreamingText",
1619
     "ConfirmationModal",
20
+    "ModelSelectModal",
1721
 ]
src/loader/ui/widgets/approval_bar.pyadded
@@ -0,0 +1,144 @@
1
+"""Approval bar widget for command confirmation (Claude Code style)."""
2
+
3
+from textual.app import ComposeResult
4
+from textual.containers import Horizontal
5
+from textual.message import Message
6
+from textual.widgets import Static
7
+from textual.binding import Binding
8
+from textual.widget import Widget
9
+
10
+
11
+class ApprovalBar(Widget):
12
+    """Inline approval bar that appears above the input when confirmation is needed.
13
+
14
+    Shows: [tool_name] command_preview                    [Y]es [n]o [e]dit
15
+
16
+    Similar to Claude Code's approval flow.
17
+    """
18
+
19
+    BINDINGS = [
20
+        Binding("y", "approve", "Yes", show=False, priority=True),
21
+        Binding("n", "reject", "No", show=False, priority=True),
22
+        Binding("e", "edit", "Edit", show=False, priority=True),
23
+        Binding("escape", "reject", "Cancel", show=False, priority=True),
24
+    ]
25
+
26
+    DEFAULT_CSS = """
27
+    ApprovalBar {
28
+        height: auto;
29
+        dock: bottom;
30
+        display: none;
31
+        padding: 0 1;
32
+        background: $surface;
33
+        border-top: solid $warning;
34
+    }
35
+
36
+    ApprovalBar.visible {
37
+        display: block;
38
+    }
39
+
40
+    ApprovalBar #approval-container {
41
+        width: 100%;
42
+        height: auto;
43
+    }
44
+
45
+    ApprovalBar #approval-tool {
46
+        color: $warning;
47
+        text-style: bold;
48
+        padding-right: 1;
49
+    }
50
+
51
+    ApprovalBar #approval-preview {
52
+        color: $text;
53
+    }
54
+
55
+    ApprovalBar #approval-keys {
56
+        color: $text-muted;
57
+        text-align: right;
58
+        width: auto;
59
+        dock: right;
60
+    }
61
+
62
+    ApprovalBar #approval-keys .key {
63
+        color: $success;
64
+        text-style: bold;
65
+    }
66
+    """
67
+
68
+    class Approved(Message):
69
+        """Message sent when user approves the action."""
70
+        pass
71
+
72
+    class Rejected(Message):
73
+        """Message sent when user rejects the action."""
74
+        pass
75
+
76
+    class EditRequested(Message):
77
+        """Message sent when user wants to edit the command."""
78
+        def __init__(self, command: str) -> None:
79
+            self.command = command
80
+            super().__init__()
81
+
82
+    def __init__(self, *args, **kwargs) -> None:
83
+        super().__init__(*args, **kwargs)
84
+        self._tool_name: str = ""
85
+        self._command_preview: str = ""
86
+        self._full_command: str = ""
87
+
88
+    def compose(self) -> ComposeResult:
89
+        with Horizontal(id="approval-container"):
90
+            yield Static("", id="approval-tool")
91
+            yield Static("", id="approval-preview")
92
+            yield Static(
93
+                "[Y]es  [n]o  [e]dit",
94
+                id="approval-keys",
95
+            )
96
+
97
+    def show_approval(self, tool_name: str, message: str, details: str = "") -> None:
98
+        """Show the approval bar with a pending action.
99
+
100
+        Args:
101
+            tool_name: Name of the tool (e.g., "bash", "write")
102
+            message: Short message about the action
103
+            details: Command or details to show (and potentially edit)
104
+        """
105
+        self._tool_name = tool_name
106
+        self._full_command = details
107
+
108
+        # Update the display
109
+        tool_label = self.query_one("#approval-tool", Static)
110
+        preview_label = self.query_one("#approval-preview", Static)
111
+
112
+        tool_label.update(f"[{tool_name}]")
113
+
114
+        # Truncate preview if too long
115
+        preview = details if details else message
116
+        if len(preview) > 60:
117
+            preview = preview[:57] + "..."
118
+        preview_label.update(preview)
119
+
120
+        # Show the bar
121
+        self.add_class("visible")
122
+        self.focus()
123
+
124
+    def hide_approval(self) -> None:
125
+        """Hide the approval bar."""
126
+        self.remove_class("visible")
127
+        self._tool_name = ""
128
+        self._command_preview = ""
129
+        self._full_command = ""
130
+
131
+    def action_approve(self) -> None:
132
+        """Handle 'y' key - approve the action."""
133
+        self.post_message(self.Approved())
134
+        self.hide_approval()
135
+
136
+    def action_reject(self) -> None:
137
+        """Handle 'n' or escape - reject the action."""
138
+        self.post_message(self.Rejected())
139
+        self.hide_approval()
140
+
141
+    def action_edit(self) -> None:
142
+        """Handle 'e' key - edit the command."""
143
+        self.post_message(self.EditRequested(self._full_command))
144
+        self.hide_approval()
src/loader/ui/widgets/input_area.pymodified
@@ -1,4 +1,4 @@
1
-"""Input area widget with '>' prompt and history support."""
1
+"""Input area widget with '>' prompt, history, and shadow text suggestions."""
22
 
33
 from textual.app import ComposeResult
44
 from textual.containers import Horizontal
@@ -7,8 +7,45 @@ from textual.message import Message
77
 from textual.widgets import Input, Static
88
 
99
 
10
+# Available slash commands for suggestions
11
+SLASH_COMMANDS = [
12
+    "/help",
13
+    "/model",
14
+    "/models",
15
+    "/clear",
16
+    "/exit",
17
+]
18
+
19
+
1020
 class InputArea(Horizontal):
11
-    """Fixed input area at bottom of screen with '>' prompt and history."""
21
+    """Fixed input area at bottom of screen with '>' prompt, history, and shadow text."""
22
+
23
+    DEFAULT_CSS = """
24
+    InputArea {
25
+        height: auto;
26
+        width: 100%;
27
+    }
28
+
29
+    InputArea .prompt {
30
+        width: auto;
31
+        padding: 0 1 0 0;
32
+    }
33
+
34
+    InputArea #input-wrapper {
35
+        width: 1fr;
36
+    }
37
+
38
+    InputArea #user-input {
39
+        width: 100%;
40
+        background: transparent;
41
+    }
42
+
43
+    InputArea #shadow-text {
44
+        color: $text-disabled;
45
+        layer: below;
46
+        offset: 0 0;
47
+    }
48
+    """
1249
 
1350
     class Submitted(Message):
1451
         """Message sent when user submits input."""
@@ -22,11 +59,32 @@ class InputArea(Horizontal):
2259
         self._history: list[str] = []
2360
         self._history_index: int = -1  # -1 = new input, 0+ = history position
2461
         self._current_input: str = ""  # Saves current input when navigating
62
+        self._suggestion: str = ""  # Current shadow text suggestion
2563
 
2664
     def compose(self) -> ComposeResult:
2765
         yield Static("> ", classes="prompt")
2866
         yield Input(placeholder="Type a message...", id="user-input")
2967
 
68
+    def on_input_changed(self, event: Input.Changed) -> None:
69
+        """Handle input changes for shadow text suggestions."""
70
+        value = event.value
71
+        self._suggestion = ""
72
+
73
+        # Suggest slash commands
74
+        if value.startswith("/") and len(value) > 0:
75
+            for cmd in SLASH_COMMANDS:
76
+                if cmd.startswith(value) and cmd != value:
77
+                    self._suggestion = cmd[len(value):]  # Just the completion part
78
+                    break
79
+
80
+        # Update placeholder to show suggestion
81
+        input_widget = event.input
82
+        if self._suggestion:
83
+            # Show the suggestion as part of placeholder
84
+            input_widget.placeholder = f"{self._suggestion}  (Tab to complete)"
85
+        else:
86
+            input_widget.placeholder = "Type a message..."
87
+
3088
     def on_input_submitted(self, event: Input.Submitted) -> None:
3189
         """Handle input submission."""
3290
         value = event.value.strip()
@@ -37,15 +95,28 @@ class InputArea(Horizontal):
3795
             # Reset history navigation
3896
             self._history_index = -1
3997
             self._current_input = ""
98
+            self._suggestion = ""
4099
             self.post_message(self.Submitted(value))
41100
             event.input.clear()
101
+            event.input.placeholder = "Type a message..."
42102
 
43103
     def on_key(self, event: Key) -> None:
44
-        """Handle up/down arrow keys for history navigation."""
45
-        if not self._history:
104
+        """Handle special keys: Tab for completion, Up/Down for history."""
105
+        input_widget = self.query_one("#user-input", Input)
106
+
107
+        # Tab to accept suggestion
108
+        if event.key == "tab" and self._suggestion:
109
+            event.prevent_default()
110
+            event.stop()
111
+            new_value = input_widget.value + self._suggestion
112
+            input_widget.value = new_value
113
+            input_widget.cursor_position = len(new_value)
114
+            self._suggestion = ""
115
+            input_widget.placeholder = "Type a message..."
46116
             return
47117
 
48
-        input_widget = self.query_one("#user-input", Input)
118
+        if not self._history:
119
+            return
49120
 
50121
         if event.key == "up":
51122
             event.prevent_default()
src/loader/ui/widgets/tool_widget.pymodified
@@ -48,10 +48,14 @@ class ToolCallWidget(Vertical):
4848
         )
4949
         yield Static("", id="tool-summary", classes="tool-summary")
5050
 
51
-        # Toggle button for expand/collapse
52
-        yield Button("▶ Show full output", id="tool-toggle", classes="tool-toggle", variant="default")
53
-
54
-        yield Static("", id="tool-full-result", classes="tool-full-result")
51
+        # Toggle button for expand/collapse (hidden by default until result has more lines)
52
+        toggle = Button("▶ Show full output", id="tool-toggle", classes="tool-toggle", variant="default")
53
+        toggle.display = False
54
+        yield toggle
55
+
56
+        full_result = Static("", id="tool-full-result", classes="tool-full-result")
57
+        full_result.display = False
58
+        yield full_result
5559
 
5660
     def on_button_pressed(self, event: Button.Pressed) -> None:
5761
         """Handle toggle button press."""