Initial commit

Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai>
Co-Authored-By: Laure Hugo <laure.hugo@mistral.ai>
Co-Authored-By: Benjamin Trom <benjamin.trom@mistral.ai>
Co-Authored-By: Mathias Gesbert <mathias.gesbert@ext.mistral.ai>
Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai>
Co-Authored-By: Clément Drouin <clement.drouin@mistral.ai>
Co-Authored-By: Vincent Guilloux <vincent.guilloux@mistral.ai>
Co-Authored-By: Valentin Berard <val@mistral.ai>
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Quentin Torroba 2025-12-09 13:13:22 +01:00 committed by Quentin Torroba
commit fa15fc977b
200 changed files with 30484 additions and 0 deletions

View file

@ -0,0 +1,40 @@
from __future__ import annotations
import sys
from rich import print as rprint
from textual.app import App
from vibe.core.config import GLOBAL_ENV_FILE
from vibe.setup.onboarding.screens import (
ApiKeyScreen,
ThemeSelectionScreen,
WelcomeScreen,
)
class OnboardingApp(App[str | None]):
CSS_PATH = "onboarding.tcss"
def on_mount(self) -> None:
self.install_screen(WelcomeScreen(), "welcome")
self.install_screen(ThemeSelectionScreen(), "theme_selection")
self.install_screen(ApiKeyScreen(), "api_key")
self.push_screen("welcome")
def run_onboarding(app: App | None = None) -> None:
result = (app or OnboardingApp()).run()
match result:
case None:
rprint("\n[yellow]Setup cancelled. See you next time![/]")
sys.exit(0)
case str() as s if s.startswith("save_error:"):
err = s.removeprefix("save_error:")
rprint(
f"\n[yellow]Warning: Could not save API key to .env file: {err}[/]"
"\n[dim]The API key is set for this session only. "
f"You may need to set it manually in {GLOBAL_ENV_FILE}[/]\n"
)
case "completed":
pass

View file

@ -0,0 +1,14 @@
from __future__ import annotations
from textual.screen import Screen
class OnboardingScreen(Screen[str | None]):
NEXT_SCREEN: str | None = None
def action_next(self) -> None:
if self.NEXT_SCREEN:
self.app.switch_screen(self.NEXT_SCREEN)
def action_cancel(self) -> None:
self.app.exit(None)

View file

@ -0,0 +1,230 @@
/* =============================================================================
Onboarding App Styles
============================================================================= */
Screen {
align: center middle;
}
OnboardingScreen {
align: center middle;
}
/* =============================================================================
Welcome Screen
============================================================================= */
#welcome-container {
align: center middle;
}
#welcome-text {
border: round #555555;
padding: 1 3;
margin-bottom: 2;
text-align: center;
width: auto;
}
WelcomeScreen #enter-hint {
color: $text-muted;
min-width: 16;
text-align: center;
}
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;
padding: 1 0;
}
#theme-title {
text-align: center;
width: 100%;
margin-bottom: 2;
}
#theme-row {
width: auto;
height: auto;
}
#nav-hint {
color: $text-muted;
width: 16;
height: 100%;
content-align: right middle;
padding-right: 2;
}
#theme-list {
width: 24;
height: auto;
}
.theme-item {
text-align: center;
width: 100%;
}
.theme-item.selected {
color: $text;
text-style: bold;
border: round #555555;
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-muted;
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 #555555;
border-title-align: center;
}
#preview-inner {
width: 100%;
height: auto;
padding: 0 1 0 1;
}
/* =============================================================================
API Key Screen
============================================================================= */
#api-key-outer {
overflow-y: auto;
}
.spacer {
height: 1fr;
}
#api-key-title {
text-align: center;
margin-bottom: 2;
}
#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;
}
#input-box {
border: round #555555;
padding: 0 1;
margin-top: 2;
width: auto;
height: 3;
}
#input-box.valid {
border: round $success;
}
#input-box.invalid {
border: round $error;
}
#key {
border: none;
width: 48;
height: 1;
padding: 0;
}
#paste-hint {
margin-top: 3;
}
#feedback {
text-align: center;
height: 1;
margin-top: 1;
}
#feedback.error {
color: $error;
}
#feedback.success {
color: $text-muted;
}
#config-docs-section {
width: 100%;
height: auto;
align: center top;
padding-bottom: 2;
}
#config-docs-group {
width: auto;
height: auto;
}
#config-docs-group Static {
color: $text-muted;
}
#config-docs-group .link-row {
margin-top: 0;
}

View file

@ -0,0 +1,7 @@
from __future__ import annotations
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen
from vibe.setup.onboarding.screens.theme_selection import ThemeSelectionScreen
from vibe.setup.onboarding.screens.welcome import WelcomeScreen
__all__ = ["ApiKeyScreen", "ThemeSelectionScreen", "WelcomeScreen"]

View file

@ -0,0 +1,133 @@
from __future__ import annotations
import os
from typing import ClassVar
from dotenv import set_key
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Center, Horizontal, Vertical
from textual.events import MouseUp
from textual.validation import Length
from textual.widgets import Input, Link, Static
from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.core.config import GLOBAL_ENV_FILE, VibeConfig
from vibe.setup.onboarding.base import OnboardingScreen
PROVIDER_HELP = {
"mistral": ("https://console.mistral.ai/codestral/vibe", "Mistral AI Studio")
}
CONFIG_DOCS_URL = (
"https://github.com/mistralai/mistral-vibe?tab=readme-ov-file#configuration"
)
def _save_api_key_to_env_file(env_key: str, api_key: str) -> None:
GLOBAL_ENV_FILE.parent.mkdir(parents=True, exist_ok=True)
set_key(GLOBAL_ENV_FILE, env_key, api_key)
class ApiKeyScreen(OnboardingScreen):
BINDINGS: ClassVar[list[BindingType]] = [
Binding("ctrl+c", "cancel", "Cancel", show=False),
Binding("escape", "cancel", "Cancel", show=False),
]
NEXT_SCREEN = None
def __init__(self) -> None:
super().__init__()
config = VibeConfig.model_construct()
active_model = config.get_active_model()
self.provider = config.get_provider_for_model(active_model)
def _compose_provider_link(self, provider_name: str) -> ComposeResult:
if self.provider.name not in PROVIDER_HELP:
return
help_url, help_name = PROVIDER_HELP[self.provider.name]
yield Static(f"Grab your {provider_name} API key from the {help_name}:")
yield Center(
Horizontal(
Static("", classes="link-chevron"),
Link(help_url, url=help_url),
classes="link-row",
)
)
def _compose_config_docs(self) -> ComposeResult:
yield Static("[dim]Learn more about Vibe configuration:[/]")
yield Horizontal(
Static("", classes="link-chevron"),
Link(CONFIG_DOCS_URL, url=CONFIG_DOCS_URL),
classes="link-row",
)
def compose(self) -> ComposeResult:
provider_name = self.provider.name.capitalize()
self.input_widget = Input(
password=True,
id="key",
placeholder="Paste your API key here",
validators=[Length(minimum=1, failure_description="No API key provided.")],
)
with Vertical(id="api-key-outer"):
yield Static("", classes="spacer")
yield Center(Static("One last thing...", id="api-key-title"))
with Center():
with Vertical(id="api-key-content"):
yield from self._compose_provider_link(provider_name)
yield Static(
"...and paste it below to finish the setup:", id="paste-hint"
)
yield Center(Horizontal(self.input_widget, id="input-box"))
yield Static("", id="feedback")
yield Static("", classes="spacer")
yield Vertical(
Vertical(*self._compose_config_docs(), id="config-docs-group"),
id="config-docs-section",
)
def on_mount(self) -> None:
self.input_widget.focus()
def on_input_changed(self, event: Input.Changed) -> None:
feedback = self.query_one("#feedback", Static)
input_box = self.query_one("#input-box")
if event.validation_result is None:
return
input_box.remove_class("valid", "invalid")
feedback.remove_class("error", "success")
if event.validation_result.is_valid:
feedback.update("Press Enter to submit ↵")
feedback.add_class("success")
input_box.add_class("valid")
return
descriptions = event.validation_result.failure_descriptions
feedback.update(descriptions[0])
feedback.add_class("error")
input_box.add_class("invalid")
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.validation_result and event.validation_result.is_valid:
self._save_and_finish(event.value)
def _save_and_finish(self, api_key: str) -> None:
env_key = self.provider.api_key_env_var
os.environ[env_key] = api_key
try:
_save_api_key_to_env_file(env_key, api_key)
except OSError as err:
self.app.exit(f"save_error:{err}")
return
self.app.exit("completed")
def on_mouse_up(self, event: MouseUp) -> None:
copy_selection_to_clipboard(self.app)

View file

@ -0,0 +1,140 @@
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.theme import BUILTIN_THEMES
from textual.widgets import Markdown, Static
from vibe.core.config import VibeConfig
from vibe.setup.onboarding.base import OnboardingScreen
THEMES = sorted(k for k in BUILTIN_THEMES if k != "textual-ansi")
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) -> None:
super().__init__()
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:
# Height is dynamically set because css won't allow filling available space and page scroll on overflow.
preview = self.query_one("#preview", Container)
header_height = 17 # title + margins + theme row + padding + buffer
available = self.app.size.height - header_height
preview.styles.max_height = max(10, 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({"textual_theme": theme})
except OSError:
pass
super().action_next()

View file

@ -0,0 +1,135 @@
from __future__ import annotations
from typing import ClassVar
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Center, Vertical
from textual.timer import Timer
from textual.widgets import Static
from vibe.setup.onboarding.base import OnboardingScreen
WELCOME_PREFIX = "Welcome to "
WELCOME_HIGHLIGHT = "Mistral Vibe"
WELCOME_SUFFIX = " - Let's get you started!"
WELCOME_TEXT = WELCOME_PREFIX + WELCOME_HIGHLIGHT + WELCOME_SUFFIX
HIGHLIGHT_START = len(WELCOME_PREFIX)
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]] = [
Binding("enter", "next", "Next", show=False, priority=True),
Binding("ctrl+c", "cancel", "Cancel", show=False),
Binding("escape", "cancel", "Cancel", show=False),
]
NEXT_SCREEN = "theme_selection"
def __init__(self) -> None:
super().__init__()
self._char_index = 0
self._gradient_offset = 0
self._typing_done = False
self._paused = False
self._typing_timer: Timer | None = None
self._button_char_index = 0
self._button_typing_timer: Timer | None = None
self._welcome_text: Static
self._enter_hint: Static
def compose(self) -> ComposeResult:
with Vertical(id="welcome-container"):
with Center():
yield Static("", id="welcome-text")
with Center():
yield Static("", id="enter-hint", classes="hidden")
def on_mount(self) -> None:
self._welcome_text = self.query_one("#welcome-text", Static)
self._enter_hint = self.query_one("#enter-hint", Static)
self._typing_timer = self.set_interval(0.04, self._type_next_char)
self.focus()
def _render_text(self, length: int) -> str:
text = WELCOME_TEXT[:length]
if length <= HIGHLIGHT_START:
return text
prefix = text[:HIGHLIGHT_START]
highlight_len = min(length, HIGHLIGHT_END) - HIGHLIGHT_START
highlight = _apply_gradient(
WELCOME_HIGHLIGHT[:highlight_len], self._gradient_offset
)
if length > HIGHLIGHT_END:
suffix = text[HIGHLIGHT_END:]
return prefix + highlight + suffix
return prefix + highlight
def _type_next_char(self) -> None:
if self._char_index >= len(WELCOME_TEXT):
if not self._typing_done:
self._typing_done = True
self.set_timer(0.5, self._show_button)
return
if self._char_index == HIGHLIGHT_END and not self._paused:
self._paused = True
if self._typing_timer:
self._typing_timer.stop()
self.set_interval(0.08, self._animate_gradient)
self.set_timer(1.4, self._resume_typing)
return
self._char_index += 1
self._welcome_text.update(self._render_text(self._char_index))
def _resume_typing(self) -> None:
self._typing_timer = self.set_interval(0.03, self._type_next_char)
def _show_button(self) -> None:
self._enter_hint.remove_class("hidden")
self._button_typing_timer = self.set_interval(0.03, self._type_button_char)
def _type_button_char(self) -> None:
if self._button_char_index >= len(BUTTON_TEXT):
if self._button_typing_timer:
self._button_typing_timer.stop()
return
self._button_char_index += 1
self._enter_hint.update(BUTTON_TEXT[: self._button_char_index])
def _animate_gradient(self) -> None:
self._gradient_offset = (self._gradient_offset + 1) % len(GRADIENT_COLORS)
self._welcome_text.update(self._render_text(self._char_index))
def action_next(self) -> None:
if self._typing_done:
super().action_next()