Component
CLI
Summary
See Vibe (mistral medium 3.5) + Claude sonnet 5 report :
Description
When exiting Vibe's Textual UI (via /exit, Ctrl+C, or Ctrl+D), the app closes correctly but prints tracebacks to stderr. This appears purely cosmetic — the process exits fine — but is confusing for users. Root cause: async event handlers in vibe/cli/textual_ui/app.py keep running after self.exiting is set to True and Textual has started tearing down widgets, so callbacks end up querying/mounting widgets that no longer exist or aren't attached to the DOM yet.
Steps to Reproduce
- Start Vibe (
vibe --mode cli or the default TUI)
- Type
/exit and press Enter — or press Ctrl+C / Ctrl+D during input
- Observe stderr after the app closes
Expected Behavior
Clean exit, no traceback printed.
Actual Behavior
Up to 5 distinct tracebacks depending on the exit path:
1) on_chat_input_container_submitted (app.py:983)
vibe/cli/textual_ui/app.py:983 in on_chat_input_container_submitted
await self._dispatch_idle_input(value)
Queries ChatInputContainer after it has already been destroyed.
2) _dispatch_idle_input (app.py:1007)
vibe/cli/textual_ui/app.py:1007 in _dispatch_idle_input
await self._handle_command(value)
Dispatches a command while self.exiting == True.
3) _handle_command (app.py:1766)
vibe/cli/textual_ui/app.py:1766 in _handle_command
await self._mount_and_scroll(SlashCommandMessage(display))
Mounts a SlashCommandMessage for /exit right before _exit_app() tears down the UI.
4) _mount_and_scroll (app.py:4400)
vibe/cli/textual_ui/app.py:4400 in _mount_and_scroll
await messages_area.mount(widget)
Mounts a widget into messages_area while self.exiting == True.
5) Textual MountError (textual/widget.py:1454)
textual/widget.py:1454 in mount
raise MountError(f"Can't mount widget(s) before {self!r} is mounted")
Mounts into messages_area before it is attached to the Textual DOM (race during init/teardown).
Environment
- Vibe: 2.21.0
- Python: 3.11.2
- OS: Linux aarch64 (Raspberry Pi, Debian-based, kernel 6.12.93+rpt-rpi-v8)
Suggested Fix
Four small guards in app.py (~15 lines total) eliminate all 5 tracebacks:
if self.exiting: return at the top of on_chat_input_container_submitted, _dispatch_idle_input, and _mount_and_scroll
- catch
NoMatches (not a bare Exception) around self.query_one(ChatInputContainer)
- check
messages_area.is_attached before mounting in _mount_and_scroll
- skip mounting
SlashCommandMessage when command.exits is True (no need to display a message right before the app closes)
### Reproduction steps
just exiting the cli
### Versions / environment
Vibe: 2.21.0, Python: 3.11.2, OS: Linux aarch64 (Raspberry Pi, Debian-based, kernel 6.12.93+rpt-rpi-v8)
### Logs & screenshots
```shell
╭────────────────────────────────────────────────────────────────────────────────────── Traceback (most recent call last) ──────────────────────────────────────────────────────────────────────────────────────╮
│ /home/pcx-pi/.local/share/uv/tools/mistral-vibe/lib/python3.12/site-packages/vibe/cli/textual_ui/app.py:983 in on_chat_input_container_submitted │
│ │
│ 980 │ │ │ │ self._restore_input_if_empty(input_widget, value) │
│ 981 │ │ │ return │
│ 982 │ │ │
│ ❱ 983 │ │ await self._dispatch_idle_input(value) │
│ 984 │ │
│ 985 │ @staticmethod │
│ 986 │ def _restore_input_if_empty(input_widget: ChatInputContainer, value: str) -> None: │
│ │
│ ╭─────────────────────────────────────────────────────────── locals ────────────────────────────────────────────────────────────╮ │
│ │ event = Submitted() │ │
│ │ input_widget = ChatInputContainer(id='input-container') │ │
│ │ self = VibeApp(title='VibeApp', classes={'-theme-ansi-dark', '-dark-mode'}, pseudo_classes={'focus', 'ansi', 'dark'}) │ │
│ │ value = '/exit' │ │
│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ /home/pcx-pi/.local/share/uv/tools/mistral-vibe/lib/python3.12/site-packages/vibe/cli/textual_ui/app.py:1007 in _dispatch_idle_input │
│ │
│ 1004 │ │ │ case Teleport(target=target): │
│ 1005 │ │ │ │ await self._handle_teleport_command(target) │
│ 1006 │ │ │ case SlashCommand(): │
│ ❱ 1007 │ │ │ │ await self._handle_command(value) │
│ 1008 │ │ │ case Skill(command=command, name=name): │
│ 1009 │ │ │ │ self._send_skill_telemetry(name) │
│ 1010 │ │ │ │ await self._handle_user_message(command, title_source=command) │
│ │
│ ╭──────────────────────────────────────────────────────── locals ────────────────────────────────────────────────────────╮ │
│ │ self = VibeApp(title='VibeApp', classes={'-theme-ansi-dark', '-dark-mode'}, pseudo_classes={'focus', 'ansi', 'dark'}) │ │
│ │ value = '/exit' │ │
│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ /home/pcx-pi/.local/share/uv/tools/mistral-vibe/lib/python3.12/site-packages/vibe/cli/textual_ui/app.py:1766 in _handle_command │
│ │
│ 1763 │ │ │ │ if command_text.startswith("/") │
│ 1764 │ │ │ │ else cmd_name │
│ 1765 │ │ │ ) │
│ ❱ 1766 │ │ │ await self._mount_and_scroll(SlashCommandMessage(display)) │
│ 1767 │ │ │ handler = getattr(self, command.handler) │
│ 1768 │ │ │ if asyncio.iscoroutinefunction(handler): │
│ 1769 │ │ │ │ await handler(cmd_args=cmd_args) │
│ │
│ ╭───────────────────────────────────────────────────────────────────────────────────────── locals ──────────────────────────────────────────────────────────────────────────────────────────╮ │
│ │ cmd_args = '' │ │
│ │ cmd_name = 'exit' │ │
│ │ command = Command(aliases=frozenset({':q', '/exit', 'exit', ':quit', 'quit'}), description='Exit the application', handler='_exit_app', exits=True, is_available=None) │ │
│ │ command_text = '/exit' │ │
│ │ display = 'exit' │ │
│ │ resolved = ('exit', Command(aliases=frozenset({':q', '/exit', 'exit', ':quit', 'quit'}), description='Exit the application', handler='_exit_app', exits=True, is_available=None), '') │ │
│ │ self = VibeApp(title='VibeApp', classes={'-theme-ansi-dark', '-dark-mode'}, pseudo_classes={'focus', 'ansi', 'dark'}) │ │
│ │ user_input = '/exit' │ │
│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ /home/pcx-pi/.local/share/uv/tools/mistral-vibe/lib/python3.12/site-packages/vibe/cli/textual_ui/app.py:4400 in _mount_and_scroll │
│ │
│ 4397 │ │ │ elif pin_anchor is not None: │
│ 4398 │ │ │ │ await messages_area.mount(widget, before=pin_anchor) │
│ 4399 │ │ │ else: │
│ ❱ 4400 │ │ │ │ await messages_area.mount(widget) │
│ 4401 │ │ │ if isinstance(widget, StreamingMessageBase): │
│ 4402 │ │ │ │ await widget.write_initial_content() │
│ 4403 │
│ │
│ ╭────────────────────────────────────────────────────────────── locals ──────────────────────────────────────────────────────────────╮ │
│ │ after = None │ │
│ │ before = None │ │
│ │ is_user_initiated = True │ │
│ │ messages_area = VerticalGroup(id='messages') │ │
│ │ pin_anchor = None │ │
│ │ self = VibeApp(title='VibeApp', classes={'-theme-ansi-dark', '-dark-mode'}, pseudo_classes={'focus', 'ansi', 'dark'}) │ │
│ │ should_anchor = True │ │
│ │ widget = SlashCommandMessage(classes='user-message slash-command-message') │ │
│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │
│ │
│ /home/pcx-pi/.local/share/uv/tools/mistral-vibe/lib/python3.12/site-packages/textual/widget.py:1454 in mount │
│ │
│ 1451 │ │ if self._closing or self._pruning: ╭──────────────────────────────────── locals ────────────────────────────────────╮ │
│ 1452 │ │ │ return AwaitMount(self, []) │ after = None │ │
│ 1453 │ │ if not self.is_attached: │ before = None │ │
│ ❱ 1454 │ │ │ raise MountError(f"Can't mount widget(s) before {self!r} is mounted") │ self = VerticalGroup(id='messages') │ │
│ 1455 │ │ # Check for duplicate IDs in the incoming widgets │ widgets = (SlashCommandMessage(classes='user-message slash-command-message'),) │ │
│ 1456 │ │ ids_to_mount = [ ╰────────────────────────────────────────────────────────────────────────────────╯ │
│ 1457 │ │ │ widget_id for widget in widgets if (widget_id := widget.id) is not None │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
MountError: Can't mount widget(s) before VerticalGroup(id='messages') is mounted
Component
CLI
Summary
See Vibe (mistral medium 3.5) + Claude sonnet 5 report :
Description
When exiting Vibe's Textual UI (via
/exit, Ctrl+C, or Ctrl+D), the app closes correctly but prints tracebacks to stderr. This appears purely cosmetic — the process exits fine — but is confusing for users. Root cause: async event handlers invibe/cli/textual_ui/app.pykeep running afterself.exitingis set toTrueand Textual has started tearing down widgets, so callbacks end up querying/mounting widgets that no longer exist or aren't attached to the DOM yet.Steps to Reproduce
vibe --mode clior the default TUI)/exitand press Enter — or press Ctrl+C / Ctrl+D during inputExpected Behavior
Clean exit, no traceback printed.
Actual Behavior
Up to 5 distinct tracebacks depending on the exit path:
1)
on_chat_input_container_submitted(app.py:983)Queries
ChatInputContainerafter it has already been destroyed.2)
_dispatch_idle_input(app.py:1007)Dispatches a command while
self.exiting == True.3)
_handle_command(app.py:1766)Mounts a
SlashCommandMessagefor/exitright before_exit_app()tears down the UI.4)
_mount_and_scroll(app.py:4400)Mounts a widget into
messages_areawhileself.exiting == True.5) Textual
MountError(textual/widget.py:1454)Mounts into
messages_areabefore it is attached to the Textual DOM (race during init/teardown).Environment
Suggested Fix
Four small guards in
app.py(~15 lines total) eliminate all 5 tracebacks:if self.exiting: returnat the top ofon_chat_input_container_submitted,_dispatch_idle_input, and_mount_and_scrollNoMatches(not a bareException) aroundself.query_one(ChatInputContainer)messages_area.is_attachedbefore mounting in_mount_and_scrollSlashCommandMessagewhencommand.exitsisTrue(no need to display a message right before the app closes)