v2.10.0 (#697)
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Corentin André <corentin.andre@mistral.ai> Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com> Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com> Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai> Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@users.noreply.github.com> Co-authored-by: Peter Evers <pevers90@gmail.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: MichisGitIsKing <MichisGitIsKing@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
626f905186
commit
228f3c65a9
158 changed files with 7235 additions and 916 deletions
|
|
@ -23,6 +23,7 @@ from vibe.core.hooks.config import load_hooks_from_fs
|
|||
from vibe.core.logger import logger
|
||||
from vibe.core.paths import HISTORY_FILE
|
||||
from vibe.core.programmatic import run_programmatic
|
||||
from vibe.core.session import last_session_pointer
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.telemetry.build_metadata import build_entrypoint_metadata
|
||||
from vibe.core.telemetry.types import EntrypointMetadata
|
||||
|
|
@ -140,9 +141,15 @@ def load_session(
|
|||
session_to_load = None
|
||||
if args.continue_session:
|
||||
cwd = Path.cwd().resolve()
|
||||
session_to_load = SessionLoader.find_latest_session(
|
||||
config.session_logging, working_directory=cwd
|
||||
)
|
||||
pointer_session_id = last_session_pointer.load(config.session_logging)
|
||||
if pointer_session_id:
|
||||
session_to_load = SessionLoader.find_session_by_id(
|
||||
pointer_session_id, config.session_logging, working_directory=cwd
|
||||
)
|
||||
if not session_to_load:
|
||||
session_to_load = SessionLoader.find_latest_session(
|
||||
config.session_logging, working_directory=cwd
|
||||
)
|
||||
if not session_to_load:
|
||||
rprint(
|
||||
f"[red]No previous sessions found in "
|
||||
|
|
|
|||
|
|
@ -96,6 +96,15 @@ def parse_arguments() -> argparse.Namespace:
|
|||
metavar="DIR",
|
||||
help="Change to this directory before running",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--add-dir",
|
||||
action="append",
|
||||
metavar="DIR",
|
||||
default=[],
|
||||
help="Additional working directory for file access and context. "
|
||||
"Implicitly trusted for the session (same semantics as --trust). "
|
||||
"Can be specified multiple times.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--trust",
|
||||
action="store_true",
|
||||
|
|
@ -180,10 +189,22 @@ def main() -> None:
|
|||
if args.trust:
|
||||
trusted_folders_manager.trust_for_session(cwd)
|
||||
|
||||
additional_dirs: list[Path] = []
|
||||
for d in args.add_dir:
|
||||
resolved = Path(d).expanduser().resolve()
|
||||
if not resolved.is_dir():
|
||||
rprint(
|
||||
f"[red]Error: --add-dir path does not exist "
|
||||
f"or is not a directory: {d}[/]"
|
||||
)
|
||||
sys.exit(1)
|
||||
additional_dirs.append(resolved)
|
||||
trusted_folders_manager.trust_for_session(resolved)
|
||||
|
||||
is_interactive = args.prompt is None
|
||||
if is_interactive:
|
||||
check_and_resolve_trusted_folder(cwd)
|
||||
init_harness_files_manager("user", "project")
|
||||
init_harness_files_manager("user", "project", additional_dirs=additional_dirs)
|
||||
|
||||
from vibe.cli.cli import run_cli
|
||||
|
||||
|
|
|
|||
|
|
@ -12,12 +12,11 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
|
|||
)
|
||||
from vibe.core.utils.http import build_ssl_context
|
||||
|
||||
BASE_URL = "https://console.mistral.ai"
|
||||
WHOAMI_PATH = "/api/vibe/whoami"
|
||||
|
||||
|
||||
class HttpWhoAmIGateway:
|
||||
def __init__(self, base_url: str = BASE_URL) -> None:
|
||||
def __init__(self, base_url: str) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
|
||||
async def whoami(self, api_key: str) -> WhoAmIResponse:
|
||||
|
|
|
|||
|
|
@ -11,15 +11,15 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
|
|||
WhoAmIPlanType,
|
||||
WhoAmIResponse,
|
||||
)
|
||||
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, ProviderConfig
|
||||
from vibe.core.config import (
|
||||
DEFAULT_CONSOLE_BASE_URL,
|
||||
DEFAULT_MISTRAL_API_ENV_KEY,
|
||||
ProviderConfig,
|
||||
)
|
||||
from vibe.core.types import Backend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONSOLE_CLI_URL = "https://console.mistral.ai/codestral/cli"
|
||||
UPGRADE_URL = CONSOLE_CLI_URL
|
||||
SWITCH_TO_PRO_KEY_URL = CONSOLE_CLI_URL
|
||||
|
||||
|
||||
class MistralCodePlanName(StrEnum):
|
||||
FREE = "F"
|
||||
|
|
@ -96,16 +96,21 @@ def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None:
|
|||
return getenv(api_env_key)
|
||||
|
||||
|
||||
def plan_offer_cta(payload: PlanInfo | None) -> str | None:
|
||||
def plan_offer_cta(
|
||||
payload: PlanInfo | None, console_base_url: str = DEFAULT_CONSOLE_BASE_URL
|
||||
) -> str | None:
|
||||
if not payload:
|
||||
return
|
||||
console_cli_url = f"{console_base_url.rstrip('/')}/codestral/cli"
|
||||
switch_to_pro_key_url = console_cli_url
|
||||
upgrade_url = console_cli_url
|
||||
if payload.prompt_switching_to_pro_plan:
|
||||
return f"### Switch to your [Le Chat Pro API key]({SWITCH_TO_PRO_KEY_URL})"
|
||||
return f"### Switch to your [Le Chat Pro API key]({switch_to_pro_key_url})"
|
||||
if (
|
||||
payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}
|
||||
or payload.is_free_mistral_code_plan()
|
||||
):
|
||||
return f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({UPGRADE_URL})"
|
||||
return f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({upgrade_url})"
|
||||
|
||||
|
||||
def plan_title(payload: PlanInfo | None) -> str | None: # noqa: PLR0911
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ from vibe.core.tools.builtins.ask_user_question import (
|
|||
Choice,
|
||||
Question,
|
||||
)
|
||||
from vibe.core.tools.connectors import ConnectorRegistry, connectors_enabled
|
||||
from vibe.core.tools.connectors import ConnectorRegistry
|
||||
from vibe.core.tools.mcp_settings import persist_mcp_toggle
|
||||
from vibe.core.tools.permissions import RequiredPermission
|
||||
from vibe.core.transcribe import make_transcribe_client
|
||||
|
|
@ -257,6 +257,19 @@ PRUNE_LOW_MARK = 1000
|
|||
PRUNE_HIGH_MARK = 1500
|
||||
DOUBLE_ESC_DELAY = 0.2
|
||||
|
||||
_DEFAULT_TYPING_DEBOUNCE_MS = 1000
|
||||
_TYPING_DEBOUNCE_ENV_VAR = "VIBE_TYPING_GRACE_PERIOD_MS"
|
||||
|
||||
|
||||
def _resolve_typing_debounce_s() -> float:
|
||||
try:
|
||||
ms = int(os.environ[_TYPING_DEBOUNCE_ENV_VAR])
|
||||
if ms < 0:
|
||||
raise ValueError
|
||||
except (KeyError, ValueError):
|
||||
ms = _DEFAULT_TYPING_DEBOUNCE_MS
|
||||
return ms / 1000
|
||||
|
||||
|
||||
async def prune_oldest_children(
|
||||
messages_area: Widget, low_mark: int, high_mark: int
|
||||
|
|
@ -320,6 +333,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
Binding(
|
||||
"shift+down", "scroll_chat_down", "Scroll Down", show=False, priority=True
|
||||
),
|
||||
Binding(
|
||||
"ctrl+g", "open_plan_in_editor", "Edit Plan", show=False, priority=False
|
||||
),
|
||||
Binding("ctrl+backslash", "toggle_debug_console", "Debug Console", show=False),
|
||||
Binding("alt+up", "rewind_prev", "Rewind Previous", show=False, priority=True),
|
||||
Binding("ctrl+p", "rewind_prev", "Rewind Previous", show=False, priority=True),
|
||||
|
|
@ -425,7 +441,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
@property
|
||||
def _connectors_enabled(self) -> bool:
|
||||
return connectors_enabled() and self.agent_loop.connector_registry is not None
|
||||
return self.agent_loop.connector_registry is not None
|
||||
|
||||
def _get_command_availability_context(self) -> CommandAvailabilityContext:
|
||||
return CommandAvailabilityContext(
|
||||
|
|
@ -519,8 +535,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._loop_runner.start()
|
||||
await self._check_and_show_whats_new()
|
||||
self._schedule_update_notification()
|
||||
if not self._is_resuming_session:
|
||||
self.agent_loop.emit_new_session_telemetry()
|
||||
if self._is_resuming_session:
|
||||
await self.agent_loop.hydrate_experiments_from_session()
|
||||
else:
|
||||
self.agent_loop.start_initialize_experiments()
|
||||
|
||||
self.call_after_refresh(self._refresh_banner)
|
||||
self._show_hook_config_issues_once()
|
||||
|
|
@ -1250,6 +1268,29 @@ class VibeApp(App): # noqa: PLR0904
|
|||
def _is_tool_enabled_in_main_agent(self, tool: str) -> bool:
|
||||
return tool in self.agent_loop.tool_manager.available_tools
|
||||
|
||||
async def _wait_for_typing_pause(self) -> None:
|
||||
try:
|
||||
text_area = self.query_one(ChatTextArea)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
debounce_s = _resolve_typing_debounce_s()
|
||||
if text_area.time_since_last_keystroke() >= debounce_s:
|
||||
return
|
||||
|
||||
if self._loading_widget:
|
||||
self._loading_widget.show_debounce_hint()
|
||||
|
||||
try:
|
||||
while True:
|
||||
elapsed = text_area.time_since_last_keystroke()
|
||||
if elapsed >= debounce_s:
|
||||
return
|
||||
await asyncio.sleep(debounce_s - elapsed)
|
||||
finally:
|
||||
if self._loading_widget:
|
||||
self._loading_widget.hide_debounce_hint()
|
||||
|
||||
async def _approval_callback(
|
||||
self,
|
||||
tool: str,
|
||||
|
|
@ -1264,6 +1305,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return (ApprovalResponse.YES, None)
|
||||
|
||||
async with self._user_interaction_lock:
|
||||
await self._wait_for_typing_pause()
|
||||
self._pending_approval = asyncio.Future()
|
||||
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
|
||||
try:
|
||||
|
|
@ -1279,6 +1321,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
question_args = cast(AskUserQuestionArgs, args)
|
||||
|
||||
async with self._user_interaction_lock:
|
||||
await self._wait_for_typing_pause()
|
||||
self._pending_question = asyncio.Future()
|
||||
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
|
||||
try:
|
||||
|
|
@ -1620,12 +1663,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
connector_registry is not None and connector_registry.connector_count > 0
|
||||
)
|
||||
if not mcp_servers and not has_connectors:
|
||||
msg = (
|
||||
"No MCP servers or connectors configured."
|
||||
if self._connectors_enabled
|
||||
else "No MCP servers configured."
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("No MCP servers or connectors configured.")
|
||||
)
|
||||
await self._mount_and_scroll(UserCommandMessage(msg))
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.MCP:
|
||||
|
|
@ -1854,9 +1894,6 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if self._chat_input_container:
|
||||
self._chat_input_container.set_custom_border(None)
|
||||
|
||||
current_system_messages = [
|
||||
msg for msg in self.agent_loop.messages if msg.role == Role.system
|
||||
]
|
||||
non_system_messages = [
|
||||
msg for msg in loaded_messages if msg.role != Role.system
|
||||
]
|
||||
|
|
@ -1866,6 +1903,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.session_logger.resume_existing_session(
|
||||
session.session_id, session_path
|
||||
)
|
||||
await self.agent_loop.hydrate_experiments_from_session()
|
||||
current_system_messages = [
|
||||
msg for msg in self.agent_loop.messages if msg.role == Role.system
|
||||
]
|
||||
self.agent_loop.messages.reset(current_system_messages + non_system_messages)
|
||||
self._refresh_profile_widgets()
|
||||
|
||||
|
|
@ -2321,19 +2362,25 @@ class VibeApp(App): # noqa: PLR0904
|
|||
def _handle_approval_app_escape(self) -> None:
|
||||
try:
|
||||
approval_app = self.query_one(ApprovalApp)
|
||||
approval_app.action_reject()
|
||||
if not approval_app.is_within_grace_period():
|
||||
approval_app.action_reject()
|
||||
self.agent_loop.telemetry_client.send_user_cancelled_action(
|
||||
"reject_approval"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self.agent_loop.telemetry_client.send_user_cancelled_action("reject_approval")
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_question_app_escape(self) -> None:
|
||||
try:
|
||||
question_app = self.query_one(QuestionApp)
|
||||
question_app.action_cancel()
|
||||
if not question_app.is_within_grace_period():
|
||||
question_app.action_cancel()
|
||||
self.agent_loop.telemetry_client.send_user_cancelled_action(
|
||||
"cancel_question"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question")
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_model_picker_app_escape(self) -> None:
|
||||
|
|
@ -2869,7 +2916,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
content = load_whats_new_content()
|
||||
if content is not None:
|
||||
whats_new_message = WhatsNewMessage(content)
|
||||
plan_offer = plan_offer_cta(self._plan_info)
|
||||
plan_offer = plan_offer_cta(
|
||||
self._plan_info, console_base_url=self.config.console_base_url
|
||||
)
|
||||
if plan_offer is not None:
|
||||
whats_new_message = WhatsNewMessage(f"{content}\n\n{plan_offer}")
|
||||
if self._history_widget_indices:
|
||||
|
|
@ -3024,6 +3073,13 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if self._chat_input_container and self._chat_input_container.input_widget:
|
||||
self._chat_input_container.input_widget.set_app_focus(True)
|
||||
|
||||
def action_open_plan_in_editor(self) -> None:
|
||||
if self.event_handler is None:
|
||||
return
|
||||
|
||||
if plan_file_message := self.event_handler.plan_file_message:
|
||||
plan_file_message.open_in_editor()
|
||||
|
||||
def action_suspend_with_message(self) -> None:
|
||||
if WINDOWS or self._driver is None or not self._driver.can_suspend:
|
||||
return
|
||||
|
|
@ -3053,7 +3109,7 @@ def run_textual_ui(
|
|||
|
||||
update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
|
||||
update_cache_repository = FileSystemUpdateCacheRepository()
|
||||
plan_offer_gateway = HttpWhoAmIGateway()
|
||||
plan_offer_gateway = HttpWhoAmIGateway(base_url=agent_loop.config.console_base_url)
|
||||
|
||||
with stderr_guard():
|
||||
app = VibeApp(
|
||||
|
|
@ -3065,4 +3121,6 @@ def run_textual_ui(
|
|||
)
|
||||
session_id = app.run()
|
||||
|
||||
print_session_resume_message(session_id, agent_loop.stats)
|
||||
print_session_resume_message(
|
||||
session_id, agent_loop.stats, agent_loop.config.session_logging
|
||||
)
|
||||
|
|
|
|||
|
|
@ -248,6 +248,7 @@ Markdown {
|
|||
margin-top: 1;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding-left: 2;
|
||||
|
||||
Markdown {
|
||||
width: 100%;
|
||||
|
|
@ -325,6 +326,35 @@ Markdown {
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.plan-file-message {
|
||||
margin-top: 1;
|
||||
margin-bottom: 0;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
|
||||
.plan-file-wrapper {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
border: solid ansi_bright_black;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
Markdown {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
& > MarkdownBlock:first-child {
|
||||
margin: 0 0 1 0;
|
||||
}
|
||||
|
||||
& > MarkdownBlock:last-child {
|
||||
margin: 1 0 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.interrupt-container,
|
||||
.error-container,
|
||||
.warning-container,
|
||||
|
|
@ -669,7 +699,12 @@ StatusMessage {
|
|||
width: auto;
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
}
|
||||
|
||||
.loading-debounce {
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin-left: 1;
|
||||
}
|
||||
|
||||
.history-load-more-message {
|
||||
|
|
@ -877,10 +912,6 @@ StatusMessage {
|
|||
height: auto;
|
||||
}
|
||||
|
||||
#question-content.question-content-docked {
|
||||
dock: bottom;
|
||||
}
|
||||
|
||||
.question-tabs {
|
||||
height: auto;
|
||||
color: ansi_blue;
|
||||
|
|
@ -935,34 +966,19 @@ StatusMessage {
|
|||
margin-top: 1;
|
||||
}
|
||||
|
||||
.question-footer-note {
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
text-style: italic;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
.question-help {
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
.question-content-preview {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 50vh;
|
||||
border: none;
|
||||
border-left: wide ansi_bright_black;
|
||||
padding: 0 0 0 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.question-content-preview-text {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
& > MarkdownBlock:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
ExpandingBorder {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
|
|
|
|||
|
|
@ -16,15 +16,19 @@ class ExternalEditor:
|
|||
def get_editor() -> str:
|
||||
return os.environ.get("VISUAL") or os.environ.get("EDITOR") or "nano"
|
||||
|
||||
@classmethod
|
||||
def edit_file(cls, file_path: Path, *, check: bool = False) -> None:
|
||||
editor = cls.get_editor()
|
||||
parts = shlex.split(editor)
|
||||
subprocess.run([*parts, str(file_path)], check=check)
|
||||
|
||||
def edit(self, initial_content: str = "") -> str | None:
|
||||
editor = self.get_editor()
|
||||
fd, filepath = tempfile.mkstemp(suffix=".md", prefix="vibe_")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(initial_content)
|
||||
|
||||
parts = shlex.split(editor)
|
||||
subprocess.run([*parts, filepath], check=True)
|
||||
self.edit_file(Path(filepath), check=True)
|
||||
|
||||
content = read_safe(Path(filepath)).text.rstrip()
|
||||
return content if content != initial_content else None
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vibe.cli.textual_ui.widgets.compact import CompactMessage
|
||||
|
|
@ -9,6 +10,7 @@ from vibe.cli.textual_ui.widgets.messages import (
|
|||
AssistantMessage,
|
||||
HookRunContainer,
|
||||
HookSystemMessageLine,
|
||||
PlanFileMessage,
|
||||
ReasoningMessage,
|
||||
UserMessage,
|
||||
)
|
||||
|
|
@ -28,6 +30,8 @@ from vibe.core.types import (
|
|||
BaseEvent,
|
||||
CompactEndEvent,
|
||||
CompactStartEvent,
|
||||
PlanReviewEndedEvent,
|
||||
PlanReviewRequestedEvent,
|
||||
ReasoningEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
|
|
@ -57,6 +61,7 @@ class EventHandler:
|
|||
self.current_compact: CompactMessage | None = None
|
||||
self.current_streaming_message: AssistantMessage | None = None
|
||||
self.current_streaming_reasoning: ReasoningMessage | None = None
|
||||
self.plan_file_message: PlanFileMessage | None = None
|
||||
self._hook_run_container: HookRunContainer | None = None
|
||||
|
||||
async def _handle_hook_event(
|
||||
|
|
@ -85,7 +90,7 @@ class EventHandler:
|
|||
if loading_widget:
|
||||
loading_widget.set_status(DEFAULT_LOADING_STATUS)
|
||||
|
||||
async def handle_event(
|
||||
async def handle_event( # noqa: PLR0912
|
||||
self, event: BaseEvent, loading_widget: LoadingWidget | None = None
|
||||
) -> ToolCallMessage | None:
|
||||
match event:
|
||||
|
|
@ -117,6 +122,10 @@ class EventHandler:
|
|||
await self.mount_callback(UserMessage(event.content))
|
||||
case HookEvent():
|
||||
await self._handle_hook_event(event, loading_widget)
|
||||
case PlanReviewRequestedEvent():
|
||||
await self._handle_start_plan_review(file_path=event.file_path)
|
||||
case PlanReviewEndedEvent():
|
||||
self._handle_stop_plan_review()
|
||||
case WaitingForInputEvent():
|
||||
await self.finalize_streaming()
|
||||
case _:
|
||||
|
|
@ -245,3 +254,16 @@ class EventHandler:
|
|||
if self.current_compact:
|
||||
self.current_compact.stop_spinning(success=False)
|
||||
self.current_compact = None
|
||||
|
||||
async def _handle_start_plan_review(self, file_path: Path) -> None:
|
||||
file_path.touch()
|
||||
msg = PlanFileMessage(file_path=file_path)
|
||||
self.plan_file_message = msg
|
||||
await self.mount_callback(msg)
|
||||
|
||||
def _handle_stop_plan_review(self) -> None:
|
||||
if self.plan_file_message is None:
|
||||
return
|
||||
|
||||
self.plan_file_message.stop_watching()
|
||||
self.plan_file_message = None
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ from __future__ import annotations
|
|||
|
||||
from rich import print as rprint
|
||||
|
||||
from vibe.core.config import SessionLoggingConfig
|
||||
from vibe.core.session import last_session_pointer
|
||||
from vibe.core.types import AgentStats
|
||||
|
||||
|
||||
|
|
@ -14,10 +16,14 @@ def format_session_usage(stats: AgentStats) -> str:
|
|||
)
|
||||
|
||||
|
||||
def print_session_resume_message(session_id: str | None, stats: AgentStats) -> None:
|
||||
def print_session_resume_message(
|
||||
session_id: str | None, stats: AgentStats, session_logging: SessionLoggingConfig
|
||||
) -> None:
|
||||
if not session_id:
|
||||
return
|
||||
|
||||
last_session_pointer.record(session_logging, session_id)
|
||||
|
||||
print()
|
||||
print(format_session_usage(stats))
|
||||
print()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import ClassVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
|
@ -15,6 +16,8 @@ from vibe.cli.textual_ui.widgets.tool_widgets import get_approval_widget
|
|||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.tools.permissions import RequiredPermission
|
||||
|
||||
_INPUT_GRACE_PERIOD_S = 0.5
|
||||
|
||||
|
||||
class ApprovalApp(Container):
|
||||
can_focus = True
|
||||
|
|
@ -88,6 +91,7 @@ class ApprovalApp(Container):
|
|||
self.tool_info_container: Vertical | None = None
|
||||
self.option_widgets: list[Static] = []
|
||||
self.help_widget: Static | None = None
|
||||
self._mount_time: float = 0.0
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="approval-options"):
|
||||
|
|
@ -119,10 +123,14 @@ class ApprovalApp(Container):
|
|||
return f"Permission for the {self.tool_name} tool"
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
self._mount_time = time.monotonic()
|
||||
await self._update_tool_info()
|
||||
self._update_options()
|
||||
self.focus()
|
||||
|
||||
def is_within_grace_period(self) -> bool:
|
||||
return (time.monotonic() - self._mount_time) < _INPUT_GRACE_PERIOD_S
|
||||
|
||||
async def _update_tool_info(self) -> None:
|
||||
if not self.tool_info_container:
|
||||
return
|
||||
|
|
@ -175,28 +183,29 @@ class ApprovalApp(Container):
|
|||
self.selected_option = (self.selected_option + 1) % self.NUM_OPTIONS
|
||||
self._update_options()
|
||||
|
||||
def _select_if_unguarded(self, option: int) -> None:
|
||||
if self.is_within_grace_period():
|
||||
return
|
||||
self.selected_option = option
|
||||
self._handle_selection(option)
|
||||
|
||||
def action_select(self) -> None:
|
||||
self._handle_selection(self.selected_option)
|
||||
self._select_if_unguarded(self.selected_option)
|
||||
|
||||
def action_select_1(self) -> None:
|
||||
self.selected_option = 0
|
||||
self._handle_selection(0)
|
||||
self._select_if_unguarded(0)
|
||||
|
||||
def action_select_2(self) -> None:
|
||||
self.selected_option = 1
|
||||
self._handle_selection(1)
|
||||
self._select_if_unguarded(1)
|
||||
|
||||
def action_select_3(self) -> None:
|
||||
self.selected_option = 2
|
||||
self._handle_selection(2)
|
||||
self._select_if_unguarded(2)
|
||||
|
||||
def action_select_4(self) -> None:
|
||||
self.selected_option = 3
|
||||
self._handle_selection(3)
|
||||
self._select_if_unguarded(3)
|
||||
|
||||
def action_reject(self) -> None:
|
||||
self.selected_option = 3
|
||||
self._handle_selection(3)
|
||||
self._select_if_unguarded(3)
|
||||
|
||||
def _handle_selection(self, option: int) -> None:
|
||||
match option:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, ClassVar, Literal
|
||||
|
||||
from textual import events
|
||||
|
|
@ -75,6 +76,7 @@ class ChatTextArea(TextArea):
|
|||
self._completion_manager: MultiCompletionManager | None = None
|
||||
self._app_has_focus: bool = True
|
||||
self._voice_manager = voice_manager
|
||||
self._last_keystroke_time: float = 0.0
|
||||
|
||||
def on_blur(self, event: events.Blur) -> None:
|
||||
if self._app_has_focus:
|
||||
|
|
@ -137,8 +139,15 @@ class ChatTextArea(TextArea):
|
|||
)
|
||||
|
||||
if should_intercept:
|
||||
self._navigating_history = True
|
||||
self.post_message(self.HistoryPrevious())
|
||||
if (
|
||||
self.text
|
||||
and self.cursor_location != (0, 0)
|
||||
and not history_loaded_and_cursor_unmoved
|
||||
):
|
||||
self.move_cursor((0, 0))
|
||||
else:
|
||||
self._navigating_history = True
|
||||
self.post_message(self.HistoryPrevious())
|
||||
return True
|
||||
return False
|
||||
|
||||
|
|
@ -198,7 +207,12 @@ class ChatTextArea(TextArea):
|
|||
|
||||
return False
|
||||
|
||||
def time_since_last_keystroke(self) -> float:
|
||||
return time.monotonic() - self._last_keystroke_time
|
||||
|
||||
async def _on_key(self, event: events.Key) -> None: # noqa: PLR0911
|
||||
self._last_keystroke_time = time.monotonic()
|
||||
|
||||
if await self._handle_voice_key(event):
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
|||
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
|
||||
|
||||
DEFAULT_LOADING_STATUS = "Generating"
|
||||
_DEBOUNCE_HINT_TEXT = "[dim italic]typing detected, waiting…[/]"
|
||||
|
||||
|
||||
def _format_elapsed(seconds: int) -> str:
|
||||
|
|
@ -84,6 +85,7 @@ class LoadingWidget(SpinnerMixin, Static):
|
|||
self._status_widget: Static | None = None
|
||||
self.hint_widget: Static | None = None
|
||||
self._show_hint = show_hint
|
||||
self.debounce_widget: Static | None = None
|
||||
self.start_time: float | None = None
|
||||
self._last_elapsed: int = -1
|
||||
self._paused_total: float = 0.0
|
||||
|
|
@ -112,6 +114,15 @@ class LoadingWidget(SpinnerMixin, Static):
|
|||
def _apply_easter_egg(self, status: str) -> str:
|
||||
return self._get_easter_egg() or status
|
||||
|
||||
def show_debounce_hint(self) -> None:
|
||||
if self.debounce_widget:
|
||||
self.debounce_widget.update(_DEBOUNCE_HINT_TEXT)
|
||||
self.debounce_widget.display = True
|
||||
|
||||
def hide_debounce_hint(self) -> None:
|
||||
if self.debounce_widget:
|
||||
self.debounce_widget.display = False
|
||||
|
||||
def pause_timer(self) -> None:
|
||||
if self._pause_start is None:
|
||||
self._pause_start = time()
|
||||
|
|
@ -142,6 +153,10 @@ class LoadingWidget(SpinnerMixin, Static):
|
|||
)
|
||||
yield self.hint_widget
|
||||
|
||||
self.debounce_widget = Static("", classes="loading-debounce")
|
||||
self.debounce_widget.display = False
|
||||
yield self.debounce_widget
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.start_time = time()
|
||||
self._update_animation()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from textual.worker import Worker
|
|||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.config import ConnectorConfig
|
||||
from vibe.core.tools.connectors import ConnectorRegistry, connectors_enabled
|
||||
from vibe.core.tools.connectors import ConnectorRegistry
|
||||
from vibe.core.tools.mcp.tools import MCPTool
|
||||
from vibe.core.tools.mcp_settings import updated_tool_list
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ def collect_mcp_tool_index(
|
|||
registered = tool_manager.registered_tools
|
||||
available = tool_manager.available_tools
|
||||
configured_servers = {server.name for server in mcp_servers}
|
||||
connector_set = set(connector_names) if connectors_enabled() else set()
|
||||
connector_set = set(connector_names)
|
||||
server_tools: dict[str, list[tuple[str, type[MCPTool]]]] = {}
|
||||
connector_tools: dict[str, list[tuple[str, type[MCPTool]]]] = {}
|
||||
|
||||
|
|
@ -410,7 +410,7 @@ class MCPApp(Container):
|
|||
def _show_list_view(self, option_list: OptionList, index: MCPToolIndex) -> None:
|
||||
self._viewing_server = None
|
||||
self._viewing_kind = None
|
||||
has_connectors = connectors_enabled() and bool(self._connector_names)
|
||||
has_connectors = bool(self._connector_names)
|
||||
title = "MCP Servers & Connectors" if has_connectors else "MCP Servers"
|
||||
self.query_one("#mcp-title", NoMarkupStatic).update(title)
|
||||
self._set_help_text(_LIST_VIEW_HELP_TOOLS)
|
||||
|
|
@ -424,12 +424,9 @@ class MCPApp(Container):
|
|||
option_list.add_option(Option(Text("", no_wrap=True), disabled=True))
|
||||
self._list_connectors(option_list=option_list, index=index)
|
||||
if not has_servers and not has_connectors:
|
||||
empty_msg = (
|
||||
"No MCP servers or connectors configured"
|
||||
if connectors_enabled()
|
||||
else "No MCP servers configured"
|
||||
option_list.add_option(
|
||||
Option("No MCP servers or connectors configured", disabled=True)
|
||||
)
|
||||
option_list.add_option(Option(empty_msg, disabled=True))
|
||||
|
||||
if has_servers or has_connectors:
|
||||
# Skip disabled header options (e.g. section labels).
|
||||
|
|
|
|||
|
|
@ -1,16 +1,25 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from vibe.core.hooks.models import HookMessageSeverity
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.utils.io import read_safe_async
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.cli.textual_ui.app import ChatScroll
|
||||
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.css.query import NoMatches
|
||||
from textual.reactive import reactive
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
from textual.widgets._markdown import MarkdownStream
|
||||
from watchfiles import awatch
|
||||
|
||||
from vibe.cli.textual_ui.ansi_markdown import AnsiMarkdown as Markdown
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
|
|
@ -443,3 +452,60 @@ class WarningMessage(Static):
|
|||
if self._show_border:
|
||||
yield ExpandingBorder(classes="warning-border")
|
||||
yield NoMarkupStatic(self._message, classes="warning-content")
|
||||
|
||||
|
||||
class PlanFileMessage(Widget):
|
||||
content: reactive[str] = reactive("")
|
||||
|
||||
def __init__(self, file_path: Path) -> None:
|
||||
super().__init__()
|
||||
self.add_class("plan-file-message")
|
||||
self._file_path = file_path
|
||||
self._watch_task: asyncio.Task | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(classes="plan-file-wrapper"):
|
||||
yield Markdown(self.content, classes="plan-file-content")
|
||||
|
||||
def watch_content(self, new_content: str) -> None:
|
||||
try:
|
||||
self.query_one(Markdown).update(new_content)
|
||||
except NoMatches:
|
||||
pass
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
self.content = (await read_safe_async(self._file_path)).text
|
||||
self._watch_task = asyncio.create_task(self._watch_file())
|
||||
|
||||
async def _watch_file(self) -> None:
|
||||
try:
|
||||
async for _ in awatch(self._file_path):
|
||||
self.content = (await read_safe_async(self._file_path)).text
|
||||
except (asyncio.CancelledError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
def open_in_editor(self) -> None:
|
||||
from vibe.cli.textual_ui.external_editor import ExternalEditor
|
||||
|
||||
try:
|
||||
self._file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.app.suspend():
|
||||
ExternalEditor.edit_file(self._file_path)
|
||||
except OSError:
|
||||
logger.warning(
|
||||
"Failed to open plan file in editor: %s", self._file_path, exc_info=True
|
||||
)
|
||||
self.app.notify(
|
||||
f"Could not open plan in editor: {self._file_path}",
|
||||
severity="error",
|
||||
timeout=6,
|
||||
)
|
||||
|
||||
def stop_watching(self) -> None:
|
||||
if self._watch_task is None:
|
||||
return
|
||||
|
||||
if not self._watch_task.done():
|
||||
self._watch_task.cancel()
|
||||
|
||||
self._watch_task = None
|
||||
|
|
|
|||
|
|
@ -1,20 +1,22 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import time
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from textual import events
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
|
||||
from textual.containers import Container, Horizontal, Vertical
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Input
|
||||
|
||||
from vibe.cli.textual_ui.ansi_markdown import AnsiMarkdown
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.vscode_compat import VscodeCompatInput
|
||||
|
||||
_INPUT_GRACE_PERIOD_S = 0.5
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.tools.builtins.ask_user_question import (
|
||||
AskUserQuestionArgs,
|
||||
|
|
@ -66,6 +68,7 @@ class QuestionApp(Container):
|
|||
self.submit_widget: NoMarkupStatic | None = None
|
||||
self.help_widget: NoMarkupStatic | None = None
|
||||
self.tabs_widget: NoMarkupStatic | None = None
|
||||
self._mount_time: float = 0.0
|
||||
|
||||
@property
|
||||
def _current_question(self) -> Question:
|
||||
|
|
@ -110,16 +113,7 @@ class QuestionApp(Container):
|
|||
)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
if self.args.content_preview:
|
||||
with VerticalScroll(classes="question-content-preview"):
|
||||
yield AnsiMarkdown(
|
||||
self.args.content_preview, classes="question-content-preview-text"
|
||||
)
|
||||
|
||||
question_content_classes = (
|
||||
"question-content-docked" if self.args.content_preview else ""
|
||||
)
|
||||
with Vertical(id="question-content", classes=question_content_classes):
|
||||
with Vertical(id="question-content"):
|
||||
if len(self.questions) > 1:
|
||||
self.tabs_widget = NoMarkupStatic("", classes="question-tabs")
|
||||
yield self.tabs_widget
|
||||
|
|
@ -147,13 +141,22 @@ class QuestionApp(Container):
|
|||
self.submit_widget = NoMarkupStatic("", classes="question-submit")
|
||||
yield self.submit_widget
|
||||
|
||||
if self.args.footer_note:
|
||||
yield NoMarkupStatic(
|
||||
self.args.footer_note, classes="question-footer-note"
|
||||
)
|
||||
|
||||
self.help_widget = NoMarkupStatic("", classes="question-help")
|
||||
yield self.help_widget
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
self._mount_time = time.monotonic()
|
||||
self._update_display()
|
||||
self.focus()
|
||||
|
||||
def is_within_grace_period(self) -> bool:
|
||||
return (time.monotonic() - self._mount_time) < _INPUT_GRACE_PERIOD_S
|
||||
|
||||
def _watch_current_question_idx(self) -> None:
|
||||
self._update_display()
|
||||
|
||||
|
|
@ -361,6 +364,8 @@ class QuestionApp(Container):
|
|||
self._switch_question(new_idx)
|
||||
|
||||
def action_select(self) -> None:
|
||||
if self.is_within_grace_period():
|
||||
return
|
||||
if self._current_question.multi_select:
|
||||
self._handle_multi_select_action()
|
||||
else:
|
||||
|
|
@ -415,6 +420,8 @@ class QuestionApp(Container):
|
|||
self._switch_question(new_idx)
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
if self.is_within_grace_period():
|
||||
return
|
||||
self.post_message(self.Cancelled())
|
||||
|
||||
def on_input_submitted(self, _event: Input.Submitted) -> None:
|
||||
|
|
@ -463,8 +470,11 @@ class QuestionApp(Container):
|
|||
|
||||
event.stop()
|
||||
event.prevent_default()
|
||||
self.selected_option = option_idx
|
||||
|
||||
if self.is_within_grace_period():
|
||||
return True
|
||||
|
||||
self.selected_option = option_idx
|
||||
if not (self._has_other and option_idx == self._other_option_idx):
|
||||
self.action_select()
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from vibe.core.types import (
|
|||
)
|
||||
|
||||
|
||||
def _empty_session_metadata() -> dict[str, str]:
|
||||
def _empty_session_metadata() -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
|
|
@ -34,13 +34,13 @@ class TurnSummaryTracker(TurnSummaryPort):
|
|||
model: ModelConfig,
|
||||
on_summary: Callable[[TurnSummaryResult], None] | None = None,
|
||||
max_tokens: int = 512,
|
||||
session_metadata_getter: Callable[[], dict[str, str]] | None = None,
|
||||
session_metadata_getter: Callable[[], dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
self._backend = backend
|
||||
self._model = model
|
||||
self._on_summary = on_summary
|
||||
self._max_tokens = max_tokens
|
||||
self._session_metadata_getter: Callable[[], dict[str, str]] = (
|
||||
self._session_metadata_getter: Callable[[], dict[str, Any]] = (
|
||||
_empty_session_metadata
|
||||
if session_metadata_getter is None
|
||||
else session_metadata_getter
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue