mirror of
https://github.com/mistralai/mistral-vibe
synced 2026-04-25 17:14:55 +02:00
Co-authored-by: Bastien <bastien.baret@gmail.com> Co-authored-by: Laure Hugo <201583486+laure0303@users.noreply.github.com> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Paul Cacheux <paul.cacheux@mistral.ai> Co-authored-by: Val <102326092+vdeva@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
241 lines
8.5 KiB
Python
241 lines
8.5 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from textual.app import ComposeResult
|
|
from textual.containers import Vertical
|
|
from textual.message import Message
|
|
|
|
from vibe.cli.autocompletion.path_completion import PathCompletionController
|
|
from vibe.cli.autocompletion.slash_command import SlashCommandController
|
|
from vibe.cli.commands import CommandRegistry
|
|
from vibe.cli.textual_ui.widgets.chat_input.body import ChatInputBody
|
|
from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
|
|
MultiCompletionManager,
|
|
)
|
|
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
|
|
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
|
|
from vibe.cli.voice_manager.voice_manager_port import VoiceManagerPort
|
|
from vibe.core.agents import AgentSafety
|
|
from vibe.core.autocompletion.completers import CommandCompleter, PathCompleter
|
|
|
|
SAFETY_BORDER_CLASSES: dict[AgentSafety, str] = {
|
|
AgentSafety.SAFE: "border-safe",
|
|
AgentSafety.DESTRUCTIVE: "border-warning",
|
|
AgentSafety.YOLO: "border-error",
|
|
}
|
|
|
|
|
|
class ChatInputContainer(Vertical):
|
|
ID_INPUT_BOX = "input-box"
|
|
REMOTE_BORDER_CLASS = "border-remote"
|
|
|
|
class Submitted(Message):
|
|
def __init__(self, value: str) -> None:
|
|
self.value = value
|
|
super().__init__()
|
|
|
|
def __init__(
|
|
self,
|
|
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,
|
|
file_watcher_for_autocomplete_getter: Callable[[], bool] | None = None,
|
|
nuage_enabled: bool = False,
|
|
voice_manager: VoiceManagerPort | None = None,
|
|
**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._file_watcher_for_autocomplete_getter = (
|
|
file_watcher_for_autocomplete_getter
|
|
)
|
|
self._nuage_enabled = nuage_enabled
|
|
self._voice_manager = voice_manager
|
|
self._custom_border_label: str | None = None
|
|
self._custom_border_class: str | None = None
|
|
|
|
self._completion_manager = MultiCompletionManager([
|
|
SlashCommandController(CommandCompleter(self._get_slash_entries), self),
|
|
PathCompletionController(
|
|
PathCompleter(
|
|
watcher_enabled_getter=self._file_watcher_for_autocomplete_getter
|
|
),
|
|
self,
|
|
),
|
|
])
|
|
self._completion_popup: CompletionPopup | None = None
|
|
self._body: ChatInputBody | None = None
|
|
|
|
def _get_slash_entries(self) -> list[tuple[str, str]]:
|
|
entries = [
|
|
(alias, command.description)
|
|
for command in self._command_registry.commands.values()
|
|
for alias in sorted(command.aliases)
|
|
]
|
|
if self._skill_entries_getter:
|
|
entries.extend(self._skill_entries_getter())
|
|
return sorted(entries)
|
|
|
|
def compose(self) -> ComposeResult:
|
|
self._completion_popup = CompletionPopup()
|
|
yield self._completion_popup
|
|
|
|
border_class = self._get_border_class()
|
|
with Vertical(id=self.ID_INPUT_BOX, classes=border_class) as input_box:
|
|
input_box.border_title = self._get_border_title()
|
|
self._body = ChatInputBody(
|
|
history_file=self._history_file,
|
|
id="input-body",
|
|
nuage_enabled=self._nuage_enabled,
|
|
voice_manager=self._voice_manager,
|
|
)
|
|
|
|
yield self._body
|
|
|
|
def on_mount(self) -> None:
|
|
if not self._body:
|
|
return
|
|
|
|
self._body.set_completion_reset_callback(self._completion_manager.reset)
|
|
if self._body.input_widget:
|
|
self._body.input_widget.set_completion_manager(self._completion_manager)
|
|
self._body.focus_input()
|
|
|
|
@property
|
|
def input_widget(self) -> ChatTextArea | None:
|
|
return self._body.input_widget if self._body else None
|
|
|
|
@property
|
|
def value(self) -> str:
|
|
if not self._body:
|
|
return ""
|
|
return self._body.value
|
|
|
|
@value.setter
|
|
def value(self, text: str) -> None:
|
|
if not self._body:
|
|
return
|
|
self._body.value = text
|
|
widget = self._body.input_widget
|
|
if widget:
|
|
self._completion_manager.on_text_changed(
|
|
widget.get_full_text(), widget._get_full_cursor_offset()
|
|
)
|
|
|
|
def focus_input(self) -> None:
|
|
if self._body:
|
|
self._body.focus_input()
|
|
|
|
def render_completion_suggestions(
|
|
self, suggestions: list[tuple[str, str]], selected_index: int
|
|
) -> None:
|
|
if self._completion_popup:
|
|
self._completion_popup.update_suggestions(suggestions, selected_index)
|
|
|
|
def clear_completion_suggestions(self) -> None:
|
|
if self._completion_popup:
|
|
self._completion_popup.hide()
|
|
|
|
def _format_insertion(self, replacement: str, suffix: str) -> str:
|
|
"""Format the insertion text with appropriate spacing.
|
|
|
|
Args:
|
|
replacement: The text to insert
|
|
suffix: The text that follows the insertion point
|
|
|
|
Returns:
|
|
The formatted insertion text with spacing if needed
|
|
"""
|
|
if replacement.startswith("@"):
|
|
if replacement.endswith("/"):
|
|
return replacement
|
|
# For @-prefixed completions, add space unless suffix starts with whitespace
|
|
return replacement + (" " if not suffix or not suffix[0].isspace() else "")
|
|
|
|
# For other completions, add space only if suffix exists and doesn't start with whitespace
|
|
return replacement + (" " if suffix and not suffix[0].isspace() else "")
|
|
|
|
def replace_completion_range(self, start: int, end: int, replacement: str) -> None:
|
|
widget = self.input_widget
|
|
if not widget or not self._body:
|
|
return
|
|
start, end, replacement = widget.adjust_from_full_text_coords(
|
|
start, end, replacement
|
|
)
|
|
|
|
text = widget.text
|
|
start = max(0, min(start, len(text)))
|
|
end = max(start, min(end, len(text)))
|
|
|
|
prefix = text[:start]
|
|
suffix = text[end:]
|
|
insertion = self._format_insertion(replacement, suffix)
|
|
new_text = f"{prefix}{insertion}{suffix}"
|
|
|
|
self._body.replace_input(new_text, cursor_offset=start + len(insertion))
|
|
|
|
def on_chat_input_body_submitted(self, event: ChatInputBody.Submitted) -> None:
|
|
event.stop()
|
|
self.post_message(self.Submitted(event.value))
|
|
|
|
@property
|
|
def switching_mode(self) -> bool:
|
|
return self._body.switching_mode if self._body else False
|
|
|
|
@switching_mode.setter
|
|
def switching_mode(self, value: bool) -> None:
|
|
if self._body:
|
|
self._body.switching_mode = value
|
|
|
|
def set_safety(self, safety: AgentSafety) -> None:
|
|
self._safety = safety
|
|
self._apply_input_box_chrome()
|
|
|
|
def set_agent_name(self, name: str) -> None:
|
|
self._agent_name = name
|
|
self._apply_input_box_chrome()
|
|
|
|
def set_custom_border(
|
|
self, label: str | None, border_class: str | None = None
|
|
) -> None:
|
|
self._custom_border_label = label
|
|
self._custom_border_class = border_class
|
|
self._apply_input_box_chrome()
|
|
|
|
def _get_border_class(self) -> str:
|
|
if self._custom_border_class is not None:
|
|
return self._custom_border_class
|
|
if self._custom_border_label is not None:
|
|
return ""
|
|
return SAFETY_BORDER_CLASSES.get(self._safety, "")
|
|
|
|
def _get_border_title(self) -> str:
|
|
if self._custom_border_label is not None:
|
|
return self._custom_border_label
|
|
return self._agent_name
|
|
|
|
def _apply_input_box_chrome(self) -> None:
|
|
try:
|
|
input_box = self.get_widget_by_id(self.ID_INPUT_BOX)
|
|
except Exception:
|
|
return
|
|
|
|
input_box.remove_class(self.REMOTE_BORDER_CLASS)
|
|
for border_class in SAFETY_BORDER_CLASSES.values():
|
|
input_box.remove_class(border_class)
|
|
|
|
border_class = self._get_border_class()
|
|
if border_class:
|
|
input_box.add_class(border_class)
|
|
|
|
input_box.border_title = self._get_border_title()
|