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

@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path
VIBE_ROOT = Path(__file__).parent
__version__ = "2.10.1"
__version__ = "2.11.0"

View file

@ -123,6 +123,7 @@ from vibe.core.config import (
load_dotenv_values,
)
from vibe.core.data_retention import DATA_RETENTION_MESSAGE
from vibe.core.feedback import record_feedback_asked, should_show_feedback
from vibe.core.hooks.config import load_hooks_from_fs
from vibe.core.proxy_setup import (
ProxySetupError,
@ -464,9 +465,11 @@ class VibeAcpAgentLoop(AcpAgent):
field_meta={
"browser-auth-delegated": {
"attemptId": attempt.process_id,
"expiresAt": attempt.expires_at.astimezone(UTC)
.isoformat()
.replace("+00:00", "Z"),
"expiresAt": (
attempt.expires_at.astimezone(UTC)
.isoformat()
.replace("+00:00", "Z")
),
"signInUrl": attempt.sign_in_url,
}
}
@ -1102,12 +1105,28 @@ class VibeAcpAgentLoop(AcpAgent):
raise InternalError(str(e)) from e
self._send_usage_update(session)
meta = self._build_end_turn_meta(session)
return PromptResponse(
stop_reason="end_turn",
usage=self._build_usage(session),
user_message_id=resolved_message_id,
field_meta=meta or None,
)
def _build_end_turn_meta(self, session: AcpSessionLoop) -> dict[str, Any] | None:
agent_loop = session.agent_loop
user_message_count = (
sum(m.role == Role.user and not m.injected for m in agent_loop.messages) + 1
) # +1 for the message just sent
if not 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,
):
return None
record_feedback_asked()
return {"show_feedback_prompt": True}
def _build_text_prompt(self, acp_prompt: list[ContentBlock]) -> str:
def _is_automatic_resource(block: ContentBlock) -> bool:
return block.type == "resource" and bool(

View file

@ -15,7 +15,7 @@ from acp.schema import (
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
from vibe.acp.tools.events import ToolTerminalOpenedEvent
from vibe.acp.tools.session_update import resolve_kind
from vibe.acp.tools.session_update import failed_tool_result, resolve_kind
from vibe.core.logger import logger
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
from vibe.core.tools.builtins.bash import Bash as CoreBashTool, BashArgs, BashResult
@ -118,9 +118,13 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
@classmethod
def tool_call_session_update(cls, event: ToolCallEvent) -> ToolCallStart | None:
if event.args is None:
# Title is left empty until args resolve so clients don't briefly
# render the tool name (e.g. "bash") as a stand-in command while
# the LLM is still streaming BashArgs. ACP requires title to be a
# str, so we use "" as the "unknown yet" sentinel.
return ToolCallStart(
session_update="tool_call",
title="bash",
title="",
tool_call_id=event.tool_call_id,
kind=resolve_kind(event.tool_name),
content=None,
@ -144,10 +148,13 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
def tool_result_session_update(
cls, event: ToolResultEvent
) -> ToolCallProgress | None:
if failure := failed_tool_result(event, BashResult):
return failure
return ToolCallProgress(
session_update="tool_call_update",
tool_call_id=event.tool_call_id,
status="failed" if event.error else "completed",
status="completed",
content=[
ContentToolCallContent(
type="content",

View file

@ -41,8 +41,11 @@ class Grep(
tool_call_id=event.tool_call_id,
kind=resolve_kind(event.tool_name),
raw_input=event.args.model_dump_json(),
locations=[ToolCallLocation(path=search_path)],
field_meta={"tool_name": event.tool_name, "query": event.args.pattern},
field_meta={
"tool_name": event.tool_name,
"query": event.args.pattern,
"search_path": search_path,
},
)
@classmethod

View file

@ -24,13 +24,11 @@ from vibe.core.tools.builtins.search_replace import (
SearchReplaceResult,
)
from vibe.core.types import ToolCallEvent, ToolResultEvent
from vibe.core.utils.io import ReadSafeResult
from vibe.core.utils.io import ReadSafeResult, normalize_newlines
class AcpSearchReplaceState(BaseToolState, AcpToolState):
file_backup_content: str | None = None
file_backup_encoding: str = "utf-8"
file_backup_newline: str = "\n"
class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
@ -54,24 +52,24 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
raise ToolError(f"Unexpected error reading {file_path}: {e}") from e
self.state.file_backup_content = response.content
self.state.file_backup_encoding = "utf-8"
self.state.file_backup_newline = "\n"
return ReadSafeResult(response.content, "utf-8", "\n")
text, newline = normalize_newlines(response.content)
return ReadSafeResult(text, "utf-8", newline)
async def _backup_file(self, file_path: Path) -> None:
if self.state.file_backup_content is None:
return
await self._write_file(
await self._write_via_client(
file_path.with_suffix(file_path.suffix + ".bak"),
self.state.file_backup_content,
self.state.file_backup_encoding,
self.state.file_backup_newline,
)
async def _write_file(
self, file_path: Path, content: str, encoding: str, newline: str
) -> None:
await self._write_via_client(file_path, content.replace("\n", newline))
async def _write_via_client(self, file_path: Path, content: str) -> None:
client, session_id = self._load_state()
try:

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import os
from pathlib import Path
from acp.helpers import SessionUpdate
@ -24,6 +25,7 @@ from vibe.core.tools.builtins.write_file import (
WriteFileResult,
)
from vibe.core.types import ToolCallEvent, ToolResultEvent
from vibe.core.utils.io import normalize_newlines
class AcpWriteFileState(BaseToolState, AcpToolState):
@ -42,10 +44,12 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]):
async def _write_file(self, args: WriteFileArgs, file_path: Path) -> None:
client, session_id = self._load_state()
normalized_text, _ = normalize_newlines(args.content)
content = normalized_text.replace("\n", os.linesep)
try:
await client.write_text_file(
session_id=session_id, path=str(file_path), content=args.content
session_id=session_id, path=str(file_path), content=content
)
except Exception as e:
raise ToolError(f"Error writing {file_path}: {e}") from e

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

View file

@ -624,8 +624,17 @@ class AgentLoop: # noqa: PLR0904
)
@requires_init
async def inject_user_context(self, content: str) -> None:
self.messages.append(LLMMessage(role=Role.user, content=content, injected=True))
async def inject_user_context(
self, content: str, *, as_message: bool = False
) -> None:
if as_message:
self.messages.append(
LLMMessage(role=Role.user, content=content, message_id=str(uuid4()))
)
else:
self.messages.append(
LLMMessage(role=Role.user, content=content, injected=True)
)
await self._save_messages()
@requires_init

View file

@ -5,6 +5,7 @@ from vibe.core.config._settings import (
DEFAULT_MISTRAL_API_ENV_KEY,
DEFAULT_MODELS,
DEFAULT_PROVIDERS,
DEFAULT_THEME,
DEFAULT_TRANSCRIBE_MODELS,
DEFAULT_TRANSCRIBE_PROVIDERS,
DEFAULT_TTS_MODELS,
@ -44,6 +45,7 @@ from vibe.core.config.layer import (
)
from vibe.core.config.patch import (
AppendToList,
ConfigPatch,
DeleteField,
PatchOp,
RemoveFromList,
@ -68,6 +70,7 @@ __all__ = [
"DEFAULT_MISTRAL_API_ENV_KEY",
"DEFAULT_MODELS",
"DEFAULT_PROVIDERS",
"DEFAULT_THEME",
"DEFAULT_TRANSCRIBE_MODELS",
"DEFAULT_TRANSCRIBE_PROVIDERS",
"DEFAULT_TTS_MODELS",
@ -78,6 +81,7 @@ __all__ = [
"ConfigFragment",
"ConfigLayer",
"ConfigLayerError",
"ConfigPatch",
"ConfigSchema",
"ConnectorConfig",
"DeleteField",

View file

@ -23,6 +23,7 @@ from pydantic_settings import (
PydanticBaseSettingsSource,
SettingsConfigDict,
)
from textual.theme import BUILTIN_THEMES
import tomli_w
from vibe.core.agents.models import BuiltinAgentName
@ -491,10 +492,13 @@ DEFAULT_TTS_MODELS = [
)
]
DEFAULT_THEME = "ansi-dark"
class VibeConfig(BaseSettings):
active_model: str = DEFAULT_ACTIVE_MODEL
vim_keybindings: bool = False
theme: str = DEFAULT_THEME
disable_welcome_banner_animation: bool = False
autocopy_to_clipboard: bool = True
file_watcher_for_autocomplete: bool = False
@ -524,6 +528,7 @@ class VibeConfig(BaseSettings):
vibe_code_task_queue: str | None = Field(default="shared-vibe-nuage", exclude=True)
vibe_code_api_key_env_var: str = Field(default="MISTRAL_API_KEY", exclude=True)
vibe_code_project_name: str | None = Field(default=None, exclude=True)
vibe_code_experimental_nuage_enabled: bool = Field(default=False, exclude=True)
# TODO(otel): remove exclude=True once the feature is publicly available
enable_otel: bool = Field(default=False, exclude=True)
@ -849,6 +854,18 @@ class VibeConfig(BaseSettings):
pass
return self
@field_validator("theme", mode="before")
@classmethod
def _validate_theme(cls, v: Any) -> str:
if not isinstance(v, str) or not v:
return DEFAULT_THEME
if v not in BUILTIN_THEMES:
logger.warning(
"Unknown theme=%s in config; falling back to %s", v, DEFAULT_THEME
)
return DEFAULT_THEME
return v
@field_validator("tool_paths", mode="before")
@classmethod
def _expand_tool_paths(cls, v: Any) -> list[Path]:

View file

@ -6,6 +6,7 @@ from typing import Literal
from vibe.core.config.harness_files._paths import (
GLOBAL_AGENTS_DIR,
GLOBAL_AGENTS_SKILLS_DIR,
GLOBAL_PROMPTS_DIR,
GLOBAL_SKILLS_DIR,
GLOBAL_TOOLS_DIR,
@ -96,8 +97,11 @@ class HarnessFilesManager:
def user_skills_dirs(self) -> list[Path]:
if "user" not in self.sources:
return []
d = GLOBAL_SKILLS_DIR.path
return [d] if d.is_dir() else []
return [
d
for d in (GLOBAL_SKILLS_DIR.path, GLOBAL_AGENTS_SKILLS_DIR.path)
if d.is_dir()
]
@property
def user_agents_dirs(self) -> list[Path]:

View file

@ -1,8 +1,9 @@
from __future__ import annotations
from vibe.core.paths import VIBE_HOME, GlobalPath
from vibe.core.paths import AGENTS_HOME, VIBE_HOME, GlobalPath
GLOBAL_TOOLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "tools")
GLOBAL_SKILLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "skills")
GLOBAL_AGENTS_DIR = GlobalPath(lambda: VIBE_HOME.path / "agents")
GLOBAL_PROMPTS_DIR = GlobalPath(lambda: VIBE_HOME.path / "prompts")
GLOBAL_AGENTS_SKILLS_DIR = GlobalPath(lambda: AGENTS_HOME.path / "skills")

View file

@ -4,6 +4,37 @@ from dataclasses import dataclass
from typing import Any
class ConfigPatch:
"""Declarative, storage-agnostic description of a config delta."""
def __init__(
self, *operations: PatchOp, fingerprint: str, reason: str = ""
) -> None:
self.operations = list(operations)
self.fingerprint = fingerprint
self.reason = reason
def add(self, *operations: PatchOp) -> ConfigPatch:
"""Append operations after construction."""
self.operations.extend(operations)
return self
def describe(self) -> list[str]:
"""Human-readable summary of each operation."""
lines: list[str] = []
for op in self.operations:
match op:
case SetField(key=key, value=value):
lines.append(f"set {key!r} = {value!r}")
case AppendToList(key=key, items=items):
lines.append(f"append to {key!r}: {list(items)!r}")
case RemoveFromList(key=key, values=values):
lines.append(f"remove from {key!r}: {list(values)!r}")
case DeleteField(key=key):
lines.append(f"delete {key!r}")
return lines
@dataclass(frozen=True, slots=True)
class SetField:
"""Set a top-level or nested field to a value."""

39
vibe/core/feedback.py Normal file
View file

@ -0,0 +1,39 @@
from __future__ import annotations
import random
import time
from vibe.cli.cache import read_cache, write_cache
from vibe.core.paths import CACHE_FILE
FEEDBACK_PROBABILITY = 0.2
FEEDBACK_COOLDOWN_SECONDS = 3600
MIN_USER_MESSAGES_FOR_FEEDBACK = 3
_CACHE_SECTION = "user_feedback"
_LAST_SHOWN_KEY = "last_shown_at"
def should_show_feedback(
*, telemetry_active: bool, is_mistral_model: bool, user_message_count: int
) -> bool:
if not telemetry_active:
return False
if not is_mistral_model:
return False
if user_message_count < 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
)
def record_feedback_asked() -> None:
write_cache(CACHE_FILE.path, _CACHE_SECTION, {_LAST_SHOWN_KEY: int(time.time())})

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from vibe.core.paths._agents_home import AGENTS_HOME
from vibe.core.paths._local_config_walk import (
WALK_MAX_DEPTH,
ConfigWalkResult,
@ -22,6 +23,7 @@ from vibe.core.paths._vibe_home import (
from vibe.core.paths.conventions import AGENTS_MD_FILENAME
__all__ = [
"AGENTS_HOME",
"AGENTS_MD_FILENAME",
"CACHE_FILE",
"DEFAULT_TOOL_DIR",

View file

@ -0,0 +1,14 @@
from __future__ import annotations
from pathlib import Path
from vibe.core.paths._vibe_home import GlobalPath
_DEFAULT_AGENTS_HOME = Path.home() / ".agents"
def _get_agents_home() -> Path:
return _DEFAULT_AGENTS_HOME
AGENTS_HOME = GlobalPath(_get_agents_home)

View file

@ -24,6 +24,7 @@ class SystemPrompt(Prompt):
EXPLORE = auto()
TESTS = auto()
LEAN = auto()
MINIMAL = auto()
class UtilityPrompt(Prompt):

View file

@ -1,4 +1,5 @@
You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with a local codebase through tools.
Today's date is $current_date.
CRITICAL: Users complain you are too verbose. Your responses must be minimal. Most tasks need <150 words. Code speaks for itself.
Phase 1 - Orient

View file

@ -1,4 +1,5 @@
You are Mistral Vibe, a CLI coding agent built by Mistral AI. You work on a local codebase using tools.
Today's date is $current_date.
## Instruction hierarchy

View file

@ -1,4 +1,5 @@
You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with a local codebase through tools.
Today's date is $current_date.
Use markdown when appropriate. Communicate clearly to the user.

View file

@ -0,0 +1,12 @@
You are Mistral Vibe, a CLI coding agent built by Mistral AI. You work on a local codebase through tools.
**Goal**
Finish the task. Prove it works.
**How**
1. Read every file you will edit before touching it.
2. Make the minimal change needed.
3. Verify: run the relevant test. A passing edit is not done — only passing verification is done.
**If stuck**
Same error twice: stop, re-read the file, change approach.

View file

@ -35,6 +35,9 @@ agents, prompts, logs, and session data live here.
vibe.log # Main log file
session/ # Session log files
plans/ # Session plans
~/.agents/
skills/ # Additional user-level skills directory
```
### Project-Local Configuration
@ -413,6 +416,7 @@ Custom agents are TOML files in `~/.vibe/agents/NAME.toml`.
- `/config` - Edit config settings
- `/model` - Select active model
- `/thinking` - Select thinking level
- `/theme` - Select Textual UI theme (persisted in config)
- `/reload` - Reload configuration, agent instructions, and skills from disk
- `/clear` - Clear conversation history
- `/log` - Show path to current interaction log file
@ -466,6 +470,7 @@ Detailed instructions for the model...
2. `.vibe/skills/` in trusted project directory
3. `.agents/skills/` in trusted project directory
4. `~/.vibe/skills/` (user global)
5. `~/.agents/skills/` (user global, Agent Skills standard)
## Environment Variables

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from datetime import date
import html
import os
from pathlib import Path
@ -168,6 +169,11 @@ def _get_os_system_prompt() -> str:
return prompt
def _format_current_date() -> str:
today = date.today()
return f"{today.isoformat()} ({today.strftime('%A')})"
def _get_windows_system_prompt() -> str:
return (
"### COMMAND COMPATIBILITY RULES (MUST FOLLOW):\n"
@ -284,6 +290,10 @@ def _resolve_system_prompt(
return prompt
def _interpolate_prompt(prompt: str) -> str:
return Template(prompt).safe_substitute(current_date=_format_current_date())
def _get_headless_section() -> str:
return (
"# Headless Mode\n\n"
@ -306,7 +316,7 @@ def get_universal_system_prompt( # noqa: PLR0912
headless: bool = False,
experiment_manager: ExperimentManager | None = None,
) -> str:
sections = [_resolve_system_prompt(config, experiment_manager)]
sections = [_interpolate_prompt(_resolve_system_prompt(config, experiment_manager))]
if headless:
sections.append(_get_headless_section())

View file

@ -0,0 +1,120 @@
from __future__ import annotations
import types
import httpx
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from vibe.core.teleport.errors import ServiceTeleportError
from vibe.core.utils.http import build_ssl_context
class ExperimentalNuageTextPart(BaseModel):
model_config = ConfigDict(extra="forbid")
type: str = "text"
text: str
class ExperimentalNuageMessage(BaseModel):
model_config = ConfigDict(extra="forbid")
role: str = "user"
parts: list[ExperimentalNuageTextPart]
class ExperimentalNuageRepository(BaseModel):
model_config = ConfigDict(extra="forbid")
repo_url: str = Field(serialization_alias="repoUrl")
branch: str | None = None
class ExperimentalNuageContext(BaseModel):
model_config = ConfigDict(extra="forbid")
repositories: list[ExperimentalNuageRepository]
class ExperimentalNuageRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
project_name: str = Field(default="Vibe CLI", serialization_alias="project_name")
source: str = "vibe_code_cli"
idempotency_key: str = Field(serialization_alias="idempotencyKey")
message: ExperimentalNuageMessage
context: ExperimentalNuageContext
class ExperimentalNuageResponse(BaseModel):
model_config = ConfigDict(extra="ignore")
nuage_session_id: str = Field(validation_alias="sessionId")
nuage_web_session_id: str = Field(validation_alias="webSessionId")
nuage_project_id: str = Field(validation_alias="projectId")
status: str
url: str
class ExperimentalNuageClient:
def __init__(
self,
base_url: str,
api_key: str,
*,
client: httpx.AsyncClient | None = None,
timeout: float = 60.0,
) -> None:
self._base_url = base_url.rstrip("/")
self._api_key = api_key
self._client = client
self._owns_client = client is None
self._timeout = timeout
async def __aenter__(self) -> ExperimentalNuageClient:
if self._client is None:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self._timeout), verify=build_ssl_context()
)
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: types.TracebackType | None,
) -> None:
if self._owns_client and self._client:
await self._client.aclose()
self._client = None
@property
def _http_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self._timeout), verify=build_ssl_context()
)
self._owns_client = True
return self._client
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
async def start(
self, request: ExperimentalNuageRequest
) -> ExperimentalNuageResponse:
response = await self._http_client.post(
f"{self._base_url}/api/v1/code/sessions",
headers=self._headers(),
json=request.model_dump(mode="json", by_alias=True, exclude_none=True),
)
if not response.is_success:
raise ServiceTeleportError(f"Vibe Code Nuage start failed: {response.text}")
try:
return ExperimentalNuageResponse.model_validate(response.json())
except (ValueError, ValidationError) as e:
raise ServiceTeleportError("Vibe Code Nuage response was invalid") from e

View file

@ -5,6 +5,7 @@ import base64
from collections.abc import AsyncGenerator
from pathlib import Path
import types
from uuid import uuid4
import httpx
import zstandard
@ -12,6 +13,14 @@ import zstandard
from vibe.core.config import VibeConfig
from vibe.core.session.session_logger import SessionLogger
from vibe.core.teleport.errors import ServiceTeleportError
from vibe.core.teleport.experimental_nuage import (
ExperimentalNuageClient,
ExperimentalNuageContext,
ExperimentalNuageMessage,
ExperimentalNuageRepository,
ExperimentalNuageRequest,
ExperimentalNuageTextPart,
)
from vibe.core.teleport.git import GitRepoInfo, GitRepository
from vibe.core.teleport.nuage import (
ChatAssistantParams,
@ -65,25 +74,34 @@ class TeleportService:
self._vibe_code_project_name = (
vibe_config.vibe_code_project_name if vibe_config else None
)
self._experimental_nuage_enabled = (
vibe_config.vibe_code_experimental_nuage_enabled if vibe_config else False
)
self._vibe_config = vibe_config
self._git = GitRepository(workdir)
self._client = client
self._owns_client = client is None
self._timeout = timeout
self._nuage_client_instance: NuageClient | None = None
self._experimental_nuage_client_instance: ExperimentalNuageClient | None = None
async def __aenter__(self) -> TeleportService:
if self._client is None:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self._timeout), verify=build_ssl_context()
)
self._nuage_client_instance = NuageClient(
self._vibe_code_base_url,
self._vibe_code_api_key,
self._vibe_code_workflow_id,
task_queue=self._vibe_code_task_queue,
client=self._client,
)
if self._experimental_nuage_enabled:
self._experimental_nuage_client_instance = ExperimentalNuageClient(
self._vibe_code_base_url, self._vibe_code_api_key, client=self._client
)
else:
self._nuage_client_instance = NuageClient(
self._vibe_code_base_url,
self._vibe_code_api_key,
self._vibe_code_workflow_id,
task_queue=self._vibe_code_task_queue,
client=self._client,
)
await self._git.__aenter__()
return self
@ -119,6 +137,16 @@ class TeleportService:
)
return self._nuage_client_instance
@property
def _experimental_nuage_client(self) -> ExperimentalNuageClient:
if self._experimental_nuage_client_instance is None:
self._experimental_nuage_client_instance = ExperimentalNuageClient(
self._vibe_code_base_url,
self._vibe_code_api_key,
client=self._http_client,
)
return self._experimental_nuage_client_instance
async def check_supported(self) -> None:
await self._git.get_info()
@ -141,6 +169,8 @@ class TeleportService:
self._validate_config()
git_info = await self._git.get_info()
if self._experimental_nuage_enabled:
self._validate_experimental_nuage_git_info(git_info)
yield TeleportCheckingGitEvent()
await self._git.fetch()
@ -165,6 +195,15 @@ class TeleportService:
yield TeleportStartingWorkflowEvent()
if self._experimental_nuage_enabled:
result = await self._experimental_nuage_client.start(
self._build_experimental_nuage_request(
lechat_user_message=lechat_user_message, git_info=git_info
)
)
yield TeleportCompleteEvent(url=result.url)
return
execution_id = await self._nuage_client.start_workflow(
WorkflowParams(
prompt=prompt,
@ -235,6 +274,36 @@ class TeleportService:
teleported_diffs=self._compress_diff(git_info.diff or ""),
)
def _build_experimental_nuage_request(
self, *, lechat_user_message: str, git_info: GitRepoInfo
) -> ExperimentalNuageRequest:
if git_info.branch is None:
raise ServiceTeleportError(
"Experimental Nuage teleport requires a checked-out branch."
)
return ExperimentalNuageRequest(
idempotency_key=str(uuid4()),
message=ExperimentalNuageMessage(
parts=[ExperimentalNuageTextPart(text=lechat_user_message)]
),
context=ExperimentalNuageContext(
repositories=[
ExperimentalNuageRepository(
repo_url=git_info.remote_url, branch=git_info.branch
)
]
),
)
def _validate_experimental_nuage_git_info(self, git_info: GitRepoInfo) -> None:
if git_info.branch is not None:
return
raise ServiceTeleportError(
"Experimental Nuage teleport requires a checked-out branch."
)
def _compress_diff(self, diff: str, max_size: int = 1_000_000) -> bytes | None:
if not diff:
return None

View file

@ -239,6 +239,7 @@ class Grep(
"rg",
"--line-number",
"--no-heading",
"--with-filename",
"--smart-case",
"--no-binary",
# Request one extra to detect truncation
@ -261,7 +262,7 @@ class Grep(
) -> list[str]:
max_matches = args.max_matches or self.config.default_max_matches
cmd = ["grep", "-r", "-n", "-I", "-E", f"--max-count={max_matches + 1}"]
cmd = ["grep", "-r", "-n", "-H", "-I", "-E", f"--max-count={max_matches + 1}"]
if args.pattern.islower():
cmd.append("-i")

View file

@ -1,5 +1,8 @@
from __future__ import annotations
from vibe.core.tools.connectors.connector_registry import ConnectorRegistry
from vibe.core.tools.connectors.connector_registry import (
ConnectorAuthAction,
ConnectorRegistry,
)
__all__ = ["ConnectorRegistry"]
__all__ = ["ConnectorAuthAction", "ConnectorRegistry"]

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
from enum import StrEnum
import re
from typing import TYPE_CHECKING, Any, ClassVar
@ -34,6 +35,24 @@ if TYPE_CHECKING:
_BOOTSTRAP_TIMEOUT = 30.0
class ConnectorAuthAction(StrEnum):
NONE = "none"
OAUTH = "oauth"
CREDENTIALS_SETUP = "credentials_setup"
@classmethod
def from_payload(cls, payload: dict[str, Any] | None) -> ConnectorAuthAction:
if not payload:
return cls.NONE
match payload.get("type"):
case "oauth":
return cls.OAUTH
case "credentials_setup":
return cls.CREDENTIALS_SETUP
case _:
return cls.NONE
def _normalize_name(name: str) -> str:
normalized = re.sub(r"[^a-zA-Z0-9_-]", "_", name)
result = normalized.strip("_-")[:256]
@ -234,6 +253,7 @@ class ConnectorRegistry:
self._cache: dict[str, dict[str, type[BaseTool]]] | None = None
self._connector_names: list[str] = []
self._connector_connected: dict[str, bool] = {}
self._connector_auth_action: dict[str, ConnectorAuthAction] = {}
self._alias_to_id: dict[str, str] = {}
self._discover_lock = asyncio.Lock()
@ -255,10 +275,11 @@ class ConnectorRegistry:
base_url = self._server_url or _DEFAULT_BASE_URL
url = f"{base_url}/v1/connectors/bootstrap"
headers = {"Authorization": f"Bearer {self._api_key}"}
params = {"include_auth_actionable_connectors": "true"}
async with httpx.AsyncClient(
timeout=_BOOTSTRAP_TIMEOUT, verify=build_ssl_context()
) as http:
response = await http.get(url, headers=headers)
response = await http.get(url, headers=headers, params=params)
response.raise_for_status()
return response.json()
@ -311,6 +332,7 @@ class ConnectorRegistry:
self._cache = {}
self._connector_names = []
self._connector_connected = {}
self._connector_auth_action = {}
self._alias_to_id = {}
return {}
@ -320,10 +342,15 @@ class ConnectorRegistry:
all_tools: dict[str, type[BaseTool]] = {}
connector_names: list[str] = []
connector_connected: dict[str, bool] = {}
connector_auth_action: dict[str, ConnectorAuthAction] = {}
for connector_id, alias, connector in unique_connectors:
connector_names.append(alias)
name = connector.get("name") or connector_id
auth_action = ConnectorAuthAction.from_payload(
connector.get("auth_action")
)
connector_auth_action[alias] = auth_action
if bootstrap_errors := connector.get("bootstrap_errors"):
logger.warning(
@ -349,6 +376,7 @@ class ConnectorRegistry:
# lock will see the completed cache.
self._connector_names = connector_names
self._connector_connected = connector_connected
self._connector_auth_action = connector_auth_action
self._alias_to_id = {alias: cid for cid, alias, _ in unique_connectors}
self._cache = cache
@ -366,6 +394,9 @@ class ConnectorRegistry:
def is_connected(self, name: str) -> bool:
return self._connector_connected.get(name, False)
def get_auth_action(self, alias: str) -> ConnectorAuthAction:
return self._connector_auth_action.get(alias, ConnectorAuthAction.NONE)
def get_connector_id(self, alias: str) -> str | None:
"""Return the API connector ID for a given alias, or None."""
return self._alias_to_id.get(alias)
@ -382,13 +413,21 @@ class ConnectorRegistry:
return {}
tools_map: dict[str, type[BaseTool]] | None = None
fresh_auth_action: ConnectorAuthAction | None = None
found = False
fetch_ok = False
try:
data = await self._fetch_bootstrap()
fetch_ok = True
for connector in data.get("connectors") or []:
if str(connector.get("id")) != connector_id:
continue
found = True
name = connector.get("name") or connector_id
fresh_auth_action = ConnectorAuthAction.from_payload(
connector.get("auth_action")
)
status = connector.get("status") or {}
if not status.get("is_ready", False):
break
@ -406,6 +445,13 @@ class ConnectorRegistry:
if self._cache is None:
self._cache = {}
if fetch_ok and not found:
self._drop_connector(alias, connector_id)
return {}
if fresh_auth_action is not None:
self._connector_auth_action[alias] = fresh_auth_action
if tools_map is None:
self._cache.pop(connector_id, None)
self._connector_connected[alias] = False
@ -415,6 +461,15 @@ class ConnectorRegistry:
self._connector_connected[alias] = bool(tools_map)
return tools_map
def _drop_connector(self, alias: str, connector_id: str) -> None:
if self._cache is not None:
self._cache.pop(connector_id, None)
self._connector_connected.pop(alias, None)
self._connector_auth_action.pop(alias, None)
self._alias_to_id.pop(alias, None)
if alias in self._connector_names:
self._connector_names.remove(alias)
async def get_auth_url(self, alias: str) -> str | None:
"""Return the OAuth authorization URL for a connector, or None.
@ -449,4 +504,5 @@ class ConnectorRegistry:
self._cache = None
self._connector_names = []
self._connector_connected = {}
self._connector_auth_action = {}
self._alias_to_id = {}

View file

@ -254,6 +254,9 @@ class ToolManager:
elif srv.disabled_tools:
per_source_disabled[key] = set(srv.disabled_tools)
# Track connector names that have explicit config entries
configured_connectors = {cfg.name for cfg in self._config.connectors}
for cfg in self._config.connectors:
key = (cfg.name, True)
if cfg.disabled:
@ -261,6 +264,12 @@ class ToolManager:
elif cfg.disabled_tools:
per_source_disabled[key] = set(cfg.disabled_tools)
# Disable connectors discovered but not in config (new connectors)
if self._connector_registry is not None:
for name in self._connector_registry.get_connector_names():
if name not in configured_connectors:
disabled_sources.add((name, True))
return disabled_sources, per_source_disabled
@staticmethod

View file

@ -68,6 +68,12 @@ def _get_candidate_encodings(raw: bytes) -> Iterator[str]:
yield best
def normalize_newlines(text: str) -> tuple[str, str]:
r"""Return ``text`` with ``\n`` newlines and the detected original style."""
newline = _detect_newline(text)
return text.replace("\r\n", "\n").replace("\r", "\n"), newline
def decode_safe(raw: bytes, *, raise_on_error: bool = False) -> ReadSafeResult:
"""Decode ``raw`` like :func:`read_safe` after ``read_bytes``.
@ -85,8 +91,7 @@ def decode_safe(raw: bytes, *, raise_on_error: bool = False) -> ReadSafeResult:
errors = "strict" if raise_on_error else "replace"
encoding = "utf-8"
text = raw.decode(encoding, errors=errors)
newline = _detect_newline(text)
text = text.replace("\r\n", "\n").replace("\r", "\n")
text, newline = normalize_newlines(text)
return ReadSafeResult(text, encoding, newline)

View file

@ -16,8 +16,10 @@ from vibe.setup.onboarding.screens import (
ApiKeyScreen,
AuthMethodScreen,
BrowserSignInScreen,
ThemeSelectionScreen,
WelcomeScreen,
)
from vibe.setup.onboarding.screens.browser_sign_in import SUCCESS_EXIT_DELAY_SECONDS
class OnboardingApp(App[str | None]):
@ -29,6 +31,7 @@ class OnboardingApp(App[str | None]):
browser_sign_in_service_factory: Callable[[], BrowserSignInService]
| None = None,
entrypoint_metadata: EntrypointMetadata | None = None,
browser_sign_in_success_delay: float = SUCCESS_EXIT_DELAY_SECONDS,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
@ -40,16 +43,20 @@ class OnboardingApp(App[str | None]):
self._config = config
self._provider = config.provider
self._entrypoint_metadata = entrypoint_metadata
self._browser_sign_in_success_delay = browser_sign_in_success_delay
self._browser_sign_in_service_factory = self._resolve_browser_sign_in_factory(
browser_sign_in_service_factory
)
def on_mount(self) -> None:
self.theme = "textual-ansi"
self.theme = "ansi-dark"
welcome_next = "auth_method" if self.supports_browser_sign_in else "api_key"
welcome_screen = WelcomeScreen(next_screen=welcome_next)
theme_next = "auth_method" if self.supports_browser_sign_in else "api_key"
welcome_screen = WelcomeScreen(next_screen="theme_selection")
self.install_screen(welcome_screen, "welcome")
self.install_screen(
ThemeSelectionScreen(next_screen=theme_next), "theme_selection"
)
self.install_screen(
ApiKeyScreen(self._provider, entrypoint_metadata=self._entrypoint_metadata),
"api_key",
@ -61,6 +68,7 @@ class OnboardingApp(App[str | None]):
self._provider,
self._browser_sign_in_service_factory,
entrypoint_metadata=self._entrypoint_metadata,
success_exit_delay=self._browser_sign_in_success_delay,
),
"browser_sign_in",
)

View file

@ -0,0 +1,30 @@
from __future__ import annotations
from rich.text import Text
GRADIENT_COLORS = [
"#ff6b00",
"#ff7b00",
"#ff8c00",
"#ff9d00",
"#ffae00",
"#ffbf00",
"#ffae00",
"#ff9d00",
"#ff8c00",
"#ff7b00",
]
def gradient_markup(text: str, offset: int) -> str:
result = []
for index, char in enumerate(text):
color = GRADIENT_COLORS[(index + offset) % len(GRADIENT_COLORS)]
result.append(f"[bold {color}]{char}[/]")
return "".join(result)
def append_gradient_text(content: Text, text: str, offset: int) -> None:
for index, char in enumerate(text):
color = GRADIENT_COLORS[(index + offset) % len(GRADIENT_COLORS)]
content.append(char, style=f"bold {color}")

View file

@ -1,22 +1,22 @@
/* =============================================================================
Onboarding App Styles
============================================================================= */
/* Onboarding App Styles */
$mistral_orange: #FF8205;
$mistral_orange_title: #ff5a00;
$browser_sign_in_card_border: #303040;
Screen,
OnboardingScreen {
align: center middle;
}
/* =============================================================================
Welcome Screen
============================================================================= */
/* Welcome Screen */
#welcome-container {
align: center middle;
}
#welcome-text {
border: round #555555;
border: round $foreground-muted;
padding: 1 3;
margin-bottom: 2;
text-align: center;
@ -24,7 +24,7 @@ OnboardingScreen {
}
WelcomeScreen #enter-hint {
color: ansi_bright_black;
color: $text;
min-width: 16;
text-align: center;
}
@ -33,100 +33,301 @@ WelcomeScreen #enter-hint.hidden {
visibility: hidden;
}
/* Theme Selection Screen */
#theme-outer {
width: 100%;
height: 100%;
align: center middle;
overflow-y: auto;
}
#theme-content {
width: auto;
height: auto;
}
#theme-title {
text-align: center;
width: 100%;
margin-bottom: 2;
}
#theme-row {
width: auto;
height: auto;
}
#nav-hint {
color: $text;
width: 16;
height: 100%;
content-align: right middle;
padding-right: 2;
}
#theme-list {
width: 30;
height: auto;
}
.theme-item {
text-align: center;
width: 100%;
background: transparent;
}
.theme-item.selected {
color: $text;
text-style: bold;
border: round $foreground-muted;
padding: 0 2;
}
.theme-item.fade-1 {
text-opacity: 50%;
}
.theme-item.fade-2 {
text-opacity: 25%;
}
.theme-item.fade-3 {
text-opacity: 10%;
}
ThemeSelectionScreen #enter-hint {
color: $text;
width: 16;
height: 100%;
content-align: left middle;
padding-left: 2;
}
#preview-center {
width: 100%;
height: auto;
margin-top: 2;
align: center middle;
}
#preview {
min-width: 50;
max-width: 70;
height: auto;
max-height: 100%;
overflow: auto;
border: round $foreground-muted;
border-title-align: center;
}
#preview-inner {
width: 100%;
height: auto;
padding: 0 1 0 1;
}
/* =============================================================================
Auth Method Screen
============================================================================= */
#auth-method-content,
#browser-sign-in-content {
.onboarding-content {
width: 100%;
height: auto;
}
#auth-method-card,
#browser-sign-in-card {
border: round #555555;
padding: 1 2;
width: 72;
max-width: 90w;
.onboarding-panel {
width: 70;
max-width: 88w;
height: auto;
}
#auth-method-title,
#browser-sign-in-title {
margin-bottom: 1;
text-align: center;
.onboarding-chat {
width: auto;
height: auto;
margin-left: 2;
margin-bottom: 0;
}
.onboarding-chat .petit-chat {
width: auto;
color: $text;
}
.onboarding-heading {
width: 100%;
margin-left: 2;
margin-bottom: 0;
text-style: bold;
}
#auth-method-subtitle,
#browser-sign-in-subtitle {
color: ansi_bright_black;
text-align: center;
margin-bottom: 2;
#auth-method-title {
color: $mistral_orange_title;
}
.auth-method-option {
border: round #444444;
padding: 1 2;
margin-top: 1;
#auth-method-subtitle {
width: 100%;
margin-left: 2;
margin-bottom: 2;
color: $text;
text-style: bold;
}
#auth-method-options {
width: 100%;
height: auto;
}
.auth-method-option.selected {
border: round ansi_bright_yellow;
background: #2b2400;
.onboarding-option-row {
width: 100%;
height: auto;
margin: 0 0;
}
#auth-method-help,
#browser-sign-in-hint {
color: ansi_bright_black;
text-align: center;
.auth-method-option-marker {
width: 2;
height: 3;
content-align: center middle;
color: transparent;
}
.auth-method-option-marker.selected {
color: $mistral_orange;
}
.onboarding-card {
border: solid $browser_sign_in_card_border;
padding: 0 1;
width: 1fr;
height: auto;
}
.auth-method-option {
color: $foreground-muted;
border-title-align: right;
border-title-color: $primary;
border-title-style: bold;
}
.auth-method-option.selected {
border: solid $browser_sign_in_card_border;
border-left: solid $mistral_orange;
color: $text;
}
.auth-method-separator {
width: 100%;
height: 1;
margin: 1 0 1 2;
color: $text-muted;
text-style: dim;
&:ansi {
text-style: dim;
}
}
.onboarding-hint-row {
width: 100%;
color: $foreground-muted;
margin-top: 2;
padding-left: 2;
}
/* =============================================================================
Browser Sign-In Screen
============================================================================= */
.browser-sign-in-step {
border-left: wide #444444;
padding-left: 1;
#browser-sign-in-subtitle {
width: 100%;
color: $foreground-muted;
margin-left: 2;
margin-bottom: 1;
color: ansi_bright_black;
}
#browser-sign-in-steps {
width: 100%;
height: auto;
}
.browser-sign-in-step-marker {
width: 2;
height: 3;
margin-top: 0;
content-align: center middle;
color: $mistral_orange;
}
.browser-sign-in-step-marker.idle,
.browser-sign-in-step-marker.done {
color: transparent;
}
.browser-sign-in-step {
color: $foreground-muted;
}
.browser-sign-in-step.active {
border-left: wide ansi_bright_yellow;
color: $text;
}
.browser-sign-in-step.done {
border-left: wide ansi_green;
color: ansi_green;
border-left: solid $success;
color: $text;
}
#browser-sign-in-status {
border: round #444444;
padding: 1 2;
.browser-sign-in-step.idle {
color: $foreground-muted;
}
.browser-sign-in-step-title,
.browser-sign-in-step-detail {
width: 100%;
height: auto;
margin-top: 2;
padding-left: 2;
}
#browser-sign-in-status.pending {
border: round ansi_bright_yellow;
.browser-sign-in-step-title {
text-style: bold;
}
#browser-sign-in-status.error {
border: round ansi_red;
color: ansi_red;
.browser-sign-in-step-title.idle {
color: $foreground-muted;
text-style: bold dim;
}
#browser-sign-in-status.success {
border: round ansi_green;
color: ansi_green;
.browser-sign-in-step-detail.idle,
.browser-sign-in-step-detail.done {
color: $foreground-muted;
text-style: dim;
}
.browser-sign-in-step-title.active,
.browser-sign-in-step-title.done {
color: $text;
}
.browser-sign-in-step-detail.pending {
color: $mistral_orange;
text-style: bold;
}
.browser-sign-in-step-detail.error {
color: $error;
text-style: bold;
}
.browser-sign-in-step-detail.success {
color: $success;
text-style: bold;
}
#browser-sign-in-hint {
width: 100%;
color: $foreground-muted;
text-align: center;
margin-top: 1;
padding-left: 2;
}
/* =============================================================================
@ -134,94 +335,105 @@ WelcomeScreen #enter-hint.hidden {
============================================================================= */
#api-key-outer {
overflow-y: auto;
}
.spacer {
height: 1fr;
width: 100%;
height: auto;
}
#api-key-title {
text-align: center;
color: $mistral_orange_title;
}
#api-key-panel {
width: 76;
max-width: 100%;
}
#api-key-subtitle {
width: 100%;
margin-left: 2;
margin-bottom: 1;
color: $text;
text-style: bold;
}
#api-key-provider-link {
width: 100%;
height: auto;
margin-left: 2;
margin-bottom: 2;
}
color: $primary;
text-style: underline;
pointer: pointer;
#api-key-content {
width: auto;
height: auto;
}
#api-key-content Static {
text-align: center;
}
.link-row {
width: auto;
height: auto;
margin-top: 1;
}
.link-chevron {
width: auto;
&:hover {
color: $secondary;
text-style: bold underline;
}
}
#input-box {
border: round #555555;
padding: 0 1;
margin-top: 2;
width: auto;
border-title-align: left;
border-title-color: $text;
border-title-style: bold;
margin-left: 2;
margin-bottom: 1;
width: 100%;
height: 3;
}
#input-box.valid {
border: round ansi_green;
border: solid $success;
}
#input-box.invalid {
border: round ansi_red;
border: solid $error;
}
#key {
border: none;
width: 48;
width: 100%;
height: 1;
padding: 0;
}
#paste-hint {
margin-top: 3;
padding: 0 1;
}
#feedback {
text-align: center;
width: 100%;
height: 1;
margin-top: 1;
color: $text-muted;
padding-left: 2;
&:ansi {
text-style: dim;
}
}
#feedback.error {
color: ansi_red;
color: $error;
}
#feedback.success {
color: ansi_bright_black;
color: $text-muted;
}
#config-docs-section {
#config-docs-label {
width: 100%;
height: auto;
align: center top;
padding-bottom: 2;
height: 1;
margin-left: 2;
margin-top: 1;
color: $text-muted;
text-style: dim;
}
#config-docs-group {
width: auto;
height: auto;
}
#config-docs-link {
width: 100%;
height: 1;
color: $text;
margin-left: 2;
text-style: dim;
#config-docs-group Static {
color: ansi_bright_black;
}
#config-docs-group .link-row {
margin-top: 0;
pointer: pointer;
&:hover {
color: $primary;
text-style: bold underline;
}
}

View file

@ -3,6 +3,13 @@ from __future__ import annotations
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen
from vibe.setup.onboarding.screens.auth_method import AuthMethodScreen
from vibe.setup.onboarding.screens.browser_sign_in import BrowserSignInScreen
from vibe.setup.onboarding.screens.theme_selection import ThemeSelectionScreen
from vibe.setup.onboarding.screens.welcome import WelcomeScreen
__all__ = ["ApiKeyScreen", "AuthMethodScreen", "BrowserSignInScreen", "WelcomeScreen"]
__all__ = [
"ApiKeyScreen",
"AuthMethodScreen",
"BrowserSignInScreen",
"ThemeSelectionScreen",
"WelcomeScreen",
]

View file

@ -4,12 +4,13 @@ from typing import ClassVar
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Center, Horizontal, Vertical
from textual.containers import Center, Vertical
from textual.events import MouseUp
from textual.validation import Length
from textual.widgets import Input, Link, Static
from textual.widgets import Input, Link
from vibe.cli.clipboard import copy_selection_to_clipboard
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 ProviderConfig
from vibe.core.telemetry.types import EntrypointMetadata
@ -19,9 +20,8 @@ from vibe.setup.auth.api_key_persistence import (
)
from vibe.setup.onboarding.base import OnboardingScreen
PROVIDER_HELP = {
"mistral": ("https://console.mistral.ai/codestral/cli", "Mistral AI Studio")
}
# providers with a dedicated key creation page get a direct onboarding link
PROVIDER_HELP = {"mistral": ("https://console.mistral.ai/codestral/cli", "AI Studio")}
CONFIG_DOCS_URL = (
"https://github.com/mistralai/mistral-vibe?tab=readme-ov-file#configuration"
)
@ -45,54 +45,51 @@ class ApiKeyScreen(OnboardingScreen):
self.provider = resolve_api_key_provider(provider)
self._entrypoint_metadata = entrypoint_metadata
def _compose_provider_link(self, provider_name: str) -> ComposeResult:
if self.provider.name not in PROVIDER_HELP:
def _compose_provider_link(self) -> ComposeResult:
if (help_info := PROVIDER_HELP.get(self.provider.name)) is None:
return
help_url, help_name = PROVIDER_HELP[self.provider.name]
yield NoMarkupStatic(f"Grab your {provider_name} API key from the {help_name}:")
yield Center(
Horizontal(
NoMarkupStatic("", classes="link-chevron"),
Link(help_url, url=help_url),
classes="link-row",
)
)
help_url, _ = help_info
yield Link(help_url, url=help_url, id="api-key-provider-link")
def _compose_config_docs(self) -> ComposeResult:
yield Static("[dim]Learn more about Vibe configuration:[/]")
yield Horizontal(
NoMarkupStatic("", classes="link-chevron"),
Link(CONFIG_DOCS_URL, url=CONFIG_DOCS_URL),
classes="link-row",
yield NoMarkupStatic(
"Learn more about Vibe configurations", id="config-docs-label"
)
yield Link(CONFIG_DOCS_URL, url=CONFIG_DOCS_URL, id="config-docs-link")
def compose(self) -> ComposeResult:
provider_name = self.provider.name.capitalize()
help_info = PROVIDER_HELP.get(self.provider.name)
help_name = help_info[1] if help_info is not None else "your provider"
self.input_widget = Input(
password=True,
id="key",
placeholder="Paste your API key here",
validators=[Length(minimum=1, failure_description="No API key provided.")],
)
input_box = Vertical(
self.input_widget, id="input-box", classes="onboarding-card"
)
input_box.border_title = "Paste API key"
with Vertical(id="api-key-outer"):
yield NoMarkupStatic("", classes="spacer")
yield Center(NoMarkupStatic("One last thing...", id="api-key-title"))
with Vertical(id="api-key-outer", classes="onboarding-content"):
with Center():
with Vertical(id="api-key-content"):
yield from self._compose_provider_link(provider_name)
with Vertical(id="api-key-panel", classes="onboarding-panel"):
yield PetitChat(id="api-key-chat", classes="onboarding-chat")
yield NoMarkupStatic(
"...and paste it below to finish the setup:", id="paste-hint"
f"Get your {provider_name} API key",
id="api-key-title",
classes="onboarding-heading",
)
yield Center(Horizontal(self.input_widget, id="input-box"))
yield NoMarkupStatic(
f"Visit {help_name} to generate or copy your Vibe key",
id="api-key-subtitle",
)
yield from self._compose_provider_link()
yield input_box
yield NoMarkupStatic("", id="feedback")
yield NoMarkupStatic("", classes="spacer")
yield Vertical(
Vertical(*self._compose_config_docs(), id="config-docs-group"),
id="config-docs-section",
)
yield from self._compose_config_docs()
def on_mount(self) -> None:
self.input_widget.focus()

View file

@ -5,8 +5,9 @@ from typing import ClassVar
from rich.text import Text
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Center, Vertical
from textual.containers import Center, Horizontal, Vertical
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 ProviderConfig
from vibe.setup.onboarding.base import OnboardingScreen
@ -28,28 +29,46 @@ class AuthMethodScreen(OnboardingScreen):
super().__init__()
self.provider = provider
self._selected_index = OPTION_BROWSER
self._option_markers: list[NoMarkupStatic] = []
self._option_widgets: list[NoMarkupStatic] = []
self._help_widget: NoMarkupStatic
def compose(self) -> ComposeResult:
with Vertical(id="auth-method-content"):
with Vertical(id="auth-method-content", classes="onboarding-content"):
with Center():
with Vertical(id="auth-method-card"):
with Vertical(id="auth-method-panel", classes="onboarding-panel"):
yield PetitChat(id="auth-method-chat", classes="onboarding-chat")
yield NoMarkupStatic(
"How would you like to sign in?", id="auth-method-title"
"Welcome to Mistral Vibe",
id="auth-method-title",
classes="onboarding-heading",
)
yield NoMarkupStatic(
"Choose the setup that works best for you.",
id="auth-method-subtitle",
"Choose your sign in method", id="auth-method-subtitle"
)
with Vertical(id="auth-method-options"):
yield from self._compose_option_rows()
self._help_widget = NoMarkupStatic(
"", id="auth-method-help", classes="onboarding-hint-row"
)
self._option_widgets = [
NoMarkupStatic("", classes="auth-method-option"),
NoMarkupStatic("", classes="auth-method-option"),
]
yield from self._option_widgets
self._help_widget = NoMarkupStatic("", id="auth-method-help")
yield self._help_widget
def _compose_option_rows(self) -> ComposeResult:
self._option_markers = []
self._option_widgets = []
for index in range(2):
if index == OPTION_MANUAL:
yield NoMarkupStatic("or", classes="auth-method-separator")
with Horizontal(classes="auth-method-option-row onboarding-option-row"):
marker = NoMarkupStatic("", classes="auth-method-option-marker")
option = NoMarkupStatic(
"", classes="auth-method-option onboarding-card"
)
self._option_markers.append(marker)
self._option_widgets.append(option)
yield marker
yield option
def on_mount(self) -> None:
self._update_display()
self.focus()
@ -75,27 +94,30 @@ class AuthMethodScreen(OnboardingScreen):
self._update_display()
def _update_display(self) -> None:
provider_name = self.provider.name.capitalize()
options = [
(
"Sign in with your browser",
f"Sign in to {provider_name} to finish setup automatically.",
"Launch browser",
"Recommended",
"Sign in to Mistral AI Studio and finish setup automatically.",
),
("Use an API key", "Paste an existing API key to sign in manually."),
("Use an API key", None, "Already have a key? Paste it manually instead."),
]
for index, (widget, (title, description)) in enumerate(
zip(self._option_widgets, options, strict=True)
for index, (marker, widget, (title, badge, description)) in enumerate(
zip(self._option_markers, self._option_widgets, options, strict=True)
):
is_selected = index == self._selected_index
prefix = "" if is_selected else " "
content = Text()
content.append(f"{prefix} ")
content.append(title, style="bold")
content.append(f"\n {description}")
content.append("\n")
content.append(description, style="dim")
marker.update(">" if is_selected else "")
marker.remove_class("selected")
widget.border_title = badge
widget.update(content)
widget.remove_class("selected")
if is_selected:
marker.add_class("selected")
widget.add_class("selected")
self._help_widget.update("↑↓ Choose · Enter to select · Esc Cancel")
self._help_widget.update("Use arrows to navigate - Enter Select - Esc Cancel")

View file

@ -6,12 +6,15 @@ from dataclasses import dataclass
from enum import IntEnum
from typing import ClassVar, Literal
from rich.text import Text
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Center, Vertical
from textual.containers import Center, Horizontal, Vertical
from textual.reactive import reactive
from textual.timer import Timer
from textual.worker import Worker
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 ProviderConfig
from vibe.core.logger import logger
@ -27,10 +30,18 @@ from vibe.setup.auth.api_key_persistence import (
resolve_api_key_provider,
)
from vibe.setup.onboarding.base import OnboardingScreen
from vibe.setup.onboarding.gradient_text import GRADIENT_COLORS, append_gradient_text
PENDING_HINT = "Press M to enter API key manually · Esc to cancel"
ERROR_HINT = "Press R to retry · Press M to enter API key manually · Esc to cancel"
STEP_DESCRIPTIONS = ["Open your browser", "Sign in and return here", "Finish setup"]
PENDING_HINT = "Press M to enter API key manually - Esc to cancel"
ERROR_HINT = "Press R to retry - Press M to enter API key manually - Esc to cancel"
SUCCESS_HINT = "Finishing setup..."
SUCCESS_EXIT_DELAY_SECONDS: float = 2.0
WAITING_FOR_AUTHENTICATION_MESSAGE = "Waiting for authentication..."
STEP_DESCRIPTIONS = [
("Open browser", "Your browser should open automatically", "Browser opened"),
("Complete sign-in", WAITING_FOR_AUTHENTICATION_MESSAGE, "Sign-in confirmed."),
("Finished setup", "Vibe will start automatically", "Setup complete."),
]
UNEXPECTED_ERROR_MESSAGE = (
"Something went wrong during browser sign-in. Please try again."
)
@ -55,6 +66,14 @@ class BrowserSignInViewState:
running: bool
@dataclass(frozen=True)
class BrowserSignInStepWidgets:
marker: NoMarkupStatic
card: Vertical
title: NoMarkupStatic
detail: NoMarkupStatic
class BrowserSignInScreen(OnboardingScreen):
state = reactive(
BrowserSignInViewState(
@ -80,14 +99,18 @@ class BrowserSignInScreen(OnboardingScreen):
browser_sign_in_factory: Callable[[], BrowserSignInService],
*,
entrypoint_metadata: EntrypointMetadata | None = None,
success_exit_delay: float = SUCCESS_EXIT_DELAY_SECONDS,
) -> None:
super().__init__()
self.provider = provider
self._browser_sign_in_factory = browser_sign_in_factory
self._entrypoint_metadata = entrypoint_metadata
self._success_exit_delay = success_exit_delay
self._attempt_number = 0
self._active_attempt_number: int | None = None
self._worker: Worker[None] | None = None
self._gradient_offset = 0
self._gradient_timer: Timer | None = None
self._initial_state = BrowserSignInViewState(
step=BrowserSignInStep.OPEN,
message="Getting things ready...",
@ -95,45 +118,57 @@ class BrowserSignInScreen(OnboardingScreen):
variant="pending",
running=False,
)
self._step_widgets: list[NoMarkupStatic] = []
self._step_widgets: list[BrowserSignInStepWidgets] = []
self._title_widget: NoMarkupStatic
self._subtitle_widget: NoMarkupStatic
self._status_widget: NoMarkupStatic
self._hint_widget: NoMarkupStatic
def compose(self) -> ComposeResult:
with Vertical(id="browser-sign-in-content"):
with Vertical(id="browser-sign-in-content", classes="onboarding-content"):
with Center():
with Vertical(id="browser-sign-in-card"):
with Vertical(id="browser-sign-in-panel", classes="onboarding-panel"):
yield PetitChat(
id="browser-sign-in-chat", classes="onboarding-chat"
)
self._title_widget = NoMarkupStatic(
"Sign in with your browser", id="browser-sign-in-title"
"Launch browser",
id="browser-sign-in-title",
classes="onboarding-heading",
)
yield self._title_widget
self._subtitle_widget = NoMarkupStatic(
"", id="browser-sign-in-subtitle"
yield NoMarkupStatic(
"Your browser should open automatically",
id="browser-sign-in-subtitle",
)
yield self._subtitle_widget
self._step_widgets = [
NoMarkupStatic("", classes="browser-sign-in-step"),
NoMarkupStatic("", classes="browser-sign-in-step"),
NoMarkupStatic("", classes="browser-sign-in-step"),
]
yield from self._step_widgets
yield NoMarkupStatic("", id="browser-sign-in-status")
with Vertical(id="browser-sign-in-steps"):
yield from self._compose_step_rows()
yield NoMarkupStatic("", id="browser-sign-in-hint")
def _compose_step_rows(self) -> ComposeResult:
self._step_widgets = []
for _ in STEP_DESCRIPTIONS:
with Horizontal(classes="browser-sign-in-step-row onboarding-option-row"):
marker = NoMarkupStatic("", classes="browser-sign-in-step-marker")
yield marker
with Vertical(classes="browser-sign-in-step onboarding-card") as card:
title = NoMarkupStatic("", classes="browser-sign-in-step-title")
detail = NoMarkupStatic("", classes="browser-sign-in-step-detail")
self._step_widgets.append(
BrowserSignInStepWidgets(marker, card, title, detail)
)
yield title
yield detail
def on_mount(self) -> None:
provider_name = self.provider.name.capitalize()
self._subtitle_widget.update(
f"Continue with {provider_name} to finish setup automatically."
)
self._hint_widget = self.query_one("#browser-sign-in-hint", NoMarkupStatic)
self._status_widget = self.query_one("#browser-sign-in-status", NoMarkupStatic)
self.state = self._initial_state
self.watch_state(self.state)
self._gradient_timer = self.set_interval(0.08, self._animate_gradient)
self.call_after_refresh(self._start_browser_sign_in)
def on_unmount(self) -> None:
if self._gradient_timer is not None:
self._gradient_timer.stop()
self._gradient_timer = None
self._cancel_current_attempt()
def action_retry(self) -> None:
@ -141,10 +176,14 @@ class BrowserSignInScreen(OnboardingScreen):
self._start_browser_sign_in()
def action_manual(self) -> None:
if self.state.variant == "success":
return
self._cancel_current_attempt()
self.app.switch_screen("api_key")
def action_cancel(self) -> None:
if self.state.variant == "success":
return
self._cancel_current_attempt()
super().action_cancel()
@ -211,15 +250,29 @@ class BrowserSignInScreen(OnboardingScreen):
if api_key is None:
msg = "Browser sign-in finished without returning an API key."
raise AssertionError(msg)
result = persist_api_key(
resolve_api_key_provider(self.provider),
api_key,
entrypoint_metadata=self._entrypoint_metadata,
)
if result != "completed":
self._active_attempt_number = None
self._worker = None
self.app.exit(result)
return
self.state = BrowserSignInViewState(
step=BrowserSignInStep.FINISH,
message="Sign-in complete",
hint=SUCCESS_HINT,
variant="success",
running=True,
)
if self._success_exit_delay > 0:
await asyncio.sleep(self._success_exit_delay)
self._active_attempt_number = None
self._worker = None
self.app.exit(
persist_api_key(
resolve_api_key_provider(self.provider),
api_key,
entrypoint_metadata=self._entrypoint_metadata,
)
)
self.app.exit(result)
def _on_status(self, attempt_number: int, status: BrowserSignInStatus) -> None:
if not self._is_attempt_active(attempt_number):
@ -242,7 +295,7 @@ class BrowserSignInScreen(OnboardingScreen):
variant="pending",
running=True,
)
case BrowserSignInStatus.EXCHANGING:
case BrowserSignInStatus.EXCHANGING | BrowserSignInStatus.COMPLETED:
state = BrowserSignInViewState(
step=BrowserSignInStep.FINISH,
message="Finishing setup...",
@ -250,14 +303,6 @@ class BrowserSignInScreen(OnboardingScreen):
variant="pending",
running=True,
)
case BrowserSignInStatus.COMPLETED:
state = BrowserSignInViewState(
step=BrowserSignInStep.FINISH,
message="You're signed in. Finishing setup...",
hint=PENDING_HINT,
variant="success",
running=True,
)
case _:
return
@ -267,26 +312,64 @@ class BrowserSignInScreen(OnboardingScreen):
if not self.is_mounted:
return
self._status_widget.update(state.message)
self._status_widget.remove_class("pending", "error", "success")
self._status_widget.add_class(state.variant)
self._hint_widget.update(state.hint)
for index, (widget, description) in enumerate(
for index, (widgets, (title, pending_detail, done_detail)) in enumerate(
zip(self._step_widgets, STEP_DESCRIPTIONS, strict=True)
):
if index < state.step:
prefix = ""
detail = done_detail
widget_class = "done"
elif index == state.step:
prefix = ""
detail = pending_detail
widget_class = "active"
else:
prefix = "·"
detail = pending_detail
widget_class = "idle"
widget.update(f"{prefix} {description}")
widget.remove_class("done", "active", "idle")
widget.add_class(widget_class)
widgets.title.update(title)
widgets.title.remove_class("done", "active", "idle")
widgets.title.add_class(widget_class)
widgets.detail.remove_class("done", "active", "idle")
widgets.detail.remove_class("pending", "error", "success")
if widget_class == "active":
self._update_active_step_detail(widgets.detail, state)
else:
widgets.detail.update(detail)
widgets.detail.add_class(widget_class)
widgets.marker.update(">" if widget_class == "active" else "")
widgets.marker.remove_class("done", "active", "idle")
widgets.marker.add_class(widget_class)
widgets.card.remove_class("done", "active", "idle")
widgets.card.add_class(widget_class)
def _update_active_step_detail(
self, detail: NoMarkupStatic, state: BrowserSignInViewState
) -> None:
if state.variant == "pending" and state.step == BrowserSignInStep.CONFIRM:
content = Text()
append_gradient_text(
content, WAITING_FOR_AUTHENTICATION_MESSAGE, self._gradient_offset
)
detail.update(content)
detail.add_class("pending")
return
if state.variant == "error":
detail.update(state.message)
detail.add_class("error")
return
detail.update(state.message)
detail.add_class(state.variant)
def _animate_gradient(self) -> None:
self._gradient_offset = (self._gradient_offset + 1) % len(GRADIENT_COLORS)
if (
self.state.variant == "pending"
and self.state.step == BrowserSignInStep.CONFIRM
):
self.watch_state(self.state)
async def _close_browser_sign_in(
self, browser_sign_in: BrowserSignInService | None

View file

@ -0,0 +1,141 @@
from __future__ import annotations
from typing import ClassVar
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Center, Container, Horizontal, Vertical
from textual.events import Resize
from textual.widgets import Markdown, Static
from vibe.cli.textual_ui.widgets.theme_picker import sorted_theme_names
from vibe.core.config import MissingAPIKeyError, VibeConfig
from vibe.core.logger import logger
from vibe.setup.onboarding.base import OnboardingScreen
THEMES = sorted_theme_names()
VISIBLE_NEIGHBORS = 3
FADE_CLASSES = ["fade-1", "fade-2", "fade-3"]
PREVIEW_MARKDOWN = """\
### Heading
**Bold**, *italic*, and `inline code`.
- Bullet point
- Another bullet point
1. First item
2. Second item
```python
def greet(name: str = "World") -> str:
return f"Hello, {name}!"
```
> Blockquote
---
| Column 1 | Column 2 |
|----------|----------|
| Item 1 | Item 2 |
"""
class ThemeSelectionScreen(OnboardingScreen):
BINDINGS: ClassVar[list[BindingType]] = [
Binding("enter", "next", "Next", show=False, priority=True),
Binding("up", "prev_theme", "Previous", show=False),
Binding("down", "next_theme", "Next Theme", show=False),
Binding("ctrl+c", "cancel", "Cancel", show=False),
Binding("escape", "cancel", "Cancel", show=False),
]
NEXT_SCREEN = "api_key"
def __init__(self, next_screen: str = "api_key") -> None:
super().__init__()
self.NEXT_SCREEN = next_screen
self._theme_index = 0
self._theme_widgets: list[Static] = []
def _compose_theme_list(self) -> ComposeResult:
for _ in range(VISIBLE_NEIGHBORS * 2 + 1):
widget = Static("", classes="theme-item")
self._theme_widgets.append(widget)
yield widget
def compose(self) -> ComposeResult:
with Center(id="theme-outer"):
with Vertical(id="theme-content"):
yield Static("Select your preferred theme", id="theme-title")
yield Center(
Horizontal(
Static("Navigate ↑ ↓", id="nav-hint"),
Vertical(*self._compose_theme_list(), id="theme-list"),
Static("Press Enter ↵", id="enter-hint"),
id="theme-row",
)
)
with Container(id="preview-center"):
preview = Container(id="preview")
preview.border_title = "Preview"
with preview:
yield Container(Markdown(PREVIEW_MARKDOWN), id="preview-inner")
def on_mount(self) -> None:
current_theme = self.app.theme
if current_theme in THEMES:
self._theme_index = THEMES.index(current_theme)
self._update_display()
self._update_preview_height()
self.focus()
def on_resize(self, _: Resize) -> None:
self._update_preview_height()
def _update_preview_height(self) -> None:
preview = self.query_one("#preview", Container)
header_height = 17
available = self.app.size.height - header_height
preview.styles.max_height = max(7, available)
def _get_theme_at_offset(self, offset: int) -> str:
index = (self._theme_index + offset) % len(THEMES)
return THEMES[index]
def _update_display(self) -> None:
for i, widget in enumerate(self._theme_widgets):
offset = i - VISIBLE_NEIGHBORS
theme = self._get_theme_at_offset(offset)
widget.remove_class("selected", *FADE_CLASSES)
if offset == 0:
widget.update(f" {theme} ")
widget.add_class("selected")
else:
distance = min(abs(offset) - 1, len(FADE_CLASSES) - 1)
widget.update(theme)
widget.add_class(FADE_CLASSES[distance])
def _navigate(self, direction: int) -> None:
self._theme_index = (self._theme_index + direction) % len(THEMES)
self.app.theme = THEMES[self._theme_index]
self._update_display()
def action_next_theme(self) -> None:
self._navigate(1)
def action_prev_theme(self) -> None:
self._navigate(-1)
def action_next(self) -> None:
theme = THEMES[self._theme_index]
try:
VibeConfig.save_updates({"theme": theme})
except (OSError, MissingAPIKeyError) as e:
logger.warning("Failed to persist theme=%s: %s", theme, e)
super().action_next()

View file

@ -10,6 +10,7 @@ from textual.widgets import Static
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.setup.onboarding.base import OnboardingScreen
from vibe.setup.onboarding.gradient_text import GRADIENT_COLORS, gradient_markup
WELCOME_PREFIX = "Welcome to "
WELCOME_HIGHLIGHT = "Mistral Vibe"
@ -21,27 +22,6 @@ HIGHLIGHT_END = HIGHLIGHT_START + len(WELCOME_HIGHLIGHT)
BUTTON_TEXT = "Press Enter ↵"
GRADIENT_COLORS = [
"#ff6b00",
"#ff7b00",
"#ff8c00",
"#ff9d00",
"#ffae00",
"#ffbf00",
"#ffae00",
"#ff9d00",
"#ff8c00",
"#ff7b00",
]
def _apply_gradient(text: str, offset: int) -> str:
result = []
for i, char in enumerate(text):
color = GRADIENT_COLORS[(i + offset) % len(GRADIENT_COLORS)]
result.append(f"[bold {color}]{char}[/]")
return "".join(result)
class WelcomeScreen(OnboardingScreen):
BINDINGS: ClassVar[list[BindingType]] = [
@ -50,9 +30,9 @@ class WelcomeScreen(OnboardingScreen):
Binding("escape", "cancel", "Cancel", show=False),
]
NEXT_SCREEN = "api_key"
NEXT_SCREEN = "theme_selection"
def __init__(self, next_screen: str = "api_key") -> None:
def __init__(self, next_screen: str = "theme_selection") -> None:
super().__init__()
self.NEXT_SCREEN = next_screen
self._char_index = 0
@ -86,7 +66,7 @@ class WelcomeScreen(OnboardingScreen):
prefix = text[:HIGHLIGHT_START]
highlight_len = min(length, HIGHLIGHT_END) - HIGHLIGHT_START
highlight = _apply_gradient(
highlight = gradient_markup(
WELCOME_HIGHLIGHT[:highlight_len], self._gradient_offset
)

View file

@ -187,7 +187,7 @@ class TrustFolderApp(App):
self._quit_without_saving = False
def on_mount(self) -> None:
self.theme = "textual-ansi"
self.theme = "ansi-dark"
def compose(self) -> ComposeResult:
yield TrustFolderDialog(self.folder_path, self.detected_files)

View file

@ -7,7 +7,7 @@ Screen {
#trust-dialog {
max-width: 70;
overflow-y: scroll;
border: round ansi_bright_black;
border: round $border-blurred;
background: transparent;
height:auto;
max-height: 1fr;
@ -28,7 +28,7 @@ Screen {
width: 100%;
height: auto;
text-style: bold;
color: ansi_yellow;
color: $warning;
text-align: center;
margin-bottom: 1;
}
@ -36,7 +36,7 @@ Screen {
#trust-dialog-path {
width: 100%;
height: auto;
color: ansi_blue;
color: $primary;
text-align: center;
text-wrap: wrap;
margin-bottom: 1;
@ -45,7 +45,7 @@ Screen {
.trust-dialog-section-header {
width: 100%;
height: auto;
color: ansi_default;
color: $foreground;
text-align: center;
text-wrap: wrap;
padding: 0 2;
@ -61,7 +61,7 @@ Screen {
width: auto;
max-width: 100%;
height: auto;
color: ansi_default;
color: $foreground;
text-align: center;
text-overflow: fold;
}
@ -69,11 +69,11 @@ Screen {
.trust-dialog-footer-warning {
width: 100%;
height: auto;
color: ansi_yellow;
color: $warning;
text-align: center;
text-style: bold;
border-top: solid ansi_bright_black;
border-bottom: solid ansi_bright_black;
border-top: solid $border-blurred;
border-bottom: solid $border-blurred;
margin-bottom: 1;
padding: 0 1;
}
@ -88,33 +88,41 @@ Screen {
.trust-option {
height: auto;
width: auto;
color: ansi_default;
color: $foreground;
margin: 0 3;
content-align: center middle;
}
.trust-cursor-selected {
color: ansi_default;
color: $foreground;
text-style: bold;
}
.trust-option-selected {
color: ansi_default;
color: $foreground;
}
.trust-dialog-help {
width: 100%;
height: auto;
color: ansi_bright_black;
color: $text-muted;
text-align: center;
margin-bottom: 1;
&:ansi {
text-style: dim;
}
}
.trust-dialog-save-info {
width: 100%;
height: auto;
color: ansi_bright_black;
color: $text-muted;
text-align: center;
text-style: italic;
text-wrap: wrap;
&:ansi {
text-style: dim;
}
}

View file

@ -1,4 +1,5 @@
# What's new in v2.10.0
# What's new in v2.11.0
- **Multi-repo sessions**: Use `--add-dir` to pull additional repository roots into your session
- **Improved plan mode**: Plan is now displayed as live-updating markdown with Ctrl+G to edit in your default editor
- **Shared skills**: Vibe now loads skills from `~/.agents/skills` so they can be reused across agents
- **Connector onboarding from the CLI**: `/mcp` lists unauthenticated connectors and lets you start the OAuth flow without leaving Vibe
- **Theme picker is back**: Pick a theme during onboarding (`vibe --setup`) or switch any time with `/theme`