v2.11.0 (#717)
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
f71bfd3b8c
commit
adb1ca74ce
202 changed files with 5828 additions and 1968 deletions
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
VIBE_ROOT = Path(__file__).parent
|
||||
__version__ = "2.10.1"
|
||||
__version__ = "2.11.0"
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ from vibe.core.config import (
|
|||
load_dotenv_values,
|
||||
)
|
||||
from vibe.core.data_retention import DATA_RETENTION_MESSAGE
|
||||
from vibe.core.feedback import record_feedback_asked, should_show_feedback
|
||||
from vibe.core.hooks.config import load_hooks_from_fs
|
||||
from vibe.core.proxy_setup import (
|
||||
ProxySetupError,
|
||||
|
|
@ -464,9 +465,11 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
field_meta={
|
||||
"browser-auth-delegated": {
|
||||
"attemptId": attempt.process_id,
|
||||
"expiresAt": attempt.expires_at.astimezone(UTC)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z"),
|
||||
"expiresAt": (
|
||||
attempt.expires_at.astimezone(UTC)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
),
|
||||
"signInUrl": attempt.sign_in_url,
|
||||
}
|
||||
}
|
||||
|
|
@ -1102,12 +1105,28 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
raise InternalError(str(e)) from e
|
||||
|
||||
self._send_usage_update(session)
|
||||
meta = self._build_end_turn_meta(session)
|
||||
return PromptResponse(
|
||||
stop_reason="end_turn",
|
||||
usage=self._build_usage(session),
|
||||
user_message_id=resolved_message_id,
|
||||
field_meta=meta or None,
|
||||
)
|
||||
|
||||
def _build_end_turn_meta(self, session: AcpSessionLoop) -> dict[str, Any] | None:
|
||||
agent_loop = session.agent_loop
|
||||
user_message_count = (
|
||||
sum(m.role == Role.user and not m.injected for m in agent_loop.messages) + 1
|
||||
) # +1 for the message just sent
|
||||
if not should_show_feedback(
|
||||
telemetry_active=agent_loop.telemetry_client.is_active(),
|
||||
is_mistral_model=agent_loop.config.is_active_model_mistral(),
|
||||
user_message_count=user_message_count,
|
||||
):
|
||||
return None
|
||||
record_feedback_asked()
|
||||
return {"show_feedback_prompt": True}
|
||||
|
||||
def _build_text_prompt(self, acp_prompt: list[ContentBlock]) -> str:
|
||||
def _is_automatic_resource(block: ContentBlock) -> bool:
|
||||
return block.type == "resource" and bool(
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from acp.schema import (
|
|||
from vibe import VIBE_ROOT
|
||||
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
|
||||
from vibe.acp.tools.events import ToolTerminalOpenedEvent
|
||||
from vibe.acp.tools.session_update import resolve_kind
|
||||
from vibe.acp.tools.session_update import failed_tool_result, resolve_kind
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
|
||||
from vibe.core.tools.builtins.bash import Bash as CoreBashTool, BashArgs, BashResult
|
||||
|
|
@ -118,9 +118,13 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
|
|||
@classmethod
|
||||
def tool_call_session_update(cls, event: ToolCallEvent) -> ToolCallStart | None:
|
||||
if event.args is None:
|
||||
# Title is left empty until args resolve so clients don't briefly
|
||||
# render the tool name (e.g. "bash") as a stand-in command while
|
||||
# the LLM is still streaming BashArgs. ACP requires title to be a
|
||||
# str, so we use "" as the "unknown yet" sentinel.
|
||||
return ToolCallStart(
|
||||
session_update="tool_call",
|
||||
title="bash",
|
||||
title="",
|
||||
tool_call_id=event.tool_call_id,
|
||||
kind=resolve_kind(event.tool_name),
|
||||
content=None,
|
||||
|
|
@ -144,10 +148,13 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
|
|||
def tool_result_session_update(
|
||||
cls, event: ToolResultEvent
|
||||
) -> ToolCallProgress | None:
|
||||
if failure := failed_tool_result(event, BashResult):
|
||||
return failure
|
||||
|
||||
return ToolCallProgress(
|
||||
session_update="tool_call_update",
|
||||
tool_call_id=event.tool_call_id,
|
||||
status="failed" if event.error else "completed",
|
||||
status="completed",
|
||||
content=[
|
||||
ContentToolCallContent(
|
||||
type="content",
|
||||
|
|
|
|||
|
|
@ -41,8 +41,11 @@ class Grep(
|
|||
tool_call_id=event.tool_call_id,
|
||||
kind=resolve_kind(event.tool_name),
|
||||
raw_input=event.args.model_dump_json(),
|
||||
locations=[ToolCallLocation(path=search_path)],
|
||||
field_meta={"tool_name": event.tool_name, "query": event.args.pattern},
|
||||
field_meta={
|
||||
"tool_name": event.tool_name,
|
||||
"query": event.args.pattern,
|
||||
"search_path": search_path,
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -24,13 +24,11 @@ from vibe.core.tools.builtins.search_replace import (
|
|||
SearchReplaceResult,
|
||||
)
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
from vibe.core.utils.io import ReadSafeResult
|
||||
from vibe.core.utils.io import ReadSafeResult, normalize_newlines
|
||||
|
||||
|
||||
class AcpSearchReplaceState(BaseToolState, AcpToolState):
|
||||
file_backup_content: str | None = None
|
||||
file_backup_encoding: str = "utf-8"
|
||||
file_backup_newline: str = "\n"
|
||||
|
||||
|
||||
class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
|
||||
|
|
@ -54,24 +52,24 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
|
|||
raise ToolError(f"Unexpected error reading {file_path}: {e}") from e
|
||||
|
||||
self.state.file_backup_content = response.content
|
||||
self.state.file_backup_encoding = "utf-8"
|
||||
self.state.file_backup_newline = "\n"
|
||||
return ReadSafeResult(response.content, "utf-8", "\n")
|
||||
text, newline = normalize_newlines(response.content)
|
||||
return ReadSafeResult(text, "utf-8", newline)
|
||||
|
||||
async def _backup_file(self, file_path: Path) -> None:
|
||||
if self.state.file_backup_content is None:
|
||||
return
|
||||
|
||||
await self._write_file(
|
||||
await self._write_via_client(
|
||||
file_path.with_suffix(file_path.suffix + ".bak"),
|
||||
self.state.file_backup_content,
|
||||
self.state.file_backup_encoding,
|
||||
self.state.file_backup_newline,
|
||||
)
|
||||
|
||||
async def _write_file(
|
||||
self, file_path: Path, content: str, encoding: str, newline: str
|
||||
) -> None:
|
||||
await self._write_via_client(file_path, content.replace("\n", newline))
|
||||
|
||||
async def _write_via_client(self, file_path: Path, content: str) -> None:
|
||||
client, session_id = self._load_state()
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from acp.helpers import SessionUpdate
|
||||
|
|
@ -24,6 +25,7 @@ from vibe.core.tools.builtins.write_file import (
|
|||
WriteFileResult,
|
||||
)
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
from vibe.core.utils.io import normalize_newlines
|
||||
|
||||
|
||||
class AcpWriteFileState(BaseToolState, AcpToolState):
|
||||
|
|
@ -42,10 +44,12 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]):
|
|||
|
||||
async def _write_file(self, args: WriteFileArgs, file_path: Path) -> None:
|
||||
client, session_id = self._load_state()
|
||||
normalized_text, _ = normalize_newlines(args.content)
|
||||
content = normalized_text.replace("\n", os.linesep)
|
||||
|
||||
try:
|
||||
await client.write_text_file(
|
||||
session_id=session_id, path=str(file_path), content=args.content
|
||||
session_id=session_id, path=str(file_path), content=content
|
||||
)
|
||||
except Exception as e:
|
||||
raise ToolError(f"Error writing {file_path}: {e}") from e
|
||||
|
|
|
|||
|
|
@ -174,6 +174,11 @@ class CommandRegistry:
|
|||
description="Show data retention information",
|
||||
handler="_show_data_retention",
|
||||
),
|
||||
"theme": Command(
|
||||
aliases=frozenset(["/theme"]),
|
||||
description="Select theme",
|
||||
handler="_show_theme",
|
||||
),
|
||||
}
|
||||
|
||||
@property
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pygments.token import Token
|
||||
from textual.content import Content
|
||||
from textual.highlight import HighlightTheme, highlight
|
||||
from textual.widgets import Markdown
|
||||
from textual.widgets._markdown import MarkdownFence
|
||||
|
||||
|
||||
class AnsiHighlightTheme(HighlightTheme):
|
||||
STYLES = {
|
||||
Token.Comment: "ansi_bright_black italic",
|
||||
Token.Error: "ansi_red",
|
||||
Token.Generic.Strong: "bold",
|
||||
Token.Generic.Emph: "italic",
|
||||
Token.Generic.Error: "ansi_red",
|
||||
Token.Generic.Heading: "ansi_blue underline",
|
||||
Token.Generic.Subheading: "ansi_blue",
|
||||
Token.Keyword: "ansi_magenta",
|
||||
Token.Keyword.Constant: "ansi_cyan",
|
||||
Token.Keyword.Namespace: "ansi_magenta",
|
||||
Token.Keyword.Type: "ansi_cyan",
|
||||
Token.Literal.Number: "ansi_yellow",
|
||||
Token.Literal.String.Backtick: "ansi_bright_black",
|
||||
Token.Literal.String: "ansi_green",
|
||||
Token.Literal.String.Doc: "ansi_green italic",
|
||||
Token.Literal.String.Double: "ansi_green",
|
||||
Token.Name: "ansi_default",
|
||||
Token.Name.Attribute: "ansi_yellow",
|
||||
Token.Name.Builtin: "ansi_cyan",
|
||||
Token.Name.Builtin.Pseudo: "italic",
|
||||
Token.Name.Class: "ansi_yellow",
|
||||
Token.Name.Constant: "ansi_red",
|
||||
Token.Name.Decorator: "ansi_blue",
|
||||
Token.Name.Function: "ansi_blue",
|
||||
Token.Name.Function.Magic: "ansi_blue",
|
||||
Token.Name.Tag: "ansi_blue",
|
||||
Token.Name.Variable: "ansi_default",
|
||||
Token.Number: "ansi_yellow",
|
||||
Token.Operator: "ansi_default",
|
||||
Token.Operator.Word: "ansi_magenta",
|
||||
Token.String: "ansi_green",
|
||||
Token.Whitespace: "",
|
||||
}
|
||||
|
||||
|
||||
class AnsiMarkdownFence(MarkdownFence):
|
||||
@classmethod
|
||||
def highlight(cls, code: str, language: str) -> Content:
|
||||
return highlight(code, language=language or None, theme=AnsiHighlightTheme)
|
||||
|
||||
|
||||
class AnsiMarkdown(Markdown):
|
||||
BLOCKS = {
|
||||
**Markdown.BLOCKS,
|
||||
"fence": AnsiMarkdownFence,
|
||||
"code_block": AnsiMarkdownFence,
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ from textual.binding import Binding, BindingType
|
|||
from textual.containers import Horizontal, VerticalGroup, VerticalScroll
|
||||
from textual.driver import Driver
|
||||
from textual.events import AppBlur, AppFocus, MouseUp
|
||||
from textual.theme import BUILTIN_THEMES
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
||||
|
|
@ -91,6 +92,7 @@ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
|||
from vibe.cli.textual_ui.widgets.rewind_app import RewindApp
|
||||
from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp
|
||||
from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage
|
||||
from vibe.cli.textual_ui.widgets.theme_picker import ThemePickerApp, sorted_theme_names
|
||||
from vibe.cli.textual_ui.widgets.thinking_picker import ThinkingPickerApp
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolResultMessage
|
||||
from vibe.cli.textual_ui.widgets.voice_app import VoiceApp
|
||||
|
|
@ -128,7 +130,7 @@ from vibe.core.autocompletion.path_prompt import (
|
|||
build_title_segments,
|
||||
)
|
||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.config import DEFAULT_THEME, VibeConfig
|
||||
from vibe.core.data_retention import DATA_RETENTION_MESSAGE
|
||||
from vibe.core.hooks.models import HookStartEvent
|
||||
from vibe.core.log_reader import LogReader
|
||||
|
|
@ -185,17 +187,18 @@ from vibe.core.utils import (
|
|||
)
|
||||
|
||||
|
||||
def _compute_connectors_count(
|
||||
def _compute_connector_counts(
|
||||
config: VibeConfig, connector_registry: ConnectorRegistry | None
|
||||
) -> int:
|
||||
) -> tuple[int, int]:
|
||||
total = connector_registry.connector_count if connector_registry else 0
|
||||
if total == 0:
|
||||
return 0
|
||||
return (0, 0)
|
||||
disabled_names = {c.name for c in config.connectors if c.disabled}
|
||||
known_names = set(
|
||||
connector_registry.get_connector_names() if connector_registry else []
|
||||
)
|
||||
return total - len(disabled_names & known_names)
|
||||
enabled = total - len(disabled_names & known_names)
|
||||
return (enabled, total)
|
||||
|
||||
|
||||
class BottomApp(StrEnum):
|
||||
|
|
@ -214,6 +217,7 @@ class BottomApp(StrEnum):
|
|||
ModelPicker = auto()
|
||||
ProxySetup = auto()
|
||||
Question = auto()
|
||||
ThemePicker = auto()
|
||||
ThinkingPicker = auto()
|
||||
Rewind = auto()
|
||||
SessionPicker = auto()
|
||||
|
|
@ -464,13 +468,14 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
def compose(self) -> ComposeResult:
|
||||
with ChatScroll(id="chat"):
|
||||
connectors_enabled, connectors_total = _compute_connector_counts(
|
||||
self.config, self.agent_loop.connector_registry
|
||||
)
|
||||
self._banner = Banner(
|
||||
config=self.config,
|
||||
skill_manager=self.agent_loop.skill_manager,
|
||||
mcp_registry=self.agent_loop.mcp_registry,
|
||||
connectors_count=_compute_connectors_count(
|
||||
self.config, self.agent_loop.connector_registry
|
||||
),
|
||||
connectors_enabled=connectors_enabled,
|
||||
connectors_total=connectors_total,
|
||||
)
|
||||
yield self._banner
|
||||
yield VerticalGroup(id="messages")
|
||||
|
|
@ -498,7 +503,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
yield ContextProgress()
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
self.theme = "textual-ansi"
|
||||
self._apply_theme(self.config.theme)
|
||||
self._terminal_notifier.restore()
|
||||
|
||||
self._cached_messages_area = self.query_one("#messages")
|
||||
|
|
@ -853,6 +858,25 @@ class VibeApp(App): # noqa: PLR0904
|
|||
) -> None:
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_theme_picker_app_theme_previewed(
|
||||
self, message: ThemePickerApp.ThemePreviewed
|
||||
) -> None:
|
||||
self._apply_theme(message.theme)
|
||||
|
||||
async def on_theme_picker_app_theme_selected(
|
||||
self, message: ThemePickerApp.ThemeSelected
|
||||
) -> None:
|
||||
self._apply_theme(message.theme)
|
||||
self.config.theme = message.theme
|
||||
VibeConfig.save_updates({"theme": message.theme})
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_theme_picker_app_cancelled(
|
||||
self, message: ThemePickerApp.Cancelled
|
||||
) -> None:
|
||||
self._apply_theme(message.original_theme)
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_mcpapp_mcpclosed(self, _message: MCPApp.MCPClosed) -> None:
|
||||
await self._mount_and_scroll(UserCommandMessage("MCP servers closed."))
|
||||
await self._switch_to_input_app()
|
||||
|
|
@ -1751,6 +1775,11 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return
|
||||
await self._switch_to_thinking_picker_app()
|
||||
|
||||
async def _show_theme(self, **kwargs: Any) -> None:
|
||||
if self._current_bottom_app == BottomApp.ThemePicker:
|
||||
return
|
||||
await self._switch_to_theme_picker_app()
|
||||
|
||||
async def _show_proxy_setup(self, **kwargs: Any) -> None:
|
||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||
return
|
||||
|
|
@ -2017,13 +2046,14 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._narrator_manager.sync()
|
||||
|
||||
if self._banner:
|
||||
ce, ct = _compute_connector_counts(
|
||||
base_config, self.agent_loop.connector_registry
|
||||
)
|
||||
self._banner.set_state(
|
||||
base_config,
|
||||
self.agent_loop.skill_manager,
|
||||
self.agent_loop.mcp_registry,
|
||||
connectors_count=_compute_connectors_count(
|
||||
base_config, self.agent_loop.connector_registry
|
||||
),
|
||||
connectors_enabled=ce,
|
||||
connectors_total=ct,
|
||||
plan_description=plan_title(self._plan_info),
|
||||
)
|
||||
await self._mount_and_scroll(
|
||||
|
|
@ -2287,6 +2317,23 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
)
|
||||
|
||||
async def _switch_to_theme_picker_app(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.ThemePicker:
|
||||
return
|
||||
|
||||
await self._switch_from_input(
|
||||
ThemePickerApp(
|
||||
theme_names=sorted_theme_names(), current_theme=self.config.theme
|
||||
)
|
||||
)
|
||||
|
||||
def _apply_theme(self, theme: str) -> None:
|
||||
if theme not in BUILTIN_THEMES:
|
||||
logger.warning("Unknown theme=%s; falling back to %s", theme, DEFAULT_THEME)
|
||||
self.theme = DEFAULT_THEME
|
||||
return
|
||||
self.theme = theme
|
||||
|
||||
async def _switch_to_proxy_setup_app(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||
return
|
||||
|
|
@ -2340,6 +2387,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.query_one(ConfigApp).focus()
|
||||
case BottomApp.ModelPicker:
|
||||
self.query_one(ModelPickerApp).focus()
|
||||
case BottomApp.ThemePicker:
|
||||
self.query_one(ThemePickerApp).focus()
|
||||
case BottomApp.ThinkingPicker:
|
||||
self.query_one(ThinkingPickerApp).focus()
|
||||
case BottomApp.ProxySetup:
|
||||
|
|
@ -2411,6 +2460,16 @@ class VibeApp(App): # noqa: PLR0904
|
|||
pass
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_theme_picker_app_escape(self) -> None:
|
||||
try:
|
||||
theme_picker = self.query_one(ThemePickerApp)
|
||||
theme_picker.post_message(
|
||||
ThemePickerApp.Cancelled(original_theme=self.config.theme)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_thinking_picker_app_escape(self) -> None:
|
||||
try:
|
||||
thinking_picker = self.query_one(ThinkingPickerApp)
|
||||
|
|
@ -2661,6 +2720,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_question_app_escape()
|
||||
elif self._current_bottom_app == BottomApp.ModelPicker:
|
||||
self._handle_model_picker_app_escape()
|
||||
elif self._current_bottom_app == BottomApp.ThemePicker:
|
||||
self._handle_theme_picker_app_escape()
|
||||
elif self._current_bottom_app == BottomApp.ThinkingPicker:
|
||||
self._handle_thinking_picker_app_escape()
|
||||
elif self._current_bottom_app == BottomApp.SessionPicker:
|
||||
|
|
@ -2781,13 +2842,14 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
def _refresh_banner(self) -> None:
|
||||
if self._banner:
|
||||
ce, ct = _compute_connector_counts(
|
||||
self.config, self.agent_loop.connector_registry
|
||||
)
|
||||
self._banner.set_state(
|
||||
self.config,
|
||||
self.agent_loop.skill_manager,
|
||||
self.agent_loop.mcp_registry,
|
||||
connectors_count=_compute_connectors_count(
|
||||
self.config, self.agent_loop.connector_registry
|
||||
),
|
||||
connectors_enabled=ce,
|
||||
connectors_total=ct,
|
||||
plan_description=plan_title(self._plan_info),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,6 @@
|
|||
$mistral_orange: #FF8205;
|
||||
|
||||
* {
|
||||
scrollbar-color: ansi_black;
|
||||
scrollbar-color-hover: ansi_bright_black;
|
||||
scrollbar-color-active: ansi_bright_black;
|
||||
scrollbar-background: transparent;
|
||||
scrollbar-background-hover: transparent;
|
||||
scrollbar-background-active: transparent;
|
||||
scrollbar-size: 1 1;
|
||||
}
|
||||
|
||||
|
|
@ -20,7 +14,7 @@ Horizontal {
|
|||
}
|
||||
|
||||
TextArea > .text-area--cursor {
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
}
|
||||
|
||||
#chat {
|
||||
|
|
@ -54,6 +48,55 @@ TextArea > .text-area--cursor {
|
|||
background: transparent;
|
||||
}
|
||||
|
||||
#queued-messages {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
padding: 0 0 1 0;
|
||||
}
|
||||
|
||||
#queued-messages-box {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
border: round $surface-lighten-2;
|
||||
border-title-color: $text-muted;
|
||||
border-title-style: bold;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
#queued-messages-list {
|
||||
height: auto;
|
||||
max-height: 8;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#queued-messages-hint {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: $text-muted;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.queued-message-item {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
color: $text-muted;
|
||||
text-style: italic;
|
||||
}
|
||||
|
||||
.queued-message-bash {
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
.queued-message-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#bottom-bar {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
|
|
@ -89,14 +132,19 @@ TextArea > .text-area--cursor {
|
|||
display: none;
|
||||
width: auto;
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
background: $surface;
|
||||
border: solid ansi_bright_black;
|
||||
color: $foreground;
|
||||
background: $background;
|
||||
border: solid $foreground-muted;
|
||||
overflow-y: auto;
|
||||
scrollbar-size-vertical: 1;
|
||||
/* max-height, max-width and padding set in completion_popup.py */
|
||||
}
|
||||
|
||||
OptionList, OptionList:focus {
|
||||
background: $background;
|
||||
background-tint: transparent;
|
||||
}
|
||||
|
||||
#completion-popup _CompletionItem {
|
||||
height: auto;
|
||||
width: 1fr;
|
||||
|
|
@ -106,24 +154,24 @@ TextArea > .text-area--cursor {
|
|||
height: auto;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: solid ansi_bright_black;
|
||||
border: solid $foreground-muted;
|
||||
border-title-align: right;
|
||||
border-title-color: ansi_bright_black;
|
||||
border-title-color: $text-muted;
|
||||
padding: 0 1;
|
||||
|
||||
&.border-warning {
|
||||
border: solid ansi_yellow;
|
||||
border-title-color: ansi_yellow;
|
||||
border: solid $warning;
|
||||
border-title-color: $warning;
|
||||
}
|
||||
|
||||
&.border-safe {
|
||||
border: solid ansi_green;
|
||||
border-title-color: ansi_green;
|
||||
border: solid $success;
|
||||
border-title-color: $success;
|
||||
}
|
||||
|
||||
&.border-error {
|
||||
border: solid ansi_red;
|
||||
border-title-color: ansi_red;
|
||||
border: solid $error;
|
||||
border-title-color: $error;
|
||||
}
|
||||
|
||||
&.border-recording {
|
||||
|
|
@ -157,13 +205,17 @@ TextArea > .text-area--cursor {
|
|||
min-height: 3;
|
||||
max-height: 50vh;
|
||||
background: transparent;
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
border: none;
|
||||
padding: 0;
|
||||
scrollbar-visibility: hidden;
|
||||
|
||||
&.recording {
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -174,10 +226,10 @@ ToastRack {
|
|||
}
|
||||
|
||||
Markdown {
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
|
||||
.code_inline {
|
||||
color: ansi_green;
|
||||
color: $success;
|
||||
background: transparent;
|
||||
text-style: bold;
|
||||
}
|
||||
|
|
@ -199,13 +251,13 @@ Markdown {
|
|||
|
||||
MarkdownBlockQuote {
|
||||
background: transparent;
|
||||
border-left: outer ansi_bright_black;
|
||||
border-left: outer $foreground-muted;
|
||||
}
|
||||
|
||||
MarkdownBullet,
|
||||
MarkdownBulletList,
|
||||
MarkdownOrderedList {
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -240,7 +292,6 @@ Markdown {
|
|||
text-style: bold;
|
||||
padding-left: 1;
|
||||
border-left: heavy $mistral_orange;
|
||||
color: $mistral_orange;
|
||||
}
|
||||
|
||||
.assistant-message {
|
||||
|
|
@ -277,11 +328,15 @@ Markdown {
|
|||
.reasoning-indicator {
|
||||
width: auto;
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
margin-right: 1;
|
||||
|
||||
&.success {
|
||||
color: ansi_green;
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -289,8 +344,12 @@ Markdown {
|
|||
.reasoning-triangle {
|
||||
width: auto;
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
text-style: italic;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
.reasoning-triangle {
|
||||
|
|
@ -303,12 +362,20 @@ Markdown {
|
|||
height: auto;
|
||||
padding: 0 0 0 2;
|
||||
margin: 0;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
text-style: italic;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
|
||||
* {
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
text-style: italic;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
& > MarkdownBlock:last-child {
|
||||
|
|
@ -335,7 +402,7 @@ Markdown {
|
|||
.plan-file-wrapper {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
border: solid ansi_bright_black;
|
||||
border: solid $foreground-muted;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
|
|
@ -372,21 +439,20 @@ Markdown {
|
|||
width: auto;
|
||||
height: 100%;
|
||||
padding: 0 1 0 2;
|
||||
color: ansi_bright_black;
|
||||
}
|
||||
|
||||
.interrupt-content {
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
color: ansi_yellow;
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
color: ansi_red;
|
||||
color: $error;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
|
|
@ -394,7 +460,7 @@ Markdown {
|
|||
width: 1fr;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
color: ansi_yellow;
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
.user-command-content {
|
||||
|
|
@ -441,15 +507,15 @@ Markdown {
|
|||
}
|
||||
|
||||
&.bash-success {
|
||||
color: ansi_green;
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.bash-error {
|
||||
color: ansi_red;
|
||||
color: $error;
|
||||
}
|
||||
|
||||
&.bash-interrupted {
|
||||
color: ansi_yellow;
|
||||
color: $warning;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -468,7 +534,11 @@ Markdown {
|
|||
width: auto;
|
||||
height: 100%;
|
||||
padding: 0 1 0 2;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
.bash-output {
|
||||
|
|
@ -478,7 +548,11 @@ Markdown {
|
|||
|
||||
.unknown-event {
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
StatusMessage {
|
||||
|
|
@ -494,22 +568,22 @@ StatusMessage {
|
|||
.status-indicator-icon {
|
||||
width: auto;
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
margin-right: 1;
|
||||
|
||||
&.success {
|
||||
color: ansi_green;
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: ansi_red;
|
||||
color: $error;
|
||||
}
|
||||
}
|
||||
|
||||
.status-indicator-text {
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
}
|
||||
|
||||
.compact-message,
|
||||
|
|
@ -531,8 +605,12 @@ StatusMessage {
|
|||
.tool-stream-message {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
padding-left: 2;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-result {
|
||||
|
|
@ -542,14 +620,14 @@ StatusMessage {
|
|||
margin-left: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
|
||||
&.error-text {
|
||||
color: ansi_red;
|
||||
color: $error;
|
||||
}
|
||||
|
||||
&.warning-text {
|
||||
color: ansi_yellow;
|
||||
color: $warning;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -564,7 +642,11 @@ StatusMessage {
|
|||
width: auto;
|
||||
height: 100%;
|
||||
padding: 0 1 0 2;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-result-content {
|
||||
|
|
@ -577,7 +659,7 @@ StatusMessage {
|
|||
.tool-result-widget {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
}
|
||||
|
||||
.tool-result-widget Static {
|
||||
|
|
@ -605,7 +687,11 @@ StatusMessage {
|
|||
.tool-call-detail,
|
||||
.tool-result-detail {
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-result-detail {
|
||||
|
|
@ -613,64 +699,76 @@ StatusMessage {
|
|||
}
|
||||
|
||||
.tool-result-error {
|
||||
color: ansi_red;
|
||||
color: $error;
|
||||
}
|
||||
|
||||
.tool-result-warning {
|
||||
color: ansi_yellow;
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
.diff-header {
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
text-style: bold;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
.diff-removed {
|
||||
height: auto;
|
||||
color: ansi_red;
|
||||
color: $error;
|
||||
}
|
||||
|
||||
.diff-added {
|
||||
height: auto;
|
||||
color: ansi_green;
|
||||
color: $success;
|
||||
}
|
||||
|
||||
.diff-range {
|
||||
height: auto;
|
||||
color: ansi_blue;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.diff-context {
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
.todo-empty,
|
||||
.todo-cancelled {
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
.todo-pending {
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
}
|
||||
|
||||
.todo-in_progress {
|
||||
height: auto;
|
||||
color: ansi_yellow;
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
.todo-completed {
|
||||
height: auto;
|
||||
color: ansi_green;
|
||||
color: $success;
|
||||
}
|
||||
|
||||
.loading-widget {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
|
|
@ -698,7 +796,11 @@ StatusMessage {
|
|||
.loading-hint {
|
||||
width: auto;
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-debounce {
|
||||
|
|
@ -724,7 +826,7 @@ StatusMessage {
|
|||
.history-load-more-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: ansi_yellow;
|
||||
color: $warning;
|
||||
text-style: bold;
|
||||
pointer: pointer;
|
||||
padding: 1;
|
||||
|
|
@ -738,7 +840,7 @@ StatusMessage {
|
|||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: solid ansi_bright_black;
|
||||
border: solid $foreground-muted;
|
||||
padding: 0 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -751,7 +853,7 @@ StatusMessage {
|
|||
.settings-title {
|
||||
height: auto;
|
||||
text-style: bold;
|
||||
color: ansi_blue;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
#config-options, #mcp-options, #connectorauth-options {
|
||||
|
|
@ -770,14 +872,18 @@ StatusMessage {
|
|||
|
||||
.settings-help {
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
#proxysetup-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: solid ansi_bright_black;
|
||||
border: solid $foreground-muted;
|
||||
padding: 0 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -795,7 +901,7 @@ StatusMessage {
|
|||
width: 100%;
|
||||
height: auto;
|
||||
border: none;
|
||||
border-left: wide ansi_bright_black;
|
||||
border-left: wide $foreground-muted;
|
||||
padding: 0 0 0 1;
|
||||
}
|
||||
|
||||
|
|
@ -804,7 +910,7 @@ StatusMessage {
|
|||
height: auto;
|
||||
max-height: 70vh;
|
||||
background: transparent;
|
||||
border: solid ansi_bright_black;
|
||||
border: solid $foreground-muted;
|
||||
padding: 0 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -829,7 +935,7 @@ StatusMessage {
|
|||
.approval-title {
|
||||
height: auto;
|
||||
text-style: bold;
|
||||
color: ansi_bright_yellow;
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
.approval-tool-info-container {
|
||||
|
|
@ -850,7 +956,7 @@ StatusMessage {
|
|||
|
||||
.approval-option {
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
margin-bottom: 1;
|
||||
|
||||
&:last-of-type {
|
||||
|
|
@ -860,39 +966,47 @@ StatusMessage {
|
|||
|
||||
.approval-cursor-selected {
|
||||
&.approval-option-yes {
|
||||
color: ansi_green;
|
||||
color: $success;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
&.approval-option-no {
|
||||
color: ansi_bright_red;
|
||||
color: $error;
|
||||
text-style: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.approval-option-selected {
|
||||
&.approval-option-yes {
|
||||
color: ansi_white;
|
||||
color: $foreground;
|
||||
}
|
||||
|
||||
&.approval-option-no {
|
||||
color: ansi_red;
|
||||
color: $error;
|
||||
}
|
||||
}
|
||||
|
||||
.approval-help {
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
.approval-description {
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
.code-block {
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
background: transparent;
|
||||
padding: 1;
|
||||
}
|
||||
|
|
@ -902,7 +1016,7 @@ StatusMessage {
|
|||
height: auto;
|
||||
max-height: 70vh;
|
||||
background: transparent;
|
||||
border: solid ansi_bright_black;
|
||||
border: solid $foreground-muted;
|
||||
padding: 0 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -914,24 +1028,24 @@ StatusMessage {
|
|||
|
||||
.question-tabs {
|
||||
height: auto;
|
||||
color: ansi_blue;
|
||||
color: $primary;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.question-title {
|
||||
height: auto;
|
||||
text-style: bold;
|
||||
color: ansi_blue;
|
||||
color: $primary;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.question-option {
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
}
|
||||
|
||||
.question-option-selected {
|
||||
color: ansi_blue;
|
||||
color: $primary;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
|
|
@ -943,7 +1057,7 @@ StatusMessage {
|
|||
.question-other-prefix {
|
||||
width: auto;
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
}
|
||||
|
||||
.question-other-input {
|
||||
|
|
@ -957,26 +1071,38 @@ StatusMessage {
|
|||
.question-other-static {
|
||||
width: auto;
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
.question-submit {
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
.question-footer-note {
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
text-style: italic;
|
||||
margin-top: 1;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
.question-help {
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
margin-top: 1;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
ExpandingBorder {
|
||||
|
|
@ -992,7 +1118,11 @@ ContextProgress {
|
|||
background: transparent;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
NarratorStatus {
|
||||
|
|
@ -1037,12 +1167,12 @@ NarratorStatus {
|
|||
}
|
||||
|
||||
.banner-meta {
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
#banner-model {
|
||||
color: ansi_cyan;
|
||||
color: $secondary;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
|
|
@ -1055,12 +1185,12 @@ NarratorStatus {
|
|||
}
|
||||
|
||||
.banner-cmd {
|
||||
color: ansi_cyan;
|
||||
color: $secondary;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.petit-chat {
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
width: 12;
|
||||
}
|
||||
|
||||
|
|
@ -1105,7 +1235,7 @@ NarratorStatus {
|
|||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: solid ansi_bright_black;
|
||||
border: solid $foreground-muted;
|
||||
padding: 0 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -1130,8 +1260,12 @@ NarratorStatus {
|
|||
.sessionpicker-help {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
margin-top: 1;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
/* Debug Console */
|
||||
|
|
@ -1143,14 +1277,14 @@ NarratorStatus {
|
|||
min-width: 40;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border-left: solid ansi_bright_black;
|
||||
border-left: solid $foreground-muted;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
#debug-console-header {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
padding: 0 0 1 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
|
@ -1167,7 +1301,7 @@ NarratorStatus {
|
|||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: solid ansi_bright_black;
|
||||
border: solid $foreground-muted;
|
||||
padding: 0 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -1180,7 +1314,7 @@ NarratorStatus {
|
|||
.modelpicker-title {
|
||||
height: auto;
|
||||
text-style: bold;
|
||||
color: ansi_blue;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
#modelpicker-options {
|
||||
|
|
@ -1198,15 +1332,19 @@ NarratorStatus {
|
|||
.modelpicker-help {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
margin-top: 1;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
#thinkingpicker-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: solid ansi_bright_black;
|
||||
border: solid $foreground-muted;
|
||||
padding: 0 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -1219,7 +1357,7 @@ NarratorStatus {
|
|||
.thinkingpicker-title {
|
||||
height: auto;
|
||||
text-style: bold;
|
||||
color: ansi_blue;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
#thinkingpicker-options {
|
||||
|
|
@ -1237,8 +1375,55 @@ NarratorStatus {
|
|||
.thinkingpicker-help {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
margin-top: 1;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
#themepicker-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: solid $foreground-muted;
|
||||
padding: 0 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#themepicker-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.themepicker-title {
|
||||
height: auto;
|
||||
text-style: bold;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
#themepicker-options {
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#themepicker-options:focus {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.themepicker-help {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: $text-muted;
|
||||
margin-top: 1;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
FeedbackBar {
|
||||
|
|
@ -1257,7 +1442,7 @@ FeedbackBar {
|
|||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: solid ansi_bright_black;
|
||||
border: solid $foreground-muted;
|
||||
padding: 0 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -1270,32 +1455,36 @@ FeedbackBar {
|
|||
.rewind-title {
|
||||
height: auto;
|
||||
text-style: bold;
|
||||
color: ansi_blue;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.rewind-option {
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
}
|
||||
|
||||
.rewind-cursor-selected {
|
||||
color: ansi_blue;
|
||||
color: $primary;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.rewind-option-unselected {
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
}
|
||||
|
||||
.rewind-help {
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
#feedback-text {
|
||||
width: auto;
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
}
|
||||
|
||||
.hook-system-message {
|
||||
|
|
@ -1333,17 +1522,21 @@ FeedbackBar {
|
|||
height: auto;
|
||||
max-height: 3;
|
||||
overflow: hidden;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
.hook-severity-ok .hook-system-icon {
|
||||
color: ansi_green;
|
||||
color: $success;
|
||||
}
|
||||
|
||||
.hook-severity-warning .hook-system-icon {
|
||||
color: ansi_yellow;
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
.hook-severity-error .hook-system-icon {
|
||||
color: ansi_red;
|
||||
color: $error;
|
||||
}
|
||||
|
|
|
|||
85
vibe/cli/textual_ui/message_queue.py
Normal file
85
vibe/cli/textual_ui/message_queue.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum, auto
|
||||
|
||||
|
||||
class QueuedItemKind(StrEnum):
|
||||
PROMPT = auto()
|
||||
BASH = auto()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QueuedItem:
|
||||
kind: QueuedItemKind
|
||||
content: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MessageQueue:
|
||||
_items: list[QueuedItem] = field(default_factory=list)
|
||||
_paused: bool = False
|
||||
_on_change: Callable[[], None] | None = None
|
||||
|
||||
def set_change_listener(self, listener: Callable[[], None] | None) -> None:
|
||||
self._on_change = listener
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._items)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._items)
|
||||
|
||||
@property
|
||||
def items(self) -> list[QueuedItem]:
|
||||
return list(self._items)
|
||||
|
||||
@property
|
||||
def paused(self) -> bool:
|
||||
return self._paused
|
||||
|
||||
def append_prompt(self, content: str) -> None:
|
||||
self._items.append(QueuedItem(QueuedItemKind.PROMPT, content))
|
||||
self._notify()
|
||||
|
||||
def append_bash(self, content: str) -> None:
|
||||
self._items.append(QueuedItem(QueuedItemKind.BASH, content))
|
||||
self._notify()
|
||||
|
||||
def pop_last(self) -> QueuedItem | None:
|
||||
if not self._items:
|
||||
return None
|
||||
item = self._items.pop()
|
||||
self._notify()
|
||||
return item
|
||||
|
||||
def pop_first(self) -> QueuedItem | None:
|
||||
if not self._items:
|
||||
return None
|
||||
item = self._items.pop(0)
|
||||
self._notify()
|
||||
return item
|
||||
|
||||
def pause(self) -> None:
|
||||
if self._paused:
|
||||
return
|
||||
self._paused = True
|
||||
self._notify()
|
||||
|
||||
def resume(self) -> None:
|
||||
if not self._paused:
|
||||
return
|
||||
self._paused = False
|
||||
self._notify()
|
||||
|
||||
def clear(self) -> None:
|
||||
if not self._items and not self._paused:
|
||||
return
|
||||
self._items.clear()
|
||||
self._paused = False
|
||||
self._notify()
|
||||
|
||||
def _notify(self) -> None:
|
||||
if self._on_change is not None:
|
||||
self._on_change()
|
||||
|
|
@ -31,15 +31,18 @@ class QuitManager:
|
|||
and (time.monotonic() - self._confirm_time) < QUIT_CONFIRM_DELAY
|
||||
)
|
||||
|
||||
def request_confirmation(self, key: QuitConfirmKey) -> None:
|
||||
def request_confirmation(self, key: QuitConfirmKey, extra: str = "") -> None:
|
||||
if self._confirm_timer is not None:
|
||||
self._confirm_timer.stop()
|
||||
self._confirm_timer = None
|
||||
self._confirm_time = time.monotonic()
|
||||
self._confirm_key = key
|
||||
prompt = f"Press {key} again to quit"
|
||||
if extra:
|
||||
prompt = f"{prompt} ({extra})"
|
||||
try:
|
||||
path_display = self._app.query_one(PathDisplay)
|
||||
path_display.update(f"Press {key} again to quit")
|
||||
path_display.update(prompt)
|
||||
except Exception:
|
||||
pass
|
||||
self._confirm_timer = self._app.set_timer(
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
|
|||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.skills.manager import SkillManager
|
||||
from vibe.core.tools.mcp.registry import MCPRegistry
|
||||
|
||||
|
||||
def _pluralize(count: int, singular: str) -> str:
|
||||
|
|
@ -24,8 +23,10 @@ def _pluralize(count: int, singular: str) -> str:
|
|||
class BannerState:
|
||||
active_model: str = ""
|
||||
models_count: int = 0
|
||||
mcp_servers_count: int = 0
|
||||
connectors_count: int = 0
|
||||
mcp_servers_enabled: int = 0
|
||||
mcp_servers_total: int = 0
|
||||
connectors_enabled: int = 0
|
||||
connectors_total: int = 0
|
||||
skills_count: int = 0
|
||||
plan_description: str | None = None
|
||||
|
||||
|
|
@ -37,8 +38,8 @@ class Banner(Static):
|
|||
self,
|
||||
config: VibeConfig,
|
||||
skill_manager: SkillManager,
|
||||
mcp_registry: MCPRegistry,
|
||||
connectors_count: int = 0,
|
||||
connectors_enabled: int = 0,
|
||||
connectors_total: int = 0,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
|
|
@ -46,8 +47,8 @@ class Banner(Static):
|
|||
self._initial_state = self._build_state(
|
||||
config=config,
|
||||
skill_manager=skill_manager,
|
||||
mcp_registry=mcp_registry,
|
||||
connectors_count=connectors_count,
|
||||
connectors_enabled=connectors_enabled,
|
||||
connectors_total=connectors_total,
|
||||
plan_description=None,
|
||||
)
|
||||
self._animated = not config.disable_welcome_banner_animation
|
||||
|
|
@ -90,40 +91,62 @@ class Banner(Static):
|
|||
self,
|
||||
config: VibeConfig,
|
||||
skill_manager: SkillManager,
|
||||
mcp_registry: MCPRegistry,
|
||||
connectors_count: int = 0,
|
||||
connectors_enabled: int = 0,
|
||||
connectors_total: int = 0,
|
||||
plan_description: str | None = None,
|
||||
) -> None:
|
||||
self.state = self._build_state(
|
||||
config, skill_manager, mcp_registry, connectors_count, plan_description
|
||||
config,
|
||||
skill_manager,
|
||||
connectors_enabled,
|
||||
connectors_total,
|
||||
plan_description,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_state(
|
||||
config: VibeConfig,
|
||||
skill_manager: SkillManager,
|
||||
mcp_registry: MCPRegistry,
|
||||
connectors_count: int = 0,
|
||||
connectors_enabled: int = 0,
|
||||
connectors_total: int = 0,
|
||||
plan_description: str | None = None,
|
||||
) -> BannerState:
|
||||
enabled_servers = [s for s in config.mcp_servers if not s.disabled]
|
||||
mcp_count = mcp_registry.count_loaded(enabled_servers)
|
||||
all_servers = config.mcp_servers
|
||||
enabled_servers = [s for s in all_servers if not s.disabled]
|
||||
|
||||
active_model = config.get_active_model()
|
||||
return BannerState(
|
||||
active_model=f"{active_model.alias}[{active_model.thinking}]",
|
||||
models_count=len(config.models),
|
||||
mcp_servers_count=mcp_count,
|
||||
connectors_count=connectors_count,
|
||||
mcp_servers_enabled=len(enabled_servers),
|
||||
mcp_servers_total=len(all_servers),
|
||||
connectors_enabled=connectors_enabled,
|
||||
connectors_total=connectors_total,
|
||||
skills_count=skill_manager.custom_skills_count,
|
||||
plan_description=plan_description,
|
||||
)
|
||||
|
||||
def _format_meta_counts(self) -> str:
|
||||
parts = [_pluralize(self.state.models_count, "model")]
|
||||
if self.state.connectors_count > 0:
|
||||
parts.append(_pluralize(self.state.connectors_count, "connector"))
|
||||
parts.append(_pluralize(self.state.mcp_servers_count, "MCP server"))
|
||||
# Format as x/y for MCP servers and connectors (only when enabled != total)
|
||||
if self.state.connectors_total > 0:
|
||||
if self.state.connectors_enabled != self.state.connectors_total:
|
||||
connector_str = (
|
||||
f"{self.state.connectors_enabled}/{self.state.connectors_total} connector"
|
||||
+ ("s" if self.state.connectors_total != 1 else "")
|
||||
)
|
||||
else:
|
||||
connector_str = _pluralize(self.state.connectors_enabled, "connector")
|
||||
parts.append(connector_str)
|
||||
# Always show MCP servers count (even if 0/0)
|
||||
if self.state.mcp_servers_enabled != self.state.mcp_servers_total:
|
||||
mcp_str = (
|
||||
f"{self.state.mcp_servers_enabled}/{self.state.mcp_servers_total} MCP server"
|
||||
+ ("s" if self.state.mcp_servers_total != 1 else "")
|
||||
)
|
||||
else:
|
||||
mcp_str = _pluralize(self.state.mcp_servers_enabled, "MCP server")
|
||||
parts.append(mcp_str)
|
||||
parts.append(_pluralize(self.state.skills_count, "skill"))
|
||||
return " · ".join(parts)
|
||||
|
||||
|
|
|
|||
|
|
@ -163,7 +163,9 @@ TRANSITIONS = [
|
|||
|
||||
class PetitChat(Static):
|
||||
def __init__(self, animate: bool = True, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs, classes="banner-chat")
|
||||
classes = kwargs.pop("classes", None)
|
||||
merged_classes = "banner-chat" if classes is None else f"banner-chat {classes}"
|
||||
super().__init__(**kwargs, classes=merged_classes)
|
||||
self._dots = {1j * y + x for y, row in enumerate(STARTING_DOTS) for x in row}
|
||||
self._transition_index = 0
|
||||
self._do_animate = animate
|
||||
|
|
|
|||
|
|
@ -1,49 +1,23 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import time
|
||||
|
||||
from vibe.cli.cache import read_cache, write_cache
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.paths import CACHE_FILE
|
||||
from vibe.core.feedback import record_feedback_asked, should_show_feedback
|
||||
from vibe.core.types import Role
|
||||
|
||||
FEEDBACK_PROBABILITY = 0.2
|
||||
FEEDBACK_COOLDOWN_SECONDS = 3600
|
||||
_CACHE_SECTION = "user_feedback"
|
||||
_LAST_SHOWN_KEY = "last_shown_at"
|
||||
MIN_USER_MESSAGES_FOR_FEEDBACK = 3
|
||||
|
||||
|
||||
class FeedbackBarManager:
|
||||
"""Decides whether to show the feedback bar and records when feedback is given."""
|
||||
|
||||
def should_show(self, agent_loop: AgentLoop) -> bool:
|
||||
if not agent_loop.telemetry_client.is_active():
|
||||
return False
|
||||
|
||||
if not agent_loop.config.is_active_model_mistral():
|
||||
return False
|
||||
|
||||
if (
|
||||
user_message_count = (
|
||||
sum(m.role == Role.user and not m.injected for m in agent_loop.messages)
|
||||
+ 1 # +1 for the message the user just sent
|
||||
< MIN_USER_MESSAGES_FOR_FEEDBACK
|
||||
):
|
||||
return False
|
||||
|
||||
last_ts = (
|
||||
read_cache(CACHE_FILE.path).get(_CACHE_SECTION, {}).get(_LAST_SHOWN_KEY, 0)
|
||||
)
|
||||
if not isinstance(last_ts, int):
|
||||
return False
|
||||
|
||||
return (
|
||||
time.time() - last_ts >= FEEDBACK_COOLDOWN_SECONDS
|
||||
and random.random() <= FEEDBACK_PROBABILITY
|
||||
return should_show_feedback(
|
||||
telemetry_active=agent_loop.telemetry_client.is_active(),
|
||||
is_mistral_model=agent_loop.config.is_active_model_mistral(),
|
||||
user_message_count=user_message_count,
|
||||
)
|
||||
|
||||
def record_feedback_asked(self) -> None:
|
||||
write_cache(
|
||||
CACHE_FILE.path, _CACHE_SECTION, {_LAST_SHOWN_KEY: int(time.time())}
|
||||
)
|
||||
record_feedback_asked()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from textual.worker import Worker
|
|||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.config import ConnectorConfig
|
||||
from vibe.core.tools.connectors import ConnectorRegistry
|
||||
from vibe.core.tools.connectors import ConnectorAuthAction, ConnectorRegistry
|
||||
from vibe.core.tools.mcp.tools import MCPTool
|
||||
from vibe.core.tools.mcp_settings import updated_tool_list
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ _LIST_VIEW_HELP_TOOLS = (
|
|||
"↑↓ Navigate Enter Show tools D Disable E Enable R Refresh Esc Close"
|
||||
)
|
||||
_LIST_VIEW_HELP_AUTH = (
|
||||
"↑↓ Navigate Enter Authenticate D Disable E Enable R Refresh Esc Close"
|
||||
"↑↓ Navigate Enter Connect D Disable E Enable R Refresh Esc Close"
|
||||
)
|
||||
_DETAIL_VIEW_HELP = (
|
||||
"↑↓ Navigate D Disable E Enable Backspace Back R Refresh Esc Close"
|
||||
|
|
@ -246,7 +246,11 @@ class MCPApp(Container):
|
|||
option_id = option.id or ""
|
||||
if option_id.startswith("connector:") and self._connector_registry:
|
||||
name = option_id.removeprefix("connector:")
|
||||
if not self._connector_registry.is_connected(name):
|
||||
if (
|
||||
not self._connector_registry.is_connected(name)
|
||||
and self._connector_registry.get_auth_action(name)
|
||||
== ConnectorAuthAction.OAUTH
|
||||
):
|
||||
return _LIST_VIEW_HELP_AUTH
|
||||
return _LIST_VIEW_HELP_TOOLS
|
||||
|
||||
|
|
@ -461,9 +465,7 @@ class MCPApp(Container):
|
|||
label.append(f" {type_tag:<{max_type}}", style="dim")
|
||||
label.append(f" {_tool_count_text(enabled, total)}", style="dim")
|
||||
if srv.disabled:
|
||||
label.append(" ")
|
||||
label.append("○", style="dim")
|
||||
label.append(" disabled", style="dim")
|
||||
_append_status(label, "○", "dim", "disabled")
|
||||
option_list.add_option(Option(label, id=f"server:{srv.name}"))
|
||||
|
||||
def _list_connectors(self, option_list: OptionList, index: MCPToolIndex) -> None:
|
||||
|
|
@ -485,7 +487,7 @@ class MCPApp(Container):
|
|||
)
|
||||
for cname in ordered_connector_names:
|
||||
cfg = self._find_connector_config(cname)
|
||||
is_disabled = cfg.disabled if cfg else False
|
||||
is_disabled = cfg.disabled if cfg else True
|
||||
connected = (
|
||||
self._connector_registry.is_connected(cname)
|
||||
if self._connector_registry
|
||||
|
|
@ -495,18 +497,24 @@ class MCPApp(Container):
|
|||
label.append(f" {cname:<{max_name}}")
|
||||
label.append(f" {type_tag:<{type_width}}", style="dim")
|
||||
label.append(f" {tool_texts[cname]:<{max_tools}}", style="dim")
|
||||
auth_action = (
|
||||
self._connector_registry.get_auth_action(cname)
|
||||
if self._connector_registry
|
||||
else ConnectorAuthAction.NONE
|
||||
)
|
||||
if is_disabled:
|
||||
label.append(" ")
|
||||
label.append("○", style="dim")
|
||||
label.append(" disabled", style="dim")
|
||||
_append_status(label, "○", "dim", "disabled")
|
||||
elif connected:
|
||||
label.append(" ")
|
||||
label.append("●", style="green")
|
||||
label.append(" connected", style="dim")
|
||||
_append_status(label, "●", "green", "connected")
|
||||
else:
|
||||
label.append(" ")
|
||||
label.append("○", style="dim")
|
||||
label.append(" not connected", style="dim")
|
||||
match auth_action:
|
||||
case ConnectorAuthAction.OAUTH:
|
||||
text = "needs auth"
|
||||
case ConnectorAuthAction.CREDENTIALS_SETUP:
|
||||
text = "needs setup"
|
||||
case _:
|
||||
text = "error - try refreshing"
|
||||
_append_status(label, "○", "dim", text)
|
||||
option_list.add_option(Option(label, id=f"connector:{cname}"))
|
||||
|
||||
# ── detail view ──────────────────────────────────────────────────
|
||||
|
|
@ -537,13 +545,31 @@ class MCPApp(Container):
|
|||
and self._connector_registry
|
||||
and not self._connector_registry.is_connected(server_name)
|
||||
):
|
||||
self.post_message(
|
||||
self.ConnectorAuthRequested(
|
||||
connector_name=server_name,
|
||||
connector_registry=self._connector_registry,
|
||||
tool_manager=self._tool_manager,
|
||||
)
|
||||
)
|
||||
auth_action = self._connector_registry.get_auth_action(server_name)
|
||||
match auth_action:
|
||||
case ConnectorAuthAction.CREDENTIALS_SETUP:
|
||||
option_list.add_option(
|
||||
Option(
|
||||
"Set up credentials in the Mistral dashboard, "
|
||||
"then press R to refresh.",
|
||||
disabled=True,
|
||||
)
|
||||
)
|
||||
case ConnectorAuthAction.OAUTH:
|
||||
self.post_message(
|
||||
self.ConnectorAuthRequested(
|
||||
connector_name=server_name,
|
||||
connector_registry=self._connector_registry,
|
||||
tool_manager=self._tool_manager,
|
||||
)
|
||||
)
|
||||
case _:
|
||||
option_list.add_option(
|
||||
Option(
|
||||
"Connector unavailable; press R to refresh.",
|
||||
disabled=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
option_list.add_option(
|
||||
Option("No tools discovered for this server", disabled=True)
|
||||
|
|
@ -569,6 +595,12 @@ class MCPApp(Container):
|
|||
option_list.highlighted = 0
|
||||
|
||||
|
||||
def _append_status(label: Text, symbol: str, symbol_style: str, text: str) -> None:
|
||||
label.append(" ")
|
||||
label.append(symbol, style=symbol_style)
|
||||
label.append(f" {text}", style="dim")
|
||||
|
||||
|
||||
def _tool_count_text(enabled: int, total: int | None = None) -> str:
|
||||
if total is not None and enabled < total:
|
||||
noun = "tool" if total == 1 else "tools"
|
||||
|
|
|
|||
|
|
@ -17,11 +17,10 @@ from textual.containers import Horizontal, Vertical
|
|||
from textual.css.query import NoMatches
|
||||
from textual.reactive import reactive
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
from textual.widgets import Markdown, Static
|
||||
from textual.widgets._markdown import MarkdownStream
|
||||
from watchfiles import awatch
|
||||
|
||||
from vibe.cli.textual_ui.ansi_markdown import AnsiMarkdown as Markdown
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ class ProxySetupApp(Container):
|
|||
yield NoMarkupStatic("Proxy Configuration", classes="settings-title")
|
||||
|
||||
for key, description in SUPPORTED_PROXY_VARS.items():
|
||||
yield Static(f"[bold ansi_blue]{key}[/]", classes="proxy-label-line")
|
||||
yield Static(f"[bold $primary]{key}[/]", classes="proxy-label-line")
|
||||
|
||||
initial_value = self.initial_values.get(key) or ""
|
||||
input_widget = VscodeCompatInput(
|
||||
|
|
|
|||
97
vibe/cli/textual_ui/widgets/queued_messages.py
Normal file
97
vibe/cli/textual_ui/widgets/queued_messages.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical, VerticalScroll
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.message_queue import MessageQueue, QueuedItem, QueuedItemKind
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
|
||||
|
||||
class QueuedMessageItem(Static):
|
||||
def __init__(self, item: QueuedItem) -> None:
|
||||
super().__init__()
|
||||
self.add_class("queued-message-item")
|
||||
if item.kind == QueuedItemKind.BASH:
|
||||
self.add_class("queued-message-bash")
|
||||
self._item = item
|
||||
|
||||
@property
|
||||
def item(self) -> QueuedItem:
|
||||
return self._item
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
prefix = "$ " if self._item.kind == QueuedItemKind.BASH else ""
|
||||
yield NoMarkupStatic(
|
||||
f"{prefix}{self._item.content}", classes="queued-message-content"
|
||||
)
|
||||
|
||||
|
||||
class QueuedMessages(Widget):
|
||||
DEFAULT_CSS = ""
|
||||
ID = "queued-messages"
|
||||
ID_LIST = "queued-messages-list"
|
||||
ID_HINT = "queued-messages-hint"
|
||||
ID_BOX = "queued-messages-box"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id=self.ID)
|
||||
self._queue: MessageQueue | None = None
|
||||
self._is_job_running: bool = False
|
||||
|
||||
def bind(self, queue: MessageQueue) -> None:
|
||||
self._queue = queue
|
||||
self._queue.set_change_listener(self._on_queue_changed)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id=self.ID_BOX) as box:
|
||||
box.border_title = "Queue"
|
||||
yield VerticalScroll(id=self.ID_LIST)
|
||||
yield NoMarkupStatic("", id=self.ID_HINT)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._refresh()
|
||||
|
||||
def set_job_running(self, running: bool) -> None:
|
||||
if self._is_job_running == running:
|
||||
return
|
||||
self._is_job_running = running
|
||||
self._refresh()
|
||||
|
||||
def _on_queue_changed(self) -> None:
|
||||
if self.is_mounted:
|
||||
self._refresh()
|
||||
|
||||
def _refresh(self) -> None:
|
||||
queue = self._queue
|
||||
if queue is None or len(queue) == 0:
|
||||
self.display = False
|
||||
return
|
||||
|
||||
self.display = True
|
||||
|
||||
try:
|
||||
box = self.query_one(f"#{self.ID_BOX}", Vertical)
|
||||
list_widget = self.query_one(f"#{self.ID_LIST}", VerticalScroll)
|
||||
hint = self.query_one(f"#{self.ID_HINT}", NoMarkupStatic)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
title = f"Queue ({len(queue)})"
|
||||
if queue.paused:
|
||||
title = f"{title} — paused"
|
||||
box.border_title = title
|
||||
|
||||
existing = list(list_widget.query(QueuedMessageItem))
|
||||
for widget in existing:
|
||||
widget.remove()
|
||||
for item in queue.items:
|
||||
list_widget.mount(QueuedMessageItem(item))
|
||||
|
||||
if queue.paused:
|
||||
hint.update("Enter send queue • Ctrl+C drop last queued")
|
||||
elif self._is_job_running:
|
||||
hint.update("Esc cancel job + pause • Ctrl+C drop last queued")
|
||||
else:
|
||||
hint.update("")
|
||||
114
vibe/cli/textual_ui/widgets/theme_picker.py
Normal file
114
vibe/cli/textual_ui/widgets/theme_picker.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.message import Message
|
||||
from textual.theme import BUILTIN_THEMES
|
||||
from textual.timer import Timer
|
||||
from textual.widgets import OptionList
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
|
||||
PREVIEW_DEBOUNCE_SECONDS = 0.1
|
||||
|
||||
|
||||
def sorted_theme_names() -> list[str]:
|
||||
light = sorted(name for name, t in BUILTIN_THEMES.items() if not t.dark)
|
||||
dark = sorted(name for name, t in BUILTIN_THEMES.items() if t.dark)
|
||||
return light + dark
|
||||
|
||||
|
||||
def _build_option_text(theme: str, is_current: bool) -> Text:
|
||||
text = Text(no_wrap=True)
|
||||
marker = "› " if is_current else " "
|
||||
text.append(marker, style="green" if is_current else "")
|
||||
text.append(theme, style="bold" if is_current else "")
|
||||
return text
|
||||
|
||||
|
||||
class ThemePickerApp(Container):
|
||||
can_focus_children = True
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("escape", "cancel", "Cancel", show=False)
|
||||
]
|
||||
|
||||
class ThemeSelected(Message):
|
||||
def __init__(self, theme: str) -> None:
|
||||
self.theme = theme
|
||||
super().__init__()
|
||||
|
||||
class ThemePreviewed(Message):
|
||||
def __init__(self, theme: str) -> None:
|
||||
self.theme = theme
|
||||
super().__init__()
|
||||
|
||||
class Cancelled(Message):
|
||||
def __init__(self, original_theme: str) -> None:
|
||||
self.original_theme = original_theme
|
||||
super().__init__()
|
||||
|
||||
def __init__(
|
||||
self, theme_names: list[str], current_theme: str, **kwargs: Any
|
||||
) -> None:
|
||||
super().__init__(id="themepicker-app", **kwargs)
|
||||
self._theme_names = theme_names
|
||||
self._current_theme = current_theme
|
||||
self._preview_timer: Timer | None = None
|
||||
self._pending_preview: str | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
options = [
|
||||
Option(_build_option_text(name, name == self._current_theme), id=name)
|
||||
for name in self._theme_names
|
||||
]
|
||||
with Vertical(id="themepicker-content"):
|
||||
yield NoMarkupStatic("Select Theme", classes="themepicker-title")
|
||||
yield OptionList(*options, id="themepicker-options")
|
||||
yield NoMarkupStatic(
|
||||
"↑↓ Preview Enter Select Esc Cancel", classes="themepicker-help"
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
option_list = self.query_one(OptionList)
|
||||
for i, name in enumerate(self._theme_names):
|
||||
if name == self._current_theme:
|
||||
option_list.highlighted = i
|
||||
break
|
||||
option_list.focus()
|
||||
|
||||
def on_option_list_option_highlighted(
|
||||
self, event: OptionList.OptionHighlighted
|
||||
) -> None:
|
||||
if not event.option.id:
|
||||
return
|
||||
self._pending_preview = event.option.id
|
||||
if self._preview_timer is not None:
|
||||
self._preview_timer.stop()
|
||||
self._preview_timer = self.set_timer(
|
||||
PREVIEW_DEBOUNCE_SECONDS, self._flush_preview
|
||||
)
|
||||
|
||||
def _flush_preview(self) -> None:
|
||||
if self._pending_preview is None:
|
||||
return
|
||||
self.post_message(self.ThemePreviewed(self._pending_preview))
|
||||
self._pending_preview = None
|
||||
|
||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
if self._preview_timer is not None:
|
||||
self._preview_timer.stop()
|
||||
self._pending_preview = None
|
||||
if event.option.id:
|
||||
self.post_message(self.ThemeSelected(event.option.id))
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
if self._preview_timer is not None:
|
||||
self._preview_timer.stop()
|
||||
self._pending_preview = None
|
||||
self.post_message(self.Cancelled(self._current_theme))
|
||||
|
|
@ -6,9 +6,8 @@ from pathlib import Path
|
|||
from pydantic import BaseModel
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.widgets import Static
|
||||
from textual.widgets import Markdown, Static
|
||||
|
||||
from vibe.cli.textual_ui.ansi_markdown import AnsiMarkdown as Markdown
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.tools.builtins.ask_user_question import AskUserQuestionResult
|
||||
from vibe.core.tools.builtins.bash import BashArgs, BashResult
|
||||
|
|
|
|||
|
|
@ -624,8 +624,17 @@ class AgentLoop: # noqa: PLR0904
|
|||
)
|
||||
|
||||
@requires_init
|
||||
async def inject_user_context(self, content: str) -> None:
|
||||
self.messages.append(LLMMessage(role=Role.user, content=content, injected=True))
|
||||
async def inject_user_context(
|
||||
self, content: str, *, as_message: bool = False
|
||||
) -> None:
|
||||
if as_message:
|
||||
self.messages.append(
|
||||
LLMMessage(role=Role.user, content=content, message_id=str(uuid4()))
|
||||
)
|
||||
else:
|
||||
self.messages.append(
|
||||
LLMMessage(role=Role.user, content=content, injected=True)
|
||||
)
|
||||
await self._save_messages()
|
||||
|
||||
@requires_init
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from vibe.core.config._settings import (
|
|||
DEFAULT_MISTRAL_API_ENV_KEY,
|
||||
DEFAULT_MODELS,
|
||||
DEFAULT_PROVIDERS,
|
||||
DEFAULT_THEME,
|
||||
DEFAULT_TRANSCRIBE_MODELS,
|
||||
DEFAULT_TRANSCRIBE_PROVIDERS,
|
||||
DEFAULT_TTS_MODELS,
|
||||
|
|
@ -44,6 +45,7 @@ from vibe.core.config.layer import (
|
|||
)
|
||||
from vibe.core.config.patch import (
|
||||
AppendToList,
|
||||
ConfigPatch,
|
||||
DeleteField,
|
||||
PatchOp,
|
||||
RemoveFromList,
|
||||
|
|
@ -68,6 +70,7 @@ __all__ = [
|
|||
"DEFAULT_MISTRAL_API_ENV_KEY",
|
||||
"DEFAULT_MODELS",
|
||||
"DEFAULT_PROVIDERS",
|
||||
"DEFAULT_THEME",
|
||||
"DEFAULT_TRANSCRIBE_MODELS",
|
||||
"DEFAULT_TRANSCRIBE_PROVIDERS",
|
||||
"DEFAULT_TTS_MODELS",
|
||||
|
|
@ -78,6 +81,7 @@ __all__ = [
|
|||
"ConfigFragment",
|
||||
"ConfigLayer",
|
||||
"ConfigLayerError",
|
||||
"ConfigPatch",
|
||||
"ConfigSchema",
|
||||
"ConnectorConfig",
|
||||
"DeleteField",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from pydantic_settings import (
|
|||
PydanticBaseSettingsSource,
|
||||
SettingsConfigDict,
|
||||
)
|
||||
from textual.theme import BUILTIN_THEMES
|
||||
import tomli_w
|
||||
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
|
|
@ -491,10 +492,13 @@ DEFAULT_TTS_MODELS = [
|
|||
)
|
||||
]
|
||||
|
||||
DEFAULT_THEME = "ansi-dark"
|
||||
|
||||
|
||||
class VibeConfig(BaseSettings):
|
||||
active_model: str = DEFAULT_ACTIVE_MODEL
|
||||
vim_keybindings: bool = False
|
||||
theme: str = DEFAULT_THEME
|
||||
disable_welcome_banner_animation: bool = False
|
||||
autocopy_to_clipboard: bool = True
|
||||
file_watcher_for_autocomplete: bool = False
|
||||
|
|
@ -524,6 +528,7 @@ class VibeConfig(BaseSettings):
|
|||
vibe_code_task_queue: str | None = Field(default="shared-vibe-nuage", exclude=True)
|
||||
vibe_code_api_key_env_var: str = Field(default="MISTRAL_API_KEY", exclude=True)
|
||||
vibe_code_project_name: str | None = Field(default=None, exclude=True)
|
||||
vibe_code_experimental_nuage_enabled: bool = Field(default=False, exclude=True)
|
||||
|
||||
# TODO(otel): remove exclude=True once the feature is publicly available
|
||||
enable_otel: bool = Field(default=False, exclude=True)
|
||||
|
|
@ -849,6 +854,18 @@ class VibeConfig(BaseSettings):
|
|||
pass
|
||||
return self
|
||||
|
||||
@field_validator("theme", mode="before")
|
||||
@classmethod
|
||||
def _validate_theme(cls, v: Any) -> str:
|
||||
if not isinstance(v, str) or not v:
|
||||
return DEFAULT_THEME
|
||||
if v not in BUILTIN_THEMES:
|
||||
logger.warning(
|
||||
"Unknown theme=%s in config; falling back to %s", v, DEFAULT_THEME
|
||||
)
|
||||
return DEFAULT_THEME
|
||||
return v
|
||||
|
||||
@field_validator("tool_paths", mode="before")
|
||||
@classmethod
|
||||
def _expand_tool_paths(cls, v: Any) -> list[Path]:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from typing import Literal
|
|||
|
||||
from vibe.core.config.harness_files._paths import (
|
||||
GLOBAL_AGENTS_DIR,
|
||||
GLOBAL_AGENTS_SKILLS_DIR,
|
||||
GLOBAL_PROMPTS_DIR,
|
||||
GLOBAL_SKILLS_DIR,
|
||||
GLOBAL_TOOLS_DIR,
|
||||
|
|
@ -96,8 +97,11 @@ class HarnessFilesManager:
|
|||
def user_skills_dirs(self) -> list[Path]:
|
||||
if "user" not in self.sources:
|
||||
return []
|
||||
d = GLOBAL_SKILLS_DIR.path
|
||||
return [d] if d.is_dir() else []
|
||||
return [
|
||||
d
|
||||
for d in (GLOBAL_SKILLS_DIR.path, GLOBAL_AGENTS_SKILLS_DIR.path)
|
||||
if d.is_dir()
|
||||
]
|
||||
|
||||
@property
|
||||
def user_agents_dirs(self) -> list[Path]:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.paths import VIBE_HOME, GlobalPath
|
||||
from vibe.core.paths import AGENTS_HOME, VIBE_HOME, GlobalPath
|
||||
|
||||
GLOBAL_TOOLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "tools")
|
||||
GLOBAL_SKILLS_DIR = GlobalPath(lambda: VIBE_HOME.path / "skills")
|
||||
GLOBAL_AGENTS_DIR = GlobalPath(lambda: VIBE_HOME.path / "agents")
|
||||
GLOBAL_PROMPTS_DIR = GlobalPath(lambda: VIBE_HOME.path / "prompts")
|
||||
GLOBAL_AGENTS_SKILLS_DIR = GlobalPath(lambda: AGENTS_HOME.path / "skills")
|
||||
|
|
|
|||
|
|
@ -4,6 +4,37 @@ from dataclasses import dataclass
|
|||
from typing import Any
|
||||
|
||||
|
||||
class ConfigPatch:
|
||||
"""Declarative, storage-agnostic description of a config delta."""
|
||||
|
||||
def __init__(
|
||||
self, *operations: PatchOp, fingerprint: str, reason: str = ""
|
||||
) -> None:
|
||||
self.operations = list(operations)
|
||||
self.fingerprint = fingerprint
|
||||
self.reason = reason
|
||||
|
||||
def add(self, *operations: PatchOp) -> ConfigPatch:
|
||||
"""Append operations after construction."""
|
||||
self.operations.extend(operations)
|
||||
return self
|
||||
|
||||
def describe(self) -> list[str]:
|
||||
"""Human-readable summary of each operation."""
|
||||
lines: list[str] = []
|
||||
for op in self.operations:
|
||||
match op:
|
||||
case SetField(key=key, value=value):
|
||||
lines.append(f"set {key!r} = {value!r}")
|
||||
case AppendToList(key=key, items=items):
|
||||
lines.append(f"append to {key!r}: {list(items)!r}")
|
||||
case RemoveFromList(key=key, values=values):
|
||||
lines.append(f"remove from {key!r}: {list(values)!r}")
|
||||
case DeleteField(key=key):
|
||||
lines.append(f"delete {key!r}")
|
||||
return lines
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SetField:
|
||||
"""Set a top-level or nested field to a value."""
|
||||
|
|
|
|||
39
vibe/core/feedback.py
Normal file
39
vibe/core/feedback.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import time
|
||||
|
||||
from vibe.cli.cache import read_cache, write_cache
|
||||
from vibe.core.paths import CACHE_FILE
|
||||
|
||||
FEEDBACK_PROBABILITY = 0.2
|
||||
FEEDBACK_COOLDOWN_SECONDS = 3600
|
||||
MIN_USER_MESSAGES_FOR_FEEDBACK = 3
|
||||
_CACHE_SECTION = "user_feedback"
|
||||
_LAST_SHOWN_KEY = "last_shown_at"
|
||||
|
||||
|
||||
def should_show_feedback(
|
||||
*, telemetry_active: bool, is_mistral_model: bool, user_message_count: int
|
||||
) -> bool:
|
||||
if not telemetry_active:
|
||||
return False
|
||||
if not is_mistral_model:
|
||||
return False
|
||||
if user_message_count < MIN_USER_MESSAGES_FOR_FEEDBACK:
|
||||
return False
|
||||
|
||||
last_ts = (
|
||||
read_cache(CACHE_FILE.path).get(_CACHE_SECTION, {}).get(_LAST_SHOWN_KEY, 0)
|
||||
)
|
||||
if not isinstance(last_ts, int):
|
||||
return False
|
||||
|
||||
return (
|
||||
time.time() - last_ts >= FEEDBACK_COOLDOWN_SECONDS
|
||||
and random.random() <= FEEDBACK_PROBABILITY
|
||||
)
|
||||
|
||||
|
||||
def record_feedback_asked() -> None:
|
||||
write_cache(CACHE_FILE.path, _CACHE_SECTION, {_LAST_SHOWN_KEY: int(time.time())})
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.paths._agents_home import AGENTS_HOME
|
||||
from vibe.core.paths._local_config_walk import (
|
||||
WALK_MAX_DEPTH,
|
||||
ConfigWalkResult,
|
||||
|
|
@ -22,6 +23,7 @@ from vibe.core.paths._vibe_home import (
|
|||
from vibe.core.paths.conventions import AGENTS_MD_FILENAME
|
||||
|
||||
__all__ = [
|
||||
"AGENTS_HOME",
|
||||
"AGENTS_MD_FILENAME",
|
||||
"CACHE_FILE",
|
||||
"DEFAULT_TOOL_DIR",
|
||||
|
|
|
|||
14
vibe/core/paths/_agents_home.py
Normal file
14
vibe/core/paths/_agents_home.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from vibe.core.paths._vibe_home import GlobalPath
|
||||
|
||||
_DEFAULT_AGENTS_HOME = Path.home() / ".agents"
|
||||
|
||||
|
||||
def _get_agents_home() -> Path:
|
||||
return _DEFAULT_AGENTS_HOME
|
||||
|
||||
|
||||
AGENTS_HOME = GlobalPath(_get_agents_home)
|
||||
|
|
@ -24,6 +24,7 @@ class SystemPrompt(Prompt):
|
|||
EXPLORE = auto()
|
||||
TESTS = auto()
|
||||
LEAN = auto()
|
||||
MINIMAL = auto()
|
||||
|
||||
|
||||
class UtilityPrompt(Prompt):
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with a local codebase through tools.
|
||||
Today's date is $current_date.
|
||||
CRITICAL: Users complain you are too verbose. Your responses must be minimal. Most tasks need <150 words. Code speaks for itself.
|
||||
|
||||
Phase 1 - Orient
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
You are Mistral Vibe, a CLI coding agent built by Mistral AI. You work on a local codebase using tools.
|
||||
Today's date is $current_date.
|
||||
|
||||
## Instruction hierarchy
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
You are Mistral Vibe, a CLI coding agent built by Mistral AI. You interact with a local codebase through tools.
|
||||
Today's date is $current_date.
|
||||
|
||||
Use markdown when appropriate. Communicate clearly to the user.
|
||||
|
||||
|
|
|
|||
12
vibe/core/prompts/minimal.md
Normal file
12
vibe/core/prompts/minimal.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
You are Mistral Vibe, a CLI coding agent built by Mistral AI. You work on a local codebase through tools.
|
||||
|
||||
**Goal**
|
||||
Finish the task. Prove it works.
|
||||
|
||||
**How**
|
||||
1. Read every file you will edit before touching it.
|
||||
2. Make the minimal change needed.
|
||||
3. Verify: run the relevant test. A passing edit is not done — only passing verification is done.
|
||||
|
||||
**If stuck**
|
||||
Same error twice: stop, re-read the file, change approach.
|
||||
|
|
@ -35,6 +35,9 @@ agents, prompts, logs, and session data live here.
|
|||
vibe.log # Main log file
|
||||
session/ # Session log files
|
||||
plans/ # Session plans
|
||||
|
||||
~/.agents/
|
||||
skills/ # Additional user-level skills directory
|
||||
```
|
||||
|
||||
### Project-Local Configuration
|
||||
|
|
@ -413,6 +416,7 @@ Custom agents are TOML files in `~/.vibe/agents/NAME.toml`.
|
|||
- `/config` - Edit config settings
|
||||
- `/model` - Select active model
|
||||
- `/thinking` - Select thinking level
|
||||
- `/theme` - Select Textual UI theme (persisted in config)
|
||||
- `/reload` - Reload configuration, agent instructions, and skills from disk
|
||||
- `/clear` - Clear conversation history
|
||||
- `/log` - Show path to current interaction log file
|
||||
|
|
@ -466,6 +470,7 @@ Detailed instructions for the model...
|
|||
2. `.vibe/skills/` in trusted project directory
|
||||
3. `.agents/skills/` in trusted project directory
|
||||
4. `~/.vibe/skills/` (user global)
|
||||
5. `~/.agents/skills/` (user global, Agent Skills standard)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import date
|
||||
import html
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
|
@ -168,6 +169,11 @@ def _get_os_system_prompt() -> str:
|
|||
return prompt
|
||||
|
||||
|
||||
def _format_current_date() -> str:
|
||||
today = date.today()
|
||||
return f"{today.isoformat()} ({today.strftime('%A')})"
|
||||
|
||||
|
||||
def _get_windows_system_prompt() -> str:
|
||||
return (
|
||||
"### COMMAND COMPATIBILITY RULES (MUST FOLLOW):\n"
|
||||
|
|
@ -284,6 +290,10 @@ def _resolve_system_prompt(
|
|||
return prompt
|
||||
|
||||
|
||||
def _interpolate_prompt(prompt: str) -> str:
|
||||
return Template(prompt).safe_substitute(current_date=_format_current_date())
|
||||
|
||||
|
||||
def _get_headless_section() -> str:
|
||||
return (
|
||||
"# Headless Mode\n\n"
|
||||
|
|
@ -306,7 +316,7 @@ def get_universal_system_prompt( # noqa: PLR0912
|
|||
headless: bool = False,
|
||||
experiment_manager: ExperimentManager | None = None,
|
||||
) -> str:
|
||||
sections = [_resolve_system_prompt(config, experiment_manager)]
|
||||
sections = [_interpolate_prompt(_resolve_system_prompt(config, experiment_manager))]
|
||||
|
||||
if headless:
|
||||
sections.append(_get_headless_section())
|
||||
|
|
|
|||
120
vibe/core/teleport/experimental_nuage.py
Normal file
120
vibe/core/teleport/experimental_nuage.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from vibe.core.teleport.errors import ServiceTeleportError
|
||||
from vibe.core.utils.http import build_ssl_context
|
||||
|
||||
|
||||
class ExperimentalNuageTextPart(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type: str = "text"
|
||||
text: str
|
||||
|
||||
|
||||
class ExperimentalNuageMessage(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
role: str = "user"
|
||||
parts: list[ExperimentalNuageTextPart]
|
||||
|
||||
|
||||
class ExperimentalNuageRepository(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
repo_url: str = Field(serialization_alias="repoUrl")
|
||||
branch: str | None = None
|
||||
|
||||
|
||||
class ExperimentalNuageContext(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
repositories: list[ExperimentalNuageRepository]
|
||||
|
||||
|
||||
class ExperimentalNuageRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
project_name: str = Field(default="Vibe CLI", serialization_alias="project_name")
|
||||
source: str = "vibe_code_cli"
|
||||
idempotency_key: str = Field(serialization_alias="idempotencyKey")
|
||||
message: ExperimentalNuageMessage
|
||||
context: ExperimentalNuageContext
|
||||
|
||||
|
||||
class ExperimentalNuageResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
nuage_session_id: str = Field(validation_alias="sessionId")
|
||||
nuage_web_session_id: str = Field(validation_alias="webSessionId")
|
||||
nuage_project_id: str = Field(validation_alias="projectId")
|
||||
status: str
|
||||
url: str
|
||||
|
||||
|
||||
class ExperimentalNuageClient:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
timeout: float = 60.0,
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._client = client
|
||||
self._owns_client = client is None
|
||||
self._timeout = timeout
|
||||
|
||||
async def __aenter__(self) -> ExperimentalNuageClient:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self._timeout), verify=build_ssl_context()
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: types.TracebackType | None,
|
||||
) -> None:
|
||||
if self._owns_client and self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
@property
|
||||
def _http_client(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self._timeout), verify=build_ssl_context()
|
||||
)
|
||||
self._owns_client = True
|
||||
return self._client
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def start(
|
||||
self, request: ExperimentalNuageRequest
|
||||
) -> ExperimentalNuageResponse:
|
||||
response = await self._http_client.post(
|
||||
f"{self._base_url}/api/v1/code/sessions",
|
||||
headers=self._headers(),
|
||||
json=request.model_dump(mode="json", by_alias=True, exclude_none=True),
|
||||
)
|
||||
if not response.is_success:
|
||||
raise ServiceTeleportError(f"Vibe Code Nuage start failed: {response.text}")
|
||||
|
||||
try:
|
||||
return ExperimentalNuageResponse.model_validate(response.json())
|
||||
except (ValueError, ValidationError) as e:
|
||||
raise ServiceTeleportError("Vibe Code Nuage response was invalid") from e
|
||||
|
|
@ -5,6 +5,7 @@ import base64
|
|||
from collections.abc import AsyncGenerator
|
||||
from pathlib import Path
|
||||
import types
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import zstandard
|
||||
|
|
@ -12,6 +13,14 @@ import zstandard
|
|||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.session.session_logger import SessionLogger
|
||||
from vibe.core.teleport.errors import ServiceTeleportError
|
||||
from vibe.core.teleport.experimental_nuage import (
|
||||
ExperimentalNuageClient,
|
||||
ExperimentalNuageContext,
|
||||
ExperimentalNuageMessage,
|
||||
ExperimentalNuageRepository,
|
||||
ExperimentalNuageRequest,
|
||||
ExperimentalNuageTextPart,
|
||||
)
|
||||
from vibe.core.teleport.git import GitRepoInfo, GitRepository
|
||||
from vibe.core.teleport.nuage import (
|
||||
ChatAssistantParams,
|
||||
|
|
@ -65,25 +74,34 @@ class TeleportService:
|
|||
self._vibe_code_project_name = (
|
||||
vibe_config.vibe_code_project_name if vibe_config else None
|
||||
)
|
||||
self._experimental_nuage_enabled = (
|
||||
vibe_config.vibe_code_experimental_nuage_enabled if vibe_config else False
|
||||
)
|
||||
self._vibe_config = vibe_config
|
||||
self._git = GitRepository(workdir)
|
||||
self._client = client
|
||||
self._owns_client = client is None
|
||||
self._timeout = timeout
|
||||
self._nuage_client_instance: NuageClient | None = None
|
||||
self._experimental_nuage_client_instance: ExperimentalNuageClient | None = None
|
||||
|
||||
async def __aenter__(self) -> TeleportService:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self._timeout), verify=build_ssl_context()
|
||||
)
|
||||
self._nuage_client_instance = NuageClient(
|
||||
self._vibe_code_base_url,
|
||||
self._vibe_code_api_key,
|
||||
self._vibe_code_workflow_id,
|
||||
task_queue=self._vibe_code_task_queue,
|
||||
client=self._client,
|
||||
)
|
||||
if self._experimental_nuage_enabled:
|
||||
self._experimental_nuage_client_instance = ExperimentalNuageClient(
|
||||
self._vibe_code_base_url, self._vibe_code_api_key, client=self._client
|
||||
)
|
||||
else:
|
||||
self._nuage_client_instance = NuageClient(
|
||||
self._vibe_code_base_url,
|
||||
self._vibe_code_api_key,
|
||||
self._vibe_code_workflow_id,
|
||||
task_queue=self._vibe_code_task_queue,
|
||||
client=self._client,
|
||||
)
|
||||
await self._git.__aenter__()
|
||||
return self
|
||||
|
||||
|
|
@ -119,6 +137,16 @@ class TeleportService:
|
|||
)
|
||||
return self._nuage_client_instance
|
||||
|
||||
@property
|
||||
def _experimental_nuage_client(self) -> ExperimentalNuageClient:
|
||||
if self._experimental_nuage_client_instance is None:
|
||||
self._experimental_nuage_client_instance = ExperimentalNuageClient(
|
||||
self._vibe_code_base_url,
|
||||
self._vibe_code_api_key,
|
||||
client=self._http_client,
|
||||
)
|
||||
return self._experimental_nuage_client_instance
|
||||
|
||||
async def check_supported(self) -> None:
|
||||
await self._git.get_info()
|
||||
|
||||
|
|
@ -141,6 +169,8 @@ class TeleportService:
|
|||
self._validate_config()
|
||||
|
||||
git_info = await self._git.get_info()
|
||||
if self._experimental_nuage_enabled:
|
||||
self._validate_experimental_nuage_git_info(git_info)
|
||||
|
||||
yield TeleportCheckingGitEvent()
|
||||
await self._git.fetch()
|
||||
|
|
@ -165,6 +195,15 @@ class TeleportService:
|
|||
|
||||
yield TeleportStartingWorkflowEvent()
|
||||
|
||||
if self._experimental_nuage_enabled:
|
||||
result = await self._experimental_nuage_client.start(
|
||||
self._build_experimental_nuage_request(
|
||||
lechat_user_message=lechat_user_message, git_info=git_info
|
||||
)
|
||||
)
|
||||
yield TeleportCompleteEvent(url=result.url)
|
||||
return
|
||||
|
||||
execution_id = await self._nuage_client.start_workflow(
|
||||
WorkflowParams(
|
||||
prompt=prompt,
|
||||
|
|
@ -235,6 +274,36 @@ class TeleportService:
|
|||
teleported_diffs=self._compress_diff(git_info.diff or ""),
|
||||
)
|
||||
|
||||
def _build_experimental_nuage_request(
|
||||
self, *, lechat_user_message: str, git_info: GitRepoInfo
|
||||
) -> ExperimentalNuageRequest:
|
||||
if git_info.branch is None:
|
||||
raise ServiceTeleportError(
|
||||
"Experimental Nuage teleport requires a checked-out branch."
|
||||
)
|
||||
|
||||
return ExperimentalNuageRequest(
|
||||
idempotency_key=str(uuid4()),
|
||||
message=ExperimentalNuageMessage(
|
||||
parts=[ExperimentalNuageTextPart(text=lechat_user_message)]
|
||||
),
|
||||
context=ExperimentalNuageContext(
|
||||
repositories=[
|
||||
ExperimentalNuageRepository(
|
||||
repo_url=git_info.remote_url, branch=git_info.branch
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
def _validate_experimental_nuage_git_info(self, git_info: GitRepoInfo) -> None:
|
||||
if git_info.branch is not None:
|
||||
return
|
||||
|
||||
raise ServiceTeleportError(
|
||||
"Experimental Nuage teleport requires a checked-out branch."
|
||||
)
|
||||
|
||||
def _compress_diff(self, diff: str, max_size: int = 1_000_000) -> bytes | None:
|
||||
if not diff:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -239,6 +239,7 @@ class Grep(
|
|||
"rg",
|
||||
"--line-number",
|
||||
"--no-heading",
|
||||
"--with-filename",
|
||||
"--smart-case",
|
||||
"--no-binary",
|
||||
# Request one extra to detect truncation
|
||||
|
|
@ -261,7 +262,7 @@ class Grep(
|
|||
) -> list[str]:
|
||||
max_matches = args.max_matches or self.config.default_max_matches
|
||||
|
||||
cmd = ["grep", "-r", "-n", "-I", "-E", f"--max-count={max_matches + 1}"]
|
||||
cmd = ["grep", "-r", "-n", "-H", "-I", "-E", f"--max-count={max_matches + 1}"]
|
||||
|
||||
if args.pattern.islower():
|
||||
cmd.append("-i")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.tools.connectors.connector_registry import ConnectorRegistry
|
||||
from vibe.core.tools.connectors.connector_registry import (
|
||||
ConnectorAuthAction,
|
||||
ConnectorRegistry,
|
||||
)
|
||||
|
||||
__all__ = ["ConnectorRegistry"]
|
||||
__all__ = ["ConnectorAuthAction", "ConnectorRegistry"]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
from enum import StrEnum
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
|
|
@ -34,6 +35,24 @@ if TYPE_CHECKING:
|
|||
_BOOTSTRAP_TIMEOUT = 30.0
|
||||
|
||||
|
||||
class ConnectorAuthAction(StrEnum):
|
||||
NONE = "none"
|
||||
OAUTH = "oauth"
|
||||
CREDENTIALS_SETUP = "credentials_setup"
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: dict[str, Any] | None) -> ConnectorAuthAction:
|
||||
if not payload:
|
||||
return cls.NONE
|
||||
match payload.get("type"):
|
||||
case "oauth":
|
||||
return cls.OAUTH
|
||||
case "credentials_setup":
|
||||
return cls.CREDENTIALS_SETUP
|
||||
case _:
|
||||
return cls.NONE
|
||||
|
||||
|
||||
def _normalize_name(name: str) -> str:
|
||||
normalized = re.sub(r"[^a-zA-Z0-9_-]", "_", name)
|
||||
result = normalized.strip("_-")[:256]
|
||||
|
|
@ -234,6 +253,7 @@ class ConnectorRegistry:
|
|||
self._cache: dict[str, dict[str, type[BaseTool]]] | None = None
|
||||
self._connector_names: list[str] = []
|
||||
self._connector_connected: dict[str, bool] = {}
|
||||
self._connector_auth_action: dict[str, ConnectorAuthAction] = {}
|
||||
self._alias_to_id: dict[str, str] = {}
|
||||
self._discover_lock = asyncio.Lock()
|
||||
|
||||
|
|
@ -255,10 +275,11 @@ class ConnectorRegistry:
|
|||
base_url = self._server_url or _DEFAULT_BASE_URL
|
||||
url = f"{base_url}/v1/connectors/bootstrap"
|
||||
headers = {"Authorization": f"Bearer {self._api_key}"}
|
||||
params = {"include_auth_actionable_connectors": "true"}
|
||||
async with httpx.AsyncClient(
|
||||
timeout=_BOOTSTRAP_TIMEOUT, verify=build_ssl_context()
|
||||
) as http:
|
||||
response = await http.get(url, headers=headers)
|
||||
response = await http.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
|
@ -311,6 +332,7 @@ class ConnectorRegistry:
|
|||
self._cache = {}
|
||||
self._connector_names = []
|
||||
self._connector_connected = {}
|
||||
self._connector_auth_action = {}
|
||||
self._alias_to_id = {}
|
||||
return {}
|
||||
|
||||
|
|
@ -320,10 +342,15 @@ class ConnectorRegistry:
|
|||
all_tools: dict[str, type[BaseTool]] = {}
|
||||
connector_names: list[str] = []
|
||||
connector_connected: dict[str, bool] = {}
|
||||
connector_auth_action: dict[str, ConnectorAuthAction] = {}
|
||||
|
||||
for connector_id, alias, connector in unique_connectors:
|
||||
connector_names.append(alias)
|
||||
name = connector.get("name") or connector_id
|
||||
auth_action = ConnectorAuthAction.from_payload(
|
||||
connector.get("auth_action")
|
||||
)
|
||||
connector_auth_action[alias] = auth_action
|
||||
|
||||
if bootstrap_errors := connector.get("bootstrap_errors"):
|
||||
logger.warning(
|
||||
|
|
@ -349,6 +376,7 @@ class ConnectorRegistry:
|
|||
# lock will see the completed cache.
|
||||
self._connector_names = connector_names
|
||||
self._connector_connected = connector_connected
|
||||
self._connector_auth_action = connector_auth_action
|
||||
self._alias_to_id = {alias: cid for cid, alias, _ in unique_connectors}
|
||||
self._cache = cache
|
||||
|
||||
|
|
@ -366,6 +394,9 @@ class ConnectorRegistry:
|
|||
def is_connected(self, name: str) -> bool:
|
||||
return self._connector_connected.get(name, False)
|
||||
|
||||
def get_auth_action(self, alias: str) -> ConnectorAuthAction:
|
||||
return self._connector_auth_action.get(alias, ConnectorAuthAction.NONE)
|
||||
|
||||
def get_connector_id(self, alias: str) -> str | None:
|
||||
"""Return the API connector ID for a given alias, or None."""
|
||||
return self._alias_to_id.get(alias)
|
||||
|
|
@ -382,13 +413,21 @@ class ConnectorRegistry:
|
|||
return {}
|
||||
|
||||
tools_map: dict[str, type[BaseTool]] | None = None
|
||||
fresh_auth_action: ConnectorAuthAction | None = None
|
||||
found = False
|
||||
fetch_ok = False
|
||||
try:
|
||||
data = await self._fetch_bootstrap()
|
||||
fetch_ok = True
|
||||
for connector in data.get("connectors") or []:
|
||||
if str(connector.get("id")) != connector_id:
|
||||
continue
|
||||
|
||||
found = True
|
||||
name = connector.get("name") or connector_id
|
||||
fresh_auth_action = ConnectorAuthAction.from_payload(
|
||||
connector.get("auth_action")
|
||||
)
|
||||
status = connector.get("status") or {}
|
||||
if not status.get("is_ready", False):
|
||||
break
|
||||
|
|
@ -406,6 +445,13 @@ class ConnectorRegistry:
|
|||
if self._cache is None:
|
||||
self._cache = {}
|
||||
|
||||
if fetch_ok and not found:
|
||||
self._drop_connector(alias, connector_id)
|
||||
return {}
|
||||
|
||||
if fresh_auth_action is not None:
|
||||
self._connector_auth_action[alias] = fresh_auth_action
|
||||
|
||||
if tools_map is None:
|
||||
self._cache.pop(connector_id, None)
|
||||
self._connector_connected[alias] = False
|
||||
|
|
@ -415,6 +461,15 @@ class ConnectorRegistry:
|
|||
self._connector_connected[alias] = bool(tools_map)
|
||||
return tools_map
|
||||
|
||||
def _drop_connector(self, alias: str, connector_id: str) -> None:
|
||||
if self._cache is not None:
|
||||
self._cache.pop(connector_id, None)
|
||||
self._connector_connected.pop(alias, None)
|
||||
self._connector_auth_action.pop(alias, None)
|
||||
self._alias_to_id.pop(alias, None)
|
||||
if alias in self._connector_names:
|
||||
self._connector_names.remove(alias)
|
||||
|
||||
async def get_auth_url(self, alias: str) -> str | None:
|
||||
"""Return the OAuth authorization URL for a connector, or None.
|
||||
|
||||
|
|
@ -449,4 +504,5 @@ class ConnectorRegistry:
|
|||
self._cache = None
|
||||
self._connector_names = []
|
||||
self._connector_connected = {}
|
||||
self._connector_auth_action = {}
|
||||
self._alias_to_id = {}
|
||||
|
|
|
|||
|
|
@ -254,6 +254,9 @@ class ToolManager:
|
|||
elif srv.disabled_tools:
|
||||
per_source_disabled[key] = set(srv.disabled_tools)
|
||||
|
||||
# Track connector names that have explicit config entries
|
||||
configured_connectors = {cfg.name for cfg in self._config.connectors}
|
||||
|
||||
for cfg in self._config.connectors:
|
||||
key = (cfg.name, True)
|
||||
if cfg.disabled:
|
||||
|
|
@ -261,6 +264,12 @@ class ToolManager:
|
|||
elif cfg.disabled_tools:
|
||||
per_source_disabled[key] = set(cfg.disabled_tools)
|
||||
|
||||
# Disable connectors discovered but not in config (new connectors)
|
||||
if self._connector_registry is not None:
|
||||
for name in self._connector_registry.get_connector_names():
|
||||
if name not in configured_connectors:
|
||||
disabled_sources.add((name, True))
|
||||
|
||||
return disabled_sources, per_source_disabled
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -68,6 +68,12 @@ def _get_candidate_encodings(raw: bytes) -> Iterator[str]:
|
|||
yield best
|
||||
|
||||
|
||||
def normalize_newlines(text: str) -> tuple[str, str]:
|
||||
r"""Return ``text`` with ``\n`` newlines and the detected original style."""
|
||||
newline = _detect_newline(text)
|
||||
return text.replace("\r\n", "\n").replace("\r", "\n"), newline
|
||||
|
||||
|
||||
def decode_safe(raw: bytes, *, raise_on_error: bool = False) -> ReadSafeResult:
|
||||
"""Decode ``raw`` like :func:`read_safe` after ``read_bytes``.
|
||||
|
||||
|
|
@ -85,8 +91,7 @@ def decode_safe(raw: bytes, *, raise_on_error: bool = False) -> ReadSafeResult:
|
|||
errors = "strict" if raise_on_error else "replace"
|
||||
encoding = "utf-8"
|
||||
text = raw.decode(encoding, errors=errors)
|
||||
newline = _detect_newline(text)
|
||||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
text, newline = normalize_newlines(text)
|
||||
return ReadSafeResult(text, encoding, newline)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ from vibe.setup.onboarding.screens import (
|
|||
ApiKeyScreen,
|
||||
AuthMethodScreen,
|
||||
BrowserSignInScreen,
|
||||
ThemeSelectionScreen,
|
||||
WelcomeScreen,
|
||||
)
|
||||
from vibe.setup.onboarding.screens.browser_sign_in import SUCCESS_EXIT_DELAY_SECONDS
|
||||
|
||||
|
||||
class OnboardingApp(App[str | None]):
|
||||
|
|
@ -29,6 +31,7 @@ class OnboardingApp(App[str | None]):
|
|||
browser_sign_in_service_factory: Callable[[], BrowserSignInService]
|
||||
| None = None,
|
||||
entrypoint_metadata: EntrypointMetadata | None = None,
|
||||
browser_sign_in_success_delay: float = SUCCESS_EXIT_DELAY_SECONDS,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
|
|
@ -40,16 +43,20 @@ class OnboardingApp(App[str | None]):
|
|||
self._config = config
|
||||
self._provider = config.provider
|
||||
self._entrypoint_metadata = entrypoint_metadata
|
||||
self._browser_sign_in_success_delay = browser_sign_in_success_delay
|
||||
self._browser_sign_in_service_factory = self._resolve_browser_sign_in_factory(
|
||||
browser_sign_in_service_factory
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.theme = "textual-ansi"
|
||||
self.theme = "ansi-dark"
|
||||
|
||||
welcome_next = "auth_method" if self.supports_browser_sign_in else "api_key"
|
||||
welcome_screen = WelcomeScreen(next_screen=welcome_next)
|
||||
theme_next = "auth_method" if self.supports_browser_sign_in else "api_key"
|
||||
welcome_screen = WelcomeScreen(next_screen="theme_selection")
|
||||
self.install_screen(welcome_screen, "welcome")
|
||||
self.install_screen(
|
||||
ThemeSelectionScreen(next_screen=theme_next), "theme_selection"
|
||||
)
|
||||
self.install_screen(
|
||||
ApiKeyScreen(self._provider, entrypoint_metadata=self._entrypoint_metadata),
|
||||
"api_key",
|
||||
|
|
@ -61,6 +68,7 @@ class OnboardingApp(App[str | None]):
|
|||
self._provider,
|
||||
self._browser_sign_in_service_factory,
|
||||
entrypoint_metadata=self._entrypoint_metadata,
|
||||
success_exit_delay=self._browser_sign_in_success_delay,
|
||||
),
|
||||
"browser_sign_in",
|
||||
)
|
||||
|
|
|
|||
30
vibe/setup/onboarding/gradient_text.py
Normal file
30
vibe/setup/onboarding/gradient_text.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
GRADIENT_COLORS = [
|
||||
"#ff6b00",
|
||||
"#ff7b00",
|
||||
"#ff8c00",
|
||||
"#ff9d00",
|
||||
"#ffae00",
|
||||
"#ffbf00",
|
||||
"#ffae00",
|
||||
"#ff9d00",
|
||||
"#ff8c00",
|
||||
"#ff7b00",
|
||||
]
|
||||
|
||||
|
||||
def gradient_markup(text: str, offset: int) -> str:
|
||||
result = []
|
||||
for index, char in enumerate(text):
|
||||
color = GRADIENT_COLORS[(index + offset) % len(GRADIENT_COLORS)]
|
||||
result.append(f"[bold {color}]{char}[/]")
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def append_gradient_text(content: Text, text: str, offset: int) -> None:
|
||||
for index, char in enumerate(text):
|
||||
color = GRADIENT_COLORS[(index + offset) % len(GRADIENT_COLORS)]
|
||||
content.append(char, style=f"bold {color}")
|
||||
|
|
@ -1,22 +1,22 @@
|
|||
/* =============================================================================
|
||||
Onboarding App Styles
|
||||
============================================================================= */
|
||||
/* Onboarding App Styles */
|
||||
|
||||
$mistral_orange: #FF8205;
|
||||
$mistral_orange_title: #ff5a00;
|
||||
$browser_sign_in_card_border: #303040;
|
||||
|
||||
Screen,
|
||||
OnboardingScreen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Welcome Screen
|
||||
============================================================================= */
|
||||
/* Welcome Screen */
|
||||
|
||||
#welcome-container {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#welcome-text {
|
||||
border: round #555555;
|
||||
border: round $foreground-muted;
|
||||
padding: 1 3;
|
||||
margin-bottom: 2;
|
||||
text-align: center;
|
||||
|
|
@ -24,7 +24,7 @@ OnboardingScreen {
|
|||
}
|
||||
|
||||
WelcomeScreen #enter-hint {
|
||||
color: ansi_bright_black;
|
||||
color: $text;
|
||||
min-width: 16;
|
||||
text-align: center;
|
||||
}
|
||||
|
|
@ -33,100 +33,301 @@ WelcomeScreen #enter-hint.hidden {
|
|||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Theme Selection Screen */
|
||||
|
||||
#theme-outer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align: center middle;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#theme-content {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#theme-title {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
margin-bottom: 2;
|
||||
}
|
||||
|
||||
#theme-row {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#nav-hint {
|
||||
color: $text;
|
||||
width: 16;
|
||||
height: 100%;
|
||||
content-align: right middle;
|
||||
padding-right: 2;
|
||||
}
|
||||
|
||||
#theme-list {
|
||||
width: 30;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.theme-item {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.theme-item.selected {
|
||||
color: $text;
|
||||
text-style: bold;
|
||||
border: round $foreground-muted;
|
||||
padding: 0 2;
|
||||
}
|
||||
|
||||
.theme-item.fade-1 {
|
||||
text-opacity: 50%;
|
||||
}
|
||||
|
||||
.theme-item.fade-2 {
|
||||
text-opacity: 25%;
|
||||
}
|
||||
|
||||
.theme-item.fade-3 {
|
||||
text-opacity: 10%;
|
||||
}
|
||||
|
||||
ThemeSelectionScreen #enter-hint {
|
||||
color: $text;
|
||||
width: 16;
|
||||
height: 100%;
|
||||
content-align: left middle;
|
||||
padding-left: 2;
|
||||
}
|
||||
|
||||
#preview-center {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-top: 2;
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#preview {
|
||||
min-width: 50;
|
||||
max-width: 70;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
border: round $foreground-muted;
|
||||
border-title-align: center;
|
||||
}
|
||||
|
||||
#preview-inner {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 0 1 0 1;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Auth Method Screen
|
||||
============================================================================= */
|
||||
|
||||
#auth-method-content,
|
||||
#browser-sign-in-content {
|
||||
.onboarding-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#auth-method-card,
|
||||
#browser-sign-in-card {
|
||||
border: round #555555;
|
||||
padding: 1 2;
|
||||
width: 72;
|
||||
max-width: 90w;
|
||||
.onboarding-panel {
|
||||
width: 70;
|
||||
max-width: 88w;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#auth-method-title,
|
||||
#browser-sign-in-title {
|
||||
margin-bottom: 1;
|
||||
text-align: center;
|
||||
.onboarding-chat {
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin-left: 2;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.onboarding-chat .petit-chat {
|
||||
width: auto;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.onboarding-heading {
|
||||
width: 100%;
|
||||
margin-left: 2;
|
||||
margin-bottom: 0;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
#auth-method-subtitle,
|
||||
#browser-sign-in-subtitle {
|
||||
color: ansi_bright_black;
|
||||
text-align: center;
|
||||
margin-bottom: 2;
|
||||
#auth-method-title {
|
||||
color: $mistral_orange_title;
|
||||
}
|
||||
|
||||
.auth-method-option {
|
||||
border: round #444444;
|
||||
padding: 1 2;
|
||||
margin-top: 1;
|
||||
#auth-method-subtitle {
|
||||
width: 100%;
|
||||
margin-left: 2;
|
||||
margin-bottom: 2;
|
||||
color: $text;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
#auth-method-options {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.auth-method-option.selected {
|
||||
border: round ansi_bright_yellow;
|
||||
background: #2b2400;
|
||||
.onboarding-option-row {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 0 0;
|
||||
}
|
||||
|
||||
#auth-method-help,
|
||||
#browser-sign-in-hint {
|
||||
color: ansi_bright_black;
|
||||
text-align: center;
|
||||
.auth-method-option-marker {
|
||||
width: 2;
|
||||
height: 3;
|
||||
content-align: center middle;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.auth-method-option-marker.selected {
|
||||
color: $mistral_orange;
|
||||
}
|
||||
|
||||
.onboarding-card {
|
||||
border: solid $browser_sign_in_card_border;
|
||||
padding: 0 1;
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.auth-method-option {
|
||||
color: $foreground-muted;
|
||||
border-title-align: right;
|
||||
border-title-color: $primary;
|
||||
border-title-style: bold;
|
||||
}
|
||||
|
||||
.auth-method-option.selected {
|
||||
border: solid $browser_sign_in_card_border;
|
||||
border-left: solid $mistral_orange;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.auth-method-separator {
|
||||
width: 100%;
|
||||
height: 1;
|
||||
margin: 1 0 1 2;
|
||||
color: $text-muted;
|
||||
text-style: dim;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
.onboarding-hint-row {
|
||||
width: 100%;
|
||||
color: $foreground-muted;
|
||||
margin-top: 2;
|
||||
padding-left: 2;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Browser Sign-In Screen
|
||||
============================================================================= */
|
||||
|
||||
.browser-sign-in-step {
|
||||
border-left: wide #444444;
|
||||
padding-left: 1;
|
||||
#browser-sign-in-subtitle {
|
||||
width: 100%;
|
||||
color: $foreground-muted;
|
||||
margin-left: 2;
|
||||
margin-bottom: 1;
|
||||
color: ansi_bright_black;
|
||||
}
|
||||
|
||||
#browser-sign-in-steps {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.browser-sign-in-step-marker {
|
||||
width: 2;
|
||||
height: 3;
|
||||
margin-top: 0;
|
||||
content-align: center middle;
|
||||
color: $mistral_orange;
|
||||
}
|
||||
|
||||
.browser-sign-in-step-marker.idle,
|
||||
.browser-sign-in-step-marker.done {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.browser-sign-in-step {
|
||||
color: $foreground-muted;
|
||||
}
|
||||
|
||||
.browser-sign-in-step.active {
|
||||
border-left: wide ansi_bright_yellow;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.browser-sign-in-step.done {
|
||||
border-left: wide ansi_green;
|
||||
color: ansi_green;
|
||||
border-left: solid $success;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
#browser-sign-in-status {
|
||||
border: round #444444;
|
||||
padding: 1 2;
|
||||
.browser-sign-in-step.idle {
|
||||
color: $foreground-muted;
|
||||
}
|
||||
|
||||
.browser-sign-in-step-title,
|
||||
.browser-sign-in-step-detail {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-top: 2;
|
||||
padding-left: 2;
|
||||
}
|
||||
|
||||
#browser-sign-in-status.pending {
|
||||
border: round ansi_bright_yellow;
|
||||
.browser-sign-in-step-title {
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
#browser-sign-in-status.error {
|
||||
border: round ansi_red;
|
||||
color: ansi_red;
|
||||
.browser-sign-in-step-title.idle {
|
||||
color: $foreground-muted;
|
||||
text-style: bold dim;
|
||||
}
|
||||
|
||||
#browser-sign-in-status.success {
|
||||
border: round ansi_green;
|
||||
color: ansi_green;
|
||||
.browser-sign-in-step-detail.idle,
|
||||
.browser-sign-in-step-detail.done {
|
||||
color: $foreground-muted;
|
||||
text-style: dim;
|
||||
}
|
||||
|
||||
.browser-sign-in-step-title.active,
|
||||
.browser-sign-in-step-title.done {
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.browser-sign-in-step-detail.pending {
|
||||
color: $mistral_orange;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.browser-sign-in-step-detail.error {
|
||||
color: $error;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.browser-sign-in-step-detail.success {
|
||||
color: $success;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
#browser-sign-in-hint {
|
||||
width: 100%;
|
||||
color: $foreground-muted;
|
||||
text-align: center;
|
||||
margin-top: 1;
|
||||
padding-left: 2;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
|
|
@ -134,94 +335,105 @@ WelcomeScreen #enter-hint.hidden {
|
|||
============================================================================= */
|
||||
|
||||
#api-key-outer {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 1fr;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#api-key-title {
|
||||
text-align: center;
|
||||
color: $mistral_orange_title;
|
||||
}
|
||||
|
||||
#api-key-panel {
|
||||
width: 76;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#api-key-subtitle {
|
||||
width: 100%;
|
||||
margin-left: 2;
|
||||
margin-bottom: 1;
|
||||
color: $text;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
#api-key-provider-link {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-left: 2;
|
||||
margin-bottom: 2;
|
||||
}
|
||||
color: $primary;
|
||||
text-style: underline;
|
||||
pointer: pointer;
|
||||
|
||||
#api-key-content {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#api-key-content Static {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.link-row {
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
.link-chevron {
|
||||
width: auto;
|
||||
&:hover {
|
||||
color: $secondary;
|
||||
text-style: bold underline;
|
||||
}
|
||||
}
|
||||
|
||||
#input-box {
|
||||
border: round #555555;
|
||||
padding: 0 1;
|
||||
margin-top: 2;
|
||||
width: auto;
|
||||
border-title-align: left;
|
||||
border-title-color: $text;
|
||||
border-title-style: bold;
|
||||
margin-left: 2;
|
||||
margin-bottom: 1;
|
||||
width: 100%;
|
||||
height: 3;
|
||||
}
|
||||
|
||||
#input-box.valid {
|
||||
border: round ansi_green;
|
||||
border: solid $success;
|
||||
}
|
||||
|
||||
#input-box.invalid {
|
||||
border: round ansi_red;
|
||||
border: solid $error;
|
||||
}
|
||||
|
||||
#key {
|
||||
border: none;
|
||||
width: 48;
|
||||
width: 100%;
|
||||
height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#paste-hint {
|
||||
margin-top: 3;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
#feedback {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
height: 1;
|
||||
margin-top: 1;
|
||||
color: $text-muted;
|
||||
padding-left: 2;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
#feedback.error {
|
||||
color: ansi_red;
|
||||
color: $error;
|
||||
}
|
||||
|
||||
#feedback.success {
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
#config-docs-section {
|
||||
#config-docs-label {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
align: center top;
|
||||
padding-bottom: 2;
|
||||
height: 1;
|
||||
margin-left: 2;
|
||||
margin-top: 1;
|
||||
color: $text-muted;
|
||||
text-style: dim;
|
||||
}
|
||||
|
||||
#config-docs-group {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
#config-docs-link {
|
||||
width: 100%;
|
||||
height: 1;
|
||||
color: $text;
|
||||
margin-left: 2;
|
||||
text-style: dim;
|
||||
|
||||
#config-docs-group Static {
|
||||
color: ansi_bright_black;
|
||||
}
|
||||
|
||||
#config-docs-group .link-row {
|
||||
margin-top: 0;
|
||||
pointer: pointer;
|
||||
&:hover {
|
||||
color: $primary;
|
||||
text-style: bold underline;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,13 @@ from __future__ import annotations
|
|||
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen
|
||||
from vibe.setup.onboarding.screens.auth_method import AuthMethodScreen
|
||||
from vibe.setup.onboarding.screens.browser_sign_in import BrowserSignInScreen
|
||||
from vibe.setup.onboarding.screens.theme_selection import ThemeSelectionScreen
|
||||
from vibe.setup.onboarding.screens.welcome import WelcomeScreen
|
||||
|
||||
__all__ = ["ApiKeyScreen", "AuthMethodScreen", "BrowserSignInScreen", "WelcomeScreen"]
|
||||
__all__ = [
|
||||
"ApiKeyScreen",
|
||||
"AuthMethodScreen",
|
||||
"BrowserSignInScreen",
|
||||
"ThemeSelectionScreen",
|
||||
"WelcomeScreen",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ from typing import ClassVar
|
|||
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Center, Horizontal, Vertical
|
||||
from textual.containers import Center, Vertical
|
||||
from textual.events import MouseUp
|
||||
from textual.validation import Length
|
||||
from textual.widgets import Input, Link, Static
|
||||
from textual.widgets import Input, Link
|
||||
|
||||
from vibe.cli.clipboard import copy_selection_to_clipboard
|
||||
from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.telemetry.types import EntrypointMetadata
|
||||
|
|
@ -19,9 +20,8 @@ from vibe.setup.auth.api_key_persistence import (
|
|||
)
|
||||
from vibe.setup.onboarding.base import OnboardingScreen
|
||||
|
||||
PROVIDER_HELP = {
|
||||
"mistral": ("https://console.mistral.ai/codestral/cli", "Mistral AI Studio")
|
||||
}
|
||||
# providers with a dedicated key creation page get a direct onboarding link
|
||||
PROVIDER_HELP = {"mistral": ("https://console.mistral.ai/codestral/cli", "AI Studio")}
|
||||
CONFIG_DOCS_URL = (
|
||||
"https://github.com/mistralai/mistral-vibe?tab=readme-ov-file#configuration"
|
||||
)
|
||||
|
|
@ -45,54 +45,51 @@ class ApiKeyScreen(OnboardingScreen):
|
|||
self.provider = resolve_api_key_provider(provider)
|
||||
self._entrypoint_metadata = entrypoint_metadata
|
||||
|
||||
def _compose_provider_link(self, provider_name: str) -> ComposeResult:
|
||||
if self.provider.name not in PROVIDER_HELP:
|
||||
def _compose_provider_link(self) -> ComposeResult:
|
||||
if (help_info := PROVIDER_HELP.get(self.provider.name)) is None:
|
||||
return
|
||||
|
||||
help_url, help_name = PROVIDER_HELP[self.provider.name]
|
||||
yield NoMarkupStatic(f"Grab your {provider_name} API key from the {help_name}:")
|
||||
yield Center(
|
||||
Horizontal(
|
||||
NoMarkupStatic("→ ", classes="link-chevron"),
|
||||
Link(help_url, url=help_url),
|
||||
classes="link-row",
|
||||
)
|
||||
)
|
||||
help_url, _ = help_info
|
||||
yield Link(help_url, url=help_url, id="api-key-provider-link")
|
||||
|
||||
def _compose_config_docs(self) -> ComposeResult:
|
||||
yield Static("[dim]Learn more about Vibe configuration:[/]")
|
||||
yield Horizontal(
|
||||
NoMarkupStatic("→ ", classes="link-chevron"),
|
||||
Link(CONFIG_DOCS_URL, url=CONFIG_DOCS_URL),
|
||||
classes="link-row",
|
||||
yield NoMarkupStatic(
|
||||
"Learn more about Vibe configurations", id="config-docs-label"
|
||||
)
|
||||
yield Link(CONFIG_DOCS_URL, url=CONFIG_DOCS_URL, id="config-docs-link")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
provider_name = self.provider.name.capitalize()
|
||||
help_info = PROVIDER_HELP.get(self.provider.name)
|
||||
help_name = help_info[1] if help_info is not None else "your provider"
|
||||
|
||||
self.input_widget = Input(
|
||||
password=True,
|
||||
id="key",
|
||||
placeholder="Paste your API key here",
|
||||
validators=[Length(minimum=1, failure_description="No API key provided.")],
|
||||
)
|
||||
input_box = Vertical(
|
||||
self.input_widget, id="input-box", classes="onboarding-card"
|
||||
)
|
||||
input_box.border_title = "Paste API key"
|
||||
|
||||
with Vertical(id="api-key-outer"):
|
||||
yield NoMarkupStatic("", classes="spacer")
|
||||
yield Center(NoMarkupStatic("One last thing...", id="api-key-title"))
|
||||
with Vertical(id="api-key-outer", classes="onboarding-content"):
|
||||
with Center():
|
||||
with Vertical(id="api-key-content"):
|
||||
yield from self._compose_provider_link(provider_name)
|
||||
with Vertical(id="api-key-panel", classes="onboarding-panel"):
|
||||
yield PetitChat(id="api-key-chat", classes="onboarding-chat")
|
||||
yield NoMarkupStatic(
|
||||
"...and paste it below to finish the setup:", id="paste-hint"
|
||||
f"Get your {provider_name} API key",
|
||||
id="api-key-title",
|
||||
classes="onboarding-heading",
|
||||
)
|
||||
yield Center(Horizontal(self.input_widget, id="input-box"))
|
||||
yield NoMarkupStatic(
|
||||
f"Visit {help_name} to generate or copy your Vibe key",
|
||||
id="api-key-subtitle",
|
||||
)
|
||||
yield from self._compose_provider_link()
|
||||
yield input_box
|
||||
yield NoMarkupStatic("", id="feedback")
|
||||
yield NoMarkupStatic("", classes="spacer")
|
||||
yield Vertical(
|
||||
Vertical(*self._compose_config_docs(), id="config-docs-group"),
|
||||
id="config-docs-section",
|
||||
)
|
||||
yield from self._compose_config_docs()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.input_widget.focus()
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ from typing import ClassVar
|
|||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Center, Vertical
|
||||
from textual.containers import Center, Horizontal, Vertical
|
||||
|
||||
from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.setup.onboarding.base import OnboardingScreen
|
||||
|
|
@ -28,28 +29,46 @@ class AuthMethodScreen(OnboardingScreen):
|
|||
super().__init__()
|
||||
self.provider = provider
|
||||
self._selected_index = OPTION_BROWSER
|
||||
self._option_markers: list[NoMarkupStatic] = []
|
||||
self._option_widgets: list[NoMarkupStatic] = []
|
||||
self._help_widget: NoMarkupStatic
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="auth-method-content"):
|
||||
with Vertical(id="auth-method-content", classes="onboarding-content"):
|
||||
with Center():
|
||||
with Vertical(id="auth-method-card"):
|
||||
with Vertical(id="auth-method-panel", classes="onboarding-panel"):
|
||||
yield PetitChat(id="auth-method-chat", classes="onboarding-chat")
|
||||
yield NoMarkupStatic(
|
||||
"How would you like to sign in?", id="auth-method-title"
|
||||
"Welcome to Mistral Vibe",
|
||||
id="auth-method-title",
|
||||
classes="onboarding-heading",
|
||||
)
|
||||
yield NoMarkupStatic(
|
||||
"Choose the setup that works best for you.",
|
||||
id="auth-method-subtitle",
|
||||
"Choose your sign in method", id="auth-method-subtitle"
|
||||
)
|
||||
with Vertical(id="auth-method-options"):
|
||||
yield from self._compose_option_rows()
|
||||
self._help_widget = NoMarkupStatic(
|
||||
"", id="auth-method-help", classes="onboarding-hint-row"
|
||||
)
|
||||
self._option_widgets = [
|
||||
NoMarkupStatic("", classes="auth-method-option"),
|
||||
NoMarkupStatic("", classes="auth-method-option"),
|
||||
]
|
||||
yield from self._option_widgets
|
||||
self._help_widget = NoMarkupStatic("", id="auth-method-help")
|
||||
yield self._help_widget
|
||||
|
||||
def _compose_option_rows(self) -> ComposeResult:
|
||||
self._option_markers = []
|
||||
self._option_widgets = []
|
||||
for index in range(2):
|
||||
if index == OPTION_MANUAL:
|
||||
yield NoMarkupStatic("or", classes="auth-method-separator")
|
||||
with Horizontal(classes="auth-method-option-row onboarding-option-row"):
|
||||
marker = NoMarkupStatic("", classes="auth-method-option-marker")
|
||||
option = NoMarkupStatic(
|
||||
"", classes="auth-method-option onboarding-card"
|
||||
)
|
||||
self._option_markers.append(marker)
|
||||
self._option_widgets.append(option)
|
||||
yield marker
|
||||
yield option
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._update_display()
|
||||
self.focus()
|
||||
|
|
@ -75,27 +94,30 @@ class AuthMethodScreen(OnboardingScreen):
|
|||
self._update_display()
|
||||
|
||||
def _update_display(self) -> None:
|
||||
provider_name = self.provider.name.capitalize()
|
||||
options = [
|
||||
(
|
||||
"Sign in with your browser",
|
||||
f"Sign in to {provider_name} to finish setup automatically.",
|
||||
"Launch browser",
|
||||
"Recommended",
|
||||
"Sign in to Mistral AI Studio and finish setup automatically.",
|
||||
),
|
||||
("Use an API key", "Paste an existing API key to sign in manually."),
|
||||
("Use an API key", None, "Already have a key? Paste it manually instead."),
|
||||
]
|
||||
|
||||
for index, (widget, (title, description)) in enumerate(
|
||||
zip(self._option_widgets, options, strict=True)
|
||||
for index, (marker, widget, (title, badge, description)) in enumerate(
|
||||
zip(self._option_markers, self._option_widgets, options, strict=True)
|
||||
):
|
||||
is_selected = index == self._selected_index
|
||||
prefix = "›" if is_selected else " "
|
||||
content = Text()
|
||||
content.append(f"{prefix} ")
|
||||
content.append(title, style="bold")
|
||||
content.append(f"\n {description}")
|
||||
content.append("\n")
|
||||
content.append(description, style="dim")
|
||||
marker.update(">" if is_selected else "")
|
||||
marker.remove_class("selected")
|
||||
widget.border_title = badge
|
||||
widget.update(content)
|
||||
widget.remove_class("selected")
|
||||
if is_selected:
|
||||
marker.add_class("selected")
|
||||
widget.add_class("selected")
|
||||
|
||||
self._help_widget.update("↑↓ Choose · Enter to select · Esc Cancel")
|
||||
self._help_widget.update("Use arrows to navigate - Enter Select - Esc Cancel")
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@ from dataclasses import dataclass
|
|||
from enum import IntEnum
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Center, Vertical
|
||||
from textual.containers import Center, Horizontal, Vertical
|
||||
from textual.reactive import reactive
|
||||
from textual.timer import Timer
|
||||
from textual.worker import Worker
|
||||
|
||||
from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.config import ProviderConfig
|
||||
from vibe.core.logger import logger
|
||||
|
|
@ -27,10 +30,18 @@ from vibe.setup.auth.api_key_persistence import (
|
|||
resolve_api_key_provider,
|
||||
)
|
||||
from vibe.setup.onboarding.base import OnboardingScreen
|
||||
from vibe.setup.onboarding.gradient_text import GRADIENT_COLORS, append_gradient_text
|
||||
|
||||
PENDING_HINT = "Press M to enter API key manually · Esc to cancel"
|
||||
ERROR_HINT = "Press R to retry · Press M to enter API key manually · Esc to cancel"
|
||||
STEP_DESCRIPTIONS = ["Open your browser", "Sign in and return here", "Finish setup"]
|
||||
PENDING_HINT = "Press M to enter API key manually - Esc to cancel"
|
||||
ERROR_HINT = "Press R to retry - Press M to enter API key manually - Esc to cancel"
|
||||
SUCCESS_HINT = "Finishing setup..."
|
||||
SUCCESS_EXIT_DELAY_SECONDS: float = 2.0
|
||||
WAITING_FOR_AUTHENTICATION_MESSAGE = "Waiting for authentication..."
|
||||
STEP_DESCRIPTIONS = [
|
||||
("Open browser", "Your browser should open automatically", "Browser opened"),
|
||||
("Complete sign-in", WAITING_FOR_AUTHENTICATION_MESSAGE, "Sign-in confirmed."),
|
||||
("Finished setup", "Vibe will start automatically", "Setup complete."),
|
||||
]
|
||||
UNEXPECTED_ERROR_MESSAGE = (
|
||||
"Something went wrong during browser sign-in. Please try again."
|
||||
)
|
||||
|
|
@ -55,6 +66,14 @@ class BrowserSignInViewState:
|
|||
running: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserSignInStepWidgets:
|
||||
marker: NoMarkupStatic
|
||||
card: Vertical
|
||||
title: NoMarkupStatic
|
||||
detail: NoMarkupStatic
|
||||
|
||||
|
||||
class BrowserSignInScreen(OnboardingScreen):
|
||||
state = reactive(
|
||||
BrowserSignInViewState(
|
||||
|
|
@ -80,14 +99,18 @@ class BrowserSignInScreen(OnboardingScreen):
|
|||
browser_sign_in_factory: Callable[[], BrowserSignInService],
|
||||
*,
|
||||
entrypoint_metadata: EntrypointMetadata | None = None,
|
||||
success_exit_delay: float = SUCCESS_EXIT_DELAY_SECONDS,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.provider = provider
|
||||
self._browser_sign_in_factory = browser_sign_in_factory
|
||||
self._entrypoint_metadata = entrypoint_metadata
|
||||
self._success_exit_delay = success_exit_delay
|
||||
self._attempt_number = 0
|
||||
self._active_attempt_number: int | None = None
|
||||
self._worker: Worker[None] | None = None
|
||||
self._gradient_offset = 0
|
||||
self._gradient_timer: Timer | None = None
|
||||
self._initial_state = BrowserSignInViewState(
|
||||
step=BrowserSignInStep.OPEN,
|
||||
message="Getting things ready...",
|
||||
|
|
@ -95,45 +118,57 @@ class BrowserSignInScreen(OnboardingScreen):
|
|||
variant="pending",
|
||||
running=False,
|
||||
)
|
||||
self._step_widgets: list[NoMarkupStatic] = []
|
||||
self._step_widgets: list[BrowserSignInStepWidgets] = []
|
||||
self._title_widget: NoMarkupStatic
|
||||
self._subtitle_widget: NoMarkupStatic
|
||||
self._status_widget: NoMarkupStatic
|
||||
self._hint_widget: NoMarkupStatic
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="browser-sign-in-content"):
|
||||
with Vertical(id="browser-sign-in-content", classes="onboarding-content"):
|
||||
with Center():
|
||||
with Vertical(id="browser-sign-in-card"):
|
||||
with Vertical(id="browser-sign-in-panel", classes="onboarding-panel"):
|
||||
yield PetitChat(
|
||||
id="browser-sign-in-chat", classes="onboarding-chat"
|
||||
)
|
||||
self._title_widget = NoMarkupStatic(
|
||||
"Sign in with your browser", id="browser-sign-in-title"
|
||||
"Launch browser",
|
||||
id="browser-sign-in-title",
|
||||
classes="onboarding-heading",
|
||||
)
|
||||
yield self._title_widget
|
||||
self._subtitle_widget = NoMarkupStatic(
|
||||
"", id="browser-sign-in-subtitle"
|
||||
yield NoMarkupStatic(
|
||||
"Your browser should open automatically",
|
||||
id="browser-sign-in-subtitle",
|
||||
)
|
||||
yield self._subtitle_widget
|
||||
self._step_widgets = [
|
||||
NoMarkupStatic("", classes="browser-sign-in-step"),
|
||||
NoMarkupStatic("", classes="browser-sign-in-step"),
|
||||
NoMarkupStatic("", classes="browser-sign-in-step"),
|
||||
]
|
||||
yield from self._step_widgets
|
||||
yield NoMarkupStatic("", id="browser-sign-in-status")
|
||||
with Vertical(id="browser-sign-in-steps"):
|
||||
yield from self._compose_step_rows()
|
||||
yield NoMarkupStatic("", id="browser-sign-in-hint")
|
||||
|
||||
def _compose_step_rows(self) -> ComposeResult:
|
||||
self._step_widgets = []
|
||||
for _ in STEP_DESCRIPTIONS:
|
||||
with Horizontal(classes="browser-sign-in-step-row onboarding-option-row"):
|
||||
marker = NoMarkupStatic("", classes="browser-sign-in-step-marker")
|
||||
yield marker
|
||||
with Vertical(classes="browser-sign-in-step onboarding-card") as card:
|
||||
title = NoMarkupStatic("", classes="browser-sign-in-step-title")
|
||||
detail = NoMarkupStatic("", classes="browser-sign-in-step-detail")
|
||||
self._step_widgets.append(
|
||||
BrowserSignInStepWidgets(marker, card, title, detail)
|
||||
)
|
||||
yield title
|
||||
yield detail
|
||||
|
||||
def on_mount(self) -> None:
|
||||
provider_name = self.provider.name.capitalize()
|
||||
self._subtitle_widget.update(
|
||||
f"Continue with {provider_name} to finish setup automatically."
|
||||
)
|
||||
self._hint_widget = self.query_one("#browser-sign-in-hint", NoMarkupStatic)
|
||||
self._status_widget = self.query_one("#browser-sign-in-status", NoMarkupStatic)
|
||||
self.state = self._initial_state
|
||||
self.watch_state(self.state)
|
||||
self._gradient_timer = self.set_interval(0.08, self._animate_gradient)
|
||||
self.call_after_refresh(self._start_browser_sign_in)
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
if self._gradient_timer is not None:
|
||||
self._gradient_timer.stop()
|
||||
self._gradient_timer = None
|
||||
self._cancel_current_attempt()
|
||||
|
||||
def action_retry(self) -> None:
|
||||
|
|
@ -141,10 +176,14 @@ class BrowserSignInScreen(OnboardingScreen):
|
|||
self._start_browser_sign_in()
|
||||
|
||||
def action_manual(self) -> None:
|
||||
if self.state.variant == "success":
|
||||
return
|
||||
self._cancel_current_attempt()
|
||||
self.app.switch_screen("api_key")
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
if self.state.variant == "success":
|
||||
return
|
||||
self._cancel_current_attempt()
|
||||
super().action_cancel()
|
||||
|
||||
|
|
@ -211,15 +250,29 @@ class BrowserSignInScreen(OnboardingScreen):
|
|||
if api_key is None:
|
||||
msg = "Browser sign-in finished without returning an API key."
|
||||
raise AssertionError(msg)
|
||||
result = persist_api_key(
|
||||
resolve_api_key_provider(self.provider),
|
||||
api_key,
|
||||
entrypoint_metadata=self._entrypoint_metadata,
|
||||
)
|
||||
if result != "completed":
|
||||
self._active_attempt_number = None
|
||||
self._worker = None
|
||||
self.app.exit(result)
|
||||
return
|
||||
|
||||
self.state = BrowserSignInViewState(
|
||||
step=BrowserSignInStep.FINISH,
|
||||
message="Sign-in complete",
|
||||
hint=SUCCESS_HINT,
|
||||
variant="success",
|
||||
running=True,
|
||||
)
|
||||
if self._success_exit_delay > 0:
|
||||
await asyncio.sleep(self._success_exit_delay)
|
||||
self._active_attempt_number = None
|
||||
self._worker = None
|
||||
self.app.exit(
|
||||
persist_api_key(
|
||||
resolve_api_key_provider(self.provider),
|
||||
api_key,
|
||||
entrypoint_metadata=self._entrypoint_metadata,
|
||||
)
|
||||
)
|
||||
self.app.exit(result)
|
||||
|
||||
def _on_status(self, attempt_number: int, status: BrowserSignInStatus) -> None:
|
||||
if not self._is_attempt_active(attempt_number):
|
||||
|
|
@ -242,7 +295,7 @@ class BrowserSignInScreen(OnboardingScreen):
|
|||
variant="pending",
|
||||
running=True,
|
||||
)
|
||||
case BrowserSignInStatus.EXCHANGING:
|
||||
case BrowserSignInStatus.EXCHANGING | BrowserSignInStatus.COMPLETED:
|
||||
state = BrowserSignInViewState(
|
||||
step=BrowserSignInStep.FINISH,
|
||||
message="Finishing setup...",
|
||||
|
|
@ -250,14 +303,6 @@ class BrowserSignInScreen(OnboardingScreen):
|
|||
variant="pending",
|
||||
running=True,
|
||||
)
|
||||
case BrowserSignInStatus.COMPLETED:
|
||||
state = BrowserSignInViewState(
|
||||
step=BrowserSignInStep.FINISH,
|
||||
message="You're signed in. Finishing setup...",
|
||||
hint=PENDING_HINT,
|
||||
variant="success",
|
||||
running=True,
|
||||
)
|
||||
case _:
|
||||
return
|
||||
|
||||
|
|
@ -267,26 +312,64 @@ class BrowserSignInScreen(OnboardingScreen):
|
|||
if not self.is_mounted:
|
||||
return
|
||||
|
||||
self._status_widget.update(state.message)
|
||||
self._status_widget.remove_class("pending", "error", "success")
|
||||
self._status_widget.add_class(state.variant)
|
||||
self._hint_widget.update(state.hint)
|
||||
|
||||
for index, (widget, description) in enumerate(
|
||||
for index, (widgets, (title, pending_detail, done_detail)) in enumerate(
|
||||
zip(self._step_widgets, STEP_DESCRIPTIONS, strict=True)
|
||||
):
|
||||
if index < state.step:
|
||||
prefix = "✓"
|
||||
detail = done_detail
|
||||
widget_class = "done"
|
||||
elif index == state.step:
|
||||
prefix = "›"
|
||||
detail = pending_detail
|
||||
widget_class = "active"
|
||||
else:
|
||||
prefix = "·"
|
||||
detail = pending_detail
|
||||
widget_class = "idle"
|
||||
widget.update(f"{prefix} {description}")
|
||||
widget.remove_class("done", "active", "idle")
|
||||
widget.add_class(widget_class)
|
||||
|
||||
widgets.title.update(title)
|
||||
widgets.title.remove_class("done", "active", "idle")
|
||||
widgets.title.add_class(widget_class)
|
||||
widgets.detail.remove_class("done", "active", "idle")
|
||||
widgets.detail.remove_class("pending", "error", "success")
|
||||
if widget_class == "active":
|
||||
self._update_active_step_detail(widgets.detail, state)
|
||||
else:
|
||||
widgets.detail.update(detail)
|
||||
widgets.detail.add_class(widget_class)
|
||||
widgets.marker.update(">" if widget_class == "active" else "")
|
||||
widgets.marker.remove_class("done", "active", "idle")
|
||||
widgets.marker.add_class(widget_class)
|
||||
widgets.card.remove_class("done", "active", "idle")
|
||||
widgets.card.add_class(widget_class)
|
||||
|
||||
def _update_active_step_detail(
|
||||
self, detail: NoMarkupStatic, state: BrowserSignInViewState
|
||||
) -> None:
|
||||
if state.variant == "pending" and state.step == BrowserSignInStep.CONFIRM:
|
||||
content = Text()
|
||||
append_gradient_text(
|
||||
content, WAITING_FOR_AUTHENTICATION_MESSAGE, self._gradient_offset
|
||||
)
|
||||
detail.update(content)
|
||||
detail.add_class("pending")
|
||||
return
|
||||
|
||||
if state.variant == "error":
|
||||
detail.update(state.message)
|
||||
detail.add_class("error")
|
||||
return
|
||||
|
||||
detail.update(state.message)
|
||||
detail.add_class(state.variant)
|
||||
|
||||
def _animate_gradient(self) -> None:
|
||||
self._gradient_offset = (self._gradient_offset + 1) % len(GRADIENT_COLORS)
|
||||
if (
|
||||
self.state.variant == "pending"
|
||||
and self.state.step == BrowserSignInStep.CONFIRM
|
||||
):
|
||||
self.watch_state(self.state)
|
||||
|
||||
async def _close_browser_sign_in(
|
||||
self, browser_sign_in: BrowserSignInService | None
|
||||
|
|
|
|||
141
vibe/setup/onboarding/screens/theme_selection.py
Normal file
141
vibe/setup/onboarding/screens/theme_selection.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Center, Container, Horizontal, Vertical
|
||||
from textual.events import Resize
|
||||
from textual.widgets import Markdown, Static
|
||||
|
||||
from vibe.cli.textual_ui.widgets.theme_picker import sorted_theme_names
|
||||
from vibe.core.config import MissingAPIKeyError, VibeConfig
|
||||
from vibe.core.logger import logger
|
||||
from vibe.setup.onboarding.base import OnboardingScreen
|
||||
|
||||
THEMES = sorted_theme_names()
|
||||
|
||||
VISIBLE_NEIGHBORS = 3
|
||||
FADE_CLASSES = ["fade-1", "fade-2", "fade-3"]
|
||||
|
||||
PREVIEW_MARKDOWN = """\
|
||||
### Heading
|
||||
|
||||
**Bold**, *italic*, and `inline code`.
|
||||
|
||||
- Bullet point
|
||||
- Another bullet point
|
||||
|
||||
1. First item
|
||||
2. Second item
|
||||
|
||||
```python
|
||||
def greet(name: str = "World") -> str:
|
||||
return f"Hello, {name}!"
|
||||
```
|
||||
|
||||
> Blockquote
|
||||
|
||||
---
|
||||
|
||||
| Column 1 | Column 2 |
|
||||
|----------|----------|
|
||||
| Item 1 | Item 2 |
|
||||
"""
|
||||
|
||||
|
||||
class ThemeSelectionScreen(OnboardingScreen):
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("enter", "next", "Next", show=False, priority=True),
|
||||
Binding("up", "prev_theme", "Previous", show=False),
|
||||
Binding("down", "next_theme", "Next Theme", show=False),
|
||||
Binding("ctrl+c", "cancel", "Cancel", show=False),
|
||||
Binding("escape", "cancel", "Cancel", show=False),
|
||||
]
|
||||
|
||||
NEXT_SCREEN = "api_key"
|
||||
|
||||
def __init__(self, next_screen: str = "api_key") -> None:
|
||||
super().__init__()
|
||||
self.NEXT_SCREEN = next_screen
|
||||
self._theme_index = 0
|
||||
self._theme_widgets: list[Static] = []
|
||||
|
||||
def _compose_theme_list(self) -> ComposeResult:
|
||||
for _ in range(VISIBLE_NEIGHBORS * 2 + 1):
|
||||
widget = Static("", classes="theme-item")
|
||||
self._theme_widgets.append(widget)
|
||||
yield widget
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Center(id="theme-outer"):
|
||||
with Vertical(id="theme-content"):
|
||||
yield Static("Select your preferred theme", id="theme-title")
|
||||
yield Center(
|
||||
Horizontal(
|
||||
Static("Navigate ↑ ↓", id="nav-hint"),
|
||||
Vertical(*self._compose_theme_list(), id="theme-list"),
|
||||
Static("Press Enter ↵", id="enter-hint"),
|
||||
id="theme-row",
|
||||
)
|
||||
)
|
||||
with Container(id="preview-center"):
|
||||
preview = Container(id="preview")
|
||||
preview.border_title = "Preview"
|
||||
with preview:
|
||||
yield Container(Markdown(PREVIEW_MARKDOWN), id="preview-inner")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
current_theme = self.app.theme
|
||||
if current_theme in THEMES:
|
||||
self._theme_index = THEMES.index(current_theme)
|
||||
self._update_display()
|
||||
self._update_preview_height()
|
||||
self.focus()
|
||||
|
||||
def on_resize(self, _: Resize) -> None:
|
||||
self._update_preview_height()
|
||||
|
||||
def _update_preview_height(self) -> None:
|
||||
preview = self.query_one("#preview", Container)
|
||||
header_height = 17
|
||||
available = self.app.size.height - header_height
|
||||
preview.styles.max_height = max(7, available)
|
||||
|
||||
def _get_theme_at_offset(self, offset: int) -> str:
|
||||
index = (self._theme_index + offset) % len(THEMES)
|
||||
return THEMES[index]
|
||||
|
||||
def _update_display(self) -> None:
|
||||
for i, widget in enumerate(self._theme_widgets):
|
||||
offset = i - VISIBLE_NEIGHBORS
|
||||
theme = self._get_theme_at_offset(offset)
|
||||
|
||||
widget.remove_class("selected", *FADE_CLASSES)
|
||||
|
||||
if offset == 0:
|
||||
widget.update(f" {theme} ")
|
||||
widget.add_class("selected")
|
||||
else:
|
||||
distance = min(abs(offset) - 1, len(FADE_CLASSES) - 1)
|
||||
widget.update(theme)
|
||||
widget.add_class(FADE_CLASSES[distance])
|
||||
|
||||
def _navigate(self, direction: int) -> None:
|
||||
self._theme_index = (self._theme_index + direction) % len(THEMES)
|
||||
self.app.theme = THEMES[self._theme_index]
|
||||
self._update_display()
|
||||
|
||||
def action_next_theme(self) -> None:
|
||||
self._navigate(1)
|
||||
|
||||
def action_prev_theme(self) -> None:
|
||||
self._navigate(-1)
|
||||
|
||||
def action_next(self) -> None:
|
||||
theme = THEMES[self._theme_index]
|
||||
try:
|
||||
VibeConfig.save_updates({"theme": theme})
|
||||
except (OSError, MissingAPIKeyError) as e:
|
||||
logger.warning("Failed to persist theme=%s: %s", theme, e)
|
||||
super().action_next()
|
||||
|
|
@ -10,6 +10,7 @@ from textual.widgets import Static
|
|||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.setup.onboarding.base import OnboardingScreen
|
||||
from vibe.setup.onboarding.gradient_text import GRADIENT_COLORS, gradient_markup
|
||||
|
||||
WELCOME_PREFIX = "Welcome to "
|
||||
WELCOME_HIGHLIGHT = "Mistral Vibe"
|
||||
|
|
@ -21,27 +22,6 @@ HIGHLIGHT_END = HIGHLIGHT_START + len(WELCOME_HIGHLIGHT)
|
|||
|
||||
BUTTON_TEXT = "Press Enter ↵"
|
||||
|
||||
GRADIENT_COLORS = [
|
||||
"#ff6b00",
|
||||
"#ff7b00",
|
||||
"#ff8c00",
|
||||
"#ff9d00",
|
||||
"#ffae00",
|
||||
"#ffbf00",
|
||||
"#ffae00",
|
||||
"#ff9d00",
|
||||
"#ff8c00",
|
||||
"#ff7b00",
|
||||
]
|
||||
|
||||
|
||||
def _apply_gradient(text: str, offset: int) -> str:
|
||||
result = []
|
||||
for i, char in enumerate(text):
|
||||
color = GRADIENT_COLORS[(i + offset) % len(GRADIENT_COLORS)]
|
||||
result.append(f"[bold {color}]{char}[/]")
|
||||
return "".join(result)
|
||||
|
||||
|
||||
class WelcomeScreen(OnboardingScreen):
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
|
|
@ -50,9 +30,9 @@ class WelcomeScreen(OnboardingScreen):
|
|||
Binding("escape", "cancel", "Cancel", show=False),
|
||||
]
|
||||
|
||||
NEXT_SCREEN = "api_key"
|
||||
NEXT_SCREEN = "theme_selection"
|
||||
|
||||
def __init__(self, next_screen: str = "api_key") -> None:
|
||||
def __init__(self, next_screen: str = "theme_selection") -> None:
|
||||
super().__init__()
|
||||
self.NEXT_SCREEN = next_screen
|
||||
self._char_index = 0
|
||||
|
|
@ -86,7 +66,7 @@ class WelcomeScreen(OnboardingScreen):
|
|||
|
||||
prefix = text[:HIGHLIGHT_START]
|
||||
highlight_len = min(length, HIGHLIGHT_END) - HIGHLIGHT_START
|
||||
highlight = _apply_gradient(
|
||||
highlight = gradient_markup(
|
||||
WELCOME_HIGHLIGHT[:highlight_len], self._gradient_offset
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ class TrustFolderApp(App):
|
|||
self._quit_without_saving = False
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.theme = "textual-ansi"
|
||||
self.theme = "ansi-dark"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield TrustFolderDialog(self.folder_path, self.detected_files)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ Screen {
|
|||
#trust-dialog {
|
||||
max-width: 70;
|
||||
overflow-y: scroll;
|
||||
border: round ansi_bright_black;
|
||||
border: round $border-blurred;
|
||||
background: transparent;
|
||||
height:auto;
|
||||
max-height: 1fr;
|
||||
|
|
@ -28,7 +28,7 @@ Screen {
|
|||
width: 100%;
|
||||
height: auto;
|
||||
text-style: bold;
|
||||
color: ansi_yellow;
|
||||
color: $warning;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ Screen {
|
|||
#trust-dialog-path {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_blue;
|
||||
color: $primary;
|
||||
text-align: center;
|
||||
text-wrap: wrap;
|
||||
margin-bottom: 1;
|
||||
|
|
@ -45,7 +45,7 @@ Screen {
|
|||
.trust-dialog-section-header {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
text-align: center;
|
||||
text-wrap: wrap;
|
||||
padding: 0 2;
|
||||
|
|
@ -61,7 +61,7 @@ Screen {
|
|||
width: auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
text-align: center;
|
||||
text-overflow: fold;
|
||||
}
|
||||
|
|
@ -69,11 +69,11 @@ Screen {
|
|||
.trust-dialog-footer-warning {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_yellow;
|
||||
color: $warning;
|
||||
text-align: center;
|
||||
text-style: bold;
|
||||
border-top: solid ansi_bright_black;
|
||||
border-bottom: solid ansi_bright_black;
|
||||
border-top: solid $border-blurred;
|
||||
border-bottom: solid $border-blurred;
|
||||
margin-bottom: 1;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
|
@ -88,33 +88,41 @@ Screen {
|
|||
.trust-option {
|
||||
height: auto;
|
||||
width: auto;
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
margin: 0 3;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
.trust-cursor-selected {
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.trust-option-selected {
|
||||
color: ansi_default;
|
||||
color: $foreground;
|
||||
}
|
||||
|
||||
.trust-dialog-help {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
||||
.trust-dialog-save-info {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
text-style: italic;
|
||||
text-wrap: wrap;
|
||||
|
||||
&:ansi {
|
||||
text-style: dim;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# What's new in v2.10.0
|
||||
# What's new in v2.11.0
|
||||
|
||||
- **Multi-repo sessions**: Use `--add-dir` to pull additional repository roots into your session
|
||||
- **Improved plan mode**: Plan is now displayed as live-updating markdown with Ctrl+G to edit in your default editor
|
||||
- **Shared skills**: Vibe now loads skills from `~/.agents/skills` so they can be reused across agents
|
||||
- **Connector onboarding from the CLI**: `/mcp` lists unauthenticated connectors and lets you start the OAuth flow without leaving Vibe
|
||||
- **Theme picker is back**: Pick a theme during onboarding (`vibe --setup`) or switch any time with `/theme`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue