TUI Architecture and Event Flow
The TUI (Terminal User Interface) is built with Textual, providing a rich interactive experience in the terminal.
Overview
Directory: vibe/cli/textual_ui/
vibe/cli/textual_ui/
├── app.py # Main VibeApp class
├── app.tcss # Textual CSS styling
├── external_editor.py # External editor integration
├── terminal_theme.py # Terminal theme detection
├── handlers/
│ └── event_handler.py # Agent event handling
└── widgets/
├── approval_app.py # Tool approval UI
├── chat_input.py # Input widget
├── messages.py # Message display widgets
├── tools.py # Tool call/result widgets
├── config_app.py # Configuration editor
├── welcome.py # Welcome banner
├── question_app.py # Ask user question UI
├── agent_indicator.py # Agent profile display
├── spinner.py # Loading spinners
├── status_message.py # Status messages
├── no_markup_static.py # Safe text display (no markup injection)
└── ...
VibeApp Class
File: vibe/cli/textual_ui/app.py
Class: VibeApp at line 65
Key Bindings
# app.py:69-75
BINDINGS: ClassVar[list[BindingType]] = [
Binding("ctrl+c", "force_quit", "Quit", show=False),
Binding("escape", "interrupt", "Interrupt", show=False, priority=True),
Binding("ctrl+o", "toggle_tool", "Toggle Tool", show=False),
Binding("ctrl+t", "toggle_todo", "Toggle Todo", show=False),
Binding("shift+tab", "cycle_mode", "Cycle Mode", show=False, priority=True),
]UI Layout
# app.py:130-155
def compose(self) -> ComposeResult:
with VerticalScroll(id="chat"):
yield WelcomeBanner(self.config)
yield Static(id="messages") # Message container
with Horizontal(id="loading-area"):
yield Static(id="loading-area-content")
yield AgentIndicator() # Shows active agent profile
yield Static(id="todo-area") # Todo display
with Static(id="bottom-app-container"):
yield ChatInputContainer(...) # Input area
with Horizontal(id="bottom-bar"):
yield PathDisplay(...) # Current path
yield Static(id="spacer")
yield ContextProgress() # Token counterVisual Layout
┌─────────────────────────────────────────────────────────────┐
│ [Welcome Banner] │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ #messages │ │
│ │ - UserMessage │ │
│ │ - AssistantMessage │ │
│ │ - ToolCallMessage │ │
│ │ - ToolResultMessage │ │
│ │ ... │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ [Loading Indicator] [Agent: Default/Plan/...] │
│ │
│ [Todo Area - if visible] │
│ │
├──────────────────────────────────────────────────────────────┤
│ [Bottom App Container] │
│ - ChatInputContainer (default) │
│ - ApprovalApp (when tool needs approval) │
│ - ConfigApp (when editing config) │
├──────────────────────────────────────────────────────────────┤
│ [Path Display] [Context Progress] │
└─────────────────────────────────────────────────────────────┘
Application State
State Variables
# app.py:93-128
self.agent: AgentLoop | None = None # Agent instance
self._agent_running = False # Is agent processing
self._agent_initializing = False # Is agent being created
self._interrupt_requested = False # Interrupt in progress
self._agent_task: asyncio.Task | None = None # Current agent task
self._agent_init_task: asyncio.Task | None = None
self._loading_widget: LoadingWidget | None = None
self._pending_approval: asyncio.Future | None = None
self._current_bottom_app: BottomApp = BottomApp.Input
self._tools_collapsed = True
self._todos_collapsed = False
self._current_streaming_message: AssistantMessage | None = NoneBottom App States
# app.py:59-62
class BottomApp(StrEnum):
Approval = auto() # Tool approval dialog
Config = auto() # Configuration editor
Input = auto() # Normal chat inputEvent Handler
File: vibe/cli/textual_ui/handlers/event_handler.py
Class: EventHandler
Processes agent events and updates the UI:
EventHandler.handle_event(event, loading_active, loading_widget)
│
match event:
│
├── AssistantEvent:
│ - Mount or update AssistantMessage widget
│ - Handle streaming content
│
├── ReasoningEvent:
│ - Display reasoning/thinking content from LLM
│
├── ToolCallEvent:
│ - Mount ToolCallMessage widget
│ - Show tool name and arguments
│ - Uses ToolUIData.get_call_display() for custom rendering
│
├── ToolResultEvent:
│ - Mount ToolResultMessage widget
│ - Display result or error
│ - Uses ToolUIData.get_result_display() for custom rendering
│ - Handle todo updates specially
│
├── ToolStreamEvent:
│ - Streaming output from tools (e.g., Task subagent progress)
│
├── CompactStartEvent:
│ - Show compaction in progress
│
└── CompactEndEvent:
- Show compaction results
Question App (Ask User Question)
When the ask_user_question tool is invoked, a QuestionApp widget replaces the normal chat input:
- Displays question text with header tabs for multiple questions
- Shows 2-4 choice options as selectable buttons
- Includes automatic "Other" option for free-text input
- Multi-select mode for non-exclusive choices
- Supports cancellation (Escape)
User Input Flow
Input Submission
User types message and presses Enter
│
▼
on_chat_input_container_submitted() [app.py:193]
│
├─1─► Get and validate input [app.py:196-198]
│
├─2─► Clear input widget [app.py:200-201]
│
├─3─► Handle special prefixes:
│ │
│ ├── "!" → _handle_bash_command() [app.py:310]
│ │ Direct shell execution
│ │
│ └── "/" → _handle_command() [app.py:300]
│ Slash command (help, status, config, etc.)
│
└─4─► _handle_user_message() [app.py:353]
Slash Commands
File: vibe/cli/commands.py
# Available commands
/help, /h → Show help
/status, /stats → Show session statistics
/config, /cfg → Open configuration editor
/reload, /r → Reload configuration
/clear, /reset → Clear history
/log, /logpath → Show log file path
/compact → Trigger context compaction
/exit, /quit, /q → Exit application
/agent <name> → Switch agent profileSlash commands prefixed with / are also used to invoke skills (defined via SKILL.md files with user-invocable: true).
Agent Turn Processing
_handle_agent_turn(prompt) [app.py:470]
│
├─1─► Set _agent_running = True
│
├─2─► Mount loading widget [app.py:476-480]
│
├─3─► Render path prompt [app.py:483-484]
│ Expand @file references
│
├─4─► Process agent events [app.py:486-499]
│ │
│ async for event in agent.act(prompt):
│ │
│ ├── Update context progress
│ │ _context_progress.tokens = TokenState(...)
│ │
│ └── Delegate to event_handler
│ event_handler.handle_event(event)
│
└─5─► Cleanup [app.py:515-522]
- Remove loading widget
- Finalize streaming message
- Reset state
Tool Approval Flow
When a tool requires approval:
Tool call with ASK permission
│
▼
AgentLoop calls approval_callback [agent_loop.py]
│
▼
_approval_callback() [app.py:461-468]
│
├─1─► Create Future for result
│ _pending_approval = asyncio.Future()
│
├─2─► Switch to approval UI
│ _switch_to_approval_app(tool, args)
│
├─3─► Await user decision
│ result = await _pending_approval
│
└─4─► Return result to agent
Approval Events
# app.py:215-248
on_approval_app_approval_granted() # User clicked "Yes"
on_approval_app_approval_granted_always_tool() # "Always allow this tool"
on_approval_app_approval_rejected() # User clicked "No"Message Widgets
File: vibe/cli/textual_ui/widgets/messages.py
| Widget | Purpose |
|---|---|
UserMessage |
User input display |
AssistantMessage |
LLM response (supports streaming) |
ErrorMessage |
Error display |
InterruptMessage |
Interrupt notification |
UserCommandMessage |
Slash command output |
BashOutputMessage |
Shell command output |
Streaming Messages
# app.py:973-998
async def _mount_and_scroll(self, widget: Widget) -> None:
if isinstance(widget, AssistantMessage):
if self._current_streaming_message is not None:
# Append to existing streaming message
content = widget._content or ""
await self._current_streaming_message.append_content(content)
else:
# Start new streaming message
self._current_streaming_message = widget
await messages_area.mount(widget)
await widget.write_initial_content()
else:
await self._finalize_current_streaming_message()
await messages_area.mount(widget)Key Actions
Interrupt (Escape)
# app.py:851-882
def action_interrupt(self) -> None:
# Close config or approval app if open
if self._current_bottom_app == BottomApp.Config:
config_app.action_close()
return
if self._current_bottom_app == BottomApp.Approval:
approval_app.action_reject()
return
# Interrupt agent if running
if self._agent_running or agent_init_pending:
self.run_worker(self._interrupt_agent())Toggle Tool Output (Ctrl+O)
# app.py:884-905
async def action_toggle_tool(self) -> None:
self._tools_collapsed = not self._tools_collapsed
# Update all tool result widgets (except todo)
for result in self.event_handler.tool_results:
if result.event.tool_name != "todo":
result.collapsed = self._tools_collapsed
await result.render_result()Toggle Todo (Ctrl+T)
# app.py:907-921
async def action_toggle_todo(self) -> None:
self._todos_collapsed = not self._todos_collapsed
# Update todo result widgets only
for result in self.event_handler.tool_results:
if result.event.tool_name == "todo":
result.collapsed = self._todos_collapsedCycle Agent Profile (Shift+Tab)
Shift+Tab now cycles through available agent profiles (default → plan → accept-edits → auto-approve → custom agents → ...).
The cycling order is determined by AgentManager.get_agent_order(), which returns built-in agents in a fixed order followed by alphabetically sorted custom agents. Subagents (like explore) are excluded from cycling.
When switching agents:
- The agent profile is updated via
AgentManager.switch_profile() - The system prompt is rebuilt with the new agent's config
- The agent indicator widget updates to show the active profile
- Auto-approve state adjusts based on the profile's safety level
Context Progress Widget
Displays token usage:
# app.py:169-172
if self.config.auto_compact_threshold > 0:
self._context_progress.tokens = TokenState(
max_tokens=self.config.auto_compact_threshold,
current_tokens=0
)Updated after each agent turn:
# app.py:487-492
if self._context_progress and self.agent:
self._context_progress.tokens = TokenState(
max_tokens=current_state.max_tokens,
current_tokens=self.agent.stats.context_tokens,
)Update Notification
# app.py:1025-1073
def _schedule_update_notification(self) -> None:
if self._version_update_notifier and self._is_update_check_enabled:
self._update_notification_task = asyncio.create_task(
self._check_version_update()
)
async def _check_version_update(self) -> None:
update = await is_version_update_available(
self._version_update_notifier,
current_version=self._current_version
)
if update:
self._display_update_notification(update)
def _display_update_notification(self, update: VersionUpdate) -> None:
self.notify(
f'{current} => {update.latest_version}\nRun "uv tool upgrade mistral-vibe"',
title="Update available",
severity="information",
timeout=10
)Clipboard Support
# app.py:1075-1076
def on_mouse_up(self, event: MouseUp) -> None:
copy_selection_to_clipboard(self)Event Flow Diagram
User Input
│
▼
┌─────────────────────────────────────────────────────────┐
│ ChatInputContainer.Submitted │
└────────────────────────┬────────────────────────────────┘
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
"!" prefix "/" prefix Normal message
│ │ │
▼ ▼ ▼
Direct bash Slash cmd _handle_user_message()
execution handler │
▼
┌───────────────┐
│ Mount UserMsg │
└───────┬───────┘
│
▼
┌───────────────┐
│ agent.act() │
└───────┬───────┘
│
┌──────────────────────────────┼──────────────────────────────┐
│ │ │
▼ ▼ ▼
AssistantEvent ToolCallEvent ToolResultEvent
│ │ │
▼ ▼ ▼
Mount/Update Mount ToolCallMsg Mount ToolResultMsg
AssistantMessage │ │
│ │ │
│ If ASK permission: │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ Switch to ApprovalApp │ │
│ └───────────┬─────────────┘ │
│ │ │
│ ┌────────────┼────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ Approve Approve Reject │
│ Always │
│ │ │ │ │
│ └────────────┼────────────┘ │
│ │ │
│ ▼ │
│ Switch back to Input │
│ │
└─────────────────────────┬──────────────────────────────────┘
│
▼
Continue loop or
finalize message
Source File References
| File | Key Lines | Description |
|---|---|---|
app.py:65-129 |
VibeApp.__init__() |
App initialization |
app.py:130-155 |
compose() |
UI layout |
app.py:156-185 |
on_mount() |
App mounting |
app.py:193-213 |
on_chat_input_container_submitted() |
Input handling |
app.py:300-308 |
_handle_command() |
Slash commands |
app.py:310-351 |
_handle_bash_command() |
Direct shell |
app.py:353-368 |
_handle_user_message() |
Message handling |
app.py:461-468 |
_approval_callback() |
Tool approval |
app.py:470-522 |
_handle_agent_turn() |
Agent processing |
app.py:524-562 |
_interrupt_agent() |
Interruption |
app.py:851-943 |
Actions | Key bindings |
app.py:973-998 |
_mount_and_scroll() |
Widget mounting |
app.py:1079-1099 |
run_textual_ui() |
Entry point |