mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-08-27 02:41:20 -04:00
2203 lines
82 KiB
Python
2203 lines
82 KiB
Python
"""Chat input widget for deepagents-code with autocomplete and history support."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import logging
|
|
import time
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any, ClassVar
|
|
|
|
from rich.cells import cell_len
|
|
from rich.segment import Segment
|
|
from textual.binding import Binding
|
|
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
from textual.content import Content
|
|
from textual.css.query import NoMatches
|
|
from textual.geometry import Offset
|
|
from textual.message import Message
|
|
from textual.reactive import reactive
|
|
from textual.strip import Strip
|
|
from textual.widgets import Static, TextArea
|
|
|
|
from deepagents_code import theme
|
|
from deepagents_code.command_registry import SLASH_COMMANDS, CommandEntry
|
|
from deepagents_code.config import (
|
|
MODE_DISPLAY_GLYPHS,
|
|
MODE_PREFIXES,
|
|
detect_mode_prefix,
|
|
is_ascii_mode,
|
|
)
|
|
from deepagents_code.input import IMAGE_PLACEHOLDER_PATTERN, VIDEO_PLACEHOLDER_PATTERN
|
|
from deepagents_code.widgets.autocomplete import (
|
|
CompletionResult,
|
|
FuzzyFileController,
|
|
MultiCompletionManager,
|
|
SlashCommandController,
|
|
)
|
|
from deepagents_code.widgets.history import HistoryManager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _default_history_path() -> Path:
|
|
"""Return the default history file path.
|
|
|
|
Extracted as a function so tests can monkeypatch it to a temp path,
|
|
preventing test runs from polluting `~/.deepagents/.state/history.jsonl`.
|
|
"""
|
|
from deepagents_code.model_config import DEFAULT_STATE_DIR
|
|
|
|
return DEFAULT_STATE_DIR / "history.jsonl"
|
|
|
|
|
|
_PASTE_BURST_CHAR_GAP_SECONDS = 0.03
|
|
"""Maximum time between chars to treat input as a paste-like burst."""
|
|
|
|
_PASTE_BURST_FLUSH_DELAY_SECONDS = 0.08
|
|
"""Idle timeout before flushing buffered burst text."""
|
|
|
|
_PASTE_BURST_START_CHARS = {"'", '"'}
|
|
"""Characters that can start dropped-path payloads."""
|
|
|
|
_BACKSLASH_ENTER_GAP_SECONDS = 0.15
|
|
"""Maximum gap between a `\\` key and a following `enter` key to treat the
|
|
pair as a terminal-emitted shift+enter sequence.
|
|
|
|
Some terminals (e.g. VSCode's built-in terminal) send a literal backslash
|
|
followed by enter when the user presses shift+enter. The gap is
|
|
generous (150 ms) because the terminal emits both characters nearly
|
|
simultaneously; a human deliberately typing `\\` then pressing Enter would
|
|
have a much larger gap."""
|
|
|
|
if TYPE_CHECKING:
|
|
from textual import events
|
|
from textual.app import ComposeResult
|
|
from textual.events import Click
|
|
from textual.timer import Timer
|
|
|
|
from deepagents_code.input import MediaTracker, ParsedPastedPathPayload
|
|
|
|
|
|
class CompletionOption(Static):
|
|
"""A clickable completion option in the autocomplete popup."""
|
|
|
|
DEFAULT_CSS = """
|
|
CompletionOption {
|
|
height: 1;
|
|
padding: 0 1;
|
|
}
|
|
|
|
CompletionOption:hover {
|
|
background: $surface-lighten-1;
|
|
}
|
|
|
|
CompletionOption.completion-option-selected {
|
|
background: $primary;
|
|
color: $background;
|
|
text-style: bold;
|
|
}
|
|
|
|
CompletionOption.completion-option-selected:hover {
|
|
background: $primary-lighten-1;
|
|
}
|
|
"""
|
|
|
|
class Clicked(Message):
|
|
"""Message sent when a completion option is clicked."""
|
|
|
|
def __init__(self, index: int) -> None:
|
|
"""Initialize with the clicked option index."""
|
|
super().__init__()
|
|
self.index = index
|
|
|
|
def __init__(
|
|
self,
|
|
label: str,
|
|
description: str,
|
|
index: int,
|
|
is_selected: bool = False,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
"""Initialize the completion option.
|
|
|
|
Args:
|
|
label: The main label text (e.g., command name or file path)
|
|
description: Secondary description text
|
|
index: Index of this option in the suggestions list
|
|
is_selected: Whether this option is currently selected
|
|
**kwargs: Additional arguments for parent
|
|
"""
|
|
super().__init__(**kwargs)
|
|
self._label = label
|
|
self._description = description
|
|
self._index = index
|
|
self._is_selected = is_selected
|
|
|
|
def on_mount(self) -> None:
|
|
"""Set up the option display on mount."""
|
|
self._update_display()
|
|
|
|
def _update_display(self) -> None:
|
|
"""Update the display text and styling."""
|
|
display_label = self._label.removeprefix("/")
|
|
if self._description:
|
|
content = Content.from_markup(
|
|
"[bold]$label[/bold] [dim]$desc[/dim]",
|
|
label=display_label,
|
|
desc=self._description,
|
|
)
|
|
else:
|
|
content = Content.from_markup("[bold]$label[/bold]", label=display_label)
|
|
|
|
self.update(content)
|
|
|
|
if self._is_selected:
|
|
self.add_class("completion-option-selected")
|
|
else:
|
|
self.remove_class("completion-option-selected")
|
|
|
|
def set_selected(self, *, selected: bool) -> None:
|
|
"""Update the selected state of this option."""
|
|
if self._is_selected != selected:
|
|
self._is_selected = selected
|
|
self._update_display()
|
|
|
|
def set_content(
|
|
self, label: str, description: str, index: int, *, is_selected: bool
|
|
) -> None:
|
|
"""Replace label, description, index, and selection in-place."""
|
|
self._label = label
|
|
self._description = description
|
|
self._index = index
|
|
self._is_selected = is_selected
|
|
self._update_display()
|
|
|
|
def on_click(self, event: Click) -> None:
|
|
"""Handle click on this option."""
|
|
event.stop()
|
|
self.post_message(self.Clicked(self._index))
|
|
|
|
|
|
class CompletionPopup(VerticalScroll):
|
|
"""Popup widget that displays completion suggestions as clickable options."""
|
|
|
|
DEFAULT_CSS = """
|
|
CompletionPopup {
|
|
display: none;
|
|
height: auto;
|
|
max-height: 12;
|
|
}
|
|
"""
|
|
|
|
class OptionClicked(Message):
|
|
"""Message sent when a completion option is clicked."""
|
|
|
|
def __init__(self, index: int) -> None:
|
|
"""Initialize with the clicked option index."""
|
|
super().__init__()
|
|
self.index = index
|
|
|
|
def __init__(self, **kwargs: Any) -> None:
|
|
"""Initialize the completion popup."""
|
|
super().__init__(**kwargs)
|
|
self.can_focus = False
|
|
self._options: list[CompletionOption] = []
|
|
self._selected_index = 0
|
|
self._pending_suggestions: list[tuple[str, str]] = []
|
|
self._pending_selected: int = 0
|
|
self._rebuild_generation: int = 0
|
|
|
|
def update_suggestions(
|
|
self, suggestions: list[tuple[str, str]], selected_index: int
|
|
) -> None:
|
|
"""Update the popup with new suggestions."""
|
|
if not suggestions:
|
|
self.hide()
|
|
return
|
|
|
|
self._selected_index = selected_index
|
|
self._pending_suggestions = suggestions
|
|
self._pending_selected = selected_index
|
|
# Increment generation so stale callbacks from prior calls are skipped.
|
|
self._rebuild_generation += 1
|
|
gen = self._rebuild_generation
|
|
# show() deferred to _rebuild_options to avoid a flash of stale content.
|
|
self.call_after_refresh(lambda: self._rebuild_options(gen))
|
|
|
|
async def _rebuild_options(self, generation: int) -> None:
|
|
"""Rebuild option widgets from pending suggestions.
|
|
|
|
Reuses existing DOM nodes where possible to avoid flicker from
|
|
a full teardown/mount cycle while the popup is visible.
|
|
|
|
Args:
|
|
generation: Caller's generation counter; skipped if superseded.
|
|
"""
|
|
if generation != self._rebuild_generation:
|
|
return
|
|
|
|
suggestions = self._pending_suggestions
|
|
selected_index = self._pending_selected
|
|
|
|
if not suggestions:
|
|
self.hide()
|
|
return
|
|
|
|
existing = len(self._options)
|
|
needed = len(suggestions)
|
|
|
|
# Update existing widgets in-place
|
|
for i in range(min(existing, needed)):
|
|
label, desc = suggestions[i]
|
|
self._options[i].set_content(
|
|
label, desc, i, is_selected=(i == selected_index)
|
|
)
|
|
|
|
# DOM mutations: trim extras / mount new widgets
|
|
try:
|
|
if existing > needed:
|
|
for option in self._options[needed:]:
|
|
await option.remove()
|
|
del self._options[needed:]
|
|
|
|
if needed > existing:
|
|
new_widgets: list[CompletionOption] = []
|
|
for idx in range(existing, needed):
|
|
label, desc = suggestions[idx]
|
|
option = CompletionOption(
|
|
label=label,
|
|
description=desc,
|
|
index=idx,
|
|
is_selected=(idx == selected_index),
|
|
)
|
|
new_widgets.append(option)
|
|
self._options.extend(new_widgets)
|
|
await self.mount(*new_widgets)
|
|
except Exception:
|
|
logger.exception("Failed to rebuild completion popup; hiding to recover")
|
|
self._options = []
|
|
with contextlib.suppress(Exception):
|
|
await self.remove_children()
|
|
self.hide()
|
|
return
|
|
|
|
self.show()
|
|
|
|
if 0 <= selected_index < len(self._options):
|
|
self._options[selected_index].scroll_visible()
|
|
|
|
def update_selection(self, selected_index: int) -> None:
|
|
"""Update which option is selected without rebuilding the list."""
|
|
# Keep pending state in sync so an in-flight _rebuild_options uses
|
|
# the latest selection.
|
|
self._pending_selected = selected_index
|
|
|
|
if self._selected_index == selected_index:
|
|
return
|
|
|
|
# Deselect previous
|
|
if 0 <= self._selected_index < len(self._options):
|
|
self._options[self._selected_index].set_selected(selected=False)
|
|
|
|
# Select new
|
|
self._selected_index = selected_index
|
|
if 0 <= selected_index < len(self._options):
|
|
self._options[selected_index].set_selected(selected=True)
|
|
self._options[selected_index].scroll_visible()
|
|
|
|
def on_completion_option_clicked(self, event: CompletionOption.Clicked) -> None:
|
|
"""Handle click on a completion option."""
|
|
event.stop()
|
|
self.post_message(self.OptionClicked(event.index))
|
|
|
|
def hide(self) -> None:
|
|
"""Hide the popup."""
|
|
self._pending_suggestions = []
|
|
self._rebuild_generation += 1 # Cancel any in-flight rebuild
|
|
self.styles.display = "none" # type: ignore[assignment] # Textual accepts string display values at runtime
|
|
|
|
def show(self) -> None:
|
|
"""Show the popup."""
|
|
self.styles.display = "block"
|
|
|
|
|
|
class ChatTextArea(TextArea):
|
|
"""TextArea subclass with custom key handling for chat input."""
|
|
|
|
BINDINGS: ClassVar[list[Binding]] = [
|
|
Binding(
|
|
"shift+enter,alt+enter,ctrl+enter,ctrl+j",
|
|
"insert_newline",
|
|
"New Line",
|
|
show=False,
|
|
priority=True,
|
|
),
|
|
Binding(
|
|
"ctrl+backspace,alt+backspace",
|
|
"delete_word_left",
|
|
"Delete left to start of word",
|
|
show=False,
|
|
),
|
|
]
|
|
"""Key bindings for the chat text area.
|
|
|
|
These are the single source of truth for shortcut keys. `_NEWLINE_KEYS`
|
|
is derived from this list so that `_on_key` stays in sync automatically.
|
|
"""
|
|
|
|
_NEWLINE_KEYS: ClassVar[frozenset[str]] = frozenset(
|
|
key
|
|
for b in BINDINGS
|
|
if b.action == "insert_newline"
|
|
for key in b.key.split(",")
|
|
)
|
|
"""Flattened set of keys that insert a newline, derived from `BINDINGS`."""
|
|
|
|
_skip_history_change_events: int
|
|
"""Counter incremented before a history-driven text replacement so the
|
|
resulting `TextArea.Changed` event (which fires on the next message-loop
|
|
iteration) can be suppressed. `ChatInput.on_text_area_changed` decrements
|
|
the counter.
|
|
"""
|
|
|
|
class Submitted(Message):
|
|
"""Message sent when text is submitted."""
|
|
|
|
def __init__(self, value: str) -> None:
|
|
"""Initialize with submitted value."""
|
|
self.value = value
|
|
super().__init__()
|
|
|
|
class HistoryPrevious(Message):
|
|
"""Request previous history entry."""
|
|
|
|
def __init__(self, current_text: str) -> None:
|
|
"""Initialize with current text for saving."""
|
|
self.current_text = current_text
|
|
super().__init__()
|
|
|
|
class HistoryNext(Message):
|
|
"""Request next history entry."""
|
|
|
|
class PastedPaths(Message):
|
|
"""Message sent when paste payload resolves to file paths."""
|
|
|
|
def __init__(self, raw_text: str, paths: list[Path]) -> None:
|
|
"""Initialize with raw pasted text and parsed file paths."""
|
|
self.raw_text = raw_text
|
|
self.paths = paths
|
|
super().__init__()
|
|
|
|
class Typing(Message):
|
|
"""Posted when the user presses a printable key or backspace.
|
|
|
|
Relayed by `ChatInput` as `ChatInput.Typing` for the app to track
|
|
typing activity.
|
|
"""
|
|
|
|
argument_hint: reactive[str] = reactive("")
|
|
"""Inline slash-command argument hint rendered at the end of the line."""
|
|
|
|
def __init__(self, **kwargs: Any) -> None:
|
|
"""Initialize the chat text area."""
|
|
# Remove placeholder if passed, TextArea doesn't support it the same way
|
|
kwargs.pop("placeholder", None)
|
|
super().__init__(**kwargs)
|
|
self._skip_history_change_events = 0
|
|
self._completion_active = False
|
|
# Buffer quote-prefixed high-frequency key bursts from terminals that
|
|
# emulate paste via rapid key events instead of dispatching a paste
|
|
# event.
|
|
self._paste_burst_buffer = ""
|
|
self._paste_burst_last_char_time: float | None = None
|
|
self._paste_burst_timer: Timer | None = None
|
|
# See _BACKSLASH_ENTER_GAP_SECONDS for context.
|
|
self._backslash_pending_time: float | None = None
|
|
|
|
def render_line(self, y: int) -> Strip:
|
|
"""Render a single line, appending any argument hint at line end.
|
|
|
|
The built-in `TextArea.suggestion` renders at the cursor position,
|
|
but slash-command argument hints should stay attached to the end of the
|
|
command text regardless of cursor movement.
|
|
|
|
Args:
|
|
y: Y Coordinate of line relative to the widget region.
|
|
|
|
Returns:
|
|
A rendered line.
|
|
"""
|
|
strip = super().render_line(y)
|
|
if not self._should_render_argument_hint():
|
|
return strip
|
|
|
|
line_info = self._get_visual_line_info(y)
|
|
if line_info is None:
|
|
return strip
|
|
|
|
line_index, section_offset = line_info
|
|
if not self._is_argument_hint_section(line_index, section_offset):
|
|
return strip
|
|
|
|
content_cells = self._get_section_cell_length(line_index, section_offset)
|
|
if content_cells >= strip.cell_length:
|
|
return strip
|
|
|
|
prefix = strip.crop(0, content_cells)
|
|
suffix = strip.crop(content_cells, strip.cell_length)
|
|
suffix_width = suffix.cell_length
|
|
cursor_on_hint = self._cursor_at_argument_hint_anchor(line_index)
|
|
if cursor_on_hint and suffix_width > 0:
|
|
suffix = suffix.crop(1, suffix.cell_length)
|
|
|
|
hint_strip = self._build_argument_hint_strip(cursor_on_hint=cursor_on_hint)
|
|
tail = Strip.join([hint_strip, suffix]).crop(0, suffix_width)
|
|
return Strip.join([prefix, tail])
|
|
|
|
def _should_render_argument_hint(self) -> bool:
|
|
"""Return whether the inline argument hint should be rendered."""
|
|
return bool(
|
|
self.argument_hint and (self.has_focus or not self.hide_suggestion_on_blur)
|
|
)
|
|
|
|
def _get_visual_line_info(self, y: int) -> tuple[int, int] | None:
|
|
"""Map a widget-relative y coordinate to wrapped line metadata.
|
|
|
|
Returns:
|
|
Tuple of `(line_index, section_offset)` for the wrapped line at `y`,
|
|
otherwise `None` when `y` is outside the wrapped document.
|
|
"""
|
|
_scroll_x, scroll_y = self.scroll_offset
|
|
absolute_y = scroll_y + y
|
|
# Private Textual API (verified against textual 3.x); revisit on
|
|
# major Textual upgrades.
|
|
try:
|
|
offset_map = self.wrapped_document._offset_to_line_info
|
|
except AttributeError:
|
|
logger.warning(
|
|
"WrappedDocument._offset_to_line_info not found; "
|
|
"argument hint rendering disabled (Textual API change?)"
|
|
)
|
|
return None
|
|
if absolute_y < 0 or absolute_y >= len(offset_map):
|
|
return None
|
|
entry = offset_map[absolute_y]
|
|
expected_length = 2 # (line_index, section_offset)
|
|
if not isinstance(entry, tuple) or len(entry) != expected_length:
|
|
logger.warning("Unexpected offset_map entry: %r", entry)
|
|
return None
|
|
return entry
|
|
|
|
def _is_argument_hint_section(self, line_index: int, section_offset: int) -> bool:
|
|
"""Return whether a wrapped section owns the end-of-line hint."""
|
|
if line_index != self.document.line_count - 1:
|
|
return False
|
|
return section_offset == len(self.wrapped_document.get_offsets(line_index))
|
|
|
|
def _get_section_cell_length(self, line_index: int, section_offset: int) -> int:
|
|
"""Return the rendered cell width of a wrapped text section."""
|
|
wrapped_sections = self.wrapped_document.get_sections(line_index)
|
|
if section_offset < 0 or section_offset >= len(wrapped_sections):
|
|
return 0
|
|
section_text = wrapped_sections[section_offset].expandtabs(self.indent_width)
|
|
return cell_len(section_text)
|
|
|
|
def _cursor_at_argument_hint_anchor(self, line_index: int) -> bool:
|
|
"""Return whether the cursor currently sits on the hint anchor."""
|
|
if not self._draw_cursor or not self.show_cursor or not self.has_focus:
|
|
return False
|
|
cursor_row, cursor_column = self.selection.end
|
|
if cursor_row != line_index:
|
|
return False
|
|
return cursor_column == len(self.document.get_line(line_index))
|
|
|
|
def _build_argument_hint_strip(self, *, cursor_on_hint: bool) -> Strip:
|
|
"""Build a strip for the current argument hint text.
|
|
|
|
Returns:
|
|
A `Strip` containing the current argument hint, with cursor styling
|
|
applied to the first hint character when the cursor sits on the
|
|
hint anchor.
|
|
"""
|
|
hint = self.argument_hint
|
|
hint_style = self.get_component_rich_style("text-area--suggestion")
|
|
if not cursor_on_hint or not hint:
|
|
return Strip([Segment(hint, hint_style)], cell_length=cell_len(hint))
|
|
|
|
ta_theme = self._theme
|
|
cursor_style = ta_theme.cursor_style if ta_theme else None
|
|
first_style = hint_style if cursor_style is None else hint_style + cursor_style
|
|
segments = [Segment(hint[0], first_style)]
|
|
if len(hint) > 1:
|
|
segments.append(Segment(hint[1:], hint_style))
|
|
return Strip(segments, cell_length=cell_len(hint))
|
|
|
|
def scroll_cursor_visible(
|
|
self, center: bool = False, animate: bool = False
|
|
) -> Offset:
|
|
"""Scroll to make the cursor visible, guarding against cursor/document desync.
|
|
|
|
Textual's `WrappedDocument.location_to_offset` has an off-by-one in its
|
|
line-index clamp (`len(...)` instead of `len(...) - 1`). When a reactive
|
|
watcher (e.g. `_watch_show_vertical_scrollbar`) fires between a document
|
|
replacement and cursor update, the stale cursor location triggers a
|
|
`ValueError`. Guard here since `scroll_cursor_visible` is the sole
|
|
caller of `_recompute_cursor_offset`.
|
|
|
|
Args:
|
|
center: Whether the cursor should be scrolled to the center.
|
|
animate: Whether to animate while scrolling.
|
|
|
|
Returns:
|
|
The scroll offset applied, or `Offset(0, 0)` on desync.
|
|
"""
|
|
try:
|
|
return super().scroll_cursor_visible(center=center, animate=animate)
|
|
except (
|
|
ValueError
|
|
): # WrappedDocument.get_offsets off-by-one clamp in location_to_offset
|
|
logger.warning(
|
|
"Cursor/document desync in scroll_cursor_visible "
|
|
"(cursor=%s, doc_lines=%d); skipping scroll",
|
|
self.cursor_location,
|
|
self.document.line_count,
|
|
)
|
|
return Offset(0, 0)
|
|
|
|
def set_app_focus(self, *, has_focus: bool) -> None:
|
|
"""Set whether the app should show the cursor as active.
|
|
|
|
Args:
|
|
has_focus: Whether the app input should be focused.
|
|
"""
|
|
self._backslash_pending_time = None
|
|
if has_focus and not self.has_focus:
|
|
self.call_after_refresh(self.focus)
|
|
|
|
def set_completion_active(self, *, active: bool) -> None:
|
|
"""Set whether completion suggestions are visible."""
|
|
self._completion_active = active
|
|
|
|
def action_insert_newline(self) -> None:
|
|
"""Insert a newline character."""
|
|
self.insert("\n")
|
|
# TextArea's built-in cursor-visible scroll runs before the widget
|
|
# reflows for the new row, so it sees stale dimensions and is a no-op
|
|
# when the cursor would land below `max-height`. Re-issue after
|
|
# refresh so it stays in view.
|
|
self.call_after_refresh(self.scroll_cursor_visible)
|
|
|
|
def _cursor_at_visual_top(self) -> bool:
|
|
"""Return whether the cursor cannot move up further."""
|
|
try:
|
|
return self.get_cursor_up_location() == self.cursor_location
|
|
except ValueError:
|
|
# `WrappedDocument.location_to_offset` can raise during a brief
|
|
# text/cursor desync window (see `scroll_cursor_visible` guard).
|
|
# Treat as "not at top" so TextArea moves the cursor instead of
|
|
# firing history navigation on a transient state.
|
|
return False
|
|
|
|
def _cursor_at_visual_bottom(self) -> bool:
|
|
"""Return whether the cursor cannot move down further."""
|
|
try:
|
|
return self.get_cursor_down_location() == self.cursor_location
|
|
except ValueError:
|
|
return False
|
|
|
|
def action_cursor_up(self, select: bool = False) -> None:
|
|
"""Move cursor up, or navigate to the previous history entry at top.
|
|
|
|
When `select` is true or a selection is active, falls through to
|
|
TextArea's default so shift+up extends selection rather than
|
|
triggering navigation. History fires only when moving up cannot
|
|
advance the cursor — handled via the wrapped-document navigator so
|
|
soft-wrap is respected.
|
|
"""
|
|
if not select and self.selection.is_empty and self._cursor_at_visual_top():
|
|
self.post_message(self.HistoryPrevious(self.text))
|
|
return
|
|
super().action_cursor_up(select)
|
|
|
|
def action_cursor_down(self, select: bool = False) -> None:
|
|
"""Move cursor down, or navigate to the next history entry at bottom.
|
|
|
|
Mirrors `action_cursor_up`: defers to TextArea on selection or when
|
|
the cursor still has somewhere to move; otherwise fires history.
|
|
"""
|
|
if not select and self.selection.is_empty and self._cursor_at_visual_bottom():
|
|
self.post_message(self.HistoryNext())
|
|
return
|
|
super().action_cursor_down(select)
|
|
|
|
def _cancel_paste_burst_timer(self) -> None:
|
|
"""Cancel any scheduled paste-burst flush timer."""
|
|
if self._paste_burst_timer is None:
|
|
return
|
|
self._paste_burst_timer.stop()
|
|
self._paste_burst_timer = None
|
|
|
|
def _schedule_paste_burst_flush(self) -> None:
|
|
"""Schedule idle-time flush for buffered paste-burst text."""
|
|
self._cancel_paste_burst_timer()
|
|
self._paste_burst_timer = self.set_timer(
|
|
_PASTE_BURST_FLUSH_DELAY_SECONDS, self._flush_paste_burst
|
|
)
|
|
|
|
def _start_paste_burst(self, char: str, now: float) -> None:
|
|
"""Start buffering a paste-like keystroke burst."""
|
|
self._paste_burst_buffer = char
|
|
self._paste_burst_last_char_time = now
|
|
self._schedule_paste_burst_flush()
|
|
|
|
def _append_paste_burst(self, text: str, now: float) -> None:
|
|
"""Append text to an active paste-burst buffer."""
|
|
if not self._paste_burst_buffer:
|
|
self._start_paste_burst(text, now)
|
|
return
|
|
self._paste_burst_buffer += text
|
|
self._paste_burst_last_char_time = now
|
|
self._schedule_paste_burst_flush()
|
|
|
|
def _should_start_paste_burst(self, char: str) -> bool:
|
|
"""Return whether a keypress should start paste-burst buffering.
|
|
|
|
Restricting to quote-prefixed input at an empty cursor reduces false
|
|
positives for normal typing and slash-command entry.
|
|
"""
|
|
if char not in _PASTE_BURST_START_CHARS:
|
|
return False
|
|
if self.text or not self.selection.is_empty:
|
|
return False
|
|
row, col = self.cursor_location
|
|
return row == 0 and col == 0
|
|
|
|
async def _flush_paste_burst(self) -> None:
|
|
"""Flush buffered burst text through dropped-path parsing.
|
|
|
|
When parsing fails, the buffered text is inserted unchanged so regular
|
|
typing behavior is preserved.
|
|
"""
|
|
payload = self._paste_burst_buffer
|
|
self._paste_burst_buffer = ""
|
|
self._paste_burst_last_char_time = None
|
|
self._cancel_paste_burst_timer()
|
|
if not payload:
|
|
return
|
|
|
|
from deepagents_code.input import parse_pasted_path_payload
|
|
|
|
try:
|
|
parsed = await asyncio.to_thread(parse_pasted_path_payload, payload)
|
|
except Exception: # noqa: BLE001 # Treat thread failure as non-path text
|
|
parsed = None
|
|
if parsed is not None:
|
|
self.post_message(self.PastedPaths(payload, parsed.paths))
|
|
return
|
|
|
|
self.insert(payload)
|
|
|
|
def _delete_preceding_backslash(self) -> bool:
|
|
"""Delete the backslash character immediately before the cursor.
|
|
|
|
Caller must ensure a backslash is expected at this position. The
|
|
method verifies the character before deleting it.
|
|
|
|
Returns:
|
|
`True` if a backslash was found and deleted, `False` otherwise.
|
|
"""
|
|
row, col = self.cursor_location
|
|
if col > 0:
|
|
start = (row, col - 1)
|
|
if self.document.get_text_range(start, self.cursor_location) == "\\":
|
|
self.delete(start, self.cursor_location)
|
|
return True
|
|
elif row > 0:
|
|
prev_line = self.document.get_line(row - 1)
|
|
start = (row - 1, len(prev_line) - 1)
|
|
end = (row - 1, len(prev_line))
|
|
if self.document.get_text_range(start, end) == "\\":
|
|
self.delete(start, self.cursor_location)
|
|
return True
|
|
return False
|
|
|
|
async def _on_key(self, event: events.Key) -> None:
|
|
"""Handle key events."""
|
|
# VS Code 1.110 incorrectly sends space as a CSI u escape code
|
|
# (`\x1b[32u`) instead of a plain ` ` character. Textual parses
|
|
# this as Key(key='space', character=None, is_printable=False), so
|
|
# the TextArea never inserts the space. Per the kitty keyboard
|
|
# protocol spec, keys that generate text (like space) should NOT
|
|
# use CSI u encoding — VS Code is the outlier here.
|
|
#
|
|
# This workaround should be safe to keep indefinitely: once VS Code or
|
|
# Textual fixes the issue upstream, `character` will be `' '` and
|
|
# this branch simply won't match.
|
|
#
|
|
# Upstream: https://github.com/Textualize/textual/issues/6408
|
|
if event.key == "space" and event.character is None:
|
|
event.prevent_default()
|
|
event.stop()
|
|
self.insert(" ")
|
|
self.post_message(self.Typing())
|
|
return
|
|
|
|
now = time.monotonic()
|
|
|
|
# Signal typing activity for printable keys and backspace so the app
|
|
# can defer approval widgets while the user is actively editing.
|
|
if event.is_printable or event.key == "backspace":
|
|
self.post_message(self.Typing())
|
|
|
|
if self._paste_burst_buffer:
|
|
if event.key == "enter":
|
|
self._append_paste_burst("\n", now)
|
|
event.prevent_default()
|
|
event.stop()
|
|
return
|
|
|
|
if event.is_printable and event.character is not None:
|
|
last_time = self._paste_burst_last_char_time
|
|
if (
|
|
last_time is not None
|
|
and (now - last_time) <= _PASTE_BURST_CHAR_GAP_SECONDS
|
|
):
|
|
self._append_paste_burst(event.character, now)
|
|
event.prevent_default()
|
|
event.stop()
|
|
return
|
|
|
|
await self._flush_paste_burst()
|
|
|
|
if (
|
|
event.is_printable
|
|
and event.character is not None
|
|
and self._should_start_paste_burst(event.character)
|
|
):
|
|
self._start_paste_burst(event.character, now)
|
|
event.prevent_default()
|
|
event.stop()
|
|
return
|
|
|
|
# Some terminals (e.g. VSCode built-in) send a literal backslash
|
|
# followed by enter for shift+enter. When enter arrives shortly
|
|
# after a backslash, delete the backslash and insert a newline.
|
|
if (
|
|
event.key == "enter"
|
|
and not self._completion_active
|
|
and self._backslash_pending_time is not None
|
|
and (now - self._backslash_pending_time) <= _BACKSLASH_ENTER_GAP_SECONDS
|
|
):
|
|
self._backslash_pending_time = None
|
|
if self._delete_preceding_backslash():
|
|
event.prevent_default()
|
|
event.stop()
|
|
self.action_insert_newline()
|
|
return
|
|
self._backslash_pending_time = None
|
|
|
|
if event.key == "backslash" and event.character == "\\":
|
|
self._backslash_pending_time = now
|
|
|
|
# Modifier+Enter inserts newline — keys derived from BINDINGS
|
|
if event.key in self._NEWLINE_KEYS:
|
|
event.prevent_default()
|
|
event.stop()
|
|
self.action_insert_newline()
|
|
return
|
|
|
|
if event.key == "backspace" and self._delete_image_placeholder(backwards=True):
|
|
event.prevent_default()
|
|
event.stop()
|
|
return
|
|
|
|
if event.key == "delete" and self._delete_image_placeholder(backwards=False):
|
|
event.prevent_default()
|
|
event.stop()
|
|
return
|
|
|
|
# If completion is active, let parent handle navigation keys.
|
|
# Space is included so that slash-command completion can accept the
|
|
# selected suggestion via the same code path as Tab (avoiding a
|
|
# frame-lag between the popup hiding and the argument hint appearing).
|
|
# When the active controller ignores the space (e.g. file completion),
|
|
# ChatInput.on_key inserts it manually.
|
|
if self._completion_active and event.key in {
|
|
"up",
|
|
"down",
|
|
"tab",
|
|
"enter",
|
|
"space",
|
|
}:
|
|
# Prevent TextArea's default behavior (e.g., Enter inserting newline)
|
|
# but let event bubble to ChatInput for completion handling
|
|
event.prevent_default()
|
|
return
|
|
|
|
# Plain Enter submits
|
|
if event.key == "enter":
|
|
event.prevent_default()
|
|
event.stop()
|
|
value = self.text.strip()
|
|
if value:
|
|
self.post_message(self.Submitted(value))
|
|
return
|
|
|
|
await super()._on_key(event)
|
|
|
|
def _delete_image_placeholder(self, *, backwards: bool) -> bool:
|
|
"""Delete a full image placeholder token in one keypress.
|
|
|
|
Args:
|
|
backwards: Whether the delete action is backwards (`backspace`) or
|
|
forwards (`delete`).
|
|
|
|
Returns:
|
|
`True` when a placeholder token was deleted.
|
|
"""
|
|
if not self.text or not self.selection.is_empty:
|
|
return False
|
|
|
|
cursor_offset = self.document.get_index_from_location(self.cursor_location) # type: ignore[attr-defined] # Document has this method; DocumentBase stub is narrower
|
|
span = self._find_image_placeholder_span(cursor_offset, backwards=backwards)
|
|
if span is None:
|
|
return False
|
|
|
|
start, end = span
|
|
start_location = self.document.get_location_from_index(start) # type: ignore[attr-defined] # Document has this method; DocumentBase stub is narrower
|
|
end_location = self.document.get_location_from_index(end) # type: ignore[attr-defined]
|
|
self.delete(start_location, end_location)
|
|
self.move_cursor(start_location)
|
|
return True
|
|
|
|
def _find_image_placeholder_span(
|
|
self, cursor_offset: int, *, backwards: bool
|
|
) -> tuple[int, int] | None:
|
|
"""Return placeholder span to delete for current cursor and key direction.
|
|
|
|
Args:
|
|
cursor_offset: Character offset of the cursor from the start of text.
|
|
backwards: Whether the delete action is backwards (backspace) or
|
|
forwards (delete).
|
|
"""
|
|
text = self.text
|
|
# Check both image and video placeholders
|
|
for pattern in (IMAGE_PLACEHOLDER_PATTERN, VIDEO_PLACEHOLDER_PATTERN):
|
|
for match in pattern.finditer(text):
|
|
start, end = match.span()
|
|
if backwards:
|
|
# Cursor is inside token or right after a trailing space inserted
|
|
# with the token.
|
|
if start < cursor_offset <= end:
|
|
return start, end
|
|
if cursor_offset > 0:
|
|
previous_index = cursor_offset - 1
|
|
if (
|
|
previous_index < len(text)
|
|
and previous_index == end
|
|
and text[previous_index].isspace()
|
|
):
|
|
return start, cursor_offset
|
|
elif start <= cursor_offset < end:
|
|
return start, end
|
|
return None
|
|
|
|
async def _on_paste(self, event: events.Paste) -> None:
|
|
"""Handle paste events and detect dragged file paths."""
|
|
self._backslash_pending_time = None
|
|
if self._paste_burst_buffer:
|
|
await self._flush_paste_burst()
|
|
|
|
from deepagents_code.input import parse_pasted_path_payload
|
|
|
|
try:
|
|
parsed = await asyncio.to_thread(parse_pasted_path_payload, event.text)
|
|
except Exception: # noqa: BLE001 # Treat thread failure as non-path text
|
|
parsed = None
|
|
if parsed is None:
|
|
# Don't call super() here — Textual's MRO dispatch already calls
|
|
# TextArea._on_paste after this handler returns. Calling super()
|
|
# would insert the text a second time, duplicating the paste.
|
|
return
|
|
|
|
event.prevent_default()
|
|
event.stop()
|
|
self.post_message(self.PastedPaths(event.text, parsed.paths))
|
|
|
|
def set_text_from_history(self, text: str, *, cursor_at_end: bool = True) -> None:
|
|
"""Set text from history navigation.
|
|
|
|
Args:
|
|
text: The history entry text to load.
|
|
cursor_at_end: Place the cursor at the end of the loaded text
|
|
(use for down-navigation, so the next down press continues
|
|
forward through history). When `False`, place at the start
|
|
so the next up press continues backward. Defaults to `True`
|
|
to preserve historical cursor-at-end behavior for callers
|
|
that don't specify a direction.
|
|
"""
|
|
self._paste_burst_buffer = ""
|
|
self._paste_burst_last_char_time = None
|
|
self._cancel_paste_burst_timer()
|
|
self._backslash_pending_time = None
|
|
self._skip_history_change_events += 1
|
|
self.text = text
|
|
if cursor_at_end:
|
|
lines = text.split("\n")
|
|
last_row = len(lines) - 1
|
|
self.move_cursor((last_row, len(lines[last_row])))
|
|
else:
|
|
self.move_cursor((0, 0))
|
|
|
|
def clear_text(self) -> None:
|
|
"""Clear the text area."""
|
|
# Increment (not reset) so any pending Changed event from a prior
|
|
# set_text_from_history is still suppressed, plus one for the
|
|
# self.text = "" assignment below.
|
|
self._skip_history_change_events += 1
|
|
self._paste_burst_buffer = ""
|
|
self._paste_burst_last_char_time = None
|
|
self._cancel_paste_burst_timer()
|
|
self._backslash_pending_time = None
|
|
self.text = ""
|
|
self.move_cursor((0, 0))
|
|
|
|
|
|
class _CompletionViewAdapter:
|
|
"""Translate completion-space replacements to text-area coordinates."""
|
|
|
|
def __init__(self, chat_input: ChatInput) -> None:
|
|
"""Initialize adapter with its owning `ChatInput`."""
|
|
self._chat_input = chat_input
|
|
|
|
def render_completion_suggestions(
|
|
self, suggestions: list[tuple[str, str]], selected_index: int
|
|
) -> None:
|
|
"""Delegate suggestion rendering to `ChatInput`."""
|
|
self._chat_input.render_completion_suggestions(suggestions, selected_index)
|
|
|
|
def clear_completion_suggestions(self) -> None:
|
|
"""Delegate completion clearing to `ChatInput`."""
|
|
self._chat_input.clear_completion_suggestions()
|
|
|
|
def replace_completion_range(self, start: int, end: int, replacement: str) -> None:
|
|
"""Map completion indices to text-area indices before replacing text."""
|
|
# The completion controller returns the full command name (e.g.
|
|
# "/remember") in completion space, but the TextArea only contains
|
|
# text after the virtual mode prefix (e.g. "/" in command mode).
|
|
# Strip the prefix to avoid double-insertion.
|
|
prefix = MODE_PREFIXES.get(self._chat_input.mode, "")
|
|
if prefix and replacement.startswith(prefix):
|
|
replacement = replacement[len(prefix) :]
|
|
self._chat_input.replace_completion_range(
|
|
self._chat_input._completion_index_to_text_index(start),
|
|
self._chat_input._completion_index_to_text_index(end),
|
|
replacement,
|
|
)
|
|
|
|
|
|
class ChatInput(Vertical):
|
|
"""Chat input widget with prompt, multi-line text, autocomplete, and history.
|
|
|
|
Features:
|
|
- Multi-line input with TextArea
|
|
- Enter to submit, modifier key for newlines (see `config.newline_shortcut`)
|
|
- Up/Down arrows for command history at input boundaries (start/end of text)
|
|
- Autocomplete for @ (files) and / (commands)
|
|
"""
|
|
|
|
DEFAULT_CSS = """
|
|
ChatInput {
|
|
height: auto;
|
|
min-height: 3;
|
|
max-height: 25;
|
|
padding: 0;
|
|
background: $surface;
|
|
border: solid $primary;
|
|
}
|
|
|
|
ChatInput.mode-shell {
|
|
border: solid $mode-bash;
|
|
}
|
|
|
|
ChatInput.mode-command {
|
|
border: solid $mode-command;
|
|
}
|
|
|
|
ChatInput.mode-shell-incognito {
|
|
border: solid $mode-incognito;
|
|
border-title-color: $mode-incognito;
|
|
border-title-style: bold;
|
|
}
|
|
|
|
ChatInput .input-row {
|
|
height: auto;
|
|
width: 100%;
|
|
}
|
|
|
|
ChatInput .input-prompt {
|
|
width: 3;
|
|
height: 1;
|
|
padding: 0 1;
|
|
color: $primary;
|
|
text-style: bold;
|
|
}
|
|
|
|
ChatInput.mode-shell .input-prompt {
|
|
color: $mode-bash;
|
|
}
|
|
|
|
ChatInput.mode-command .input-prompt {
|
|
color: $mode-command;
|
|
}
|
|
|
|
ChatInput.mode-shell-incognito .input-prompt {
|
|
color: $mode-incognito;
|
|
}
|
|
|
|
ChatInput ChatTextArea {
|
|
width: 1fr;
|
|
height: auto;
|
|
min-height: 1;
|
|
max-height: 8;
|
|
border: none;
|
|
background: transparent;
|
|
padding: 0;
|
|
}
|
|
|
|
ChatInput ChatTextArea:focus {
|
|
border: none;
|
|
}
|
|
"""
|
|
"""Border and prompt glyph change color per mode for immediate visual feedback."""
|
|
|
|
class Submitted(Message):
|
|
"""Message sent when input is submitted."""
|
|
|
|
def __init__(self, value: str, mode: str = "normal") -> None:
|
|
"""Initialize with value and mode."""
|
|
super().__init__()
|
|
self.value = value
|
|
self.mode = mode
|
|
|
|
class ModeChanged(Message):
|
|
"""Message sent when input mode changes."""
|
|
|
|
def __init__(self, mode: str) -> None:
|
|
"""Initialize with new mode."""
|
|
super().__init__()
|
|
self.mode = mode
|
|
|
|
class Typing(Message):
|
|
"""Posted when the user presses a printable key or backspace in the input.
|
|
|
|
The app uses this to delay approval widgets while the user is actively
|
|
typing, preventing accidental key presses (e.g. `y`, `n`) from
|
|
triggering approval decisions.
|
|
"""
|
|
|
|
mode: reactive[str] = reactive("normal")
|
|
|
|
def __init__(
|
|
self,
|
|
cwd: str | Path | None = None,
|
|
history_file: Path | None = None,
|
|
image_tracker: MediaTracker | None = None,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
"""Initialize the chat input widget.
|
|
|
|
Args:
|
|
cwd: Current working directory for file completion
|
|
history_file: Override path for persisted input history.
|
|
Resolved by `_default_history_path()` when `None`.
|
|
image_tracker: Optional tracker for attached images
|
|
**kwargs: Additional arguments for parent
|
|
"""
|
|
super().__init__(**kwargs)
|
|
self._cwd = Path(cwd) if cwd else Path.cwd()
|
|
self._image_tracker = image_tracker
|
|
self._text_area: ChatTextArea | None = None
|
|
self._popup: CompletionPopup | None = None
|
|
self._completion_manager: MultiCompletionManager | None = None
|
|
self._completion_view: _CompletionViewAdapter | None = None
|
|
self._slash_controller: SlashCommandController | None = None
|
|
|
|
# Guard flag: set True before programmatically stripping the mode
|
|
# prefix character so the resulting text-change event does not
|
|
# re-evaluate mode.
|
|
self._stripping_prefix = False
|
|
|
|
# When the user submits, we clear the text area which fires a
|
|
# text-change event. Without this guard the tracker would see the
|
|
# now-empty text, assume all media were deleted, and discard them
|
|
# before the app has a chance to send them. Each submit bumps the
|
|
# counter by one; the next text-change event decrements it and
|
|
# skips the sync.
|
|
self._skip_media_sync_events = 0
|
|
|
|
# Number of virtual prefix characters currently injected for
|
|
# completion controller calls (0 for normal, 1 for shell/command).
|
|
self._completion_prefix_len = 0
|
|
|
|
# Guard flag: set while replacing a dropped path payload with an
|
|
# inline image placeholder so the resulting change event doesn't
|
|
# immediately recurse into the same replacement path.
|
|
self._applying_inline_path_replacement = False
|
|
|
|
# Text area content from the previous Changed event. Used to skip
|
|
# blocking filesystem path-detection on single-keystroke edits while
|
|
# still detecting replacement edits that insert a full path payload.
|
|
self._prev_text = ""
|
|
|
|
# Track current suggestions for click handling
|
|
self._current_suggestions: list[tuple[str, str]] = []
|
|
self._current_selected_index = 0
|
|
|
|
# Command name (without /) → argument hint for inline ghost text
|
|
self._argument_hints: dict[str, str] = {}
|
|
|
|
# Set up history manager
|
|
if history_file is None:
|
|
history_file = _default_history_path()
|
|
self._history = HistoryManager(history_file)
|
|
|
|
def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual widget method convention
|
|
"""Compose the chat input layout.
|
|
|
|
Yields:
|
|
Widgets for the input row and completion popup.
|
|
"""
|
|
with Horizontal(classes="input-row"):
|
|
yield Static(">", classes="input-prompt", id="prompt")
|
|
yield ChatTextArea(id="chat-input")
|
|
|
|
yield CompletionPopup(id="completion-popup")
|
|
|
|
def on_mount(self) -> None:
|
|
"""Initialize components after mount."""
|
|
if is_ascii_mode():
|
|
colors = theme.get_theme_colors(self)
|
|
self.styles.border = ("ascii", colors.primary)
|
|
|
|
self._text_area = self.query_one("#chat-input", ChatTextArea)
|
|
self._popup = self.query_one("#completion-popup", CompletionPopup)
|
|
|
|
# Both controllers implement the CompletionController protocol but have
|
|
# different concrete types; the list-item warning is a false positive.
|
|
self._completion_view = _CompletionViewAdapter(self)
|
|
self._file_controller = FuzzyFileController(
|
|
self._completion_view, cwd=self._cwd
|
|
)
|
|
self._slash_controller = SlashCommandController(
|
|
SLASH_COMMANDS, self._completion_view
|
|
)
|
|
self._completion_manager = MultiCompletionManager(
|
|
[
|
|
self._slash_controller,
|
|
self._file_controller,
|
|
] # type: ignore[list-item] # Controller types are compatible at runtime
|
|
)
|
|
|
|
self._rebuild_argument_hints(SLASH_COMMANDS)
|
|
|
|
self.run_worker(
|
|
self._file_controller.warm_cache(),
|
|
exclusive=False,
|
|
exit_on_error=False,
|
|
)
|
|
self._text_area.focus()
|
|
|
|
def update_slash_commands(self, commands: list[CommandEntry]) -> None:
|
|
"""Update the slash command controller's command list.
|
|
|
|
Called by the app after discovering skills to merge static
|
|
commands with dynamic `/skill:` entries.
|
|
|
|
Args:
|
|
commands: Full list of `CommandEntry` instances.
|
|
"""
|
|
if self._slash_controller:
|
|
self._slash_controller.update_commands(commands)
|
|
self._rebuild_argument_hints(commands)
|
|
else:
|
|
logger.warning(
|
|
"Cannot update slash commands: controller not initialized "
|
|
"(widget not yet mounted)"
|
|
)
|
|
|
|
def _rebuild_argument_hints(self, commands: list[CommandEntry]) -> None:
|
|
"""Rebuild the command-name -> argument-hint lookup.
|
|
|
|
Args:
|
|
commands: Current list of `CommandEntry` instances.
|
|
"""
|
|
self._argument_hints = {
|
|
entry.name.removeprefix("/"): entry.argument_hint
|
|
for entry in commands
|
|
if entry.argument_hint
|
|
}
|
|
|
|
def _update_argument_hint(self) -> None:
|
|
"""Show or clear inline ghost text for slash-command argument hints.
|
|
|
|
Sets `ChatTextArea.argument_hint` when the input is a known slash
|
|
command followed by a trailing space with no args typed yet. Both
|
|
spacebar and Tab completion produce this state (Tab goes through
|
|
`replace_completion_range` which appends a trailing space).
|
|
"""
|
|
if not self._text_area:
|
|
return
|
|
|
|
if self.mode == "command":
|
|
text = self._text_area.text
|
|
if text.endswith(" ") and text.count(" ") == 1:
|
|
hint = self._argument_hints.get(text[:-1], "")
|
|
if hint:
|
|
self._text_area.argument_hint = hint
|
|
return
|
|
|
|
self._text_area.argument_hint = ""
|
|
|
|
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
|
"""Detect input mode and update completions."""
|
|
text = event.text_area.text
|
|
# Drag-drop / bracketed paste arrive as one Changed event with a
|
|
# multi-character inserted span. Normal typing arrives one character at
|
|
# a time. Checking the changed span (rather than net length delta)
|
|
# preserves replacement edits where selected text is replaced by a path
|
|
# of similar length.
|
|
should_check_path_payload = self._should_check_path_payload(text)
|
|
self._prev_text = text
|
|
self._sync_media_tracker_to_text(text)
|
|
|
|
# History handlers explicitly decide mode and stripped display text.
|
|
# Skip mode detection here so recalled entries don't inherit stale mode.
|
|
if self._text_area and self._text_area._skip_history_change_events > 0:
|
|
self._text_area._skip_history_change_events -= 1
|
|
if self._completion_manager:
|
|
self._completion_manager.reset()
|
|
self.scroll_visible()
|
|
return
|
|
if self._text_area and self._text_area._skip_history_change_events < 0:
|
|
logger.warning(
|
|
"_skip_history_change_events is negative (%d); resetting to 0",
|
|
self._text_area._skip_history_change_events,
|
|
)
|
|
self._text_area._skip_history_change_events = 0
|
|
|
|
if self._applying_inline_path_replacement:
|
|
self._applying_inline_path_replacement = False
|
|
elif should_check_path_payload and self._apply_inline_dropped_path_replacement(
|
|
text
|
|
):
|
|
return
|
|
|
|
# Checked after the guards above so we skip the (potentially slow)
|
|
# filesystem lookup when the text change came from history navigation
|
|
# or prefix stripping, which never need path detection.
|
|
is_path_payload = should_check_path_payload and self._is_dropped_path_payload(
|
|
text
|
|
)
|
|
|
|
# Guard: skip mode re-detection after we programmatically stripped
|
|
# a prefix character.
|
|
if self._stripping_prefix:
|
|
self._stripping_prefix = False
|
|
elif detected_prefix := detect_mode_prefix(text):
|
|
prefix, detected = detected_prefix
|
|
strip_length = len(prefix)
|
|
if self.mode == "shell" and detected == "shell":
|
|
# First `!` was stripped on entry to shell mode, so the
|
|
# currently-visible `!` is the second bang of `!!`. Promote to
|
|
# incognito and consume it.
|
|
detected = "shell_incognito"
|
|
elif self.mode == "shell_incognito" and detected == "shell":
|
|
# Already in incognito; an extra `!` is part of the command
|
|
# body. Skip the strip-and-demote path that would otherwise
|
|
# drop us back to plain shell mode.
|
|
detected = "shell_incognito"
|
|
strip_length = 0
|
|
if prefix == "/" and is_path_payload:
|
|
# Absolute dropped paths stay normal input, not slash-command mode.
|
|
if self.mode != "normal":
|
|
self.mode = "normal"
|
|
else:
|
|
# Detected a mode-trigger prefix (e.g. "!" or "/").
|
|
# Strip it unconditionally -- even when already in the correct
|
|
# mode -- because completion controllers may write replacement
|
|
# text that re-includes the trigger character. The
|
|
# _stripping_prefix guard prevents the resulting change event
|
|
# from looping back here.
|
|
if self.mode != detected:
|
|
self.mode = detected
|
|
if strip_length:
|
|
self._strip_mode_prefix(strip_length)
|
|
# Fall through to update completion suggestions in the same
|
|
# refresh cycle as the mode/glyph change rather than waiting
|
|
# for the next text-change event caused by the prefix strip.
|
|
# Note: the strip's text-change event will also call
|
|
# on_text_changed (idempotently) since _stripping_prefix only
|
|
# skips mode detection, not the completion block below.
|
|
# Set inline argument hint before the completion manager runs so
|
|
# the suggestion is ready in the same render pass that hides the popup.
|
|
self._update_argument_hint()
|
|
|
|
# Update completion suggestions using completion-space text/cursor.
|
|
if self._completion_manager and self._text_area:
|
|
if is_path_payload:
|
|
self._completion_manager.reset()
|
|
else:
|
|
vtext, vcursor = self._completion_text_and_cursor()
|
|
self._completion_manager.on_text_changed(vtext, vcursor)
|
|
|
|
# Scroll input into view when content changes (handles text wrap)
|
|
self.scroll_visible()
|
|
|
|
def _should_check_path_payload(self, text: str) -> bool:
|
|
"""Return whether a text change may contain a pasted path payload."""
|
|
old = self._prev_text
|
|
if text == old:
|
|
return False
|
|
|
|
prefix_len = 0
|
|
max_prefix_len = min(len(old), len(text))
|
|
while prefix_len < max_prefix_len and old[prefix_len] == text[prefix_len]:
|
|
prefix_len += 1
|
|
|
|
old_suffix = len(old)
|
|
text_suffix = len(text)
|
|
while (
|
|
old_suffix > prefix_len
|
|
and text_suffix > prefix_len
|
|
and old[old_suffix - 1] == text[text_suffix - 1]
|
|
):
|
|
old_suffix -= 1
|
|
text_suffix -= 1
|
|
|
|
inserted_len = text_suffix - prefix_len
|
|
return inserted_len > 1
|
|
|
|
@staticmethod
|
|
def _parse_dropped_path_payload(
|
|
text: str, *, allow_leading_path: bool = False
|
|
) -> ParsedPastedPathPayload | None:
|
|
"""Parse dropped-path payload text through a single parser entrypoint.
|
|
|
|
Returns:
|
|
Parsed payload details, otherwise `None`.
|
|
"""
|
|
from deepagents_code.input import parse_pasted_path_payload
|
|
|
|
return parse_pasted_path_payload(text, allow_leading_path=allow_leading_path)
|
|
|
|
def _parse_dropped_path_payload_with_command_recovery(
|
|
self, text: str, *, allow_leading_path: bool = False
|
|
) -> tuple[str, ParsedPastedPathPayload | None]:
|
|
"""Parse payload and recover stripped leading slash in command mode.
|
|
|
|
Args:
|
|
text: Input text to parse.
|
|
allow_leading_path: Whether to parse leading path + suffix payloads.
|
|
|
|
Returns:
|
|
Tuple of `(candidate_text, parsed_payload)`.
|
|
"""
|
|
candidate = text
|
|
parsed = self._parse_dropped_path_payload(
|
|
text, allow_leading_path=allow_leading_path
|
|
)
|
|
if parsed is not None:
|
|
return candidate, parsed
|
|
|
|
if self.mode != "command":
|
|
return candidate, None
|
|
|
|
prefixed = f"/{text.lstrip('/')}"
|
|
parsed = self._parse_dropped_path_payload(
|
|
prefixed, allow_leading_path=allow_leading_path
|
|
)
|
|
if parsed is None:
|
|
return candidate, None
|
|
|
|
logger.debug(
|
|
"Recovering stripped absolute path; resetting mode from "
|
|
"'command' to 'normal'"
|
|
)
|
|
self.mode = "normal"
|
|
return prefixed, parsed
|
|
|
|
def _extract_leading_dropped_path_with_command_recovery(
|
|
self, text: str
|
|
) -> tuple[str, tuple[Path, int] | None]:
|
|
"""Extract a leading dropped-path token with command-mode recovery.
|
|
|
|
Args:
|
|
text: Input text to parse.
|
|
|
|
Returns:
|
|
Tuple of `(candidate_text, leading_match)`, where `leading_match` is
|
|
`(path, token_end)` when extraction succeeds, otherwise `None`.
|
|
"""
|
|
from deepagents_code.input import extract_leading_pasted_file_path
|
|
|
|
leading_match = extract_leading_pasted_file_path(text)
|
|
candidate = text
|
|
if leading_match is not None:
|
|
return candidate, leading_match
|
|
|
|
if self.mode != "command":
|
|
return candidate, None
|
|
|
|
prefixed = f"/{text.lstrip('/')}"
|
|
leading_match = extract_leading_pasted_file_path(prefixed)
|
|
if leading_match is None:
|
|
return candidate, None
|
|
|
|
logger.debug(
|
|
"Recovering stripped absolute leading path; resetting mode "
|
|
"from 'command' to 'normal'"
|
|
)
|
|
self.mode = "normal"
|
|
return prefixed, leading_match
|
|
|
|
@staticmethod
|
|
def _is_existing_path_payload(text: str) -> bool:
|
|
"""Return whether text is a dropped-path payload for existing files."""
|
|
if len(text) < 2: # noqa: PLR2004 # Need at least '/' + one char
|
|
return False
|
|
from deepagents_code.input import parse_pasted_path_payload
|
|
|
|
return parse_pasted_path_payload(text, allow_leading_path=True) is not None
|
|
|
|
def _is_dropped_path_payload(self, text: str) -> bool:
|
|
"""Return whether current text looks like a dropped file-path payload."""
|
|
if not text:
|
|
return False
|
|
if self._is_existing_path_payload(text):
|
|
return True
|
|
if self.mode == "command":
|
|
candidate = f"/{text.lstrip('/')}"
|
|
return self._is_existing_path_payload(candidate)
|
|
return False
|
|
|
|
def _strip_mode_prefix(self, length: int = 1) -> None:
|
|
"""Remove the mode trigger from the text area.
|
|
|
|
Sets the `_stripping_prefix` guard so the resulting text-change event is
|
|
not misinterpreted as new input.
|
|
|
|
Args:
|
|
length: Number of leading characters to strip (matches the trigger
|
|
length detected by `detect_mode_prefix`).
|
|
"""
|
|
if not self._text_area:
|
|
return
|
|
if self._stripping_prefix:
|
|
logger.warning(
|
|
"Previous _stripping_prefix guard was never cleared; "
|
|
"resetting. This may indicate a missed text-change event."
|
|
)
|
|
text = self._text_area.text
|
|
if not text:
|
|
return
|
|
row, col = self._text_area.cursor_location
|
|
self._stripping_prefix = True
|
|
self._text_area.text = text[length:]
|
|
if row == 0 and col > 0:
|
|
col = max(0, col - length)
|
|
self._text_area.move_cursor((row, col))
|
|
|
|
def _completion_text_and_cursor(self) -> tuple[str, int]:
|
|
"""Return controller-facing text/cursor in completion space.
|
|
|
|
Also updates `_completion_prefix_len` so that subsequent calls to
|
|
`_completion_index_to_text_index` use the matching offset.
|
|
"""
|
|
if not self._text_area:
|
|
self._completion_prefix_len = 0
|
|
return "", 0
|
|
|
|
text = self._text_area.text
|
|
cursor = self._get_cursor_offset()
|
|
prefix = MODE_PREFIXES.get(self.mode, "")
|
|
self._completion_prefix_len = len(prefix)
|
|
|
|
if prefix:
|
|
return prefix + text, cursor + len(prefix)
|
|
return text, cursor
|
|
|
|
def _completion_index_to_text_index(self, index: int) -> int:
|
|
"""Translate completion-space index into text-area index.
|
|
|
|
Args:
|
|
index: Cursor/index position in completion space.
|
|
|
|
Returns:
|
|
Clamped index in text-area space.
|
|
"""
|
|
if not self._text_area:
|
|
return 0
|
|
|
|
if 0 <= index <= self._completion_prefix_len:
|
|
return 0
|
|
|
|
mapped = index - self._completion_prefix_len
|
|
text_len = len(self._text_area.text)
|
|
if mapped < 0 or mapped > text_len:
|
|
logger.warning(
|
|
"Completion index %d mapped to %d, outside [0, %d]; "
|
|
"clamping (prefix_len=%d, mode=%s)",
|
|
index,
|
|
mapped,
|
|
text_len,
|
|
self._completion_prefix_len,
|
|
self.mode,
|
|
)
|
|
return max(0, min(mapped, text_len))
|
|
|
|
def _submit_value(self, value: str) -> None:
|
|
"""Prepend mode prefix, save to history, post message, and reset input.
|
|
|
|
This is the single path for all submission flows so the prefix-prepend +
|
|
history + post + clear + mode-reset logic stays in one place.
|
|
|
|
Args:
|
|
value: The stripped text to submit (without mode prefix).
|
|
"""
|
|
if not value:
|
|
return
|
|
|
|
if self._completion_manager:
|
|
self._completion_manager.reset()
|
|
|
|
value = self._replace_submitted_paths_with_images(value)
|
|
|
|
# Prepend mode prefix so the app layer receives the original trigger
|
|
# form (e.g. "!ls", "/help"). The value may already contain the prefix
|
|
# when a completion controller wrote it back into the text area before
|
|
# the strip handler ran.
|
|
prefix = MODE_PREFIXES.get(self.mode, "")
|
|
if prefix and not value.startswith(prefix):
|
|
value = prefix + value
|
|
|
|
self._history.add(value)
|
|
self.post_message(self.Submitted(value, self.mode))
|
|
|
|
if self._text_area:
|
|
# Preserve submission-time attachments until adapter consumes them.
|
|
self._skip_media_sync_events += 1
|
|
self._text_area.clear_text()
|
|
self.mode = "normal"
|
|
|
|
def _sync_media_tracker_to_text(self, text: str) -> None:
|
|
"""Keep tracked media aligned with placeholder tokens in input text.
|
|
|
|
Args:
|
|
text: Current text in the input area.
|
|
"""
|
|
if not self._image_tracker:
|
|
return
|
|
if self._skip_media_sync_events:
|
|
if self._skip_media_sync_events < 0:
|
|
logger.warning(
|
|
"_skip_media_sync_events is negative (%d); resetting to 0",
|
|
self._skip_media_sync_events,
|
|
)
|
|
self._skip_media_sync_events = 0
|
|
else:
|
|
self._skip_media_sync_events -= 1
|
|
return
|
|
self._image_tracker.sync_to_text(text)
|
|
|
|
def on_chat_text_area_typing(
|
|
self,
|
|
event: ChatTextArea.Typing, # noqa: ARG002 # Textual event handler signature
|
|
) -> None:
|
|
"""Relay typing activity to the app as `ChatInput.Typing`."""
|
|
self.post_message(self.Typing())
|
|
|
|
def on_chat_text_area_submitted(self, event: ChatTextArea.Submitted) -> None:
|
|
"""Handle text submission.
|
|
|
|
Always posts the Submitted event - the app layer decides whether to
|
|
process immediately or queue based on agent status.
|
|
"""
|
|
self._submit_value(event.value)
|
|
|
|
def on_chat_text_area_history_previous(
|
|
self, event: ChatTextArea.HistoryPrevious
|
|
) -> None:
|
|
"""Handle history previous request."""
|
|
entry = self._history.get_previous(event.current_text, query=event.current_text)
|
|
if entry is not None and self._text_area:
|
|
mode, display_text = self._history_entry_mode_and_text(entry)
|
|
self.mode = mode
|
|
# Cursor at top so pressing up again continues backward through
|
|
# history without the user having to navigate to the first row.
|
|
self._text_area.set_text_from_history(display_text, cursor_at_end=False)
|
|
else:
|
|
# No matching older entry — surface the boundary so the user
|
|
# doesn't think their keypress was lost.
|
|
self.app.bell()
|
|
|
|
def on_chat_text_area_history_next(
|
|
self,
|
|
event: ChatTextArea.HistoryNext, # noqa: ARG002 # Textual event handler signature
|
|
) -> None:
|
|
"""Handle history next request."""
|
|
entry = self._history.get_next()
|
|
if entry is not None and self._text_area:
|
|
mode, display_text = self._history_entry_mode_and_text(entry)
|
|
self.mode = mode
|
|
# Cursor at end so pressing down again continues forward through
|
|
# history.
|
|
self._text_area.set_text_from_history(display_text, cursor_at_end=True)
|
|
else:
|
|
self.app.bell()
|
|
|
|
def on_chat_text_area_pasted_paths(self, event: ChatTextArea.PastedPaths) -> None:
|
|
"""Handle paste payloads that resolve to dropped file paths."""
|
|
if not self._text_area:
|
|
return
|
|
|
|
self._insert_pasted_paths(event.raw_text, event.paths)
|
|
|
|
def handle_external_paste(self, pasted: str) -> bool:
|
|
"""Handle paste text from app-level routing when input is not focused.
|
|
|
|
When the text area is mounted, the paste is always consumed: file paths
|
|
are attached as images, and plain text is inserted directly.
|
|
|
|
Args:
|
|
pasted: Raw pasted text payload.
|
|
|
|
Returns:
|
|
`True` when the text area is mounted and the paste was inserted,
|
|
`False` if the widget is not yet composed.
|
|
"""
|
|
if not self._text_area:
|
|
return False
|
|
|
|
parsed = self._parse_dropped_path_payload(pasted)
|
|
if parsed is None:
|
|
self._text_area.insert(pasted)
|
|
else:
|
|
self._insert_pasted_paths(pasted, parsed.paths)
|
|
|
|
self._text_area.focus()
|
|
return True
|
|
|
|
def _apply_inline_dropped_path_replacement(self, text: str) -> bool:
|
|
"""Replace full dropped-path payload text with image placeholders.
|
|
|
|
Some terminals insert drag-and-drop payloads as plain text rather than
|
|
dispatching a dedicated paste event. When the current text resolves to
|
|
one or more file paths and at least one path is an image, rewrite the
|
|
text inline to `[image N]` placeholders.
|
|
|
|
Args:
|
|
text: Current text area content.
|
|
|
|
Returns:
|
|
`True` if text was rewritten inline, otherwise `False`.
|
|
"""
|
|
if not self._text_area:
|
|
return False
|
|
|
|
parsed = self._parse_dropped_path_payload(text)
|
|
if parsed is None:
|
|
return False
|
|
|
|
replacement, attached = self._build_path_replacement(
|
|
text, parsed.paths, add_trailing_space=True
|
|
)
|
|
if not attached or replacement == text:
|
|
return False
|
|
|
|
self._applying_inline_path_replacement = True
|
|
self._text_area.text = replacement
|
|
lines = replacement.split("\n")
|
|
self._text_area.move_cursor((len(lines) - 1, len(lines[-1])))
|
|
return True
|
|
|
|
def _insert_pasted_paths(self, raw_text: str, paths: list[Path]) -> None:
|
|
"""Insert pasted path payload, attaching images when possible.
|
|
|
|
Args:
|
|
raw_text: Original paste payload text.
|
|
paths: Resolved file paths parsed from the payload.
|
|
"""
|
|
if not self._text_area:
|
|
return
|
|
replacement, attached = self._build_path_replacement(
|
|
raw_text, paths, add_trailing_space=True
|
|
)
|
|
if attached:
|
|
self._text_area.insert(replacement)
|
|
return
|
|
self._text_area.insert(raw_text)
|
|
|
|
def _build_path_replacement(
|
|
self,
|
|
raw_text: str,
|
|
paths: list[Path],
|
|
*,
|
|
add_trailing_space: bool,
|
|
) -> tuple[str, bool]:
|
|
"""Build replacement text for dropped paths and attach any images.
|
|
|
|
Args:
|
|
raw_text: Original paste payload text.
|
|
paths: Resolved file paths parsed from the payload.
|
|
add_trailing_space: Whether to append a trailing space after the
|
|
last token when paths are separated by spaces.
|
|
|
|
Returns:
|
|
Tuple of `(replacement, attached)` where `attached` indicates whether
|
|
at least one media attachment (image or video) was created.
|
|
"""
|
|
if not self._image_tracker:
|
|
return raw_text, False
|
|
|
|
from deepagents_code.media_utils import (
|
|
IMAGE_EXTENSIONS,
|
|
MAX_MEDIA_BYTES,
|
|
VIDEO_EXTENSIONS,
|
|
ImageData,
|
|
get_media_from_path,
|
|
)
|
|
|
|
parts: list[str] = []
|
|
attached = False
|
|
for path in paths:
|
|
media = get_media_from_path(path)
|
|
if media is not None:
|
|
kind = "image" if isinstance(media, ImageData) else "video"
|
|
parts.append(self._image_tracker.add_media(media, kind))
|
|
attached = True
|
|
continue
|
|
|
|
# Check if it looked like media but failed validation
|
|
suffix = path.suffix.lower()
|
|
if suffix in IMAGE_EXTENSIONS or suffix in VIDEO_EXTENSIONS:
|
|
label = "Video" if suffix in VIDEO_EXTENSIONS else "Image"
|
|
try:
|
|
size = path.stat().st_size
|
|
if size > MAX_MEDIA_BYTES:
|
|
msg = (
|
|
f"{label} too large: {path.name} "
|
|
f"({size // (1024 * 1024)} MB, max "
|
|
f"{MAX_MEDIA_BYTES // (1024 * 1024)} MB)"
|
|
)
|
|
else:
|
|
msg = f"Could not attach {label.lower()}: {path.name}"
|
|
except OSError as exc:
|
|
logger.debug("Failed to stat media file %s: %s", path, exc)
|
|
msg = f"Could not attach {label.lower()}: {path.name}"
|
|
self.app.notify(msg, severity="warning", timeout=5, markup=False)
|
|
|
|
# Not a supported media file, keep as path
|
|
logger.debug("Could not load media from dropped path: %s", path)
|
|
parts.append(str(path))
|
|
|
|
if not attached:
|
|
return raw_text, False
|
|
|
|
separator = "\n" if "\n" in raw_text else " "
|
|
replacement = separator.join(parts)
|
|
if separator == " " and add_trailing_space:
|
|
replacement += " "
|
|
return replacement, True
|
|
|
|
def _replace_submitted_paths_with_images(self, value: str) -> str:
|
|
"""Replace dropped-path payloads in submitted text with image placeholders.
|
|
|
|
Handles both full-path payloads and leading-path-with-suffix payloads
|
|
(for example, `'<path>' what is this?`). When command mode previously
|
|
stripped a leading slash, this method also retries with the slash
|
|
restored before giving up.
|
|
|
|
Args:
|
|
value: Stripped submitted text (without mode prefix).
|
|
|
|
Returns:
|
|
Submitted text with image placeholders when attachment succeeded.
|
|
"""
|
|
candidate, parsed = self._parse_dropped_path_payload_with_command_recovery(
|
|
value, allow_leading_path=True
|
|
)
|
|
if parsed is None:
|
|
return value
|
|
|
|
if parsed.token_end is None:
|
|
replacement, attached = self._build_path_replacement(
|
|
candidate, parsed.paths, add_trailing_space=False
|
|
)
|
|
if attached:
|
|
return replacement.strip()
|
|
# Even when full-payload parsing resolves, still retry explicit
|
|
# leading-token extraction before giving up.
|
|
candidate, leading_match = (
|
|
self._extract_leading_dropped_path_with_command_recovery(value)
|
|
)
|
|
if leading_match is None:
|
|
return value
|
|
leading_path, token_end = leading_match
|
|
else:
|
|
leading_path = parsed.paths[0]
|
|
token_end = parsed.token_end
|
|
|
|
replacement, attached = self._build_path_replacement(
|
|
str(leading_path), [leading_path], add_trailing_space=False
|
|
)
|
|
if attached:
|
|
suffix = candidate[token_end:].lstrip()
|
|
if suffix:
|
|
return f"{replacement.strip()} {suffix}".strip()
|
|
return replacement.strip()
|
|
return value
|
|
|
|
@staticmethod
|
|
def _history_entry_mode_and_text(entry: str) -> tuple[str, str]:
|
|
"""Return mode and stripped display text for a history entry.
|
|
|
|
Args:
|
|
entry: Raw entry value read from history storage.
|
|
|
|
Returns:
|
|
Tuple of `(mode, display_text)` where mode-trigger prefixes are
|
|
removed from `display_text`.
|
|
"""
|
|
if mode_match := detect_mode_prefix(entry):
|
|
prefix, mode = mode_match
|
|
return mode, entry[len(prefix) :]
|
|
return "normal", entry
|
|
|
|
async def on_key(self, event: events.Key) -> None:
|
|
"""Handle key events for completion navigation."""
|
|
if not self._completion_manager or not self._text_area:
|
|
return
|
|
|
|
# Backspace at cursor position 0 (or on empty input) exits the
|
|
# current mode (e.g. command/shell). When the cursor is at the very
|
|
# start of the text area, backspace is a no-op for the underlying
|
|
# widget, so without this guard the user would be stuck in the mode.
|
|
if (
|
|
event.key == "backspace"
|
|
and self.mode != "normal"
|
|
and self._get_cursor_offset() == 0
|
|
):
|
|
# Defer the popup reset so it coalesces with the glyph update
|
|
# that watch_mode schedules via call_after_refresh.
|
|
def _deferred_reset() -> None:
|
|
if self._completion_manager is not None:
|
|
self._completion_manager.reset()
|
|
|
|
self.call_after_refresh(_deferred_reset)
|
|
self.mode = "normal"
|
|
event.prevent_default()
|
|
event.stop()
|
|
return
|
|
|
|
text, cursor = self._completion_text_and_cursor()
|
|
result = self._completion_manager.on_key(event, text, cursor)
|
|
|
|
match result:
|
|
case CompletionResult.HANDLED:
|
|
event.prevent_default()
|
|
event.stop()
|
|
case CompletionResult.SUBMIT:
|
|
event.prevent_default()
|
|
event.stop()
|
|
self._submit_value(self._text_area.text.strip())
|
|
case CompletionResult.IGNORED if event.key == "space":
|
|
# Space was intercepted (prevent_default) so the active
|
|
# controller could attempt completion. The controller
|
|
# declined (e.g. file completion), so insert the space that
|
|
# TextArea would have inserted normally.
|
|
self._text_area.insert(" ")
|
|
case CompletionResult.IGNORED if event.key == "enter":
|
|
# Handle Enter when completion is not active (shell/normal modes)
|
|
value = self._text_area.text.strip()
|
|
if value:
|
|
event.prevent_default()
|
|
event.stop()
|
|
self._submit_value(value)
|
|
|
|
def _get_cursor_offset(self) -> int:
|
|
"""Get the cursor offset as a single integer.
|
|
|
|
Returns:
|
|
Cursor position as character offset from start of text.
|
|
"""
|
|
if not self._text_area:
|
|
return 0
|
|
|
|
text = self._text_area.text
|
|
row, col = self._text_area.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 watch_mode(self, mode: str) -> None:
|
|
"""Post mode changed message and update prompt indicator.
|
|
|
|
The prompt glyph update is deferred via `call_after_refresh` so that
|
|
callers which also schedule deferred work (e.g. the completion popup)
|
|
can coalesce both visual changes into a single refresh.
|
|
"""
|
|
# Keep inline argument hints in sync for mode-only transitions
|
|
# (for example, exiting command mode via Escape or backspace).
|
|
self._update_argument_hint()
|
|
|
|
glyph = MODE_DISPLAY_GLYPHS.get(mode)
|
|
if not glyph and mode != "normal":
|
|
logger.warning(
|
|
"No display glyph for mode %r; falling back to '>'",
|
|
mode,
|
|
)
|
|
|
|
def _apply() -> None:
|
|
self.remove_class("mode-shell", "mode-command", "mode-shell-incognito")
|
|
if glyph:
|
|
class_name = (
|
|
"mode-shell-incognito"
|
|
if mode == "shell_incognito"
|
|
else f"mode-{mode}"
|
|
)
|
|
self.add_class(class_name)
|
|
try:
|
|
prompt = self.query_one("#prompt", Static)
|
|
except NoMatches:
|
|
logger.warning("watch_mode._apply: prompt widget not found")
|
|
if mode == "shell_incognito":
|
|
# Privacy-sensitive: surface a visible warning so the user
|
|
# never types an incognito command without confirmation
|
|
# that the mode is active.
|
|
app = getattr(self, "app", None)
|
|
if app is not None:
|
|
with contextlib.suppress(Exception):
|
|
app.notify(
|
|
"Incognito mode UI failed to render; "
|
|
"switching back to normal input.",
|
|
severity="warning",
|
|
markup=False,
|
|
)
|
|
self.mode = "normal"
|
|
return
|
|
prompt.update(glyph or ">")
|
|
if mode == "shell_incognito":
|
|
self.border_title = "incognito"
|
|
else:
|
|
self.border_title = None
|
|
|
|
self.call_after_refresh(_apply)
|
|
self.post_message(self.ModeChanged(mode))
|
|
|
|
def focus_input(self) -> None:
|
|
"""Focus the input field."""
|
|
if self._text_area:
|
|
self._text_area.focus()
|
|
|
|
@property
|
|
def value(self) -> str:
|
|
"""Get the current input value.
|
|
|
|
Returns:
|
|
Current text in the input field.
|
|
"""
|
|
if self._text_area:
|
|
return self._text_area.text
|
|
return ""
|
|
|
|
@value.setter
|
|
def value(self, val: str) -> None:
|
|
"""Set the input value."""
|
|
if self._text_area:
|
|
self._text_area.text = val
|
|
|
|
@property
|
|
def input_widget(self) -> ChatTextArea | None:
|
|
"""Get the underlying TextArea widget.
|
|
|
|
Returns:
|
|
The ChatTextArea widget or None if not mounted.
|
|
"""
|
|
return self._text_area
|
|
|
|
def set_disabled(self, *, disabled: bool) -> None:
|
|
"""Enable or disable the input widget."""
|
|
if self._text_area:
|
|
self._text_area.disabled = disabled
|
|
if disabled:
|
|
self._text_area.blur()
|
|
if self._completion_manager:
|
|
self._completion_manager.reset()
|
|
|
|
def set_cursor_active(self, *, active: bool) -> None:
|
|
"""Toggle input focus state (e.g., unfocus while agent is working).
|
|
|
|
Args:
|
|
active: Whether the input should be focused and accepting input.
|
|
"""
|
|
if self._text_area:
|
|
self._text_area.set_app_focus(has_focus=active)
|
|
|
|
def set_cursor_blink(self, *, blink: bool) -> None:
|
|
"""Toggle the input's cursor blink without changing focus.
|
|
|
|
Args:
|
|
blink: Whether the cursor should blink.
|
|
"""
|
|
if self._text_area is not None:
|
|
self._text_area.cursor_blink = blink
|
|
|
|
def exit_mode(self) -> bool:
|
|
"""Exit the current input mode (command/shell) back to normal.
|
|
|
|
Returns:
|
|
True if mode was non-normal and has been reset.
|
|
"""
|
|
if self.mode == "normal":
|
|
return False
|
|
self.mode = "normal"
|
|
if self._completion_manager:
|
|
self._completion_manager.reset()
|
|
self.clear_completion_suggestions()
|
|
return True
|
|
|
|
def dismiss_completion(self) -> bool:
|
|
"""Dismiss completion: clear view and reset controller state.
|
|
|
|
Returns:
|
|
True if completion was active and has been dismissed.
|
|
"""
|
|
if not self._current_suggestions:
|
|
return False
|
|
if self._completion_manager:
|
|
self._completion_manager.reset()
|
|
# Always clear local state so the popup is hidden even if the
|
|
# manager's active controller was already None (no-op reset).
|
|
self.clear_completion_suggestions()
|
|
return True
|
|
|
|
# =========================================================================
|
|
# CompletionView protocol implementation
|
|
# =========================================================================
|
|
|
|
def render_completion_suggestions(
|
|
self, suggestions: list[tuple[str, str]], selected_index: int
|
|
) -> None:
|
|
"""Render completion suggestions in the popup."""
|
|
prev_suggestions = self._current_suggestions
|
|
self._current_suggestions = suggestions
|
|
self._current_selected_index = selected_index
|
|
|
|
if self._popup:
|
|
# If only the selection changed (same items), skip full rebuild
|
|
if suggestions == prev_suggestions:
|
|
self._popup.update_selection(selected_index)
|
|
else:
|
|
self._popup.update_suggestions(suggestions, selected_index)
|
|
# Tell TextArea that completion is active so it yields navigation keys
|
|
if self._text_area:
|
|
self._text_area.set_completion_active(active=bool(suggestions))
|
|
|
|
def clear_completion_suggestions(self) -> None:
|
|
"""Clear/hide the completion popup."""
|
|
self._current_suggestions = []
|
|
self._current_selected_index = 0
|
|
|
|
if self._popup:
|
|
self._popup.hide()
|
|
# Tell TextArea that completion is no longer active
|
|
if self._text_area:
|
|
self._text_area.set_completion_active(active=False)
|
|
|
|
def on_completion_popup_option_clicked(
|
|
self, event: CompletionPopup.OptionClicked
|
|
) -> None:
|
|
"""Handle click on a completion option."""
|
|
if not self._current_suggestions or not self._text_area:
|
|
return
|
|
|
|
index = event.index
|
|
if index < 0 or index >= len(self._current_suggestions):
|
|
return
|
|
|
|
# Get the selected completion
|
|
label, _ = self._current_suggestions[index]
|
|
text = self._text_area.text
|
|
cursor = self._get_cursor_offset()
|
|
|
|
# Determine replacement range based on completion type.
|
|
# Slash completions use completion-space coordinates and are translated
|
|
# through the completion view adapter.
|
|
if label.startswith("/"):
|
|
if self._completion_view is None:
|
|
logger.warning(
|
|
"Slash completion clicked but _completion_view is not "
|
|
"initialized; this indicates a widget lifecycle issue."
|
|
)
|
|
return
|
|
_, virtual_cursor = self._completion_text_and_cursor()
|
|
self._completion_view.replace_completion_range(0, virtual_cursor, label)
|
|
elif label.startswith("@"):
|
|
# File mention: replace from @ to cursor
|
|
at_index = text[:cursor].rfind("@")
|
|
if at_index >= 0:
|
|
self.replace_completion_range(at_index, cursor, label)
|
|
|
|
# Reset completion state
|
|
if self._completion_manager:
|
|
self._completion_manager.reset()
|
|
|
|
# Re-focus the text input after click
|
|
self._text_area.focus()
|
|
|
|
def replace_completion_range(self, start: int, end: int, replacement: str) -> None:
|
|
"""Replace text in the input field."""
|
|
if not self._text_area:
|
|
return
|
|
|
|
text = self._text_area.text
|
|
|
|
start = max(0, min(start, len(text)))
|
|
end = max(start, min(end, len(text)))
|
|
|
|
prefix = text[:start]
|
|
suffix = text[end:]
|
|
|
|
# Add space after completion unless it's a directory path
|
|
if replacement.endswith("/"):
|
|
insertion = replacement
|
|
else:
|
|
insertion = replacement + " " if not suffix.startswith(" ") else replacement
|
|
|
|
new_text = f"{prefix}{insertion}{suffix}"
|
|
self._text_area.text = new_text
|
|
|
|
# Calculate new cursor position and move cursor
|
|
new_offset = start + len(insertion)
|
|
lines = new_text.split("\n")
|
|
remaining = new_offset
|
|
for row, line in enumerate(lines):
|
|
if remaining <= len(line):
|
|
self._text_area.move_cursor((row, remaining))
|
|
break
|
|
remaining -= len(line) + 1
|
|
|
|
# Completion selections should render their final inline hint
|
|
# immediately, without waiting for the subsequent Changed event.
|
|
self._update_argument_hint()
|