Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Quentin 2026-05-25 16:05:26 +02:00 committed by GitHub
parent f71bfd3b8c
commit adb1ca74ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
202 changed files with 5828 additions and 1968 deletions

View file

@ -174,6 +174,11 @@ class CommandRegistry:
description="Show data retention information",
handler="_show_data_retention",
),
"theme": Command(
aliases=frozenset(["/theme"]),
description="Select theme",
handler="_show_theme",
),
}
@property

View file

@ -1,58 +0,0 @@
from __future__ import annotations
from pygments.token import Token
from textual.content import Content
from textual.highlight import HighlightTheme, highlight
from textual.widgets import Markdown
from textual.widgets._markdown import MarkdownFence
class AnsiHighlightTheme(HighlightTheme):
STYLES = {
Token.Comment: "ansi_bright_black italic",
Token.Error: "ansi_red",
Token.Generic.Strong: "bold",
Token.Generic.Emph: "italic",
Token.Generic.Error: "ansi_red",
Token.Generic.Heading: "ansi_blue underline",
Token.Generic.Subheading: "ansi_blue",
Token.Keyword: "ansi_magenta",
Token.Keyword.Constant: "ansi_cyan",
Token.Keyword.Namespace: "ansi_magenta",
Token.Keyword.Type: "ansi_cyan",
Token.Literal.Number: "ansi_yellow",
Token.Literal.String.Backtick: "ansi_bright_black",
Token.Literal.String: "ansi_green",
Token.Literal.String.Doc: "ansi_green italic",
Token.Literal.String.Double: "ansi_green",
Token.Name: "ansi_default",
Token.Name.Attribute: "ansi_yellow",
Token.Name.Builtin: "ansi_cyan",
Token.Name.Builtin.Pseudo: "italic",
Token.Name.Class: "ansi_yellow",
Token.Name.Constant: "ansi_red",
Token.Name.Decorator: "ansi_blue",
Token.Name.Function: "ansi_blue",
Token.Name.Function.Magic: "ansi_blue",
Token.Name.Tag: "ansi_blue",
Token.Name.Variable: "ansi_default",
Token.Number: "ansi_yellow",
Token.Operator: "ansi_default",
Token.Operator.Word: "ansi_magenta",
Token.String: "ansi_green",
Token.Whitespace: "",
}
class AnsiMarkdownFence(MarkdownFence):
@classmethod
def highlight(cls, code: str, language: str) -> Content:
return highlight(code, language=language or None, theme=AnsiHighlightTheme)
class AnsiMarkdown(Markdown):
BLOCKS = {
**Markdown.BLOCKS,
"fence": AnsiMarkdownFence,
"code_block": AnsiMarkdownFence,
}

View file

@ -23,6 +23,7 @@ from textual.binding import Binding, BindingType
from textual.containers import Horizontal, VerticalGroup, VerticalScroll
from textual.driver import Driver
from textual.events import AppBlur, AppFocus, MouseUp
from textual.theme import BUILTIN_THEMES
from textual.widget import Widget
from textual.widgets import Static
@ -91,6 +92,7 @@ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
from vibe.cli.textual_ui.widgets.rewind_app import RewindApp
from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp
from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage
from vibe.cli.textual_ui.widgets.theme_picker import ThemePickerApp, sorted_theme_names
from vibe.cli.textual_ui.widgets.thinking_picker import ThinkingPickerApp
from vibe.cli.textual_ui.widgets.tools import ToolResultMessage
from vibe.cli.textual_ui.widgets.voice_app import VoiceApp
@ -128,7 +130,7 @@ from vibe.core.autocompletion.path_prompt import (
build_title_segments,
)
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import VibeConfig
from vibe.core.config import DEFAULT_THEME, VibeConfig
from vibe.core.data_retention import DATA_RETENTION_MESSAGE
from vibe.core.hooks.models import HookStartEvent
from vibe.core.log_reader import LogReader
@ -185,17 +187,18 @@ from vibe.core.utils import (
)
def _compute_connectors_count(
def _compute_connector_counts(
config: VibeConfig, connector_registry: ConnectorRegistry | None
) -> int:
) -> tuple[int, int]:
total = connector_registry.connector_count if connector_registry else 0
if total == 0:
return 0
return (0, 0)
disabled_names = {c.name for c in config.connectors if c.disabled}
known_names = set(
connector_registry.get_connector_names() if connector_registry else []
)
return total - len(disabled_names & known_names)
enabled = total - len(disabled_names & known_names)
return (enabled, total)
class BottomApp(StrEnum):
@ -214,6 +217,7 @@ class BottomApp(StrEnum):
ModelPicker = auto()
ProxySetup = auto()
Question = auto()
ThemePicker = auto()
ThinkingPicker = auto()
Rewind = auto()
SessionPicker = auto()
@ -464,13 +468,14 @@ class VibeApp(App): # noqa: PLR0904
def compose(self) -> ComposeResult:
with ChatScroll(id="chat"):
connectors_enabled, connectors_total = _compute_connector_counts(
self.config, self.agent_loop.connector_registry
)
self._banner = Banner(
config=self.config,
skill_manager=self.agent_loop.skill_manager,
mcp_registry=self.agent_loop.mcp_registry,
connectors_count=_compute_connectors_count(
self.config, self.agent_loop.connector_registry
),
connectors_enabled=connectors_enabled,
connectors_total=connectors_total,
)
yield self._banner
yield VerticalGroup(id="messages")
@ -498,7 +503,7 @@ class VibeApp(App): # noqa: PLR0904
yield ContextProgress()
async def on_mount(self) -> None:
self.theme = "textual-ansi"
self._apply_theme(self.config.theme)
self._terminal_notifier.restore()
self._cached_messages_area = self.query_one("#messages")
@ -853,6 +858,25 @@ class VibeApp(App): # noqa: PLR0904
) -> None:
await self._switch_to_input_app()
async def on_theme_picker_app_theme_previewed(
self, message: ThemePickerApp.ThemePreviewed
) -> None:
self._apply_theme(message.theme)
async def on_theme_picker_app_theme_selected(
self, message: ThemePickerApp.ThemeSelected
) -> None:
self._apply_theme(message.theme)
self.config.theme = message.theme
VibeConfig.save_updates({"theme": message.theme})
await self._switch_to_input_app()
async def on_theme_picker_app_cancelled(
self, message: ThemePickerApp.Cancelled
) -> None:
self._apply_theme(message.original_theme)
await self._switch_to_input_app()
async def on_mcpapp_mcpclosed(self, _message: MCPApp.MCPClosed) -> None:
await self._mount_and_scroll(UserCommandMessage("MCP servers closed."))
await self._switch_to_input_app()
@ -1751,6 +1775,11 @@ class VibeApp(App): # noqa: PLR0904
return
await self._switch_to_thinking_picker_app()
async def _show_theme(self, **kwargs: Any) -> None:
if self._current_bottom_app == BottomApp.ThemePicker:
return
await self._switch_to_theme_picker_app()
async def _show_proxy_setup(self, **kwargs: Any) -> None:
if self._current_bottom_app == BottomApp.ProxySetup:
return
@ -2017,13 +2046,14 @@ class VibeApp(App): # noqa: PLR0904
self._narrator_manager.sync()
if self._banner:
ce, ct = _compute_connector_counts(
base_config, self.agent_loop.connector_registry
)
self._banner.set_state(
base_config,
self.agent_loop.skill_manager,
self.agent_loop.mcp_registry,
connectors_count=_compute_connectors_count(
base_config, self.agent_loop.connector_registry
),
connectors_enabled=ce,
connectors_total=ct,
plan_description=plan_title(self._plan_info),
)
await self._mount_and_scroll(
@ -2287,6 +2317,23 @@ class VibeApp(App): # noqa: PLR0904
)
)
async def _switch_to_theme_picker_app(self) -> None:
if self._current_bottom_app == BottomApp.ThemePicker:
return
await self._switch_from_input(
ThemePickerApp(
theme_names=sorted_theme_names(), current_theme=self.config.theme
)
)
def _apply_theme(self, theme: str) -> None:
if theme not in BUILTIN_THEMES:
logger.warning("Unknown theme=%s; falling back to %s", theme, DEFAULT_THEME)
self.theme = DEFAULT_THEME
return
self.theme = theme
async def _switch_to_proxy_setup_app(self) -> None:
if self._current_bottom_app == BottomApp.ProxySetup:
return
@ -2340,6 +2387,8 @@ class VibeApp(App): # noqa: PLR0904
self.query_one(ConfigApp).focus()
case BottomApp.ModelPicker:
self.query_one(ModelPickerApp).focus()
case BottomApp.ThemePicker:
self.query_one(ThemePickerApp).focus()
case BottomApp.ThinkingPicker:
self.query_one(ThinkingPickerApp).focus()
case BottomApp.ProxySetup:
@ -2411,6 +2460,16 @@ class VibeApp(App): # noqa: PLR0904
pass
self._last_escape_time = None
def _handle_theme_picker_app_escape(self) -> None:
try:
theme_picker = self.query_one(ThemePickerApp)
theme_picker.post_message(
ThemePickerApp.Cancelled(original_theme=self.config.theme)
)
except Exception:
pass
self._last_escape_time = None
def _handle_thinking_picker_app_escape(self) -> None:
try:
thinking_picker = self.query_one(ThinkingPickerApp)
@ -2661,6 +2720,8 @@ class VibeApp(App): # noqa: PLR0904
self._handle_question_app_escape()
elif self._current_bottom_app == BottomApp.ModelPicker:
self._handle_model_picker_app_escape()
elif self._current_bottom_app == BottomApp.ThemePicker:
self._handle_theme_picker_app_escape()
elif self._current_bottom_app == BottomApp.ThinkingPicker:
self._handle_thinking_picker_app_escape()
elif self._current_bottom_app == BottomApp.SessionPicker:
@ -2781,13 +2842,14 @@ class VibeApp(App): # noqa: PLR0904
def _refresh_banner(self) -> None:
if self._banner:
ce, ct = _compute_connector_counts(
self.config, self.agent_loop.connector_registry
)
self._banner.set_state(
self.config,
self.agent_loop.skill_manager,
self.agent_loop.mcp_registry,
connectors_count=_compute_connectors_count(
self.config, self.agent_loop.connector_registry
),
connectors_enabled=ce,
connectors_total=ct,
plan_description=plan_title(self._plan_info),
)

View file

@ -1,12 +1,6 @@
$mistral_orange: #FF8205;
* {
scrollbar-color: ansi_black;
scrollbar-color-hover: ansi_bright_black;
scrollbar-color-active: ansi_bright_black;
scrollbar-background: transparent;
scrollbar-background-hover: transparent;
scrollbar-background-active: transparent;
scrollbar-size: 1 1;
}
@ -20,7 +14,7 @@ Horizontal {
}
TextArea > .text-area--cursor {
color: ansi_default;
color: $foreground;
}
#chat {
@ -54,6 +48,55 @@ TextArea > .text-area--cursor {
background: transparent;
}
#queued-messages {
height: auto;
width: 100%;
background: transparent;
padding: 0 0 1 0;
}
#queued-messages-box {
height: auto;
width: 100%;
border: round $surface-lighten-2;
border-title-color: $text-muted;
border-title-style: bold;
padding: 0 1;
}
#queued-messages-list {
height: auto;
max-height: 8;
width: 100%;
padding: 0;
background: transparent;
}
#queued-messages-hint {
width: 100%;
height: auto;
color: $text-muted;
padding: 0;
}
.queued-message-item {
width: 100%;
height: auto;
padding: 0;
color: $text-muted;
text-style: italic;
}
.queued-message-bash {
color: $accent;
}
.queued-message-content {
width: 100%;
height: auto;
padding: 0;
}
#bottom-bar {
height: auto;
width: 100%;
@ -89,14 +132,19 @@ TextArea > .text-area--cursor {
display: none;
width: auto;
height: auto;
color: ansi_default;
background: $surface;
border: solid ansi_bright_black;
color: $foreground;
background: $background;
border: solid $foreground-muted;
overflow-y: auto;
scrollbar-size-vertical: 1;
/* max-height, max-width and padding set in completion_popup.py */
}
OptionList, OptionList:focus {
background: $background;
background-tint: transparent;
}
#completion-popup _CompletionItem {
height: auto;
width: 1fr;
@ -106,24 +154,24 @@ TextArea > .text-area--cursor {
height: auto;
width: 100%;
background: transparent;
border: solid ansi_bright_black;
border: solid $foreground-muted;
border-title-align: right;
border-title-color: ansi_bright_black;
border-title-color: $text-muted;
padding: 0 1;
&.border-warning {
border: solid ansi_yellow;
border-title-color: ansi_yellow;
border: solid $warning;
border-title-color: $warning;
}
&.border-safe {
border: solid ansi_green;
border-title-color: ansi_green;
border: solid $success;
border-title-color: $success;
}
&.border-error {
border: solid ansi_red;
border-title-color: ansi_red;
border: solid $error;
border-title-color: $error;
}
&.border-recording {
@ -157,13 +205,17 @@ TextArea > .text-area--cursor {
min-height: 3;
max-height: 50vh;
background: transparent;
color: ansi_default;
color: $foreground;
border: none;
padding: 0;
scrollbar-visibility: hidden;
&.recording {
color: ansi_bright_black;
color: $text-muted;
&:ansi {
text-style: dim;
}
}
}
@ -174,10 +226,10 @@ ToastRack {
}
Markdown {
color: ansi_default;
color: $foreground;
.code_inline {
color: ansi_green;
color: $success;
background: transparent;
text-style: bold;
}
@ -199,13 +251,13 @@ Markdown {
MarkdownBlockQuote {
background: transparent;
border-left: outer ansi_bright_black;
border-left: outer $foreground-muted;
}
MarkdownBullet,
MarkdownBulletList,
MarkdownOrderedList {
color: ansi_default;
color: $foreground;
}
}
@ -240,7 +292,6 @@ Markdown {
text-style: bold;
padding-left: 1;
border-left: heavy $mistral_orange;
color: $mistral_orange;
}
.assistant-message {
@ -277,11 +328,15 @@ Markdown {
.reasoning-indicator {
width: auto;
height: auto;
color: ansi_bright_black;
color: $text-muted;
margin-right: 1;
&.success {
color: ansi_green;
color: $success;
}
&:ansi {
text-style: dim;
}
}
@ -289,8 +344,12 @@ Markdown {
.reasoning-triangle {
width: auto;
height: auto;
color: ansi_bright_black;
color: $text-muted;
text-style: italic;
&:ansi {
text-style: dim;
}
}
.reasoning-triangle {
@ -303,12 +362,20 @@ Markdown {
height: auto;
padding: 0 0 0 2;
margin: 0;
color: ansi_bright_black;
color: $text-muted;
text-style: italic;
&:ansi {
text-style: dim;
}
* {
color: ansi_bright_black;
color: $text-muted;
text-style: italic;
&:ansi {
text-style: dim;
}
}
& > MarkdownBlock:last-child {
@ -335,7 +402,7 @@ Markdown {
.plan-file-wrapper {
height: auto;
width: 100%;
border: solid ansi_bright_black;
border: solid $foreground-muted;
padding: 0 1;
}
@ -372,21 +439,20 @@ Markdown {
width: auto;
height: 100%;
padding: 0 1 0 2;
color: ansi_bright_black;
}
.interrupt-content {
width: 1fr;
height: auto;
margin: 0;
color: ansi_yellow;
color: $warning;
}
.error-content {
width: 1fr;
height: auto;
margin: 0;
color: ansi_red;
color: $error;
text-style: bold;
}
@ -394,7 +460,7 @@ Markdown {
width: 1fr;
height: auto;
margin: 0;
color: ansi_yellow;
color: $warning;
}
.user-command-content {
@ -441,15 +507,15 @@ Markdown {
}
&.bash-success {
color: ansi_green;
color: $success;
}
&.bash-error {
color: ansi_red;
color: $error;
}
&.bash-interrupted {
color: ansi_yellow;
color: $warning;
}
}
@ -468,7 +534,11 @@ Markdown {
width: auto;
height: 100%;
padding: 0 1 0 2;
color: ansi_bright_black;
color: $text-muted;
&:ansi {
text-style: dim;
}
}
.bash-output {
@ -478,7 +548,11 @@ Markdown {
.unknown-event {
height: auto;
color: ansi_bright_black;
color: $text-muted;
&:ansi {
text-style: dim;
}
}
StatusMessage {
@ -494,22 +568,22 @@ StatusMessage {
.status-indicator-icon {
width: auto;
height: auto;
color: ansi_default;
color: $foreground;
margin-right: 1;
&.success {
color: ansi_green;
color: $success;
}
&.error {
color: ansi_red;
color: $error;
}
}
.status-indicator-text {
width: 1fr;
height: auto;
color: ansi_default;
color: $foreground;
}
.compact-message,
@ -531,8 +605,12 @@ StatusMessage {
.tool-stream-message {
width: 100%;
height: auto;
color: ansi_bright_black;
color: $text-muted;
padding-left: 2;
&:ansi {
text-style: dim;
}
}
.tool-result {
@ -542,14 +620,14 @@ StatusMessage {
margin-left: 0;
padding: 0;
background: transparent;
color: ansi_default;
color: $foreground;
&.error-text {
color: ansi_red;
color: $error;
}
&.warning-text {
color: ansi_yellow;
color: $warning;
}
}
@ -564,7 +642,11 @@ StatusMessage {
width: auto;
height: 100%;
padding: 0 1 0 2;
color: ansi_bright_black;
color: $text-muted;
&:ansi {
text-style: dim;
}
}
.tool-result-content {
@ -577,7 +659,7 @@ StatusMessage {
.tool-result-widget {
width: 100%;
height: auto;
color: ansi_default;
color: $foreground;
}
.tool-result-widget Static {
@ -605,7 +687,11 @@ StatusMessage {
.tool-call-detail,
.tool-result-detail {
height: auto;
color: ansi_bright_black;
color: $text-muted;
&:ansi {
text-style: dim;
}
}
.tool-result-detail {
@ -613,64 +699,76 @@ StatusMessage {
}
.tool-result-error {
color: ansi_red;
color: $error;
}
.tool-result-warning {
color: ansi_yellow;
color: $warning;
}
.diff-header {
height: auto;
color: ansi_bright_black;
color: $text-muted;
text-style: bold;
&:ansi {
text-style: dim;
}
}
.diff-removed {
height: auto;
color: ansi_red;
color: $error;
}
.diff-added {
height: auto;
color: ansi_green;
color: $success;
}
.diff-range {
height: auto;
color: ansi_blue;
color: $primary;
}
.diff-context {
height: auto;
color: ansi_bright_black;
color: $text-muted;
&:ansi {
text-style: dim;
}
}
.todo-empty,
.todo-cancelled {
height: auto;
color: ansi_bright_black;
color: $text-muted;
&:ansi {
text-style: dim;
}
}
.todo-pending {
height: auto;
color: ansi_default;
color: $foreground;
}
.todo-in_progress {
height: auto;
color: ansi_yellow;
color: $warning;
}
.todo-completed {
height: auto;
color: ansi_green;
color: $success;
}
.loading-widget {
width: 100%;
height: auto;
color: ansi_bright_black;
color: $text-muted;
}
.loading-container,
@ -698,7 +796,11 @@ StatusMessage {
.loading-hint {
width: auto;
height: auto;
color: ansi_bright_black;
color: $text-muted;
&:ansi {
text-style: dim;
}
}
.loading-debounce {
@ -724,7 +826,7 @@ StatusMessage {
.history-load-more-button {
background: transparent;
border: none;
color: ansi_yellow;
color: $warning;
text-style: bold;
pointer: pointer;
padding: 1;
@ -738,7 +840,7 @@ StatusMessage {
width: 100%;
height: auto;
background: transparent;
border: solid ansi_bright_black;
border: solid $foreground-muted;
padding: 0 1;
margin: 0;
}
@ -751,7 +853,7 @@ StatusMessage {
.settings-title {
height: auto;
text-style: bold;
color: ansi_blue;
color: $primary;
}
#config-options, #mcp-options, #connectorauth-options {
@ -770,14 +872,18 @@ StatusMessage {
.settings-help {
height: auto;
color: ansi_bright_black;
color: $text-muted;
&:ansi {
text-style: dim;
}
}
#proxysetup-app {
width: 100%;
height: auto;
background: transparent;
border: solid ansi_bright_black;
border: solid $foreground-muted;
padding: 0 1;
margin: 0;
}
@ -795,7 +901,7 @@ StatusMessage {
width: 100%;
height: auto;
border: none;
border-left: wide ansi_bright_black;
border-left: wide $foreground-muted;
padding: 0 0 0 1;
}
@ -804,7 +910,7 @@ StatusMessage {
height: auto;
max-height: 70vh;
background: transparent;
border: solid ansi_bright_black;
border: solid $foreground-muted;
padding: 0 1;
margin: 0;
}
@ -829,7 +935,7 @@ StatusMessage {
.approval-title {
height: auto;
text-style: bold;
color: ansi_bright_yellow;
color: $warning;
}
.approval-tool-info-container {
@ -850,7 +956,7 @@ StatusMessage {
.approval-option {
height: auto;
color: ansi_default;
color: $foreground;
margin-bottom: 1;
&:last-of-type {
@ -860,39 +966,47 @@ StatusMessage {
.approval-cursor-selected {
&.approval-option-yes {
color: ansi_green;
color: $success;
text-style: bold;
}
&.approval-option-no {
color: ansi_bright_red;
color: $error;
text-style: bold;
}
}
.approval-option-selected {
&.approval-option-yes {
color: ansi_white;
color: $foreground;
}
&.approval-option-no {
color: ansi_red;
color: $error;
}
}
.approval-help {
height: auto;
color: ansi_bright_black;
color: $text-muted;
&:ansi {
text-style: dim;
}
}
.approval-description {
height: auto;
color: ansi_bright_black;
color: $text-muted;
&:ansi {
text-style: dim;
}
}
.code-block {
height: auto;
color: ansi_default;
color: $foreground;
background: transparent;
padding: 1;
}
@ -902,7 +1016,7 @@ StatusMessage {
height: auto;
max-height: 70vh;
background: transparent;
border: solid ansi_bright_black;
border: solid $foreground-muted;
padding: 0 1;
margin: 0;
}
@ -914,24 +1028,24 @@ StatusMessage {
.question-tabs {
height: auto;
color: ansi_blue;
color: $primary;
margin-bottom: 1;
}
.question-title {
height: auto;
text-style: bold;
color: ansi_blue;
color: $primary;
margin-bottom: 1;
}
.question-option {
height: auto;
color: ansi_default;
color: $foreground;
}
.question-option-selected {
color: ansi_blue;
color: $primary;
text-style: bold;
}
@ -943,7 +1057,7 @@ StatusMessage {
.question-other-prefix {
width: auto;
height: auto;
color: ansi_default;
color: $foreground;
}
.question-other-input {
@ -957,26 +1071,38 @@ StatusMessage {
.question-other-static {
width: auto;
height: auto;
color: ansi_bright_black;
color: $text-muted;
&:ansi {
text-style: dim;
}
}
.question-submit {
height: auto;
color: ansi_default;
color: $foreground;
margin-top: 1;
}
.question-footer-note {
height: auto;
color: ansi_bright_black;
color: $text-muted;
text-style: italic;
margin-top: 1;
&:ansi {
text-style: dim;
}
}
.question-help {
height: auto;
color: ansi_bright_black;
color: $text-muted;
margin-top: 1;
&:ansi {
text-style: dim;
}
}
ExpandingBorder {
@ -992,7 +1118,11 @@ ContextProgress {
background: transparent;
padding: 0;
margin: 0;
color: ansi_bright_black;
color: $text-muted;
&:ansi {
text-style: dim;
}
}
NarratorStatus {
@ -1037,12 +1167,12 @@ NarratorStatus {
}
.banner-meta {
color: ansi_default;
color: $foreground;
width: auto;
}
#banner-model {
color: ansi_cyan;
color: $secondary;
width: auto;
}
@ -1055,12 +1185,12 @@ NarratorStatus {
}
.banner-cmd {
color: ansi_cyan;
color: $secondary;
width: auto;
}
.petit-chat {
color: ansi_default;
color: $foreground;
width: 12;
}
@ -1105,7 +1235,7 @@ NarratorStatus {
width: 100%;
height: auto;
background: transparent;
border: solid ansi_bright_black;
border: solid $foreground-muted;
padding: 0 1;
margin: 0;
}
@ -1130,8 +1260,12 @@ NarratorStatus {
.sessionpicker-help {
width: 100%;
height: auto;
color: ansi_bright_black;
color: $text-muted;
margin-top: 1;
&:ansi {
text-style: dim;
}
}
/* Debug Console */
@ -1143,14 +1277,14 @@ NarratorStatus {
min-width: 40;
height: 100%;
background: transparent;
border-left: solid ansi_bright_black;
border-left: solid $foreground-muted;
padding: 0 1;
}
#debug-console-header {
width: 100%;
height: auto;
color: ansi_default;
color: $foreground;
padding: 0 0 1 0;
text-align: left;
}
@ -1167,7 +1301,7 @@ NarratorStatus {
width: 100%;
height: auto;
background: transparent;
border: solid ansi_bright_black;
border: solid $foreground-muted;
padding: 0 1;
margin: 0;
}
@ -1180,7 +1314,7 @@ NarratorStatus {
.modelpicker-title {
height: auto;
text-style: bold;
color: ansi_blue;
color: $primary;
}
#modelpicker-options {
@ -1198,15 +1332,19 @@ NarratorStatus {
.modelpicker-help {
width: 100%;
height: auto;
color: ansi_bright_black;
color: $text-muted;
margin-top: 1;
&:ansi {
text-style: dim;
}
}
#thinkingpicker-app {
width: 100%;
height: auto;
background: transparent;
border: solid ansi_bright_black;
border: solid $foreground-muted;
padding: 0 1;
margin: 0;
}
@ -1219,7 +1357,7 @@ NarratorStatus {
.thinkingpicker-title {
height: auto;
text-style: bold;
color: ansi_blue;
color: $primary;
}
#thinkingpicker-options {
@ -1237,8 +1375,55 @@ NarratorStatus {
.thinkingpicker-help {
width: 100%;
height: auto;
color: ansi_bright_black;
color: $text-muted;
margin-top: 1;
&:ansi {
text-style: dim;
}
}
#themepicker-app {
width: 100%;
height: auto;
background: transparent;
border: solid $foreground-muted;
padding: 0 1;
margin: 0;
}
#themepicker-content {
width: 100%;
height: auto;
}
.themepicker-title {
height: auto;
text-style: bold;
color: $primary;
}
#themepicker-options {
width: 100%;
max-height: 50vh;
text-wrap: nowrap;
text-overflow: ellipsis;
border: none;
}
#themepicker-options:focus {
border: none;
}
.themepicker-help {
width: 100%;
height: auto;
color: $text-muted;
margin-top: 1;
&:ansi {
text-style: dim;
}
}
FeedbackBar {
@ -1257,7 +1442,7 @@ FeedbackBar {
width: 100%;
height: auto;
background: transparent;
border: solid ansi_bright_black;
border: solid $foreground-muted;
padding: 0 1;
margin: 0;
}
@ -1270,32 +1455,36 @@ FeedbackBar {
.rewind-title {
height: auto;
text-style: bold;
color: ansi_blue;
color: $primary;
}
.rewind-option {
height: auto;
color: ansi_default;
color: $foreground;
}
.rewind-cursor-selected {
color: ansi_blue;
color: $primary;
text-style: bold;
}
.rewind-option-unselected {
color: ansi_default;
color: $foreground;
}
.rewind-help {
height: auto;
color: ansi_bright_black;
color: $text-muted;
&:ansi {
text-style: dim;
}
}
#feedback-text {
width: auto;
height: auto;
color: ansi_default;
color: $foreground;
}
.hook-system-message {
@ -1333,17 +1522,21 @@ FeedbackBar {
height: auto;
max-height: 3;
overflow: hidden;
color: ansi_bright_black;
color: $text-muted;
&:ansi {
text-style: dim;
}
}
.hook-severity-ok .hook-system-icon {
color: ansi_green;
color: $success;
}
.hook-severity-warning .hook-system-icon {
color: ansi_yellow;
color: $warning;
}
.hook-severity-error .hook-system-icon {
color: ansi_red;
color: $error;
}

View file

@ -0,0 +1,85 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import StrEnum, auto
class QueuedItemKind(StrEnum):
PROMPT = auto()
BASH = auto()
@dataclass(frozen=True, slots=True)
class QueuedItem:
kind: QueuedItemKind
content: str
@dataclass(slots=True)
class MessageQueue:
_items: list[QueuedItem] = field(default_factory=list)
_paused: bool = False
_on_change: Callable[[], None] | None = None
def set_change_listener(self, listener: Callable[[], None] | None) -> None:
self._on_change = listener
def __len__(self) -> int:
return len(self._items)
def __bool__(self) -> bool:
return bool(self._items)
@property
def items(self) -> list[QueuedItem]:
return list(self._items)
@property
def paused(self) -> bool:
return self._paused
def append_prompt(self, content: str) -> None:
self._items.append(QueuedItem(QueuedItemKind.PROMPT, content))
self._notify()
def append_bash(self, content: str) -> None:
self._items.append(QueuedItem(QueuedItemKind.BASH, content))
self._notify()
def pop_last(self) -> QueuedItem | None:
if not self._items:
return None
item = self._items.pop()
self._notify()
return item
def pop_first(self) -> QueuedItem | None:
if not self._items:
return None
item = self._items.pop(0)
self._notify()
return item
def pause(self) -> None:
if self._paused:
return
self._paused = True
self._notify()
def resume(self) -> None:
if not self._paused:
return
self._paused = False
self._notify()
def clear(self) -> None:
if not self._items and not self._paused:
return
self._items.clear()
self._paused = False
self._notify()
def _notify(self) -> None:
if self._on_change is not None:
self._on_change()

View file

@ -31,15 +31,18 @@ class QuitManager:
and (time.monotonic() - self._confirm_time) < QUIT_CONFIRM_DELAY
)
def request_confirmation(self, key: QuitConfirmKey) -> None:
def request_confirmation(self, key: QuitConfirmKey, extra: str = "") -> None:
if self._confirm_timer is not None:
self._confirm_timer.stop()
self._confirm_timer = None
self._confirm_time = time.monotonic()
self._confirm_key = key
prompt = f"Press {key} again to quit"
if extra:
prompt = f"{prompt} ({extra})"
try:
path_display = self._app.query_one(PathDisplay)
path_display.update(f"Press {key} again to quit")
path_display.update(prompt)
except Exception:
pass
self._confirm_timer = self._app.set_timer(

View file

@ -13,7 +13,6 @@ from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.config import VibeConfig
from vibe.core.skills.manager import SkillManager
from vibe.core.tools.mcp.registry import MCPRegistry
def _pluralize(count: int, singular: str) -> str:
@ -24,8 +23,10 @@ def _pluralize(count: int, singular: str) -> str:
class BannerState:
active_model: str = ""
models_count: int = 0
mcp_servers_count: int = 0
connectors_count: int = 0
mcp_servers_enabled: int = 0
mcp_servers_total: int = 0
connectors_enabled: int = 0
connectors_total: int = 0
skills_count: int = 0
plan_description: str | None = None
@ -37,8 +38,8 @@ class Banner(Static):
self,
config: VibeConfig,
skill_manager: SkillManager,
mcp_registry: MCPRegistry,
connectors_count: int = 0,
connectors_enabled: int = 0,
connectors_total: int = 0,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
@ -46,8 +47,8 @@ class Banner(Static):
self._initial_state = self._build_state(
config=config,
skill_manager=skill_manager,
mcp_registry=mcp_registry,
connectors_count=connectors_count,
connectors_enabled=connectors_enabled,
connectors_total=connectors_total,
plan_description=None,
)
self._animated = not config.disable_welcome_banner_animation
@ -90,40 +91,62 @@ class Banner(Static):
self,
config: VibeConfig,
skill_manager: SkillManager,
mcp_registry: MCPRegistry,
connectors_count: int = 0,
connectors_enabled: int = 0,
connectors_total: int = 0,
plan_description: str | None = None,
) -> None:
self.state = self._build_state(
config, skill_manager, mcp_registry, connectors_count, plan_description
config,
skill_manager,
connectors_enabled,
connectors_total,
plan_description,
)
@staticmethod
def _build_state(
config: VibeConfig,
skill_manager: SkillManager,
mcp_registry: MCPRegistry,
connectors_count: int = 0,
connectors_enabled: int = 0,
connectors_total: int = 0,
plan_description: str | None = None,
) -> BannerState:
enabled_servers = [s for s in config.mcp_servers if not s.disabled]
mcp_count = mcp_registry.count_loaded(enabled_servers)
all_servers = config.mcp_servers
enabled_servers = [s for s in all_servers if not s.disabled]
active_model = config.get_active_model()
return BannerState(
active_model=f"{active_model.alias}[{active_model.thinking}]",
models_count=len(config.models),
mcp_servers_count=mcp_count,
connectors_count=connectors_count,
mcp_servers_enabled=len(enabled_servers),
mcp_servers_total=len(all_servers),
connectors_enabled=connectors_enabled,
connectors_total=connectors_total,
skills_count=skill_manager.custom_skills_count,
plan_description=plan_description,
)
def _format_meta_counts(self) -> str:
parts = [_pluralize(self.state.models_count, "model")]
if self.state.connectors_count > 0:
parts.append(_pluralize(self.state.connectors_count, "connector"))
parts.append(_pluralize(self.state.mcp_servers_count, "MCP server"))
# Format as x/y for MCP servers and connectors (only when enabled != total)
if self.state.connectors_total > 0:
if self.state.connectors_enabled != self.state.connectors_total:
connector_str = (
f"{self.state.connectors_enabled}/{self.state.connectors_total} connector"
+ ("s" if self.state.connectors_total != 1 else "")
)
else:
connector_str = _pluralize(self.state.connectors_enabled, "connector")
parts.append(connector_str)
# Always show MCP servers count (even if 0/0)
if self.state.mcp_servers_enabled != self.state.mcp_servers_total:
mcp_str = (
f"{self.state.mcp_servers_enabled}/{self.state.mcp_servers_total} MCP server"
+ ("s" if self.state.mcp_servers_total != 1 else "")
)
else:
mcp_str = _pluralize(self.state.mcp_servers_enabled, "MCP server")
parts.append(mcp_str)
parts.append(_pluralize(self.state.skills_count, "skill"))
return " · ".join(parts)

View file

@ -163,7 +163,9 @@ TRANSITIONS = [
class PetitChat(Static):
def __init__(self, animate: bool = True, **kwargs: Any) -> None:
super().__init__(**kwargs, classes="banner-chat")
classes = kwargs.pop("classes", None)
merged_classes = "banner-chat" if classes is None else f"banner-chat {classes}"
super().__init__(**kwargs, classes=merged_classes)
self._dots = {1j * y + x for y, row in enumerate(STARTING_DOTS) for x in row}
self._transition_index = 0
self._do_animate = animate

View file

@ -1,49 +1,23 @@
from __future__ import annotations
import random
import time
from vibe.cli.cache import read_cache, write_cache
from vibe.core.agent_loop import AgentLoop
from vibe.core.paths import CACHE_FILE
from vibe.core.feedback import record_feedback_asked, should_show_feedback
from vibe.core.types import Role
FEEDBACK_PROBABILITY = 0.2
FEEDBACK_COOLDOWN_SECONDS = 3600
_CACHE_SECTION = "user_feedback"
_LAST_SHOWN_KEY = "last_shown_at"
MIN_USER_MESSAGES_FOR_FEEDBACK = 3
class FeedbackBarManager:
"""Decides whether to show the feedback bar and records when feedback is given."""
def should_show(self, agent_loop: AgentLoop) -> bool:
if not agent_loop.telemetry_client.is_active():
return False
if not agent_loop.config.is_active_model_mistral():
return False
if (
user_message_count = (
sum(m.role == Role.user and not m.injected for m in agent_loop.messages)
+ 1 # +1 for the message the user just sent
< MIN_USER_MESSAGES_FOR_FEEDBACK
):
return False
last_ts = (
read_cache(CACHE_FILE.path).get(_CACHE_SECTION, {}).get(_LAST_SHOWN_KEY, 0)
)
if not isinstance(last_ts, int):
return False
return (
time.time() - last_ts >= FEEDBACK_COOLDOWN_SECONDS
and random.random() <= FEEDBACK_PROBABILITY
return should_show_feedback(
telemetry_active=agent_loop.telemetry_client.is_active(),
is_mistral_model=agent_loop.config.is_active_model_mistral(),
user_message_count=user_message_count,
)
def record_feedback_asked(self) -> None:
write_cache(
CACHE_FILE.path, _CACHE_SECTION, {_LAST_SHOWN_KEY: int(time.time())}
)
record_feedback_asked()

View file

@ -16,7 +16,7 @@ from textual.worker import Worker
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.config import ConnectorConfig
from vibe.core.tools.connectors import ConnectorRegistry
from vibe.core.tools.connectors import ConnectorAuthAction, ConnectorRegistry
from vibe.core.tools.mcp.tools import MCPTool
from vibe.core.tools.mcp_settings import updated_tool_list
@ -66,7 +66,7 @@ _LIST_VIEW_HELP_TOOLS = (
"↑↓ Navigate Enter Show tools D Disable E Enable R Refresh Esc Close"
)
_LIST_VIEW_HELP_AUTH = (
"↑↓ Navigate Enter Authenticate D Disable E Enable R Refresh Esc Close"
"↑↓ Navigate Enter Connect D Disable E Enable R Refresh Esc Close"
)
_DETAIL_VIEW_HELP = (
"↑↓ Navigate D Disable E Enable Backspace Back R Refresh Esc Close"
@ -246,7 +246,11 @@ class MCPApp(Container):
option_id = option.id or ""
if option_id.startswith("connector:") and self._connector_registry:
name = option_id.removeprefix("connector:")
if not self._connector_registry.is_connected(name):
if (
not self._connector_registry.is_connected(name)
and self._connector_registry.get_auth_action(name)
== ConnectorAuthAction.OAUTH
):
return _LIST_VIEW_HELP_AUTH
return _LIST_VIEW_HELP_TOOLS
@ -461,9 +465,7 @@ class MCPApp(Container):
label.append(f" {type_tag:<{max_type}}", style="dim")
label.append(f" {_tool_count_text(enabled, total)}", style="dim")
if srv.disabled:
label.append(" ")
label.append("", style="dim")
label.append(" disabled", style="dim")
_append_status(label, "", "dim", "disabled")
option_list.add_option(Option(label, id=f"server:{srv.name}"))
def _list_connectors(self, option_list: OptionList, index: MCPToolIndex) -> None:
@ -485,7 +487,7 @@ class MCPApp(Container):
)
for cname in ordered_connector_names:
cfg = self._find_connector_config(cname)
is_disabled = cfg.disabled if cfg else False
is_disabled = cfg.disabled if cfg else True
connected = (
self._connector_registry.is_connected(cname)
if self._connector_registry
@ -495,18 +497,24 @@ class MCPApp(Container):
label.append(f" {cname:<{max_name}}")
label.append(f" {type_tag:<{type_width}}", style="dim")
label.append(f" {tool_texts[cname]:<{max_tools}}", style="dim")
auth_action = (
self._connector_registry.get_auth_action(cname)
if self._connector_registry
else ConnectorAuthAction.NONE
)
if is_disabled:
label.append(" ")
label.append("", style="dim")
label.append(" disabled", style="dim")
_append_status(label, "", "dim", "disabled")
elif connected:
label.append(" ")
label.append("", style="green")
label.append(" connected", style="dim")
_append_status(label, "", "green", "connected")
else:
label.append(" ")
label.append("", style="dim")
label.append(" not connected", style="dim")
match auth_action:
case ConnectorAuthAction.OAUTH:
text = "needs auth"
case ConnectorAuthAction.CREDENTIALS_SETUP:
text = "needs setup"
case _:
text = "error - try refreshing"
_append_status(label, "", "dim", text)
option_list.add_option(Option(label, id=f"connector:{cname}"))
# ── detail view ──────────────────────────────────────────────────
@ -537,13 +545,31 @@ class MCPApp(Container):
and self._connector_registry
and not self._connector_registry.is_connected(server_name)
):
self.post_message(
self.ConnectorAuthRequested(
connector_name=server_name,
connector_registry=self._connector_registry,
tool_manager=self._tool_manager,
)
)
auth_action = self._connector_registry.get_auth_action(server_name)
match auth_action:
case ConnectorAuthAction.CREDENTIALS_SETUP:
option_list.add_option(
Option(
"Set up credentials in the Mistral dashboard, "
"then press R to refresh.",
disabled=True,
)
)
case ConnectorAuthAction.OAUTH:
self.post_message(
self.ConnectorAuthRequested(
connector_name=server_name,
connector_registry=self._connector_registry,
tool_manager=self._tool_manager,
)
)
case _:
option_list.add_option(
Option(
"Connector unavailable; press R to refresh.",
disabled=True,
)
)
else:
option_list.add_option(
Option("No tools discovered for this server", disabled=True)
@ -569,6 +595,12 @@ class MCPApp(Container):
option_list.highlighted = 0
def _append_status(label: Text, symbol: str, symbol_style: str, text: str) -> None:
label.append(" ")
label.append(symbol, style=symbol_style)
label.append(f" {text}", style="dim")
def _tool_count_text(enabled: int, total: int | None = None) -> str:
if total is not None and enabled < total:
noun = "tool" if total == 1 else "tools"

View file

@ -17,11 +17,10 @@ from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches
from textual.reactive import reactive
from textual.widget import Widget
from textual.widgets import Static
from textual.widgets import Markdown, Static
from textual.widgets._markdown import MarkdownStream
from watchfiles import awatch
from vibe.cli.textual_ui.ansi_markdown import AnsiMarkdown as Markdown
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType

View file

@ -46,7 +46,7 @@ class ProxySetupApp(Container):
yield NoMarkupStatic("Proxy Configuration", classes="settings-title")
for key, description in SUPPORTED_PROXY_VARS.items():
yield Static(f"[bold ansi_blue]{key}[/]", classes="proxy-label-line")
yield Static(f"[bold $primary]{key}[/]", classes="proxy-label-line")
initial_value = self.initial_values.get(key) or ""
input_widget = VscodeCompatInput(

View file

@ -0,0 +1,97 @@
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Vertical, VerticalScroll
from textual.widget import Widget
from textual.widgets import Static
from vibe.cli.textual_ui.message_queue import MessageQueue, QueuedItem, QueuedItemKind
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
class QueuedMessageItem(Static):
def __init__(self, item: QueuedItem) -> None:
super().__init__()
self.add_class("queued-message-item")
if item.kind == QueuedItemKind.BASH:
self.add_class("queued-message-bash")
self._item = item
@property
def item(self) -> QueuedItem:
return self._item
def compose(self) -> ComposeResult:
prefix = "$ " if self._item.kind == QueuedItemKind.BASH else ""
yield NoMarkupStatic(
f"{prefix}{self._item.content}", classes="queued-message-content"
)
class QueuedMessages(Widget):
DEFAULT_CSS = ""
ID = "queued-messages"
ID_LIST = "queued-messages-list"
ID_HINT = "queued-messages-hint"
ID_BOX = "queued-messages-box"
def __init__(self) -> None:
super().__init__(id=self.ID)
self._queue: MessageQueue | None = None
self._is_job_running: bool = False
def bind(self, queue: MessageQueue) -> None:
self._queue = queue
self._queue.set_change_listener(self._on_queue_changed)
def compose(self) -> ComposeResult:
with Vertical(id=self.ID_BOX) as box:
box.border_title = "Queue"
yield VerticalScroll(id=self.ID_LIST)
yield NoMarkupStatic("", id=self.ID_HINT)
def on_mount(self) -> None:
self._refresh()
def set_job_running(self, running: bool) -> None:
if self._is_job_running == running:
return
self._is_job_running = running
self._refresh()
def _on_queue_changed(self) -> None:
if self.is_mounted:
self._refresh()
def _refresh(self) -> None:
queue = self._queue
if queue is None or len(queue) == 0:
self.display = False
return
self.display = True
try:
box = self.query_one(f"#{self.ID_BOX}", Vertical)
list_widget = self.query_one(f"#{self.ID_LIST}", VerticalScroll)
hint = self.query_one(f"#{self.ID_HINT}", NoMarkupStatic)
except Exception:
return
title = f"Queue ({len(queue)})"
if queue.paused:
title = f"{title} — paused"
box.border_title = title
existing = list(list_widget.query(QueuedMessageItem))
for widget in existing:
widget.remove()
for item in queue.items:
list_widget.mount(QueuedMessageItem(item))
if queue.paused:
hint.update("Enter send queue • Ctrl+C drop last queued")
elif self._is_job_running:
hint.update("Esc cancel job + pause • Ctrl+C drop last queued")
else:
hint.update("")

View file

@ -0,0 +1,114 @@
from __future__ import annotations
from typing import Any, ClassVar
from rich.text import Text
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container, Vertical
from textual.message import Message
from textual.theme import BUILTIN_THEMES
from textual.timer import Timer
from textual.widgets import OptionList
from textual.widgets.option_list import Option
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
PREVIEW_DEBOUNCE_SECONDS = 0.1
def sorted_theme_names() -> list[str]:
light = sorted(name for name, t in BUILTIN_THEMES.items() if not t.dark)
dark = sorted(name for name, t in BUILTIN_THEMES.items() if t.dark)
return light + dark
def _build_option_text(theme: str, is_current: bool) -> Text:
text = Text(no_wrap=True)
marker = " " if is_current else " "
text.append(marker, style="green" if is_current else "")
text.append(theme, style="bold" if is_current else "")
return text
class ThemePickerApp(Container):
can_focus_children = True
BINDINGS: ClassVar[list[BindingType]] = [
Binding("escape", "cancel", "Cancel", show=False)
]
class ThemeSelected(Message):
def __init__(self, theme: str) -> None:
self.theme = theme
super().__init__()
class ThemePreviewed(Message):
def __init__(self, theme: str) -> None:
self.theme = theme
super().__init__()
class Cancelled(Message):
def __init__(self, original_theme: str) -> None:
self.original_theme = original_theme
super().__init__()
def __init__(
self, theme_names: list[str], current_theme: str, **kwargs: Any
) -> None:
super().__init__(id="themepicker-app", **kwargs)
self._theme_names = theme_names
self._current_theme = current_theme
self._preview_timer: Timer | None = None
self._pending_preview: str | None = None
def compose(self) -> ComposeResult:
options = [
Option(_build_option_text(name, name == self._current_theme), id=name)
for name in self._theme_names
]
with Vertical(id="themepicker-content"):
yield NoMarkupStatic("Select Theme", classes="themepicker-title")
yield OptionList(*options, id="themepicker-options")
yield NoMarkupStatic(
"↑↓ Preview Enter Select Esc Cancel", classes="themepicker-help"
)
def on_mount(self) -> None:
option_list = self.query_one(OptionList)
for i, name in enumerate(self._theme_names):
if name == self._current_theme:
option_list.highlighted = i
break
option_list.focus()
def on_option_list_option_highlighted(
self, event: OptionList.OptionHighlighted
) -> None:
if not event.option.id:
return
self._pending_preview = event.option.id
if self._preview_timer is not None:
self._preview_timer.stop()
self._preview_timer = self.set_timer(
PREVIEW_DEBOUNCE_SECONDS, self._flush_preview
)
def _flush_preview(self) -> None:
if self._pending_preview is None:
return
self.post_message(self.ThemePreviewed(self._pending_preview))
self._pending_preview = None
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
if self._preview_timer is not None:
self._preview_timer.stop()
self._pending_preview = None
if event.option.id:
self.post_message(self.ThemeSelected(event.option.id))
def action_cancel(self) -> None:
if self._preview_timer is not None:
self._preview_timer.stop()
self._pending_preview = None
self.post_message(self.Cancelled(self._current_theme))

View file

@ -6,9 +6,8 @@ from pathlib import Path
from pydantic import BaseModel
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.widgets import Static
from textual.widgets import Markdown, Static
from vibe.cli.textual_ui.ansi_markdown import AnsiMarkdown as Markdown
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.tools.builtins.ask_user_question import AskUserQuestionResult
from vibe.core.tools.builtins.bash import BashArgs, BashResult