vibe/vibe/cli/textual_ui/widgets/chat_input/text_area.py
Mathias Gesbert 3f8487f761
v2.14.0 (#743)
Co-authored-by: Alexis Tacnet <alexis@mistral.ai>
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com>
Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Val <102326092+vdeva@users.noreply.github.com>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: p.vezia <166131032+le-codeur-rapide@users.noreply.github.com>
Co-authored-by: Hiba Chaabnia <Hiba-Chaabnia@users.noreply.github.com>
Co-authored-by: Nikhil Bhima <nikhilbhima@users.noreply.github.com>
Co-authored-by: Nkipohcs <Nkipohcs@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
2026-06-04 18:26:35 +02:00

445 lines
15 KiB
Python

from __future__ import annotations
import time
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.commands import CommandRegistry
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.chat_input.paste_path import (
maybe_prepend_at_for_image_path,
rewrite_bare_image_paths_in_text,
)
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("shift+backspace", "delete_left", "Delete character left", show=False),
Binding("shift+delete", "delete_right", "Delete character 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,
command_registry: CommandRegistry,
voice_manager: VoiceManagerPort | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self._command_registry = command_registry
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
self._last_keystroke_time: float = 0.0
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()
async def _on_paste(self, event: events.Paste) -> None:
# Best-effort: terminals that emit bracketed paste sequences will
# land here, and we can rewrite event.text directly. event.stop()
# prevents App.on_event from auto-forwarding the Paste back to the
# focused widget (which would otherwise dispatch this handler a
# second time and double-insert). TextArea._on_paste in the same
# MRO still runs inside this dispatch cycle and performs the
# single insertion using the mutated text.
event.text = maybe_prepend_at_for_image_path(event.text)
event.stop()
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:
# Fallback for terminals that deliver drag-n-drop as a bulk text
# edit rather than a Paste event (so _on_paste never fires).
# Scan the full text for any bare absolute image path token and
# rewrite it to @<path>. Idempotent: tokens already preceded by
# `@` are skipped, so this is safe to run on every change.
current = self.text
rewritten = rewrite_bare_image_paths_in_text(current)
if rewritten != current:
self.text = rewritten
last_line = rewritten.rsplit("\n", 1)[-1]
self.move_cursor((rewritten.count("\n"), len(last_line)))
return
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:
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
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
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
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 in {"backspace", "shift+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._command_registry.has_command("teleport"):
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