CodeDocs Vault

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 counter

Visual 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 = None

Bottom App States

# app.py:59-62
class BottomApp(StrEnum):
    Approval = auto()   # Tool approval dialog
    Config = auto()     # Configuration editor
    Input = auto()      # Normal chat input

Event 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:

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 profile

Slash 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_collapsed

Cycle 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:

  1. The agent profile is updated via AgentManager.switch_profile()
  2. The system prompt is rebuilt with the new agent's config
  3. The agent indicator widget updates to show the active profile
  4. 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