2.1.0 (#317)
Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai> Co-authored-by: Michel Thomazo <michel.thomazo@mistral.ai> Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai> Co-authored-by: Clément Siriex <clement.sirieix@mistral.ai> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Nicolas Karolak <nicolas@karolak.fr>
This commit is contained in:
parent
9809cfc831
commit
51fecc67d9
176 changed files with 8652 additions and 4451 deletions
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
VIBE_ROOT = Path(__file__).parent
|
||||
__version__ = "2.0.2"
|
||||
__version__ = "2.1.0"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from acp.helpers import ContentBlock, SessionUpdate
|
|||
from acp.schema import (
|
||||
AgentCapabilities,
|
||||
AgentMessageChunk,
|
||||
AgentThoughtChunk,
|
||||
AllowedOutcome,
|
||||
AuthenticateResponse,
|
||||
AuthMethod,
|
||||
|
|
@ -73,6 +74,7 @@ from vibe.core.types import (
|
|||
AsyncApprovalCallback,
|
||||
CompactEndEvent,
|
||||
CompactStartEvent,
|
||||
ReasoningEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
ToolStreamEvent,
|
||||
|
|
@ -173,7 +175,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
async def new_session(
|
||||
self,
|
||||
cwd: str,
|
||||
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio],
|
||||
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> NewSessionResponse:
|
||||
load_dotenv_values()
|
||||
|
|
@ -295,8 +297,8 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
async def load_session(
|
||||
self,
|
||||
cwd: str,
|
||||
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio],
|
||||
session_id: str,
|
||||
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> LoadSessionResponse | None:
|
||||
raise NotImplementedError()
|
||||
|
|
@ -464,6 +466,13 @@ class VibeAcpAgentLoop(AcpAgent):
|
|||
field_meta={"messageId": event.message_id},
|
||||
)
|
||||
|
||||
elif isinstance(event, ReasoningEvent):
|
||||
yield AgentThoughtChunk(
|
||||
session_update="agent_thought_chunk",
|
||||
content=TextContentBlock(type="text", text=event.content),
|
||||
field_meta={"messageId": event.message_id},
|
||||
)
|
||||
|
||||
elif isinstance(event, ToolCallEvent):
|
||||
if issubclass(event.tool_class, BaseAcpTool):
|
||||
event.tool_class.update_tool_state(
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
|
|||
max_bytes = self.config.max_output_bytes
|
||||
|
||||
try:
|
||||
terminal_handle = await client.create_terminal(
|
||||
terminal = await client.create_terminal(
|
||||
session_id=session_id,
|
||||
command=args.command,
|
||||
cwd=str(Path.cwd()),
|
||||
|
|
@ -49,7 +49,7 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
|
|||
except Exception as e:
|
||||
raise ToolError(f"Failed to create terminal: {e!r}") from e
|
||||
|
||||
terminal_id = terminal_handle.id
|
||||
terminal_id = terminal.terminal_id
|
||||
|
||||
await self._send_in_progress_session_update([
|
||||
TerminalToolCallContent(type="terminal", terminal_id=terminal_id)
|
||||
|
|
|
|||
|
|
@ -180,6 +180,7 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
run_textual_ui(
|
||||
agent_loop=agent_loop,
|
||||
initial_prompt=args.initial_prompt or stdin_prompt,
|
||||
teleport_on_start=args.teleport,
|
||||
)
|
||||
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
|
|
|
|||
|
|
@ -1,13 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from collections.abc import Callable
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pyperclip
|
||||
from textual.app import App
|
||||
|
||||
_PREVIEW_MAX_LENGTH = 40
|
||||
|
|
@ -24,33 +19,6 @@ def _copy_osc52(text: str) -> None:
|
|||
tty.flush()
|
||||
|
||||
|
||||
def _copy_x11_clipboard(text: str) -> None:
|
||||
subprocess.run(
|
||||
["xclip", "-selection", "clipboard"], input=text.encode("utf-8"), check=True
|
||||
)
|
||||
|
||||
|
||||
def _copy_wayland_clipboard(text: str) -> None:
|
||||
subprocess.run(["wl-copy"], input=text.encode("utf-8"), check=True)
|
||||
|
||||
|
||||
def _has_cmd(cmd: str) -> bool:
|
||||
return shutil.which(cmd) is not None
|
||||
|
||||
|
||||
def _get_copy_fns(app: App) -> list[Callable[[str], None]]:
|
||||
copy_fns: list[Callable[[str], None]] = [
|
||||
_copy_osc52,
|
||||
pyperclip.copy,
|
||||
app.copy_to_clipboard,
|
||||
]
|
||||
if platform.system() == "Linux" and _has_cmd("wl-copy"):
|
||||
copy_fns = [_copy_wayland_clipboard, *copy_fns]
|
||||
if platform.system() == "Linux" and _has_cmd("xclip"):
|
||||
copy_fns = [_copy_x11_clipboard, *copy_fns]
|
||||
return copy_fns
|
||||
|
||||
|
||||
def _shorten_preview(texts: list[str]) -> str:
|
||||
dense_text = "⏎".join(texts).replace("\n", "⏎")
|
||||
if len(dense_text) > _PREVIEW_MAX_LENGTH:
|
||||
|
|
@ -58,7 +26,7 @@ def _shorten_preview(texts: list[str]) -> str:
|
|||
return dense_text
|
||||
|
||||
|
||||
def copy_selection_to_clipboard(app: App) -> None:
|
||||
def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> None:
|
||||
selected_texts = []
|
||||
|
||||
for widget in app.query("*"):
|
||||
|
|
@ -84,27 +52,16 @@ def copy_selection_to_clipboard(app: App) -> None:
|
|||
|
||||
combined_text = "\n".join(selected_texts)
|
||||
|
||||
success = False
|
||||
copy_fns = _get_copy_fns(app)
|
||||
|
||||
for copy_fn in copy_fns:
|
||||
try:
|
||||
copy_fn(combined_text)
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
success = True
|
||||
|
||||
if success:
|
||||
try:
|
||||
_copy_osc52(combined_text)
|
||||
if show_toast:
|
||||
app.notify(
|
||||
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
|
||||
severity="information",
|
||||
timeout=2,
|
||||
markup=False,
|
||||
)
|
||||
except Exception:
|
||||
app.notify(
|
||||
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
|
||||
severity="information",
|
||||
timeout=2,
|
||||
markup=False,
|
||||
)
|
||||
else:
|
||||
app.notify(
|
||||
"Failed to copy - no clipboard method available",
|
||||
severity="warning",
|
||||
timeout=3,
|
||||
"Failed to copy - clipboard not available", severity="warning", timeout=3
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ class CommandRegistry:
|
|||
handler="_show_help",
|
||||
),
|
||||
"config": Command(
|
||||
aliases=frozenset(["/config", "/theme", "/model"]),
|
||||
aliases=frozenset(["/config", "/model"]),
|
||||
description="Edit config settings",
|
||||
handler="_show_config",
|
||||
),
|
||||
|
|
@ -62,6 +62,11 @@ class CommandRegistry:
|
|||
description="Display agent statistics",
|
||||
handler="_show_status",
|
||||
),
|
||||
"teleport": Command(
|
||||
aliases=frozenset(["/teleport"]),
|
||||
description="Teleport session to Vibe Nuage",
|
||||
handler="_teleport_command",
|
||||
),
|
||||
}
|
||||
|
||||
for command in excluded_commands:
|
||||
|
|
@ -86,7 +91,6 @@ class CommandRegistry:
|
|||
"- `Ctrl+C` Quit (or clear input if text present)",
|
||||
"- `Ctrl+G` Edit input in external editor",
|
||||
"- `Ctrl+O` Toggle tool output view",
|
||||
"- `Ctrl+T` Toggle todo view",
|
||||
"- `Shift+Tab` Toggle auto-approve mode",
|
||||
"",
|
||||
"### Special Features",
|
||||
|
|
|
|||
|
|
@ -84,6 +84,9 @@ def parse_arguments() -> argparse.Namespace:
|
|||
help="Change to this directory before running",
|
||||
)
|
||||
|
||||
# Feature flag for teleport, not exposed to the user yet
|
||||
parser.add_argument("--teleport", action="store_true", help=argparse.SUPPRESS)
|
||||
|
||||
continuation_group = parser.add_mutually_exclusive_group()
|
||||
continuation_group.add_argument(
|
||||
"-c",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
from os import getenv
|
||||
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import (
|
||||
WhoAmIGateway,
|
||||
|
|
@ -9,6 +10,7 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
|
|||
WhoAmIGatewayUnauthorized,
|
||||
WhoAmIResponse,
|
||||
)
|
||||
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, Backend, ProviderConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -62,3 +64,24 @@ def _action_and_plan_from_response(
|
|||
return PlanOfferAction.UPGRADE, PlanType.FREE
|
||||
case _:
|
||||
return PlanOfferAction.NONE, PlanType.UNKNOWN
|
||||
|
||||
|
||||
def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None:
|
||||
api_env_key = DEFAULT_MISTRAL_API_ENV_KEY
|
||||
|
||||
if provider.backend == Backend.MISTRAL:
|
||||
api_env_key = provider.api_key_env_var
|
||||
|
||||
return getenv(api_env_key)
|
||||
|
||||
|
||||
def plan_offer_cta(action: PlanOfferAction) -> str | None:
|
||||
if action is PlanOfferAction.NONE:
|
||||
return
|
||||
url = ACTION_TO_URL[action]
|
||||
match action:
|
||||
case PlanOfferAction.UPGRADE:
|
||||
text = f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({url})"
|
||||
case PlanOfferAction.SWITCH_TO_PRO_KEY:
|
||||
text = f"### Switch to your [Le Chat Pro API key]({url})"
|
||||
return text
|
||||
|
|
|
|||
|
|
@ -272,117 +272,29 @@ def _setup_iterm2() -> SetupResult:
|
|||
|
||||
|
||||
def _setup_wezterm() -> SetupResult:
|
||||
wezterm_config = Path.home() / ".wezterm.lua"
|
||||
|
||||
key_binding = """{
|
||||
key = "Enter",
|
||||
mods = "SHIFT",
|
||||
action = wezterm.action.SendString("\\x1b[13;2u"),
|
||||
}"""
|
||||
|
||||
try:
|
||||
if wezterm_config.exists():
|
||||
content = wezterm_config.read_text()
|
||||
|
||||
if 'mods = "SHIFT"' in content and 'key = "Enter"' in content:
|
||||
return SetupResult(
|
||||
success=True,
|
||||
terminal=Terminal.WEZTERM,
|
||||
message="Shift+Enter already configured in WezTerm",
|
||||
)
|
||||
|
||||
if "keys = {" in content:
|
||||
content = content.replace("keys = {", f"keys = {{\n {key_binding},")
|
||||
else:
|
||||
return SetupResult(
|
||||
success=False,
|
||||
terminal=Terminal.WEZTERM,
|
||||
message="Please manually add the following to your .wezterm.lua:\n\n"
|
||||
f" keys = {{\n {key_binding}\n }}",
|
||||
)
|
||||
else:
|
||||
content = f"""local wezterm = require 'wezterm'
|
||||
|
||||
return {{
|
||||
keys = {{
|
||||
{key_binding}
|
||||
}},
|
||||
}}
|
||||
"""
|
||||
|
||||
wezterm_config.write_text(content)
|
||||
|
||||
return SetupResult(
|
||||
success=True,
|
||||
terminal=Terminal.WEZTERM,
|
||||
message=f"Added Shift+Enter binding to {wezterm_config}",
|
||||
requires_restart=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return SetupResult(
|
||||
success=False,
|
||||
terminal=Terminal.WEZTERM,
|
||||
message=f"Failed to configure WezTerm: {e}",
|
||||
)
|
||||
return SetupResult(
|
||||
success=True,
|
||||
terminal=Terminal.WEZTERM,
|
||||
message="Please manually add the following to your .wezterm.lua:\n"
|
||||
"local wezterm = require 'wezterm'\n"
|
||||
"local config = wezterm.config_builder()\n\n"
|
||||
"config.keys = {\n"
|
||||
" {\n"
|
||||
' key = "Enter",\n'
|
||||
' mods = "SHIFT",\n'
|
||||
' action = wezterm.action.SendString("\\x1b[13;2u"),\n'
|
||||
" }\n"
|
||||
"}\n\n"
|
||||
"return config",
|
||||
)
|
||||
|
||||
|
||||
def _setup_ghostty() -> SetupResult:
|
||||
system = platform.system()
|
||||
|
||||
if system == "Darwin":
|
||||
config_path = (
|
||||
Path.home()
|
||||
/ "Library"
|
||||
/ "Application Support"
|
||||
/ "com.mitchellh.ghostty"
|
||||
/ "config"
|
||||
)
|
||||
elif system == "Linux":
|
||||
xdg_config = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
|
||||
config_path = Path(xdg_config) / "ghostty" / "config"
|
||||
else:
|
||||
return SetupResult(
|
||||
success=False,
|
||||
terminal=Terminal.GHOSTTY,
|
||||
message="Ghostty configuration path unknown for this OS",
|
||||
)
|
||||
|
||||
keybind_line = "keybind = shift+enter=text:\\x1b[13;2u"
|
||||
|
||||
try:
|
||||
if config_path.exists():
|
||||
content = config_path.read_text()
|
||||
|
||||
if "shift+enter" in content.lower():
|
||||
return SetupResult(
|
||||
success=True,
|
||||
terminal=Terminal.GHOSTTY,
|
||||
message="Shift+Enter already configured in Ghostty",
|
||||
)
|
||||
|
||||
if not content.endswith("\n"):
|
||||
content += "\n"
|
||||
content += keybind_line + "\n"
|
||||
else:
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = keybind_line + "\n"
|
||||
|
||||
config_path.write_text(content)
|
||||
|
||||
return SetupResult(
|
||||
success=True,
|
||||
terminal=Terminal.GHOSTTY,
|
||||
message=f"Added Shift+Enter binding to {config_path}",
|
||||
requires_restart=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return SetupResult(
|
||||
success=False,
|
||||
terminal=Terminal.GHOSTTY,
|
||||
message=f"Failed to configure Ghostty: {e}",
|
||||
)
|
||||
return SetupResult(
|
||||
success=True,
|
||||
terminal=Terminal.GHOSTTY,
|
||||
message="Shift+Enter is already configured in Ghostty",
|
||||
)
|
||||
|
||||
|
||||
def setup_terminal() -> SetupResult:
|
||||
|
|
|
|||
58
vibe/cli/textual_ui/ansi_markdown.py
Normal file
58
vibe/cli/textual_ui/ansi_markdown.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pygments.token import Token
|
||||
from textual.content import Content
|
||||
from textual.highlight import HighlightTheme, highlight
|
||||
from textual.widgets import Markdown
|
||||
from textual.widgets._markdown import MarkdownFence
|
||||
|
||||
|
||||
class AnsiHighlightTheme(HighlightTheme):
|
||||
STYLES = {
|
||||
Token.Comment: "ansi_bright_black italic",
|
||||
Token.Error: "ansi_red",
|
||||
Token.Generic.Strong: "bold",
|
||||
Token.Generic.Emph: "italic",
|
||||
Token.Generic.Error: "ansi_red",
|
||||
Token.Generic.Heading: "ansi_blue underline",
|
||||
Token.Generic.Subheading: "ansi_blue",
|
||||
Token.Keyword: "ansi_magenta",
|
||||
Token.Keyword.Constant: "ansi_cyan",
|
||||
Token.Keyword.Namespace: "ansi_magenta",
|
||||
Token.Keyword.Type: "ansi_cyan",
|
||||
Token.Literal.Number: "ansi_yellow",
|
||||
Token.Literal.String.Backtick: "ansi_bright_black",
|
||||
Token.Literal.String: "ansi_green",
|
||||
Token.Literal.String.Doc: "ansi_green italic",
|
||||
Token.Literal.String.Double: "ansi_green",
|
||||
Token.Name: "ansi_default",
|
||||
Token.Name.Attribute: "ansi_yellow",
|
||||
Token.Name.Builtin: "ansi_cyan",
|
||||
Token.Name.Builtin.Pseudo: "italic",
|
||||
Token.Name.Class: "ansi_yellow",
|
||||
Token.Name.Constant: "ansi_red",
|
||||
Token.Name.Decorator: "ansi_blue",
|
||||
Token.Name.Function: "ansi_blue",
|
||||
Token.Name.Function.Magic: "ansi_blue",
|
||||
Token.Name.Tag: "ansi_blue",
|
||||
Token.Name.Variable: "ansi_default",
|
||||
Token.Number: "ansi_yellow",
|
||||
Token.Operator: "ansi_default",
|
||||
Token.Operator.Word: "ansi_magenta",
|
||||
Token.String: "ansi_green",
|
||||
Token.Whitespace: "",
|
||||
}
|
||||
|
||||
|
||||
class AnsiMarkdownFence(MarkdownFence):
|
||||
@classmethod
|
||||
def highlight(cls, code: str, language: str) -> Content:
|
||||
return highlight(code, language=language or None, theme=AnsiHighlightTheme)
|
||||
|
||||
|
||||
class AnsiMarkdown(Markdown):
|
||||
BLOCKS = {
|
||||
**Markdown.BLOCKS,
|
||||
"fence": AnsiMarkdownFence,
|
||||
"code_block": AnsiMarkdownFence,
|
||||
}
|
||||
|
|
@ -2,16 +2,16 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
from enum import StrEnum, auto
|
||||
from os import getenv
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any, ClassVar, assert_never, cast
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
from pydantic import BaseModel
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Horizontal, VerticalScroll
|
||||
from textual.containers import Horizontal, VerticalGroup, VerticalScroll
|
||||
from textual.events import AppBlur, AppFocus, MouseUp
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
|
@ -21,31 +21,27 @@ from vibe.cli.clipboard import copy_selection_to_clipboard
|
|||
from vibe.cli.commands import CommandRegistry
|
||||
from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway
|
||||
from vibe.cli.plan_offer.decide_plan_offer import (
|
||||
ACTION_TO_URL,
|
||||
PlanOfferAction,
|
||||
PlanType,
|
||||
decide_plan_offer,
|
||||
plan_offer_cta,
|
||||
resolve_api_key_for_plan,
|
||||
)
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway
|
||||
from vibe.cli.terminal_setup import setup_terminal
|
||||
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
|
||||
from vibe.cli.textual_ui.terminal_theme import (
|
||||
TERMINAL_THEME_NAME,
|
||||
capture_terminal_theme,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.agent_indicator import AgentIndicator
|
||||
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
|
||||
from vibe.cli.textual_ui.widgets.banner.banner import Banner
|
||||
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
|
||||
from vibe.cli.textual_ui.widgets.compact import CompactMessage
|
||||
from vibe.cli.textual_ui.widgets.config_app import ConfigApp
|
||||
from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState
|
||||
from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreRequested
|
||||
from vibe.cli.textual_ui.widgets.loading import LoadingWidget, paused_timer
|
||||
from vibe.cli.textual_ui.widgets.messages import (
|
||||
AssistantMessage,
|
||||
BashOutputMessage,
|
||||
ErrorMessage,
|
||||
InterruptMessage,
|
||||
PlanOfferMessage,
|
||||
ReasoningMessage,
|
||||
StreamingMessageBase,
|
||||
UserCommandMessage,
|
||||
|
|
@ -56,8 +52,19 @@ from vibe.cli.textual_ui.widgets.messages import (
|
|||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.path_display import PathDisplay
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
||||
from vibe.cli.textual_ui.widgets.welcome import WelcomeBanner
|
||||
from vibe.cli.textual_ui.windowing import (
|
||||
HISTORY_RESUME_TAIL_MESSAGES,
|
||||
LOAD_MORE_BATCH_SIZE,
|
||||
HistoryLoadMoreManager,
|
||||
SessionWindowing,
|
||||
build_history_widgets,
|
||||
create_resume_plan,
|
||||
non_system_history_messages,
|
||||
should_resume_history,
|
||||
sync_backfill_state,
|
||||
)
|
||||
from vibe.cli.update_notifier import (
|
||||
FileSystemUpdateCacheRepository,
|
||||
PyPIUpdateGateway,
|
||||
|
|
@ -70,16 +77,29 @@ from vibe.cli.update_notifier import (
|
|||
should_show_whats_new,
|
||||
)
|
||||
from vibe.cli.update_notifier.update import do_update
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agent_loop import AgentLoop, TeleportError
|
||||
from vibe.core.agents import AgentProfile
|
||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.paths.config_paths import HISTORY_FILE
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.teleport.types import (
|
||||
TeleportAuthCompleteEvent,
|
||||
TeleportAuthRequiredEvent,
|
||||
TeleportCheckingGitEvent,
|
||||
TeleportCompleteEvent,
|
||||
TeleportPushingEvent,
|
||||
TeleportPushRequiredEvent,
|
||||
TeleportPushResponseEvent,
|
||||
TeleportSendingGithubTokenEvent,
|
||||
TeleportStartingWorkflowEvent,
|
||||
)
|
||||
from vibe.core.tools.base import ToolPermission
|
||||
from vibe.core.tools.builtins.ask_user_question import (
|
||||
AskUserQuestionArgs,
|
||||
AskUserQuestionResult,
|
||||
Choice,
|
||||
Question,
|
||||
)
|
||||
from vibe.core.types import (
|
||||
AgentStats,
|
||||
|
|
@ -110,6 +130,47 @@ class BottomApp(StrEnum):
|
|||
Question = auto()
|
||||
|
||||
|
||||
class ChatScroll(VerticalScroll):
|
||||
"""Optimized scroll container that skips cascading style recalculations."""
|
||||
|
||||
def update_node_styles(self, animate: bool = True) -> None:
|
||||
pass
|
||||
|
||||
|
||||
PRUNE_LOW_MARK = 1000
|
||||
PRUNE_HIGH_MARK = 1500
|
||||
|
||||
|
||||
async def prune_by_height(messages_area: Widget, low_mark: int, high_mark: int) -> bool:
|
||||
"""Remove older children to keep virtual height within bounds.
|
||||
Implementation from https://github.com/batrachianai/toad/blob/a335b56c9015514d5f38654e3909aaa78850c510/src/toad/widgets/conversation.py#L1495
|
||||
"""
|
||||
height = messages_area.virtual_size.height
|
||||
if height <= high_mark:
|
||||
return False
|
||||
prune_children: list[Widget] = []
|
||||
bottom_margin = 0
|
||||
prune_height = 0
|
||||
for child in messages_area.children:
|
||||
if not child.display:
|
||||
prune_children.append(child)
|
||||
continue
|
||||
top, _, bottom, _ = child.styles.margin
|
||||
child_height = child.outer_size.height
|
||||
prune_height = (
|
||||
(prune_height - bottom_margin + max(bottom_margin, top))
|
||||
+ bottom
|
||||
+ child_height
|
||||
)
|
||||
bottom_margin = bottom
|
||||
if height - prune_height <= low_mark:
|
||||
break
|
||||
prune_children.append(child)
|
||||
if prune_children:
|
||||
await messages_area.remove_children(prune_children)
|
||||
return bool(prune_children)
|
||||
|
||||
|
||||
class VibeApp(App): # noqa: PLR0904
|
||||
ENABLE_COMMAND_PALETTE = False
|
||||
CSS_PATH = "app.tcss"
|
||||
|
|
@ -119,7 +180,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
Binding("ctrl+d", "force_quit", "Quit", show=False, priority=True),
|
||||
Binding("escape", "interrupt", "Interrupt", show=False, priority=True),
|
||||
Binding("ctrl+o", "toggle_tool", "Toggle Tool", show=False),
|
||||
Binding("ctrl+t", "toggle_todo", "Toggle Todo", show=False),
|
||||
Binding("ctrl+y", "copy_selection", "Copy", show=False, priority=True),
|
||||
Binding("ctrl+shift+c", "copy_selection", "Copy", show=False, priority=True),
|
||||
Binding("shift+tab", "cycle_mode", "Cycle Mode", show=False, priority=True),
|
||||
Binding("shift+up", "scroll_chat_up", "Scroll Up", show=False, priority=True),
|
||||
Binding(
|
||||
|
|
@ -131,6 +193,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self,
|
||||
agent_loop: AgentLoop,
|
||||
initial_prompt: str | None = None,
|
||||
teleport_on_start: bool = False,
|
||||
update_notifier: UpdateGateway | None = None,
|
||||
update_cache_repository: UpdateCacheRepository | None = None,
|
||||
current_version: str = CORE_VERSION,
|
||||
|
|
@ -148,42 +211,51 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._pending_question: asyncio.Future | None = None
|
||||
|
||||
self.event_handler: EventHandler | None = None
|
||||
self.commands = CommandRegistry()
|
||||
|
||||
excluded_commands = []
|
||||
if not self.config.nuage_enabled:
|
||||
excluded_commands.append("teleport")
|
||||
self.commands = CommandRegistry(excluded_commands=excluded_commands)
|
||||
|
||||
self._chat_input_container: ChatInputContainer | None = None
|
||||
self._agent_indicator: AgentIndicator | None = None
|
||||
self._current_bottom_app: BottomApp = BottomApp.Input
|
||||
|
||||
self.history_file = HISTORY_FILE.path
|
||||
|
||||
self._tools_collapsed = True
|
||||
self._todos_collapsed = False
|
||||
self._current_streaming_message: AssistantMessage | None = None
|
||||
self._current_streaming_reasoning: ReasoningMessage | None = None
|
||||
self._windowing = SessionWindowing(load_more_batch_size=LOAD_MORE_BATCH_SIZE)
|
||||
self._load_more = HistoryLoadMoreManager()
|
||||
self._tool_call_map: dict[str, str] | None = None
|
||||
self._history_widget_indices: WeakKeyDictionary[Widget, int] = (
|
||||
WeakKeyDictionary()
|
||||
)
|
||||
self._update_notifier = update_notifier
|
||||
self._update_cache_repository = update_cache_repository
|
||||
self._current_version = current_version
|
||||
self._plan_offer_gateway = plan_offer_gateway
|
||||
self._plan_offer_shown = False
|
||||
self._initial_prompt = initial_prompt
|
||||
self._teleport_on_start = teleport_on_start and self.config.nuage_enabled
|
||||
self._auto_scroll = True
|
||||
self._last_escape_time: float | None = None
|
||||
self._terminal_theme = capture_terminal_theme()
|
||||
self._banner: Banner | None = None
|
||||
self._cached_messages_area: Widget | None = None
|
||||
self._cached_chat: ChatScroll | None = None
|
||||
self._cached_loading_area: Widget | None = None
|
||||
|
||||
@property
|
||||
def config(self) -> VibeConfig:
|
||||
return self.agent_loop.config
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with VerticalScroll(id="chat"):
|
||||
yield WelcomeBanner(self.config)
|
||||
yield Static(id="messages")
|
||||
with ChatScroll(id="chat"):
|
||||
self._banner = Banner(self.config, self.agent_loop.skill_manager)
|
||||
yield self._banner
|
||||
yield VerticalGroup(id="messages")
|
||||
|
||||
with Horizontal(id="loading-area"):
|
||||
yield Static(id="loading-area-content")
|
||||
yield AgentIndicator(profile=self.agent_loop.agent_profile)
|
||||
|
||||
yield Static(id="todo-area")
|
||||
|
||||
with Static(id="bottom-app-container"):
|
||||
yield ChatInputContainer(
|
||||
|
|
@ -191,7 +263,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
command_registry=self.commands,
|
||||
id="input-container",
|
||||
safety=self.agent_loop.agent_profile.safety,
|
||||
agent_name=self.agent_loop.agent_profile.display_name.lower(),
|
||||
skill_entries_getter=self._get_skill_entries,
|
||||
nuage_enabled=self.config.nuage_enabled,
|
||||
)
|
||||
|
||||
with Horizontal(id="bottom-bar"):
|
||||
|
|
@ -200,25 +274,19 @@ class VibeApp(App): # noqa: PLR0904
|
|||
yield ContextProgress()
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
if self._terminal_theme:
|
||||
self.register_theme(self._terminal_theme)
|
||||
self.theme = "textual-ansi"
|
||||
|
||||
if self.config.textual_theme == TERMINAL_THEME_NAME:
|
||||
if self._terminal_theme:
|
||||
self.theme = TERMINAL_THEME_NAME
|
||||
else:
|
||||
self.theme = self.config.textual_theme
|
||||
self._cached_messages_area = self.query_one("#messages")
|
||||
self._cached_chat = self.query_one("#chat", ChatScroll)
|
||||
self._cached_loading_area = self.query_one("#loading-area-content")
|
||||
|
||||
self.event_handler = EventHandler(
|
||||
mount_callback=self._mount_and_scroll,
|
||||
scroll_callback=self._scroll_to_bottom_deferred,
|
||||
todo_area_callback=lambda: self.query_one("#todo-area"),
|
||||
get_tools_collapsed=lambda: self._tools_collapsed,
|
||||
get_todos_collapsed=lambda: self._todos_collapsed,
|
||||
)
|
||||
|
||||
self._chat_input_container = self.query_one(ChatInputContainer)
|
||||
self._agent_indicator = self.query_one(AgentIndicator)
|
||||
context_progress = self.query_one(ContextProgress)
|
||||
|
||||
def update_context_progress(stats: AgentStats) -> None:
|
||||
|
|
@ -227,7 +295,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
current_tokens=stats.context_tokens,
|
||||
)
|
||||
|
||||
AgentStats.add_listener("context_tokens", update_context_progress)
|
||||
self.agent_loop.stats.add_listener("context_tokens", update_context_progress)
|
||||
self.agent_loop.stats.trigger_listeners()
|
||||
|
||||
self.agent_loop.set_approval_callback(self._approval_callback)
|
||||
|
|
@ -237,17 +305,19 @@ class VibeApp(App): # noqa: PLR0904
|
|||
chat_input_container = self.query_one(ChatInputContainer)
|
||||
chat_input_container.focus_input()
|
||||
await self._show_dangerous_directory_warning()
|
||||
await self._resume_history_from_messages()
|
||||
await self._check_and_show_whats_new()
|
||||
await self._maybe_show_plan_offer()
|
||||
self._schedule_update_notification()
|
||||
|
||||
await self._rebuild_history_from_messages()
|
||||
|
||||
if self._initial_prompt:
|
||||
if self._initial_prompt or self._teleport_on_start:
|
||||
self.call_after_refresh(self._process_initial_prompt)
|
||||
|
||||
def _process_initial_prompt(self) -> None:
|
||||
if self._initial_prompt:
|
||||
if self._teleport_on_start:
|
||||
self.run_worker(
|
||||
self._handle_teleport_command(self._initial_prompt), exclusive=False
|
||||
)
|
||||
elif self._initial_prompt:
|
||||
self.run_worker(
|
||||
self._handle_user_message(self._initial_prompt), exclusive=False
|
||||
)
|
||||
|
|
@ -255,6 +325,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
async def on_chat_input_container_submitted(
|
||||
self, event: ChatInputContainer.Submitted
|
||||
) -> None:
|
||||
if self._banner:
|
||||
self._banner.freeze_animation()
|
||||
|
||||
value = event.value.strip()
|
||||
if not value:
|
||||
return
|
||||
|
|
@ -269,6 +342,11 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._handle_bash_command(value[1:])
|
||||
return
|
||||
|
||||
if value.startswith("&"):
|
||||
if self.config.nuage_enabled:
|
||||
await self._handle_teleport_command(value[1:])
|
||||
return
|
||||
|
||||
if await self._handle_command(value):
|
||||
return
|
||||
|
||||
|
|
@ -330,29 +408,6 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if self._loading_widget and self._loading_widget.parent:
|
||||
await self._loading_widget.remove()
|
||||
self._loading_widget = None
|
||||
self._hide_todo_area()
|
||||
|
||||
def _show_todo_area(self) -> None:
|
||||
try:
|
||||
todo_area = self.query_one("#todo-area")
|
||||
todo_area.add_class("loading-active")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _hide_todo_area(self) -> None:
|
||||
try:
|
||||
todo_area = self.query_one("#todo-area")
|
||||
todo_area.remove_class("loading-active")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_config_app_setting_changed(self, message: ConfigApp.SettingChanged) -> None:
|
||||
if message.key == "textual_theme":
|
||||
if message.value == TERMINAL_THEME_NAME:
|
||||
if self._terminal_theme:
|
||||
self.theme = TERMINAL_THEME_NAME
|
||||
else:
|
||||
self.theme = message.value
|
||||
|
||||
async def on_config_app_config_closed(
|
||||
self, message: ConfigApp.ConfigClosed
|
||||
|
|
@ -370,7 +425,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
async def on_compact_message_completed(
|
||||
self, message: CompactMessage.Completed
|
||||
) -> None:
|
||||
messages_area = self.query_one("#messages")
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
children = list(messages_area.children)
|
||||
|
||||
try:
|
||||
|
|
@ -483,62 +538,62 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_agent_loop_turn(message)
|
||||
)
|
||||
|
||||
async def _rebuild_history_from_messages(self) -> None:
|
||||
if all(msg.role == Role.system for msg in self.agent_loop.messages):
|
||||
async def _resume_history_from_messages(self) -> None:
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
if not should_resume_history(list(messages_area.children)):
|
||||
return
|
||||
|
||||
messages_area = self.query_one("#messages")
|
||||
# Don't rebuild if messages are already displayed
|
||||
if messages_area.children:
|
||||
self._windowing.reset()
|
||||
history_messages = non_system_history_messages(self.agent_loop.messages)
|
||||
if (
|
||||
plan := create_resume_plan(history_messages, HISTORY_RESUME_TAIL_MESSAGES)
|
||||
) is None:
|
||||
return
|
||||
await self._mount_history_batch(
|
||||
plan.tail_messages,
|
||||
messages_area,
|
||||
plan.tool_call_map,
|
||||
start_index=plan.tail_start_index,
|
||||
)
|
||||
self.call_after_refresh(
|
||||
lambda: self._align_chat_after_history_rebuild(plan.has_backfill)
|
||||
)
|
||||
self._tool_call_map = plan.tool_call_map
|
||||
self._windowing.set_backfill(plan.backfill_messages)
|
||||
await self._load_more.set_visible(
|
||||
messages_area,
|
||||
visible=self._windowing.has_backfill,
|
||||
remaining=self._windowing.remaining,
|
||||
)
|
||||
|
||||
tool_call_map: dict[str, str] = {}
|
||||
async def _mount_history_batch(
|
||||
self,
|
||||
batch: list[LLMMessage],
|
||||
messages_area: Widget,
|
||||
tool_call_map: dict[str, str],
|
||||
*,
|
||||
start_index: int,
|
||||
before: Widget | int | None = None,
|
||||
after: Widget | None = None,
|
||||
) -> None:
|
||||
widgets = build_history_widgets(
|
||||
batch=batch,
|
||||
tool_call_map=tool_call_map,
|
||||
start_index=start_index,
|
||||
tools_collapsed=self._tools_collapsed,
|
||||
history_widget_indices=self._history_widget_indices,
|
||||
)
|
||||
|
||||
with self.batch_update():
|
||||
for msg in self.agent_loop.messages:
|
||||
if msg.role == Role.system:
|
||||
continue
|
||||
|
||||
match msg.role:
|
||||
case Role.user:
|
||||
if msg.content:
|
||||
await messages_area.mount(UserMessage(msg.content))
|
||||
|
||||
case Role.assistant:
|
||||
await self._mount_history_assistant_message(
|
||||
msg, messages_area, tool_call_map
|
||||
)
|
||||
|
||||
case Role.tool:
|
||||
tool_name = msg.name or tool_call_map.get(
|
||||
msg.tool_call_id or "", "tool"
|
||||
)
|
||||
await messages_area.mount(
|
||||
ToolResultMessage(
|
||||
tool_name=tool_name,
|
||||
content=msg.content,
|
||||
collapsed=self._tools_collapsed,
|
||||
)
|
||||
)
|
||||
|
||||
async def _mount_history_assistant_message(
|
||||
self, msg: LLMMessage, messages_area: Widget, tool_call_map: dict[str, str]
|
||||
) -> None:
|
||||
if msg.content:
|
||||
widget = AssistantMessage(msg.content)
|
||||
await messages_area.mount(widget)
|
||||
await widget.write_initial_content()
|
||||
await widget.stop_stream()
|
||||
|
||||
if not msg.tool_calls:
|
||||
return
|
||||
|
||||
for tool_call in msg.tool_calls:
|
||||
tool_name = tool_call.function.name or "unknown"
|
||||
if tool_call.id:
|
||||
tool_call_map[tool_call.id] = tool_name
|
||||
|
||||
await messages_area.mount(ToolCallMessage(tool_name=tool_name))
|
||||
if not widgets:
|
||||
return
|
||||
if before is not None:
|
||||
await messages_area.mount_all(widgets, before=before)
|
||||
return
|
||||
if after is not None:
|
||||
await messages_area.mount_all(widgets, after=after)
|
||||
return
|
||||
await messages_area.mount_all(widgets)
|
||||
|
||||
def _is_tool_enabled_in_main_agent(self, tool: str) -> bool:
|
||||
return tool in self.agent_loop.tool_manager.available_tools
|
||||
|
|
@ -574,12 +629,13 @@ class VibeApp(App): # noqa: PLR0904
|
|||
async def _handle_agent_loop_turn(self, prompt: str) -> None:
|
||||
self._agent_running = True
|
||||
|
||||
loading_area = self.query_one("#loading-area-content")
|
||||
loading_area = self._cached_loading_area or self.query_one(
|
||||
"#loading-area-content"
|
||||
)
|
||||
|
||||
loading = LoadingWidget()
|
||||
self._loading_widget = loading
|
||||
await loading_area.mount(loading)
|
||||
self._show_todo_area()
|
||||
|
||||
try:
|
||||
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
|
||||
|
|
@ -595,13 +651,13 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if self._loading_widget and self._loading_widget.parent:
|
||||
await self._loading_widget.remove()
|
||||
if self.event_handler:
|
||||
self.event_handler.stop_current_tool_call()
|
||||
self.event_handler.stop_current_tool_call(success=False)
|
||||
raise
|
||||
except Exception as e:
|
||||
if self._loading_widget and self._loading_widget.parent:
|
||||
await self._loading_widget.remove()
|
||||
if self.event_handler:
|
||||
self.event_handler.stop_current_tool_call()
|
||||
self.event_handler.stop_current_tool_call(success=False)
|
||||
|
||||
message = str(e)
|
||||
if isinstance(e, RateLimitError):
|
||||
|
|
@ -620,8 +676,100 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if self._loading_widget:
|
||||
await self._loading_widget.remove()
|
||||
self._loading_widget = None
|
||||
self._hide_todo_area()
|
||||
await self._finalize_current_streaming_message()
|
||||
await self._refresh_windowing_from_history()
|
||||
|
||||
async def _teleport_command(self) -> None:
|
||||
await self._handle_teleport_command(show_message=False)
|
||||
|
||||
async def _handle_teleport_command(
|
||||
self, value: str | None = None, show_message: bool = True
|
||||
) -> None:
|
||||
has_history = any(msg.role != Role.system for msg in self.agent_loop.messages)
|
||||
if not value:
|
||||
if show_message:
|
||||
await self._mount_and_scroll(UserMessage("/teleport"))
|
||||
if not has_history:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
"No conversation history to teleport.",
|
||||
collapsed=self._tools_collapsed,
|
||||
)
|
||||
)
|
||||
return
|
||||
elif show_message:
|
||||
await self._mount_and_scroll(UserMessage(value))
|
||||
self.run_worker(self._teleport(value), exclusive=False)
|
||||
|
||||
async def _teleport(self, prompt: str | None = None) -> None:
|
||||
loading_area = self._cached_loading_area or self.query_one(
|
||||
"#loading-area-content"
|
||||
)
|
||||
loading = LoadingWidget()
|
||||
await loading_area.mount(loading)
|
||||
|
||||
teleport_msg = TeleportMessage()
|
||||
await self._mount_and_scroll(teleport_msg)
|
||||
|
||||
try:
|
||||
gen = self.agent_loop.teleport_to_vibe_nuage(prompt)
|
||||
async for event in gen:
|
||||
match event:
|
||||
case TeleportCheckingGitEvent():
|
||||
teleport_msg.set_status("Checking git status...")
|
||||
case TeleportPushRequiredEvent(unpushed_count=count):
|
||||
await loading.remove()
|
||||
response = await self._ask_push_approval(count)
|
||||
await loading_area.mount(loading)
|
||||
teleport_msg.set_status("Teleporting...")
|
||||
await gen.asend(response)
|
||||
case TeleportPushingEvent():
|
||||
teleport_msg.set_status("Pushing to remote...")
|
||||
case TeleportAuthRequiredEvent(
|
||||
user_code=code, verification_uri=uri
|
||||
):
|
||||
teleport_msg.set_status(
|
||||
f"GitHub auth required. Code: {code} (copied)\nOpen: {uri}"
|
||||
)
|
||||
case TeleportAuthCompleteEvent():
|
||||
teleport_msg.set_status("GitHub authenticated.")
|
||||
case TeleportStartingWorkflowEvent():
|
||||
teleport_msg.set_status("Starting Nuage workflow...")
|
||||
case TeleportSendingGithubTokenEvent():
|
||||
teleport_msg.set_status("Sending encrypted GitHub token...")
|
||||
case TeleportCompleteEvent(url=url):
|
||||
teleport_msg.set_complete(url)
|
||||
except TeleportError as e:
|
||||
await teleport_msg.remove()
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(str(e), collapsed=self._tools_collapsed)
|
||||
)
|
||||
finally:
|
||||
if loading.parent:
|
||||
await loading.remove()
|
||||
|
||||
async def _ask_push_approval(self, count: int) -> TeleportPushResponseEvent:
|
||||
word = f"commit{'s' if count != 1 else ''}"
|
||||
push_label = "Push and continue"
|
||||
result = await self._user_input_callback(
|
||||
AskUserQuestionArgs(
|
||||
questions=[
|
||||
Question(
|
||||
question=f"You have {count} unpushed {word}. Push to continue?",
|
||||
header="Push",
|
||||
options=[Choice(label=push_label), Choice(label="Cancel")],
|
||||
hide_other=True,
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
ok = (
|
||||
isinstance(result, AskUserQuestionResult)
|
||||
and not result.cancelled
|
||||
and bool(result.answers)
|
||||
and result.answers[0].answer == push_label
|
||||
)
|
||||
return TeleportPushResponseEvent(approved=ok)
|
||||
|
||||
async def _interrupt_agent_loop(self) -> None:
|
||||
if not self._agent_running or self._interrupt_requested:
|
||||
|
|
@ -637,14 +785,15 @@ class VibeApp(App): # noqa: PLR0904
|
|||
pass
|
||||
|
||||
if self.event_handler:
|
||||
self.event_handler.stop_current_tool_call()
|
||||
self.event_handler.stop_current_tool_call(success=False)
|
||||
self.event_handler.stop_current_compact()
|
||||
|
||||
self._agent_running = False
|
||||
loading_area = self.query_one("#loading-area-content")
|
||||
loading_area = self._cached_loading_area or self.query_one(
|
||||
"#loading-area-content"
|
||||
)
|
||||
await loading_area.remove_children()
|
||||
self._loading_widget = None
|
||||
self._hide_todo_area()
|
||||
|
||||
await self._finalize_current_streaming_message()
|
||||
await self._mount_and_scroll(InterruptMessage())
|
||||
|
|
@ -676,10 +825,16 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
async def _reload_config(self) -> None:
|
||||
try:
|
||||
self._windowing.reset()
|
||||
self._tool_call_map = None
|
||||
self._history_widget_indices = WeakKeyDictionary()
|
||||
await self._load_more.hide()
|
||||
base_config = VibeConfig.load()
|
||||
|
||||
await self.agent_loop.reload_with_initial_messages(base_config=base_config)
|
||||
|
||||
if self._banner:
|
||||
self._banner.set_state(base_config, self.agent_loop.skill_manager)
|
||||
await self._mount_and_scroll(UserCommandMessage("Configuration reloaded."))
|
||||
except Exception as e:
|
||||
await self._mount_and_scroll(
|
||||
|
|
@ -690,18 +845,19 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
async def _clear_history(self) -> None:
|
||||
try:
|
||||
self._windowing.reset()
|
||||
self._tool_call_map = None
|
||||
self._history_widget_indices = WeakKeyDictionary()
|
||||
await self.agent_loop.clear_history()
|
||||
await self._finalize_current_streaming_message()
|
||||
messages_area = self.query_one("#messages")
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
await messages_area.remove_children()
|
||||
todo_area = self.query_one("#todo-area")
|
||||
await todo_area.remove_children()
|
||||
|
||||
await messages_area.mount(UserMessage("/clear"))
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("Conversation history cleared!")
|
||||
)
|
||||
chat = self.query_one("#chat", VerticalScroll)
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
chat.scroll_home(animate=False)
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -805,16 +961,14 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
if result.success:
|
||||
if result.requires_restart:
|
||||
message = f"{result.message or 'Set up Shift+Enter keybind'} (You may need to restart your terminal.)"
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage(
|
||||
f"{result.terminal.value}: Set up Shift+Enter keybind (You may need to restart your terminal.)"
|
||||
)
|
||||
UserCommandMessage(f"{result.terminal.value}: {message}")
|
||||
)
|
||||
else:
|
||||
message = result.message or "Shift+Enter keybind already set up"
|
||||
await self._mount_and_scroll(
|
||||
WarningMessage(
|
||||
f"{result.terminal.value}: Shift+Enter keybind already set up"
|
||||
)
|
||||
WarningMessage(f"{result.terminal.value}: {message}")
|
||||
)
|
||||
else:
|
||||
await self._mount_and_scroll(
|
||||
|
|
@ -828,9 +982,6 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._chat_input_container.display = False
|
||||
self._chat_input_container.disabled = True
|
||||
|
||||
if self._agent_indicator:
|
||||
self._agent_indicator.display = False
|
||||
|
||||
self._current_bottom_app = BottomApp[type(widget).__name__.removesuffix("App")]
|
||||
await bottom_container.mount(widget)
|
||||
|
||||
|
|
@ -843,9 +994,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return
|
||||
|
||||
await self._mount_and_scroll(UserCommandMessage("Configuration opened..."))
|
||||
await self._switch_from_input(
|
||||
ConfigApp(self.config, has_terminal_theme=self._terminal_theme is not None)
|
||||
)
|
||||
await self._switch_from_input(ConfigApp(self.config))
|
||||
|
||||
async def _switch_to_approval_app(
|
||||
self, tool_name: str, tool_args: BaseModel
|
||||
|
|
@ -866,9 +1015,6 @@ class VibeApp(App): # noqa: PLR0904
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
if self._agent_indicator:
|
||||
self._agent_indicator.display = True
|
||||
|
||||
if self._chat_input_container:
|
||||
self._chat_input_container.disabled = False
|
||||
self._chat_input_container.display = True
|
||||
|
|
@ -942,12 +1088,44 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._scroll_to_bottom()
|
||||
self._focus_current_bottom_app()
|
||||
|
||||
async def on_history_load_more_requested(self, _: HistoryLoadMoreRequested) -> None:
|
||||
self._load_more.set_enabled(False)
|
||||
try:
|
||||
if not self._windowing.has_backfill:
|
||||
await self._load_more.hide()
|
||||
return
|
||||
if (batch := self._windowing.next_load_more_batch()) is None:
|
||||
await self._load_more.hide()
|
||||
return
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
if self._tool_call_map is None:
|
||||
self._tool_call_map = {}
|
||||
if self._load_more.widget:
|
||||
before: Widget | int | None = None
|
||||
after: Widget | None = self._load_more.widget
|
||||
else:
|
||||
before = 0
|
||||
after = None
|
||||
await self._mount_history_batch(
|
||||
batch.messages,
|
||||
messages_area,
|
||||
self._tool_call_map,
|
||||
start_index=batch.start_index,
|
||||
before=before,
|
||||
after=after,
|
||||
)
|
||||
if not self._windowing.has_backfill:
|
||||
await self._load_more.hide()
|
||||
else:
|
||||
await self._load_more.show(messages_area, self._windowing.remaining)
|
||||
finally:
|
||||
self._load_more.set_enabled(True)
|
||||
|
||||
async def action_toggle_tool(self) -> None:
|
||||
self._tools_collapsed = not self._tools_collapsed
|
||||
|
||||
for result in self.query(ToolResultMessage):
|
||||
if result.tool_name != "todo":
|
||||
await result.set_collapsed(self._tools_collapsed)
|
||||
await result.set_collapsed(self._tools_collapsed)
|
||||
|
||||
try:
|
||||
for error_msg in self.query(ErrorMessage):
|
||||
|
|
@ -955,13 +1133,6 @@ class VibeApp(App): # noqa: PLR0904
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
async def action_toggle_todo(self) -> None:
|
||||
self._todos_collapsed = not self._todos_collapsed
|
||||
|
||||
for result in self.query(ToolResultMessage):
|
||||
if result.tool_name == "todo":
|
||||
await result.set_collapsed(self._todos_collapsed)
|
||||
|
||||
def action_cycle_mode(self) -> None:
|
||||
if self._current_bottom_app != BottomApp.Input:
|
||||
return
|
||||
|
|
@ -973,10 +1144,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._update_profile_widgets(self.agent_loop.agent_profile)
|
||||
|
||||
def _update_profile_widgets(self, profile: AgentProfile) -> None:
|
||||
if self._agent_indicator:
|
||||
self._agent_indicator.set_profile(profile)
|
||||
if self._chat_input_container:
|
||||
self._chat_input_container.set_safety(profile.safety)
|
||||
self._chat_input_container.set_agent_name(profile.display_name.lower())
|
||||
|
||||
async def _cycle_agent(self) -> None:
|
||||
new_profile = self.agent_loop.agent_manager.next_agent(
|
||||
|
|
@ -1005,7 +1175,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
def action_scroll_chat_up(self) -> None:
|
||||
try:
|
||||
chat = self.query_one("#chat", VerticalScroll)
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
chat.scroll_relative(y=-5, animate=False)
|
||||
self._auto_scroll = False
|
||||
except Exception:
|
||||
|
|
@ -1013,7 +1183,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
def action_scroll_chat_down(self) -> None:
|
||||
try:
|
||||
chat = self.query_one("#chat", VerticalScroll)
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
chat.scroll_relative(y=5, animate=False)
|
||||
if self._is_scrolled_to_bottom(chat):
|
||||
self._auto_scroll = True
|
||||
|
|
@ -1039,42 +1209,37 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
content = load_whats_new_content()
|
||||
if content is not None:
|
||||
await self._mount_and_scroll(WhatsNewMessage(content))
|
||||
whats_new_message = WhatsNewMessage(content)
|
||||
plan_offer = await self._plan_offer_cta()
|
||||
if plan_offer is not None:
|
||||
whats_new_message = WhatsNewMessage(f"{content}\n\n{plan_offer}")
|
||||
if self._history_widget_indices:
|
||||
whats_new_message.add_class("after-history")
|
||||
await self._mount_and_scroll(whats_new_message)
|
||||
await mark_version_as_seen(self._current_version, self._update_cache_repository)
|
||||
|
||||
async def _maybe_show_plan_offer(self) -> None:
|
||||
if self._plan_offer_shown:
|
||||
return
|
||||
action, plan_type = await self._resolve_plan_offer_action()
|
||||
self.plan_type = plan_type
|
||||
if action is PlanOfferAction.NONE:
|
||||
return
|
||||
url = ACTION_TO_URL[action]
|
||||
match action:
|
||||
case PlanOfferAction.UPGRADE:
|
||||
text = f"Upgrade to [Pro]({url})"
|
||||
case PlanOfferAction.SWITCH_TO_PRO_KEY:
|
||||
text = f"Switch to your [Pro API key]({url})"
|
||||
await self._mount_and_scroll(PlanOfferMessage(text))
|
||||
self._plan_offer_shown = True
|
||||
async def _plan_offer_cta(self) -> str | None:
|
||||
self.plan_type = PlanType.UNKNOWN
|
||||
|
||||
if self._plan_offer_gateway is None:
|
||||
return
|
||||
|
||||
async def _resolve_plan_offer_action(self) -> tuple[PlanOfferAction, PlanType]:
|
||||
try:
|
||||
active_model = self.config.get_active_model()
|
||||
provider = self.config.get_provider_for_model(active_model)
|
||||
except ValueError:
|
||||
return PlanOfferAction.NONE, PlanType.UNKNOWN
|
||||
|
||||
api_key_env = provider.api_key_env_var
|
||||
api_key = getenv(api_key_env) if api_key_env else None
|
||||
gateway = self._plan_offer_gateway or HttpWhoAmIGateway()
|
||||
try:
|
||||
return await decide_plan_offer(api_key, gateway)
|
||||
api_key = resolve_api_key_for_plan(provider)
|
||||
action, plan_type = await decide_plan_offer(
|
||||
api_key, self._plan_offer_gateway
|
||||
)
|
||||
|
||||
self.plan_type = plan_type
|
||||
return plan_offer_cta(action)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Plan-offer check failed (%s).", type(exc).__name__, exc_info=True
|
||||
)
|
||||
return PlanOfferAction.NONE, PlanType.UNKNOWN
|
||||
return
|
||||
|
||||
async def _finalize_current_streaming_message(self) -> None:
|
||||
if self._current_streaming_reasoning is not None:
|
||||
|
|
@ -1108,9 +1273,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return widget
|
||||
|
||||
async def _mount_and_scroll(self, widget: Widget) -> None:
|
||||
messages_area = self.query_one("#messages")
|
||||
chat = self.query_one("#chat", VerticalScroll)
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
was_at_bottom = self._is_scrolled_to_bottom(chat)
|
||||
result: Widget | None = None
|
||||
|
||||
if was_at_bottom:
|
||||
self._auto_scroll = True
|
||||
|
|
@ -1140,12 +1306,15 @@ class VibeApp(App): # noqa: PLR0904
|
|||
else:
|
||||
await self._finalize_current_streaming_message()
|
||||
await messages_area.mount(widget)
|
||||
result = widget
|
||||
|
||||
is_tool_message = isinstance(widget, (ToolCallMessage, ToolResultMessage))
|
||||
|
||||
if not is_tool_message:
|
||||
self.call_after_refresh(self._scroll_to_bottom)
|
||||
|
||||
if result is not None:
|
||||
self.call_after_refresh(self._try_prune)
|
||||
if was_at_bottom:
|
||||
self.call_after_refresh(self._anchor_if_scrollable)
|
||||
|
||||
|
|
@ -1158,7 +1327,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
def _scroll_to_bottom(self) -> None:
|
||||
try:
|
||||
chat = self.query_one("#chat")
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
chat.scroll_end(animate=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -1166,17 +1335,50 @@ class VibeApp(App): # noqa: PLR0904
|
|||
def _scroll_to_bottom_deferred(self) -> None:
|
||||
self.call_after_refresh(self._scroll_to_bottom)
|
||||
|
||||
async def _try_prune(self) -> None:
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
await prune_by_height(messages_area, PRUNE_LOW_MARK, PRUNE_HIGH_MARK)
|
||||
if self._load_more.widget and not self._load_more.widget.parent:
|
||||
self._load_more.widget = None
|
||||
|
||||
async def _refresh_windowing_from_history(self) -> None:
|
||||
if self._load_more.widget is None:
|
||||
return
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
has_backfill, tool_call_map = sync_backfill_state(
|
||||
history_messages=non_system_history_messages(self.agent_loop.messages),
|
||||
messages_children=list(messages_area.children),
|
||||
history_widget_indices=self._history_widget_indices,
|
||||
windowing=self._windowing,
|
||||
)
|
||||
self._tool_call_map = tool_call_map
|
||||
await self._load_more.set_visible(
|
||||
messages_area, visible=has_backfill, remaining=self._windowing.remaining
|
||||
)
|
||||
|
||||
def _anchor_if_scrollable(self) -> None:
|
||||
if not self._auto_scroll:
|
||||
return
|
||||
try:
|
||||
chat = self.query_one("#chat", VerticalScroll)
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
if chat.max_scroll_y == 0:
|
||||
return
|
||||
chat.anchor()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _align_chat_after_history_rebuild(self, has_backfill: bool) -> None:
|
||||
try:
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
if has_backfill and chat.max_scroll_y > 0:
|
||||
chat.anchor(True)
|
||||
chat.scroll_end(animate=False)
|
||||
chat.anchor(False)
|
||||
return
|
||||
chat.scroll_end(animate=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _schedule_update_notification(self) -> None:
|
||||
if self._update_notifier is None or not self.config.enable_update_checks:
|
||||
return
|
||||
|
|
@ -1227,8 +1429,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
message, title="Update available", severity="information", timeout=10
|
||||
)
|
||||
|
||||
def action_copy_selection(self) -> None:
|
||||
copy_selection_to_clipboard(self, show_toast=False)
|
||||
|
||||
def on_mouse_up(self, event: MouseUp) -> None:
|
||||
copy_selection_to_clipboard(self)
|
||||
if self.config.autocopy_to_clipboard:
|
||||
copy_selection_to_clipboard(self, show_toast=True)
|
||||
|
||||
def on_app_blur(self, event: AppBlur) -> None:
|
||||
if self._chat_input_container and self._chat_input_container.input_widget:
|
||||
|
|
@ -1248,14 +1454,21 @@ def _print_session_resume_message(session_id: str | None) -> None:
|
|||
print(f"Or: vibe --resume {session_id}")
|
||||
|
||||
|
||||
def run_textual_ui(agent_loop: AgentLoop, initial_prompt: str | None = None) -> None:
|
||||
def run_textual_ui(
|
||||
agent_loop: AgentLoop,
|
||||
initial_prompt: str | None = None,
|
||||
teleport_on_start: bool = False,
|
||||
) -> None:
|
||||
update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
|
||||
update_cache_repository = FileSystemUpdateCacheRepository()
|
||||
plan_offer_gateway = HttpWhoAmIGateway()
|
||||
app = VibeApp(
|
||||
agent_loop=agent_loop,
|
||||
initial_prompt=initial_prompt,
|
||||
teleport_on_start=teleport_on_start,
|
||||
update_notifier=update_notifier,
|
||||
update_cache_repository=update_cache_repository,
|
||||
plan_offer_gateway=plan_offer_gateway,
|
||||
)
|
||||
session_id = app.run()
|
||||
_print_session_resume_message(session_id)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -7,6 +7,7 @@ from vibe.cli.textual_ui.widgets.compact import CompactMessage
|
|||
from vibe.cli.textual_ui.widgets.messages import AssistantMessage, ReasoningMessage
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
||||
from vibe.core.tools.ui import ToolUIDataAdapter
|
||||
from vibe.core.types import (
|
||||
AssistantEvent,
|
||||
BaseEvent,
|
||||
|
|
@ -29,15 +30,11 @@ class EventHandler:
|
|||
self,
|
||||
mount_callback: Callable,
|
||||
scroll_callback: Callable,
|
||||
todo_area_callback: Callable,
|
||||
get_tools_collapsed: Callable[[], bool],
|
||||
get_todos_collapsed: Callable[[], bool],
|
||||
) -> None:
|
||||
self.mount_callback = mount_callback
|
||||
self.scroll_callback = scroll_callback
|
||||
self.todo_area_callback = todo_area_callback
|
||||
self.get_tools_collapsed = get_tools_collapsed
|
||||
self.get_todos_collapsed = get_todos_collapsed
|
||||
self.current_tool_call: ToolCallMessage | None = None
|
||||
self.current_compact: CompactMessage | None = None
|
||||
|
||||
|
|
@ -93,35 +90,21 @@ class EventHandler:
|
|||
tool_call = ToolCallMessage(event)
|
||||
|
||||
if loading_widget and event.tool_class:
|
||||
from vibe.core.tools.ui import ToolUIDataAdapter
|
||||
|
||||
adapter = ToolUIDataAdapter(event.tool_class)
|
||||
status_text = adapter.get_status_text()
|
||||
loading_widget.set_status(status_text)
|
||||
|
||||
# Don't show todo in messages
|
||||
if event.tool_name != "todo":
|
||||
await self.mount_callback(tool_call)
|
||||
|
||||
self.current_tool_call = tool_call
|
||||
await self.mount_callback(tool_call)
|
||||
|
||||
return tool_call
|
||||
|
||||
async def _handle_tool_result(self, event: ToolResultEvent) -> None:
|
||||
if event.tool_name == "todo":
|
||||
todos_collapsed = self.get_todos_collapsed()
|
||||
tool_result = ToolResultMessage(
|
||||
event, self.current_tool_call, collapsed=todos_collapsed
|
||||
)
|
||||
# Show in todo area
|
||||
todo_area = self.todo_area_callback()
|
||||
await todo_area.remove_children()
|
||||
await todo_area.mount(tool_result)
|
||||
else:
|
||||
tools_collapsed = self.get_tools_collapsed()
|
||||
tool_result = ToolResultMessage(
|
||||
event, self.current_tool_call, collapsed=tools_collapsed
|
||||
)
|
||||
await self.mount_callback(tool_result)
|
||||
tools_collapsed = self.get_tools_collapsed()
|
||||
tool_result = ToolResultMessage(
|
||||
event, self.current_tool_call, collapsed=tools_collapsed
|
||||
)
|
||||
await self.mount_callback(tool_result)
|
||||
|
||||
self.current_tool_call = None
|
||||
|
||||
|
|
@ -153,9 +136,9 @@ class EventHandler:
|
|||
async def _handle_unknown_event(self, event: BaseEvent) -> None:
|
||||
await self.mount_callback(NoMarkupStatic(str(event), classes="unknown-event"))
|
||||
|
||||
def stop_current_tool_call(self) -> None:
|
||||
def stop_current_tool_call(self, success: bool = True) -> None:
|
||||
if self.current_tool_call:
|
||||
self.current_tool_call.stop_spinning()
|
||||
self.current_tool_call.stop_spinning(success=success)
|
||||
self.current_tool_call = None
|
||||
|
||||
def stop_current_compact(self) -> None:
|
||||
|
|
|
|||
|
|
@ -1,266 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, fields
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from textual.theme import Theme
|
||||
|
||||
try:
|
||||
import select
|
||||
import termios
|
||||
|
||||
_UNIX_AVAILABLE = True
|
||||
except ImportError:
|
||||
select = None # type: ignore[assignment]
|
||||
termios: Any = None
|
||||
_UNIX_AVAILABLE = False
|
||||
|
||||
TERMINAL_THEME_NAME = "terminal"
|
||||
|
||||
_LUMINANCE_THRESHOLD = 0.5
|
||||
_RGB_16BIT_LEN = 4
|
||||
_RGB_8BIT_LEN = 2
|
||||
|
||||
# OSC codes for terminal colors
|
||||
_OSC_FOREGROUND = "10"
|
||||
_OSC_BACKGROUND = "11"
|
||||
|
||||
# ANSI color indices (0-15) mapped to field names
|
||||
_ANSI_COLORS = (
|
||||
"black",
|
||||
"red",
|
||||
"green",
|
||||
"yellow",
|
||||
"blue",
|
||||
"magenta",
|
||||
"cyan",
|
||||
"white",
|
||||
"bright_black",
|
||||
"bright_red",
|
||||
"bright_green",
|
||||
"bright_yellow",
|
||||
"bright_blue",
|
||||
"bright_magenta",
|
||||
"bright_cyan",
|
||||
"bright_white",
|
||||
)
|
||||
|
||||
# DA1 (Primary Device Attributes) query - used to detect unsupported terminals
|
||||
# Terminals respond to DA1 even if they don't support OSC color queries
|
||||
_DA1_QUERY = b"\x1b[c"
|
||||
_DA1_RESPONSE_PREFIX = b"\x1b[?"
|
||||
|
||||
_OSC_RESPONSE_RE = re.compile(
|
||||
rb"\x1b\](10|11|4;\d+);rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)(?:\x1b\\|\x07)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TerminalColors:
|
||||
foreground: str | None = None
|
||||
background: str | None = None
|
||||
black: str | None = None
|
||||
red: str | None = None
|
||||
green: str | None = None
|
||||
yellow: str | None = None
|
||||
blue: str | None = None
|
||||
magenta: str | None = None
|
||||
cyan: str | None = None
|
||||
white: str | None = None
|
||||
bright_black: str | None = None
|
||||
bright_red: str | None = None
|
||||
bright_green: str | None = None
|
||||
bright_yellow: str | None = None
|
||||
bright_blue: str | None = None
|
||||
bright_magenta: str | None = None
|
||||
bright_cyan: str | None = None
|
||||
bright_white: str | None = None
|
||||
|
||||
def is_complete(self) -> bool:
|
||||
return all(getattr(self, f.name) is not None for f in fields(self))
|
||||
|
||||
|
||||
def _build_osc_query(code: str) -> bytes:
|
||||
"""Build an OSC query: ESC ] <code> ; ? ST"""
|
||||
return f"\x1b]{code};?\x1b\\".encode()
|
||||
|
||||
|
||||
def _build_color_queries() -> tuple[bytes, dict[bytes, str]]:
|
||||
"""Build all OSC color queries and the mapping from OSC codes to field names."""
|
||||
queries = bytearray()
|
||||
osc_to_field: dict[bytes, str] = {}
|
||||
|
||||
# Foreground and background
|
||||
queries.extend(_build_osc_query(_OSC_FOREGROUND))
|
||||
queries.extend(_build_osc_query(_OSC_BACKGROUND))
|
||||
osc_to_field[_OSC_FOREGROUND.encode()] = "foreground"
|
||||
osc_to_field[_OSC_BACKGROUND.encode()] = "background"
|
||||
|
||||
# ANSI colors 0-15
|
||||
for i, name in enumerate(_ANSI_COLORS):
|
||||
code = f"4;{i}"
|
||||
queries.extend(_build_osc_query(code))
|
||||
osc_to_field[code.encode()] = name
|
||||
|
||||
return bytes(queries), osc_to_field
|
||||
|
||||
|
||||
_COLOR_QUERIES, _OSC_TO_FIELD = _build_color_queries()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _raw_mode(fd: int) -> Iterator[None]:
|
||||
"""Context manager to temporarily set terminal to raw mode."""
|
||||
assert termios is not None # Only called on Unix so typing doesn't freak out
|
||||
try:
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
except termios.error:
|
||||
yield
|
||||
return
|
||||
|
||||
try:
|
||||
new_settings = termios.tcgetattr(fd)
|
||||
new_settings[3] &= ~(termios.ECHO | termios.ICANON)
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, new_settings)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
except termios.error:
|
||||
pass
|
||||
|
||||
|
||||
def _parse_rgb(r_hex: bytes, g_hex: bytes, b_hex: bytes) -> str | None:
|
||||
"""Parse RGB hex values to a #rrggbb string.
|
||||
|
||||
Terminals return either 16-bit (4 hex chars) or 8-bit (2 hex chars) per channel.
|
||||
"""
|
||||
try:
|
||||
if len(r_hex) == _RGB_16BIT_LEN:
|
||||
r, g, b = int(r_hex[:2], 16), int(g_hex[:2], 16), int(b_hex[:2], 16)
|
||||
elif len(r_hex) == _RGB_8BIT_LEN:
|
||||
r, g, b = int(r_hex, 16), int(g_hex, 16), int(b_hex, 16)
|
||||
else:
|
||||
return None
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _read_responses(fd: int, timeout: float = 1.0) -> bytes:
|
||||
"""Read terminal responses until DA1 response or timeout.
|
||||
|
||||
Uses the DA1 trick: we send color queries followed by DA1. Since terminals
|
||||
respond in order, receiving the DA1 response means all color responses
|
||||
(if any) have been received.
|
||||
"""
|
||||
assert select is not None # Only called on Unix so typing doesn't freak out
|
||||
response = bytearray()
|
||||
while True:
|
||||
ready, _, _ = select.select([fd], [], [], timeout)
|
||||
if not ready:
|
||||
break
|
||||
chunk = os.read(fd, 4096)
|
||||
if not chunk:
|
||||
break
|
||||
response.extend(chunk)
|
||||
# DA1 response received - we have all the color responses
|
||||
if _DA1_RESPONSE_PREFIX in response:
|
||||
break
|
||||
return bytes(response)
|
||||
|
||||
|
||||
def _parse_osc_responses(response: bytes) -> TerminalColors:
|
||||
colors = TerminalColors()
|
||||
for match in _OSC_RESPONSE_RE.finditer(response):
|
||||
osc_code, r_hex, g_hex, b_hex = match.groups()
|
||||
field = _OSC_TO_FIELD.get(osc_code)
|
||||
if field and (color := _parse_rgb(r_hex, g_hex, b_hex)):
|
||||
setattr(colors, field, color)
|
||||
return colors
|
||||
|
||||
|
||||
def _query_terminal_colors() -> TerminalColors:
|
||||
if not _UNIX_AVAILABLE:
|
||||
return TerminalColors()
|
||||
|
||||
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
||||
return TerminalColors()
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
|
||||
try:
|
||||
with _raw_mode(fd):
|
||||
os.write(sys.stdout.fileno(), _COLOR_QUERIES + _DA1_QUERY)
|
||||
response = _read_responses(fd)
|
||||
return _parse_osc_responses(response)
|
||||
except OSError:
|
||||
return TerminalColors()
|
||||
|
||||
|
||||
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
|
||||
h = hex_color.lstrip("#")
|
||||
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
||||
|
||||
|
||||
def _rgb_to_hex(r: int, g: int, b: int) -> str:
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
||||
|
||||
def _adjust_brightness(hex_color: str, factor: float) -> str:
|
||||
r, g, b = _hex_to_rgb(hex_color)
|
||||
return _rgb_to_hex(
|
||||
min(255, max(0, int(r * factor))),
|
||||
min(255, max(0, int(g * factor))),
|
||||
min(255, max(0, int(b * factor))),
|
||||
)
|
||||
|
||||
|
||||
def _blend(c1: str, c2: str, ratio: float = 0.5) -> str:
|
||||
r1, g1, b1 = _hex_to_rgb(c1)
|
||||
r2, g2, b2 = _hex_to_rgb(c2)
|
||||
return _rgb_to_hex(
|
||||
int(r1 * (1 - ratio) + r2 * ratio),
|
||||
int(g1 * (1 - ratio) + g2 * ratio),
|
||||
int(b1 * (1 - ratio) + b2 * ratio),
|
||||
)
|
||||
|
||||
|
||||
def _luminance(hex_color: str) -> float:
|
||||
"""Calculate perceived luminance (0-1) using ITU-R BT.601 coefficients."""
|
||||
r, g, b = _hex_to_rgb(hex_color)
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
|
||||
|
||||
def capture_terminal_theme() -> Theme | None:
|
||||
colors = _query_terminal_colors()
|
||||
|
||||
if not colors.background or not colors.foreground:
|
||||
return None
|
||||
|
||||
is_dark = _luminance(colors.background) < _LUMINANCE_THRESHOLD
|
||||
fg = colors.foreground
|
||||
bg = colors.background
|
||||
|
||||
surface = _adjust_brightness(bg, 1.15 if is_dark else 0.95)
|
||||
panel = _blend(bg, surface)
|
||||
|
||||
return Theme(
|
||||
name=TERMINAL_THEME_NAME,
|
||||
primary=colors.blue or fg,
|
||||
secondary=colors.cyan or fg,
|
||||
warning=colors.yellow or fg,
|
||||
error=colors.red or fg,
|
||||
success=colors.green or fg,
|
||||
accent=colors.magenta or fg,
|
||||
foreground=fg,
|
||||
background=bg,
|
||||
surface=surface,
|
||||
panel=panel,
|
||||
dark=is_dark,
|
||||
)
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.core.agents import AgentProfile, AgentSafety, BuiltinAgentName
|
||||
|
||||
AGENT_ICONS: dict[str, str] = {
|
||||
BuiltinAgentName.DEFAULT: "⏵",
|
||||
BuiltinAgentName.PLAN: "⏸",
|
||||
BuiltinAgentName.ACCEPT_EDITS: "⏵⏵",
|
||||
BuiltinAgentName.AUTO_APPROVE: "⏵⏵⏵",
|
||||
}
|
||||
|
||||
SAFETY_CLASSES: dict[AgentSafety, str] = {
|
||||
AgentSafety.SAFE: "agent-safe",
|
||||
AgentSafety.NEUTRAL: "agent-neutral",
|
||||
AgentSafety.DESTRUCTIVE: "agent-destructive",
|
||||
AgentSafety.YOLO: "agent-yolo",
|
||||
}
|
||||
|
||||
|
||||
class AgentIndicator(Static):
|
||||
def __init__(self, profile: AgentProfile) -> None:
|
||||
super().__init__(markup=False)
|
||||
self.can_focus = False
|
||||
self.profile = profile
|
||||
self._update_display()
|
||||
|
||||
def _update_display(self) -> None:
|
||||
icon = AGENT_ICONS.get(self.profile.name, "")
|
||||
name = self.profile.display_name.lower()
|
||||
self.update(f"{icon}{' ' if icon else ''}{name} agent (shift+tab to cycle)")
|
||||
|
||||
for safety_class in SAFETY_CLASSES.values():
|
||||
self.remove_class(safety_class)
|
||||
|
||||
self.add_class(SAFETY_CLASSES[self.profile.safety])
|
||||
|
||||
def set_profile(self, profile: AgentProfile) -> None:
|
||||
self.profile = profile
|
||||
self._update_display()
|
||||
|
|
@ -66,6 +66,18 @@ class ApprovalApp(Container):
|
|||
self.help_widget: Static | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="approval-options"):
|
||||
yield NoMarkupStatic("")
|
||||
for _ in range(3):
|
||||
widget = NoMarkupStatic("", classes="approval-option")
|
||||
self.option_widgets.append(widget)
|
||||
yield widget
|
||||
yield NoMarkupStatic("")
|
||||
self.help_widget = NoMarkupStatic(
|
||||
"↑↓ navigate Enter select ESC reject", classes="approval-help"
|
||||
)
|
||||
yield self.help_widget
|
||||
|
||||
with Vertical(id="approval-content"):
|
||||
self.title_widget = NoMarkupStatic(
|
||||
f"⚠ {self.tool_name} command", classes="approval-title"
|
||||
|
|
@ -78,20 +90,6 @@ class ApprovalApp(Container):
|
|||
)
|
||||
yield self.tool_info_container
|
||||
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
for _ in range(3):
|
||||
widget = NoMarkupStatic("", classes="approval-option")
|
||||
self.option_widgets.append(widget)
|
||||
yield widget
|
||||
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
self.help_widget = NoMarkupStatic(
|
||||
"↑↓ navigate Enter select ESC reject", classes="approval-help"
|
||||
)
|
||||
yield self.help_widget
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
await self._update_tool_info()
|
||||
self._update_options()
|
||||
|
|
|
|||
85
vibe/cli/textual_ui/widgets/banner/banner.py
Normal file
85
vibe/cli/textual_ui/widgets/banner/banner.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe import __version__
|
||||
from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.skills.manager import SkillManager
|
||||
|
||||
|
||||
@dataclass
|
||||
class BannerState:
|
||||
active_model: str = ""
|
||||
models_count: int = 0
|
||||
mcp_servers_count: int = 0
|
||||
skills_count: int = 0
|
||||
|
||||
|
||||
class Banner(Static):
|
||||
state = reactive(BannerState(), init=False)
|
||||
|
||||
def __init__(
|
||||
self, config: VibeConfig, skill_manager: SkillManager, **kwargs: Any
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.can_focus = False
|
||||
self._initial_state = BannerState(
|
||||
active_model=config.active_model,
|
||||
models_count=len(config.models),
|
||||
mcp_servers_count=len(config.mcp_servers),
|
||||
skills_count=len(skill_manager.available_skills),
|
||||
)
|
||||
self._animated = not config.disable_welcome_banner_animation
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(id="banner-container"):
|
||||
yield PetitChat(animate=self._animated)
|
||||
|
||||
with Vertical(id="banner-info"):
|
||||
with Horizontal(classes="banner-line"):
|
||||
yield NoMarkupStatic("Mistral Vibe", id="banner-brand")
|
||||
yield NoMarkupStatic(" ", classes="banner-spacer")
|
||||
yield NoMarkupStatic(f"v{__version__} · ", classes="banner-meta")
|
||||
yield NoMarkupStatic("", id="banner-model")
|
||||
with Horizontal(classes="banner-line"):
|
||||
yield NoMarkupStatic("", id="banner-meta-counts")
|
||||
with Horizontal(classes="banner-line"):
|
||||
yield NoMarkupStatic("Type ", classes="banner-meta")
|
||||
yield NoMarkupStatic("/help", classes="banner-cmd")
|
||||
yield NoMarkupStatic(" for more information", classes="banner-meta")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.state = self._initial_state
|
||||
|
||||
def watch_state(self) -> None:
|
||||
self.query_one("#banner-model", NoMarkupStatic).update(self.state.active_model)
|
||||
self.query_one("#banner-meta-counts", NoMarkupStatic).update(
|
||||
self._format_meta_counts()
|
||||
)
|
||||
|
||||
def freeze_animation(self) -> None:
|
||||
if self._animated:
|
||||
self.query_one(PetitChat).freeze_animation()
|
||||
|
||||
def set_state(self, config: VibeConfig, skill_manager: SkillManager) -> None:
|
||||
self.state = BannerState(
|
||||
active_model=config.active_model,
|
||||
models_count=len(config.models),
|
||||
mcp_servers_count=len(config.mcp_servers),
|
||||
skills_count=len(skill_manager.available_skills),
|
||||
)
|
||||
|
||||
def _format_meta_counts(self) -> str:
|
||||
return (
|
||||
f"{self.state.models_count} model{'s' if self.state.models_count != 1 else ''}"
|
||||
f" · {self.state.mcp_servers_count} MCP server{'s' if self.state.mcp_servers_count != 1 else ''}"
|
||||
f" · {self.state.skills_count} skill{'s' if self.state.skills_count != 1 else ''}"
|
||||
)
|
||||
195
vibe/cli/textual_ui/widgets/banner/petit_chat.py
Normal file
195
vibe/cli/textual_ui/widgets/banner/petit_chat.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.timer import Timer
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.widgets.braille_renderer import render_braille
|
||||
|
||||
WIDTH = 22
|
||||
HEIGHT = 12
|
||||
STARTING_DOTS = [
|
||||
set[int](),
|
||||
{6, 7, 15, 19},
|
||||
{5, 8, 14, 16, 18, 20},
|
||||
{4, 6, 7, 14, 17, 20},
|
||||
{3, 5, 10, 11, 12, 14, 20},
|
||||
{3, 5, 9, 13, 14, 16, 18, 20},
|
||||
{3, 5, 8, 13, 17, 21},
|
||||
{3, 6, 7, 8, 11, 14, 15, 16, 18, 19, 20},
|
||||
{4, 5, 8, 12, 17, 19},
|
||||
{6, 7, 8, 13, 18, 20},
|
||||
{9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20},
|
||||
set[int](),
|
||||
]
|
||||
QUEUE_RIGHT_TO_MID = {
|
||||
"remove": {1j + 6, 1j + 7, 2j + 8, 3j + 4, 3j + 6, 3j + 7, 8j + 4, 8j + 5},
|
||||
"add": {1j + 4, 2j + 3, 3j + 3, 3j + 5, 7j + 5, 8j + 3, 9j + 4, 9j + 5},
|
||||
}
|
||||
QUEUE_MID_TO_RIGHT = {
|
||||
"remove": QUEUE_RIGHT_TO_MID["add"],
|
||||
"add": QUEUE_RIGHT_TO_MID["remove"],
|
||||
}
|
||||
QUEUE_MID_TO_LEFT = {
|
||||
"remove": {1j + 4, 2j + 5, 3j + 3, 3j + 5, 7j + 5, 8j + 3, 9j + 4, 9j + 5},
|
||||
"add": {1j + 1, 1j + 2, 2j, 3j + 1, 3j + 2, 3j + 4, 8j + 4, 8j + 5},
|
||||
}
|
||||
QUEUE_LEFT_TO_MID = {
|
||||
"remove": QUEUE_MID_TO_LEFT["add"],
|
||||
"add": QUEUE_MID_TO_LEFT["remove"],
|
||||
}
|
||||
WAIT = {"remove": set[int](), "add": set[int]()}
|
||||
HEAD_RIGHT = {"remove": {5j + 16, 5j + 18, 6j + 17}, "add": {5j + 17, 5j + 19, 6j + 18}}
|
||||
HEAD_LEFT = {"remove": {5j + 17, 5j + 19, 6j + 18}, "add": {5j + 16, 5j + 18, 6j + 17}}
|
||||
HEAD_DOWN = {
|
||||
"remove": {
|
||||
1j + 15,
|
||||
1j + 19,
|
||||
2j + 14,
|
||||
2j + 16,
|
||||
2j + 18,
|
||||
2j + 20,
|
||||
3j + 17,
|
||||
5j + 17,
|
||||
5j + 19,
|
||||
6j + 13,
|
||||
6j + 18,
|
||||
6j + 21,
|
||||
7j + 14,
|
||||
7j + 15,
|
||||
7j + 16,
|
||||
7j + 19,
|
||||
7j + 20,
|
||||
},
|
||||
"add": {
|
||||
2j + 15,
|
||||
2j + 19,
|
||||
3j + 16,
|
||||
3j + 18,
|
||||
4j + 17,
|
||||
6j + 14,
|
||||
6j + 17,
|
||||
6j + 19,
|
||||
6j + 20,
|
||||
7j + 13,
|
||||
7j + 18,
|
||||
7j + 21,
|
||||
8j + 14,
|
||||
8j + 15,
|
||||
8j + 16,
|
||||
8j + 18,
|
||||
8j + 20,
|
||||
},
|
||||
}
|
||||
HEAD_UP = {
|
||||
"remove": {
|
||||
2j + 15,
|
||||
2j + 19,
|
||||
3j + 16,
|
||||
3j + 18,
|
||||
4j + 17,
|
||||
6j + 14,
|
||||
6j + 17,
|
||||
6j + 19,
|
||||
6j + 20,
|
||||
7j + 13,
|
||||
7j + 18,
|
||||
7j + 21,
|
||||
8j + 14,
|
||||
8j + 15,
|
||||
8j + 16,
|
||||
8j + 18,
|
||||
8j + 20,
|
||||
},
|
||||
"add": {
|
||||
1j + 15,
|
||||
1j + 19,
|
||||
2j + 14,
|
||||
2j + 16,
|
||||
2j + 18,
|
||||
2j + 20,
|
||||
3j + 17,
|
||||
5j + 17,
|
||||
5j + 19,
|
||||
6j + 13,
|
||||
6j + 18,
|
||||
6j + 21,
|
||||
7j + 14,
|
||||
7j + 15,
|
||||
7j + 16,
|
||||
7j + 18,
|
||||
7j + 19,
|
||||
7j + 20,
|
||||
},
|
||||
}
|
||||
BLINK_EYES_HEAD_HIGH = [
|
||||
{"remove": {5j + 16, 5j + 18}, "add": set[int]()},
|
||||
{"remove": set[int](), "add": {5j + 16, 5j + 18}},
|
||||
]
|
||||
BLINK_EYES_HEAD_LOW = [
|
||||
{"remove": {6j + 17, 6j + 19}, "add": set[int]()},
|
||||
{"remove": set[int](), "add": {6j + 17, 6j + 19}},
|
||||
]
|
||||
TRANSITIONS = [
|
||||
*BLINK_EYES_HEAD_HIGH,
|
||||
WAIT,
|
||||
QUEUE_RIGHT_TO_MID,
|
||||
HEAD_RIGHT,
|
||||
WAIT,
|
||||
QUEUE_MID_TO_LEFT,
|
||||
WAIT,
|
||||
QUEUE_LEFT_TO_MID,
|
||||
WAIT,
|
||||
HEAD_DOWN,
|
||||
WAIT,
|
||||
QUEUE_MID_TO_RIGHT,
|
||||
*BLINK_EYES_HEAD_LOW,
|
||||
WAIT,
|
||||
QUEUE_RIGHT_TO_MID,
|
||||
WAIT,
|
||||
QUEUE_MID_TO_LEFT,
|
||||
WAIT,
|
||||
HEAD_UP,
|
||||
WAIT,
|
||||
QUEUE_LEFT_TO_MID,
|
||||
HEAD_LEFT,
|
||||
WAIT,
|
||||
QUEUE_MID_TO_RIGHT,
|
||||
]
|
||||
# cf render_braille() docstring for coordinates convention
|
||||
|
||||
|
||||
class PetitChat(Static):
|
||||
def __init__(self, animate: bool = True, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs, classes="banner-chat")
|
||||
self._dots = {1j * y + x for y, row in enumerate(STARTING_DOTS) for x in row}
|
||||
self._transition_index = 0
|
||||
self._do_animate = animate
|
||||
self._freeze_requested = False
|
||||
self._timer: Timer | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(render_braille(self._dots, WIDTH, HEIGHT), classes="petit-chat")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._inner = self.query_one(".petit-chat", Static)
|
||||
if self._do_animate:
|
||||
self._timer = self.set_interval(0.16, self._apply_next_transition)
|
||||
|
||||
def freeze_animation(self) -> None:
|
||||
self._freeze_requested = True
|
||||
|
||||
def _apply_next_transition(self) -> None:
|
||||
if self._freeze_requested and self._transition_index == 0:
|
||||
if self._timer:
|
||||
self._timer.stop()
|
||||
self._timer = None
|
||||
return
|
||||
|
||||
transition = TRANSITIONS[self._transition_index]
|
||||
self._dots -= transition["remove"]
|
||||
self._dots |= transition["add"]
|
||||
self._transition_index = (self._transition_index + 1) % len(TRANSITIONS)
|
||||
self._inner.update(render_braille(self._dots, WIDTH, HEIGHT))
|
||||
58
vibe/cli/textual_ui/widgets/braille_renderer.py
Normal file
58
vibe/cli/textual_ui/widgets/braille_renderer.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
import math
|
||||
|
||||
# for more details on braille characters encoding, see: https://en.wikipedia.org/wiki/Braille_Patterns
|
||||
|
||||
_BRAILLE_DOT_COUNT = 8
|
||||
|
||||
|
||||
def _braille_dot_index(x: int, y: int) -> int:
|
||||
"""returns the number associated with a dot in a braille character
|
||||
x ∈ {0, 1}, y ∈ {0, 1, 2, 3}
|
||||
-x->
|
||||
| 1 4
|
||||
y 2 5
|
||||
| 3 6
|
||||
V 7 8
|
||||
"""
|
||||
if y < 3: # noqa: PLR2004
|
||||
return y + 1 + 3 * x
|
||||
return 7 + x
|
||||
|
||||
|
||||
def _braille_char_from_dot_indices(indices: list[int]) -> str:
|
||||
if any(n < 1 or n > _BRAILLE_DOT_COUNT for n in indices):
|
||||
raise ValueError(f"Invalid braille dot indices: {indices}")
|
||||
return chr(0x2800 + sum(2 ** (d - 1) for d in indices)) if indices else " "
|
||||
|
||||
|
||||
def render_braille(dot_coords: Iterable[complex], width: int, height: int) -> str:
|
||||
"""this function receives a list of dot coordinantes, a width and a height,
|
||||
and returns a string representing these dots with braille characters.
|
||||
|
||||
Origin is (0,0) and is located at the top left:
|
||||
0----x---->
|
||||
|
|
||||
y
|
||||
|
|
||||
V
|
||||
"""
|
||||
dots_matrix: list[list[list[int]]] = [
|
||||
[[] for _ in range(math.ceil(width / 2))] for _ in range(math.ceil(height / 4))
|
||||
] # the list of dots for each character in the final str
|
||||
|
||||
for coord in dot_coords:
|
||||
x = int(coord.real // 2)
|
||||
y = int(coord.imag // 4)
|
||||
sub_x = int(coord.real) % 2
|
||||
sub_y = int(coord.imag) % 4
|
||||
dots_matrix[y][x].append(_braille_dot_index(sub_x, sub_y))
|
||||
|
||||
braille_chars = [
|
||||
[_braille_char_from_dot_indices(char_dots) for char_dots in row]
|
||||
for row in dots_matrix
|
||||
]
|
||||
|
||||
return "\n".join("".join(row) for row in braille_chars)
|
||||
|
|
@ -20,10 +20,16 @@ class ChatInputBody(Widget):
|
|||
self.value = value
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, history_file: Path | None = None, **kwargs: Any) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
history_file: Path | None = None,
|
||||
nuage_enabled: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.input_widget: ChatTextArea | None = None
|
||||
self.prompt_widget: NoMarkupStatic | None = None
|
||||
self._nuage_enabled = nuage_enabled
|
||||
|
||||
if history_file:
|
||||
self.history = HistoryManager(history_file)
|
||||
|
|
@ -37,7 +43,9 @@ class ChatInputBody(Widget):
|
|||
self.prompt_widget = NoMarkupStatic(">", id="prompt")
|
||||
yield self.prompt_widget
|
||||
|
||||
self.input_widget = ChatTextArea(placeholder="Ask anything...", id="input")
|
||||
self.input_widget = ChatTextArea(
|
||||
id="input", nuage_enabled=self._nuage_enabled
|
||||
)
|
||||
yield self.input_widget
|
||||
|
||||
def on_mount(self) -> None:
|
||||
|
|
@ -49,6 +57,8 @@ class ChatInputBody(Widget):
|
|||
return "!", text[1:]
|
||||
elif text.startswith("/"):
|
||||
return "/", text[1:]
|
||||
elif text.startswith("&") and self._nuage_enabled:
|
||||
return "&", text[1:]
|
||||
else:
|
||||
return ">", text
|
||||
|
||||
|
|
|
|||
|
|
@ -40,14 +40,18 @@ class ChatInputContainer(Vertical):
|
|||
history_file: Path | None = None,
|
||||
command_registry: CommandRegistry | None = None,
|
||||
safety: AgentSafety = AgentSafety.NEUTRAL,
|
||||
agent_name: str = "",
|
||||
skill_entries_getter: Callable[[], list[tuple[str, str]]] | None = None,
|
||||
nuage_enabled: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._history_file = history_file
|
||||
self._command_registry = command_registry or CommandRegistry()
|
||||
self._safety = safety
|
||||
self._agent_name = agent_name
|
||||
self._skill_entries_getter = skill_entries_getter
|
||||
self._nuage_enabled = nuage_enabled
|
||||
|
||||
self._completion_manager = MultiCompletionManager([
|
||||
SlashCommandController(CommandCompleter(self._get_slash_entries), self),
|
||||
|
|
@ -71,8 +75,13 @@ class ChatInputContainer(Vertical):
|
|||
yield self._completion_popup
|
||||
|
||||
border_class = SAFETY_BORDER_CLASSES.get(self._safety, "")
|
||||
with Vertical(id=self.ID_INPUT_BOX, classes=border_class):
|
||||
self._body = ChatInputBody(history_file=self._history_file, id="input-body")
|
||||
with Vertical(id=self.ID_INPUT_BOX, classes=border_class) as input_box:
|
||||
input_box.border_title = self._agent_name
|
||||
self._body = ChatInputBody(
|
||||
history_file=self._history_file,
|
||||
id="input-body",
|
||||
nuage_enabled=self._nuage_enabled,
|
||||
)
|
||||
|
||||
yield self._body
|
||||
|
||||
|
|
@ -175,3 +184,12 @@ class ChatInputContainer(Vertical):
|
|||
|
||||
if safety in SAFETY_BORDER_CLASSES:
|
||||
input_box.add_class(SAFETY_BORDER_CLASSES[safety])
|
||||
|
||||
def set_agent_name(self, name: str) -> None:
|
||||
self._agent_name = name
|
||||
|
||||
try:
|
||||
input_box = self.get_widget_by_id(self.ID_INPUT_BOX)
|
||||
input_box.border_title = name
|
||||
except Exception:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
|
|||
MultiCompletionManager,
|
||||
)
|
||||
|
||||
InputMode = Literal["!", "/", ">"]
|
||||
InputMode = Literal["!", "/", ">", "&"]
|
||||
|
||||
|
||||
class ChatTextArea(TextArea):
|
||||
|
|
@ -28,7 +28,6 @@ class ChatTextArea(TextArea):
|
|||
Binding("ctrl+g", "open_external_editor", "External Editor", show=False),
|
||||
]
|
||||
|
||||
MODE_CHARACTERS: ClassVar[set[Literal["!", "/"]]] = {"!", "/"}
|
||||
DEFAULT_MODE: ClassVar[Literal[">"]] = ">"
|
||||
|
||||
class Submitted(Message):
|
||||
|
|
@ -50,14 +49,15 @@ class ChatTextArea(TextArea):
|
|||
"""Message sent when history navigation should be reset."""
|
||||
|
||||
class ModeChanged(Message):
|
||||
"""Message sent when the input mode changes (>, !, /)."""
|
||||
"""Message sent when the input mode changes (>, !, /, &)."""
|
||||
|
||||
def __init__(self, mode: InputMode) -> None:
|
||||
self.mode = mode
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
def __init__(self, nuage_enabled: bool = False, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._nuage_enabled = nuage_enabled
|
||||
self._input_mode: InputMode = self.DEFAULT_MODE
|
||||
self._history_prefix: str | None = None
|
||||
self._last_text = ""
|
||||
|
|
@ -213,7 +213,7 @@ class ChatTextArea(TextArea):
|
|||
|
||||
if (
|
||||
event.character
|
||||
and event.character in self.MODE_CHARACTERS
|
||||
and event.character in self.mode_characters
|
||||
and not self.text
|
||||
and self._input_mode == self.DEFAULT_MODE
|
||||
):
|
||||
|
|
@ -328,7 +328,14 @@ class ChatTextArea(TextArea):
|
|||
return self.get_cursor_offset() + self._get_mode_prefix_length()
|
||||
|
||||
def _get_mode_prefix_length(self) -> int:
|
||||
return {">": 0, "/": 1, "!": 1}[self._input_mode]
|
||||
return {">": 0, "/": 1, "!": 1, "&": 1}[self._input_mode]
|
||||
|
||||
@property
|
||||
def mode_characters(self) -> set[InputMode]:
|
||||
chars: set[InputMode] = {"!", "/"}
|
||||
if self._nuage_enabled:
|
||||
chars.add("&")
|
||||
return chars
|
||||
|
||||
@property
|
||||
def input_mode(self) -> InputMode:
|
||||
|
|
|
|||
|
|
@ -7,19 +7,13 @@ from textual.app import ComposeResult
|
|||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.message import Message
|
||||
from textual.theme import BUILTIN_THEMES
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.terminal_theme import TERMINAL_THEME_NAME
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.config import VibeConfig
|
||||
|
||||
_ALL_THEMES = [TERMINAL_THEME_NAME] + sorted(
|
||||
k for k in BUILTIN_THEMES if k != "textual-ansi"
|
||||
)
|
||||
|
||||
|
||||
class SettingDefinition(TypedDict):
|
||||
key: str
|
||||
|
|
@ -46,22 +40,16 @@ class ConfigApp(Container):
|
|||
self.value = value
|
||||
|
||||
class ConfigClosed(Message):
|
||||
def __init__(self, changes: dict[str, str]) -> None:
|
||||
def __init__(self, changes: dict[str, str | bool]) -> None:
|
||||
super().__init__()
|
||||
self.changes = changes
|
||||
|
||||
def __init__(self, config: VibeConfig, *, has_terminal_theme: bool = False) -> None:
|
||||
def __init__(self, config: VibeConfig) -> None:
|
||||
super().__init__(id="config-app")
|
||||
self.config = config
|
||||
self.selected_index = 0
|
||||
self.changes: dict[str, str] = {}
|
||||
|
||||
themes = (
|
||||
_ALL_THEMES
|
||||
if has_terminal_theme
|
||||
else [t for t in _ALL_THEMES if t != TERMINAL_THEME_NAME]
|
||||
)
|
||||
|
||||
self.settings: list[SettingDefinition] = [
|
||||
{
|
||||
"key": "active_model",
|
||||
|
|
@ -70,10 +58,10 @@ class ConfigApp(Container):
|
|||
"options": [m.alias for m in self.config.models],
|
||||
},
|
||||
{
|
||||
"key": "textual_theme",
|
||||
"label": "Theme",
|
||||
"key": "autocopy_to_clipboard",
|
||||
"label": "Auto-copy",
|
||||
"type": "cycle",
|
||||
"options": themes,
|
||||
"options": ["On", "Off"],
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -104,6 +92,15 @@ class ConfigApp(Container):
|
|||
self._update_display()
|
||||
self.focus()
|
||||
|
||||
def _get_display_value(self, setting: SettingDefinition) -> str:
|
||||
key = setting["key"]
|
||||
if key in self.changes:
|
||||
return self.changes[key]
|
||||
raw_value = getattr(self.config, key, "")
|
||||
if isinstance(raw_value, bool):
|
||||
return "On" if raw_value else "Off"
|
||||
return str(raw_value)
|
||||
|
||||
def _update_display(self) -> None:
|
||||
for i, (setting, widget) in enumerate(
|
||||
zip(self.settings, self.setting_widgets, strict=True)
|
||||
|
|
@ -112,9 +109,7 @@ class ConfigApp(Container):
|
|||
cursor = "› " if is_selected else " "
|
||||
|
||||
label: str = setting["label"]
|
||||
value: str = self.changes.get(
|
||||
setting["key"], getattr(self.config, setting["key"], "")
|
||||
)
|
||||
value: str = self._get_display_value(setting)
|
||||
|
||||
text = f"{cursor}{label}: {value}"
|
||||
|
||||
|
|
@ -140,7 +135,7 @@ class ConfigApp(Container):
|
|||
def action_toggle_setting(self) -> None:
|
||||
setting = self.settings[self.selected_index]
|
||||
key: str = setting["key"]
|
||||
current: str = self.changes.get(key, getattr(self.config, key)) or ""
|
||||
current: str = self._get_display_value(setting)
|
||||
|
||||
options: list[str] = setting["options"]
|
||||
new_value = ""
|
||||
|
|
@ -160,8 +155,17 @@ class ConfigApp(Container):
|
|||
def action_cycle(self) -> None:
|
||||
self.action_toggle_setting()
|
||||
|
||||
def _convert_changes_for_save(self) -> dict[str, str | bool]:
|
||||
result: dict[str, str | bool] = {}
|
||||
for key, value in self.changes.items():
|
||||
if value in {"On", "Off"}:
|
||||
result[key] = value == "On"
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
def action_close(self) -> None:
|
||||
self.post_message(self.ConfigClosed(changes=self.changes.copy()))
|
||||
self.post_message(self.ConfigClosed(changes=self._convert_changes_for_save()))
|
||||
|
||||
def on_blur(self, event: events.Blur) -> None:
|
||||
self.call_after_refresh(self.focus)
|
||||
|
|
|
|||
43
vibe/cli/textual_ui/widgets/load_more.py
Normal file
43
vibe/cli/textual_ui/widgets/load_more.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal
|
||||
from textual.message import Message
|
||||
from textual.widgets import Button, Static
|
||||
|
||||
|
||||
class HistoryLoadMoreRequested(Message):
|
||||
pass
|
||||
|
||||
|
||||
class HistoryLoadMoreMessage(Static):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.add_class("history-load-more-message")
|
||||
self._label_widget: Button | None = None
|
||||
self._remaining: int | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="history-load-more-container"):
|
||||
self._label_widget = Button(
|
||||
self._label_text(), classes="history-load-more-button"
|
||||
)
|
||||
yield self._label_widget
|
||||
|
||||
def _label_text(self) -> str:
|
||||
if self._remaining is None:
|
||||
return "Load more messages"
|
||||
return f"Load more messages ({self._remaining})"
|
||||
|
||||
def set_enabled(self, enabled: bool) -> None:
|
||||
if self._label_widget:
|
||||
self._label_widget.disabled = not enabled
|
||||
|
||||
def set_remaining(self, remaining: int | None) -> None:
|
||||
self._remaining = remaining
|
||||
if self._label_widget:
|
||||
self._label_widget.label = self._label_text()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
event.stop()
|
||||
self.post_message(HistoryLoadMoreRequested())
|
||||
|
|
@ -15,9 +15,21 @@ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
|||
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
|
||||
|
||||
|
||||
def _format_elapsed(seconds: int) -> str:
|
||||
if seconds < 60: # noqa: PLR2004
|
||||
return f"{seconds}s"
|
||||
|
||||
minutes, secs = divmod(seconds, 60)
|
||||
if minutes < 60: # noqa: PLR2004
|
||||
return f"{minutes}m{secs}s"
|
||||
|
||||
hours, mins = divmod(minutes, 60)
|
||||
return f"{hours}h{mins}m{secs}s"
|
||||
|
||||
|
||||
class LoadingWidget(SpinnerMixin, Static):
|
||||
TARGET_COLORS = ("#FFD800", "#FFAF00", "#FF8205", "#FA500F", "#E10500")
|
||||
SPINNER_TYPE = SpinnerType.BRAILLE
|
||||
SPINNER_TYPE = SpinnerType.SNAKE
|
||||
|
||||
EASTER_EGGS: ClassVar[list[str]] = [
|
||||
"Eating a chocolatine",
|
||||
|
|
@ -173,7 +185,9 @@ class LoadingWidget(SpinnerMixin, Static):
|
|||
elapsed = int(time() - self.start_time - paused)
|
||||
if elapsed != self._last_elapsed:
|
||||
self._last_elapsed = elapsed
|
||||
self.hint_widget.update(f"({elapsed}s esc to interrupt)")
|
||||
self.hint_widget.update(
|
||||
f"({_format_elapsed(elapsed)} esc to interrupt)"
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ from typing import Any
|
|||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.widgets import Markdown, Static
|
||||
from textual.widgets import Static
|
||||
from textual.widgets._markdown import MarkdownStream
|
||||
|
||||
from vibe.cli.textual_ui.ansi_markdown import AnsiMarkdown as Markdown
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
|
||||
|
||||
|
|
@ -42,7 +43,6 @@ class UserMessage(Static):
|
|||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="user-message-container"):
|
||||
yield NonSelectableStatic("> ", classes="user-message-prompt")
|
||||
yield NoMarkupStatic(self._content, classes="user-message-content")
|
||||
if self._pending:
|
||||
self.add_class("pending")
|
||||
|
|
@ -66,6 +66,7 @@ class StreamingMessageBase(Static):
|
|||
self._content = content
|
||||
self._markdown: Markdown | None = None
|
||||
self._stream: MarkdownStream | None = None
|
||||
self._content_initialized = False
|
||||
|
||||
def _get_markdown(self) -> Markdown:
|
||||
if self._markdown is None:
|
||||
|
|
@ -89,6 +90,8 @@ class StreamingMessageBase(Static):
|
|||
await stream.write(content)
|
||||
|
||||
async def write_initial_content(self) -> None:
|
||||
if self._content_initialized:
|
||||
return
|
||||
if self._content and self._should_write_content():
|
||||
stream = self._ensure_stream()
|
||||
await stream.write(self._content)
|
||||
|
|
@ -110,16 +113,15 @@ class AssistantMessage(StreamingMessageBase):
|
|||
self.add_class("assistant-message")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="assistant-message-container"):
|
||||
yield NonSelectableStatic("● ", classes="assistant-message-dot")
|
||||
with Vertical(classes="assistant-message-content"):
|
||||
markdown = Markdown("")
|
||||
self._markdown = markdown
|
||||
yield markdown
|
||||
if self._content:
|
||||
self._content_initialized = True
|
||||
markdown = Markdown(self._content)
|
||||
self._markdown = markdown
|
||||
yield markdown
|
||||
|
||||
|
||||
class ReasoningMessage(SpinnerMixin, StreamingMessageBase):
|
||||
SPINNER_TYPE = SpinnerType.LINE
|
||||
SPINNER_TYPE = SpinnerType.PULSE
|
||||
SPINNING_TEXT = "Thinking"
|
||||
COMPLETED_TEXT = "Thought"
|
||||
|
||||
|
|
@ -207,19 +209,6 @@ class WhatsNewMessage(Static):
|
|||
yield Markdown(self._content)
|
||||
|
||||
|
||||
class PlanOfferMessage(Static):
|
||||
def __init__(self, text: str) -> None:
|
||||
super().__init__()
|
||||
self.add_class("plan-offer-message")
|
||||
self._text = text
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Markdown(self._text)
|
||||
|
||||
def get_text(self) -> str:
|
||||
return self._text
|
||||
|
||||
|
||||
class InterruptMessage(Static):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
|
@ -240,30 +229,22 @@ class BashOutputMessage(Static):
|
|||
self.add_class("bash-output-message")
|
||||
self._command = command
|
||||
self._cwd = cwd
|
||||
self._output = output
|
||||
self._output = output.rstrip("\n")
|
||||
self._exit_code = exit_code
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(classes="bash-output-container"):
|
||||
with Horizontal(classes="bash-cwd-line"):
|
||||
yield NoMarkupStatic(self._cwd, classes="bash-cwd")
|
||||
yield NoMarkupStatic("", classes="bash-cwd-spacer")
|
||||
if self._exit_code == 0:
|
||||
yield NoMarkupStatic("✓", classes="bash-exit-success")
|
||||
else:
|
||||
yield NoMarkupStatic("✗", classes="bash-exit-failure")
|
||||
yield NoMarkupStatic(
|
||||
f" ({self._exit_code})", classes="bash-exit-code"
|
||||
)
|
||||
with Horizontal(classes="bash-command-line"):
|
||||
yield NoMarkupStatic("> ", classes="bash-chevron")
|
||||
yield NoMarkupStatic(self._command, classes="bash-command")
|
||||
yield NoMarkupStatic("", classes="bash-command-spacer")
|
||||
status_class = "bash-success" if self._exit_code == 0 else "bash-error"
|
||||
self.add_class(status_class)
|
||||
with Horizontal(classes="bash-command-line"):
|
||||
yield NonSelectableStatic("$ ", classes=f"bash-prompt {status_class}")
|
||||
yield NoMarkupStatic(self._command, classes="bash-command")
|
||||
with Horizontal(classes="bash-output-container"):
|
||||
yield ExpandingBorder(classes="bash-output-border")
|
||||
yield NoMarkupStatic(self._output, classes="bash-output")
|
||||
|
||||
|
||||
class ErrorMessage(Static):
|
||||
def __init__(self, error: str, collapsed: bool = True) -> None:
|
||||
def __init__(self, error: str, collapsed: bool = False) -> None:
|
||||
super().__init__()
|
||||
self.add_class("error-message")
|
||||
self._error = error
|
||||
|
|
@ -274,22 +255,12 @@ class ErrorMessage(Static):
|
|||
with Horizontal(classes="error-container"):
|
||||
yield ExpandingBorder(classes="error-border")
|
||||
self._content_widget = NoMarkupStatic(
|
||||
self._get_text(), classes="error-content"
|
||||
f"Error: {self._error}", classes="error-content"
|
||||
)
|
||||
yield self._content_widget
|
||||
|
||||
def _get_text(self) -> str:
|
||||
if self.collapsed:
|
||||
return "Error. (ctrl+o to expand)"
|
||||
return f"Error: {self._error}"
|
||||
|
||||
def set_collapsed(self, collapsed: bool) -> None:
|
||||
if self.collapsed == collapsed:
|
||||
return
|
||||
|
||||
self.collapsed = collapsed
|
||||
if self._content_widget:
|
||||
self._content_widget.update(self._get_text())
|
||||
pass
|
||||
|
||||
|
||||
class WarningMessage(Static):
|
||||
|
|
|
|||
|
|
@ -69,26 +69,36 @@ class QuestionApp(Container):
|
|||
def _current_question(self) -> Question:
|
||||
return self.questions[self.current_question_idx]
|
||||
|
||||
@property
|
||||
def _has_other(self) -> bool:
|
||||
return not self._current_question.hide_other
|
||||
|
||||
@property
|
||||
def _total_options(self) -> int:
|
||||
base = len(self._current_question.options) + 1
|
||||
base = len(self._current_question.options)
|
||||
if self._has_other:
|
||||
base += 1
|
||||
if self._current_question.multi_select:
|
||||
return base + 1
|
||||
base += 1
|
||||
return base
|
||||
|
||||
@property
|
||||
def _other_option_idx(self) -> int:
|
||||
if not self._has_other:
|
||||
return -1
|
||||
return len(self._current_question.options)
|
||||
|
||||
@property
|
||||
def _submit_option_idx(self) -> int:
|
||||
if not self._current_question.multi_select:
|
||||
return -1
|
||||
return self._other_option_idx + 1
|
||||
if self._has_other:
|
||||
return self._other_option_idx + 1
|
||||
return len(self._current_question.options)
|
||||
|
||||
@property
|
||||
def _is_other_selected(self) -> bool:
|
||||
return self.selected_option == self._other_option_idx
|
||||
return self._has_other and self.selected_option == self._other_option_idx
|
||||
|
||||
@property
|
||||
def _is_submit_selected(self) -> bool:
|
||||
|
|
@ -217,6 +227,12 @@ class QuestionApp(Container):
|
|||
if not self.other_prefix or not self.other_input or not self.other_static:
|
||||
return
|
||||
|
||||
if not self._has_other:
|
||||
self.other_prefix.display = False
|
||||
self.other_input.display = False
|
||||
self.other_static.display = False
|
||||
return
|
||||
|
||||
q = self._current_question
|
||||
is_multi = q.multi_select
|
||||
multi_selected = self.multi_selections.get(self.current_question_idx, set())
|
||||
|
|
@ -235,6 +251,7 @@ class QuestionApp(Container):
|
|||
|
||||
show_input = is_focused or bool(stored_text)
|
||||
|
||||
self.other_prefix.display = True
|
||||
self.other_input.display = show_input
|
||||
self.other_static.display = not show_input
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@ from __future__ import annotations
|
|||
|
||||
from abc import ABC
|
||||
from collections.abc import Callable
|
||||
from enum import Enum
|
||||
from enum import Enum, auto
|
||||
import random
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, runtime_checkable
|
||||
|
||||
from textual.timer import Timer
|
||||
|
||||
from vibe.cli.textual_ui.widgets.braille_renderer import render_braille
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.widgets import Static
|
||||
|
||||
|
|
@ -51,47 +54,84 @@ class BrailleSpinner(Spinner):
|
|||
)
|
||||
|
||||
|
||||
class LineSpinner(Spinner):
|
||||
FRAMES: ClassVar[tuple[str, ...]] = ("|", "/", "-", "\\")
|
||||
|
||||
|
||||
class CircleSpinner(Spinner):
|
||||
FRAMES: ClassVar[tuple[str, ...]] = ("◴", "◷", "◶", "◵")
|
||||
|
||||
|
||||
class BowtieSpinner(Spinner):
|
||||
class PulseSpinner(Spinner):
|
||||
FRAMES: ClassVar[tuple[str, ...]] = (
|
||||
"⠋",
|
||||
"⠙",
|
||||
"⠚",
|
||||
"⠞",
|
||||
"⠖",
|
||||
"⠦",
|
||||
"⠴",
|
||||
"⠲",
|
||||
"⠳",
|
||||
"⠓",
|
||||
"■",
|
||||
"■",
|
||||
"■",
|
||||
"■",
|
||||
"■",
|
||||
"■",
|
||||
"□",
|
||||
"□",
|
||||
"□",
|
||||
"□",
|
||||
)
|
||||
|
||||
|
||||
class DotWaveSpinner(Spinner):
|
||||
FRAMES: ClassVar[tuple[str, ...]] = ("⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷")
|
||||
|
||||
|
||||
class SpinnerType(Enum):
|
||||
BRAILLE = "braille"
|
||||
LINE = "line"
|
||||
CIRCLE = "circle"
|
||||
BOWTIE = "bowtie"
|
||||
DOT_WAVE = "dot_wave"
|
||||
BRAILLE = auto()
|
||||
PULSE = auto()
|
||||
SNAKE = auto()
|
||||
|
||||
|
||||
class SnakeSpinner(Spinner):
|
||||
MAP_WIDTH: ClassVar[int] = 4
|
||||
MAP_HEIGHT: ClassVar[int] = 4
|
||||
SNAKE_LENGTH: ClassVar[int] = 3
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._positions: list[complex] = [1, 0, 1j]
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def current_direction(self) -> complex:
|
||||
return self._positions[0] - self._positions[1]
|
||||
|
||||
def _is_in_bounds(self, position: complex) -> bool:
|
||||
return (
|
||||
0 <= position.real < self.MAP_WIDTH and 0 <= position.imag < self.MAP_HEIGHT
|
||||
)
|
||||
|
||||
def _get_direction(self) -> complex:
|
||||
if (
|
||||
len(set(z.real for z in self._positions)) > 1
|
||||
and len(set(z.imag for z in self._positions)) > 1
|
||||
and self._is_in_bounds(self._positions[0] + self.current_direction)
|
||||
):
|
||||
return self.current_direction
|
||||
valid_directions = []
|
||||
for rotation in [1, 1j, -1j]:
|
||||
offset = rotation * self.current_direction
|
||||
new_position = self._positions[0] + offset
|
||||
if self._is_in_bounds(new_position) and new_position not in self._positions:
|
||||
valid_directions.append(offset)
|
||||
return random.choice(valid_directions)
|
||||
|
||||
def _next_positions(self) -> list[complex]:
|
||||
if len(self._positions) > self.SNAKE_LENGTH:
|
||||
return self._positions[: self.SNAKE_LENGTH]
|
||||
head_position = self._positions[0]
|
||||
direction = self._get_direction()
|
||||
if self.current_direction != direction:
|
||||
return [head_position + direction] + self._positions
|
||||
return [head_position + direction] + self._positions[:-1]
|
||||
|
||||
def current_frame(self) -> str:
|
||||
return render_braille(self._positions, self.MAP_WIDTH, self.MAP_HEIGHT)
|
||||
|
||||
def next_frame(self) -> str:
|
||||
self._positions = self._next_positions()
|
||||
return self.current_frame()
|
||||
|
||||
def reset(self) -> None:
|
||||
self._positions = [1, 0, 1j]
|
||||
|
||||
|
||||
_SPINNER_CLASSES: dict[SpinnerType, type[Spinner]] = {
|
||||
SpinnerType.BRAILLE: BrailleSpinner,
|
||||
SpinnerType.LINE: LineSpinner,
|
||||
SpinnerType.CIRCLE: CircleSpinner,
|
||||
SpinnerType.BOWTIE: BowtieSpinner,
|
||||
SpinnerType.DOT_WAVE: DotWaveSpinner,
|
||||
SpinnerType.PULSE: PulseSpinner,
|
||||
SpinnerType.SNAKE: SnakeSpinner,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -101,7 +141,7 @@ def create_spinner(spinner_type: SpinnerType = SpinnerType.BRAILLE) -> Spinner:
|
|||
|
||||
|
||||
class SpinnerMixin:
|
||||
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.LINE
|
||||
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.BRAILLE
|
||||
SPINNING_TEXT: ClassVar[str] = ""
|
||||
COMPLETED_TEXT: ClassVar[str] = ""
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
|
|||
|
||||
|
||||
class StatusMessage(SpinnerMixin, NoMarkupStatic):
|
||||
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.LINE
|
||||
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.PULSE
|
||||
|
||||
def __init__(self, initial_text: str = "", **kwargs: Any) -> None:
|
||||
self._initial_text = initial_text
|
||||
|
|
|
|||
31
vibe/cli/textual_ui/widgets/teleport_message.py
Normal file
31
vibe/cli/textual_ui/widgets/teleport_message.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
|
||||
|
||||
|
||||
class TeleportMessage(StatusMessage):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.add_class("teleport-message")
|
||||
self._status: str = "Teleporting..."
|
||||
self._final_url: str | None = None
|
||||
self._error: str | None = None
|
||||
|
||||
def get_content(self) -> str:
|
||||
if self._error:
|
||||
return f"Teleport failed: {self._error}"
|
||||
if self._final_url:
|
||||
return f"Teleported to Nuage: {self._final_url}"
|
||||
return self._status
|
||||
|
||||
def set_status(self, status: str) -> None:
|
||||
self._status = status
|
||||
self.update_display()
|
||||
|
||||
def set_complete(self, url: str) -> None:
|
||||
self._final_url = url
|
||||
self.stop_spinning(success=True)
|
||||
|
||||
def set_error(self, error: str) -> None:
|
||||
self._error = error
|
||||
self.stop_spinning(success=False)
|
||||
|
|
@ -6,10 +6,10 @@ from pathlib import Path
|
|||
from pydantic import BaseModel
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.widgets import Markdown, Static
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.ansi_markdown import AnsiMarkdown as Markdown
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS
|
||||
from vibe.core.tools.builtins.ask_user_question import AskUserQuestionResult
|
||||
from vibe.core.tools.builtins.bash import BashArgs, BashResult
|
||||
from vibe.core.tools.builtins.grep import GrepArgs, GrepResult
|
||||
|
|
@ -23,13 +23,13 @@ from vibe.core.tools.builtins.todo import TodoArgs, TodoResult
|
|||
from vibe.core.tools.builtins.write_file import WriteFileArgs, WriteFileResult
|
||||
|
||||
|
||||
def _truncate_lines(content: str, max_lines: int) -> str:
|
||||
"""Truncate content to max_lines, adding indicator if truncated."""
|
||||
def _truncate_lines(content: str, max_lines: int) -> tuple[str, str | None]:
|
||||
"""Truncate content to max_lines, returning (content, truncation_info)."""
|
||||
lines = content.split("\n")
|
||||
if len(lines) <= max_lines:
|
||||
return content
|
||||
return content, None
|
||||
remaining = len(lines) - max_lines
|
||||
return "\n".join(lines[:max_lines] + [f"… ({remaining} more lines)"])
|
||||
return "\n".join(lines[:max_lines]), f"… ({remaining} more lines)"
|
||||
|
||||
|
||||
def parse_search_replace_to_diff(content: str) -> list[str]:
|
||||
|
|
@ -90,8 +90,6 @@ class ToolApprovalWidget[TArgs: BaseModel](Vertical):
|
|||
class ToolResultWidget[TResult: BaseModel](Static):
|
||||
"""Base class for result widgets with typed result."""
|
||||
|
||||
SHORTCUT = DEFAULT_TOOL_SHORTCUT
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
result: TResult | None,
|
||||
|
|
@ -108,21 +106,13 @@ class ToolResultWidget[TResult: BaseModel](Static):
|
|||
self.warnings = warnings or []
|
||||
self.add_class("tool-result-widget")
|
||||
|
||||
def _hint(self) -> str:
|
||||
action = "expand" if self.collapsed else "collapse"
|
||||
return f"({self.SHORTCUT} to {action})"
|
||||
|
||||
def _header(self) -> ComposeResult:
|
||||
"""Yield the standard header. Subclasses can call this then add content."""
|
||||
if self.collapsed:
|
||||
yield NoMarkupStatic(f"{self.message} {self._hint()}")
|
||||
else:
|
||||
yield NoMarkupStatic(self.message)
|
||||
def _footer(self, extra: str | None = None) -> ComposeResult:
|
||||
"""Yield the footer with optional extra info."""
|
||||
if extra:
|
||||
yield NoMarkupStatic(extra, classes="tool-result-hint")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Default: show message and optionally result fields."""
|
||||
yield from self._header()
|
||||
|
||||
"""Default: show result fields."""
|
||||
if not self.collapsed and self.result:
|
||||
for field_name in type(self.result).model_fields:
|
||||
value = getattr(self.result, field_name)
|
||||
|
|
@ -130,6 +120,7 @@ class ToolResultWidget[TResult: BaseModel](Static):
|
|||
yield NoMarkupStatic(
|
||||
f"{field_name}: {value}", classes="tool-result-detail"
|
||||
)
|
||||
yield from self._footer()
|
||||
|
||||
|
||||
class BashApprovalWidget(ToolApprovalWidget[BashArgs]):
|
||||
|
|
@ -139,8 +130,17 @@ class BashApprovalWidget(ToolApprovalWidget[BashArgs]):
|
|||
|
||||
class BashResultWidget(ToolResultWidget[BashResult]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield from self._header()
|
||||
if self.collapsed or not self.result:
|
||||
if not self.result:
|
||||
yield from self._footer()
|
||||
return
|
||||
if self.collapsed:
|
||||
truncation_info = None
|
||||
if self.result.stdout:
|
||||
content, truncation_info = _truncate_lines(self.result.stdout, 10)
|
||||
yield NoMarkupStatic(content, classes="tool-result-detail")
|
||||
else:
|
||||
yield NoMarkupStatic("(no content)", classes="tool-result-detail")
|
||||
yield from self._footer(truncation_info)
|
||||
return
|
||||
yield NoMarkupStatic(
|
||||
f"returncode: {self.result.returncode}", classes="tool-result-detail"
|
||||
|
|
@ -155,6 +155,7 @@ class BashResultWidget(ToolResultWidget[BashResult]):
|
|||
yield NoMarkupStatic(
|
||||
f"stderr:{sep}{self.result.stderr}", classes="tool-result-detail"
|
||||
)
|
||||
yield from self._footer()
|
||||
|
||||
|
||||
class WriteFileApprovalWidget(ToolApprovalWidget[WriteFileArgs]):
|
||||
|
|
@ -169,8 +170,16 @@ class WriteFileApprovalWidget(ToolApprovalWidget[WriteFileArgs]):
|
|||
|
||||
class WriteFileResultWidget(ToolResultWidget[WriteFileResult]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield from self._header()
|
||||
if self.collapsed or not self.result:
|
||||
if not self.result:
|
||||
yield from self._footer()
|
||||
return
|
||||
ext = Path(self.result.path).suffix.lstrip(".") or "text"
|
||||
if self.collapsed:
|
||||
truncation_info = None
|
||||
if self.result.content:
|
||||
content, truncation_info = _truncate_lines(self.result.content, 10)
|
||||
yield Markdown(f"```{ext}\n{content}\n```")
|
||||
yield from self._footer(truncation_info)
|
||||
return
|
||||
yield NoMarkupStatic(f"Path: {self.result.path}", classes="tool-result-detail")
|
||||
yield NoMarkupStatic(
|
||||
|
|
@ -178,8 +187,9 @@ class WriteFileResultWidget(ToolResultWidget[WriteFileResult]):
|
|||
)
|
||||
if self.result.content:
|
||||
yield NoMarkupStatic("")
|
||||
ext = Path(self.result.path).suffix.lstrip(".") or "text"
|
||||
yield Markdown(f"```{ext}\n{_truncate_lines(self.result.content, 10)}\n```")
|
||||
content, _ = _truncate_lines(self.result.content, 10)
|
||||
yield Markdown(f"```{ext}\n{content}\n```")
|
||||
yield from self._footer()
|
||||
|
||||
|
||||
class SearchReplaceApprovalWidget(ToolApprovalWidget[SearchReplaceArgs]):
|
||||
|
|
@ -196,23 +206,15 @@ class SearchReplaceApprovalWidget(ToolApprovalWidget[SearchReplaceArgs]):
|
|||
|
||||
class SearchReplaceResultWidget(ToolResultWidget[SearchReplaceResult]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield from self._header()
|
||||
if self.collapsed or not self.result:
|
||||
if not self.result:
|
||||
yield from self._footer()
|
||||
return
|
||||
yield NoMarkupStatic(f"File: {self.result.file}", classes="tool-result-detail")
|
||||
yield NoMarkupStatic(
|
||||
f"Blocks applied: {self.result.blocks_applied}",
|
||||
classes="tool-result-detail",
|
||||
)
|
||||
yield NoMarkupStatic(
|
||||
f"Lines changed: {self.result.lines_changed}", classes="tool-result-detail"
|
||||
)
|
||||
for warning in self.result.warnings:
|
||||
for warning in self.warnings:
|
||||
yield NoMarkupStatic(f"⚠ {warning}", classes="tool-result-warning")
|
||||
if self.result.content:
|
||||
yield NoMarkupStatic("")
|
||||
for line in parse_search_replace_to_diff(self.result.content):
|
||||
yield render_diff_line(line)
|
||||
yield from self._footer()
|
||||
|
||||
|
||||
class TodoApprovalWidget(ToolApprovalWidget[TodoArgs]):
|
||||
|
|
@ -227,41 +229,30 @@ class TodoApprovalWidget(ToolApprovalWidget[TodoArgs]):
|
|||
|
||||
|
||||
class TodoResultWidget(ToolResultWidget[TodoResult]):
|
||||
SHORTCUT = TOOL_SHORTCUTS["todo"]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
if self.collapsed:
|
||||
yield NoMarkupStatic(f"{self.message} {self._hint()}")
|
||||
else:
|
||||
yield NoMarkupStatic(f"{self.message} {self._hint()}")
|
||||
yield NoMarkupStatic("")
|
||||
if not self.result or not self.result.todos:
|
||||
yield NoMarkupStatic("No todos", classes="todo-empty")
|
||||
yield from self._footer()
|
||||
return
|
||||
|
||||
if not self.result or not self.result.todos:
|
||||
yield NoMarkupStatic("No todos", classes="todo-empty")
|
||||
return
|
||||
by_status: dict[str, list] = {
|
||||
"in_progress": [],
|
||||
"pending": [],
|
||||
"completed": [],
|
||||
"cancelled": [],
|
||||
}
|
||||
for todo in self.result.todos:
|
||||
status = (
|
||||
todo.status.value if hasattr(todo.status, "value") else str(todo.status)
|
||||
)
|
||||
if status in by_status:
|
||||
by_status[status].append(todo)
|
||||
|
||||
# Group todos by status
|
||||
by_status: dict[str, list] = {
|
||||
"in_progress": [],
|
||||
"pending": [],
|
||||
"completed": [],
|
||||
"cancelled": [],
|
||||
}
|
||||
for todo in self.result.todos:
|
||||
status = (
|
||||
todo.status.value
|
||||
if hasattr(todo.status, "value")
|
||||
else str(todo.status)
|
||||
)
|
||||
if status in by_status:
|
||||
by_status[status].append(todo)
|
||||
|
||||
for status in ["in_progress", "pending", "completed", "cancelled"]:
|
||||
for todo in by_status[status]:
|
||||
icon = self._get_status_icon(status)
|
||||
yield NoMarkupStatic(
|
||||
f"{icon} {todo.content}", classes=f"todo-{status}"
|
||||
)
|
||||
for status in ["in_progress", "pending", "completed", "cancelled"]:
|
||||
for todo in by_status[status]:
|
||||
icon = self._get_status_icon(status)
|
||||
yield NoMarkupStatic(f"{icon} {todo.content}", classes=f"todo-{status}")
|
||||
yield from self._footer()
|
||||
|
||||
def _get_status_icon(self, status: str) -> str:
|
||||
icons = {"pending": "☐", "in_progress": "☐", "completed": "☑", "cancelled": "☒"}
|
||||
|
|
@ -283,8 +274,8 @@ class ReadFileApprovalWidget(ToolApprovalWidget[ReadFileArgs]):
|
|||
|
||||
class ReadFileResultWidget(ToolResultWidget[ReadFileResult]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield from self._header()
|
||||
if self.collapsed:
|
||||
yield from self._footer()
|
||||
return
|
||||
if self.result:
|
||||
yield NoMarkupStatic(
|
||||
|
|
@ -292,10 +283,13 @@ class ReadFileResultWidget(ToolResultWidget[ReadFileResult]):
|
|||
)
|
||||
for warning in self.warnings:
|
||||
yield NoMarkupStatic(f"⚠ {warning}", classes="tool-result-warning")
|
||||
truncation_info = None
|
||||
if self.result and self.result.content:
|
||||
yield NoMarkupStatic("")
|
||||
ext = Path(self.result.path).suffix.lstrip(".") or "text"
|
||||
yield Markdown(f"```{ext}\n{_truncate_lines(self.result.content, 10)}\n```")
|
||||
content, truncation_info = _truncate_lines(self.result.content, 10)
|
||||
yield Markdown(f"```{ext}\n{content}\n```")
|
||||
yield from self._footer(truncation_info)
|
||||
|
||||
|
||||
class GrepApprovalWidget(ToolApprovalWidget[GrepArgs]):
|
||||
|
|
@ -312,20 +306,24 @@ class GrepApprovalWidget(ToolApprovalWidget[GrepArgs]):
|
|||
|
||||
class GrepResultWidget(ToolResultWidget[GrepResult]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield from self._header()
|
||||
if self.collapsed:
|
||||
return
|
||||
for warning in self.warnings:
|
||||
yield NoMarkupStatic(f"⚠ {warning}", classes="tool-result-warning")
|
||||
if self.result and self.result.matches:
|
||||
yield NoMarkupStatic("")
|
||||
yield Markdown(f"```\n{_truncate_lines(self.result.matches, 30)}\n```")
|
||||
if not self.result or not self.result.matches:
|
||||
yield from self._footer()
|
||||
return
|
||||
max_lines = 10 if self.collapsed else None
|
||||
if max_lines:
|
||||
content, truncation_info = _truncate_lines(self.result.matches, max_lines)
|
||||
else:
|
||||
content, truncation_info = self.result.matches, None
|
||||
yield NoMarkupStatic(content, classes="tool-result-detail")
|
||||
yield from self._footer(truncation_info)
|
||||
|
||||
|
||||
class AskUserQuestionResultWidget(ToolResultWidget[AskUserQuestionResult]):
|
||||
def compose(self) -> ComposeResult:
|
||||
if self.collapsed or not self.result:
|
||||
yield from self._header()
|
||||
yield from self._footer()
|
||||
return
|
||||
|
||||
for answer in self.result.answers:
|
||||
|
|
@ -333,6 +331,7 @@ class AskUserQuestionResultWidget(ToolResultWidget[AskUserQuestionResult]):
|
|||
yield NoMarkupStatic(answer.question, classes="tool-result-detail")
|
||||
prefix = "(Other) " if answer.is_other else ""
|
||||
yield NoMarkupStatic(f"{prefix}{answer.answer}", classes="ask-user-answer")
|
||||
yield from self._footer()
|
||||
|
||||
|
||||
APPROVAL_WIDGETS: dict[str, type[ToolApprovalWidget]] = {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from vibe.cli.textual_ui.widgets.messages import ExpandingBorder, NonSelectableS
|
|||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
|
||||
from vibe.cli.textual_ui.widgets.tool_widgets import get_result_widget
|
||||
from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS
|
||||
from vibe.core.tools.ui import ToolUIDataAdapter
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
|
|
@ -44,6 +43,14 @@ class ToolCallMessage(StatusMessage):
|
|||
self._stream_widget.display = False
|
||||
yield self._stream_widget
|
||||
|
||||
def on_mount(self) -> None:
|
||||
siblings = list(self.parent.children) if self.parent else []
|
||||
idx = siblings.index(self) if self in siblings else -1
|
||||
if idx > 0 and isinstance(
|
||||
siblings[idx - 1], (ToolCallMessage, ToolResultMessage)
|
||||
):
|
||||
self.add_class("no-gap")
|
||||
|
||||
def get_content(self) -> str:
|
||||
if self._event and self._event.tool_class:
|
||||
adapter = ToolUIDataAdapter(self._event.tool_class)
|
||||
|
|
@ -63,6 +70,10 @@ class ToolCallMessage(StatusMessage):
|
|||
self._stream_widget.display = False
|
||||
super().stop_spinning(success)
|
||||
|
||||
def set_result_text(self, text: str) -> None:
|
||||
if self._text_widget:
|
||||
self._text_widget.update(text)
|
||||
|
||||
|
||||
class ToolResultMessage(Static):
|
||||
def __init__(
|
||||
|
|
@ -91,13 +102,6 @@ class ToolResultMessage(Static):
|
|||
def tool_name(self) -> str:
|
||||
return self._tool_name
|
||||
|
||||
def _shortcut(self) -> str:
|
||||
return TOOL_SHORTCUTS.get(self._tool_name, DEFAULT_TOOL_SHORTCUT)
|
||||
|
||||
def _hint(self) -> str:
|
||||
action = "expand" if self.collapsed else "collapse"
|
||||
return f"({self._shortcut()} to {action})"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="tool-result-container"):
|
||||
yield ExpandingBorder(classes="tool-result-border")
|
||||
|
|
@ -108,6 +112,8 @@ class ToolResultMessage(Static):
|
|||
if self._call_widget:
|
||||
success = self._determine_success()
|
||||
self._call_widget.stop_spinning(success=success)
|
||||
result_text = self._get_result_text()
|
||||
self._call_widget.set_result_text(result_text)
|
||||
await self._render_result()
|
||||
|
||||
def _determine_success(self) -> bool:
|
||||
|
|
@ -121,6 +127,23 @@ class ToolResultMessage(Static):
|
|||
return display.success
|
||||
return True
|
||||
|
||||
def _get_result_text(self) -> str:
|
||||
if self._event is None:
|
||||
return f"{self._tool_name} completed"
|
||||
|
||||
if self._event.error:
|
||||
return f"{self._tool_name}: error"
|
||||
|
||||
if self._event.skipped:
|
||||
return f"{self._tool_name}: skipped"
|
||||
|
||||
if self._event.tool_class:
|
||||
adapter = ToolUIDataAdapter(self._event.tool_class)
|
||||
display = adapter.get_result_display(self._event)
|
||||
return display.message
|
||||
|
||||
return f"{self._tool_name} completed"
|
||||
|
||||
async def _render_result(self) -> None:
|
||||
if self._content_container is None:
|
||||
return
|
||||
|
|
@ -128,39 +151,29 @@ class ToolResultMessage(Static):
|
|||
await self._content_container.remove_children()
|
||||
|
||||
if self._event is None:
|
||||
await self._render_simple()
|
||||
self.display = False
|
||||
return
|
||||
|
||||
if self._event.error:
|
||||
self.add_class("error-text")
|
||||
if self.collapsed:
|
||||
await self._content_container.mount(
|
||||
NoMarkupStatic(f"Error. {self._hint()}")
|
||||
)
|
||||
else:
|
||||
await self._content_container.mount(
|
||||
NoMarkupStatic(f"Error: {self._event.error}")
|
||||
)
|
||||
await self._content_container.mount(
|
||||
NoMarkupStatic(f"Error: {self._event.error}")
|
||||
)
|
||||
self.display = True
|
||||
return
|
||||
|
||||
if self._event.skipped:
|
||||
self.add_class("warning-text")
|
||||
reason = self._event.skip_reason or "User skipped"
|
||||
if self.collapsed:
|
||||
await self._content_container.mount(
|
||||
NoMarkupStatic(f"Skipped. {self._hint()}")
|
||||
)
|
||||
else:
|
||||
await self._content_container.mount(
|
||||
NoMarkupStatic(f"Skipped: {reason}")
|
||||
)
|
||||
await self._content_container.mount(NoMarkupStatic(f"Skipped: {reason}"))
|
||||
self.display = True
|
||||
return
|
||||
|
||||
self.remove_class("error-text")
|
||||
self.remove_class("warning-text")
|
||||
|
||||
if self._event.tool_class is None:
|
||||
await self._render_simple()
|
||||
self.display = False
|
||||
return
|
||||
|
||||
adapter = ToolUIDataAdapter(self._event.tool_class)
|
||||
|
|
@ -175,23 +188,7 @@ class ToolResultMessage(Static):
|
|||
warnings=display.warnings,
|
||||
)
|
||||
await self._content_container.mount(widget)
|
||||
|
||||
async def _render_simple(self) -> None:
|
||||
if self._content_container is None:
|
||||
return
|
||||
|
||||
if self.collapsed:
|
||||
await self._content_container.mount(
|
||||
NoMarkupStatic(f"{self._tool_name} completed {self._hint()}")
|
||||
)
|
||||
return
|
||||
|
||||
if self._content:
|
||||
await self._content_container.mount(NoMarkupStatic(self._content))
|
||||
else:
|
||||
await self._content_container.mount(
|
||||
NoMarkupStatic(f"{self._tool_name} completed.")
|
||||
)
|
||||
self.display = bool(widget.children)
|
||||
|
||||
async def set_collapsed(self, collapsed: bool) -> None:
|
||||
if self.collapsed == collapsed:
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
TOOL_SHORTCUTS = {"todo": "ctrl+t"}
|
||||
DEFAULT_TOOL_SHORTCUT = "ctrl+o"
|
||||
|
|
@ -1,283 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from time import monotonic
|
||||
|
||||
from rich.align import Align
|
||||
from rich.console import Group
|
||||
from rich.text import Text
|
||||
from textual.color import Color
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe import __version__
|
||||
from vibe.core.config import VibeConfig
|
||||
|
||||
|
||||
def hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
|
||||
normalized = hex_color.lstrip("#")
|
||||
r, g, b = (int(normalized[i : i + 2], 16) for i in (0, 2, 4))
|
||||
return (r, g, b)
|
||||
|
||||
|
||||
def rgb_to_hex(r: int, g: int, b: int) -> str:
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
||||
|
||||
def interpolate_color(
|
||||
start_rgb: tuple[int, int, int], end_rgb: tuple[int, int, int], progress: float
|
||||
) -> str:
|
||||
progress = max(0.0, min(1.0, progress))
|
||||
r = int(start_rgb[0] + (end_rgb[0] - start_rgb[0]) * progress)
|
||||
g = int(start_rgb[1] + (end_rgb[1] - start_rgb[1]) * progress)
|
||||
b = int(start_rgb[2] + (end_rgb[2] - start_rgb[2]) * progress)
|
||||
return rgb_to_hex(r, g, b)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LineAnimationState:
|
||||
progress: float = 0.0
|
||||
cached_color: str | None = None
|
||||
cached_progress: float = -1.0
|
||||
rendered_color: str | None = None
|
||||
|
||||
|
||||
class WelcomeBanner(Static):
|
||||
FLASH_COLOR = "#FFFFFF"
|
||||
TARGET_COLORS = ("#FFD800", "#FFAF00", "#FF8205", "#FA500F", "#E10500")
|
||||
BORDER_TARGET_COLOR = "#b05800"
|
||||
|
||||
LINE_ANIMATION_DURATION_MS = 200
|
||||
LINE_STAGGER_MS = 280
|
||||
FLASH_RESET_DURATION_MS = 400
|
||||
ANIMATION_TICK_INTERVAL = 0.1
|
||||
|
||||
COLOR_FLASH_MIDPOINT = 0.5
|
||||
COLOR_PHASE_SCALE = 2.0
|
||||
COLOR_CACHE_THRESHOLD = 0.001
|
||||
BORDER_PROGRESS_THRESHOLD = 0.01
|
||||
|
||||
BLOCK = "▇▇"
|
||||
SPACE = " "
|
||||
LOGO_TEXT_GAP = " "
|
||||
|
||||
def __init__(self, config: VibeConfig) -> None:
|
||||
super().__init__(" ")
|
||||
self.config = config
|
||||
self.animation_timer = None
|
||||
self._animation_start_time: float | None = None
|
||||
|
||||
self._cached_skeleton_color: str | None = None
|
||||
self._cached_skeleton_rgb: tuple[int, int, int] | None = None
|
||||
self._flash_rgb = hex_to_rgb(self.FLASH_COLOR)
|
||||
self._target_rgbs = [hex_to_rgb(c) for c in self.TARGET_COLORS]
|
||||
self._border_target_rgb = hex_to_rgb(self.BORDER_TARGET_COLOR)
|
||||
|
||||
self._line_states = [LineAnimationState() for _ in self.TARGET_COLORS]
|
||||
self.border_progress = 0.0
|
||||
self._cached_border_color: str | None = None
|
||||
self._cached_border_progress = -1.0
|
||||
|
||||
self._line_duration = self.LINE_ANIMATION_DURATION_MS / 1000
|
||||
self._line_stagger = self.LINE_STAGGER_MS / 1000
|
||||
self._border_duration = self.FLASH_RESET_DURATION_MS / 1000
|
||||
self._line_start_times = [
|
||||
idx * self._line_stagger for idx in range(len(self.TARGET_COLORS))
|
||||
]
|
||||
self._all_lines_finish_time = (
|
||||
(len(self.TARGET_COLORS) - 1) * self.LINE_STAGGER_MS
|
||||
+ self.LINE_ANIMATION_DURATION_MS
|
||||
) / 1000
|
||||
|
||||
self._cached_text_lines: list[Text | None] = [None] * 7
|
||||
self._initialize_static_line_suffixes()
|
||||
|
||||
def _initialize_static_line_suffixes(self) -> None:
|
||||
self._static_line1_suffix = (
|
||||
f"{self.LOGO_TEXT_GAP}[b]Mistral Vibe v{__version__}[/]"
|
||||
)
|
||||
self._static_line2_suffix = (
|
||||
f"{self.LOGO_TEXT_GAP}[dim]{self.config.active_model}[/]"
|
||||
)
|
||||
mcp_count = len(self.config.mcp_servers)
|
||||
model_count = len(self.config.models)
|
||||
self._static_line3_suffix = f"{self.LOGO_TEXT_GAP}[dim]{model_count} models · {mcp_count} MCP servers[/]"
|
||||
self._static_line5_suffix = (
|
||||
f"{self.LOGO_TEXT_GAP}[dim]{self.config.displayed_workdir or Path.cwd()}[/]"
|
||||
)
|
||||
self._static_line7 = f"[dim]Type[/] [{self.BORDER_TARGET_COLOR}]/help[/] [dim]for more information • [/][{self.BORDER_TARGET_COLOR}]/terminal-setup[/][dim] for shift+enter[/]"
|
||||
|
||||
@property
|
||||
def skeleton_color(self) -> str:
|
||||
return self._cached_skeleton_color or "#1e1e1e"
|
||||
|
||||
@property
|
||||
def skeleton_rgb(self) -> tuple[int, int, int]:
|
||||
return self._cached_skeleton_rgb or hex_to_rgb("#1e1e1e")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
if not self.config.disable_welcome_banner_animation:
|
||||
self.call_after_refresh(self._init_after_styles)
|
||||
|
||||
def _init_after_styles(self) -> None:
|
||||
self._cache_skeleton_color()
|
||||
self._cached_text_lines[5] = Text("")
|
||||
self._cached_text_lines[6] = Text.from_markup(self._static_line7)
|
||||
self._update_display()
|
||||
self._start_animation()
|
||||
|
||||
def _cache_skeleton_color(self) -> None:
|
||||
try:
|
||||
border = self.styles.border
|
||||
if (
|
||||
hasattr(border, "top")
|
||||
and isinstance(edge := border.top, tuple)
|
||||
and len(edge) >= 2 # noqa: PLR2004
|
||||
and isinstance(color := edge[1], Color)
|
||||
):
|
||||
self._cached_skeleton_color = color.hex
|
||||
self._cached_skeleton_rgb = hex_to_rgb(color.hex)
|
||||
return
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
self._cached_skeleton_color = "#1e1e1e"
|
||||
self._cached_skeleton_rgb = hex_to_rgb("#1e1e1e")
|
||||
|
||||
def _stop_timer(self) -> None:
|
||||
if self.animation_timer:
|
||||
try:
|
||||
self.animation_timer.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self.animation_timer = None
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
self._stop_timer()
|
||||
|
||||
def _start_animation(self) -> None:
|
||||
self._animation_start_time = monotonic()
|
||||
|
||||
def tick() -> None:
|
||||
if self._is_animation_complete():
|
||||
self._stop_timer()
|
||||
return
|
||||
if self._animation_start_time is None:
|
||||
return
|
||||
|
||||
elapsed = monotonic() - self._animation_start_time
|
||||
updated_lines = self._advance_line_progress(elapsed)
|
||||
border_updated = self._advance_border_progress(elapsed)
|
||||
|
||||
if border_updated:
|
||||
self._update_border_color()
|
||||
if updated_lines or border_updated:
|
||||
self._update_display()
|
||||
|
||||
self.animation_timer = self.set_interval(self.ANIMATION_TICK_INTERVAL, tick)
|
||||
|
||||
def _advance_line_progress(self, elapsed: float) -> bool:
|
||||
any_updates = False
|
||||
for line_idx, state in enumerate(self._line_states):
|
||||
if state.progress >= 1.0:
|
||||
continue
|
||||
start_time = self._line_start_times[line_idx]
|
||||
if elapsed < start_time:
|
||||
continue
|
||||
progress = min(1.0, (elapsed - start_time) / self._line_duration)
|
||||
if progress > state.progress:
|
||||
state.progress = progress
|
||||
any_updates = True
|
||||
return any_updates
|
||||
|
||||
def _advance_border_progress(self, elapsed: float) -> bool:
|
||||
if elapsed < self._all_lines_finish_time:
|
||||
return False
|
||||
|
||||
new_progress = min(
|
||||
1.0, (elapsed - self._all_lines_finish_time) / self._border_duration
|
||||
)
|
||||
|
||||
if abs(new_progress - self.border_progress) > self.BORDER_PROGRESS_THRESHOLD:
|
||||
self.border_progress = new_progress
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _is_animation_complete(self) -> bool:
|
||||
return (
|
||||
all(state.progress >= 1.0 for state in self._line_states)
|
||||
and self.border_progress >= 1.0
|
||||
)
|
||||
|
||||
def _update_border_color(self) -> None:
|
||||
progress = self.border_progress
|
||||
if abs(progress - self._cached_border_progress) < self.COLOR_CACHE_THRESHOLD:
|
||||
return
|
||||
|
||||
border_color = self._compute_color_for_progress(
|
||||
progress, self._border_target_rgb
|
||||
)
|
||||
self._cached_border_color = border_color
|
||||
self._cached_border_progress = progress
|
||||
self.styles.border = ("round", border_color)
|
||||
|
||||
def _compute_color_for_progress(
|
||||
self, progress: float, target_rgb: tuple[int, int, int]
|
||||
) -> str:
|
||||
if progress <= 0:
|
||||
return self.skeleton_color
|
||||
|
||||
if progress <= self.COLOR_FLASH_MIDPOINT:
|
||||
phase = progress * self.COLOR_PHASE_SCALE
|
||||
return interpolate_color(self.skeleton_rgb, self._flash_rgb, phase)
|
||||
|
||||
phase = (progress - self.COLOR_FLASH_MIDPOINT) * self.COLOR_PHASE_SCALE
|
||||
return interpolate_color(self._flash_rgb, target_rgb, phase)
|
||||
|
||||
def _update_display(self) -> None:
|
||||
for idx in range(5):
|
||||
self._update_colored_line(idx, idx)
|
||||
|
||||
lines = [line if line else Text("") for line in self._cached_text_lines]
|
||||
self.update(Align.center(Group(*lines)))
|
||||
|
||||
def _get_color(self, line_idx: int) -> str:
|
||||
state = self._line_states[line_idx]
|
||||
if (
|
||||
abs(state.progress - state.cached_progress) < self.COLOR_CACHE_THRESHOLD
|
||||
and state.cached_color
|
||||
):
|
||||
return state.cached_color
|
||||
|
||||
color = self._compute_color_for_progress(
|
||||
state.progress, self._target_rgbs[line_idx]
|
||||
)
|
||||
state.cached_color = color
|
||||
state.cached_progress = state.progress
|
||||
return color
|
||||
|
||||
def _update_colored_line(self, slot_idx: int, line_idx: int) -> None:
|
||||
color = self._get_color(line_idx)
|
||||
state = self._line_states[line_idx]
|
||||
|
||||
if color == state.rendered_color and self._cached_text_lines[slot_idx]:
|
||||
return
|
||||
|
||||
state.rendered_color = color
|
||||
self._cached_text_lines[slot_idx] = Text.from_markup(
|
||||
self._build_line(slot_idx, color)
|
||||
)
|
||||
|
||||
def _build_line(self, line_idx: int, color: str) -> str:
|
||||
B = self.BLOCK
|
||||
S = self.SPACE
|
||||
|
||||
patterns = [
|
||||
f"{S}[{color}]{B}[/]{S}{S}{S}[{color}]{B}[/]{S}{self._static_line1_suffix}",
|
||||
f"{S}[{color}]{B}{B}[/]{S}[{color}]{B}{B}[/]{S}{self._static_line2_suffix}",
|
||||
f"{S}[{color}]{B}{B}{B}{B}{B}[/]{S}{self._static_line3_suffix}",
|
||||
f"{S}[{color}]{B}[/]{S}[{color}]{B}[/]{S}[{color}]{B}[/]{S}",
|
||||
f"[{color}]{B}{B}{B}[/]{S}[{color}]{B}{B}{B}[/]{self._static_line5_suffix}",
|
||||
]
|
||||
return patterns[line_idx]
|
||||
29
vibe/cli/textual_ui/windowing/__init__.py
Normal file
29
vibe/cli/textual_ui/windowing/__init__.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.textual_ui.windowing.history import (
|
||||
build_history_widgets,
|
||||
non_system_history_messages,
|
||||
)
|
||||
from vibe.cli.textual_ui.windowing.history_windowing import (
|
||||
create_resume_plan,
|
||||
should_resume_history,
|
||||
sync_backfill_state,
|
||||
)
|
||||
from vibe.cli.textual_ui.windowing.state import (
|
||||
HISTORY_RESUME_TAIL_MESSAGES,
|
||||
LOAD_MORE_BATCH_SIZE,
|
||||
HistoryLoadMoreManager,
|
||||
SessionWindowing,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"HISTORY_RESUME_TAIL_MESSAGES",
|
||||
"LOAD_MORE_BATCH_SIZE",
|
||||
"HistoryLoadMoreManager",
|
||||
"SessionWindowing",
|
||||
"build_history_widgets",
|
||||
"create_resume_plan",
|
||||
"non_system_history_messages",
|
||||
"should_resume_history",
|
||||
"sync_backfill_state",
|
||||
]
|
||||
105
vibe/cli/textual_ui/windowing/history.py
Normal file
105
vibe/cli/textual_ui/windowing/history.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
from textual.widget import Widget
|
||||
|
||||
from vibe.cli.textual_ui.widgets.messages import (
|
||||
AssistantMessage,
|
||||
ReasoningMessage,
|
||||
UserMessage,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
||||
from vibe.core.types import LLMMessage, Role
|
||||
|
||||
|
||||
def non_system_history_messages(messages: list[LLMMessage]) -> list[LLMMessage]:
|
||||
return [msg for msg in messages if msg.role != Role.system]
|
||||
|
||||
|
||||
def build_tool_call_map(messages: list[LLMMessage]) -> dict[str, str]:
|
||||
tool_call_map: dict[str, str] = {}
|
||||
for msg in messages:
|
||||
if msg.role != Role.assistant or not msg.tool_calls:
|
||||
continue
|
||||
for tool_call in msg.tool_calls:
|
||||
if tool_call.id:
|
||||
tool_call_map[tool_call.id] = tool_call.function.name or "unknown"
|
||||
return tool_call_map
|
||||
|
||||
|
||||
def build_history_widgets(
|
||||
batch: list[LLMMessage],
|
||||
tool_call_map: dict[str, str],
|
||||
*,
|
||||
start_index: int,
|
||||
tools_collapsed: bool,
|
||||
history_widget_indices: WeakKeyDictionary[Widget, int],
|
||||
) -> list[Widget]:
|
||||
widgets: list[Widget] = []
|
||||
|
||||
for offset, msg in enumerate(batch):
|
||||
history_index = start_index + offset
|
||||
match msg.role:
|
||||
case Role.user:
|
||||
if msg.content:
|
||||
widget = UserMessage(msg.content)
|
||||
widgets.append(widget)
|
||||
history_widget_indices[widget] = history_index
|
||||
|
||||
case Role.assistant:
|
||||
if msg.content:
|
||||
assistant_widget = AssistantMessage(msg.content)
|
||||
widgets.append(assistant_widget)
|
||||
history_widget_indices[assistant_widget] = history_index
|
||||
|
||||
if msg.tool_calls:
|
||||
for tool_call in msg.tool_calls:
|
||||
tool_name = tool_call.function.name or "unknown"
|
||||
if tool_call.id:
|
||||
tool_call_map[tool_call.id] = tool_name
|
||||
widget = ToolCallMessage(tool_name=tool_name)
|
||||
widgets.append(widget)
|
||||
history_widget_indices[widget] = history_index
|
||||
|
||||
case Role.tool:
|
||||
tool_name = msg.name or tool_call_map.get(
|
||||
msg.tool_call_id or "", "tool"
|
||||
)
|
||||
widget = ToolResultMessage(
|
||||
tool_name=tool_name, content=msg.content, collapsed=tools_collapsed
|
||||
)
|
||||
widgets.append(widget)
|
||||
history_widget_indices[widget] = history_index
|
||||
|
||||
return widgets
|
||||
|
||||
|
||||
def split_history_tail(
|
||||
history_messages: list[LLMMessage], tail_size: int
|
||||
) -> tuple[list[LLMMessage], list[LLMMessage], int]:
|
||||
tail_messages = history_messages[-tail_size:]
|
||||
backfill_messages = history_messages[:-tail_size]
|
||||
tail_start_index = len(history_messages) - len(tail_messages)
|
||||
return tail_messages, backfill_messages, tail_start_index
|
||||
|
||||
|
||||
def visible_history_indices(
|
||||
children: list[Widget], history_widget_indices: WeakKeyDictionary[Widget, int]
|
||||
) -> list[int]:
|
||||
return [
|
||||
idx
|
||||
for child in children
|
||||
if (idx := history_widget_indices.get(child)) is not None
|
||||
]
|
||||
|
||||
|
||||
def visible_history_widgets_count(children: list[Widget]) -> int:
|
||||
history_widget_types = (
|
||||
UserMessage,
|
||||
AssistantMessage,
|
||||
ReasoningMessage,
|
||||
ToolCallMessage,
|
||||
ToolResultMessage,
|
||||
)
|
||||
return sum(isinstance(child, history_widget_types) for child in children)
|
||||
71
vibe/cli/textual_ui/windowing/history_windowing.py
Normal file
71
vibe/cli/textual_ui/windowing/history_windowing.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
from textual.widget import Widget
|
||||
|
||||
from vibe.cli.textual_ui.widgets.messages import WhatsNewMessage
|
||||
from vibe.cli.textual_ui.windowing.history import (
|
||||
build_tool_call_map,
|
||||
split_history_tail,
|
||||
visible_history_indices,
|
||||
visible_history_widgets_count,
|
||||
)
|
||||
from vibe.cli.textual_ui.windowing.state import SessionWindowing
|
||||
from vibe.core.types import LLMMessage
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HistoryResumePlan:
|
||||
tool_call_map: dict[str, str]
|
||||
tail_messages: list[LLMMessage]
|
||||
backfill_messages: list[LLMMessage]
|
||||
tail_start_index: int
|
||||
|
||||
@property
|
||||
def has_backfill(self) -> bool:
|
||||
return bool(self.backfill_messages)
|
||||
|
||||
|
||||
def should_resume_history(messages_children: list[Widget]) -> bool:
|
||||
allowed_pre_existing_types = (WhatsNewMessage,)
|
||||
return all(
|
||||
isinstance(child, allowed_pre_existing_types) for child in messages_children
|
||||
)
|
||||
|
||||
|
||||
def create_resume_plan(
|
||||
history_messages: list[LLMMessage], tail_size: int
|
||||
) -> HistoryResumePlan | None:
|
||||
if not history_messages:
|
||||
return None
|
||||
tail_messages, backfill_messages, tail_start_index = split_history_tail(
|
||||
history_messages, tail_size
|
||||
)
|
||||
return HistoryResumePlan(
|
||||
tool_call_map=build_tool_call_map(history_messages),
|
||||
tail_messages=tail_messages,
|
||||
backfill_messages=backfill_messages,
|
||||
tail_start_index=tail_start_index,
|
||||
)
|
||||
|
||||
|
||||
def sync_backfill_state(
|
||||
*,
|
||||
history_messages: list[LLMMessage],
|
||||
messages_children: list[Widget],
|
||||
history_widget_indices: WeakKeyDictionary[Widget, int],
|
||||
windowing: SessionWindowing,
|
||||
) -> tuple[bool, dict[str, str] | None]:
|
||||
if not history_messages:
|
||||
windowing.reset()
|
||||
return False, None
|
||||
visible_indices = visible_history_indices(messages_children, history_widget_indices)
|
||||
visible_history_widgets = visible_history_widgets_count(messages_children)
|
||||
has_backfill = windowing.recompute_backfill(
|
||||
history_messages,
|
||||
visible_indices=visible_indices,
|
||||
visible_history_widgets_count=visible_history_widgets,
|
||||
)
|
||||
return has_backfill, build_tool_call_map(history_messages)
|
||||
105
vibe/cli/textual_ui/windowing/state.py
Normal file
105
vibe/cli/textual_ui/windowing/state.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from textual.widget import Widget
|
||||
|
||||
from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreMessage
|
||||
from vibe.core.types import LLMMessage
|
||||
|
||||
HISTORY_RESUME_TAIL_MESSAGES = 20
|
||||
LOAD_MORE_BATCH_SIZE = 10
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoadMoreBatch:
|
||||
start_index: int
|
||||
messages: list[LLMMessage]
|
||||
|
||||
|
||||
class SessionWindowing:
|
||||
def __init__(self, load_more_batch_size: int) -> None:
|
||||
self.load_more_batch_size = load_more_batch_size
|
||||
self._backfill_messages: list[LLMMessage] = []
|
||||
self._backfill_cursor = 0
|
||||
|
||||
@property
|
||||
def remaining(self) -> int:
|
||||
return self._backfill_cursor
|
||||
|
||||
@property
|
||||
def has_backfill(self) -> bool:
|
||||
return self._backfill_cursor > 0
|
||||
|
||||
def reset(self) -> None:
|
||||
self._backfill_messages = []
|
||||
self._backfill_cursor = 0
|
||||
|
||||
def set_backfill(self, backfill_messages: list[LLMMessage]) -> None:
|
||||
self._backfill_messages = backfill_messages
|
||||
self._backfill_cursor = len(backfill_messages)
|
||||
|
||||
def next_load_more_batch(self) -> LoadMoreBatch | None:
|
||||
if self._backfill_cursor == 0:
|
||||
return None
|
||||
start_index = max(self._backfill_cursor - self.load_more_batch_size, 0)
|
||||
batch = self._backfill_messages[start_index : self._backfill_cursor]
|
||||
self._backfill_cursor = start_index
|
||||
if not batch:
|
||||
return None
|
||||
return LoadMoreBatch(start_index=start_index, messages=batch)
|
||||
|
||||
def recompute_backfill(
|
||||
self,
|
||||
history_messages: list[LLMMessage],
|
||||
visible_indices: list[int],
|
||||
visible_history_widgets_count: int,
|
||||
) -> bool:
|
||||
if not history_messages:
|
||||
self._backfill_messages = []
|
||||
self._backfill_cursor = 0
|
||||
return False
|
||||
if visible_indices:
|
||||
backfill_end = min(visible_indices)
|
||||
else:
|
||||
backfill_end = max(len(history_messages) - visible_history_widgets_count, 0)
|
||||
self._backfill_messages = history_messages[:backfill_end]
|
||||
self._backfill_cursor = len(self._backfill_messages)
|
||||
return self._backfill_cursor > 0
|
||||
|
||||
|
||||
class HistoryLoadMoreManager:
|
||||
def __init__(self) -> None:
|
||||
self.widget: HistoryLoadMoreMessage | None = None
|
||||
|
||||
async def show(self, messages_area: Widget, remaining: int) -> None:
|
||||
if self.widget is None:
|
||||
widget = HistoryLoadMoreMessage()
|
||||
await messages_area.mount(widget, before=0)
|
||||
self.widget = widget
|
||||
self.set_remaining(remaining)
|
||||
|
||||
async def hide(self) -> None:
|
||||
if self.widget is None:
|
||||
return
|
||||
if self.widget.parent:
|
||||
await self.widget.remove()
|
||||
self.widget = None
|
||||
|
||||
async def set_visible(
|
||||
self, messages_area: Widget, *, visible: bool, remaining: int
|
||||
) -> None:
|
||||
if visible:
|
||||
await self.show(messages_area, remaining)
|
||||
return
|
||||
await self.hide()
|
||||
|
||||
def set_enabled(self, enabled: bool) -> None:
|
||||
if self.widget is None:
|
||||
return
|
||||
self.widget.set_enabled(enabled)
|
||||
|
||||
def set_remaining(self, remaining: int) -> None:
|
||||
if self.widget is None:
|
||||
return
|
||||
self.widget.set_remaining(remaining)
|
||||
|
|
@ -6,7 +6,7 @@ from enum import StrEnum, auto
|
|||
from http import HTTPStatus
|
||||
from threading import Thread
|
||||
import time
|
||||
from typing import cast
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
|
@ -75,6 +75,19 @@ from vibe.core.utils import (
|
|||
is_user_cancellation_event,
|
||||
)
|
||||
|
||||
try:
|
||||
from vibe.core.teleport.teleport import TeleportService as _TeleportService
|
||||
|
||||
_TELEPORT_AVAILABLE = True
|
||||
except ImportError:
|
||||
_TELEPORT_AVAILABLE = False
|
||||
_TeleportService = None
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.teleport.nuage import TeleportSession
|
||||
from vibe.core.teleport.teleport import TeleportService
|
||||
from vibe.core.teleport.types import TeleportPushResponseEvent, TeleportYieldEvent
|
||||
|
||||
|
||||
class ToolExecutionResponse(StrEnum):
|
||||
SKIP = auto()
|
||||
|
|
@ -98,6 +111,10 @@ class AgentLoopLLMResponseError(AgentLoopError):
|
|||
"""Raised when LLM response is malformed or missing expected data."""
|
||||
|
||||
|
||||
class TeleportError(AgentLoopError):
|
||||
"""Raised when teleport to Vibe Nuage fails."""
|
||||
|
||||
|
||||
def _should_raise_rate_limit_error(e: Exception) -> bool:
|
||||
return isinstance(e, BackendError) and e.status == HTTPStatus.TOO_MANY_REQUESTS
|
||||
|
||||
|
|
@ -134,7 +151,7 @@ class AgentLoop:
|
|||
self._setup_middleware()
|
||||
|
||||
system_prompt = get_universal_system_prompt(
|
||||
self.tool_manager, config, self.skill_manager, self.agent_manager
|
||||
self.tool_manager, self.config, self.skill_manager, self.agent_manager
|
||||
)
|
||||
self.messages = [LLMMessage(role=Role.system, content=system_prompt)]
|
||||
|
||||
|
|
@ -156,6 +173,7 @@ class AgentLoop:
|
|||
self.session_id = str(uuid4())
|
||||
|
||||
self.session_logger = SessionLogger(config.session_logging, self.session_id)
|
||||
self._teleport_service: TeleportService | None = None
|
||||
|
||||
thread = Thread(
|
||||
target=migrate_sessions_entrypoint,
|
||||
|
|
@ -227,6 +245,60 @@ class AgentLoop:
|
|||
async for event in self._conversation_loop(msg):
|
||||
yield event
|
||||
|
||||
@property
|
||||
def teleport_service(self) -> TeleportService:
|
||||
if not _TELEPORT_AVAILABLE:
|
||||
raise TeleportError(
|
||||
"Teleport requires git to be installed. "
|
||||
"Please install git and try again."
|
||||
)
|
||||
|
||||
if self._teleport_service is None:
|
||||
if _TeleportService is None:
|
||||
raise TeleportError("_TeleportService is unexpectedly None")
|
||||
self._teleport_service = _TeleportService(
|
||||
session_logger=self.session_logger,
|
||||
nuage_base_url=self.config.nuage_base_url,
|
||||
nuage_workflow_id=self.config.nuage_workflow_id,
|
||||
nuage_api_key=self.config.nuage_api_key,
|
||||
)
|
||||
return self._teleport_service
|
||||
|
||||
def teleport_to_vibe_nuage(
|
||||
self, prompt: str | None
|
||||
) -> AsyncGenerator[TeleportYieldEvent, TeleportPushResponseEvent | None]:
|
||||
from vibe.core.teleport.nuage import TeleportSession
|
||||
|
||||
session = TeleportSession(
|
||||
metadata={
|
||||
"agent": self.agent_profile.name,
|
||||
"model": self.config.active_model,
|
||||
"stats": self.stats.model_dump(),
|
||||
},
|
||||
messages=[msg.model_dump(exclude_none=True) for msg in self.messages[1:]],
|
||||
)
|
||||
return self._teleport_generator(prompt, session)
|
||||
|
||||
async def _teleport_generator(
|
||||
self, prompt: str | None, session: TeleportSession
|
||||
) -> AsyncGenerator[TeleportYieldEvent, TeleportPushResponseEvent | None]:
|
||||
from vibe.core.teleport.errors import ServiceTeleportError
|
||||
|
||||
try:
|
||||
async with self.teleport_service:
|
||||
gen = self.teleport_service.execute(prompt=prompt, session=session)
|
||||
response: TeleportPushResponseEvent | None = None
|
||||
while True:
|
||||
try:
|
||||
event = await gen.asend(response)
|
||||
response = yield event
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
except ServiceTeleportError as e:
|
||||
raise TeleportError(str(e)) from e
|
||||
finally:
|
||||
self._teleport_service = None
|
||||
|
||||
def _setup_middleware(self) -> None:
|
||||
"""Configure middleware pipeline for this conversation."""
|
||||
self.middleware_pipeline.clear()
|
||||
|
|
@ -575,19 +647,18 @@ class AgentLoop:
|
|||
|
||||
try:
|
||||
start_time = time.perf_counter()
|
||||
async with self.backend as backend:
|
||||
result = await backend.complete(
|
||||
model=active_model,
|
||||
messages=self.messages,
|
||||
temperature=active_model.temperature,
|
||||
tools=available_tools,
|
||||
tool_choice=tool_choice,
|
||||
extra_headers={
|
||||
"user-agent": get_user_agent(provider.backend),
|
||||
"x-affinity": self.session_id,
|
||||
},
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
result = await self.backend.complete(
|
||||
model=active_model,
|
||||
messages=self.messages,
|
||||
temperature=active_model.temperature,
|
||||
tools=available_tools,
|
||||
tool_choice=tool_choice,
|
||||
extra_headers={
|
||||
"user-agent": get_user_agent(provider.backend),
|
||||
"x-affinity": self.session_id,
|
||||
},
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
end_time = time.perf_counter()
|
||||
|
||||
if result.usage is None:
|
||||
|
|
@ -622,28 +693,25 @@ class AgentLoop:
|
|||
start_time = time.perf_counter()
|
||||
usage = LLMUsage()
|
||||
chunk_agg = LLMChunk(message=LLMMessage(role=Role.assistant))
|
||||
async with self.backend as backend:
|
||||
async for chunk in backend.complete_streaming(
|
||||
model=active_model,
|
||||
messages=self.messages,
|
||||
temperature=active_model.temperature,
|
||||
tools=available_tools,
|
||||
tool_choice=tool_choice,
|
||||
extra_headers={
|
||||
"user-agent": get_user_agent(provider.backend),
|
||||
"x-affinity": self.session_id,
|
||||
},
|
||||
max_tokens=max_tokens,
|
||||
):
|
||||
processed_message = (
|
||||
self.format_handler.process_api_response_message(chunk.message)
|
||||
)
|
||||
processed_chunk = LLMChunk(
|
||||
message=processed_message, usage=chunk.usage
|
||||
)
|
||||
chunk_agg += processed_chunk
|
||||
usage += chunk.usage or LLMUsage()
|
||||
yield processed_chunk
|
||||
async for chunk in self.backend.complete_streaming(
|
||||
model=active_model,
|
||||
messages=self.messages,
|
||||
temperature=active_model.temperature,
|
||||
tools=available_tools,
|
||||
tool_choice=tool_choice,
|
||||
extra_headers={
|
||||
"user-agent": get_user_agent(provider.backend),
|
||||
"x-affinity": self.session_id,
|
||||
},
|
||||
max_tokens=max_tokens,
|
||||
):
|
||||
processed_message = self.format_handler.process_api_response_message(
|
||||
chunk.message
|
||||
)
|
||||
processed_chunk = LLMChunk(message=processed_message, usage=chunk.usage)
|
||||
chunk_agg += processed_chunk
|
||||
usage += chunk.usage or LLMUsage()
|
||||
yield processed_chunk
|
||||
end_time = time.perf_counter()
|
||||
|
||||
if chunk_agg.usage is None:
|
||||
|
|
@ -851,13 +919,12 @@ class AgentLoop:
|
|||
active_model = self.config.get_active_model()
|
||||
provider = self.config.get_provider_for_model(active_model)
|
||||
|
||||
async with self.backend as backend:
|
||||
actual_context_tokens = await backend.count_tokens(
|
||||
model=active_model,
|
||||
messages=self.messages,
|
||||
tools=self.format_handler.get_available_tools(self.tool_manager),
|
||||
extra_headers={"user-agent": get_user_agent(provider.backend)},
|
||||
)
|
||||
actual_context_tokens = await self.backend.count_tokens(
|
||||
model=active_model,
|
||||
messages=self.messages,
|
||||
tools=self.format_handler.get_available_tools(self.tool_manager),
|
||||
extra_headers={"user-agent": get_user_agent(provider.backend)},
|
||||
)
|
||||
|
||||
self.stats.context_tokens = actual_context_tokens
|
||||
|
||||
|
|
@ -896,6 +963,12 @@ class AgentLoop:
|
|||
max_turns: int | None = None,
|
||||
max_price: float | None = None,
|
||||
) -> None:
|
||||
# Force an immediate yield to allow the UI to update before heavy sync work.
|
||||
# When there are no messages, save_interaction returns early without any await,
|
||||
# so the coroutine would run synchronously through ToolManager, SkillManager,
|
||||
# and system prompt generation without yielding control to the event loop.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await self.session_logger.save_interaction(
|
||||
self.messages,
|
||||
self.stats,
|
||||
|
|
|
|||
6
vibe/core/auth/__init__.py
Normal file
6
vibe/core/auth/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.auth.crypto import EncryptedPayload, decrypt, encrypt
|
||||
from vibe.core.auth.github import GitHubAuthProvider
|
||||
|
||||
__all__ = ["EncryptedPayload", "GitHubAuthProvider", "decrypt", "encrypt"]
|
||||
137
vibe/core/auth/crypto.py
Normal file
137
vibe/core/auth/crypto.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
_AES_KEY_SIZE = 32
|
||||
_NONCE_SIZE = 12
|
||||
_MIN_RSA_KEY_SIZE = 2048
|
||||
_MAX_ENCRYPTED_KEY_SIZE = 1024
|
||||
_MAX_CIPHERTEXT_SIZE = 2 * 1024 * 1024 # Workflow transport limit: 2MB
|
||||
_PAYLOAD_VERSION = 1
|
||||
_ALG = "RSA-OAEP-SHA256"
|
||||
_ENC = "A256GCM"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EncryptedPayload:
|
||||
encrypted_key: str
|
||||
nonce: str
|
||||
ciphertext: str
|
||||
version: int | None = None
|
||||
alg: str | None = None
|
||||
enc: str | None = None
|
||||
kid: str | None = None
|
||||
purpose: str | None = None
|
||||
|
||||
|
||||
def _b64decode_strict(value: str, field_name: str) -> bytes:
|
||||
try:
|
||||
return base64.b64decode(value, validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise ValueError(f"Invalid base64 for {field_name}") from exc
|
||||
|
||||
|
||||
def _validate_payload_lengths(
|
||||
encrypted_key: bytes, nonce: bytes, ciphertext: bytes
|
||||
) -> None:
|
||||
if len(encrypted_key) > _MAX_ENCRYPTED_KEY_SIZE:
|
||||
raise ValueError("Encrypted key too large")
|
||||
if len(nonce) != _NONCE_SIZE:
|
||||
raise ValueError("Invalid nonce size")
|
||||
if not ciphertext:
|
||||
raise ValueError("Ciphertext is empty")
|
||||
if len(ciphertext) > _MAX_CIPHERTEXT_SIZE:
|
||||
raise ValueError("Ciphertext exceeds maximum allowed size")
|
||||
|
||||
|
||||
def _build_aad(payload: EncryptedPayload) -> bytes | None:
|
||||
if payload.version is None or payload.version <= 0:
|
||||
return None
|
||||
alg = payload.alg or _ALG
|
||||
enc = payload.enc or _ENC
|
||||
parts = [f"v={payload.version}", f"alg={alg}", f"enc={enc}"]
|
||||
if payload.kid:
|
||||
parts.append(f"kid={payload.kid}")
|
||||
if payload.purpose:
|
||||
parts.append(f"purpose={payload.purpose}")
|
||||
return "|".join(parts).encode("utf-8")
|
||||
|
||||
|
||||
def encrypt(plaintext: str, public_key_pem: bytes) -> EncryptedPayload:
|
||||
public_key = serialization.load_pem_public_key(public_key_pem)
|
||||
if not isinstance(public_key, RSAPublicKey):
|
||||
raise TypeError("Expected RSA public key")
|
||||
|
||||
if public_key.key_size < _MIN_RSA_KEY_SIZE:
|
||||
raise ValueError(f"RSA key size must be at least {_MIN_RSA_KEY_SIZE} bits")
|
||||
|
||||
aes_key = os.urandom(_AES_KEY_SIZE)
|
||||
nonce = os.urandom(_NONCE_SIZE)
|
||||
|
||||
payload = EncryptedPayload(
|
||||
encrypted_key="",
|
||||
nonce="",
|
||||
ciphertext="",
|
||||
version=_PAYLOAD_VERSION,
|
||||
alg=_ALG,
|
||||
enc=_ENC,
|
||||
)
|
||||
aad = _build_aad(payload)
|
||||
aesgcm = AESGCM(aes_key)
|
||||
ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), aad)
|
||||
|
||||
encrypted_key = public_key.encrypt(
|
||||
aes_key,
|
||||
padding.OAEP(
|
||||
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
||||
algorithm=hashes.SHA256(),
|
||||
label=None,
|
||||
),
|
||||
)
|
||||
|
||||
return EncryptedPayload(
|
||||
encrypted_key=base64.b64encode(encrypted_key).decode("ascii"),
|
||||
nonce=base64.b64encode(nonce).decode("ascii"),
|
||||
ciphertext=base64.b64encode(ciphertext).decode("ascii"),
|
||||
version=payload.version,
|
||||
alg=payload.alg,
|
||||
enc=payload.enc,
|
||||
)
|
||||
|
||||
|
||||
def decrypt(payload: EncryptedPayload, private_key_pem: bytes) -> str:
|
||||
private_key = serialization.load_pem_private_key(private_key_pem, password=None)
|
||||
if not isinstance(private_key, RSAPrivateKey):
|
||||
raise TypeError("Expected RSA private key")
|
||||
|
||||
if private_key.key_size < _MIN_RSA_KEY_SIZE:
|
||||
raise ValueError(f"RSA key size must be at least {_MIN_RSA_KEY_SIZE} bits")
|
||||
|
||||
encrypted_key = _b64decode_strict(payload.encrypted_key, "encrypted_key")
|
||||
nonce = _b64decode_strict(payload.nonce, "nonce")
|
||||
ciphertext = _b64decode_strict(payload.ciphertext, "ciphertext")
|
||||
_validate_payload_lengths(encrypted_key, nonce, ciphertext)
|
||||
|
||||
aes_key = private_key.decrypt(
|
||||
encrypted_key,
|
||||
padding.OAEP(
|
||||
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
||||
algorithm=hashes.SHA256(),
|
||||
label=None,
|
||||
),
|
||||
)
|
||||
|
||||
if len(aes_key) != _AES_KEY_SIZE:
|
||||
raise ValueError("Invalid AES key size after decryption")
|
||||
|
||||
aesgcm = AESGCM(aes_key)
|
||||
aad = _build_aad(payload)
|
||||
return aesgcm.decrypt(nonce, ciphertext, aad).decode("utf-8")
|
||||
178
vibe/core/auth/github.py
Normal file
178
vibe/core/auth/github.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
import types
|
||||
import webbrowser
|
||||
|
||||
import httpx
|
||||
import keyring
|
||||
import keyring.errors
|
||||
|
||||
GITHUB_CLIENT_ID = "Ov23liJ7sk5kFDMEyvDT"
|
||||
|
||||
_SERVICE_NAME = "vibe"
|
||||
_KEYRING_USERNAME = "github_token"
|
||||
_DEVICE_CODE_URL = "https://github.com/login/device/code"
|
||||
_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
||||
_VALIDATE_URL = "https://api.github.com/user"
|
||||
_SCOPES = "repo read:org write:org workflow read:user user:email"
|
||||
|
||||
|
||||
class GitHubAuthError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceFlowInfo:
|
||||
user_code: str
|
||||
verification_uri: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceFlowHandle:
|
||||
device_code: str
|
||||
expires_in: int
|
||||
info: DeviceFlowInfo
|
||||
|
||||
|
||||
class GitHubAuthProvider:
|
||||
def __init__(
|
||||
self,
|
||||
client_id: str = GITHUB_CLIENT_ID,
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
timeout: float = 60.0,
|
||||
) -> None:
|
||||
self._client_id = client_id
|
||||
self._client = client
|
||||
self._owns_client = client is None
|
||||
self._timeout = timeout
|
||||
|
||||
async def __aenter__(self) -> GitHubAuthProvider:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: types.TracebackType | None,
|
||||
) -> None:
|
||||
if self._owns_client and self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
def _get_client(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
|
||||
self._owns_client = True
|
||||
return self._client
|
||||
|
||||
def get_token(self) -> str | None:
|
||||
try:
|
||||
return keyring.get_password(_SERVICE_NAME, _KEYRING_USERNAME)
|
||||
except keyring.errors.KeyringError:
|
||||
return None
|
||||
|
||||
def has_token(self) -> bool:
|
||||
return bool(self.get_token())
|
||||
|
||||
def delete_token(self) -> None:
|
||||
try:
|
||||
keyring.delete_password(_SERVICE_NAME, _KEYRING_USERNAME)
|
||||
except keyring.errors.KeyringError:
|
||||
pass
|
||||
|
||||
async def get_valid_token(self) -> str | None:
|
||||
token = self.get_token()
|
||||
if not token:
|
||||
return None
|
||||
if await self._is_token_valid(token):
|
||||
return token
|
||||
self.delete_token()
|
||||
return None
|
||||
|
||||
async def _is_token_valid(self, token: str) -> bool:
|
||||
client = self._get_client()
|
||||
try:
|
||||
response = await client.get(
|
||||
_VALIDATE_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
},
|
||||
)
|
||||
return response.is_success
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
async def start_device_flow(self, open_browser: bool = True) -> DeviceFlowHandle:
|
||||
client = self._get_client()
|
||||
response = await client.post(
|
||||
_DEVICE_CODE_URL,
|
||||
data={"client_id": self._client_id, "scope": _SCOPES},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
if not response.is_success:
|
||||
raise GitHubAuthError(f"Failed to initiate device flow: {response.text}")
|
||||
|
||||
data = response.json()
|
||||
|
||||
if open_browser:
|
||||
webbrowser.open(data["verification_uri"])
|
||||
|
||||
return DeviceFlowHandle(
|
||||
device_code=data["device_code"],
|
||||
expires_in=data["expires_in"],
|
||||
info=DeviceFlowInfo(data["user_code"], data["verification_uri"]),
|
||||
)
|
||||
|
||||
async def wait_for_token(self, handle: DeviceFlowHandle) -> str:
|
||||
client = self._get_client()
|
||||
token = await self._poll_for_token(
|
||||
client, handle.device_code, handle.expires_in, interval=1
|
||||
)
|
||||
self._save_token(token)
|
||||
return token
|
||||
|
||||
def _save_token(self, token: str) -> None:
|
||||
try:
|
||||
keyring.set_password(_SERVICE_NAME, _KEYRING_USERNAME, token)
|
||||
except keyring.errors.KeyringError as e:
|
||||
raise GitHubAuthError(f"Failed to save token to keyring: {e}") from e
|
||||
|
||||
async def _poll_for_token(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
device_code: str,
|
||||
expires_in: int,
|
||||
interval: int,
|
||||
) -> str:
|
||||
elapsed = 0.0
|
||||
while elapsed < expires_in:
|
||||
await asyncio.sleep(interval)
|
||||
elapsed += interval
|
||||
|
||||
response = await client.post(
|
||||
_TOKEN_URL,
|
||||
data={
|
||||
"client_id": self._client_id,
|
||||
"device_code": device_code,
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
result = response.json()
|
||||
|
||||
if "access_token" in result:
|
||||
return result["access_token"]
|
||||
|
||||
error = result.get("error")
|
||||
if error == "slow_down":
|
||||
interval = result.get("interval", interval + 5)
|
||||
elif error in {"expired_token", "access_denied"}:
|
||||
raise GitHubAuthError(f"Authentication failed: {error}")
|
||||
|
||||
raise GitHubAuthError("Authentication timed out")
|
||||
|
|
@ -34,7 +34,8 @@ def load_dotenv_values(
|
|||
env_path: Path = GLOBAL_ENV_FILE.path,
|
||||
environ: MutableMapping[str, str] = os.environ,
|
||||
) -> None:
|
||||
if not env_path.is_file():
|
||||
# We allow FIFO path to support some environment management solutions (e.g. https://developer.1password.com/docs/environments/local-env-file/)
|
||||
if not env_path.is_file() and not env_path.is_fifo():
|
||||
return
|
||||
|
||||
env_vars = dotenv_values(env_path)
|
||||
|
|
@ -260,11 +261,14 @@ class ModelConfig(BaseModel):
|
|||
return data
|
||||
|
||||
|
||||
DEFAULT_MISTRAL_API_ENV_KEY = "MISTRAL_API_KEY"
|
||||
|
||||
|
||||
DEFAULT_PROVIDERS = [
|
||||
ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY,
|
||||
backend=Backend.MISTRAL,
|
||||
),
|
||||
ProviderConfig(
|
||||
|
|
@ -301,9 +305,9 @@ DEFAULT_MODELS = [
|
|||
|
||||
class VibeConfig(BaseSettings):
|
||||
active_model: str = "devstral-2"
|
||||
textual_theme: str = "terminal"
|
||||
vim_keybindings: bool = False
|
||||
disable_welcome_banner_animation: bool = False
|
||||
autocopy_to_clipboard: bool = True
|
||||
displayed_workdir: str = ""
|
||||
auto_compact_threshold: int = 200_000
|
||||
context_warnings: bool = False
|
||||
|
|
@ -316,6 +320,14 @@ class VibeConfig(BaseSettings):
|
|||
enable_update_checks: bool = True
|
||||
enable_auto_update: bool = True
|
||||
api_timeout: float = 720.0
|
||||
|
||||
# TODO(vibe-nuage): remove exclude=True once the feature is publicly available
|
||||
nuage_enabled: bool = Field(default=False, exclude=True)
|
||||
nuage_base_url: str = Field(default="https://api.globalaegis.net", exclude=True)
|
||||
nuage_workflow_id: str = Field(default="__shared-nuage-workflow", exclude=True)
|
||||
# TODO(vibe-nuage): change default value to MISTRAL_API_KEY once prod has shared vibe-nuage workers
|
||||
nuage_api_key_env_var: str = Field(default="STAGING_MISTRAL_API_KEY", exclude=True)
|
||||
|
||||
providers: list[ProviderConfig] = Field(
|
||||
default_factory=lambda: list(DEFAULT_PROVIDERS)
|
||||
)
|
||||
|
|
@ -402,6 +414,10 @@ class VibeConfig(BaseSettings):
|
|||
env_prefix="VIBE_", case_sensitive=False, extra="ignore"
|
||||
)
|
||||
|
||||
@property
|
||||
def nuage_api_key(self) -> str:
|
||||
return os.getenv(self.nuage_api_key_env_var, "")
|
||||
|
||||
@property
|
||||
def system_prompt(self) -> str:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
|
|
@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any
|
|||
from anyio import NamedTemporaryFile, Path as AsyncPath
|
||||
|
||||
from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata
|
||||
from vibe.core.utils import is_windows
|
||||
from vibe.core.utils import is_windows, utc_now
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.agents.models import AgentProfile
|
||||
|
|
@ -40,7 +40,7 @@ class SessionLogger:
|
|||
self.save_dir = Path(session_config.save_dir)
|
||||
self.session_prefix = session_config.session_prefix
|
||||
self.session_id = session_id
|
||||
self.session_start_time = datetime.now().isoformat()
|
||||
self.session_start_time = utc_now().isoformat()
|
||||
|
||||
self.save_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.session_dir = self.save_folder
|
||||
|
|
@ -53,7 +53,7 @@ class SessionLogger:
|
|||
"Cannot get session save folder when logging is disabled"
|
||||
)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
timestamp = utc_now().strftime("%Y%m%d_%H%M%S")
|
||||
folder_name = f"{self.session_prefix}_{timestamp}_{self.session_id[:8]}"
|
||||
return self.save_dir / folder_name
|
||||
|
||||
|
|
@ -265,7 +265,7 @@ class SessionLogger:
|
|||
|
||||
metadata_dump = {
|
||||
**self.session_metadata.model_dump(),
|
||||
"end_time": datetime.now().isoformat(),
|
||||
"end_time": utc_now().isoformat(),
|
||||
"stats": stats.model_dump(),
|
||||
"title": title,
|
||||
"total_messages": total_messages,
|
||||
|
|
@ -292,7 +292,7 @@ class SessionLogger:
|
|||
return
|
||||
|
||||
self.session_id = session_id
|
||||
self.session_start_time = datetime.now().isoformat()
|
||||
self.session_start_time = utc_now().isoformat()
|
||||
self.session_dir = self.save_folder
|
||||
self.session_metadata = self._initialize_session_metadata()
|
||||
|
||||
|
|
@ -301,7 +301,7 @@ class SessionLogger:
|
|||
if not self.enabled or not self.save_dir:
|
||||
return
|
||||
|
||||
now = datetime.now()
|
||||
now = utc_now()
|
||||
ago = now - timedelta(minutes=5)
|
||||
|
||||
tmp_files = self.save_dir.glob("**/*.json.tmp") # Recursive search
|
||||
|
|
@ -309,7 +309,9 @@ class SessionLogger:
|
|||
for file_path in tmp_files:
|
||||
if file_path.is_file():
|
||||
try:
|
||||
file_mtime = datetime.fromtimestamp(file_path.stat().st_mtime)
|
||||
file_mtime = datetime.fromtimestamp(
|
||||
file_path.stat().st_mtime, tz=UTC
|
||||
)
|
||||
if file_mtime < ago:
|
||||
file_path.unlink()
|
||||
except Exception:
|
||||
|
|
|
|||
9
vibe/core/teleport/errors.py
Normal file
9
vibe/core/teleport/errors.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
|
||||
class ServiceTeleportError(Exception):
|
||||
"""Base exception for teleport errors."""
|
||||
|
||||
|
||||
class ServiceTeleportNotSupportedError(ServiceTeleportError):
|
||||
"""Raised when teleport is not supported in current environment."""
|
||||
196
vibe/core/teleport/git.py
Normal file
196
vibe/core/teleport/git.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from git import InvalidGitRepositoryError, Repo
|
||||
from git.exc import GitCommandError
|
||||
from giturlparse import parse as parse_git_url
|
||||
|
||||
from vibe.core.teleport.errors import (
|
||||
ServiceTeleportError,
|
||||
ServiceTeleportNotSupportedError,
|
||||
)
|
||||
from vibe.core.utils import AsyncExecutor
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitRepoInfo:
|
||||
remote_url: str
|
||||
owner: str
|
||||
repo: str
|
||||
branch: str | None
|
||||
commit: str
|
||||
diff: str
|
||||
|
||||
|
||||
class GitRepository:
|
||||
def __init__(self, workdir: Path | None = None) -> None:
|
||||
self._workdir = workdir or Path.cwd()
|
||||
self._repo: Repo | None = None
|
||||
# For network I/O (fetch, push) and potentially slow git commands (diff, rev-list)
|
||||
self._executor = AsyncExecutor(max_workers=2, timeout=60.0, name="git")
|
||||
|
||||
async def __aenter__(self) -> GitRepository:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
self._executor.shutdown(wait=False)
|
||||
|
||||
async def is_supported(self) -> bool:
|
||||
try:
|
||||
repo = self._repo_or_raise()
|
||||
except ServiceTeleportNotSupportedError:
|
||||
return False
|
||||
return self._find_github_remote(repo) is not None
|
||||
|
||||
async def get_info(self) -> GitRepoInfo:
|
||||
repo = self._repo_or_raise()
|
||||
|
||||
parsed = self._find_github_remote(repo)
|
||||
if not parsed:
|
||||
raise ServiceTeleportNotSupportedError(
|
||||
"No GitHub remote found. Teleport only supports GitHub repositories."
|
||||
)
|
||||
|
||||
try:
|
||||
commit = repo.head.commit.hexsha
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ServiceTeleportNotSupportedError(
|
||||
"Could not determine current commit"
|
||||
) from e
|
||||
|
||||
if not commit:
|
||||
raise ServiceTeleportNotSupportedError("Could not determine current commit")
|
||||
|
||||
owner, repo_name = parsed
|
||||
branch = None if repo.head.is_detached else repo.active_branch.name
|
||||
diff = await self._get_diff(repo)
|
||||
|
||||
return GitRepoInfo(
|
||||
remote_url=self._to_https_url(owner, repo_name),
|
||||
owner=owner,
|
||||
repo=repo_name,
|
||||
branch=branch,
|
||||
commit=commit,
|
||||
diff=diff,
|
||||
)
|
||||
|
||||
async def is_commit_pushed(self, commit: str, remote: str = "origin") -> bool:
|
||||
repo = self._repo_or_raise()
|
||||
await self._fetch(repo, remote)
|
||||
return await self._branch_contains(repo, commit, remote)
|
||||
|
||||
async def get_unpushed_commit_count(self, remote: str = "origin") -> int:
|
||||
repo = self._repo_or_raise()
|
||||
|
||||
if repo.head.is_detached:
|
||||
raise ServiceTeleportError(
|
||||
"Cannot count unpushed commits: no current branch"
|
||||
)
|
||||
branch = repo.active_branch.name
|
||||
|
||||
await self._fetch(repo, remote)
|
||||
|
||||
result = await self._rev_list_count(repo, f"{remote}/{branch}..HEAD")
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
# Fallback: branch not pushed yet, count commits from default branch
|
||||
default_branch = await self._get_remote_default_branch(repo, remote)
|
||||
if default_branch:
|
||||
result = await self._rev_list_count(repo, f"{default_branch}..HEAD")
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
raise ServiceTeleportError(f"Failed to count unpushed commits for {branch}")
|
||||
|
||||
async def push_current_branch(self, remote: str = "origin") -> bool:
|
||||
repo = self._repo_or_raise()
|
||||
if repo.head.is_detached:
|
||||
return False
|
||||
return await self._push(repo, repo.active_branch.name, remote)
|
||||
|
||||
def _repo_or_raise(self) -> Repo:
|
||||
if self._repo is None:
|
||||
try:
|
||||
self._repo = Repo(self._workdir, search_parent_directories=True)
|
||||
except InvalidGitRepositoryError as e:
|
||||
raise ServiceTeleportNotSupportedError("Not a git repository") from e
|
||||
return self._repo
|
||||
|
||||
def _find_github_remote(self, repo: Repo) -> tuple[str, str] | None:
|
||||
for remote in repo.remotes:
|
||||
for url in remote.urls:
|
||||
if parsed := self._parse_github_url(url):
|
||||
return parsed
|
||||
return None
|
||||
|
||||
async def _fetch(self, repo: Repo, remote: str) -> None:
|
||||
try:
|
||||
await self._executor.run(lambda: repo.remote(remote).fetch())
|
||||
except (TimeoutError, ValueError, GitCommandError):
|
||||
pass
|
||||
|
||||
async def _get_diff(self, repo: Repo) -> str:
|
||||
def get_full_diff() -> str:
|
||||
# Mark untracked files as intent-to-add so they appear in diff
|
||||
repo.git.add("-N", ".")
|
||||
return repo.git.diff("HEAD", binary=True)
|
||||
|
||||
try:
|
||||
return await self._executor.run(get_full_diff)
|
||||
except (TimeoutError, GitCommandError):
|
||||
return ""
|
||||
|
||||
async def _branch_contains(self, repo: Repo, commit: str, remote: str) -> bool:
|
||||
try:
|
||||
out = await self._executor.run(
|
||||
lambda: repo.git.branch("-r", "--contains", commit)
|
||||
)
|
||||
return any(ln.strip().startswith(f"{remote}/") for ln in out.splitlines())
|
||||
except (TimeoutError, GitCommandError):
|
||||
return False
|
||||
|
||||
async def _rev_list_count(self, repo: Repo, ref_range: str) -> int | None:
|
||||
try:
|
||||
out = await self._executor.run(
|
||||
lambda: repo.git.rev_list("--count", ref_range)
|
||||
)
|
||||
return int(out)
|
||||
except (TimeoutError, GitCommandError, ValueError):
|
||||
return None
|
||||
|
||||
async def _ref_exists(self, repo: Repo, ref: str) -> bool:
|
||||
try:
|
||||
await self._executor.run(lambda: repo.git.rev_parse("--verify", ref))
|
||||
return True
|
||||
except (TimeoutError, GitCommandError):
|
||||
return False
|
||||
|
||||
async def _get_remote_default_branch(self, repo: Repo, remote: str) -> str | None:
|
||||
try:
|
||||
ref = repo.remotes[remote].refs.HEAD.reference.name
|
||||
if await self._ref_exists(repo, ref):
|
||||
return ref
|
||||
except (KeyError, IndexError, TypeError, AttributeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
async def _push(self, repo: Repo, branch: str, remote: str) -> bool:
|
||||
try:
|
||||
await self._executor.run(lambda: repo.remote(remote).push(branch))
|
||||
return True
|
||||
except (TimeoutError, ValueError, GitCommandError):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _parse_github_url(url: str) -> tuple[str, str] | None:
|
||||
p = parse_git_url(url)
|
||||
if p.github and p.owner and p.repo:
|
||||
return p.owner, p.repo
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _to_https_url(owner: str, repo: str) -> str:
|
||||
return f"https://github.com/{owner}/{repo}.git"
|
||||
180
vibe/core/teleport/nuage.py
Normal file
180
vibe/core/teleport/nuage.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
import types
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from vibe.core.auth import EncryptedPayload, encrypt
|
||||
from vibe.core.teleport.errors import ServiceTeleportError
|
||||
|
||||
|
||||
class GitRepoConfig(BaseModel):
|
||||
url: str
|
||||
branch: str | None = None
|
||||
commit: str | None = None
|
||||
|
||||
|
||||
class VibeSandboxConfig(BaseModel):
|
||||
git_repo: GitRepoConfig | None = None
|
||||
|
||||
|
||||
class VibeNewSandbox(BaseModel):
|
||||
type: str = "new"
|
||||
config: VibeSandboxConfig = Field(default_factory=VibeSandboxConfig)
|
||||
teleported_diffs: bytes | None = None
|
||||
|
||||
|
||||
class TeleportSession(BaseModel):
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
messages: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WorkflowParams(BaseModel):
|
||||
prompt: str
|
||||
sandbox: VibeNewSandbox
|
||||
session: TeleportSession | None = None
|
||||
|
||||
|
||||
class WorkflowExecuteResponse(BaseModel):
|
||||
execution_id: str
|
||||
|
||||
|
||||
class PublicKeyResult(BaseModel):
|
||||
public_key: str
|
||||
|
||||
|
||||
class QueryResponse(BaseModel):
|
||||
result: PublicKeyResult
|
||||
|
||||
|
||||
class CreateLeChatThreadInput(BaseModel):
|
||||
encrypted_api_key: dict[str, str]
|
||||
user_message: str
|
||||
project_name: str | None = None
|
||||
|
||||
|
||||
class CreateLeChatThreadOutput(BaseModel):
|
||||
chat_url: str
|
||||
|
||||
|
||||
class UpdateResponse(BaseModel):
|
||||
result: CreateLeChatThreadOutput
|
||||
|
||||
|
||||
class NuageClient:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
workflow_id: str,
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
timeout: float = 60.0,
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._workflow_id = workflow_id
|
||||
self._client = client
|
||||
self._owns_client = client is None
|
||||
self._timeout = timeout
|
||||
|
||||
async def __aenter__(self) -> NuageClient:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: types.TracebackType | None,
|
||||
) -> None:
|
||||
if self._owns_client and self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
@property
|
||||
def _http_client(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
|
||||
self._owns_client = True
|
||||
return self._client
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def start_workflow(self, params: WorkflowParams) -> str:
|
||||
response = await self._http_client.post(
|
||||
f"{self._base_url}/v1/workflows/{self._workflow_id}/execute",
|
||||
headers=self._headers(),
|
||||
json={"input": params.model_dump(mode="json")},
|
||||
)
|
||||
if not response.is_success:
|
||||
error_msg = f"Nuage workflow trigger failed: {response.text}"
|
||||
# TODO(vibe-nuage): remove this once prod has shared vibe-nuage workers
|
||||
if "Unauthorized" in response.text or "unauthorized" in response.text:
|
||||
error_msg += (
|
||||
"\n\nHint: This version uses Mistral staging environment. "
|
||||
"Set STAGING_MISTRAL_API_KEY from https://console.globalaegis.net/"
|
||||
)
|
||||
raise ServiceTeleportError(error_msg)
|
||||
result = WorkflowExecuteResponse.model_validate(response.json())
|
||||
return result.execution_id
|
||||
|
||||
async def send_github_token(self, execution_id: str, token: str) -> None:
|
||||
public_key_pem = await self._query_public_key(execution_id)
|
||||
encrypted = encrypt(token, public_key_pem)
|
||||
await self._signal_encrypted_token(execution_id, encrypted)
|
||||
|
||||
async def _query_public_key(self, execution_id: str) -> bytes:
|
||||
response = await self._http_client.post(
|
||||
f"{self._base_url}/v1/workflows/executions/{execution_id}/queries",
|
||||
headers=self._headers(),
|
||||
json={"name": "get_public_key", "input": {}},
|
||||
)
|
||||
if not response.is_success:
|
||||
raise ServiceTeleportError(f"Failed to get public key: {response.text}")
|
||||
|
||||
result = QueryResponse.model_validate(response.json())
|
||||
return result.result.public_key.encode("utf-8")
|
||||
|
||||
async def _signal_encrypted_token(
|
||||
self, execution_id: str, encrypted: EncryptedPayload
|
||||
) -> None:
|
||||
response = await self._http_client.post(
|
||||
f"{self._base_url}/v1/workflows/executions/{execution_id}/signals",
|
||||
headers=self._headers(),
|
||||
json={"name": "github_token", "input": {"payload": asdict(encrypted)}},
|
||||
)
|
||||
if not response.is_success:
|
||||
raise ServiceTeleportError(f"Failed to send GitHub token: {response.text}")
|
||||
|
||||
async def create_le_chat_thread(
|
||||
self, execution_id: str, user_message: str, project_name: str | None = None
|
||||
) -> str:
|
||||
public_key_pem = await self._query_public_key(execution_id)
|
||||
encrypted = encrypt(self._api_key, public_key_pem)
|
||||
input_data = CreateLeChatThreadInput(
|
||||
encrypted_api_key={
|
||||
k: v for k, v in asdict(encrypted).items() if v is not None
|
||||
},
|
||||
user_message=user_message,
|
||||
project_name=project_name,
|
||||
)
|
||||
response = await self._http_client.post(
|
||||
f"{self._base_url}/v1/workflows/executions/{execution_id}/updates",
|
||||
headers=self._headers(),
|
||||
json={"name": "create_le_chat_thread", "input": input_data.model_dump()},
|
||||
)
|
||||
if not response.is_success:
|
||||
raise ServiceTeleportError(
|
||||
f"Failed to create Le Chat thread: {response.text}"
|
||||
)
|
||||
result = UpdateResponse.model_validate(response.json())
|
||||
return result.result.chat_url
|
||||
208
vibe/core/teleport/teleport.py
Normal file
208
vibe/core/teleport/teleport.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from collections.abc import AsyncGenerator
|
||||
from pathlib import Path
|
||||
import types
|
||||
|
||||
import httpx
|
||||
import zstandard
|
||||
|
||||
from vibe.core.auth.github import GitHubAuthProvider
|
||||
from vibe.core.session.session_logger import SessionLogger
|
||||
from vibe.core.teleport.errors import ServiceTeleportError
|
||||
from vibe.core.teleport.git import GitRepoInfo, GitRepository
|
||||
from vibe.core.teleport.nuage import (
|
||||
GitRepoConfig,
|
||||
NuageClient,
|
||||
TeleportSession,
|
||||
VibeNewSandbox,
|
||||
VibeSandboxConfig,
|
||||
WorkflowParams,
|
||||
)
|
||||
from vibe.core.teleport.types import (
|
||||
TeleportAuthCompleteEvent,
|
||||
TeleportAuthRequiredEvent,
|
||||
TeleportCheckingGitEvent,
|
||||
TeleportCompleteEvent,
|
||||
TeleportPushingEvent,
|
||||
TeleportPushRequiredEvent,
|
||||
TeleportPushResponseEvent,
|
||||
TeleportSendEvent,
|
||||
TeleportSendingGithubTokenEvent,
|
||||
TeleportStartingWorkflowEvent,
|
||||
TeleportYieldEvent,
|
||||
)
|
||||
|
||||
# TODO(vibe-nuage): update URL once prod has shared vibe-nuage workers
|
||||
_NUAGE_EXECUTION_URL_TEMPLATE = "https://console.globalaegis.net/build/workflows/{workflow_id}?tab=executions&executionId={execution_id}"
|
||||
_DEFAULT_TELEPORT_PROMPT = "please continue where you left off"
|
||||
|
||||
|
||||
class TeleportService:
|
||||
def __init__(
|
||||
self,
|
||||
session_logger: SessionLogger,
|
||||
nuage_base_url: str,
|
||||
nuage_workflow_id: str,
|
||||
nuage_api_key: str,
|
||||
workdir: Path | None = None,
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
timeout: float = 60.0,
|
||||
) -> None:
|
||||
self._session_logger = session_logger
|
||||
self._nuage_base_url = nuage_base_url
|
||||
self._nuage_workflow_id = nuage_workflow_id
|
||||
self._nuage_api_key = nuage_api_key
|
||||
self._git = GitRepository(workdir)
|
||||
self._client = client
|
||||
self._owns_client = client is None
|
||||
self._timeout = timeout
|
||||
self._github_auth: GitHubAuthProvider | None = None
|
||||
self._nuage: NuageClient | None = None
|
||||
|
||||
async def __aenter__(self) -> TeleportService:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
|
||||
self._github_auth = GitHubAuthProvider(client=self._client)
|
||||
self._nuage = NuageClient(
|
||||
self._nuage_base_url,
|
||||
self._nuage_api_key,
|
||||
self._nuage_workflow_id,
|
||||
client=self._client,
|
||||
)
|
||||
await self._git.__aenter__()
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: types.TracebackType | None,
|
||||
) -> None:
|
||||
await self._git.__aexit__(exc_type, exc_val, exc_tb)
|
||||
if self._owns_client and self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
@property
|
||||
def _http_client(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout))
|
||||
self._owns_client = True
|
||||
return self._client
|
||||
|
||||
@property
|
||||
def _github_auth_provider(self) -> GitHubAuthProvider:
|
||||
if self._github_auth is None:
|
||||
self._github_auth = GitHubAuthProvider(client=self._http_client)
|
||||
return self._github_auth
|
||||
|
||||
@property
|
||||
def _nuage_client(self) -> NuageClient:
|
||||
if self._nuage is None:
|
||||
self._nuage = NuageClient(
|
||||
self._nuage_base_url,
|
||||
self._nuage_api_key,
|
||||
self._nuage_workflow_id,
|
||||
client=self._http_client,
|
||||
)
|
||||
return self._nuage
|
||||
|
||||
async def check_supported(self) -> None:
|
||||
await self._git.get_info()
|
||||
|
||||
async def is_supported(self) -> bool:
|
||||
return await self._git.is_supported()
|
||||
|
||||
async def execute(
|
||||
self, prompt: str | None, session: TeleportSession
|
||||
) -> AsyncGenerator[TeleportYieldEvent, TeleportSendEvent]:
|
||||
prompt = prompt or _DEFAULT_TELEPORT_PROMPT
|
||||
self._validate_config()
|
||||
|
||||
git_info = await self._git.get_info()
|
||||
|
||||
yield TeleportCheckingGitEvent()
|
||||
if not await self._git.is_commit_pushed(git_info.commit):
|
||||
unpushed_count = await self._git.get_unpushed_commit_count()
|
||||
response = yield TeleportPushRequiredEvent(
|
||||
unpushed_count=max(1, unpushed_count)
|
||||
)
|
||||
if (
|
||||
not isinstance(response, TeleportPushResponseEvent)
|
||||
or not response.approved
|
||||
):
|
||||
raise ServiceTeleportError("Teleport cancelled: commit not pushed.")
|
||||
|
||||
yield TeleportPushingEvent()
|
||||
await self._push_or_fail()
|
||||
|
||||
github_token = await self._github_auth_provider.get_valid_token()
|
||||
|
||||
if not github_token:
|
||||
handle = await self._github_auth_provider.start_device_flow(
|
||||
open_browser=True
|
||||
)
|
||||
yield TeleportAuthRequiredEvent(
|
||||
user_code=handle.info.user_code,
|
||||
verification_uri=handle.info.verification_uri,
|
||||
)
|
||||
github_token = await self._github_auth_provider.wait_for_token(handle)
|
||||
yield TeleportAuthCompleteEvent()
|
||||
|
||||
yield TeleportStartingWorkflowEvent()
|
||||
|
||||
execution_id = await self._nuage_client.start_workflow(
|
||||
WorkflowParams(
|
||||
prompt=prompt, sandbox=self._build_sandbox(git_info), session=session
|
||||
)
|
||||
)
|
||||
|
||||
yield TeleportSendingGithubTokenEvent()
|
||||
await self._nuage_client.send_github_token(execution_id, github_token)
|
||||
|
||||
chat_url = _NUAGE_EXECUTION_URL_TEMPLATE.format(
|
||||
workflow_id=self._nuage_workflow_id, execution_id=execution_id
|
||||
)
|
||||
# chat_url = await nuage.create_le_chat_thread(
|
||||
# execution_id=execution_id, user_message=prompt
|
||||
# )
|
||||
|
||||
yield TeleportCompleteEvent(url=chat_url)
|
||||
|
||||
async def _push_or_fail(self) -> None:
|
||||
if not await self._git.push_current_branch():
|
||||
raise ServiceTeleportError("Failed to push current branch to remote.")
|
||||
|
||||
def _validate_config(self) -> None:
|
||||
# TODO(vibe-nuage): update error message once prod has shared vibe-nuage workers
|
||||
if not self._nuage_api_key:
|
||||
raise ServiceTeleportError(
|
||||
"STAGING_MISTRAL_API_KEY not set. "
|
||||
"Set it from https://console.globalaegis.net/ to use teleport."
|
||||
)
|
||||
|
||||
def _build_sandbox(self, git_info: GitRepoInfo) -> VibeNewSandbox:
|
||||
return VibeNewSandbox(
|
||||
config=VibeSandboxConfig(
|
||||
git_repo=GitRepoConfig(
|
||||
url=git_info.remote_url,
|
||||
branch=git_info.branch,
|
||||
commit=git_info.commit,
|
||||
)
|
||||
),
|
||||
teleported_diffs=self._compress_diff(git_info.diff or ""),
|
||||
)
|
||||
|
||||
def _compress_diff(self, diff: str, max_size: int = 1_000_000) -> bytes | None:
|
||||
if not diff:
|
||||
return None
|
||||
compressed = zstandard.ZstdCompressor().compress(diff.encode("utf-8"))
|
||||
encoded = base64.b64encode(compressed)
|
||||
if len(encoded) > max_size:
|
||||
raise ServiceTeleportError(
|
||||
"Diff too large to teleport. Please commit and push your changes first."
|
||||
)
|
||||
return encoded
|
||||
54
vibe/core/teleport/types.py
Normal file
54
vibe/core/teleport/types.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.core.types import BaseEvent
|
||||
|
||||
|
||||
class TeleportAuthRequiredEvent(BaseEvent):
|
||||
user_code: str
|
||||
verification_uri: str
|
||||
|
||||
|
||||
class TeleportAuthCompleteEvent(BaseEvent):
|
||||
pass
|
||||
|
||||
|
||||
class TeleportStartingWorkflowEvent(BaseEvent):
|
||||
pass
|
||||
|
||||
|
||||
class TeleportCheckingGitEvent(BaseEvent):
|
||||
pass
|
||||
|
||||
|
||||
class TeleportPushRequiredEvent(BaseEvent):
|
||||
unpushed_count: int = 1
|
||||
|
||||
|
||||
class TeleportPushResponseEvent(BaseEvent):
|
||||
approved: bool
|
||||
|
||||
|
||||
class TeleportPushingEvent(BaseEvent):
|
||||
pass
|
||||
|
||||
|
||||
class TeleportSendingGithubTokenEvent(BaseEvent):
|
||||
pass
|
||||
|
||||
|
||||
class TeleportCompleteEvent(BaseEvent):
|
||||
url: str
|
||||
|
||||
|
||||
type TeleportYieldEvent = (
|
||||
TeleportAuthRequiredEvent
|
||||
| TeleportAuthCompleteEvent
|
||||
| TeleportCheckingGitEvent
|
||||
| TeleportPushRequiredEvent
|
||||
| TeleportPushingEvent
|
||||
| TeleportStartingWorkflowEvent
|
||||
| TeleportSendingGithubTokenEvent
|
||||
| TeleportCompleteEvent
|
||||
)
|
||||
|
||||
type TeleportSendEvent = TeleportPushResponseEvent | None
|
||||
|
|
@ -39,6 +39,9 @@ class Question(BaseModel):
|
|||
multi_select: bool = Field(
|
||||
default=False, description="If true, user can select multiple options"
|
||||
)
|
||||
hide_other: bool = Field(
|
||||
default=False, description="If true, hide the 'Other' free text option"
|
||||
)
|
||||
|
||||
|
||||
class AskUserQuestionArgs(BaseModel):
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from collections import OrderedDict
|
|||
from collections.abc import Awaitable, Callable
|
||||
import copy
|
||||
from enum import StrEnum, auto
|
||||
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -18,6 +18,7 @@ from pydantic import (
|
|||
BeforeValidator,
|
||||
ConfigDict,
|
||||
Field,
|
||||
PrivateAttr,
|
||||
computed_field,
|
||||
model_validator,
|
||||
)
|
||||
|
|
@ -33,7 +34,6 @@ class AgentStats(BaseModel):
|
|||
tool_calls_succeeded: int = 0
|
||||
|
||||
context_tokens: int = 0
|
||||
listeners: ClassVar[dict[str, Callable[[AgentStats], None]]] = {}
|
||||
|
||||
last_turn_prompt_tokens: int = 0
|
||||
last_turn_completion_tokens: int = 0
|
||||
|
|
@ -43,20 +43,23 @@ class AgentStats(BaseModel):
|
|||
input_price_per_million: float = 0.0
|
||||
output_price_per_million: float = 0.0
|
||||
|
||||
_listeners: dict[str, Callable[[AgentStats], None]] = PrivateAttr(
|
||||
default_factory=dict
|
||||
)
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
super().__setattr__(name, value)
|
||||
if name in self.listeners:
|
||||
self.listeners[name](self)
|
||||
if name in self._listeners:
|
||||
self._listeners[name](self)
|
||||
|
||||
def trigger_listeners(self) -> None:
|
||||
for listener in self.listeners.values():
|
||||
for listener in self._listeners.values():
|
||||
listener(self)
|
||||
|
||||
@classmethod
|
||||
def add_listener(
|
||||
cls, attr_name: str, listener: Callable[[AgentStats], None]
|
||||
self, attr_name: str, listener: Callable[[AgentStats], None]
|
||||
) -> None:
|
||||
cls.listeners[attr_name] = listener
|
||||
self._listeners[attr_name] = listener
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine
|
||||
import concurrent.futures
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum, auto
|
||||
from fnmatch import fnmatch
|
||||
import functools
|
||||
|
|
@ -306,6 +307,37 @@ def name_matches(name: str, patterns: list[str]) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
class AsyncExecutor:
|
||||
"""Run sync functions in a thread pool with timeout. Supports async context manager."""
|
||||
|
||||
def __init__(
|
||||
self, max_workers: int = 4, timeout: float = 60.0, name: str = "async-executor"
|
||||
) -> None:
|
||||
self._executor = concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=max_workers, thread_name_prefix=name
|
||||
)
|
||||
self._timeout = timeout
|
||||
|
||||
async def __aenter__(self) -> AsyncExecutor:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
self.shutdown(wait=False)
|
||||
|
||||
async def run[T](self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
||||
loop = asyncio.get_running_loop()
|
||||
future = loop.run_in_executor(
|
||||
self._executor, functools.partial(fn, *args, **kwargs)
|
||||
)
|
||||
try:
|
||||
return await asyncio.wait_for(future, timeout=self._timeout)
|
||||
except TimeoutError as e:
|
||||
raise TimeoutError(f"Operation timed out after {self._timeout}s") from e
|
||||
|
||||
def shutdown(self, wait: bool = True) -> None:
|
||||
self._executor.shutdown(wait=wait)
|
||||
|
||||
|
||||
def compact_reduction_display(old_tokens: int | None, new_tokens: int | None) -> str:
|
||||
if old_tokens is None or new_tokens is None:
|
||||
return "Compaction complete"
|
||||
|
|
@ -316,3 +348,7 @@ def compact_reduction_display(old_tokens: int | None, new_tokens: int | None) ->
|
|||
f"Compaction complete: {old_tokens:,} → "
|
||||
f"{new_tokens:,} tokens ({-reduction_pct:+#0.2g}%)"
|
||||
)
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
|
|
|||
|
|
@ -4,34 +4,18 @@ import sys
|
|||
|
||||
from rich import print as rprint
|
||||
from textual.app import App
|
||||
from textual.theme import Theme
|
||||
|
||||
from vibe.cli.textual_ui.terminal_theme import (
|
||||
TERMINAL_THEME_NAME,
|
||||
capture_terminal_theme,
|
||||
)
|
||||
from vibe.core.paths.global_paths import GLOBAL_ENV_FILE
|
||||
from vibe.setup.onboarding.screens import (
|
||||
ApiKeyScreen,
|
||||
ThemeSelectionScreen,
|
||||
WelcomeScreen,
|
||||
)
|
||||
from vibe.setup.onboarding.screens import ApiKeyScreen, WelcomeScreen
|
||||
|
||||
|
||||
class OnboardingApp(App[str | None]):
|
||||
CSS_PATH = "onboarding.tcss"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._terminal_theme: Theme | None = capture_terminal_theme()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
if self._terminal_theme:
|
||||
self.register_theme(self._terminal_theme)
|
||||
self.theme = TERMINAL_THEME_NAME
|
||||
self.theme = "textual-ansi"
|
||||
|
||||
self.install_screen(WelcomeScreen(), "welcome")
|
||||
self.install_screen(ThemeSelectionScreen(), "theme_selection")
|
||||
self.install_screen(ApiKeyScreen(), "api_key")
|
||||
self.push_screen("welcome")
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ OnboardingScreen {
|
|||
}
|
||||
|
||||
WelcomeScreen #enter-hint {
|
||||
color: $text-muted;
|
||||
color: ansi_bright_black;
|
||||
min-width: 16;
|
||||
text-align: center;
|
||||
}
|
||||
|
|
@ -36,102 +36,6 @@ WelcomeScreen #enter-hint.hidden {
|
|||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Theme Selection Screen
|
||||
============================================================================= */
|
||||
|
||||
#theme-outer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align: center middle;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#theme-content {
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 1 0;
|
||||
}
|
||||
|
||||
#theme-title {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
margin-bottom: 2;
|
||||
}
|
||||
|
||||
#theme-row {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#nav-hint {
|
||||
color: $text-muted;
|
||||
width: 16;
|
||||
height: 100%;
|
||||
content-align: right middle;
|
||||
padding-right: 2;
|
||||
}
|
||||
|
||||
#theme-list {
|
||||
width: 24;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.theme-item {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.theme-item.selected {
|
||||
color: $text;
|
||||
text-style: bold;
|
||||
border: round #555555;
|
||||
padding: 0 2;
|
||||
}
|
||||
|
||||
.theme-item.fade-1 {
|
||||
text-opacity: 50%;
|
||||
}
|
||||
|
||||
.theme-item.fade-2 {
|
||||
text-opacity: 25%;
|
||||
}
|
||||
|
||||
.theme-item.fade-3 {
|
||||
text-opacity: 10%;
|
||||
}
|
||||
|
||||
ThemeSelectionScreen #enter-hint {
|
||||
color: $text-muted;
|
||||
width: 16;
|
||||
height: 100%;
|
||||
content-align: left middle;
|
||||
padding-left: 2;
|
||||
}
|
||||
|
||||
#preview-center {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-top: 2;
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#preview {
|
||||
min-width: 50;
|
||||
max-width: 70;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
border: round #555555;
|
||||
border-title-align: center;
|
||||
}
|
||||
|
||||
#preview-inner {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 0 1 0 1;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
API Key Screen
|
||||
============================================================================= */
|
||||
|
|
@ -177,11 +81,11 @@ ThemeSelectionScreen #enter-hint {
|
|||
}
|
||||
|
||||
#input-box.valid {
|
||||
border: round $success;
|
||||
border: round ansi_green;
|
||||
}
|
||||
|
||||
#input-box.invalid {
|
||||
border: round $error;
|
||||
border: round ansi_red;
|
||||
}
|
||||
|
||||
#key {
|
||||
|
|
@ -202,11 +106,11 @@ ThemeSelectionScreen #enter-hint {
|
|||
}
|
||||
|
||||
#feedback.error {
|
||||
color: $error;
|
||||
color: ansi_red;
|
||||
}
|
||||
|
||||
#feedback.success {
|
||||
color: $text-muted;
|
||||
color: ansi_bright_black;
|
||||
}
|
||||
|
||||
#config-docs-section {
|
||||
|
|
@ -222,7 +126,7 @@ ThemeSelectionScreen #enter-hint {
|
|||
}
|
||||
|
||||
#config-docs-group Static {
|
||||
color: $text-muted;
|
||||
color: ansi_bright_black;
|
||||
}
|
||||
|
||||
#config-docs-group .link-row {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.setup.onboarding.screens.api_key import ApiKeyScreen
|
||||
from vibe.setup.onboarding.screens.theme_selection import ThemeSelectionScreen
|
||||
from vibe.setup.onboarding.screens.welcome import WelcomeScreen
|
||||
|
||||
__all__ = ["ApiKeyScreen", "ThemeSelectionScreen", "WelcomeScreen"]
|
||||
__all__ = ["ApiKeyScreen", "WelcomeScreen"]
|
||||
|
|
|
|||
|
|
@ -1,164 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Center, Container, Horizontal, Vertical
|
||||
from textual.events import Resize
|
||||
from textual.theme import BUILTIN_THEMES
|
||||
from textual.widgets import Markdown, Static
|
||||
|
||||
from vibe.cli.textual_ui.terminal_theme import TERMINAL_THEME_NAME
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.setup.onboarding.base import OnboardingScreen
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.setup.onboarding import OnboardingApp
|
||||
|
||||
THEMES = [TERMINAL_THEME_NAME] + sorted(
|
||||
k for k in BUILTIN_THEMES if k != "textual-ansi"
|
||||
)
|
||||
|
||||
VISIBLE_NEIGHBORS = 3
|
||||
FADE_CLASSES = ["fade-1", "fade-2", "fade-3"]
|
||||
|
||||
PREVIEW_MARKDOWN = """
|
||||
### Heading
|
||||
|
||||
**Bold**, *italic*, and `inline code`.
|
||||
|
||||
- Bullet point
|
||||
- Another bullet point
|
||||
|
||||
1. First item
|
||||
2. Second item
|
||||
|
||||
```python
|
||||
def greet(name: str = "World") -> str:
|
||||
return f"Hello, {name}!"
|
||||
```
|
||||
|
||||
> Blockquote
|
||||
|
||||
---
|
||||
|
||||
| Column 1 | Column 2 |
|
||||
|----------|----------|
|
||||
| Item 1 | Item 2 |
|
||||
"""
|
||||
|
||||
|
||||
class ThemeSelectionScreen(OnboardingScreen):
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("enter", "next", "Next", show=False, priority=True),
|
||||
Binding("up", "prev_theme", "Previous", show=False),
|
||||
Binding("down", "next_theme", "Next Theme", show=False),
|
||||
Binding("ctrl+c", "cancel", "Cancel", show=False),
|
||||
Binding("escape", "cancel", "Cancel", show=False),
|
||||
]
|
||||
|
||||
NEXT_SCREEN = "api_key"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._theme_index = 0
|
||||
self._theme_widgets: list[Static] = []
|
||||
|
||||
def _compose_theme_list(self) -> ComposeResult:
|
||||
for _ in range(VISIBLE_NEIGHBORS * 2 + 1):
|
||||
widget = NoMarkupStatic("", classes="theme-item")
|
||||
self._theme_widgets.append(widget)
|
||||
yield widget
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Center(id="theme-outer"):
|
||||
with Vertical(id="theme-content"):
|
||||
yield NoMarkupStatic("Select your preferred theme", id="theme-title")
|
||||
yield Center(
|
||||
Horizontal(
|
||||
NoMarkupStatic("Navigate ↑ ↓", id="nav-hint"),
|
||||
Vertical(*self._compose_theme_list(), id="theme-list"),
|
||||
NoMarkupStatic("Press Enter \u21b5", id="enter-hint"),
|
||||
id="theme-row",
|
||||
)
|
||||
)
|
||||
with Container(id="preview-center"):
|
||||
preview = Container(id="preview")
|
||||
preview.border_title = "Preview"
|
||||
with preview:
|
||||
yield Container(Markdown(PREVIEW_MARKDOWN), id="preview-inner")
|
||||
|
||||
@property
|
||||
def _has_terminal_theme(self) -> bool:
|
||||
app: OnboardingApp = self.app # type: ignore[assignment]
|
||||
return app._terminal_theme is not None
|
||||
|
||||
@property
|
||||
def _available_themes(self) -> list[str]:
|
||||
if self._has_terminal_theme:
|
||||
return THEMES
|
||||
return [t for t in THEMES if t != TERMINAL_THEME_NAME]
|
||||
|
||||
def on_mount(self) -> None:
|
||||
current_theme = self.app.theme
|
||||
themes = self._available_themes
|
||||
if current_theme == TERMINAL_THEME_NAME:
|
||||
self._theme_index = 0
|
||||
elif current_theme in themes:
|
||||
self._theme_index = themes.index(current_theme)
|
||||
self._update_display()
|
||||
self._update_preview_height()
|
||||
self.focus()
|
||||
|
||||
def on_resize(self, _: Resize) -> None:
|
||||
self._update_preview_height()
|
||||
|
||||
def _update_preview_height(self) -> None:
|
||||
# Height is dynamically set because css won't allow filling available space and page scroll on overflow.
|
||||
preview = self.query_one("#preview", Container)
|
||||
header_height = 17 # title + margins + theme row + padding + buffer
|
||||
available = self.app.size.height - header_height
|
||||
preview.styles.max_height = max(10, available)
|
||||
|
||||
def _get_theme_at_offset(self, offset: int) -> str:
|
||||
themes = self._available_themes
|
||||
index = (self._theme_index + offset) % len(themes)
|
||||
return themes[index]
|
||||
|
||||
def _update_display(self) -> None:
|
||||
for i, widget in enumerate(self._theme_widgets):
|
||||
offset = i - VISIBLE_NEIGHBORS
|
||||
theme = self._get_theme_at_offset(offset)
|
||||
|
||||
widget.remove_class("selected", *FADE_CLASSES)
|
||||
|
||||
if offset == 0:
|
||||
widget.update(f" {theme} ")
|
||||
widget.add_class("selected")
|
||||
else:
|
||||
distance = min(abs(offset) - 1, len(FADE_CLASSES) - 1)
|
||||
widget.update(theme)
|
||||
widget.add_class(FADE_CLASSES[distance])
|
||||
|
||||
def _navigate(self, direction: int) -> None:
|
||||
themes = self._available_themes
|
||||
self._theme_index = (self._theme_index + direction) % len(themes)
|
||||
theme = themes[self._theme_index]
|
||||
self.app.theme = theme
|
||||
self._update_display()
|
||||
|
||||
def action_next_theme(self) -> None:
|
||||
self._navigate(1)
|
||||
|
||||
def action_prev_theme(self) -> None:
|
||||
self._navigate(-1)
|
||||
|
||||
def action_next(self) -> None:
|
||||
theme = self._available_themes[self._theme_index]
|
||||
try:
|
||||
VibeConfig.save_updates({"textual_theme": theme})
|
||||
except OSError:
|
||||
pass
|
||||
super().action_next()
|
||||
|
|
@ -50,7 +50,7 @@ class WelcomeScreen(OnboardingScreen):
|
|||
Binding("escape", "cancel", "Cancel", show=False),
|
||||
]
|
||||
|
||||
NEXT_SCREEN = "theme_selection"
|
||||
NEXT_SCREEN = "api_key"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from textual import events
|
||||
|
|
@ -11,12 +10,8 @@ from textual.containers import CenterMiddle, Horizontal
|
|||
from textual.message import Message
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.terminal_theme import (
|
||||
TERMINAL_THEME_NAME,
|
||||
capture_terminal_theme,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.paths.global_paths import GLOBAL_CONFIG_FILE, TRUSTED_FOLDERS_FILE
|
||||
from vibe.core.paths.global_paths import TRUSTED_FOLDERS_FILE
|
||||
|
||||
|
||||
class TrustDialogQuitException(Exception):
|
||||
|
|
@ -154,32 +149,9 @@ class TrustFolderApp(App):
|
|||
self.folder_path = folder_path
|
||||
self._result: bool | None = None
|
||||
self._quit_without_saving = False
|
||||
self._terminal_theme = capture_terminal_theme()
|
||||
self._load_theme()
|
||||
|
||||
def _load_theme(self) -> None:
|
||||
if self._terminal_theme:
|
||||
self.register_theme(self._terminal_theme)
|
||||
|
||||
config_file = GLOBAL_CONFIG_FILE.path
|
||||
if not config_file.is_file():
|
||||
return
|
||||
|
||||
try:
|
||||
with config_file.open("rb") as f:
|
||||
config_data = tomllib.load(f)
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
return
|
||||
|
||||
textual_theme = config_data.get("textual_theme")
|
||||
if not textual_theme:
|
||||
return
|
||||
|
||||
if textual_theme == TERMINAL_THEME_NAME:
|
||||
if self._terminal_theme:
|
||||
self.theme = TERMINAL_THEME_NAME
|
||||
else:
|
||||
self.theme = textual_theme
|
||||
def on_mount(self) -> None:
|
||||
self.theme = "textual-ansi"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield TrustFolderDialog(self.folder_path)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
Screen {
|
||||
align: center middle;
|
||||
background: $background 80%;
|
||||
background: transparent 80%;
|
||||
}
|
||||
|
||||
#trust-dialog {
|
||||
|
|
@ -8,8 +8,8 @@ Screen {
|
|||
max-width: 90%;
|
||||
min-width: 50;
|
||||
height: auto;
|
||||
border: round $foreground-muted;
|
||||
background: $background;
|
||||
border: round ansi_bright_black;
|
||||
background: transparent;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
|
|
@ -17,7 +17,7 @@ Screen {
|
|||
width: 100%;
|
||||
height: auto;
|
||||
text-style: bold;
|
||||
color: $warning;
|
||||
color: ansi_yellow;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ Screen {
|
|||
#trust-dialog-path {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: $primary;
|
||||
color: ansi_blue;
|
||||
text-align: center;
|
||||
text-wrap: wrap;
|
||||
margin-bottom: 1;
|
||||
|
|
@ -34,7 +34,7 @@ Screen {
|
|||
#trust-dialog-message {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: $foreground;
|
||||
color: ansi_default;
|
||||
text-align: center;
|
||||
text-wrap: wrap;
|
||||
margin-bottom: 1;
|
||||
|
|
@ -50,24 +50,24 @@ Screen {
|
|||
.trust-option {
|
||||
height: auto;
|
||||
width: auto;
|
||||
color: $foreground;
|
||||
color: ansi_default;
|
||||
margin: 0 3;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
.trust-cursor-selected {
|
||||
color: $foreground;
|
||||
color: ansi_default;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.trust-option-selected {
|
||||
color: $foreground;
|
||||
color: ansi_default;
|
||||
}
|
||||
|
||||
.trust-dialog-help {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: $text-muted;
|
||||
color: ansi_bright_black;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@ Screen {
|
|||
.trust-dialog-save-info {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: $text-muted;
|
||||
color: ansi_bright_black;
|
||||
text-align: center;
|
||||
text-style: italic;
|
||||
text-wrap: wrap;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
# What's New
|
||||
# What's New in 2.1.0
|
||||
|
||||
- **Config:** Variables defined in the `.env` file in your global `.vibe` folder now override environment variables
|
||||
- **Sessions:** Fixed message duplication in persisted sessions
|
||||
- **Resume:** Only shown when a session is available to resume
|
||||
- **UI redesign** — Refreshed interface with a cleaner layout and better performance when streaming.
|
||||
|
||||
- **Themes removed** — Application themes have been removed; the UI now follows your terminal’s theme (colors and appearance).
|
||||
|
||||
- **Clipboard** — You can now disable autocopying in /config, and copy with Ctrl+Shift+C or Cmd+C (in terminals that support it).
|
||||
|
||||
- **Performance** — Various optimizations throughout the app for a more responsive UI.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue