v2.2.1 (#403)
Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai> Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai> Co-authored-by: Thomas Kenbeek <thomas.kenbeek@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
ec7f3b25ea
commit
a560a47ce8
52 changed files with 1026 additions and 562 deletions
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
|
||||
VIBE_ROOT = Path(__file__).parent
|
||||
__version__ = "2.2.0"
|
||||
__version__ = "2.2.1"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from collections.abc import Callable
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pyperclip
|
||||
from textual.app import App
|
||||
|
||||
_PREVIEW_MAX_LENGTH = 40
|
||||
|
|
@ -19,6 +23,100 @@ def _copy_osc52(text: str) -> None:
|
|||
tty.flush()
|
||||
|
||||
|
||||
def _copy_pyperclip(text: str) -> None:
|
||||
pyperclip.copy(text)
|
||||
|
||||
|
||||
def _has_cmd(cmd: str) -> bool:
|
||||
return shutil.which(cmd) is not None
|
||||
|
||||
|
||||
def _copy_pbcopy(text: str) -> None:
|
||||
subprocess.run(["pbcopy"], input=text.encode("utf-8"), check=True)
|
||||
|
||||
|
||||
def _copy_xclip(text: str) -> None:
|
||||
subprocess.run(
|
||||
["xclip", "-selection", "clipboard"], input=text.encode("utf-8"), check=True
|
||||
)
|
||||
|
||||
|
||||
def _copy_wl_copy(text: str) -> None:
|
||||
subprocess.run(["wl-copy"], input=text.encode("utf-8"), check=True)
|
||||
|
||||
|
||||
_CMD_STRATEGIES: list[tuple[str, Callable[[str], None]]] = [
|
||||
("pbcopy", _copy_pbcopy),
|
||||
("xclip", _copy_xclip),
|
||||
("wl-copy", _copy_wl_copy),
|
||||
]
|
||||
|
||||
_COPY_METHODS: list[Callable[[str], None]] = [
|
||||
_copy_osc52,
|
||||
_copy_pyperclip,
|
||||
*[fn for cmd, fn in _CMD_STRATEGIES if _has_cmd(cmd)],
|
||||
]
|
||||
|
||||
|
||||
def _paste_pyperclip() -> str:
|
||||
return pyperclip.paste()
|
||||
|
||||
|
||||
def _paste_pbpaste() -> str:
|
||||
return subprocess.run(["pbpaste"], capture_output=True, check=True).stdout.decode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _paste_xclip() -> str:
|
||||
return subprocess.run(
|
||||
["xclip", "-selection", "clipboard", "-o"], capture_output=True, check=True
|
||||
).stdout.decode("utf-8")
|
||||
|
||||
|
||||
def _paste_wl_paste() -> str:
|
||||
return subprocess.run(["wl-paste"], capture_output=True, check=True).stdout.decode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
|
||||
_PASTE_CMD_STRATEGIES: list[tuple[str, Callable[[], str]]] = [
|
||||
("pbpaste", _paste_pbpaste),
|
||||
("xclip", _paste_xclip),
|
||||
("wl-paste", _paste_wl_paste),
|
||||
]
|
||||
|
||||
_READ_CLIPBOARD_METHODS: list[Callable[[], str]] = [
|
||||
_paste_pyperclip,
|
||||
*[fn for cmd, fn in _PASTE_CMD_STRATEGIES if _has_cmd(cmd)],
|
||||
]
|
||||
|
||||
|
||||
def _read_clipboard() -> str | None:
|
||||
for reader in _READ_CLIPBOARD_METHODS:
|
||||
try:
|
||||
return reader()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _copy_to_clipboard(text: str) -> None:
|
||||
all_strategies_failed = True
|
||||
for to_clipboard in _COPY_METHODS:
|
||||
try:
|
||||
to_clipboard(text)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
all_strategies_failed = False
|
||||
if _read_clipboard() == text:
|
||||
return
|
||||
|
||||
if all_strategies_failed:
|
||||
raise RuntimeError("All clipboard strategies failed")
|
||||
|
||||
|
||||
def _shorten_preview(texts: list[str]) -> str:
|
||||
dense_text = "⏎".join(texts).replace("\n", "⏎")
|
||||
if len(dense_text) > _PREVIEW_MAX_LENGTH:
|
||||
|
|
@ -26,7 +124,7 @@ def _shorten_preview(texts: list[str]) -> str:
|
|||
return dense_text
|
||||
|
||||
|
||||
def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None:
|
||||
def _get_selected_texts(app: App) -> list[str]:
|
||||
selected_texts = []
|
||||
|
||||
for widget in app.query("*"):
|
||||
|
|
@ -47,13 +145,17 @@ def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None
|
|||
if selected_text.strip():
|
||||
selected_texts.append(selected_text)
|
||||
|
||||
return selected_texts
|
||||
|
||||
|
||||
def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None:
|
||||
selected_texts = _get_selected_texts(app)
|
||||
if not selected_texts:
|
||||
return None
|
||||
|
||||
combined_text = "\n".join(selected_texts)
|
||||
|
||||
try:
|
||||
_copy_osc52(combined_text)
|
||||
_copy_to_clipboard(combined_text)
|
||||
if show_toast:
|
||||
app.notify(
|
||||
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
|
||||
|
|
|
|||
|
|
@ -2,16 +2,20 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
from enum import StrEnum, auto
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
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 rich import print as rprint
|
||||
from textual.app import WINDOWS, App, ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Horizontal, VerticalGroup, VerticalScroll
|
||||
from textual.driver import Driver
|
||||
from textual.events import AppBlur, AppFocus, MouseUp
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
|
@ -38,11 +42,9 @@ from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenS
|
|||
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,
|
||||
ReasoningMessage,
|
||||
StreamingMessageBase,
|
||||
UserCommandMessage,
|
||||
UserMessage,
|
||||
|
|
@ -54,7 +56,7 @@ from vibe.cli.textual_ui.widgets.path_display import PathDisplay
|
|||
from vibe.cli.textual_ui.widgets.proxy_setup_app import ProxySetupApp
|
||||
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.tools import ToolResultMessage
|
||||
from vibe.cli.textual_ui.windowing import (
|
||||
HISTORY_RESUME_TAIL_MESSAGES,
|
||||
LOAD_MORE_BATCH_SIZE,
|
||||
|
|
@ -180,6 +182,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("ctrl+c", "clear_quit", "Quit", show=False),
|
||||
Binding("ctrl+d", "force_quit", "Quit", show=False, priority=True),
|
||||
Binding("ctrl+z", "suspend_with_message", "Suspend", show=False, priority=True),
|
||||
Binding("escape", "interrupt", "Interrupt", show=False, priority=True),
|
||||
Binding("ctrl+o", "toggle_tool", "Toggle Tool", show=False),
|
||||
Binding("ctrl+y", "copy_selection", "Copy", show=False, priority=True),
|
||||
|
|
@ -225,8 +228,6 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.history_file = HISTORY_FILE.path
|
||||
|
||||
self._tools_collapsed = True
|
||||
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
|
||||
|
|
@ -239,9 +240,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._plan_offer_gateway = plan_offer_gateway
|
||||
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._banner: Banner | None = None
|
||||
self._whats_new_message: WhatsNewMessage | None = None
|
||||
self._cached_messages_area: Widget | None = None
|
||||
self._cached_chat: ChatScroll | None = None
|
||||
self._cached_loading_area: Widget | None = None
|
||||
|
|
@ -267,6 +268,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
safety=self.agent_loop.agent_profile.safety,
|
||||
agent_name=self.agent_loop.agent_profile.display_name.lower(),
|
||||
skill_entries_getter=self._get_skill_entries,
|
||||
file_watcher_for_autocomplete_getter=self._is_file_watcher_enabled,
|
||||
nuage_enabled=self.config.nuage_enabled,
|
||||
)
|
||||
|
||||
|
|
@ -284,7 +286,6 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
self.event_handler = EventHandler(
|
||||
mount_callback=self._mount_and_scroll,
|
||||
scroll_callback=self._scroll_to_bottom_deferred,
|
||||
get_tools_collapsed=lambda: self._tools_collapsed,
|
||||
)
|
||||
|
||||
|
|
@ -312,6 +313,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._schedule_update_notification()
|
||||
self.agent_loop.emit_new_session_telemetry("cli")
|
||||
|
||||
self.call_after_refresh(self._refresh_banner)
|
||||
|
||||
if self._initial_prompt or self._teleport_on_start:
|
||||
self.call_after_refresh(self._process_initial_prompt)
|
||||
|
||||
|
|
@ -325,12 +328,19 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_user_message(self._initial_prompt), exclusive=False
|
||||
)
|
||||
|
||||
def _is_file_watcher_enabled(self) -> bool:
|
||||
return self.config.file_watcher_for_autocomplete
|
||||
|
||||
async def on_chat_input_container_submitted(
|
||||
self, event: ChatInputContainer.Submitted
|
||||
) -> None:
|
||||
if self._banner:
|
||||
self._banner.freeze_animation()
|
||||
|
||||
if self._whats_new_message:
|
||||
await self._whats_new_message.remove()
|
||||
self._whats_new_message = None
|
||||
|
||||
value = event.value.strip()
|
||||
if not value:
|
||||
return
|
||||
|
|
@ -582,9 +592,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
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.call_after_refresh(self._scroll_chat_to_end)
|
||||
self._tool_call_map = plan.tool_call_map
|
||||
self._windowing.set_backfill(plan.backfill_messages)
|
||||
await self._load_more.set_visible(
|
||||
|
|
@ -703,7 +711,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if self._loading_widget:
|
||||
await self._loading_widget.remove()
|
||||
self._loading_widget = None
|
||||
await self._finalize_current_streaming_message()
|
||||
if self.event_handler:
|
||||
await self.event_handler.finalize_streaming()
|
||||
await self._refresh_windowing_from_history()
|
||||
|
||||
async def _teleport_command(self) -> None:
|
||||
|
|
@ -814,6 +823,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if self.event_handler:
|
||||
self.event_handler.stop_current_tool_call(success=False)
|
||||
self.event_handler.stop_current_compact()
|
||||
await self.event_handler.finalize_streaming()
|
||||
|
||||
self._agent_running = False
|
||||
loading_area = self._cached_loading_area or self.query_one(
|
||||
|
|
@ -822,7 +832,6 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await loading_area.remove_children()
|
||||
self._loading_widget = None
|
||||
|
||||
await self._finalize_current_streaming_message()
|
||||
await self._mount_and_scroll(InterruptMessage())
|
||||
|
||||
self._interrupt_requested = False
|
||||
|
|
@ -881,7 +890,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._tool_call_map = None
|
||||
self._history_widget_indices = WeakKeyDictionary()
|
||||
await self.agent_loop.clear_history()
|
||||
await self._finalize_current_streaming_message()
|
||||
if self.event_handler:
|
||||
await self.event_handler.finalize_streaming()
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
await messages_area.remove_children()
|
||||
|
||||
|
|
@ -1019,7 +1029,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
self.call_after_refresh(widget.focus)
|
||||
if scroll:
|
||||
self.call_after_refresh(self._scroll_to_bottom)
|
||||
self.call_after_refresh(self._scroll_chat_to_end)
|
||||
|
||||
async def _switch_to_config_app(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.Config:
|
||||
|
|
@ -1059,7 +1069,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._chat_input_container.display = True
|
||||
self._current_bottom_app = BottomApp.Input
|
||||
self.call_after_refresh(self._chat_input_container.focus_input)
|
||||
self.call_after_refresh(self._scroll_to_bottom)
|
||||
self.call_after_refresh(self._scroll_chat_to_end)
|
||||
|
||||
def _focus_current_bottom_app(self) -> None:
|
||||
try:
|
||||
|
|
@ -1153,7 +1163,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_agent_running_escape()
|
||||
|
||||
self._last_escape_time = current_time
|
||||
self._scroll_to_bottom()
|
||||
self.call_after_refresh(self._scroll_chat_to_end)
|
||||
self._focus_current_bottom_app()
|
||||
|
||||
async def on_history_load_more_requested(self, _: HistoryLoadMoreRequested) -> None:
|
||||
|
|
@ -1211,6 +1221,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
def _refresh_profile_widgets(self) -> None:
|
||||
self._update_profile_widgets(self.agent_loop.agent_profile)
|
||||
|
||||
def _refresh_banner(self) -> None:
|
||||
if self._banner:
|
||||
self._banner.set_state(self.config, self.agent_loop.skill_manager)
|
||||
|
||||
def _update_profile_widgets(self, profile: AgentProfile) -> None:
|
||||
if self._chat_input_container:
|
||||
self._chat_input_container.set_safety(profile.safety)
|
||||
|
|
@ -1245,7 +1259,6 @@ class VibeApp(App): # noqa: PLR0904
|
|||
try:
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
chat.scroll_relative(y=-5, animate=False)
|
||||
self._auto_scroll = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -1253,8 +1266,6 @@ class VibeApp(App): # noqa: PLR0904
|
|||
try:
|
||||
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
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -1283,7 +1294,11 @@ class VibeApp(App): # noqa: PLR0904
|
|||
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)
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
await chat.mount(whats_new_message, after=messages_area)
|
||||
self._whats_new_message = whats_new_message
|
||||
chat.anchor()
|
||||
await mark_version_as_seen(self._current_version, self._update_cache_repository)
|
||||
|
||||
async def _plan_offer_cta(self) -> str | None:
|
||||
|
|
@ -1309,105 +1324,29 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
return
|
||||
|
||||
async def _finalize_current_streaming_message(self) -> None:
|
||||
if self._current_streaming_reasoning is not None:
|
||||
self._current_streaming_reasoning.stop_spinning()
|
||||
await self._current_streaming_reasoning.stop_stream()
|
||||
self._current_streaming_reasoning = None
|
||||
|
||||
if self._current_streaming_message is None:
|
||||
return
|
||||
|
||||
await self._current_streaming_message.stop_stream()
|
||||
self._current_streaming_message = None
|
||||
|
||||
async def _handle_streaming_widget[T: StreamingMessageBase](
|
||||
self,
|
||||
widget: T,
|
||||
current_stream: T | None,
|
||||
other_stream: StreamingMessageBase | None,
|
||||
messages_area: Widget,
|
||||
) -> T | None:
|
||||
if other_stream is not None:
|
||||
await other_stream.stop_stream()
|
||||
|
||||
if current_stream is not None:
|
||||
if widget._content:
|
||||
await current_stream.append_content(widget._content)
|
||||
return None
|
||||
|
||||
await messages_area.mount(widget)
|
||||
await widget.write_initial_content()
|
||||
return widget
|
||||
def _scroll_chat_to_end(self) -> None:
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
chat.scroll_end(animate=False)
|
||||
|
||||
async def _mount_and_scroll(self, widget: Widget) -> None:
|
||||
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
|
||||
await messages_area.mount(widget)
|
||||
if isinstance(widget, StreamingMessageBase):
|
||||
await widget.write_initial_content()
|
||||
|
||||
if isinstance(widget, ReasoningMessage):
|
||||
result = await self._handle_streaming_widget(
|
||||
widget,
|
||||
self._current_streaming_reasoning,
|
||||
self._current_streaming_message,
|
||||
messages_area,
|
||||
)
|
||||
if result is not None:
|
||||
self._current_streaming_reasoning = result
|
||||
self._current_streaming_message = None
|
||||
elif isinstance(widget, AssistantMessage):
|
||||
if self._current_streaming_reasoning is not None:
|
||||
self._current_streaming_reasoning.stop_spinning()
|
||||
result = await self._handle_streaming_widget(
|
||||
widget,
|
||||
self._current_streaming_message,
|
||||
self._current_streaming_reasoning,
|
||||
messages_area,
|
||||
)
|
||||
if result is not None:
|
||||
self._current_streaming_message = result
|
||||
self._current_streaming_reasoning = None
|
||||
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)
|
||||
|
||||
def _is_scrolled_to_bottom(self, scroll_view: VerticalScroll) -> bool:
|
||||
try:
|
||||
threshold = 3
|
||||
return scroll_view.scroll_y >= (scroll_view.max_scroll_y - threshold)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
def _scroll_to_bottom(self) -> None:
|
||||
try:
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
chat.scroll_end(animate=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _scroll_to_bottom_deferred(self) -> None:
|
||||
self.call_after_refresh(self._scroll_to_bottom)
|
||||
self.call_after_refresh(self._try_prune)
|
||||
chat.anchor()
|
||||
|
||||
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)
|
||||
pruned = 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
|
||||
if pruned:
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
self.call_later(chat.anchor)
|
||||
|
||||
async def _refresh_windowing_from_history(self) -> None:
|
||||
if self._load_more.widget is None:
|
||||
|
|
@ -1424,29 +1363,6 @@ class VibeApp(App): # noqa: PLR0904
|
|||
messages_area, visible=has_backfill, remaining=self._windowing.remaining
|
||||
)
|
||||
|
||||
def _anchor_if_scrollable(self) -> None:
|
||||
if not self._auto_scroll:
|
||||
return
|
||||
try:
|
||||
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
|
||||
|
|
@ -1516,6 +1432,20 @@ 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_suspend_with_message(self) -> None:
|
||||
if WINDOWS or self._driver is None or not self._driver.can_suspend:
|
||||
return
|
||||
with self.suspend():
|
||||
rprint(
|
||||
"Mistral Vibe has been suspended. Run [bold cyan]fg[/bold cyan] to bring Mistral Vibe back."
|
||||
)
|
||||
os.kill(os.getpid(), signal.SIGTSTP)
|
||||
|
||||
def _on_driver_signal_resume(self, event: Driver.SignalResume) -> None:
|
||||
# Textual doesn't repaint after resuming from Ctrl+Z (SIGTSTP);
|
||||
# force a full layout refresh so the UI isn't garbled.
|
||||
self.refresh(layout=True)
|
||||
|
||||
|
||||
def _print_session_resume_message(session_id: str | None) -> None:
|
||||
if not session_id:
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ TextArea > .text-area--cursor {
|
|||
width: 100%;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
align-vertical: bottom;
|
||||
}
|
||||
|
||||
#loading-area {
|
||||
|
|
@ -147,8 +148,9 @@ Markdown {
|
|||
color: ansi_default;
|
||||
|
||||
.code_inline {
|
||||
color: ansi_default;
|
||||
background: ansi_bright_black;
|
||||
color: ansi_yellow;
|
||||
background: transparent;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
MarkdownFence {
|
||||
|
|
|
|||
|
|
@ -27,16 +27,14 @@ if TYPE_CHECKING:
|
|||
|
||||
class EventHandler:
|
||||
def __init__(
|
||||
self,
|
||||
mount_callback: Callable,
|
||||
scroll_callback: Callable,
|
||||
get_tools_collapsed: Callable[[], bool],
|
||||
self, mount_callback: Callable, get_tools_collapsed: Callable[[], bool]
|
||||
) -> None:
|
||||
self.mount_callback = mount_callback
|
||||
self.scroll_callback = scroll_callback
|
||||
self.get_tools_collapsed = get_tools_collapsed
|
||||
self.current_tool_call: ToolCallMessage | None = None
|
||||
self.current_compact: CompactMessage | None = None
|
||||
self.current_streaming_message: AssistantMessage | None = None
|
||||
self.current_streaming_reasoning: ReasoningMessage | None = None
|
||||
|
||||
async def handle_event(
|
||||
self,
|
||||
|
|
@ -45,24 +43,29 @@ class EventHandler:
|
|||
loading_widget: LoadingWidget | None = None,
|
||||
) -> ToolCallMessage | None:
|
||||
match event:
|
||||
case ToolCallEvent():
|
||||
return await self._handle_tool_call(event, loading_widget)
|
||||
case ToolResultEvent():
|
||||
sanitized_event = self._sanitize_event(event)
|
||||
await self._handle_tool_result(sanitized_event)
|
||||
case ToolStreamEvent():
|
||||
await self._handle_tool_stream(event)
|
||||
case ReasoningEvent():
|
||||
await self._handle_reasoning_message(event)
|
||||
case AssistantEvent():
|
||||
await self._handle_assistant_message(event)
|
||||
case ToolCallEvent():
|
||||
await self.finalize_streaming()
|
||||
return await self._handle_tool_call(event, loading_widget)
|
||||
case ToolResultEvent():
|
||||
await self.finalize_streaming()
|
||||
sanitized_event = self._sanitize_event(event)
|
||||
await self._handle_tool_result(sanitized_event)
|
||||
case ToolStreamEvent():
|
||||
await self._handle_tool_stream(event)
|
||||
case CompactStartEvent():
|
||||
await self.finalize_streaming()
|
||||
await self._handle_compact_start()
|
||||
case CompactEndEvent():
|
||||
await self.finalize_streaming()
|
||||
await self._handle_compact_end(event)
|
||||
case UserMessageEvent():
|
||||
pass
|
||||
await self.finalize_streaming()
|
||||
case _:
|
||||
await self.finalize_streaming()
|
||||
await self._handle_unknown_event(event)
|
||||
return None
|
||||
|
||||
|
|
@ -113,13 +116,30 @@ class EventHandler:
|
|||
self.current_tool_call.set_stream_message(event.message)
|
||||
|
||||
async def _handle_assistant_message(self, event: AssistantEvent) -> None:
|
||||
await self.mount_callback(AssistantMessage(event.content))
|
||||
if self.current_streaming_reasoning is not None:
|
||||
self.current_streaming_reasoning.stop_spinning()
|
||||
await self.current_streaming_reasoning.stop_stream()
|
||||
self.current_streaming_reasoning = None
|
||||
|
||||
if self.current_streaming_message is None:
|
||||
msg = AssistantMessage(event.content)
|
||||
self.current_streaming_message = msg
|
||||
await self.mount_callback(msg)
|
||||
else:
|
||||
await self.current_streaming_message.append_content(event.content)
|
||||
|
||||
async def _handle_reasoning_message(self, event: ReasoningEvent) -> None:
|
||||
tools_collapsed = self.get_tools_collapsed()
|
||||
await self.mount_callback(
|
||||
ReasoningMessage(event.content, collapsed=tools_collapsed)
|
||||
)
|
||||
if self.current_streaming_message is not None:
|
||||
await self.current_streaming_message.stop_stream()
|
||||
self.current_streaming_message = None
|
||||
|
||||
if self.current_streaming_reasoning is None:
|
||||
tools_collapsed = self.get_tools_collapsed()
|
||||
msg = ReasoningMessage(event.content, collapsed=tools_collapsed)
|
||||
self.current_streaming_reasoning = msg
|
||||
await self.mount_callback(msg)
|
||||
else:
|
||||
await self.current_streaming_reasoning.append_content(event.content)
|
||||
|
||||
async def _handle_compact_start(self) -> None:
|
||||
compact_msg = CompactMessage()
|
||||
|
|
@ -136,6 +156,15 @@ class EventHandler:
|
|||
async def _handle_unknown_event(self, event: BaseEvent) -> None:
|
||||
await self.mount_callback(NoMarkupStatic(str(event), classes="unknown-event"))
|
||||
|
||||
async def finalize_streaming(self) -> None:
|
||||
if self.current_streaming_reasoning is not None:
|
||||
self.current_streaming_reasoning.stop_spinning()
|
||||
await self.current_streaming_reasoning.stop_stream()
|
||||
self.current_streaming_reasoning = None
|
||||
if self.current_streaming_message is not None:
|
||||
await self.current_streaming_message.stop_stream()
|
||||
self.current_streaming_message = None
|
||||
|
||||
def stop_current_tool_call(self, success: bool = True) -> None:
|
||||
if self.current_tool_call:
|
||||
self.current_tool_call.stop_spinning(success=success)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ class ChatInputContainer(Vertical):
|
|||
safety: AgentSafety = AgentSafety.NEUTRAL,
|
||||
agent_name: str = "",
|
||||
skill_entries_getter: Callable[[], list[tuple[str, str]]] | None = None,
|
||||
file_watcher_for_autocomplete_getter: Callable[[], bool] | None = None,
|
||||
nuage_enabled: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
|
|
@ -51,11 +52,19 @@ class ChatInputContainer(Vertical):
|
|||
self._safety = safety
|
||||
self._agent_name = agent_name
|
||||
self._skill_entries_getter = skill_entries_getter
|
||||
self._file_watcher_for_autocomplete_getter = (
|
||||
file_watcher_for_autocomplete_getter
|
||||
)
|
||||
self._nuage_enabled = nuage_enabled
|
||||
|
||||
self._completion_manager = MultiCompletionManager([
|
||||
SlashCommandController(CommandCompleter(self._get_slash_entries), self),
|
||||
PathCompletionController(PathCompleter(), self),
|
||||
PathCompletionController(
|
||||
PathCompleter(
|
||||
watcher_enabled_getter=self._file_watcher_for_autocomplete_getter
|
||||
),
|
||||
self,
|
||||
),
|
||||
])
|
||||
self._completion_popup: CompletionPopup | None = None
|
||||
self._body: ChatInputBody | None = None
|
||||
|
|
|
|||
|
|
@ -63,6 +63,12 @@ class ConfigApp(Container):
|
|||
"type": "cycle",
|
||||
"options": ["On", "Off"],
|
||||
},
|
||||
{
|
||||
"key": "file_watcher_for_autocomplete",
|
||||
"label": "Autocomplete watcher (may delay first autocompletion)",
|
||||
"type": "cycle",
|
||||
"options": ["On", "Off"],
|
||||
},
|
||||
]
|
||||
|
||||
self.title_widget: Static | None = None
|
||||
|
|
|
|||
|
|
@ -25,9 +25,9 @@ from vibe.core.tools.builtins.write_file import WriteFileArgs, WriteFileResult
|
|||
|
||||
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")
|
||||
lines = content.strip("\n").split("\n")
|
||||
if len(lines) <= max_lines:
|
||||
return content, None
|
||||
return "\n".join(lines), None
|
||||
remaining = len(lines) - max_lines
|
||||
return "\n".join(lines[:max_lines]), f"… ({remaining} more lines)"
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ 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,
|
||||
|
|
@ -29,10 +28,7 @@ class HistoryResumePlan:
|
|||
|
||||
|
||||
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
|
||||
)
|
||||
return len(messages_children) == 0
|
||||
|
||||
|
||||
def create_resume_plan(
|
||||
|
|
|
|||
|
|
@ -474,54 +474,21 @@ class AgentLoop:
|
|||
async def _stream_assistant_events(
|
||||
self,
|
||||
) -> AsyncGenerator[AssistantEvent | ReasoningEvent]:
|
||||
content_buffer = ""
|
||||
reasoning_buffer = ""
|
||||
chunks_with_content = 0
|
||||
chunks_with_reasoning = 0
|
||||
message_id: str | None = None
|
||||
BATCH_SIZE = 5
|
||||
|
||||
async for chunk in self._chat_streaming():
|
||||
if message_id is None:
|
||||
message_id = chunk.message.message_id
|
||||
|
||||
if chunk.message.reasoning_content:
|
||||
if content_buffer:
|
||||
yield AssistantEvent(content=content_buffer, message_id=message_id)
|
||||
content_buffer = ""
|
||||
chunks_with_content = 0
|
||||
|
||||
reasoning_buffer += chunk.message.reasoning_content
|
||||
chunks_with_reasoning += 1
|
||||
|
||||
if chunks_with_reasoning >= BATCH_SIZE:
|
||||
yield ReasoningEvent(
|
||||
content=reasoning_buffer, message_id=message_id
|
||||
)
|
||||
reasoning_buffer = ""
|
||||
chunks_with_reasoning = 0
|
||||
yield ReasoningEvent(
|
||||
content=chunk.message.reasoning_content, message_id=message_id
|
||||
)
|
||||
|
||||
if chunk.message.content:
|
||||
if reasoning_buffer:
|
||||
yield ReasoningEvent(
|
||||
content=reasoning_buffer, message_id=message_id
|
||||
)
|
||||
reasoning_buffer = ""
|
||||
chunks_with_reasoning = 0
|
||||
|
||||
content_buffer += chunk.message.content
|
||||
chunks_with_content += 1
|
||||
|
||||
if chunks_with_content >= BATCH_SIZE:
|
||||
yield AssistantEvent(content=content_buffer, message_id=message_id)
|
||||
content_buffer = ""
|
||||
chunks_with_content = 0
|
||||
|
||||
if reasoning_buffer:
|
||||
yield ReasoningEvent(content=reasoning_buffer, message_id=message_id)
|
||||
|
||||
if content_buffer:
|
||||
yield AssistantEvent(content=content_buffer, message_id=message_id)
|
||||
yield AssistantEvent(
|
||||
content=chunk.message.content, message_id=message_id
|
||||
)
|
||||
|
||||
async def _get_assistant_event(self) -> AssistantEvent:
|
||||
llm_result = await self._chat()
|
||||
|
|
@ -933,7 +900,7 @@ class AgentLoop:
|
|||
)
|
||||
self.messages = self.messages[:1]
|
||||
|
||||
self.stats = AgentStats()
|
||||
self.stats = AgentStats.create_fresh(self.stats)
|
||||
self.stats.trigger_listeners()
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -71,8 +71,9 @@ class PathCompleter(Completer):
|
|||
self,
|
||||
max_entries_to_process: int = DEFAULT_MAX_ENTRIES_TO_PROCESS,
|
||||
target_matches: int = DEFAULT_TARGET_MATCHES,
|
||||
watcher_enabled_getter: Callable[[], bool] | None = None,
|
||||
) -> None:
|
||||
self._indexer = FileIndexer()
|
||||
self._indexer = FileIndexer(should_enable_watcher=watcher_enabled_getter)
|
||||
self._max_entries_to_process = max_entries_to_process
|
||||
self._target_matches = target_matches
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Callable, Iterable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
|
@ -23,7 +23,11 @@ class _RebuildTask:
|
|||
|
||||
|
||||
class FileIndexer:
|
||||
def __init__(self, mass_change_threshold: int = 200) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
mass_change_threshold: int = 200,
|
||||
should_enable_watcher: Callable[[], bool] | None = None,
|
||||
) -> None:
|
||||
self._lock = RLock() # guards _store snapshot access and watcher callbacks.
|
||||
self._stats = FileIndexStats()
|
||||
self._ignore_rules = IgnoreRules()
|
||||
|
|
@ -40,6 +44,7 @@ class FileIndexer:
|
|||
) # coordinates updates to _active_rebuilds and _target_root.
|
||||
self._target_root: Path | None = None
|
||||
self._shutdown = False
|
||||
self._should_enable_watcher = should_enable_watcher or (lambda: False)
|
||||
|
||||
atexit.register(self.shutdown)
|
||||
|
||||
|
|
@ -74,7 +79,10 @@ class FileIndexer:
|
|||
self._start_background_rebuild(resolved_root)
|
||||
self._wait_for_rebuild(resolved_root)
|
||||
|
||||
self._watcher.start(resolved_root)
|
||||
if self._should_enable_watcher():
|
||||
self._watcher.start(resolved_root)
|
||||
else:
|
||||
self._watcher.stop()
|
||||
|
||||
with self._lock: # ensure root reference is fresh before snapshotting
|
||||
return self._store.snapshot()
|
||||
|
|
|
|||
|
|
@ -311,6 +311,7 @@ class VibeConfig(BaseSettings):
|
|||
vim_keybindings: bool = False
|
||||
disable_welcome_banner_animation: bool = False
|
||||
autocopy_to_clipboard: bool = True
|
||||
file_watcher_for_autocomplete: bool = False
|
||||
displayed_workdir: str = ""
|
||||
auto_compact_threshold: int = 200_000
|
||||
context_warnings: bool = False
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, NamedTuple, cast
|
|||
|
||||
import httpx
|
||||
import mistralai
|
||||
from mistralai.utils.retries import BackoffStrategy, RetryConfig
|
||||
|
||||
from vibe.core.llm.exceptions import BackendErrorBuilder
|
||||
from vibe.core.llm.message_utils import merge_consecutive_user_messages
|
||||
|
|
@ -178,13 +179,22 @@ class MistralBackend:
|
|||
)
|
||||
self._server_url = match.group(1)
|
||||
self._timeout = timeout
|
||||
self._retry_config = self._build_retry_config()
|
||||
|
||||
def _build_retry_config(self) -> RetryConfig:
|
||||
return RetryConfig(
|
||||
strategy="backoff",
|
||||
backoff=BackoffStrategy(
|
||||
initial_interval=500,
|
||||
max_interval=30000,
|
||||
exponent=1.5,
|
||||
max_elapsed_time=300000,
|
||||
),
|
||||
retry_connection_errors=True,
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> MistralBackend:
|
||||
self._client = mistralai.Mistral(
|
||||
api_key=self._api_key,
|
||||
server_url=self._server_url,
|
||||
timeout_ms=int(self._timeout * 1000),
|
||||
)
|
||||
self._client = self._create_mistral_client()
|
||||
await self._client.__aenter__()
|
||||
return self
|
||||
|
||||
|
|
@ -199,11 +209,17 @@ class MistralBackend:
|
|||
exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb
|
||||
)
|
||||
|
||||
def _create_mistral_client(self) -> mistralai.Mistral:
|
||||
return mistralai.Mistral(
|
||||
api_key=self._api_key,
|
||||
server_url=self._server_url,
|
||||
timeout_ms=int(self._timeout * 1000),
|
||||
retry_config=self._retry_config,
|
||||
)
|
||||
|
||||
def _get_client(self) -> mistralai.Mistral:
|
||||
if self._client is None:
|
||||
self._client = mistralai.Mistral(
|
||||
api_key=self._api_key, server_url=self._server_url
|
||||
)
|
||||
self._client = self._create_mistral_client()
|
||||
return self._client
|
||||
|
||||
async def complete(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import google.auth
|
||||
import google.auth.credentials
|
||||
from google.auth.transport.requests import Request
|
||||
|
||||
from vibe.core.config import ProviderConfig
|
||||
|
|
@ -12,15 +14,6 @@ from vibe.core.llm.backend.base import PreparedRequest
|
|||
from vibe.core.types import AvailableTool, LLMMessage, StrToolChoice
|
||||
|
||||
|
||||
def get_vertex_access_token() -> str:
|
||||
|
||||
credentials, _ = google.auth.default(
|
||||
scopes=["https://www.googleapis.com/auth/cloud-platform"]
|
||||
)
|
||||
credentials.refresh(Request())
|
||||
return credentials.token
|
||||
|
||||
|
||||
def build_vertex_base_url(region: str) -> str:
|
||||
if region == "global":
|
||||
return "https://aiplatform.googleapis.com"
|
||||
|
|
@ -37,13 +30,39 @@ def build_vertex_endpoint(
|
|||
)
|
||||
|
||||
|
||||
class VertexCredentials:
|
||||
def __init__(self) -> None:
|
||||
self._credentials: google.auth.credentials.Credentials | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def access_token(self) -> str:
|
||||
with self._lock:
|
||||
creds = self._credentials
|
||||
if creds is None:
|
||||
creds, _ = google.auth.default(
|
||||
scopes=["https://www.googleapis.com/auth/cloud-platform"]
|
||||
)
|
||||
self._credentials = creds
|
||||
if not creds.valid:
|
||||
creds.refresh(Request())
|
||||
if creds.token is None:
|
||||
raise RuntimeError(
|
||||
"Vertex AI credential refresh did not produce a token"
|
||||
)
|
||||
return creds.token
|
||||
|
||||
|
||||
class VertexAnthropicAdapter(AnthropicAdapter):
|
||||
"""Vertex AI adapter — inherits all streaming/parsing from AnthropicAdapter."""
|
||||
|
||||
endpoint: ClassVar[str] = ""
|
||||
# Vertex AI doesn't support beta features
|
||||
BETA_FEATURES: ClassVar[str] = ""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.credentials = VertexCredentials()
|
||||
|
||||
def prepare_request( # noqa: PLR0913
|
||||
self,
|
||||
*,
|
||||
|
|
@ -70,7 +89,6 @@ class VertexAnthropicAdapter(AnthropicAdapter):
|
|||
converted_tools = self._mapper.prepare_tools(tools)
|
||||
converted_tool_choice = self._mapper.prepare_tool_choice(tool_choice)
|
||||
|
||||
# Build vertex-specific payload (no "model" key, uses anthropic_version)
|
||||
payload: dict[str, Any] = {
|
||||
"anthropic_version": "vertex-2023-10-16",
|
||||
"messages": converted_messages,
|
||||
|
|
@ -98,11 +116,9 @@ class VertexAnthropicAdapter(AnthropicAdapter):
|
|||
|
||||
self._add_cache_control_to_last_user_message(converted_messages)
|
||||
|
||||
access_token = get_vertex_access_token()
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Authorization": f"Bearer {self.credentials.access_token}",
|
||||
"anthropic-beta": self.BETA_FEATURES,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,13 +72,7 @@ def _get_shell_executable() -> str | None:
|
|||
|
||||
|
||||
def _get_base_env() -> dict[str, str]:
|
||||
base_env = {
|
||||
**os.environ,
|
||||
"CI": "true",
|
||||
"NONINTERACTIVE": "1",
|
||||
"NO_TTY": "1",
|
||||
"NO_COLOR": "1",
|
||||
}
|
||||
base_env = {**os.environ, "CI": "true", "NONINTERACTIVE": "1", "NO_TTY": "1"}
|
||||
|
||||
if is_windows():
|
||||
base_env["GIT_PAGER"] = "more"
|
||||
|
|
|
|||
|
|
@ -61,6 +61,12 @@ class AgentStats(BaseModel):
|
|||
) -> None:
|
||||
self._listeners[attr_name] = listener
|
||||
|
||||
@staticmethod
|
||||
def create_fresh(previous: AgentStats) -> AgentStats:
|
||||
fresh = AgentStats()
|
||||
fresh._listeners = previous._listeners.copy()
|
||||
return fresh
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def session_total_llm_tokens(self) -> int:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
# What's New in 2.2.0
|
||||
# What's new
|
||||
|
||||
- **Agent Skills standard** — Vibe now discovers skills from `.agents/skills/` (agentskills.io) as well as `.vibe/skills/`.
|
||||
|
||||
Optional: usage and tool events are sent to our datalake to improve the product if you have a valid Mistral API key; set `disable_telemetry = true` in config to opt out.
|
||||
- **Performance**: general improvements (streaming, scrolling).
|
||||
- **Fix**: autocopying now behaves correctly in cases where it previously failed.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue