Co-authored-by: Quentin Torroba <quentin.torroba@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: Clément Siriex <clement.sirieix@mistral.ai>
Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-authored-by: Nicolas Karolak <nicolas@karolak.fr>
This commit is contained in:
Mathias Gesbert 2026-02-11 18:17:30 +01:00 committed by GitHub
parent 9809cfc831
commit 51fecc67d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
176 changed files with 8652 additions and 4451 deletions

View file

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

View file

@ -24,6 +24,7 @@ from acp.helpers import ContentBlock, SessionUpdate
from acp.schema import (
AgentCapabilities,
AgentMessageChunk,
AgentThoughtChunk,
AllowedOutcome,
AuthenticateResponse,
AuthMethod,
@ -73,6 +74,7 @@ from vibe.core.types import (
AsyncApprovalCallback,
CompactEndEvent,
CompactStartEvent,
ReasoningEvent,
ToolCallEvent,
ToolResultEvent,
ToolStreamEvent,
@ -173,7 +175,7 @@ class VibeAcpAgentLoop(AcpAgent):
async def new_session(
self,
cwd: str,
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio],
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
**kwargs: Any,
) -> NewSessionResponse:
load_dotenv_values()
@ -295,8 +297,8 @@ class VibeAcpAgentLoop(AcpAgent):
async def load_session(
self,
cwd: str,
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio],
session_id: str,
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
**kwargs: Any,
) -> LoadSessionResponse | None:
raise NotImplementedError()
@ -464,6 +466,13 @@ class VibeAcpAgentLoop(AcpAgent):
field_meta={"messageId": event.message_id},
)
elif isinstance(event, ReasoningEvent):
yield AgentThoughtChunk(
session_update="agent_thought_chunk",
content=TextContentBlock(type="text", text=event.content),
field_meta={"messageId": event.message_id},
)
elif isinstance(event, ToolCallEvent):
if issubclass(event.tool_class, BaseAcpTool):
event.tool_class.update_tool_state(

View file

@ -40,7 +40,7 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
max_bytes = self.config.max_output_bytes
try:
terminal_handle = await client.create_terminal(
terminal = await client.create_terminal(
session_id=session_id,
command=args.command,
cwd=str(Path.cwd()),
@ -49,7 +49,7 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
except Exception as e:
raise ToolError(f"Failed to create terminal: {e!r}") from e
terminal_id = terminal_handle.id
terminal_id = terminal.terminal_id
await self._send_in_progress_session_update([
TerminalToolCallContent(type="terminal", terminal_id=terminal_id)

View file

@ -180,6 +180,7 @@ def run_cli(args: argparse.Namespace) -> None:
run_textual_ui(
agent_loop=agent_loop,
initial_prompt=args.initial_prompt or stdin_prompt,
teleport_on_start=args.teleport,
)
except (KeyboardInterrupt, EOFError):

View file

@ -1,13 +1,8 @@
from __future__ import annotations
import base64
from collections.abc import Callable
import os
import platform
import shutil
import subprocess
import pyperclip
from textual.app import App
_PREVIEW_MAX_LENGTH = 40
@ -24,33 +19,6 @@ def _copy_osc52(text: str) -> None:
tty.flush()
def _copy_x11_clipboard(text: str) -> None:
subprocess.run(
["xclip", "-selection", "clipboard"], input=text.encode("utf-8"), check=True
)
def _copy_wayland_clipboard(text: str) -> None:
subprocess.run(["wl-copy"], input=text.encode("utf-8"), check=True)
def _has_cmd(cmd: str) -> bool:
return shutil.which(cmd) is not None
def _get_copy_fns(app: App) -> list[Callable[[str], None]]:
copy_fns: list[Callable[[str], None]] = [
_copy_osc52,
pyperclip.copy,
app.copy_to_clipboard,
]
if platform.system() == "Linux" and _has_cmd("wl-copy"):
copy_fns = [_copy_wayland_clipboard, *copy_fns]
if platform.system() == "Linux" and _has_cmd("xclip"):
copy_fns = [_copy_x11_clipboard, *copy_fns]
return copy_fns
def _shorten_preview(texts: list[str]) -> str:
dense_text = "".join(texts).replace("\n", "")
if len(dense_text) > _PREVIEW_MAX_LENGTH:
@ -58,7 +26,7 @@ def _shorten_preview(texts: list[str]) -> str:
return dense_text
def copy_selection_to_clipboard(app: App) -> None:
def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> None:
selected_texts = []
for widget in app.query("*"):
@ -84,27 +52,16 @@ def copy_selection_to_clipboard(app: App) -> None:
combined_text = "\n".join(selected_texts)
success = False
copy_fns = _get_copy_fns(app)
for copy_fn in copy_fns:
try:
copy_fn(combined_text)
except:
pass
else:
success = True
if success:
try:
_copy_osc52(combined_text)
if show_toast:
app.notify(
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
severity="information",
timeout=2,
markup=False,
)
except Exception:
app.notify(
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
severity="information",
timeout=2,
markup=False,
)
else:
app.notify(
"Failed to copy - no clipboard method available",
severity="warning",
timeout=3,
"Failed to copy - clipboard not available", severity="warning", timeout=3
)

View file

@ -22,7 +22,7 @@ class CommandRegistry:
handler="_show_help",
),
"config": Command(
aliases=frozenset(["/config", "/theme", "/model"]),
aliases=frozenset(["/config", "/model"]),
description="Edit config settings",
handler="_show_config",
),
@ -62,6 +62,11 @@ class CommandRegistry:
description="Display agent statistics",
handler="_show_status",
),
"teleport": Command(
aliases=frozenset(["/teleport"]),
description="Teleport session to Vibe Nuage",
handler="_teleport_command",
),
}
for command in excluded_commands:
@ -86,7 +91,6 @@ class CommandRegistry:
"- `Ctrl+C` Quit (or clear input if text present)",
"- `Ctrl+G` Edit input in external editor",
"- `Ctrl+O` Toggle tool output view",
"- `Ctrl+T` Toggle todo view",
"- `Shift+Tab` Toggle auto-approve mode",
"",
"### Special Features",

View file

@ -84,6 +84,9 @@ def parse_arguments() -> argparse.Namespace:
help="Change to this directory before running",
)
# Feature flag for teleport, not exposed to the user yet
parser.add_argument("--teleport", action="store_true", help=argparse.SUPPRESS)
continuation_group = parser.add_mutually_exclusive_group()
continuation_group.add_argument(
"-c",

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from enum import StrEnum
import logging
from os import getenv
from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIGateway,
@ -9,6 +10,7 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIGatewayUnauthorized,
WhoAmIResponse,
)
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, Backend, ProviderConfig
logger = logging.getLogger(__name__)
@ -62,3 +64,24 @@ def _action_and_plan_from_response(
return PlanOfferAction.UPGRADE, PlanType.FREE
case _:
return PlanOfferAction.NONE, PlanType.UNKNOWN
def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None:
api_env_key = DEFAULT_MISTRAL_API_ENV_KEY
if provider.backend == Backend.MISTRAL:
api_env_key = provider.api_key_env_var
return getenv(api_env_key)
def plan_offer_cta(action: PlanOfferAction) -> str | None:
if action is PlanOfferAction.NONE:
return
url = ACTION_TO_URL[action]
match action:
case PlanOfferAction.UPGRADE:
text = f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({url})"
case PlanOfferAction.SWITCH_TO_PRO_KEY:
text = f"### Switch to your [Le Chat Pro API key]({url})"
return text

View file

@ -272,117 +272,29 @@ def _setup_iterm2() -> SetupResult:
def _setup_wezterm() -> SetupResult:
wezterm_config = Path.home() / ".wezterm.lua"
key_binding = """{
key = "Enter",
mods = "SHIFT",
action = wezterm.action.SendString("\\x1b[13;2u"),
}"""
try:
if wezterm_config.exists():
content = wezterm_config.read_text()
if 'mods = "SHIFT"' in content and 'key = "Enter"' in content:
return SetupResult(
success=True,
terminal=Terminal.WEZTERM,
message="Shift+Enter already configured in WezTerm",
)
if "keys = {" in content:
content = content.replace("keys = {", f"keys = {{\n {key_binding},")
else:
return SetupResult(
success=False,
terminal=Terminal.WEZTERM,
message="Please manually add the following to your .wezterm.lua:\n\n"
f" keys = {{\n {key_binding}\n }}",
)
else:
content = f"""local wezterm = require 'wezterm'
return {{
keys = {{
{key_binding}
}},
}}
"""
wezterm_config.write_text(content)
return SetupResult(
success=True,
terminal=Terminal.WEZTERM,
message=f"Added Shift+Enter binding to {wezterm_config}",
requires_restart=True,
)
except Exception as e:
return SetupResult(
success=False,
terminal=Terminal.WEZTERM,
message=f"Failed to configure WezTerm: {e}",
)
return SetupResult(
success=True,
terminal=Terminal.WEZTERM,
message="Please manually add the following to your .wezterm.lua:\n"
"local wezterm = require 'wezterm'\n"
"local config = wezterm.config_builder()\n\n"
"config.keys = {\n"
" {\n"
' key = "Enter",\n'
' mods = "SHIFT",\n'
' action = wezterm.action.SendString("\\x1b[13;2u"),\n'
" }\n"
"}\n\n"
"return config",
)
def _setup_ghostty() -> SetupResult:
system = platform.system()
if system == "Darwin":
config_path = (
Path.home()
/ "Library"
/ "Application Support"
/ "com.mitchellh.ghostty"
/ "config"
)
elif system == "Linux":
xdg_config = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
config_path = Path(xdg_config) / "ghostty" / "config"
else:
return SetupResult(
success=False,
terminal=Terminal.GHOSTTY,
message="Ghostty configuration path unknown for this OS",
)
keybind_line = "keybind = shift+enter=text:\\x1b[13;2u"
try:
if config_path.exists():
content = config_path.read_text()
if "shift+enter" in content.lower():
return SetupResult(
success=True,
terminal=Terminal.GHOSTTY,
message="Shift+Enter already configured in Ghostty",
)
if not content.endswith("\n"):
content += "\n"
content += keybind_line + "\n"
else:
config_path.parent.mkdir(parents=True, exist_ok=True)
content = keybind_line + "\n"
config_path.write_text(content)
return SetupResult(
success=True,
terminal=Terminal.GHOSTTY,
message=f"Added Shift+Enter binding to {config_path}",
requires_restart=True,
)
except Exception as e:
return SetupResult(
success=False,
terminal=Terminal.GHOSTTY,
message=f"Failed to configure Ghostty: {e}",
)
return SetupResult(
success=True,
terminal=Terminal.GHOSTTY,
message="Shift+Enter is already configured in Ghostty",
)
def setup_terminal() -> SetupResult:

View file

@ -0,0 +1,58 @@
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

@ -2,16 +2,16 @@ from __future__ import annotations
import asyncio
from enum import StrEnum, auto
from os import getenv
from pathlib import Path
import subprocess
import time
from typing import Any, ClassVar, assert_never, cast
from weakref import WeakKeyDictionary
from pydantic import BaseModel
from textual.app import App, ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Horizontal, VerticalScroll
from textual.containers import Horizontal, VerticalGroup, VerticalScroll
from textual.events import AppBlur, AppFocus, MouseUp
from textual.widget import Widget
from textual.widgets import Static
@ -21,31 +21,27 @@ from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.cli.commands import CommandRegistry
from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway
from vibe.cli.plan_offer.decide_plan_offer import (
ACTION_TO_URL,
PlanOfferAction,
PlanType,
decide_plan_offer,
plan_offer_cta,
resolve_api_key_for_plan,
)
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway
from vibe.cli.terminal_setup import setup_terminal
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
from vibe.cli.textual_ui.terminal_theme import (
TERMINAL_THEME_NAME,
capture_terminal_theme,
)
from vibe.cli.textual_ui.widgets.agent_indicator import AgentIndicator
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
from vibe.cli.textual_ui.widgets.banner.banner import Banner
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
from vibe.cli.textual_ui.widgets.compact import CompactMessage
from vibe.cli.textual_ui.widgets.config_app import ConfigApp
from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState
from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreRequested
from vibe.cli.textual_ui.widgets.loading import LoadingWidget, paused_timer
from vibe.cli.textual_ui.widgets.messages import (
AssistantMessage,
BashOutputMessage,
ErrorMessage,
InterruptMessage,
PlanOfferMessage,
ReasoningMessage,
StreamingMessageBase,
UserCommandMessage,
@ -56,8 +52,19 @@ from vibe.cli.textual_ui.widgets.messages import (
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.path_display import PathDisplay
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
from vibe.cli.textual_ui.widgets.welcome import WelcomeBanner
from vibe.cli.textual_ui.windowing import (
HISTORY_RESUME_TAIL_MESSAGES,
LOAD_MORE_BATCH_SIZE,
HistoryLoadMoreManager,
SessionWindowing,
build_history_widgets,
create_resume_plan,
non_system_history_messages,
should_resume_history,
sync_backfill_state,
)
from vibe.cli.update_notifier import (
FileSystemUpdateCacheRepository,
PyPIUpdateGateway,
@ -70,16 +77,29 @@ from vibe.cli.update_notifier import (
should_show_whats_new,
)
from vibe.cli.update_notifier.update import do_update
from vibe.core.agent_loop import AgentLoop
from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.agents import AgentProfile
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import VibeConfig
from vibe.core.paths.config_paths import HISTORY_FILE
from vibe.core.session.session_loader import SessionLoader
from vibe.core.teleport.types import (
TeleportAuthCompleteEvent,
TeleportAuthRequiredEvent,
TeleportCheckingGitEvent,
TeleportCompleteEvent,
TeleportPushingEvent,
TeleportPushRequiredEvent,
TeleportPushResponseEvent,
TeleportSendingGithubTokenEvent,
TeleportStartingWorkflowEvent,
)
from vibe.core.tools.base import ToolPermission
from vibe.core.tools.builtins.ask_user_question import (
AskUserQuestionArgs,
AskUserQuestionResult,
Choice,
Question,
)
from vibe.core.types import (
AgentStats,
@ -110,6 +130,47 @@ class BottomApp(StrEnum):
Question = auto()
class ChatScroll(VerticalScroll):
"""Optimized scroll container that skips cascading style recalculations."""
def update_node_styles(self, animate: bool = True) -> None:
pass
PRUNE_LOW_MARK = 1000
PRUNE_HIGH_MARK = 1500
async def prune_by_height(messages_area: Widget, low_mark: int, high_mark: int) -> bool:
"""Remove older children to keep virtual height within bounds.
Implementation from https://github.com/batrachianai/toad/blob/a335b56c9015514d5f38654e3909aaa78850c510/src/toad/widgets/conversation.py#L1495
"""
height = messages_area.virtual_size.height
if height <= high_mark:
return False
prune_children: list[Widget] = []
bottom_margin = 0
prune_height = 0
for child in messages_area.children:
if not child.display:
prune_children.append(child)
continue
top, _, bottom, _ = child.styles.margin
child_height = child.outer_size.height
prune_height = (
(prune_height - bottom_margin + max(bottom_margin, top))
+ bottom
+ child_height
)
bottom_margin = bottom
if height - prune_height <= low_mark:
break
prune_children.append(child)
if prune_children:
await messages_area.remove_children(prune_children)
return bool(prune_children)
class VibeApp(App): # noqa: PLR0904
ENABLE_COMMAND_PALETTE = False
CSS_PATH = "app.tcss"
@ -119,7 +180,8 @@ class VibeApp(App): # noqa: PLR0904
Binding("ctrl+d", "force_quit", "Quit", show=False, priority=True),
Binding("escape", "interrupt", "Interrupt", show=False, priority=True),
Binding("ctrl+o", "toggle_tool", "Toggle Tool", show=False),
Binding("ctrl+t", "toggle_todo", "Toggle Todo", show=False),
Binding("ctrl+y", "copy_selection", "Copy", show=False, priority=True),
Binding("ctrl+shift+c", "copy_selection", "Copy", show=False, priority=True),
Binding("shift+tab", "cycle_mode", "Cycle Mode", show=False, priority=True),
Binding("shift+up", "scroll_chat_up", "Scroll Up", show=False, priority=True),
Binding(
@ -131,6 +193,7 @@ class VibeApp(App): # noqa: PLR0904
self,
agent_loop: AgentLoop,
initial_prompt: str | None = None,
teleport_on_start: bool = False,
update_notifier: UpdateGateway | None = None,
update_cache_repository: UpdateCacheRepository | None = None,
current_version: str = CORE_VERSION,
@ -148,42 +211,51 @@ class VibeApp(App): # noqa: PLR0904
self._pending_question: asyncio.Future | None = None
self.event_handler: EventHandler | None = None
self.commands = CommandRegistry()
excluded_commands = []
if not self.config.nuage_enabled:
excluded_commands.append("teleport")
self.commands = CommandRegistry(excluded_commands=excluded_commands)
self._chat_input_container: ChatInputContainer | None = None
self._agent_indicator: AgentIndicator | None = None
self._current_bottom_app: BottomApp = BottomApp.Input
self.history_file = HISTORY_FILE.path
self._tools_collapsed = True
self._todos_collapsed = False
self._current_streaming_message: AssistantMessage | None = None
self._current_streaming_reasoning: ReasoningMessage | None = None
self._windowing = SessionWindowing(load_more_batch_size=LOAD_MORE_BATCH_SIZE)
self._load_more = HistoryLoadMoreManager()
self._tool_call_map: dict[str, str] | None = None
self._history_widget_indices: WeakKeyDictionary[Widget, int] = (
WeakKeyDictionary()
)
self._update_notifier = update_notifier
self._update_cache_repository = update_cache_repository
self._current_version = current_version
self._plan_offer_gateway = plan_offer_gateway
self._plan_offer_shown = False
self._initial_prompt = initial_prompt
self._teleport_on_start = teleport_on_start and self.config.nuage_enabled
self._auto_scroll = True
self._last_escape_time: float | None = None
self._terminal_theme = capture_terminal_theme()
self._banner: Banner | None = None
self._cached_messages_area: Widget | None = None
self._cached_chat: ChatScroll | None = None
self._cached_loading_area: Widget | None = None
@property
def config(self) -> VibeConfig:
return self.agent_loop.config
def compose(self) -> ComposeResult:
with VerticalScroll(id="chat"):
yield WelcomeBanner(self.config)
yield Static(id="messages")
with ChatScroll(id="chat"):
self._banner = Banner(self.config, self.agent_loop.skill_manager)
yield self._banner
yield VerticalGroup(id="messages")
with Horizontal(id="loading-area"):
yield Static(id="loading-area-content")
yield AgentIndicator(profile=self.agent_loop.agent_profile)
yield Static(id="todo-area")
with Static(id="bottom-app-container"):
yield ChatInputContainer(
@ -191,7 +263,9 @@ class VibeApp(App): # noqa: PLR0904
command_registry=self.commands,
id="input-container",
safety=self.agent_loop.agent_profile.safety,
agent_name=self.agent_loop.agent_profile.display_name.lower(),
skill_entries_getter=self._get_skill_entries,
nuage_enabled=self.config.nuage_enabled,
)
with Horizontal(id="bottom-bar"):
@ -200,25 +274,19 @@ class VibeApp(App): # noqa: PLR0904
yield ContextProgress()
async def on_mount(self) -> None:
if self._terminal_theme:
self.register_theme(self._terminal_theme)
self.theme = "textual-ansi"
if self.config.textual_theme == TERMINAL_THEME_NAME:
if self._terminal_theme:
self.theme = TERMINAL_THEME_NAME
else:
self.theme = self.config.textual_theme
self._cached_messages_area = self.query_one("#messages")
self._cached_chat = self.query_one("#chat", ChatScroll)
self._cached_loading_area = self.query_one("#loading-area-content")
self.event_handler = EventHandler(
mount_callback=self._mount_and_scroll,
scroll_callback=self._scroll_to_bottom_deferred,
todo_area_callback=lambda: self.query_one("#todo-area"),
get_tools_collapsed=lambda: self._tools_collapsed,
get_todos_collapsed=lambda: self._todos_collapsed,
)
self._chat_input_container = self.query_one(ChatInputContainer)
self._agent_indicator = self.query_one(AgentIndicator)
context_progress = self.query_one(ContextProgress)
def update_context_progress(stats: AgentStats) -> None:
@ -227,7 +295,7 @@ class VibeApp(App): # noqa: PLR0904
current_tokens=stats.context_tokens,
)
AgentStats.add_listener("context_tokens", update_context_progress)
self.agent_loop.stats.add_listener("context_tokens", update_context_progress)
self.agent_loop.stats.trigger_listeners()
self.agent_loop.set_approval_callback(self._approval_callback)
@ -237,17 +305,19 @@ class VibeApp(App): # noqa: PLR0904
chat_input_container = self.query_one(ChatInputContainer)
chat_input_container.focus_input()
await self._show_dangerous_directory_warning()
await self._resume_history_from_messages()
await self._check_and_show_whats_new()
await self._maybe_show_plan_offer()
self._schedule_update_notification()
await self._rebuild_history_from_messages()
if self._initial_prompt:
if self._initial_prompt or self._teleport_on_start:
self.call_after_refresh(self._process_initial_prompt)
def _process_initial_prompt(self) -> None:
if self._initial_prompt:
if self._teleport_on_start:
self.run_worker(
self._handle_teleport_command(self._initial_prompt), exclusive=False
)
elif self._initial_prompt:
self.run_worker(
self._handle_user_message(self._initial_prompt), exclusive=False
)
@ -255,6 +325,9 @@ class VibeApp(App): # noqa: PLR0904
async def on_chat_input_container_submitted(
self, event: ChatInputContainer.Submitted
) -> None:
if self._banner:
self._banner.freeze_animation()
value = event.value.strip()
if not value:
return
@ -269,6 +342,11 @@ class VibeApp(App): # noqa: PLR0904
await self._handle_bash_command(value[1:])
return
if value.startswith("&"):
if self.config.nuage_enabled:
await self._handle_teleport_command(value[1:])
return
if await self._handle_command(value):
return
@ -330,29 +408,6 @@ class VibeApp(App): # noqa: PLR0904
if self._loading_widget and self._loading_widget.parent:
await self._loading_widget.remove()
self._loading_widget = None
self._hide_todo_area()
def _show_todo_area(self) -> None:
try:
todo_area = self.query_one("#todo-area")
todo_area.add_class("loading-active")
except Exception:
pass
def _hide_todo_area(self) -> None:
try:
todo_area = self.query_one("#todo-area")
todo_area.remove_class("loading-active")
except Exception:
pass
def on_config_app_setting_changed(self, message: ConfigApp.SettingChanged) -> None:
if message.key == "textual_theme":
if message.value == TERMINAL_THEME_NAME:
if self._terminal_theme:
self.theme = TERMINAL_THEME_NAME
else:
self.theme = message.value
async def on_config_app_config_closed(
self, message: ConfigApp.ConfigClosed
@ -370,7 +425,7 @@ class VibeApp(App): # noqa: PLR0904
async def on_compact_message_completed(
self, message: CompactMessage.Completed
) -> None:
messages_area = self.query_one("#messages")
messages_area = self._cached_messages_area or self.query_one("#messages")
children = list(messages_area.children)
try:
@ -483,62 +538,62 @@ class VibeApp(App): # noqa: PLR0904
self._handle_agent_loop_turn(message)
)
async def _rebuild_history_from_messages(self) -> None:
if all(msg.role == Role.system for msg in self.agent_loop.messages):
async def _resume_history_from_messages(self) -> None:
messages_area = self._cached_messages_area or self.query_one("#messages")
if not should_resume_history(list(messages_area.children)):
return
messages_area = self.query_one("#messages")
# Don't rebuild if messages are already displayed
if messages_area.children:
self._windowing.reset()
history_messages = non_system_history_messages(self.agent_loop.messages)
if (
plan := create_resume_plan(history_messages, HISTORY_RESUME_TAIL_MESSAGES)
) is None:
return
await self._mount_history_batch(
plan.tail_messages,
messages_area,
plan.tool_call_map,
start_index=plan.tail_start_index,
)
self.call_after_refresh(
lambda: self._align_chat_after_history_rebuild(plan.has_backfill)
)
self._tool_call_map = plan.tool_call_map
self._windowing.set_backfill(plan.backfill_messages)
await self._load_more.set_visible(
messages_area,
visible=self._windowing.has_backfill,
remaining=self._windowing.remaining,
)
tool_call_map: dict[str, str] = {}
async def _mount_history_batch(
self,
batch: list[LLMMessage],
messages_area: Widget,
tool_call_map: dict[str, str],
*,
start_index: int,
before: Widget | int | None = None,
after: Widget | None = None,
) -> None:
widgets = build_history_widgets(
batch=batch,
tool_call_map=tool_call_map,
start_index=start_index,
tools_collapsed=self._tools_collapsed,
history_widget_indices=self._history_widget_indices,
)
with self.batch_update():
for msg in self.agent_loop.messages:
if msg.role == Role.system:
continue
match msg.role:
case Role.user:
if msg.content:
await messages_area.mount(UserMessage(msg.content))
case Role.assistant:
await self._mount_history_assistant_message(
msg, messages_area, tool_call_map
)
case Role.tool:
tool_name = msg.name or tool_call_map.get(
msg.tool_call_id or "", "tool"
)
await messages_area.mount(
ToolResultMessage(
tool_name=tool_name,
content=msg.content,
collapsed=self._tools_collapsed,
)
)
async def _mount_history_assistant_message(
self, msg: LLMMessage, messages_area: Widget, tool_call_map: dict[str, str]
) -> None:
if msg.content:
widget = AssistantMessage(msg.content)
await messages_area.mount(widget)
await widget.write_initial_content()
await widget.stop_stream()
if not msg.tool_calls:
return
for tool_call in msg.tool_calls:
tool_name = tool_call.function.name or "unknown"
if tool_call.id:
tool_call_map[tool_call.id] = tool_name
await messages_area.mount(ToolCallMessage(tool_name=tool_name))
if not widgets:
return
if before is not None:
await messages_area.mount_all(widgets, before=before)
return
if after is not None:
await messages_area.mount_all(widgets, after=after)
return
await messages_area.mount_all(widgets)
def _is_tool_enabled_in_main_agent(self, tool: str) -> bool:
return tool in self.agent_loop.tool_manager.available_tools
@ -574,12 +629,13 @@ class VibeApp(App): # noqa: PLR0904
async def _handle_agent_loop_turn(self, prompt: str) -> None:
self._agent_running = True
loading_area = self.query_one("#loading-area-content")
loading_area = self._cached_loading_area or self.query_one(
"#loading-area-content"
)
loading = LoadingWidget()
self._loading_widget = loading
await loading_area.mount(loading)
self._show_todo_area()
try:
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
@ -595,13 +651,13 @@ class VibeApp(App): # noqa: PLR0904
if self._loading_widget and self._loading_widget.parent:
await self._loading_widget.remove()
if self.event_handler:
self.event_handler.stop_current_tool_call()
self.event_handler.stop_current_tool_call(success=False)
raise
except Exception as e:
if self._loading_widget and self._loading_widget.parent:
await self._loading_widget.remove()
if self.event_handler:
self.event_handler.stop_current_tool_call()
self.event_handler.stop_current_tool_call(success=False)
message = str(e)
if isinstance(e, RateLimitError):
@ -620,8 +676,100 @@ class VibeApp(App): # noqa: PLR0904
if self._loading_widget:
await self._loading_widget.remove()
self._loading_widget = None
self._hide_todo_area()
await self._finalize_current_streaming_message()
await self._refresh_windowing_from_history()
async def _teleport_command(self) -> None:
await self._handle_teleport_command(show_message=False)
async def _handle_teleport_command(
self, value: str | None = None, show_message: bool = True
) -> None:
has_history = any(msg.role != Role.system for msg in self.agent_loop.messages)
if not value:
if show_message:
await self._mount_and_scroll(UserMessage("/teleport"))
if not has_history:
await self._mount_and_scroll(
ErrorMessage(
"No conversation history to teleport.",
collapsed=self._tools_collapsed,
)
)
return
elif show_message:
await self._mount_and_scroll(UserMessage(value))
self.run_worker(self._teleport(value), exclusive=False)
async def _teleport(self, prompt: str | None = None) -> None:
loading_area = self._cached_loading_area or self.query_one(
"#loading-area-content"
)
loading = LoadingWidget()
await loading_area.mount(loading)
teleport_msg = TeleportMessage()
await self._mount_and_scroll(teleport_msg)
try:
gen = self.agent_loop.teleport_to_vibe_nuage(prompt)
async for event in gen:
match event:
case TeleportCheckingGitEvent():
teleport_msg.set_status("Checking git status...")
case TeleportPushRequiredEvent(unpushed_count=count):
await loading.remove()
response = await self._ask_push_approval(count)
await loading_area.mount(loading)
teleport_msg.set_status("Teleporting...")
await gen.asend(response)
case TeleportPushingEvent():
teleport_msg.set_status("Pushing to remote...")
case TeleportAuthRequiredEvent(
user_code=code, verification_uri=uri
):
teleport_msg.set_status(
f"GitHub auth required. Code: {code} (copied)\nOpen: {uri}"
)
case TeleportAuthCompleteEvent():
teleport_msg.set_status("GitHub authenticated.")
case TeleportStartingWorkflowEvent():
teleport_msg.set_status("Starting Nuage workflow...")
case TeleportSendingGithubTokenEvent():
teleport_msg.set_status("Sending encrypted GitHub token...")
case TeleportCompleteEvent(url=url):
teleport_msg.set_complete(url)
except TeleportError as e:
await teleport_msg.remove()
await self._mount_and_scroll(
ErrorMessage(str(e), collapsed=self._tools_collapsed)
)
finally:
if loading.parent:
await loading.remove()
async def _ask_push_approval(self, count: int) -> TeleportPushResponseEvent:
word = f"commit{'s' if count != 1 else ''}"
push_label = "Push and continue"
result = await self._user_input_callback(
AskUserQuestionArgs(
questions=[
Question(
question=f"You have {count} unpushed {word}. Push to continue?",
header="Push",
options=[Choice(label=push_label), Choice(label="Cancel")],
hide_other=True,
)
]
)
)
ok = (
isinstance(result, AskUserQuestionResult)
and not result.cancelled
and bool(result.answers)
and result.answers[0].answer == push_label
)
return TeleportPushResponseEvent(approved=ok)
async def _interrupt_agent_loop(self) -> None:
if not self._agent_running or self._interrupt_requested:
@ -637,14 +785,15 @@ class VibeApp(App): # noqa: PLR0904
pass
if self.event_handler:
self.event_handler.stop_current_tool_call()
self.event_handler.stop_current_tool_call(success=False)
self.event_handler.stop_current_compact()
self._agent_running = False
loading_area = self.query_one("#loading-area-content")
loading_area = self._cached_loading_area or self.query_one(
"#loading-area-content"
)
await loading_area.remove_children()
self._loading_widget = None
self._hide_todo_area()
await self._finalize_current_streaming_message()
await self._mount_and_scroll(InterruptMessage())
@ -676,10 +825,16 @@ class VibeApp(App): # noqa: PLR0904
async def _reload_config(self) -> None:
try:
self._windowing.reset()
self._tool_call_map = None
self._history_widget_indices = WeakKeyDictionary()
await self._load_more.hide()
base_config = VibeConfig.load()
await self.agent_loop.reload_with_initial_messages(base_config=base_config)
if self._banner:
self._banner.set_state(base_config, self.agent_loop.skill_manager)
await self._mount_and_scroll(UserCommandMessage("Configuration reloaded."))
except Exception as e:
await self._mount_and_scroll(
@ -690,18 +845,19 @@ class VibeApp(App): # noqa: PLR0904
async def _clear_history(self) -> None:
try:
self._windowing.reset()
self._tool_call_map = None
self._history_widget_indices = WeakKeyDictionary()
await self.agent_loop.clear_history()
await self._finalize_current_streaming_message()
messages_area = self.query_one("#messages")
messages_area = self._cached_messages_area or self.query_one("#messages")
await messages_area.remove_children()
todo_area = self.query_one("#todo-area")
await todo_area.remove_children()
await messages_area.mount(UserMessage("/clear"))
await self._mount_and_scroll(
UserCommandMessage("Conversation history cleared!")
)
chat = self.query_one("#chat", VerticalScroll)
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
chat.scroll_home(animate=False)
except Exception as e:
@ -805,16 +961,14 @@ class VibeApp(App): # noqa: PLR0904
if result.success:
if result.requires_restart:
message = f"{result.message or 'Set up Shift+Enter keybind'} (You may need to restart your terminal.)"
await self._mount_and_scroll(
UserCommandMessage(
f"{result.terminal.value}: Set up Shift+Enter keybind (You may need to restart your terminal.)"
)
UserCommandMessage(f"{result.terminal.value}: {message}")
)
else:
message = result.message or "Shift+Enter keybind already set up"
await self._mount_and_scroll(
WarningMessage(
f"{result.terminal.value}: Shift+Enter keybind already set up"
)
WarningMessage(f"{result.terminal.value}: {message}")
)
else:
await self._mount_and_scroll(
@ -828,9 +982,6 @@ class VibeApp(App): # noqa: PLR0904
self._chat_input_container.display = False
self._chat_input_container.disabled = True
if self._agent_indicator:
self._agent_indicator.display = False
self._current_bottom_app = BottomApp[type(widget).__name__.removesuffix("App")]
await bottom_container.mount(widget)
@ -843,9 +994,7 @@ class VibeApp(App): # noqa: PLR0904
return
await self._mount_and_scroll(UserCommandMessage("Configuration opened..."))
await self._switch_from_input(
ConfigApp(self.config, has_terminal_theme=self._terminal_theme is not None)
)
await self._switch_from_input(ConfigApp(self.config))
async def _switch_to_approval_app(
self, tool_name: str, tool_args: BaseModel
@ -866,9 +1015,6 @@ class VibeApp(App): # noqa: PLR0904
except Exception:
pass
if self._agent_indicator:
self._agent_indicator.display = True
if self._chat_input_container:
self._chat_input_container.disabled = False
self._chat_input_container.display = True
@ -942,12 +1088,44 @@ class VibeApp(App): # noqa: PLR0904
self._scroll_to_bottom()
self._focus_current_bottom_app()
async def on_history_load_more_requested(self, _: HistoryLoadMoreRequested) -> None:
self._load_more.set_enabled(False)
try:
if not self._windowing.has_backfill:
await self._load_more.hide()
return
if (batch := self._windowing.next_load_more_batch()) is None:
await self._load_more.hide()
return
messages_area = self._cached_messages_area or self.query_one("#messages")
if self._tool_call_map is None:
self._tool_call_map = {}
if self._load_more.widget:
before: Widget | int | None = None
after: Widget | None = self._load_more.widget
else:
before = 0
after = None
await self._mount_history_batch(
batch.messages,
messages_area,
self._tool_call_map,
start_index=batch.start_index,
before=before,
after=after,
)
if not self._windowing.has_backfill:
await self._load_more.hide()
else:
await self._load_more.show(messages_area, self._windowing.remaining)
finally:
self._load_more.set_enabled(True)
async def action_toggle_tool(self) -> None:
self._tools_collapsed = not self._tools_collapsed
for result in self.query(ToolResultMessage):
if result.tool_name != "todo":
await result.set_collapsed(self._tools_collapsed)
await result.set_collapsed(self._tools_collapsed)
try:
for error_msg in self.query(ErrorMessage):
@ -955,13 +1133,6 @@ class VibeApp(App): # noqa: PLR0904
except Exception:
pass
async def action_toggle_todo(self) -> None:
self._todos_collapsed = not self._todos_collapsed
for result in self.query(ToolResultMessage):
if result.tool_name == "todo":
await result.set_collapsed(self._todos_collapsed)
def action_cycle_mode(self) -> None:
if self._current_bottom_app != BottomApp.Input:
return
@ -973,10 +1144,9 @@ class VibeApp(App): # noqa: PLR0904
self._update_profile_widgets(self.agent_loop.agent_profile)
def _update_profile_widgets(self, profile: AgentProfile) -> None:
if self._agent_indicator:
self._agent_indicator.set_profile(profile)
if self._chat_input_container:
self._chat_input_container.set_safety(profile.safety)
self._chat_input_container.set_agent_name(profile.display_name.lower())
async def _cycle_agent(self) -> None:
new_profile = self.agent_loop.agent_manager.next_agent(
@ -1005,7 +1175,7 @@ class VibeApp(App): # noqa: PLR0904
def action_scroll_chat_up(self) -> None:
try:
chat = self.query_one("#chat", VerticalScroll)
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
chat.scroll_relative(y=-5, animate=False)
self._auto_scroll = False
except Exception:
@ -1013,7 +1183,7 @@ class VibeApp(App): # noqa: PLR0904
def action_scroll_chat_down(self) -> None:
try:
chat = self.query_one("#chat", VerticalScroll)
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
chat.scroll_relative(y=5, animate=False)
if self._is_scrolled_to_bottom(chat):
self._auto_scroll = True
@ -1039,42 +1209,37 @@ class VibeApp(App): # noqa: PLR0904
content = load_whats_new_content()
if content is not None:
await self._mount_and_scroll(WhatsNewMessage(content))
whats_new_message = WhatsNewMessage(content)
plan_offer = await self._plan_offer_cta()
if plan_offer is not None:
whats_new_message = WhatsNewMessage(f"{content}\n\n{plan_offer}")
if self._history_widget_indices:
whats_new_message.add_class("after-history")
await self._mount_and_scroll(whats_new_message)
await mark_version_as_seen(self._current_version, self._update_cache_repository)
async def _maybe_show_plan_offer(self) -> None:
if self._plan_offer_shown:
return
action, plan_type = await self._resolve_plan_offer_action()
self.plan_type = plan_type
if action is PlanOfferAction.NONE:
return
url = ACTION_TO_URL[action]
match action:
case PlanOfferAction.UPGRADE:
text = f"Upgrade to [Pro]({url})"
case PlanOfferAction.SWITCH_TO_PRO_KEY:
text = f"Switch to your [Pro API key]({url})"
await self._mount_and_scroll(PlanOfferMessage(text))
self._plan_offer_shown = True
async def _plan_offer_cta(self) -> str | None:
self.plan_type = PlanType.UNKNOWN
if self._plan_offer_gateway is None:
return
async def _resolve_plan_offer_action(self) -> tuple[PlanOfferAction, PlanType]:
try:
active_model = self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model)
except ValueError:
return PlanOfferAction.NONE, PlanType.UNKNOWN
api_key_env = provider.api_key_env_var
api_key = getenv(api_key_env) if api_key_env else None
gateway = self._plan_offer_gateway or HttpWhoAmIGateway()
try:
return await decide_plan_offer(api_key, gateway)
api_key = resolve_api_key_for_plan(provider)
action, plan_type = await decide_plan_offer(
api_key, self._plan_offer_gateway
)
self.plan_type = plan_type
return plan_offer_cta(action)
except Exception as exc:
logger.warning(
"Plan-offer check failed (%s).", type(exc).__name__, exc_info=True
)
return PlanOfferAction.NONE, PlanType.UNKNOWN
return
async def _finalize_current_streaming_message(self) -> None:
if self._current_streaming_reasoning is not None:
@ -1108,9 +1273,10 @@ class VibeApp(App): # noqa: PLR0904
return widget
async def _mount_and_scroll(self, widget: Widget) -> None:
messages_area = self.query_one("#messages")
chat = self.query_one("#chat", VerticalScroll)
messages_area = self._cached_messages_area or self.query_one("#messages")
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
was_at_bottom = self._is_scrolled_to_bottom(chat)
result: Widget | None = None
if was_at_bottom:
self._auto_scroll = True
@ -1140,12 +1306,15 @@ class VibeApp(App): # noqa: PLR0904
else:
await self._finalize_current_streaming_message()
await messages_area.mount(widget)
result = widget
is_tool_message = isinstance(widget, (ToolCallMessage, ToolResultMessage))
if not is_tool_message:
self.call_after_refresh(self._scroll_to_bottom)
if result is not None:
self.call_after_refresh(self._try_prune)
if was_at_bottom:
self.call_after_refresh(self._anchor_if_scrollable)
@ -1158,7 +1327,7 @@ class VibeApp(App): # noqa: PLR0904
def _scroll_to_bottom(self) -> None:
try:
chat = self.query_one("#chat")
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
chat.scroll_end(animate=False)
except Exception:
pass
@ -1166,17 +1335,50 @@ class VibeApp(App): # noqa: PLR0904
def _scroll_to_bottom_deferred(self) -> None:
self.call_after_refresh(self._scroll_to_bottom)
async def _try_prune(self) -> None:
messages_area = self._cached_messages_area or self.query_one("#messages")
await prune_by_height(messages_area, PRUNE_LOW_MARK, PRUNE_HIGH_MARK)
if self._load_more.widget and not self._load_more.widget.parent:
self._load_more.widget = None
async def _refresh_windowing_from_history(self) -> None:
if self._load_more.widget is None:
return
messages_area = self._cached_messages_area or self.query_one("#messages")
has_backfill, tool_call_map = sync_backfill_state(
history_messages=non_system_history_messages(self.agent_loop.messages),
messages_children=list(messages_area.children),
history_widget_indices=self._history_widget_indices,
windowing=self._windowing,
)
self._tool_call_map = tool_call_map
await self._load_more.set_visible(
messages_area, visible=has_backfill, remaining=self._windowing.remaining
)
def _anchor_if_scrollable(self) -> None:
if not self._auto_scroll:
return
try:
chat = self.query_one("#chat", VerticalScroll)
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
if chat.max_scroll_y == 0:
return
chat.anchor()
except Exception:
pass
def _align_chat_after_history_rebuild(self, has_backfill: bool) -> None:
try:
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
if has_backfill and chat.max_scroll_y > 0:
chat.anchor(True)
chat.scroll_end(animate=False)
chat.anchor(False)
return
chat.scroll_end(animate=False)
except Exception:
pass
def _schedule_update_notification(self) -> None:
if self._update_notifier is None or not self.config.enable_update_checks:
return
@ -1227,8 +1429,12 @@ class VibeApp(App): # noqa: PLR0904
message, title="Update available", severity="information", timeout=10
)
def action_copy_selection(self) -> None:
copy_selection_to_clipboard(self, show_toast=False)
def on_mouse_up(self, event: MouseUp) -> None:
copy_selection_to_clipboard(self)
if self.config.autocopy_to_clipboard:
copy_selection_to_clipboard(self, show_toast=True)
def on_app_blur(self, event: AppBlur) -> None:
if self._chat_input_container and self._chat_input_container.input_widget:
@ -1248,14 +1454,21 @@ def _print_session_resume_message(session_id: str | None) -> None:
print(f"Or: vibe --resume {session_id}")
def run_textual_ui(agent_loop: AgentLoop, initial_prompt: str | None = None) -> None:
def run_textual_ui(
agent_loop: AgentLoop,
initial_prompt: str | None = None,
teleport_on_start: bool = False,
) -> None:
update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
update_cache_repository = FileSystemUpdateCacheRepository()
plan_offer_gateway = HttpWhoAmIGateway()
app = VibeApp(
agent_loop=agent_loop,
initial_prompt=initial_prompt,
teleport_on_start=teleport_on_start,
update_notifier=update_notifier,
update_cache_repository=update_cache_repository,
plan_offer_gateway=plan_offer_gateway,
)
session_id = app.run()
_print_session_resume_message(session_id)

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@ from vibe.cli.textual_ui.widgets.compact import CompactMessage
from vibe.cli.textual_ui.widgets.messages import AssistantMessage, ReasoningMessage
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
from vibe.core.tools.ui import ToolUIDataAdapter
from vibe.core.types import (
AssistantEvent,
BaseEvent,
@ -29,15 +30,11 @@ class EventHandler:
self,
mount_callback: Callable,
scroll_callback: Callable,
todo_area_callback: Callable,
get_tools_collapsed: Callable[[], bool],
get_todos_collapsed: Callable[[], bool],
) -> None:
self.mount_callback = mount_callback
self.scroll_callback = scroll_callback
self.todo_area_callback = todo_area_callback
self.get_tools_collapsed = get_tools_collapsed
self.get_todos_collapsed = get_todos_collapsed
self.current_tool_call: ToolCallMessage | None = None
self.current_compact: CompactMessage | None = None
@ -93,35 +90,21 @@ class EventHandler:
tool_call = ToolCallMessage(event)
if loading_widget and event.tool_class:
from vibe.core.tools.ui import ToolUIDataAdapter
adapter = ToolUIDataAdapter(event.tool_class)
status_text = adapter.get_status_text()
loading_widget.set_status(status_text)
# Don't show todo in messages
if event.tool_name != "todo":
await self.mount_callback(tool_call)
self.current_tool_call = tool_call
await self.mount_callback(tool_call)
return tool_call
async def _handle_tool_result(self, event: ToolResultEvent) -> None:
if event.tool_name == "todo":
todos_collapsed = self.get_todos_collapsed()
tool_result = ToolResultMessage(
event, self.current_tool_call, collapsed=todos_collapsed
)
# Show in todo area
todo_area = self.todo_area_callback()
await todo_area.remove_children()
await todo_area.mount(tool_result)
else:
tools_collapsed = self.get_tools_collapsed()
tool_result = ToolResultMessage(
event, self.current_tool_call, collapsed=tools_collapsed
)
await self.mount_callback(tool_result)
tools_collapsed = self.get_tools_collapsed()
tool_result = ToolResultMessage(
event, self.current_tool_call, collapsed=tools_collapsed
)
await self.mount_callback(tool_result)
self.current_tool_call = None
@ -153,9 +136,9 @@ class EventHandler:
async def _handle_unknown_event(self, event: BaseEvent) -> None:
await self.mount_callback(NoMarkupStatic(str(event), classes="unknown-event"))
def stop_current_tool_call(self) -> None:
def stop_current_tool_call(self, success: bool = True) -> None:
if self.current_tool_call:
self.current_tool_call.stop_spinning()
self.current_tool_call.stop_spinning(success=success)
self.current_tool_call = None
def stop_current_compact(self) -> None:

View file

@ -1,266 +0,0 @@
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass, fields
import os
import re
import sys
from typing import Any
from textual.theme import Theme
try:
import select
import termios
_UNIX_AVAILABLE = True
except ImportError:
select = None # type: ignore[assignment]
termios: Any = None
_UNIX_AVAILABLE = False
TERMINAL_THEME_NAME = "terminal"
_LUMINANCE_THRESHOLD = 0.5
_RGB_16BIT_LEN = 4
_RGB_8BIT_LEN = 2
# OSC codes for terminal colors
_OSC_FOREGROUND = "10"
_OSC_BACKGROUND = "11"
# ANSI color indices (0-15) mapped to field names
_ANSI_COLORS = (
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"white",
"bright_black",
"bright_red",
"bright_green",
"bright_yellow",
"bright_blue",
"bright_magenta",
"bright_cyan",
"bright_white",
)
# DA1 (Primary Device Attributes) query - used to detect unsupported terminals
# Terminals respond to DA1 even if they don't support OSC color queries
_DA1_QUERY = b"\x1b[c"
_DA1_RESPONSE_PREFIX = b"\x1b[?"
_OSC_RESPONSE_RE = re.compile(
rb"\x1b\](10|11|4;\d+);rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)(?:\x1b\\|\x07)"
)
@dataclass
class TerminalColors:
foreground: str | None = None
background: str | None = None
black: str | None = None
red: str | None = None
green: str | None = None
yellow: str | None = None
blue: str | None = None
magenta: str | None = None
cyan: str | None = None
white: str | None = None
bright_black: str | None = None
bright_red: str | None = None
bright_green: str | None = None
bright_yellow: str | None = None
bright_blue: str | None = None
bright_magenta: str | None = None
bright_cyan: str | None = None
bright_white: str | None = None
def is_complete(self) -> bool:
return all(getattr(self, f.name) is not None for f in fields(self))
def _build_osc_query(code: str) -> bytes:
"""Build an OSC query: ESC ] <code> ; ? ST"""
return f"\x1b]{code};?\x1b\\".encode()
def _build_color_queries() -> tuple[bytes, dict[bytes, str]]:
"""Build all OSC color queries and the mapping from OSC codes to field names."""
queries = bytearray()
osc_to_field: dict[bytes, str] = {}
# Foreground and background
queries.extend(_build_osc_query(_OSC_FOREGROUND))
queries.extend(_build_osc_query(_OSC_BACKGROUND))
osc_to_field[_OSC_FOREGROUND.encode()] = "foreground"
osc_to_field[_OSC_BACKGROUND.encode()] = "background"
# ANSI colors 0-15
for i, name in enumerate(_ANSI_COLORS):
code = f"4;{i}"
queries.extend(_build_osc_query(code))
osc_to_field[code.encode()] = name
return bytes(queries), osc_to_field
_COLOR_QUERIES, _OSC_TO_FIELD = _build_color_queries()
@contextmanager
def _raw_mode(fd: int) -> Iterator[None]:
"""Context manager to temporarily set terminal to raw mode."""
assert termios is not None # Only called on Unix so typing doesn't freak out
try:
old_settings = termios.tcgetattr(fd)
except termios.error:
yield
return
try:
new_settings = termios.tcgetattr(fd)
new_settings[3] &= ~(termios.ECHO | termios.ICANON)
termios.tcsetattr(fd, termios.TCSADRAIN, new_settings)
yield
finally:
try:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
except termios.error:
pass
def _parse_rgb(r_hex: bytes, g_hex: bytes, b_hex: bytes) -> str | None:
"""Parse RGB hex values to a #rrggbb string.
Terminals return either 16-bit (4 hex chars) or 8-bit (2 hex chars) per channel.
"""
try:
if len(r_hex) == _RGB_16BIT_LEN:
r, g, b = int(r_hex[:2], 16), int(g_hex[:2], 16), int(b_hex[:2], 16)
elif len(r_hex) == _RGB_8BIT_LEN:
r, g, b = int(r_hex, 16), int(g_hex, 16), int(b_hex, 16)
else:
return None
return f"#{r:02x}{g:02x}{b:02x}"
except ValueError:
return None
def _read_responses(fd: int, timeout: float = 1.0) -> bytes:
"""Read terminal responses until DA1 response or timeout.
Uses the DA1 trick: we send color queries followed by DA1. Since terminals
respond in order, receiving the DA1 response means all color responses
(if any) have been received.
"""
assert select is not None # Only called on Unix so typing doesn't freak out
response = bytearray()
while True:
ready, _, _ = select.select([fd], [], [], timeout)
if not ready:
break
chunk = os.read(fd, 4096)
if not chunk:
break
response.extend(chunk)
# DA1 response received - we have all the color responses
if _DA1_RESPONSE_PREFIX in response:
break
return bytes(response)
def _parse_osc_responses(response: bytes) -> TerminalColors:
colors = TerminalColors()
for match in _OSC_RESPONSE_RE.finditer(response):
osc_code, r_hex, g_hex, b_hex = match.groups()
field = _OSC_TO_FIELD.get(osc_code)
if field and (color := _parse_rgb(r_hex, g_hex, b_hex)):
setattr(colors, field, color)
return colors
def _query_terminal_colors() -> TerminalColors:
if not _UNIX_AVAILABLE:
return TerminalColors()
if not sys.stdin.isatty() or not sys.stdout.isatty():
return TerminalColors()
fd = sys.stdin.fileno()
try:
with _raw_mode(fd):
os.write(sys.stdout.fileno(), _COLOR_QUERIES + _DA1_QUERY)
response = _read_responses(fd)
return _parse_osc_responses(response)
except OSError:
return TerminalColors()
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
h = hex_color.lstrip("#")
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
def _rgb_to_hex(r: int, g: int, b: int) -> str:
return f"#{r:02x}{g:02x}{b:02x}"
def _adjust_brightness(hex_color: str, factor: float) -> str:
r, g, b = _hex_to_rgb(hex_color)
return _rgb_to_hex(
min(255, max(0, int(r * factor))),
min(255, max(0, int(g * factor))),
min(255, max(0, int(b * factor))),
)
def _blend(c1: str, c2: str, ratio: float = 0.5) -> str:
r1, g1, b1 = _hex_to_rgb(c1)
r2, g2, b2 = _hex_to_rgb(c2)
return _rgb_to_hex(
int(r1 * (1 - ratio) + r2 * ratio),
int(g1 * (1 - ratio) + g2 * ratio),
int(b1 * (1 - ratio) + b2 * ratio),
)
def _luminance(hex_color: str) -> float:
"""Calculate perceived luminance (0-1) using ITU-R BT.601 coefficients."""
r, g, b = _hex_to_rgb(hex_color)
return (0.299 * r + 0.587 * g + 0.114 * b) / 255
def capture_terminal_theme() -> Theme | None:
colors = _query_terminal_colors()
if not colors.background or not colors.foreground:
return None
is_dark = _luminance(colors.background) < _LUMINANCE_THRESHOLD
fg = colors.foreground
bg = colors.background
surface = _adjust_brightness(bg, 1.15 if is_dark else 0.95)
panel = _blend(bg, surface)
return Theme(
name=TERMINAL_THEME_NAME,
primary=colors.blue or fg,
secondary=colors.cyan or fg,
warning=colors.yellow or fg,
error=colors.red or fg,
success=colors.green or fg,
accent=colors.magenta or fg,
foreground=fg,
background=bg,
surface=surface,
panel=panel,
dark=is_dark,
)

View file

@ -1,41 +0,0 @@
from __future__ import annotations
from textual.widgets import Static
from vibe.core.agents import AgentProfile, AgentSafety, BuiltinAgentName
AGENT_ICONS: dict[str, str] = {
BuiltinAgentName.DEFAULT: "",
BuiltinAgentName.PLAN: "",
BuiltinAgentName.ACCEPT_EDITS: "⏵⏵",
BuiltinAgentName.AUTO_APPROVE: "⏵⏵⏵",
}
SAFETY_CLASSES: dict[AgentSafety, str] = {
AgentSafety.SAFE: "agent-safe",
AgentSafety.NEUTRAL: "agent-neutral",
AgentSafety.DESTRUCTIVE: "agent-destructive",
AgentSafety.YOLO: "agent-yolo",
}
class AgentIndicator(Static):
def __init__(self, profile: AgentProfile) -> None:
super().__init__(markup=False)
self.can_focus = False
self.profile = profile
self._update_display()
def _update_display(self) -> None:
icon = AGENT_ICONS.get(self.profile.name, "")
name = self.profile.display_name.lower()
self.update(f"{icon}{' ' if icon else ''}{name} agent (shift+tab to cycle)")
for safety_class in SAFETY_CLASSES.values():
self.remove_class(safety_class)
self.add_class(SAFETY_CLASSES[self.profile.safety])
def set_profile(self, profile: AgentProfile) -> None:
self.profile = profile
self._update_display()

View file

@ -66,6 +66,18 @@ class ApprovalApp(Container):
self.help_widget: Static | None = None
def compose(self) -> ComposeResult:
with Vertical(id="approval-options"):
yield NoMarkupStatic("")
for _ in range(3):
widget = NoMarkupStatic("", classes="approval-option")
self.option_widgets.append(widget)
yield widget
yield NoMarkupStatic("")
self.help_widget = NoMarkupStatic(
"↑↓ navigate Enter select ESC reject", classes="approval-help"
)
yield self.help_widget
with Vertical(id="approval-content"):
self.title_widget = NoMarkupStatic(
f"{self.tool_name} command", classes="approval-title"
@ -78,20 +90,6 @@ class ApprovalApp(Container):
)
yield self.tool_info_container
yield NoMarkupStatic("")
for _ in range(3):
widget = NoMarkupStatic("", classes="approval-option")
self.option_widgets.append(widget)
yield widget
yield NoMarkupStatic("")
self.help_widget = NoMarkupStatic(
"↑↓ navigate Enter select ESC reject", classes="approval-help"
)
yield self.help_widget
async def on_mount(self) -> None:
await self._update_tool_info()
self._update_options()

View file

@ -0,0 +1,85 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.reactive import reactive
from textual.widgets import Static
from vibe import __version__
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
@dataclass
class BannerState:
active_model: str = ""
models_count: int = 0
mcp_servers_count: int = 0
skills_count: int = 0
class Banner(Static):
state = reactive(BannerState(), init=False)
def __init__(
self, config: VibeConfig, skill_manager: SkillManager, **kwargs: Any
) -> None:
super().__init__(**kwargs)
self.can_focus = False
self._initial_state = BannerState(
active_model=config.active_model,
models_count=len(config.models),
mcp_servers_count=len(config.mcp_servers),
skills_count=len(skill_manager.available_skills),
)
self._animated = not config.disable_welcome_banner_animation
def compose(self) -> ComposeResult:
with Horizontal(id="banner-container"):
yield PetitChat(animate=self._animated)
with Vertical(id="banner-info"):
with Horizontal(classes="banner-line"):
yield NoMarkupStatic("Mistral Vibe", id="banner-brand")
yield NoMarkupStatic(" ", classes="banner-spacer")
yield NoMarkupStatic(f"v{__version__} · ", classes="banner-meta")
yield NoMarkupStatic("", id="banner-model")
with Horizontal(classes="banner-line"):
yield NoMarkupStatic("", id="banner-meta-counts")
with Horizontal(classes="banner-line"):
yield NoMarkupStatic("Type ", classes="banner-meta")
yield NoMarkupStatic("/help", classes="banner-cmd")
yield NoMarkupStatic(" for more information", classes="banner-meta")
def on_mount(self) -> None:
self.state = self._initial_state
def watch_state(self) -> None:
self.query_one("#banner-model", NoMarkupStatic).update(self.state.active_model)
self.query_one("#banner-meta-counts", NoMarkupStatic).update(
self._format_meta_counts()
)
def freeze_animation(self) -> None:
if self._animated:
self.query_one(PetitChat).freeze_animation()
def set_state(self, config: VibeConfig, skill_manager: SkillManager) -> None:
self.state = BannerState(
active_model=config.active_model,
models_count=len(config.models),
mcp_servers_count=len(config.mcp_servers),
skills_count=len(skill_manager.available_skills),
)
def _format_meta_counts(self) -> str:
return (
f"{self.state.models_count} model{'s' if self.state.models_count != 1 else ''}"
f" · {self.state.mcp_servers_count} MCP server{'s' if self.state.mcp_servers_count != 1 else ''}"
f" · {self.state.skills_count} skill{'s' if self.state.skills_count != 1 else ''}"
)

View file

@ -0,0 +1,195 @@
from __future__ import annotations
from typing import Any
from textual.app import ComposeResult
from textual.timer import Timer
from textual.widgets import Static
from vibe.cli.textual_ui.widgets.braille_renderer import render_braille
WIDTH = 22
HEIGHT = 12
STARTING_DOTS = [
set[int](),
{6, 7, 15, 19},
{5, 8, 14, 16, 18, 20},
{4, 6, 7, 14, 17, 20},
{3, 5, 10, 11, 12, 14, 20},
{3, 5, 9, 13, 14, 16, 18, 20},
{3, 5, 8, 13, 17, 21},
{3, 6, 7, 8, 11, 14, 15, 16, 18, 19, 20},
{4, 5, 8, 12, 17, 19},
{6, 7, 8, 13, 18, 20},
{9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20},
set[int](),
]
QUEUE_RIGHT_TO_MID = {
"remove": {1j + 6, 1j + 7, 2j + 8, 3j + 4, 3j + 6, 3j + 7, 8j + 4, 8j + 5},
"add": {1j + 4, 2j + 3, 3j + 3, 3j + 5, 7j + 5, 8j + 3, 9j + 4, 9j + 5},
}
QUEUE_MID_TO_RIGHT = {
"remove": QUEUE_RIGHT_TO_MID["add"],
"add": QUEUE_RIGHT_TO_MID["remove"],
}
QUEUE_MID_TO_LEFT = {
"remove": {1j + 4, 2j + 5, 3j + 3, 3j + 5, 7j + 5, 8j + 3, 9j + 4, 9j + 5},
"add": {1j + 1, 1j + 2, 2j, 3j + 1, 3j + 2, 3j + 4, 8j + 4, 8j + 5},
}
QUEUE_LEFT_TO_MID = {
"remove": QUEUE_MID_TO_LEFT["add"],
"add": QUEUE_MID_TO_LEFT["remove"],
}
WAIT = {"remove": set[int](), "add": set[int]()}
HEAD_RIGHT = {"remove": {5j + 16, 5j + 18, 6j + 17}, "add": {5j + 17, 5j + 19, 6j + 18}}
HEAD_LEFT = {"remove": {5j + 17, 5j + 19, 6j + 18}, "add": {5j + 16, 5j + 18, 6j + 17}}
HEAD_DOWN = {
"remove": {
1j + 15,
1j + 19,
2j + 14,
2j + 16,
2j + 18,
2j + 20,
3j + 17,
5j + 17,
5j + 19,
6j + 13,
6j + 18,
6j + 21,
7j + 14,
7j + 15,
7j + 16,
7j + 19,
7j + 20,
},
"add": {
2j + 15,
2j + 19,
3j + 16,
3j + 18,
4j + 17,
6j + 14,
6j + 17,
6j + 19,
6j + 20,
7j + 13,
7j + 18,
7j + 21,
8j + 14,
8j + 15,
8j + 16,
8j + 18,
8j + 20,
},
}
HEAD_UP = {
"remove": {
2j + 15,
2j + 19,
3j + 16,
3j + 18,
4j + 17,
6j + 14,
6j + 17,
6j + 19,
6j + 20,
7j + 13,
7j + 18,
7j + 21,
8j + 14,
8j + 15,
8j + 16,
8j + 18,
8j + 20,
},
"add": {
1j + 15,
1j + 19,
2j + 14,
2j + 16,
2j + 18,
2j + 20,
3j + 17,
5j + 17,
5j + 19,
6j + 13,
6j + 18,
6j + 21,
7j + 14,
7j + 15,
7j + 16,
7j + 18,
7j + 19,
7j + 20,
},
}
BLINK_EYES_HEAD_HIGH = [
{"remove": {5j + 16, 5j + 18}, "add": set[int]()},
{"remove": set[int](), "add": {5j + 16, 5j + 18}},
]
BLINK_EYES_HEAD_LOW = [
{"remove": {6j + 17, 6j + 19}, "add": set[int]()},
{"remove": set[int](), "add": {6j + 17, 6j + 19}},
]
TRANSITIONS = [
*BLINK_EYES_HEAD_HIGH,
WAIT,
QUEUE_RIGHT_TO_MID,
HEAD_RIGHT,
WAIT,
QUEUE_MID_TO_LEFT,
WAIT,
QUEUE_LEFT_TO_MID,
WAIT,
HEAD_DOWN,
WAIT,
QUEUE_MID_TO_RIGHT,
*BLINK_EYES_HEAD_LOW,
WAIT,
QUEUE_RIGHT_TO_MID,
WAIT,
QUEUE_MID_TO_LEFT,
WAIT,
HEAD_UP,
WAIT,
QUEUE_LEFT_TO_MID,
HEAD_LEFT,
WAIT,
QUEUE_MID_TO_RIGHT,
]
# cf render_braille() docstring for coordinates convention
class PetitChat(Static):
def __init__(self, animate: bool = True, **kwargs: Any) -> None:
super().__init__(**kwargs, classes="banner-chat")
self._dots = {1j * y + x for y, row in enumerate(STARTING_DOTS) for x in row}
self._transition_index = 0
self._do_animate = animate
self._freeze_requested = False
self._timer: Timer | None = None
def compose(self) -> ComposeResult:
yield Static(render_braille(self._dots, WIDTH, HEIGHT), classes="petit-chat")
def on_mount(self) -> None:
self._inner = self.query_one(".petit-chat", Static)
if self._do_animate:
self._timer = self.set_interval(0.16, self._apply_next_transition)
def freeze_animation(self) -> None:
self._freeze_requested = True
def _apply_next_transition(self) -> None:
if self._freeze_requested and self._transition_index == 0:
if self._timer:
self._timer.stop()
self._timer = None
return
transition = TRANSITIONS[self._transition_index]
self._dots -= transition["remove"]
self._dots |= transition["add"]
self._transition_index = (self._transition_index + 1) % len(TRANSITIONS)
self._inner.update(render_braille(self._dots, WIDTH, HEIGHT))

View file

@ -0,0 +1,58 @@
from __future__ import annotations
from collections.abc import Iterable
import math
# for more details on braille characters encoding, see: https://en.wikipedia.org/wiki/Braille_Patterns
_BRAILLE_DOT_COUNT = 8
def _braille_dot_index(x: int, y: int) -> int:
"""returns the number associated with a dot in a braille character
x {0, 1}, y {0, 1, 2, 3}
-x->
| 1 4
y 2 5
| 3 6
V 7 8
"""
if y < 3: # noqa: PLR2004
return y + 1 + 3 * x
return 7 + x
def _braille_char_from_dot_indices(indices: list[int]) -> str:
if any(n < 1 or n > _BRAILLE_DOT_COUNT for n in indices):
raise ValueError(f"Invalid braille dot indices: {indices}")
return chr(0x2800 + sum(2 ** (d - 1) for d in indices)) if indices else " "
def render_braille(dot_coords: Iterable[complex], width: int, height: int) -> str:
"""this function receives a list of dot coordinantes, a width and a height,
and returns a string representing these dots with braille characters.
Origin is (0,0) and is located at the top left:
0----x---->
|
y
|
V
"""
dots_matrix: list[list[list[int]]] = [
[[] for _ in range(math.ceil(width / 2))] for _ in range(math.ceil(height / 4))
] # the list of dots for each character in the final str
for coord in dot_coords:
x = int(coord.real // 2)
y = int(coord.imag // 4)
sub_x = int(coord.real) % 2
sub_y = int(coord.imag) % 4
dots_matrix[y][x].append(_braille_dot_index(sub_x, sub_y))
braille_chars = [
[_braille_char_from_dot_indices(char_dots) for char_dots in row]
for row in dots_matrix
]
return "\n".join("".join(row) for row in braille_chars)

View file

@ -20,10 +20,16 @@ class ChatInputBody(Widget):
self.value = value
super().__init__()
def __init__(self, history_file: Path | None = None, **kwargs: Any) -> None:
def __init__(
self,
history_file: Path | None = None,
nuage_enabled: bool = False,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.input_widget: ChatTextArea | None = None
self.prompt_widget: NoMarkupStatic | None = None
self._nuage_enabled = nuage_enabled
if history_file:
self.history = HistoryManager(history_file)
@ -37,7 +43,9 @@ class ChatInputBody(Widget):
self.prompt_widget = NoMarkupStatic(">", id="prompt")
yield self.prompt_widget
self.input_widget = ChatTextArea(placeholder="Ask anything...", id="input")
self.input_widget = ChatTextArea(
id="input", nuage_enabled=self._nuage_enabled
)
yield self.input_widget
def on_mount(self) -> None:
@ -49,6 +57,8 @@ class ChatInputBody(Widget):
return "!", text[1:]
elif text.startswith("/"):
return "/", text[1:]
elif text.startswith("&") and self._nuage_enabled:
return "&", text[1:]
else:
return ">", text

View file

@ -40,14 +40,18 @@ class ChatInputContainer(Vertical):
history_file: Path | None = None,
command_registry: CommandRegistry | None = None,
safety: AgentSafety = AgentSafety.NEUTRAL,
agent_name: str = "",
skill_entries_getter: Callable[[], list[tuple[str, str]]] | None = None,
nuage_enabled: bool = False,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self._history_file = history_file
self._command_registry = command_registry or CommandRegistry()
self._safety = safety
self._agent_name = agent_name
self._skill_entries_getter = skill_entries_getter
self._nuage_enabled = nuage_enabled
self._completion_manager = MultiCompletionManager([
SlashCommandController(CommandCompleter(self._get_slash_entries), self),
@ -71,8 +75,13 @@ class ChatInputContainer(Vertical):
yield self._completion_popup
border_class = SAFETY_BORDER_CLASSES.get(self._safety, "")
with Vertical(id=self.ID_INPUT_BOX, classes=border_class):
self._body = ChatInputBody(history_file=self._history_file, id="input-body")
with Vertical(id=self.ID_INPUT_BOX, classes=border_class) as input_box:
input_box.border_title = self._agent_name
self._body = ChatInputBody(
history_file=self._history_file,
id="input-body",
nuage_enabled=self._nuage_enabled,
)
yield self._body
@ -175,3 +184,12 @@ class ChatInputContainer(Vertical):
if safety in SAFETY_BORDER_CLASSES:
input_box.add_class(SAFETY_BORDER_CLASSES[safety])
def set_agent_name(self, name: str) -> None:
self._agent_name = name
try:
input_box = self.get_widget_by_id(self.ID_INPUT_BOX)
input_box.border_title = name
except Exception:
pass

View file

@ -13,7 +13,7 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
MultiCompletionManager,
)
InputMode = Literal["!", "/", ">"]
InputMode = Literal["!", "/", ">", "&"]
class ChatTextArea(TextArea):
@ -28,7 +28,6 @@ class ChatTextArea(TextArea):
Binding("ctrl+g", "open_external_editor", "External Editor", show=False),
]
MODE_CHARACTERS: ClassVar[set[Literal["!", "/"]]] = {"!", "/"}
DEFAULT_MODE: ClassVar[Literal[">"]] = ">"
class Submitted(Message):
@ -50,14 +49,15 @@ class ChatTextArea(TextArea):
"""Message sent when history navigation should be reset."""
class ModeChanged(Message):
"""Message sent when the input mode changes (>, !, /)."""
"""Message sent when the input mode changes (>, !, /, &)."""
def __init__(self, mode: InputMode) -> None:
self.mode = mode
super().__init__()
def __init__(self, **kwargs: Any) -> None:
def __init__(self, nuage_enabled: bool = False, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._nuage_enabled = nuage_enabled
self._input_mode: InputMode = self.DEFAULT_MODE
self._history_prefix: str | None = None
self._last_text = ""
@ -213,7 +213,7 @@ class ChatTextArea(TextArea):
if (
event.character
and event.character in self.MODE_CHARACTERS
and event.character in self.mode_characters
and not self.text
and self._input_mode == self.DEFAULT_MODE
):
@ -328,7 +328,14 @@ class ChatTextArea(TextArea):
return self.get_cursor_offset() + self._get_mode_prefix_length()
def _get_mode_prefix_length(self) -> int:
return {">": 0, "/": 1, "!": 1}[self._input_mode]
return {">": 0, "/": 1, "!": 1, "&": 1}[self._input_mode]
@property
def mode_characters(self) -> set[InputMode]:
chars: set[InputMode] = {"!", "/"}
if self._nuage_enabled:
chars.add("&")
return chars
@property
def input_mode(self) -> InputMode:

View file

@ -7,19 +7,13 @@ 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.widgets import Static
from vibe.cli.textual_ui.terminal_theme import TERMINAL_THEME_NAME
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
_ALL_THEMES = [TERMINAL_THEME_NAME] + sorted(
k for k in BUILTIN_THEMES if k != "textual-ansi"
)
class SettingDefinition(TypedDict):
key: str
@ -46,22 +40,16 @@ class ConfigApp(Container):
self.value = value
class ConfigClosed(Message):
def __init__(self, changes: dict[str, str]) -> None:
def __init__(self, changes: dict[str, str | bool]) -> None:
super().__init__()
self.changes = changes
def __init__(self, config: VibeConfig, *, has_terminal_theme: bool = False) -> None:
def __init__(self, config: VibeConfig) -> None:
super().__init__(id="config-app")
self.config = config
self.selected_index = 0
self.changes: dict[str, str] = {}
themes = (
_ALL_THEMES
if has_terminal_theme
else [t for t in _ALL_THEMES if t != TERMINAL_THEME_NAME]
)
self.settings: list[SettingDefinition] = [
{
"key": "active_model",
@ -70,10 +58,10 @@ class ConfigApp(Container):
"options": [m.alias for m in self.config.models],
},
{
"key": "textual_theme",
"label": "Theme",
"key": "autocopy_to_clipboard",
"label": "Auto-copy",
"type": "cycle",
"options": themes,
"options": ["On", "Off"],
},
]
@ -104,6 +92,15 @@ class ConfigApp(Container):
self._update_display()
self.focus()
def _get_display_value(self, setting: SettingDefinition) -> str:
key = setting["key"]
if key in self.changes:
return self.changes[key]
raw_value = getattr(self.config, key, "")
if isinstance(raw_value, bool):
return "On" if raw_value else "Off"
return str(raw_value)
def _update_display(self) -> None:
for i, (setting, widget) in enumerate(
zip(self.settings, self.setting_widgets, strict=True)
@ -112,9 +109,7 @@ class ConfigApp(Container):
cursor = " " if is_selected else " "
label: str = setting["label"]
value: str = self.changes.get(
setting["key"], getattr(self.config, setting["key"], "")
)
value: str = self._get_display_value(setting)
text = f"{cursor}{label}: {value}"
@ -140,7 +135,7 @@ class ConfigApp(Container):
def action_toggle_setting(self) -> None:
setting = self.settings[self.selected_index]
key: str = setting["key"]
current: str = self.changes.get(key, getattr(self.config, key)) or ""
current: str = self._get_display_value(setting)
options: list[str] = setting["options"]
new_value = ""
@ -160,8 +155,17 @@ class ConfigApp(Container):
def action_cycle(self) -> None:
self.action_toggle_setting()
def _convert_changes_for_save(self) -> dict[str, str | bool]:
result: dict[str, str | bool] = {}
for key, value in self.changes.items():
if value in {"On", "Off"}:
result[key] = value == "On"
else:
result[key] = value
return result
def action_close(self) -> None:
self.post_message(self.ConfigClosed(changes=self.changes.copy()))
self.post_message(self.ConfigClosed(changes=self._convert_changes_for_save()))
def on_blur(self, event: events.Blur) -> None:
self.call_after_refresh(self.focus)

View file

@ -0,0 +1,43 @@
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.message import Message
from textual.widgets import Button, Static
class HistoryLoadMoreRequested(Message):
pass
class HistoryLoadMoreMessage(Static):
def __init__(self) -> None:
super().__init__()
self.add_class("history-load-more-message")
self._label_widget: Button | None = None
self._remaining: int | None = None
def compose(self) -> ComposeResult:
with Horizontal(classes="history-load-more-container"):
self._label_widget = Button(
self._label_text(), classes="history-load-more-button"
)
yield self._label_widget
def _label_text(self) -> str:
if self._remaining is None:
return "Load more messages"
return f"Load more messages ({self._remaining})"
def set_enabled(self, enabled: bool) -> None:
if self._label_widget:
self._label_widget.disabled = not enabled
def set_remaining(self, remaining: int | None) -> None:
self._remaining = remaining
if self._label_widget:
self._label_widget.label = self._label_text()
def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop()
self.post_message(HistoryLoadMoreRequested())

View file

@ -15,9 +15,21 @@ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
def _format_elapsed(seconds: int) -> str:
if seconds < 60: # noqa: PLR2004
return f"{seconds}s"
minutes, secs = divmod(seconds, 60)
if minutes < 60: # noqa: PLR2004
return f"{minutes}m{secs}s"
hours, mins = divmod(minutes, 60)
return f"{hours}h{mins}m{secs}s"
class LoadingWidget(SpinnerMixin, Static):
TARGET_COLORS = ("#FFD800", "#FFAF00", "#FF8205", "#FA500F", "#E10500")
SPINNER_TYPE = SpinnerType.BRAILLE
SPINNER_TYPE = SpinnerType.SNAKE
EASTER_EGGS: ClassVar[list[str]] = [
"Eating a chocolatine",
@ -173,7 +185,9 @@ class LoadingWidget(SpinnerMixin, Static):
elapsed = int(time() - self.start_time - paused)
if elapsed != self._last_elapsed:
self._last_elapsed = elapsed
self.hint_widget.update(f"({elapsed}s esc to interrupt)")
self.hint_widget.update(
f"({_format_elapsed(elapsed)} esc to interrupt)"
)
@contextmanager

View file

@ -4,9 +4,10 @@ from typing import Any
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Markdown, Static
from textual.widgets import Static
from textual.widgets._markdown import MarkdownStream
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
@ -42,7 +43,6 @@ class UserMessage(Static):
def compose(self) -> ComposeResult:
with Horizontal(classes="user-message-container"):
yield NonSelectableStatic("> ", classes="user-message-prompt")
yield NoMarkupStatic(self._content, classes="user-message-content")
if self._pending:
self.add_class("pending")
@ -66,6 +66,7 @@ class StreamingMessageBase(Static):
self._content = content
self._markdown: Markdown | None = None
self._stream: MarkdownStream | None = None
self._content_initialized = False
def _get_markdown(self) -> Markdown:
if self._markdown is None:
@ -89,6 +90,8 @@ class StreamingMessageBase(Static):
await stream.write(content)
async def write_initial_content(self) -> None:
if self._content_initialized:
return
if self._content and self._should_write_content():
stream = self._ensure_stream()
await stream.write(self._content)
@ -110,16 +113,15 @@ class AssistantMessage(StreamingMessageBase):
self.add_class("assistant-message")
def compose(self) -> ComposeResult:
with Horizontal(classes="assistant-message-container"):
yield NonSelectableStatic("", classes="assistant-message-dot")
with Vertical(classes="assistant-message-content"):
markdown = Markdown("")
self._markdown = markdown
yield markdown
if self._content:
self._content_initialized = True
markdown = Markdown(self._content)
self._markdown = markdown
yield markdown
class ReasoningMessage(SpinnerMixin, StreamingMessageBase):
SPINNER_TYPE = SpinnerType.LINE
SPINNER_TYPE = SpinnerType.PULSE
SPINNING_TEXT = "Thinking"
COMPLETED_TEXT = "Thought"
@ -207,19 +209,6 @@ class WhatsNewMessage(Static):
yield Markdown(self._content)
class PlanOfferMessage(Static):
def __init__(self, text: str) -> None:
super().__init__()
self.add_class("plan-offer-message")
self._text = text
def compose(self) -> ComposeResult:
yield Markdown(self._text)
def get_text(self) -> str:
return self._text
class InterruptMessage(Static):
def __init__(self) -> None:
super().__init__()
@ -240,30 +229,22 @@ class BashOutputMessage(Static):
self.add_class("bash-output-message")
self._command = command
self._cwd = cwd
self._output = output
self._output = output.rstrip("\n")
self._exit_code = exit_code
def compose(self) -> ComposeResult:
with Vertical(classes="bash-output-container"):
with Horizontal(classes="bash-cwd-line"):
yield NoMarkupStatic(self._cwd, classes="bash-cwd")
yield NoMarkupStatic("", classes="bash-cwd-spacer")
if self._exit_code == 0:
yield NoMarkupStatic("", classes="bash-exit-success")
else:
yield NoMarkupStatic("", classes="bash-exit-failure")
yield NoMarkupStatic(
f" ({self._exit_code})", classes="bash-exit-code"
)
with Horizontal(classes="bash-command-line"):
yield NoMarkupStatic("> ", classes="bash-chevron")
yield NoMarkupStatic(self._command, classes="bash-command")
yield NoMarkupStatic("", classes="bash-command-spacer")
status_class = "bash-success" if self._exit_code == 0 else "bash-error"
self.add_class(status_class)
with Horizontal(classes="bash-command-line"):
yield NonSelectableStatic("$ ", classes=f"bash-prompt {status_class}")
yield NoMarkupStatic(self._command, classes="bash-command")
with Horizontal(classes="bash-output-container"):
yield ExpandingBorder(classes="bash-output-border")
yield NoMarkupStatic(self._output, classes="bash-output")
class ErrorMessage(Static):
def __init__(self, error: str, collapsed: bool = True) -> None:
def __init__(self, error: str, collapsed: bool = False) -> None:
super().__init__()
self.add_class("error-message")
self._error = error
@ -274,22 +255,12 @@ class ErrorMessage(Static):
with Horizontal(classes="error-container"):
yield ExpandingBorder(classes="error-border")
self._content_widget = NoMarkupStatic(
self._get_text(), classes="error-content"
f"Error: {self._error}", classes="error-content"
)
yield self._content_widget
def _get_text(self) -> str:
if self.collapsed:
return "Error. (ctrl+o to expand)"
return f"Error: {self._error}"
def set_collapsed(self, collapsed: bool) -> None:
if self.collapsed == collapsed:
return
self.collapsed = collapsed
if self._content_widget:
self._content_widget.update(self._get_text())
pass
class WarningMessage(Static):

View file

@ -69,26 +69,36 @@ class QuestionApp(Container):
def _current_question(self) -> Question:
return self.questions[self.current_question_idx]
@property
def _has_other(self) -> bool:
return not self._current_question.hide_other
@property
def _total_options(self) -> int:
base = len(self._current_question.options) + 1
base = len(self._current_question.options)
if self._has_other:
base += 1
if self._current_question.multi_select:
return base + 1
base += 1
return base
@property
def _other_option_idx(self) -> int:
if not self._has_other:
return -1
return len(self._current_question.options)
@property
def _submit_option_idx(self) -> int:
if not self._current_question.multi_select:
return -1
return self._other_option_idx + 1
if self._has_other:
return self._other_option_idx + 1
return len(self._current_question.options)
@property
def _is_other_selected(self) -> bool:
return self.selected_option == self._other_option_idx
return self._has_other and self.selected_option == self._other_option_idx
@property
def _is_submit_selected(self) -> bool:
@ -217,6 +227,12 @@ class QuestionApp(Container):
if not self.other_prefix or not self.other_input or not self.other_static:
return
if not self._has_other:
self.other_prefix.display = False
self.other_input.display = False
self.other_static.display = False
return
q = self._current_question
is_multi = q.multi_select
multi_selected = self.multi_selections.get(self.current_question_idx, set())
@ -235,6 +251,7 @@ class QuestionApp(Container):
show_input = is_focused or bool(stored_text)
self.other_prefix.display = True
self.other_input.display = show_input
self.other_static.display = not show_input

View file

@ -2,11 +2,14 @@ from __future__ import annotations
from abc import ABC
from collections.abc import Callable
from enum import Enum
from enum import Enum, auto
import random
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, runtime_checkable
from textual.timer import Timer
from vibe.cli.textual_ui.widgets.braille_renderer import render_braille
if TYPE_CHECKING:
from textual.widgets import Static
@ -51,47 +54,84 @@ class BrailleSpinner(Spinner):
)
class LineSpinner(Spinner):
FRAMES: ClassVar[tuple[str, ...]] = ("|", "/", "-", "\\")
class CircleSpinner(Spinner):
FRAMES: ClassVar[tuple[str, ...]] = ("", "", "", "")
class BowtieSpinner(Spinner):
class PulseSpinner(Spinner):
FRAMES: ClassVar[tuple[str, ...]] = (
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
)
class DotWaveSpinner(Spinner):
FRAMES: ClassVar[tuple[str, ...]] = ("", "", "", "", "", "", "", "")
class SpinnerType(Enum):
BRAILLE = "braille"
LINE = "line"
CIRCLE = "circle"
BOWTIE = "bowtie"
DOT_WAVE = "dot_wave"
BRAILLE = auto()
PULSE = auto()
SNAKE = auto()
class SnakeSpinner(Spinner):
MAP_WIDTH: ClassVar[int] = 4
MAP_HEIGHT: ClassVar[int] = 4
SNAKE_LENGTH: ClassVar[int] = 3
def __init__(self) -> None:
self._positions: list[complex] = [1, 0, 1j]
super().__init__()
@property
def current_direction(self) -> complex:
return self._positions[0] - self._positions[1]
def _is_in_bounds(self, position: complex) -> bool:
return (
0 <= position.real < self.MAP_WIDTH and 0 <= position.imag < self.MAP_HEIGHT
)
def _get_direction(self) -> complex:
if (
len(set(z.real for z in self._positions)) > 1
and len(set(z.imag for z in self._positions)) > 1
and self._is_in_bounds(self._positions[0] + self.current_direction)
):
return self.current_direction
valid_directions = []
for rotation in [1, 1j, -1j]:
offset = rotation * self.current_direction
new_position = self._positions[0] + offset
if self._is_in_bounds(new_position) and new_position not in self._positions:
valid_directions.append(offset)
return random.choice(valid_directions)
def _next_positions(self) -> list[complex]:
if len(self._positions) > self.SNAKE_LENGTH:
return self._positions[: self.SNAKE_LENGTH]
head_position = self._positions[0]
direction = self._get_direction()
if self.current_direction != direction:
return [head_position + direction] + self._positions
return [head_position + direction] + self._positions[:-1]
def current_frame(self) -> str:
return render_braille(self._positions, self.MAP_WIDTH, self.MAP_HEIGHT)
def next_frame(self) -> str:
self._positions = self._next_positions()
return self.current_frame()
def reset(self) -> None:
self._positions = [1, 0, 1j]
_SPINNER_CLASSES: dict[SpinnerType, type[Spinner]] = {
SpinnerType.BRAILLE: BrailleSpinner,
SpinnerType.LINE: LineSpinner,
SpinnerType.CIRCLE: CircleSpinner,
SpinnerType.BOWTIE: BowtieSpinner,
SpinnerType.DOT_WAVE: DotWaveSpinner,
SpinnerType.PULSE: PulseSpinner,
SpinnerType.SNAKE: SnakeSpinner,
}
@ -101,7 +141,7 @@ def create_spinner(spinner_type: SpinnerType = SpinnerType.BRAILLE) -> Spinner:
class SpinnerMixin:
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.LINE
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.BRAILLE
SPINNING_TEXT: ClassVar[str] = ""
COMPLETED_TEXT: ClassVar[str] = ""

View file

@ -12,7 +12,7 @@ from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
class StatusMessage(SpinnerMixin, NoMarkupStatic):
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.LINE
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.PULSE
def __init__(self, initial_text: str = "", **kwargs: Any) -> None:
self._initial_text = initial_text

View file

@ -0,0 +1,31 @@
from __future__ import annotations
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
class TeleportMessage(StatusMessage):
def __init__(self) -> None:
super().__init__()
self.add_class("teleport-message")
self._status: str = "Teleporting..."
self._final_url: str | None = None
self._error: str | None = None
def get_content(self) -> str:
if self._error:
return f"Teleport failed: {self._error}"
if self._final_url:
return f"Teleported to Nuage: {self._final_url}"
return self._status
def set_status(self, status: str) -> None:
self._status = status
self.update_display()
def set_complete(self, url: str) -> None:
self._final_url = url
self.stop_spinning(success=True)
def set_error(self, error: str) -> None:
self._error = error
self.stop_spinning(success=False)

View file

@ -6,10 +6,10 @@ from pathlib import Path
from pydantic import BaseModel
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.widgets import Markdown, Static
from textual.widgets import 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.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS
from vibe.core.tools.builtins.ask_user_question import AskUserQuestionResult
from vibe.core.tools.builtins.bash import BashArgs, BashResult
from vibe.core.tools.builtins.grep import GrepArgs, GrepResult
@ -23,13 +23,13 @@ from vibe.core.tools.builtins.todo import TodoArgs, TodoResult
from vibe.core.tools.builtins.write_file import WriteFileArgs, WriteFileResult
def _truncate_lines(content: str, max_lines: int) -> str:
"""Truncate content to max_lines, adding indicator if truncated."""
def _truncate_lines(content: str, max_lines: int) -> tuple[str, str | None]:
"""Truncate content to max_lines, returning (content, truncation_info)."""
lines = content.split("\n")
if len(lines) <= max_lines:
return content
return content, None
remaining = len(lines) - max_lines
return "\n".join(lines[:max_lines] + [f"… ({remaining} more lines)"])
return "\n".join(lines[:max_lines]), f"… ({remaining} more lines)"
def parse_search_replace_to_diff(content: str) -> list[str]:
@ -90,8 +90,6 @@ class ToolApprovalWidget[TArgs: BaseModel](Vertical):
class ToolResultWidget[TResult: BaseModel](Static):
"""Base class for result widgets with typed result."""
SHORTCUT = DEFAULT_TOOL_SHORTCUT
def __init__(
self,
result: TResult | None,
@ -108,21 +106,13 @@ class ToolResultWidget[TResult: BaseModel](Static):
self.warnings = warnings or []
self.add_class("tool-result-widget")
def _hint(self) -> str:
action = "expand" if self.collapsed else "collapse"
return f"({self.SHORTCUT} to {action})"
def _header(self) -> ComposeResult:
"""Yield the standard header. Subclasses can call this then add content."""
if self.collapsed:
yield NoMarkupStatic(f"{self.message} {self._hint()}")
else:
yield NoMarkupStatic(self.message)
def _footer(self, extra: str | None = None) -> ComposeResult:
"""Yield the footer with optional extra info."""
if extra:
yield NoMarkupStatic(extra, classes="tool-result-hint")
def compose(self) -> ComposeResult:
"""Default: show message and optionally result fields."""
yield from self._header()
"""Default: show result fields."""
if not self.collapsed and self.result:
for field_name in type(self.result).model_fields:
value = getattr(self.result, field_name)
@ -130,6 +120,7 @@ class ToolResultWidget[TResult: BaseModel](Static):
yield NoMarkupStatic(
f"{field_name}: {value}", classes="tool-result-detail"
)
yield from self._footer()
class BashApprovalWidget(ToolApprovalWidget[BashArgs]):
@ -139,8 +130,17 @@ class BashApprovalWidget(ToolApprovalWidget[BashArgs]):
class BashResultWidget(ToolResultWidget[BashResult]):
def compose(self) -> ComposeResult:
yield from self._header()
if self.collapsed or not self.result:
if not self.result:
yield from self._footer()
return
if self.collapsed:
truncation_info = None
if self.result.stdout:
content, truncation_info = _truncate_lines(self.result.stdout, 10)
yield NoMarkupStatic(content, classes="tool-result-detail")
else:
yield NoMarkupStatic("(no content)", classes="tool-result-detail")
yield from self._footer(truncation_info)
return
yield NoMarkupStatic(
f"returncode: {self.result.returncode}", classes="tool-result-detail"
@ -155,6 +155,7 @@ class BashResultWidget(ToolResultWidget[BashResult]):
yield NoMarkupStatic(
f"stderr:{sep}{self.result.stderr}", classes="tool-result-detail"
)
yield from self._footer()
class WriteFileApprovalWidget(ToolApprovalWidget[WriteFileArgs]):
@ -169,8 +170,16 @@ class WriteFileApprovalWidget(ToolApprovalWidget[WriteFileArgs]):
class WriteFileResultWidget(ToolResultWidget[WriteFileResult]):
def compose(self) -> ComposeResult:
yield from self._header()
if self.collapsed or not self.result:
if not self.result:
yield from self._footer()
return
ext = Path(self.result.path).suffix.lstrip(".") or "text"
if self.collapsed:
truncation_info = None
if self.result.content:
content, truncation_info = _truncate_lines(self.result.content, 10)
yield Markdown(f"```{ext}\n{content}\n```")
yield from self._footer(truncation_info)
return
yield NoMarkupStatic(f"Path: {self.result.path}", classes="tool-result-detail")
yield NoMarkupStatic(
@ -178,8 +187,9 @@ class WriteFileResultWidget(ToolResultWidget[WriteFileResult]):
)
if self.result.content:
yield NoMarkupStatic("")
ext = Path(self.result.path).suffix.lstrip(".") or "text"
yield Markdown(f"```{ext}\n{_truncate_lines(self.result.content, 10)}\n```")
content, _ = _truncate_lines(self.result.content, 10)
yield Markdown(f"```{ext}\n{content}\n```")
yield from self._footer()
class SearchReplaceApprovalWidget(ToolApprovalWidget[SearchReplaceArgs]):
@ -196,23 +206,15 @@ class SearchReplaceApprovalWidget(ToolApprovalWidget[SearchReplaceArgs]):
class SearchReplaceResultWidget(ToolResultWidget[SearchReplaceResult]):
def compose(self) -> ComposeResult:
yield from self._header()
if self.collapsed or not self.result:
if not self.result:
yield from self._footer()
return
yield NoMarkupStatic(f"File: {self.result.file}", classes="tool-result-detail")
yield NoMarkupStatic(
f"Blocks applied: {self.result.blocks_applied}",
classes="tool-result-detail",
)
yield NoMarkupStatic(
f"Lines changed: {self.result.lines_changed}", classes="tool-result-detail"
)
for warning in self.result.warnings:
for warning in self.warnings:
yield NoMarkupStatic(f"{warning}", classes="tool-result-warning")
if self.result.content:
yield NoMarkupStatic("")
for line in parse_search_replace_to_diff(self.result.content):
yield render_diff_line(line)
yield from self._footer()
class TodoApprovalWidget(ToolApprovalWidget[TodoArgs]):
@ -227,41 +229,30 @@ class TodoApprovalWidget(ToolApprovalWidget[TodoArgs]):
class TodoResultWidget(ToolResultWidget[TodoResult]):
SHORTCUT = TOOL_SHORTCUTS["todo"]
def compose(self) -> ComposeResult:
if self.collapsed:
yield NoMarkupStatic(f"{self.message} {self._hint()}")
else:
yield NoMarkupStatic(f"{self.message} {self._hint()}")
yield NoMarkupStatic("")
if not self.result or not self.result.todos:
yield NoMarkupStatic("No todos", classes="todo-empty")
yield from self._footer()
return
if not self.result or not self.result.todos:
yield NoMarkupStatic("No todos", classes="todo-empty")
return
by_status: dict[str, list] = {
"in_progress": [],
"pending": [],
"completed": [],
"cancelled": [],
}
for todo in self.result.todos:
status = (
todo.status.value if hasattr(todo.status, "value") else str(todo.status)
)
if status in by_status:
by_status[status].append(todo)
# Group todos by status
by_status: dict[str, list] = {
"in_progress": [],
"pending": [],
"completed": [],
"cancelled": [],
}
for todo in self.result.todos:
status = (
todo.status.value
if hasattr(todo.status, "value")
else str(todo.status)
)
if status in by_status:
by_status[status].append(todo)
for status in ["in_progress", "pending", "completed", "cancelled"]:
for todo in by_status[status]:
icon = self._get_status_icon(status)
yield NoMarkupStatic(
f"{icon} {todo.content}", classes=f"todo-{status}"
)
for status in ["in_progress", "pending", "completed", "cancelled"]:
for todo in by_status[status]:
icon = self._get_status_icon(status)
yield NoMarkupStatic(f"{icon} {todo.content}", classes=f"todo-{status}")
yield from self._footer()
def _get_status_icon(self, status: str) -> str:
icons = {"pending": "", "in_progress": "", "completed": "", "cancelled": ""}
@ -283,8 +274,8 @@ class ReadFileApprovalWidget(ToolApprovalWidget[ReadFileArgs]):
class ReadFileResultWidget(ToolResultWidget[ReadFileResult]):
def compose(self) -> ComposeResult:
yield from self._header()
if self.collapsed:
yield from self._footer()
return
if self.result:
yield NoMarkupStatic(
@ -292,10 +283,13 @@ class ReadFileResultWidget(ToolResultWidget[ReadFileResult]):
)
for warning in self.warnings:
yield NoMarkupStatic(f"{warning}", classes="tool-result-warning")
truncation_info = None
if self.result and self.result.content:
yield NoMarkupStatic("")
ext = Path(self.result.path).suffix.lstrip(".") or "text"
yield Markdown(f"```{ext}\n{_truncate_lines(self.result.content, 10)}\n```")
content, truncation_info = _truncate_lines(self.result.content, 10)
yield Markdown(f"```{ext}\n{content}\n```")
yield from self._footer(truncation_info)
class GrepApprovalWidget(ToolApprovalWidget[GrepArgs]):
@ -312,20 +306,24 @@ class GrepApprovalWidget(ToolApprovalWidget[GrepArgs]):
class GrepResultWidget(ToolResultWidget[GrepResult]):
def compose(self) -> ComposeResult:
yield from self._header()
if self.collapsed:
return
for warning in self.warnings:
yield NoMarkupStatic(f"{warning}", classes="tool-result-warning")
if self.result and self.result.matches:
yield NoMarkupStatic("")
yield Markdown(f"```\n{_truncate_lines(self.result.matches, 30)}\n```")
if not self.result or not self.result.matches:
yield from self._footer()
return
max_lines = 10 if self.collapsed else None
if max_lines:
content, truncation_info = _truncate_lines(self.result.matches, max_lines)
else:
content, truncation_info = self.result.matches, None
yield NoMarkupStatic(content, classes="tool-result-detail")
yield from self._footer(truncation_info)
class AskUserQuestionResultWidget(ToolResultWidget[AskUserQuestionResult]):
def compose(self) -> ComposeResult:
if self.collapsed or not self.result:
yield from self._header()
yield from self._footer()
return
for answer in self.result.answers:
@ -333,6 +331,7 @@ class AskUserQuestionResultWidget(ToolResultWidget[AskUserQuestionResult]):
yield NoMarkupStatic(answer.question, classes="tool-result-detail")
prefix = "(Other) " if answer.is_other else ""
yield NoMarkupStatic(f"{prefix}{answer.answer}", classes="ask-user-answer")
yield from self._footer()
APPROVAL_WIDGETS: dict[str, type[ToolApprovalWidget]] = {

View file

@ -8,7 +8,6 @@ from vibe.cli.textual_ui.widgets.messages import ExpandingBorder, NonSelectableS
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
from vibe.cli.textual_ui.widgets.tool_widgets import get_result_widget
from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS
from vibe.core.tools.ui import ToolUIDataAdapter
from vibe.core.types import ToolCallEvent, ToolResultEvent
@ -44,6 +43,14 @@ class ToolCallMessage(StatusMessage):
self._stream_widget.display = False
yield self._stream_widget
def on_mount(self) -> None:
siblings = list(self.parent.children) if self.parent else []
idx = siblings.index(self) if self in siblings else -1
if idx > 0 and isinstance(
siblings[idx - 1], (ToolCallMessage, ToolResultMessage)
):
self.add_class("no-gap")
def get_content(self) -> str:
if self._event and self._event.tool_class:
adapter = ToolUIDataAdapter(self._event.tool_class)
@ -63,6 +70,10 @@ class ToolCallMessage(StatusMessage):
self._stream_widget.display = False
super().stop_spinning(success)
def set_result_text(self, text: str) -> None:
if self._text_widget:
self._text_widget.update(text)
class ToolResultMessage(Static):
def __init__(
@ -91,13 +102,6 @@ class ToolResultMessage(Static):
def tool_name(self) -> str:
return self._tool_name
def _shortcut(self) -> str:
return TOOL_SHORTCUTS.get(self._tool_name, DEFAULT_TOOL_SHORTCUT)
def _hint(self) -> str:
action = "expand" if self.collapsed else "collapse"
return f"({self._shortcut()} to {action})"
def compose(self) -> ComposeResult:
with Horizontal(classes="tool-result-container"):
yield ExpandingBorder(classes="tool-result-border")
@ -108,6 +112,8 @@ class ToolResultMessage(Static):
if self._call_widget:
success = self._determine_success()
self._call_widget.stop_spinning(success=success)
result_text = self._get_result_text()
self._call_widget.set_result_text(result_text)
await self._render_result()
def _determine_success(self) -> bool:
@ -121,6 +127,23 @@ class ToolResultMessage(Static):
return display.success
return True
def _get_result_text(self) -> str:
if self._event is None:
return f"{self._tool_name} completed"
if self._event.error:
return f"{self._tool_name}: error"
if self._event.skipped:
return f"{self._tool_name}: skipped"
if self._event.tool_class:
adapter = ToolUIDataAdapter(self._event.tool_class)
display = adapter.get_result_display(self._event)
return display.message
return f"{self._tool_name} completed"
async def _render_result(self) -> None:
if self._content_container is None:
return
@ -128,39 +151,29 @@ class ToolResultMessage(Static):
await self._content_container.remove_children()
if self._event is None:
await self._render_simple()
self.display = False
return
if self._event.error:
self.add_class("error-text")
if self.collapsed:
await self._content_container.mount(
NoMarkupStatic(f"Error. {self._hint()}")
)
else:
await self._content_container.mount(
NoMarkupStatic(f"Error: {self._event.error}")
)
await self._content_container.mount(
NoMarkupStatic(f"Error: {self._event.error}")
)
self.display = True
return
if self._event.skipped:
self.add_class("warning-text")
reason = self._event.skip_reason or "User skipped"
if self.collapsed:
await self._content_container.mount(
NoMarkupStatic(f"Skipped. {self._hint()}")
)
else:
await self._content_container.mount(
NoMarkupStatic(f"Skipped: {reason}")
)
await self._content_container.mount(NoMarkupStatic(f"Skipped: {reason}"))
self.display = True
return
self.remove_class("error-text")
self.remove_class("warning-text")
if self._event.tool_class is None:
await self._render_simple()
self.display = False
return
adapter = ToolUIDataAdapter(self._event.tool_class)
@ -175,23 +188,7 @@ class ToolResultMessage(Static):
warnings=display.warnings,
)
await self._content_container.mount(widget)
async def _render_simple(self) -> None:
if self._content_container is None:
return
if self.collapsed:
await self._content_container.mount(
NoMarkupStatic(f"{self._tool_name} completed {self._hint()}")
)
return
if self._content:
await self._content_container.mount(NoMarkupStatic(self._content))
else:
await self._content_container.mount(
NoMarkupStatic(f"{self._tool_name} completed.")
)
self.display = bool(widget.children)
async def set_collapsed(self, collapsed: bool) -> None:
if self.collapsed == collapsed:

View file

@ -1,4 +0,0 @@
from __future__ import annotations
TOOL_SHORTCUTS = {"todo": "ctrl+t"}
DEFAULT_TOOL_SHORTCUT = "ctrl+o"

View file

@ -1,283 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from time import monotonic
from rich.align import Align
from rich.console import Group
from rich.text import Text
from textual.color import Color
from textual.widgets import Static
from vibe import __version__
from vibe.core.config import VibeConfig
def hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
normalized = hex_color.lstrip("#")
r, g, b = (int(normalized[i : i + 2], 16) for i in (0, 2, 4))
return (r, g, b)
def rgb_to_hex(r: int, g: int, b: int) -> str:
return f"#{r:02x}{g:02x}{b:02x}"
def interpolate_color(
start_rgb: tuple[int, int, int], end_rgb: tuple[int, int, int], progress: float
) -> str:
progress = max(0.0, min(1.0, progress))
r = int(start_rgb[0] + (end_rgb[0] - start_rgb[0]) * progress)
g = int(start_rgb[1] + (end_rgb[1] - start_rgb[1]) * progress)
b = int(start_rgb[2] + (end_rgb[2] - start_rgb[2]) * progress)
return rgb_to_hex(r, g, b)
@dataclass
class LineAnimationState:
progress: float = 0.0
cached_color: str | None = None
cached_progress: float = -1.0
rendered_color: str | None = None
class WelcomeBanner(Static):
FLASH_COLOR = "#FFFFFF"
TARGET_COLORS = ("#FFD800", "#FFAF00", "#FF8205", "#FA500F", "#E10500")
BORDER_TARGET_COLOR = "#b05800"
LINE_ANIMATION_DURATION_MS = 200
LINE_STAGGER_MS = 280
FLASH_RESET_DURATION_MS = 400
ANIMATION_TICK_INTERVAL = 0.1
COLOR_FLASH_MIDPOINT = 0.5
COLOR_PHASE_SCALE = 2.0
COLOR_CACHE_THRESHOLD = 0.001
BORDER_PROGRESS_THRESHOLD = 0.01
BLOCK = "▇▇"
SPACE = " "
LOGO_TEXT_GAP = " "
def __init__(self, config: VibeConfig) -> None:
super().__init__(" ")
self.config = config
self.animation_timer = None
self._animation_start_time: float | None = None
self._cached_skeleton_color: str | None = None
self._cached_skeleton_rgb: tuple[int, int, int] | None = None
self._flash_rgb = hex_to_rgb(self.FLASH_COLOR)
self._target_rgbs = [hex_to_rgb(c) for c in self.TARGET_COLORS]
self._border_target_rgb = hex_to_rgb(self.BORDER_TARGET_COLOR)
self._line_states = [LineAnimationState() for _ in self.TARGET_COLORS]
self.border_progress = 0.0
self._cached_border_color: str | None = None
self._cached_border_progress = -1.0
self._line_duration = self.LINE_ANIMATION_DURATION_MS / 1000
self._line_stagger = self.LINE_STAGGER_MS / 1000
self._border_duration = self.FLASH_RESET_DURATION_MS / 1000
self._line_start_times = [
idx * self._line_stagger for idx in range(len(self.TARGET_COLORS))
]
self._all_lines_finish_time = (
(len(self.TARGET_COLORS) - 1) * self.LINE_STAGGER_MS
+ self.LINE_ANIMATION_DURATION_MS
) / 1000
self._cached_text_lines: list[Text | None] = [None] * 7
self._initialize_static_line_suffixes()
def _initialize_static_line_suffixes(self) -> None:
self._static_line1_suffix = (
f"{self.LOGO_TEXT_GAP}[b]Mistral Vibe v{__version__}[/]"
)
self._static_line2_suffix = (
f"{self.LOGO_TEXT_GAP}[dim]{self.config.active_model}[/]"
)
mcp_count = len(self.config.mcp_servers)
model_count = len(self.config.models)
self._static_line3_suffix = f"{self.LOGO_TEXT_GAP}[dim]{model_count} models · {mcp_count} MCP servers[/]"
self._static_line5_suffix = (
f"{self.LOGO_TEXT_GAP}[dim]{self.config.displayed_workdir or Path.cwd()}[/]"
)
self._static_line7 = f"[dim]Type[/] [{self.BORDER_TARGET_COLOR}]/help[/] [dim]for more information • [/][{self.BORDER_TARGET_COLOR}]/terminal-setup[/][dim] for shift+enter[/]"
@property
def skeleton_color(self) -> str:
return self._cached_skeleton_color or "#1e1e1e"
@property
def skeleton_rgb(self) -> tuple[int, int, int]:
return self._cached_skeleton_rgb or hex_to_rgb("#1e1e1e")
def on_mount(self) -> None:
if not self.config.disable_welcome_banner_animation:
self.call_after_refresh(self._init_after_styles)
def _init_after_styles(self) -> None:
self._cache_skeleton_color()
self._cached_text_lines[5] = Text("")
self._cached_text_lines[6] = Text.from_markup(self._static_line7)
self._update_display()
self._start_animation()
def _cache_skeleton_color(self) -> None:
try:
border = self.styles.border
if (
hasattr(border, "top")
and isinstance(edge := border.top, tuple)
and len(edge) >= 2 # noqa: PLR2004
and isinstance(color := edge[1], Color)
):
self._cached_skeleton_color = color.hex
self._cached_skeleton_rgb = hex_to_rgb(color.hex)
return
except (AttributeError, TypeError):
pass
self._cached_skeleton_color = "#1e1e1e"
self._cached_skeleton_rgb = hex_to_rgb("#1e1e1e")
def _stop_timer(self) -> None:
if self.animation_timer:
try:
self.animation_timer.stop()
except Exception:
pass
self.animation_timer = None
def on_unmount(self) -> None:
self._stop_timer()
def _start_animation(self) -> None:
self._animation_start_time = monotonic()
def tick() -> None:
if self._is_animation_complete():
self._stop_timer()
return
if self._animation_start_time is None:
return
elapsed = monotonic() - self._animation_start_time
updated_lines = self._advance_line_progress(elapsed)
border_updated = self._advance_border_progress(elapsed)
if border_updated:
self._update_border_color()
if updated_lines or border_updated:
self._update_display()
self.animation_timer = self.set_interval(self.ANIMATION_TICK_INTERVAL, tick)
def _advance_line_progress(self, elapsed: float) -> bool:
any_updates = False
for line_idx, state in enumerate(self._line_states):
if state.progress >= 1.0:
continue
start_time = self._line_start_times[line_idx]
if elapsed < start_time:
continue
progress = min(1.0, (elapsed - start_time) / self._line_duration)
if progress > state.progress:
state.progress = progress
any_updates = True
return any_updates
def _advance_border_progress(self, elapsed: float) -> bool:
if elapsed < self._all_lines_finish_time:
return False
new_progress = min(
1.0, (elapsed - self._all_lines_finish_time) / self._border_duration
)
if abs(new_progress - self.border_progress) > self.BORDER_PROGRESS_THRESHOLD:
self.border_progress = new_progress
return True
return False
def _is_animation_complete(self) -> bool:
return (
all(state.progress >= 1.0 for state in self._line_states)
and self.border_progress >= 1.0
)
def _update_border_color(self) -> None:
progress = self.border_progress
if abs(progress - self._cached_border_progress) < self.COLOR_CACHE_THRESHOLD:
return
border_color = self._compute_color_for_progress(
progress, self._border_target_rgb
)
self._cached_border_color = border_color
self._cached_border_progress = progress
self.styles.border = ("round", border_color)
def _compute_color_for_progress(
self, progress: float, target_rgb: tuple[int, int, int]
) -> str:
if progress <= 0:
return self.skeleton_color
if progress <= self.COLOR_FLASH_MIDPOINT:
phase = progress * self.COLOR_PHASE_SCALE
return interpolate_color(self.skeleton_rgb, self._flash_rgb, phase)
phase = (progress - self.COLOR_FLASH_MIDPOINT) * self.COLOR_PHASE_SCALE
return interpolate_color(self._flash_rgb, target_rgb, phase)
def _update_display(self) -> None:
for idx in range(5):
self._update_colored_line(idx, idx)
lines = [line if line else Text("") for line in self._cached_text_lines]
self.update(Align.center(Group(*lines)))
def _get_color(self, line_idx: int) -> str:
state = self._line_states[line_idx]
if (
abs(state.progress - state.cached_progress) < self.COLOR_CACHE_THRESHOLD
and state.cached_color
):
return state.cached_color
color = self._compute_color_for_progress(
state.progress, self._target_rgbs[line_idx]
)
state.cached_color = color
state.cached_progress = state.progress
return color
def _update_colored_line(self, slot_idx: int, line_idx: int) -> None:
color = self._get_color(line_idx)
state = self._line_states[line_idx]
if color == state.rendered_color and self._cached_text_lines[slot_idx]:
return
state.rendered_color = color
self._cached_text_lines[slot_idx] = Text.from_markup(
self._build_line(slot_idx, color)
)
def _build_line(self, line_idx: int, color: str) -> str:
B = self.BLOCK
S = self.SPACE
patterns = [
f"{S}[{color}]{B}[/]{S}{S}{S}[{color}]{B}[/]{S}{self._static_line1_suffix}",
f"{S}[{color}]{B}{B}[/]{S}[{color}]{B}{B}[/]{S}{self._static_line2_suffix}",
f"{S}[{color}]{B}{B}{B}{B}{B}[/]{S}{self._static_line3_suffix}",
f"{S}[{color}]{B}[/]{S}[{color}]{B}[/]{S}[{color}]{B}[/]{S}",
f"[{color}]{B}{B}{B}[/]{S}[{color}]{B}{B}{B}[/]{self._static_line5_suffix}",
]
return patterns[line_idx]

View file

@ -0,0 +1,29 @@
from __future__ import annotations
from vibe.cli.textual_ui.windowing.history import (
build_history_widgets,
non_system_history_messages,
)
from vibe.cli.textual_ui.windowing.history_windowing import (
create_resume_plan,
should_resume_history,
sync_backfill_state,
)
from vibe.cli.textual_ui.windowing.state import (
HISTORY_RESUME_TAIL_MESSAGES,
LOAD_MORE_BATCH_SIZE,
HistoryLoadMoreManager,
SessionWindowing,
)
__all__ = [
"HISTORY_RESUME_TAIL_MESSAGES",
"LOAD_MORE_BATCH_SIZE",
"HistoryLoadMoreManager",
"SessionWindowing",
"build_history_widgets",
"create_resume_plan",
"non_system_history_messages",
"should_resume_history",
"sync_backfill_state",
]

View file

@ -0,0 +1,105 @@
from __future__ import annotations
from weakref import WeakKeyDictionary
from textual.widget import Widget
from vibe.cli.textual_ui.widgets.messages import (
AssistantMessage,
ReasoningMessage,
UserMessage,
)
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
from vibe.core.types import LLMMessage, Role
def non_system_history_messages(messages: list[LLMMessage]) -> list[LLMMessage]:
return [msg for msg in messages if msg.role != Role.system]
def build_tool_call_map(messages: list[LLMMessage]) -> dict[str, str]:
tool_call_map: dict[str, str] = {}
for msg in messages:
if msg.role != Role.assistant or not msg.tool_calls:
continue
for tool_call in msg.tool_calls:
if tool_call.id:
tool_call_map[tool_call.id] = tool_call.function.name or "unknown"
return tool_call_map
def build_history_widgets(
batch: list[LLMMessage],
tool_call_map: dict[str, str],
*,
start_index: int,
tools_collapsed: bool,
history_widget_indices: WeakKeyDictionary[Widget, int],
) -> list[Widget]:
widgets: list[Widget] = []
for offset, msg in enumerate(batch):
history_index = start_index + offset
match msg.role:
case Role.user:
if msg.content:
widget = UserMessage(msg.content)
widgets.append(widget)
history_widget_indices[widget] = history_index
case Role.assistant:
if msg.content:
assistant_widget = AssistantMessage(msg.content)
widgets.append(assistant_widget)
history_widget_indices[assistant_widget] = history_index
if msg.tool_calls:
for tool_call in msg.tool_calls:
tool_name = tool_call.function.name or "unknown"
if tool_call.id:
tool_call_map[tool_call.id] = tool_name
widget = ToolCallMessage(tool_name=tool_name)
widgets.append(widget)
history_widget_indices[widget] = history_index
case Role.tool:
tool_name = msg.name or tool_call_map.get(
msg.tool_call_id or "", "tool"
)
widget = ToolResultMessage(
tool_name=tool_name, content=msg.content, collapsed=tools_collapsed
)
widgets.append(widget)
history_widget_indices[widget] = history_index
return widgets
def split_history_tail(
history_messages: list[LLMMessage], tail_size: int
) -> tuple[list[LLMMessage], list[LLMMessage], int]:
tail_messages = history_messages[-tail_size:]
backfill_messages = history_messages[:-tail_size]
tail_start_index = len(history_messages) - len(tail_messages)
return tail_messages, backfill_messages, tail_start_index
def visible_history_indices(
children: list[Widget], history_widget_indices: WeakKeyDictionary[Widget, int]
) -> list[int]:
return [
idx
for child in children
if (idx := history_widget_indices.get(child)) is not None
]
def visible_history_widgets_count(children: list[Widget]) -> int:
history_widget_types = (
UserMessage,
AssistantMessage,
ReasoningMessage,
ToolCallMessage,
ToolResultMessage,
)
return sum(isinstance(child, history_widget_types) for child in children)

View file

@ -0,0 +1,71 @@
from __future__ import annotations
from dataclasses import dataclass
from weakref import WeakKeyDictionary
from textual.widget import Widget
from vibe.cli.textual_ui.widgets.messages import WhatsNewMessage
from vibe.cli.textual_ui.windowing.history import (
build_tool_call_map,
split_history_tail,
visible_history_indices,
visible_history_widgets_count,
)
from vibe.cli.textual_ui.windowing.state import SessionWindowing
from vibe.core.types import LLMMessage
@dataclass(frozen=True)
class HistoryResumePlan:
tool_call_map: dict[str, str]
tail_messages: list[LLMMessage]
backfill_messages: list[LLMMessage]
tail_start_index: int
@property
def has_backfill(self) -> bool:
return bool(self.backfill_messages)
def should_resume_history(messages_children: list[Widget]) -> bool:
allowed_pre_existing_types = (WhatsNewMessage,)
return all(
isinstance(child, allowed_pre_existing_types) for child in messages_children
)
def create_resume_plan(
history_messages: list[LLMMessage], tail_size: int
) -> HistoryResumePlan | None:
if not history_messages:
return None
tail_messages, backfill_messages, tail_start_index = split_history_tail(
history_messages, tail_size
)
return HistoryResumePlan(
tool_call_map=build_tool_call_map(history_messages),
tail_messages=tail_messages,
backfill_messages=backfill_messages,
tail_start_index=tail_start_index,
)
def sync_backfill_state(
*,
history_messages: list[LLMMessage],
messages_children: list[Widget],
history_widget_indices: WeakKeyDictionary[Widget, int],
windowing: SessionWindowing,
) -> tuple[bool, dict[str, str] | None]:
if not history_messages:
windowing.reset()
return False, None
visible_indices = visible_history_indices(messages_children, history_widget_indices)
visible_history_widgets = visible_history_widgets_count(messages_children)
has_backfill = windowing.recompute_backfill(
history_messages,
visible_indices=visible_indices,
visible_history_widgets_count=visible_history_widgets,
)
return has_backfill, build_tool_call_map(history_messages)

View file

@ -0,0 +1,105 @@
from __future__ import annotations
from dataclasses import dataclass
from textual.widget import Widget
from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreMessage
from vibe.core.types import LLMMessage
HISTORY_RESUME_TAIL_MESSAGES = 20
LOAD_MORE_BATCH_SIZE = 10
@dataclass(frozen=True)
class LoadMoreBatch:
start_index: int
messages: list[LLMMessage]
class SessionWindowing:
def __init__(self, load_more_batch_size: int) -> None:
self.load_more_batch_size = load_more_batch_size
self._backfill_messages: list[LLMMessage] = []
self._backfill_cursor = 0
@property
def remaining(self) -> int:
return self._backfill_cursor
@property
def has_backfill(self) -> bool:
return self._backfill_cursor > 0
def reset(self) -> None:
self._backfill_messages = []
self._backfill_cursor = 0
def set_backfill(self, backfill_messages: list[LLMMessage]) -> None:
self._backfill_messages = backfill_messages
self._backfill_cursor = len(backfill_messages)
def next_load_more_batch(self) -> LoadMoreBatch | None:
if self._backfill_cursor == 0:
return None
start_index = max(self._backfill_cursor - self.load_more_batch_size, 0)
batch = self._backfill_messages[start_index : self._backfill_cursor]
self._backfill_cursor = start_index
if not batch:
return None
return LoadMoreBatch(start_index=start_index, messages=batch)
def recompute_backfill(
self,
history_messages: list[LLMMessage],
visible_indices: list[int],
visible_history_widgets_count: int,
) -> bool:
if not history_messages:
self._backfill_messages = []
self._backfill_cursor = 0
return False
if visible_indices:
backfill_end = min(visible_indices)
else:
backfill_end = max(len(history_messages) - visible_history_widgets_count, 0)
self._backfill_messages = history_messages[:backfill_end]
self._backfill_cursor = len(self._backfill_messages)
return self._backfill_cursor > 0
class HistoryLoadMoreManager:
def __init__(self) -> None:
self.widget: HistoryLoadMoreMessage | None = None
async def show(self, messages_area: Widget, remaining: int) -> None:
if self.widget is None:
widget = HistoryLoadMoreMessage()
await messages_area.mount(widget, before=0)
self.widget = widget
self.set_remaining(remaining)
async def hide(self) -> None:
if self.widget is None:
return
if self.widget.parent:
await self.widget.remove()
self.widget = None
async def set_visible(
self, messages_area: Widget, *, visible: bool, remaining: int
) -> None:
if visible:
await self.show(messages_area, remaining)
return
await self.hide()
def set_enabled(self, enabled: bool) -> None:
if self.widget is None:
return
self.widget.set_enabled(enabled)
def set_remaining(self, remaining: int) -> None:
if self.widget is None:
return
self.widget.set_remaining(remaining)

View file

@ -6,7 +6,7 @@ from enum import StrEnum, auto
from http import HTTPStatus
from threading import Thread
import time
from typing import cast
from typing import TYPE_CHECKING, cast
from uuid import uuid4
from pydantic import BaseModel
@ -75,6 +75,19 @@ from vibe.core.utils import (
is_user_cancellation_event,
)
try:
from vibe.core.teleport.teleport import TeleportService as _TeleportService
_TELEPORT_AVAILABLE = True
except ImportError:
_TELEPORT_AVAILABLE = False
_TeleportService = None
if TYPE_CHECKING:
from vibe.core.teleport.nuage import TeleportSession
from vibe.core.teleport.teleport import TeleportService
from vibe.core.teleport.types import TeleportPushResponseEvent, TeleportYieldEvent
class ToolExecutionResponse(StrEnum):
SKIP = auto()
@ -98,6 +111,10 @@ class AgentLoopLLMResponseError(AgentLoopError):
"""Raised when LLM response is malformed or missing expected data."""
class TeleportError(AgentLoopError):
"""Raised when teleport to Vibe Nuage fails."""
def _should_raise_rate_limit_error(e: Exception) -> bool:
return isinstance(e, BackendError) and e.status == HTTPStatus.TOO_MANY_REQUESTS
@ -134,7 +151,7 @@ class AgentLoop:
self._setup_middleware()
system_prompt = get_universal_system_prompt(
self.tool_manager, config, self.skill_manager, self.agent_manager
self.tool_manager, self.config, self.skill_manager, self.agent_manager
)
self.messages = [LLMMessage(role=Role.system, content=system_prompt)]
@ -156,6 +173,7 @@ class AgentLoop:
self.session_id = str(uuid4())
self.session_logger = SessionLogger(config.session_logging, self.session_id)
self._teleport_service: TeleportService | None = None
thread = Thread(
target=migrate_sessions_entrypoint,
@ -227,6 +245,60 @@ class AgentLoop:
async for event in self._conversation_loop(msg):
yield event
@property
def teleport_service(self) -> TeleportService:
if not _TELEPORT_AVAILABLE:
raise TeleportError(
"Teleport requires git to be installed. "
"Please install git and try again."
)
if self._teleport_service is None:
if _TeleportService is None:
raise TeleportError("_TeleportService is unexpectedly None")
self._teleport_service = _TeleportService(
session_logger=self.session_logger,
nuage_base_url=self.config.nuage_base_url,
nuage_workflow_id=self.config.nuage_workflow_id,
nuage_api_key=self.config.nuage_api_key,
)
return self._teleport_service
def teleport_to_vibe_nuage(
self, prompt: str | None
) -> AsyncGenerator[TeleportYieldEvent, TeleportPushResponseEvent | None]:
from vibe.core.teleport.nuage import TeleportSession
session = TeleportSession(
metadata={
"agent": self.agent_profile.name,
"model": self.config.active_model,
"stats": self.stats.model_dump(),
},
messages=[msg.model_dump(exclude_none=True) for msg in self.messages[1:]],
)
return self._teleport_generator(prompt, session)
async def _teleport_generator(
self, prompt: str | None, session: TeleportSession
) -> AsyncGenerator[TeleportYieldEvent, TeleportPushResponseEvent | None]:
from vibe.core.teleport.errors import ServiceTeleportError
try:
async with self.teleport_service:
gen = self.teleport_service.execute(prompt=prompt, session=session)
response: TeleportPushResponseEvent | None = None
while True:
try:
event = await gen.asend(response)
response = yield event
except StopAsyncIteration:
break
except ServiceTeleportError as e:
raise TeleportError(str(e)) from e
finally:
self._teleport_service = None
def _setup_middleware(self) -> None:
"""Configure middleware pipeline for this conversation."""
self.middleware_pipeline.clear()
@ -575,19 +647,18 @@ class AgentLoop:
try:
start_time = time.perf_counter()
async with self.backend as backend:
result = await backend.complete(
model=active_model,
messages=self.messages,
temperature=active_model.temperature,
tools=available_tools,
tool_choice=tool_choice,
extra_headers={
"user-agent": get_user_agent(provider.backend),
"x-affinity": self.session_id,
},
max_tokens=max_tokens,
)
result = await self.backend.complete(
model=active_model,
messages=self.messages,
temperature=active_model.temperature,
tools=available_tools,
tool_choice=tool_choice,
extra_headers={
"user-agent": get_user_agent(provider.backend),
"x-affinity": self.session_id,
},
max_tokens=max_tokens,
)
end_time = time.perf_counter()
if result.usage is None:
@ -622,28 +693,25 @@ class AgentLoop:
start_time = time.perf_counter()
usage = LLMUsage()
chunk_agg = LLMChunk(message=LLMMessage(role=Role.assistant))
async with self.backend as backend:
async for chunk in backend.complete_streaming(
model=active_model,
messages=self.messages,
temperature=active_model.temperature,
tools=available_tools,
tool_choice=tool_choice,
extra_headers={
"user-agent": get_user_agent(provider.backend),
"x-affinity": self.session_id,
},
max_tokens=max_tokens,
):
processed_message = (
self.format_handler.process_api_response_message(chunk.message)
)
processed_chunk = LLMChunk(
message=processed_message, usage=chunk.usage
)
chunk_agg += processed_chunk
usage += chunk.usage or LLMUsage()
yield processed_chunk
async for chunk in self.backend.complete_streaming(
model=active_model,
messages=self.messages,
temperature=active_model.temperature,
tools=available_tools,
tool_choice=tool_choice,
extra_headers={
"user-agent": get_user_agent(provider.backend),
"x-affinity": self.session_id,
},
max_tokens=max_tokens,
):
processed_message = self.format_handler.process_api_response_message(
chunk.message
)
processed_chunk = LLMChunk(message=processed_message, usage=chunk.usage)
chunk_agg += processed_chunk
usage += chunk.usage or LLMUsage()
yield processed_chunk
end_time = time.perf_counter()
if chunk_agg.usage is None:
@ -851,13 +919,12 @@ class AgentLoop:
active_model = self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model)
async with self.backend as backend:
actual_context_tokens = await backend.count_tokens(
model=active_model,
messages=self.messages,
tools=self.format_handler.get_available_tools(self.tool_manager),
extra_headers={"user-agent": get_user_agent(provider.backend)},
)
actual_context_tokens = await self.backend.count_tokens(
model=active_model,
messages=self.messages,
tools=self.format_handler.get_available_tools(self.tool_manager),
extra_headers={"user-agent": get_user_agent(provider.backend)},
)
self.stats.context_tokens = actual_context_tokens
@ -896,6 +963,12 @@ class AgentLoop:
max_turns: int | None = None,
max_price: float | None = None,
) -> None:
# Force an immediate yield to allow the UI to update before heavy sync work.
# When there are no messages, save_interaction returns early without any await,
# so the coroutine would run synchronously through ToolManager, SkillManager,
# and system prompt generation without yielding control to the event loop.
await asyncio.sleep(0)
await self.session_logger.save_interaction(
self.messages,
self.stats,

View file

@ -0,0 +1,6 @@
from __future__ import annotations
from vibe.core.auth.crypto import EncryptedPayload, decrypt, encrypt
from vibe.core.auth.github import GitHubAuthProvider
__all__ = ["EncryptedPayload", "GitHubAuthProvider", "decrypt", "encrypt"]

137
vibe/core/auth/crypto.py Normal file
View file

@ -0,0 +1,137 @@
from __future__ import annotations
import base64
import binascii
from dataclasses import dataclass
import os
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
_AES_KEY_SIZE = 32
_NONCE_SIZE = 12
_MIN_RSA_KEY_SIZE = 2048
_MAX_ENCRYPTED_KEY_SIZE = 1024
_MAX_CIPHERTEXT_SIZE = 2 * 1024 * 1024 # Workflow transport limit: 2MB
_PAYLOAD_VERSION = 1
_ALG = "RSA-OAEP-SHA256"
_ENC = "A256GCM"
@dataclass(frozen=True)
class EncryptedPayload:
encrypted_key: str
nonce: str
ciphertext: str
version: int | None = None
alg: str | None = None
enc: str | None = None
kid: str | None = None
purpose: str | None = None
def _b64decode_strict(value: str, field_name: str) -> bytes:
try:
return base64.b64decode(value, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError(f"Invalid base64 for {field_name}") from exc
def _validate_payload_lengths(
encrypted_key: bytes, nonce: bytes, ciphertext: bytes
) -> None:
if len(encrypted_key) > _MAX_ENCRYPTED_KEY_SIZE:
raise ValueError("Encrypted key too large")
if len(nonce) != _NONCE_SIZE:
raise ValueError("Invalid nonce size")
if not ciphertext:
raise ValueError("Ciphertext is empty")
if len(ciphertext) > _MAX_CIPHERTEXT_SIZE:
raise ValueError("Ciphertext exceeds maximum allowed size")
def _build_aad(payload: EncryptedPayload) -> bytes | None:
if payload.version is None or payload.version <= 0:
return None
alg = payload.alg or _ALG
enc = payload.enc or _ENC
parts = [f"v={payload.version}", f"alg={alg}", f"enc={enc}"]
if payload.kid:
parts.append(f"kid={payload.kid}")
if payload.purpose:
parts.append(f"purpose={payload.purpose}")
return "|".join(parts).encode("utf-8")
def encrypt(plaintext: str, public_key_pem: bytes) -> EncryptedPayload:
public_key = serialization.load_pem_public_key(public_key_pem)
if not isinstance(public_key, RSAPublicKey):
raise TypeError("Expected RSA public key")
if public_key.key_size < _MIN_RSA_KEY_SIZE:
raise ValueError(f"RSA key size must be at least {_MIN_RSA_KEY_SIZE} bits")
aes_key = os.urandom(_AES_KEY_SIZE)
nonce = os.urandom(_NONCE_SIZE)
payload = EncryptedPayload(
encrypted_key="",
nonce="",
ciphertext="",
version=_PAYLOAD_VERSION,
alg=_ALG,
enc=_ENC,
)
aad = _build_aad(payload)
aesgcm = AESGCM(aes_key)
ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), aad)
encrypted_key = public_key.encrypt(
aes_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
return EncryptedPayload(
encrypted_key=base64.b64encode(encrypted_key).decode("ascii"),
nonce=base64.b64encode(nonce).decode("ascii"),
ciphertext=base64.b64encode(ciphertext).decode("ascii"),
version=payload.version,
alg=payload.alg,
enc=payload.enc,
)
def decrypt(payload: EncryptedPayload, private_key_pem: bytes) -> str:
private_key = serialization.load_pem_private_key(private_key_pem, password=None)
if not isinstance(private_key, RSAPrivateKey):
raise TypeError("Expected RSA private key")
if private_key.key_size < _MIN_RSA_KEY_SIZE:
raise ValueError(f"RSA key size must be at least {_MIN_RSA_KEY_SIZE} bits")
encrypted_key = _b64decode_strict(payload.encrypted_key, "encrypted_key")
nonce = _b64decode_strict(payload.nonce, "nonce")
ciphertext = _b64decode_strict(payload.ciphertext, "ciphertext")
_validate_payload_lengths(encrypted_key, nonce, ciphertext)
aes_key = private_key.decrypt(
encrypted_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
if len(aes_key) != _AES_KEY_SIZE:
raise ValueError("Invalid AES key size after decryption")
aesgcm = AESGCM(aes_key)
aad = _build_aad(payload)
return aesgcm.decrypt(nonce, ciphertext, aad).decode("utf-8")

178
vibe/core/auth/github.py Normal file
View file

@ -0,0 +1,178 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
import types
import webbrowser
import httpx
import keyring
import keyring.errors
GITHUB_CLIENT_ID = "Ov23liJ7sk5kFDMEyvDT"
_SERVICE_NAME = "vibe"
_KEYRING_USERNAME = "github_token"
_DEVICE_CODE_URL = "https://github.com/login/device/code"
_TOKEN_URL = "https://github.com/login/oauth/access_token"
_VALIDATE_URL = "https://api.github.com/user"
_SCOPES = "repo read:org write:org workflow read:user user:email"
class GitHubAuthError(Exception):
pass
@dataclass
class DeviceFlowInfo:
user_code: str
verification_uri: str
@dataclass
class DeviceFlowHandle:
device_code: str
expires_in: int
info: DeviceFlowInfo
class GitHubAuthProvider:
def __init__(
self,
client_id: str = GITHUB_CLIENT_ID,
*,
client: httpx.AsyncClient | None = None,
timeout: float = 60.0,
) -> None:
self._client_id = client_id
self._client = client
self._owns_client = client is None
self._timeout = timeout
async def __aenter__(self) -> GitHubAuthProvider:
if self._client is None:
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
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
def _get_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
self._owns_client = True
return self._client
def get_token(self) -> str | None:
try:
return keyring.get_password(_SERVICE_NAME, _KEYRING_USERNAME)
except keyring.errors.KeyringError:
return None
def has_token(self) -> bool:
return bool(self.get_token())
def delete_token(self) -> None:
try:
keyring.delete_password(_SERVICE_NAME, _KEYRING_USERNAME)
except keyring.errors.KeyringError:
pass
async def get_valid_token(self) -> str | None:
token = self.get_token()
if not token:
return None
if await self._is_token_valid(token):
return token
self.delete_token()
return None
async def _is_token_valid(self, token: str) -> bool:
client = self._get_client()
try:
response = await client.get(
_VALIDATE_URL,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
},
)
return response.is_success
except httpx.HTTPError:
return False
async def start_device_flow(self, open_browser: bool = True) -> DeviceFlowHandle:
client = self._get_client()
response = await client.post(
_DEVICE_CODE_URL,
data={"client_id": self._client_id, "scope": _SCOPES},
headers={"Accept": "application/json"},
)
if not response.is_success:
raise GitHubAuthError(f"Failed to initiate device flow: {response.text}")
data = response.json()
if open_browser:
webbrowser.open(data["verification_uri"])
return DeviceFlowHandle(
device_code=data["device_code"],
expires_in=data["expires_in"],
info=DeviceFlowInfo(data["user_code"], data["verification_uri"]),
)
async def wait_for_token(self, handle: DeviceFlowHandle) -> str:
client = self._get_client()
token = await self._poll_for_token(
client, handle.device_code, handle.expires_in, interval=1
)
self._save_token(token)
return token
def _save_token(self, token: str) -> None:
try:
keyring.set_password(_SERVICE_NAME, _KEYRING_USERNAME, token)
except keyring.errors.KeyringError as e:
raise GitHubAuthError(f"Failed to save token to keyring: {e}") from e
async def _poll_for_token(
self,
client: httpx.AsyncClient,
device_code: str,
expires_in: int,
interval: int,
) -> str:
elapsed = 0.0
while elapsed < expires_in:
await asyncio.sleep(interval)
elapsed += interval
response = await client.post(
_TOKEN_URL,
data={
"client_id": self._client_id,
"device_code": device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
},
headers={"Accept": "application/json"},
)
result = response.json()
if "access_token" in result:
return result["access_token"]
error = result.get("error")
if error == "slow_down":
interval = result.get("interval", interval + 5)
elif error in {"expired_token", "access_denied"}:
raise GitHubAuthError(f"Authentication failed: {error}")
raise GitHubAuthError("Authentication timed out")

View file

@ -34,7 +34,8 @@ def load_dotenv_values(
env_path: Path = GLOBAL_ENV_FILE.path,
environ: MutableMapping[str, str] = os.environ,
) -> None:
if not env_path.is_file():
# We allow FIFO path to support some environment management solutions (e.g. https://developer.1password.com/docs/environments/local-env-file/)
if not env_path.is_file() and not env_path.is_fifo():
return
env_vars = dotenv_values(env_path)
@ -260,11 +261,14 @@ class ModelConfig(BaseModel):
return data
DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY"
DEFAULT_PROVIDERS = [
ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var="MISTRAL_API_KEY",
api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY,
backend=Backend.MISTRAL,
),
ProviderConfig(
@ -301,9 +305,9 @@ DEFAULT_MODELS = [
class VibeConfig(BaseSettings):
active_model: str = "devstral-2"
textual_theme: str = "terminal"
vim_keybindings: bool = False
disable_welcome_banner_animation: bool = False
autocopy_to_clipboard: bool = True
displayed_workdir: str = ""
auto_compact_threshold: int = 200_000
context_warnings: bool = False
@ -316,6 +320,14 @@ class VibeConfig(BaseSettings):
enable_update_checks: bool = True
enable_auto_update: bool = True
api_timeout: float = 720.0
# TODO(vibe-nuage): remove exclude=True once the feature is publicly available
nuage_enabled: bool = Field(default=False, exclude=True)
nuage_base_url: str = Field(default="https://api.globalaegis.net", exclude=True)
nuage_workflow_id: str = Field(default="__shared-nuage-workflow", exclude=True)
# TODO(vibe-nuage): change default value to MISTRAL_API_KEY once prod has shared vibe-nuage workers
nuage_api_key_env_var: str = Field(default="STAGING_MISTRAL_API_KEY", exclude=True)
providers: list[ProviderConfig] = Field(
default_factory=lambda: list(DEFAULT_PROVIDERS)
)
@ -402,6 +414,10 @@ class VibeConfig(BaseSettings):
env_prefix="VIBE_", case_sensitive=False, extra="ignore"
)
@property
def nuage_api_key(self) -> str:
return os.getenv(self.nuage_api_key_env_var, "")
@property
def system_prompt(self) -> str:
try:

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
import getpass
import json
import os
@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any
from anyio import NamedTemporaryFile, Path as AsyncPath
from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata
from vibe.core.utils import is_windows
from vibe.core.utils import is_windows, utc_now
if TYPE_CHECKING:
from vibe.core.agents.models import AgentProfile
@ -40,7 +40,7 @@ class SessionLogger:
self.save_dir = Path(session_config.save_dir)
self.session_prefix = session_config.session_prefix
self.session_id = session_id
self.session_start_time = datetime.now().isoformat()
self.session_start_time = utc_now().isoformat()
self.save_dir.mkdir(parents=True, exist_ok=True)
self.session_dir = self.save_folder
@ -53,7 +53,7 @@ class SessionLogger:
"Cannot get session save folder when logging is disabled"
)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
timestamp = utc_now().strftime("%Y%m%d_%H%M%S")
folder_name = f"{self.session_prefix}_{timestamp}_{self.session_id[:8]}"
return self.save_dir / folder_name
@ -265,7 +265,7 @@ class SessionLogger:
metadata_dump = {
**self.session_metadata.model_dump(),
"end_time": datetime.now().isoformat(),
"end_time": utc_now().isoformat(),
"stats": stats.model_dump(),
"title": title,
"total_messages": total_messages,
@ -292,7 +292,7 @@ class SessionLogger:
return
self.session_id = session_id
self.session_start_time = datetime.now().isoformat()
self.session_start_time = utc_now().isoformat()
self.session_dir = self.save_folder
self.session_metadata = self._initialize_session_metadata()
@ -301,7 +301,7 @@ class SessionLogger:
if not self.enabled or not self.save_dir:
return
now = datetime.now()
now = utc_now()
ago = now - timedelta(minutes=5)
tmp_files = self.save_dir.glob("**/*.json.tmp") # Recursive search
@ -309,7 +309,9 @@ class SessionLogger:
for file_path in tmp_files:
if file_path.is_file():
try:
file_mtime = datetime.fromtimestamp(file_path.stat().st_mtime)
file_mtime = datetime.fromtimestamp(
file_path.stat().st_mtime, tz=UTC
)
if file_mtime < ago:
file_path.unlink()
except Exception:

View file

@ -0,0 +1,9 @@
from __future__ import annotations
class ServiceTeleportError(Exception):
"""Base exception for teleport errors."""
class ServiceTeleportNotSupportedError(ServiceTeleportError):
"""Raised when teleport is not supported in current environment."""

196
vibe/core/teleport/git.py Normal file
View file

@ -0,0 +1,196 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from git import InvalidGitRepositoryError, Repo
from git.exc import GitCommandError
from giturlparse import parse as parse_git_url
from vibe.core.teleport.errors import (
ServiceTeleportError,
ServiceTeleportNotSupportedError,
)
from vibe.core.utils import AsyncExecutor
@dataclass
class GitRepoInfo:
remote_url: str
owner: str
repo: str
branch: str | None
commit: str
diff: str
class GitRepository:
def __init__(self, workdir: Path | None = None) -> None:
self._workdir = workdir or Path.cwd()
self._repo: Repo | None = None
# For network I/O (fetch, push) and potentially slow git commands (diff, rev-list)
self._executor = AsyncExecutor(max_workers=2, timeout=60.0, name="git")
async def __aenter__(self) -> GitRepository:
return self
async def __aexit__(self, *_: object) -> None:
self._executor.shutdown(wait=False)
async def is_supported(self) -> bool:
try:
repo = self._repo_or_raise()
except ServiceTeleportNotSupportedError:
return False
return self._find_github_remote(repo) is not None
async def get_info(self) -> GitRepoInfo:
repo = self._repo_or_raise()
parsed = self._find_github_remote(repo)
if not parsed:
raise ServiceTeleportNotSupportedError(
"No GitHub remote found. Teleport only supports GitHub repositories."
)
try:
commit = repo.head.commit.hexsha
except (ValueError, TypeError) as e:
raise ServiceTeleportNotSupportedError(
"Could not determine current commit"
) from e
if not commit:
raise ServiceTeleportNotSupportedError("Could not determine current commit")
owner, repo_name = parsed
branch = None if repo.head.is_detached else repo.active_branch.name
diff = await self._get_diff(repo)
return GitRepoInfo(
remote_url=self._to_https_url(owner, repo_name),
owner=owner,
repo=repo_name,
branch=branch,
commit=commit,
diff=diff,
)
async def is_commit_pushed(self, commit: str, remote: str = "origin") -> bool:
repo = self._repo_or_raise()
await self._fetch(repo, remote)
return await self._branch_contains(repo, commit, remote)
async def get_unpushed_commit_count(self, remote: str = "origin") -> int:
repo = self._repo_or_raise()
if repo.head.is_detached:
raise ServiceTeleportError(
"Cannot count unpushed commits: no current branch"
)
branch = repo.active_branch.name
await self._fetch(repo, remote)
result = await self._rev_list_count(repo, f"{remote}/{branch}..HEAD")
if result is not None:
return result
# Fallback: branch not pushed yet, count commits from default branch
default_branch = await self._get_remote_default_branch(repo, remote)
if default_branch:
result = await self._rev_list_count(repo, f"{default_branch}..HEAD")
if result is not None:
return result
raise ServiceTeleportError(f"Failed to count unpushed commits for {branch}")
async def push_current_branch(self, remote: str = "origin") -> bool:
repo = self._repo_or_raise()
if repo.head.is_detached:
return False
return await self._push(repo, repo.active_branch.name, remote)
def _repo_or_raise(self) -> Repo:
if self._repo is None:
try:
self._repo = Repo(self._workdir, search_parent_directories=True)
except InvalidGitRepositoryError as e:
raise ServiceTeleportNotSupportedError("Not a git repository") from e
return self._repo
def _find_github_remote(self, repo: Repo) -> tuple[str, str] | None:
for remote in repo.remotes:
for url in remote.urls:
if parsed := self._parse_github_url(url):
return parsed
return None
async def _fetch(self, repo: Repo, remote: str) -> None:
try:
await self._executor.run(lambda: repo.remote(remote).fetch())
except (TimeoutError, ValueError, GitCommandError):
pass
async def _get_diff(self, repo: Repo) -> str:
def get_full_diff() -> str:
# Mark untracked files as intent-to-add so they appear in diff
repo.git.add("-N", ".")
return repo.git.diff("HEAD", binary=True)
try:
return await self._executor.run(get_full_diff)
except (TimeoutError, GitCommandError):
return ""
async def _branch_contains(self, repo: Repo, commit: str, remote: str) -> bool:
try:
out = await self._executor.run(
lambda: repo.git.branch("-r", "--contains", commit)
)
return any(ln.strip().startswith(f"{remote}/") for ln in out.splitlines())
except (TimeoutError, GitCommandError):
return False
async def _rev_list_count(self, repo: Repo, ref_range: str) -> int | None:
try:
out = await self._executor.run(
lambda: repo.git.rev_list("--count", ref_range)
)
return int(out)
except (TimeoutError, GitCommandError, ValueError):
return None
async def _ref_exists(self, repo: Repo, ref: str) -> bool:
try:
await self._executor.run(lambda: repo.git.rev_parse("--verify", ref))
return True
except (TimeoutError, GitCommandError):
return False
async def _get_remote_default_branch(self, repo: Repo, remote: str) -> str | None:
try:
ref = repo.remotes[remote].refs.HEAD.reference.name
if await self._ref_exists(repo, ref):
return ref
except (KeyError, IndexError, TypeError, AttributeError):
pass
return None
async def _push(self, repo: Repo, branch: str, remote: str) -> bool:
try:
await self._executor.run(lambda: repo.remote(remote).push(branch))
return True
except (TimeoutError, ValueError, GitCommandError):
return False
@staticmethod
def _parse_github_url(url: str) -> tuple[str, str] | None:
p = parse_git_url(url)
if p.github and p.owner and p.repo:
return p.owner, p.repo
return None
@staticmethod
def _to_https_url(owner: str, repo: str) -> str:
return f"https://github.com/{owner}/{repo}.git"

180
vibe/core/teleport/nuage.py Normal file
View file

@ -0,0 +1,180 @@
from __future__ import annotations
from dataclasses import asdict
import types
from typing import Any
import httpx
from pydantic import BaseModel, Field
from vibe.core.auth import EncryptedPayload, encrypt
from vibe.core.teleport.errors import ServiceTeleportError
class GitRepoConfig(BaseModel):
url: str
branch: str | None = None
commit: str | None = None
class VibeSandboxConfig(BaseModel):
git_repo: GitRepoConfig | None = None
class VibeNewSandbox(BaseModel):
type: str = "new"
config: VibeSandboxConfig = Field(default_factory=VibeSandboxConfig)
teleported_diffs: bytes | None = None
class TeleportSession(BaseModel):
metadata: dict[str, Any] = Field(default_factory=dict)
messages: list[dict[str, Any]] = Field(default_factory=list)
class WorkflowParams(BaseModel):
prompt: str
sandbox: VibeNewSandbox
session: TeleportSession | None = None
class WorkflowExecuteResponse(BaseModel):
execution_id: str
class PublicKeyResult(BaseModel):
public_key: str
class QueryResponse(BaseModel):
result: PublicKeyResult
class CreateLeChatThreadInput(BaseModel):
encrypted_api_key: dict[str, str]
user_message: str
project_name: str | None = None
class CreateLeChatThreadOutput(BaseModel):
chat_url: str
class UpdateResponse(BaseModel):
result: CreateLeChatThreadOutput
class NuageClient:
def __init__(
self,
base_url: str,
api_key: str,
workflow_id: str,
*,
client: httpx.AsyncClient | None = None,
timeout: float = 60.0,
) -> None:
self._base_url = base_url.rstrip("/")
self._api_key = api_key
self._workflow_id = workflow_id
self._client = client
self._owns_client = client is None
self._timeout = timeout
async def __aenter__(self) -> NuageClient:
if self._client is None:
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
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))
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_workflow(self, params: WorkflowParams) -> str:
response = await self._http_client.post(
f"{self._base_url}/v1/workflows/{self._workflow_id}/execute",
headers=self._headers(),
json={"input": params.model_dump(mode="json")},
)
if not response.is_success:
error_msg = f"Nuage workflow trigger failed: {response.text}"
# TODO(vibe-nuage): remove this once prod has shared vibe-nuage workers
if "Unauthorized" in response.text or "unauthorized" in response.text:
error_msg += (
"\n\nHint: This version uses Mistral staging environment. "
"Set STAGING_MISTRAL_API_KEY from https://console.globalaegis.net/"
)
raise ServiceTeleportError(error_msg)
result = WorkflowExecuteResponse.model_validate(response.json())
return result.execution_id
async def send_github_token(self, execution_id: str, token: str) -> None:
public_key_pem = await self._query_public_key(execution_id)
encrypted = encrypt(token, public_key_pem)
await self._signal_encrypted_token(execution_id, encrypted)
async def _query_public_key(self, execution_id: str) -> bytes:
response = await self._http_client.post(
f"{self._base_url}/v1/workflows/executions/{execution_id}/queries",
headers=self._headers(),
json={"name": "get_public_key", "input": {}},
)
if not response.is_success:
raise ServiceTeleportError(f"Failed to get public key: {response.text}")
result = QueryResponse.model_validate(response.json())
return result.result.public_key.encode("utf-8")
async def _signal_encrypted_token(
self, execution_id: str, encrypted: EncryptedPayload
) -> None:
response = await self._http_client.post(
f"{self._base_url}/v1/workflows/executions/{execution_id}/signals",
headers=self._headers(),
json={"name": "github_token", "input": {"payload": asdict(encrypted)}},
)
if not response.is_success:
raise ServiceTeleportError(f"Failed to send GitHub token: {response.text}")
async def create_le_chat_thread(
self, execution_id: str, user_message: str, project_name: str | None = None
) -> str:
public_key_pem = await self._query_public_key(execution_id)
encrypted = encrypt(self._api_key, public_key_pem)
input_data = CreateLeChatThreadInput(
encrypted_api_key={
k: v for k, v in asdict(encrypted).items() if v is not None
},
user_message=user_message,
project_name=project_name,
)
response = await self._http_client.post(
f"{self._base_url}/v1/workflows/executions/{execution_id}/updates",
headers=self._headers(),
json={"name": "create_le_chat_thread", "input": input_data.model_dump()},
)
if not response.is_success:
raise ServiceTeleportError(
f"Failed to create Le Chat thread: {response.text}"
)
result = UpdateResponse.model_validate(response.json())
return result.result.chat_url

View file

@ -0,0 +1,208 @@
from __future__ import annotations
import base64
from collections.abc import AsyncGenerator
from pathlib import Path
import types
import httpx
import zstandard
from vibe.core.auth.github import GitHubAuthProvider
from vibe.core.session.session_logger import SessionLogger
from vibe.core.teleport.errors import ServiceTeleportError
from vibe.core.teleport.git import GitRepoInfo, GitRepository
from vibe.core.teleport.nuage import (
GitRepoConfig,
NuageClient,
TeleportSession,
VibeNewSandbox,
VibeSandboxConfig,
WorkflowParams,
)
from vibe.core.teleport.types import (
TeleportAuthCompleteEvent,
TeleportAuthRequiredEvent,
TeleportCheckingGitEvent,
TeleportCompleteEvent,
TeleportPushingEvent,
TeleportPushRequiredEvent,
TeleportPushResponseEvent,
TeleportSendEvent,
TeleportSendingGithubTokenEvent,
TeleportStartingWorkflowEvent,
TeleportYieldEvent,
)
# TODO(vibe-nuage): update URL once prod has shared vibe-nuage workers
_NUAGE_EXECUTION_URL_TEMPLATE = "https://console.globalaegis.net/build/workflows/{workflow_id}?tab=executions&executionId={execution_id}"
_DEFAULT_TELEPORT_PROMPT = "please continue where you left off"
class TeleportService:
def __init__(
self,
session_logger: SessionLogger,
nuage_base_url: str,
nuage_workflow_id: str,
nuage_api_key: str,
workdir: Path | None = None,
*,
client: httpx.AsyncClient | None = None,
timeout: float = 60.0,
) -> None:
self._session_logger = session_logger
self._nuage_base_url = nuage_base_url
self._nuage_workflow_id = nuage_workflow_id
self._nuage_api_key = nuage_api_key
self._git = GitRepository(workdir)
self._client = client
self._owns_client = client is None
self._timeout = timeout
self._github_auth: GitHubAuthProvider | None = None
self._nuage: NuageClient | None = None
async def __aenter__(self) -> TeleportService:
if self._client is None:
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
self._github_auth = GitHubAuthProvider(client=self._client)
self._nuage = NuageClient(
self._nuage_base_url,
self._nuage_api_key,
self._nuage_workflow_id,
client=self._client,
)
await self._git.__aenter__()
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: types.TracebackType | None,
) -> None:
await self._git.__aexit__(exc_type, exc_val, exc_tb)
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))
self._owns_client = True
return self._client
@property
def _github_auth_provider(self) -> GitHubAuthProvider:
if self._github_auth is None:
self._github_auth = GitHubAuthProvider(client=self._http_client)
return self._github_auth
@property
def _nuage_client(self) -> NuageClient:
if self._nuage is None:
self._nuage = NuageClient(
self._nuage_base_url,
self._nuage_api_key,
self._nuage_workflow_id,
client=self._http_client,
)
return self._nuage
async def check_supported(self) -> None:
await self._git.get_info()
async def is_supported(self) -> bool:
return await self._git.is_supported()
async def execute(
self, prompt: str | None, session: TeleportSession
) -> AsyncGenerator[TeleportYieldEvent, TeleportSendEvent]:
prompt = prompt or _DEFAULT_TELEPORT_PROMPT
self._validate_config()
git_info = await self._git.get_info()
yield TeleportCheckingGitEvent()
if not await self._git.is_commit_pushed(git_info.commit):
unpushed_count = await self._git.get_unpushed_commit_count()
response = yield TeleportPushRequiredEvent(
unpushed_count=max(1, unpushed_count)
)
if (
not isinstance(response, TeleportPushResponseEvent)
or not response.approved
):
raise ServiceTeleportError("Teleport cancelled: commit not pushed.")
yield TeleportPushingEvent()
await self._push_or_fail()
github_token = await self._github_auth_provider.get_valid_token()
if not github_token:
handle = await self._github_auth_provider.start_device_flow(
open_browser=True
)
yield TeleportAuthRequiredEvent(
user_code=handle.info.user_code,
verification_uri=handle.info.verification_uri,
)
github_token = await self._github_auth_provider.wait_for_token(handle)
yield TeleportAuthCompleteEvent()
yield TeleportStartingWorkflowEvent()
execution_id = await self._nuage_client.start_workflow(
WorkflowParams(
prompt=prompt, sandbox=self._build_sandbox(git_info), session=session
)
)
yield TeleportSendingGithubTokenEvent()
await self._nuage_client.send_github_token(execution_id, github_token)
chat_url = _NUAGE_EXECUTION_URL_TEMPLATE.format(
workflow_id=self._nuage_workflow_id, execution_id=execution_id
)
# chat_url = await nuage.create_le_chat_thread(
# execution_id=execution_id, user_message=prompt
# )
yield TeleportCompleteEvent(url=chat_url)
async def _push_or_fail(self) -> None:
if not await self._git.push_current_branch():
raise ServiceTeleportError("Failed to push current branch to remote.")
def _validate_config(self) -> None:
# TODO(vibe-nuage): update error message once prod has shared vibe-nuage workers
if not self._nuage_api_key:
raise ServiceTeleportError(
"STAGING_MISTRAL_API_KEY not set. "
"Set it from https://console.globalaegis.net/ to use teleport."
)
def _build_sandbox(self, git_info: GitRepoInfo) -> VibeNewSandbox:
return VibeNewSandbox(
config=VibeSandboxConfig(
git_repo=GitRepoConfig(
url=git_info.remote_url,
branch=git_info.branch,
commit=git_info.commit,
)
),
teleported_diffs=self._compress_diff(git_info.diff or ""),
)
def _compress_diff(self, diff: str, max_size: int = 1_000_000) -> bytes | None:
if not diff:
return None
compressed = zstandard.ZstdCompressor().compress(diff.encode("utf-8"))
encoded = base64.b64encode(compressed)
if len(encoded) > max_size:
raise ServiceTeleportError(
"Diff too large to teleport. Please commit and push your changes first."
)
return encoded

View file

@ -0,0 +1,54 @@
from __future__ import annotations
from vibe.core.types import BaseEvent
class TeleportAuthRequiredEvent(BaseEvent):
user_code: str
verification_uri: str
class TeleportAuthCompleteEvent(BaseEvent):
pass
class TeleportStartingWorkflowEvent(BaseEvent):
pass
class TeleportCheckingGitEvent(BaseEvent):
pass
class TeleportPushRequiredEvent(BaseEvent):
unpushed_count: int = 1
class TeleportPushResponseEvent(BaseEvent):
approved: bool
class TeleportPushingEvent(BaseEvent):
pass
class TeleportSendingGithubTokenEvent(BaseEvent):
pass
class TeleportCompleteEvent(BaseEvent):
url: str
type TeleportYieldEvent = (
TeleportAuthRequiredEvent
| TeleportAuthCompleteEvent
| TeleportCheckingGitEvent
| TeleportPushRequiredEvent
| TeleportPushingEvent
| TeleportStartingWorkflowEvent
| TeleportSendingGithubTokenEvent
| TeleportCompleteEvent
)
type TeleportSendEvent = TeleportPushResponseEvent | None

View file

@ -39,6 +39,9 @@ class Question(BaseModel):
multi_select: bool = Field(
default=False, description="If true, user can select multiple options"
)
hide_other: bool = Field(
default=False, description="If true, hide the 'Other' free text option"
)
class AskUserQuestionArgs(BaseModel):

View file

@ -5,7 +5,7 @@ from collections import OrderedDict
from collections.abc import Awaitable, Callable
import copy
from enum import StrEnum, auto
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal
from typing import TYPE_CHECKING, Annotated, Any, Literal
from uuid import uuid4
if TYPE_CHECKING:
@ -18,6 +18,7 @@ from pydantic import (
BeforeValidator,
ConfigDict,
Field,
PrivateAttr,
computed_field,
model_validator,
)
@ -33,7 +34,6 @@ class AgentStats(BaseModel):
tool_calls_succeeded: int = 0
context_tokens: int = 0
listeners: ClassVar[dict[str, Callable[[AgentStats], None]]] = {}
last_turn_prompt_tokens: int = 0
last_turn_completion_tokens: int = 0
@ -43,20 +43,23 @@ class AgentStats(BaseModel):
input_price_per_million: float = 0.0
output_price_per_million: float = 0.0
_listeners: dict[str, Callable[[AgentStats], None]] = PrivateAttr(
default_factory=dict
)
def __setattr__(self, name: str, value: Any) -> None:
super().__setattr__(name, value)
if name in self.listeners:
self.listeners[name](self)
if name in self._listeners:
self._listeners[name](self)
def trigger_listeners(self) -> None:
for listener in self.listeners.values():
for listener in self._listeners.values():
listener(self)
@classmethod
def add_listener(
cls, attr_name: str, listener: Callable[[AgentStats], None]
self, attr_name: str, listener: Callable[[AgentStats], None]
) -> None:
cls.listeners[attr_name] = listener
self._listeners[attr_name] = listener
@computed_field
@property

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine
import concurrent.futures
from datetime import UTC, datetime
from enum import Enum, auto
from fnmatch import fnmatch
import functools
@ -306,6 +307,37 @@ def name_matches(name: str, patterns: list[str]) -> bool:
return False
class AsyncExecutor:
"""Run sync functions in a thread pool with timeout. Supports async context manager."""
def __init__(
self, max_workers: int = 4, timeout: float = 60.0, name: str = "async-executor"
) -> None:
self._executor = concurrent.futures.ThreadPoolExecutor(
max_workers=max_workers, thread_name_prefix=name
)
self._timeout = timeout
async def __aenter__(self) -> AsyncExecutor:
return self
async def __aexit__(self, *_: object) -> None:
self.shutdown(wait=False)
async def run[T](self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
loop = asyncio.get_running_loop()
future = loop.run_in_executor(
self._executor, functools.partial(fn, *args, **kwargs)
)
try:
return await asyncio.wait_for(future, timeout=self._timeout)
except TimeoutError as e:
raise TimeoutError(f"Operation timed out after {self._timeout}s") from e
def shutdown(self, wait: bool = True) -> None:
self._executor.shutdown(wait=wait)
def compact_reduction_display(old_tokens: int | None, new_tokens: int | None) -> str:
if old_tokens is None or new_tokens is None:
return "Compaction complete"
@ -316,3 +348,7 @@ def compact_reduction_display(old_tokens: int | None, new_tokens: int | None) ->
f"Compaction complete: {old_tokens:,}"
f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)"
)
def utc_now() -> datetime:
return datetime.now(UTC)

View file

@ -4,34 +4,18 @@ import sys
from rich import print as rprint
from textual.app import App
from textual.theme import Theme
from vibe.cli.textual_ui.terminal_theme import (
TERMINAL_THEME_NAME,
capture_terminal_theme,
)
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE
from vibe.setup.onboarding.screens import (
ApiKeyScreen,
ThemeSelectionScreen,
WelcomeScreen,
)
from vibe.setup.onboarding.screens import ApiKeyScreen, WelcomeScreen
class OnboardingApp(App[str | None]):
CSS_PATH = "onboarding.tcss"
def __init__(self) -> None:
super().__init__()
self._terminal_theme: Theme | None = capture_terminal_theme()
def on_mount(self) -> None:
if self._terminal_theme:
self.register_theme(self._terminal_theme)
self.theme = TERMINAL_THEME_NAME
self.theme = "textual-ansi"
self.install_screen(WelcomeScreen(), "welcome")
self.install_screen(ThemeSelectionScreen(), "theme_selection")
self.install_screen(ApiKeyScreen(), "api_key")
self.push_screen("welcome")

View file

@ -27,7 +27,7 @@ OnboardingScreen {
}
WelcomeScreen #enter-hint {
color: $text-muted;
color: ansi_bright_black;
min-width: 16;
text-align: center;
}
@ -36,102 +36,6 @@ 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
============================================================================= */
@ -177,11 +81,11 @@ ThemeSelectionScreen #enter-hint {
}
#input-box.valid {
border: round $success;
border: round ansi_green;
}
#input-box.invalid {
border: round $error;
border: round ansi_red;
}
#key {
@ -202,11 +106,11 @@ ThemeSelectionScreen #enter-hint {
}
#feedback.error {
color: $error;
color: ansi_red;
}
#feedback.success {
color: $text-muted;
color: ansi_bright_black;
}
#config-docs-section {
@ -222,7 +126,7 @@ ThemeSelectionScreen #enter-hint {
}
#config-docs-group Static {
color: $text-muted;
color: ansi_bright_black;
}
#config-docs-group .link-row {

View file

@ -1,7 +1,6 @@
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"]
__all__ = ["ApiKeyScreen", "WelcomeScreen"]

View file

@ -1,164 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, 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.cli.textual_ui.terminal_theme import TERMINAL_THEME_NAME
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.config import VibeConfig
from vibe.setup.onboarding.base import OnboardingScreen
if TYPE_CHECKING:
from vibe.setup.onboarding import OnboardingApp
THEMES = [TERMINAL_THEME_NAME] + 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 = NoMarkupStatic("", 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 NoMarkupStatic("Select your preferred theme", id="theme-title")
yield Center(
Horizontal(
NoMarkupStatic("Navigate ↑ ↓", id="nav-hint"),
Vertical(*self._compose_theme_list(), id="theme-list"),
NoMarkupStatic("Press Enter \u21b5", 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")
@property
def _has_terminal_theme(self) -> bool:
app: OnboardingApp = self.app # type: ignore[assignment]
return app._terminal_theme is not None
@property
def _available_themes(self) -> list[str]:
if self._has_terminal_theme:
return THEMES
return [t for t in THEMES if t != TERMINAL_THEME_NAME]
def on_mount(self) -> None:
current_theme = self.app.theme
themes = self._available_themes
if current_theme == TERMINAL_THEME_NAME:
self._theme_index = 0
elif 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:
themes = self._available_themes
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:
themes = self._available_themes
self._theme_index = (self._theme_index + direction) % len(themes)
theme = themes[self._theme_index]
self.app.theme = theme
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 = self._available_themes[self._theme_index]
try:
VibeConfig.save_updates({"textual_theme": theme})
except OSError:
pass
super().action_next()

View file

@ -50,7 +50,7 @@ class WelcomeScreen(OnboardingScreen):
Binding("escape", "cancel", "Cancel", show=False),
]
NEXT_SCREEN = "theme_selection"
NEXT_SCREEN = "api_key"
def __init__(self) -> None:
super().__init__()

View file

@ -1,7 +1,6 @@
from __future__ import annotations
from pathlib import Path
import tomllib
from typing import Any, ClassVar
from textual import events
@ -11,12 +10,8 @@ from textual.containers import CenterMiddle, Horizontal
from textual.message import Message
from textual.widgets import Static
from vibe.cli.textual_ui.terminal_theme import (
TERMINAL_THEME_NAME,
capture_terminal_theme,
)
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.paths.global_paths import GLOBAL_CONFIG_FILE, TRUSTED_FOLDERS_FILE
from vibe.core.paths.global_paths import TRUSTED_FOLDERS_FILE
class TrustDialogQuitException(Exception):
@ -154,32 +149,9 @@ class TrustFolderApp(App):
self.folder_path = folder_path
self._result: bool | None = None
self._quit_without_saving = False
self._terminal_theme = capture_terminal_theme()
self._load_theme()
def _load_theme(self) -> None:
if self._terminal_theme:
self.register_theme(self._terminal_theme)
config_file = GLOBAL_CONFIG_FILE.path
if not config_file.is_file():
return
try:
with config_file.open("rb") as f:
config_data = tomllib.load(f)
except (OSError, tomllib.TOMLDecodeError):
return
textual_theme = config_data.get("textual_theme")
if not textual_theme:
return
if textual_theme == TERMINAL_THEME_NAME:
if self._terminal_theme:
self.theme = TERMINAL_THEME_NAME
else:
self.theme = textual_theme
def on_mount(self) -> None:
self.theme = "textual-ansi"
def compose(self) -> ComposeResult:
yield TrustFolderDialog(self.folder_path)

View file

@ -1,6 +1,6 @@
Screen {
align: center middle;
background: $background 80%;
background: transparent 80%;
}
#trust-dialog {
@ -8,8 +8,8 @@ Screen {
max-width: 90%;
min-width: 50;
height: auto;
border: round $foreground-muted;
background: $background;
border: round ansi_bright_black;
background: transparent;
padding: 1;
}
@ -17,7 +17,7 @@ Screen {
width: 100%;
height: auto;
text-style: bold;
color: $warning;
color: ansi_yellow;
text-align: center;
margin-bottom: 1;
}
@ -25,7 +25,7 @@ Screen {
#trust-dialog-path {
width: 100%;
height: auto;
color: $primary;
color: ansi_blue;
text-align: center;
text-wrap: wrap;
margin-bottom: 1;
@ -34,7 +34,7 @@ Screen {
#trust-dialog-message {
width: 100%;
height: auto;
color: $foreground;
color: ansi_default;
text-align: center;
text-wrap: wrap;
margin-bottom: 1;
@ -50,24 +50,24 @@ Screen {
.trust-option {
height: auto;
width: auto;
color: $foreground;
color: ansi_default;
margin: 0 3;
content-align: center middle;
}
.trust-cursor-selected {
color: $foreground;
color: ansi_default;
text-style: bold;
}
.trust-option-selected {
color: $foreground;
color: ansi_default;
}
.trust-dialog-help {
width: 100%;
height: auto;
color: $text-muted;
color: ansi_bright_black;
text-align: center;
margin-bottom: 1;
}
@ -75,7 +75,7 @@ Screen {
.trust-dialog-save-info {
width: 100%;
height: auto;
color: $text-muted;
color: ansi_bright_black;
text-align: center;
text-style: italic;
text-wrap: wrap;

View file

@ -1,5 +1,9 @@
# What's New
# What's New in 2.1.0
- **Config:** Variables defined in the `.env` file in your global `.vibe` folder now override environment variables
- **Sessions:** Fixed message duplication in persisted sessions
- **Resume:** Only shown when a session is available to resume
- **UI redesign** — Refreshed interface with a cleaner layout and better performance when streaming.
- **Themes removed** — Application themes have been removed; the UI now follows your terminals theme (colors and appearance).
- **Clipboard** — You can now disable autocopying in /config, and copy with Ctrl+Shift+C or Cmd+C (in terminals that support it).
- **Performance** — Various optimizations throughout the app for a more responsive UI.