mirror of
https://github.com/mistralai/mistral-vibe
synced 2026-04-26 01:24:55 +02:00
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
400 lines
13 KiB
Python
400 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any, ClassVar, Literal
|
|
|
|
from textual import events
|
|
from textual.binding import Binding
|
|
from textual.message import Message
|
|
from textual.widgets import TextArea
|
|
|
|
from vibe.cli.autocompletion.base import CompletionResult
|
|
from vibe.cli.textual_ui.external_editor import ExternalEditor
|
|
from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
|
|
MultiCompletionManager,
|
|
)
|
|
from vibe.cli.textual_ui.widgets.vscode_compat import patch_vscode_space
|
|
from vibe.cli.voice_manager.voice_manager_port import (
|
|
RecordingStartError,
|
|
TranscribeState,
|
|
VoiceManagerPort,
|
|
)
|
|
|
|
InputMode = Literal["!", "/", ">", "&"]
|
|
|
|
|
|
class ChatTextArea(TextArea):
|
|
BINDINGS: ClassVar[list[Binding]] = [
|
|
Binding(
|
|
"shift+enter,ctrl+j",
|
|
"insert_newline",
|
|
"New Line",
|
|
show=False,
|
|
priority=True,
|
|
),
|
|
Binding("alt+left", "cursor_word_left", "Cursor word left", show=False),
|
|
Binding("alt+right", "cursor_word_right", "Cursor word right", show=False),
|
|
Binding("ctrl+g", "open_external_editor", "External Editor", show=False),
|
|
]
|
|
|
|
DEFAULT_MODE: ClassVar[Literal[">"]] = ">"
|
|
|
|
class Submitted(Message):
|
|
def __init__(self, value: str) -> None:
|
|
self.value = value
|
|
super().__init__()
|
|
|
|
class HistoryPrevious(Message):
|
|
pass
|
|
|
|
class HistoryNext(Message):
|
|
pass
|
|
|
|
class HistoryReset(Message):
|
|
"""Message sent when history navigation should be reset."""
|
|
|
|
class ModeChanged(Message):
|
|
"""Message sent when the input mode changes (>, !, /, &)."""
|
|
|
|
def __init__(self, mode: InputMode) -> None:
|
|
self.mode = mode
|
|
super().__init__()
|
|
|
|
def __init__(
|
|
self,
|
|
nuage_enabled: bool = False,
|
|
voice_manager: VoiceManagerPort | None = None,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
super().__init__(**kwargs)
|
|
self._nuage_enabled = nuage_enabled
|
|
self._input_mode: InputMode = self.DEFAULT_MODE
|
|
self._last_text = ""
|
|
self._navigating_history = False
|
|
self._original_text: str = ""
|
|
self._cursor_pos_after_load: tuple[int, int] | None = None
|
|
self._cursor_moved_since_load: bool = False
|
|
self._completion_manager: MultiCompletionManager | None = None
|
|
self._app_has_focus: bool = True
|
|
self._voice_manager = voice_manager
|
|
|
|
def on_blur(self, event: events.Blur) -> None:
|
|
if self._app_has_focus:
|
|
self.call_after_refresh(self.focus)
|
|
|
|
def set_app_focus(self, has_focus: bool) -> None:
|
|
self._app_has_focus = has_focus
|
|
self.cursor_blink = has_focus
|
|
if has_focus and not self.has_focus:
|
|
self.call_after_refresh(self.focus)
|
|
|
|
def on_click(self, event: events.Click) -> None:
|
|
self._mark_cursor_moved_if_needed()
|
|
|
|
def action_insert_newline(self) -> None:
|
|
self.insert("\n")
|
|
|
|
def action_open_external_editor(self) -> None:
|
|
editor = ExternalEditor()
|
|
current_text = self.get_full_text()
|
|
|
|
with self.app.suspend():
|
|
result = editor.edit(current_text)
|
|
|
|
if result is not None:
|
|
self.clear()
|
|
self.insert(result)
|
|
|
|
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
|
if not self._navigating_history and self.text != self._last_text:
|
|
self._original_text = ""
|
|
self._cursor_pos_after_load = None
|
|
self._cursor_moved_since_load = False
|
|
self.post_message(self.HistoryReset())
|
|
self._last_text = self.text
|
|
was_navigating_history = self._navigating_history
|
|
self._navigating_history = False
|
|
|
|
if self._completion_manager and not was_navigating_history:
|
|
self._completion_manager.on_text_changed(
|
|
self.get_full_text(), self._get_full_cursor_offset()
|
|
)
|
|
|
|
def _mark_cursor_moved_if_needed(self) -> None:
|
|
if (
|
|
self._cursor_pos_after_load is not None
|
|
and not self._cursor_moved_since_load
|
|
and self.cursor_location != self._cursor_pos_after_load
|
|
):
|
|
self._cursor_moved_since_load = True
|
|
|
|
def _handle_history_up(self) -> bool:
|
|
history_loaded_and_cursor_unmoved = (
|
|
self._cursor_pos_after_load is not None
|
|
and not self._cursor_moved_since_load
|
|
)
|
|
should_intercept = (
|
|
self.navigator.is_first_wrapped_line(self.cursor_location)
|
|
or history_loaded_and_cursor_unmoved
|
|
)
|
|
|
|
if should_intercept:
|
|
self._navigating_history = True
|
|
self.post_message(self.HistoryPrevious())
|
|
return True
|
|
return False
|
|
|
|
def _is_on_loaded_history_entry(self) -> bool:
|
|
return self._cursor_pos_after_load is not None
|
|
|
|
def _should_intercept_history_down(self) -> bool:
|
|
if self._is_on_loaded_history_entry() and not self._cursor_moved_since_load:
|
|
return True
|
|
|
|
if not self.navigator.is_last_wrapped_line(self.cursor_location):
|
|
return False
|
|
|
|
return self._is_on_loaded_history_entry()
|
|
|
|
def _handle_history_down(self) -> bool:
|
|
if not self._should_intercept_history_down():
|
|
return False
|
|
|
|
self._navigating_history = True
|
|
self.post_message(self.HistoryNext())
|
|
return True
|
|
|
|
class FeedbackKeyPressed(Message):
|
|
def __init__(self, rating: int) -> None:
|
|
self.rating = rating
|
|
super().__init__()
|
|
|
|
class NonFeedbackKeyPressed(Message):
|
|
pass
|
|
|
|
feedback_active: bool = False
|
|
|
|
async def _handle_voice_key(self, event: events.Key) -> bool:
|
|
if not self._voice_manager:
|
|
return False
|
|
|
|
# Handle key pressed during audio recording
|
|
if self._voice_manager.transcribe_state != TranscribeState.IDLE:
|
|
event.prevent_default()
|
|
event.stop()
|
|
if event.key == "ctrl+c": # Escape is handled in app.py
|
|
self._voice_manager.cancel_recording()
|
|
elif self._voice_manager.transcribe_state == TranscribeState.RECORDING:
|
|
await self._voice_manager.stop_recording()
|
|
return True
|
|
|
|
# Handle audio record keybind
|
|
if self._voice_manager.is_enabled and event.key == "ctrl+r":
|
|
event.prevent_default()
|
|
event.stop()
|
|
try:
|
|
self._voice_manager.start_recording()
|
|
except RecordingStartError as e:
|
|
self.notify(str(e), severity="warning")
|
|
return True
|
|
|
|
return False
|
|
|
|
async def _on_key(self, event: events.Key) -> None: # noqa: PLR0911
|
|
if await self._handle_voice_key(event):
|
|
return
|
|
|
|
self._mark_cursor_moved_if_needed()
|
|
|
|
if self.feedback_active:
|
|
if event.character in {"1", "2", "3"}:
|
|
event.prevent_default()
|
|
event.stop()
|
|
self.post_message(self.FeedbackKeyPressed(int(event.character)))
|
|
return
|
|
if event.character is not None:
|
|
self.post_message(self.NonFeedbackKeyPressed())
|
|
|
|
manager = self._completion_manager
|
|
if manager:
|
|
match manager.on_key(
|
|
event, self.get_full_text(), self._get_full_cursor_offset()
|
|
):
|
|
case CompletionResult.HANDLED:
|
|
event.prevent_default()
|
|
event.stop()
|
|
return
|
|
case CompletionResult.SUBMIT:
|
|
event.prevent_default()
|
|
event.stop()
|
|
value = self.get_full_text().strip()
|
|
if value:
|
|
self.post_message(self.Submitted(value))
|
|
return
|
|
|
|
if event.key == "enter":
|
|
event.prevent_default()
|
|
event.stop()
|
|
value = self.get_full_text().strip()
|
|
if value:
|
|
self.post_message(self.Submitted(value))
|
|
return
|
|
|
|
if event.key == "shift+enter":
|
|
event.prevent_default()
|
|
event.stop()
|
|
return
|
|
|
|
if (
|
|
event.character
|
|
and event.character in self.mode_characters
|
|
and not self.text
|
|
and self._input_mode == self.DEFAULT_MODE
|
|
):
|
|
self._set_mode(event.character)
|
|
event.prevent_default()
|
|
event.stop()
|
|
return
|
|
|
|
if event.key == "backspace" and self._should_reset_mode_on_backspace():
|
|
self._set_mode(self.DEFAULT_MODE)
|
|
event.prevent_default()
|
|
event.stop()
|
|
return
|
|
|
|
if event.key == "up" and self._handle_history_up():
|
|
event.prevent_default()
|
|
event.stop()
|
|
return
|
|
|
|
if event.key == "down" and self._handle_history_down():
|
|
event.prevent_default()
|
|
event.stop()
|
|
return
|
|
|
|
patch_vscode_space(event)
|
|
|
|
await super()._on_key(event)
|
|
self._mark_cursor_moved_if_needed()
|
|
|
|
def set_completion_manager(self, manager: MultiCompletionManager | None) -> None:
|
|
self._completion_manager = manager
|
|
if self._completion_manager:
|
|
self._completion_manager.on_text_changed(
|
|
self.get_full_text(), self._get_full_cursor_offset()
|
|
)
|
|
|
|
def get_cursor_offset(self) -> int:
|
|
text = self.text
|
|
row, col = self.cursor_location
|
|
|
|
if not text:
|
|
return 0
|
|
|
|
lines = text.split("\n")
|
|
row = max(0, min(row, len(lines) - 1))
|
|
col = max(0, col)
|
|
|
|
offset = sum(len(lines[i]) + 1 for i in range(row))
|
|
return offset + min(col, len(lines[row]))
|
|
|
|
def set_cursor_offset(self, offset: int) -> None:
|
|
text = self.text
|
|
if offset <= 0:
|
|
self.move_cursor((0, 0))
|
|
return
|
|
|
|
if offset >= len(text):
|
|
lines = text.split("\n")
|
|
if not lines:
|
|
self.move_cursor((0, 0))
|
|
return
|
|
last_row = len(lines) - 1
|
|
self.move_cursor((last_row, len(lines[last_row])))
|
|
return
|
|
|
|
remaining = offset
|
|
lines = text.split("\n")
|
|
|
|
for row, line in enumerate(lines):
|
|
line_length = len(line)
|
|
if remaining <= line_length:
|
|
self.move_cursor((row, remaining))
|
|
return
|
|
remaining -= line_length + 1
|
|
|
|
last_row = len(lines) - 1
|
|
self.move_cursor((last_row, len(lines[last_row])))
|
|
|
|
def reset_history_state(self) -> None:
|
|
self._original_text = ""
|
|
self._cursor_pos_after_load = None
|
|
self._cursor_moved_since_load = False
|
|
self._last_text = self.text
|
|
|
|
def clear_text(self) -> None:
|
|
self.clear()
|
|
self.reset_history_state()
|
|
self._set_mode(self.DEFAULT_MODE)
|
|
|
|
def _set_mode(self, mode: InputMode) -> None:
|
|
if self._input_mode == mode:
|
|
return
|
|
self._input_mode = mode
|
|
self.post_message(self.ModeChanged(mode))
|
|
if self._completion_manager:
|
|
self._completion_manager.on_text_changed(
|
|
self.get_full_text(), self._get_full_cursor_offset()
|
|
)
|
|
|
|
def _should_reset_mode_on_backspace(self) -> bool:
|
|
return (
|
|
self._input_mode != self.DEFAULT_MODE
|
|
and not self.text
|
|
and self.get_cursor_offset() == 0
|
|
)
|
|
|
|
def get_full_text(self) -> str:
|
|
if self._input_mode != self.DEFAULT_MODE:
|
|
return self._input_mode + self.text
|
|
return self.text
|
|
|
|
def _get_full_cursor_offset(self) -> int:
|
|
return self.get_cursor_offset() + self._get_mode_prefix_length()
|
|
|
|
def _get_mode_prefix_length(self) -> int:
|
|
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:
|
|
return self._input_mode
|
|
|
|
def set_mode(self, mode: InputMode) -> None:
|
|
if self._input_mode != mode:
|
|
self._input_mode = mode
|
|
self.post_message(self.ModeChanged(mode))
|
|
|
|
def adjust_from_full_text_coords(
|
|
self, start: int, end: int, replacement: str
|
|
) -> tuple[int, int, str]:
|
|
"""Translate from full-text coordinates to widget coordinates.
|
|
|
|
The completion manager works with 'full text' that includes the mode prefix.
|
|
This adjusts coordinates and replacement text for the actual widget text.
|
|
"""
|
|
mode_len = self._get_mode_prefix_length()
|
|
|
|
adj_start = max(0, start - mode_len)
|
|
adj_end = max(adj_start, end - mode_len)
|
|
|
|
if mode_len > 0 and replacement.startswith(self._input_mode):
|
|
replacement = replacement[mode_len:]
|
|
|
|
return adj_start, adj_end, replacement
|