Files
deepagent/deepagents_sourcecode/libs/deepagents-cli/deepagents_cli/input.py
HyunjunJeon 9cb01f4abe project init
2025-12-31 11:32:36 +09:00

421 lines
16 KiB
Python

"""CLI를 위한 입력 처리, 완성 및 프롬프트 세션."""
import asyncio
import os
import re
import time
from collections.abc import Callable
from pathlib import Path
from prompt_toolkit import PromptSession
from prompt_toolkit.completion import (
Completer,
Completion,
PathCompleter,
merge_completers,
)
from prompt_toolkit.document import Document
from prompt_toolkit.enums import EditingMode
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.key_binding import KeyBindings
from .config import COLORS, COMMANDS, SessionState, console
from .image_utils import ImageData, get_clipboard_image
# Regex patterns for context-aware completion
AT_MENTION_RE = re.compile(r"@(?P<path>(?:[^\s@]|(?<=\\)\s)*)$")
SLASH_COMMAND_RE = re.compile(r"^/(?P<command>[a-z]*)$")
EXIT_CONFIRM_WINDOW = 3.0
class ImageTracker:
"""현재 대화에서 붙여넣은 이미지를 추적합니다."""
def __init__(self) -> None:
self.images: list[ImageData] = []
self.next_id = 1
def add_image(self, image_data: ImageData) -> str:
"""이미지를 추가하고 해당 자리 표시자 텍스트를 반환합니다.
Args:
image_data: 추적할 이미지 데이터
Returns:
"[image 1]"과 같은 자리 표시자 문자열
"""
placeholder = f"[image {self.next_id}]"
image_data.placeholder = placeholder
self.images.append(image_data)
self.next_id += 1
return placeholder
def get_images(self) -> list[ImageData]:
"""추적된 모든 이미지를 가져옵니다."""
return self.images.copy()
def clear(self) -> None:
"""추적된 모든 이미지를 지우고 카운터를 재설정합니다."""
self.images.clear()
self.next_id = 1
class FilePathCompleter(Completer):
"""커서가 '@' 뒤에 있을 때만 파일시스템 완성을 활성화합니다."""
def __init__(self) -> None:
self.path_completer = PathCompleter(
expanduser=True,
min_input_len=0,
only_directories=False,
)
def get_completions(self, document, complete_event):
"""@가 감지되면 파일 경로 완성을 가져옵니다."""
text = document.text_before_cursor
# Use regex to detect @path pattern at end of line
m = AT_MENTION_RE.search(text)
if not m:
return # Not in an @path context
path_fragment = m.group("path")
# Unescape the path for PathCompleter (it doesn't understand escape sequences)
unescaped_fragment = path_fragment.replace("\\ ", " ")
# Strip trailing backslash if present (user is in the process of typing an escape)
unescaped_fragment = unescaped_fragment.removesuffix("\\")
# Create temporary document for the unescaped path fragment
temp_doc = Document(text=unescaped_fragment, cursor_position=len(unescaped_fragment))
# Get completions from PathCompleter and use its start_position
# PathCompleter returns suffix text with start_position=0 (insert at cursor)
for comp in self.path_completer.get_completions(temp_doc, complete_event):
# Add trailing / for directories so users can continue navigating
completed_path = Path(unescaped_fragment + comp.text).expanduser()
# Re-escape spaces in the completion text for the command line
completion_text = comp.text.replace(" ", "\\ ")
if completed_path.is_dir() and not completion_text.endswith("/"):
completion_text += "/"
yield Completion(
text=completion_text,
start_position=comp.start_position, # Use PathCompleter's position (usually 0)
display=comp.display,
display_meta=comp.display_meta,
)
class CommandCompleter(Completer):
"""줄이 '/'로 시작할 때만 명령 완성을 활성화합니다."""
def get_completions(self, document, _complete_event):
"""/가 시작 부분에 있을 때 명령 완성을 가져옵니다."""
text = document.text_before_cursor
# Use regex to detect /command pattern at start of line
m = SLASH_COMMAND_RE.match(text)
if not m:
return # Not in a /command context
command_fragment = m.group("command")
# Match commands that start with the fragment (case-insensitive)
for cmd_name, cmd_desc in COMMANDS.items():
if cmd_name.startswith(command_fragment.lower()):
yield Completion(
text=cmd_name,
start_position=-len(command_fragment), # Fixed position for original document
display=cmd_name,
display_meta=cmd_desc,
)
def parse_file_mentions(text: str) -> tuple[str, list[Path]]:
"""@file 멘션을 추출하고 해결된 파일 경로가 포함된 정리된 텍스트를 반환합니다."""
pattern = r"@((?:[^\s@]|(?<=\\)\s)+)" # Match @filename, allowing escaped spaces
matches = re.findall(pattern, text)
files = []
for match in matches:
# Remove escape characters
clean_path = match.replace("\\ ", " ")
path = Path(clean_path).expanduser()
# Try to resolve relative to cwd
if not path.is_absolute():
path = Path.cwd() / path
try:
path = path.resolve()
if path.exists() and path.is_file():
files.append(path)
else:
console.print(f"[yellow]경고: 파일을 찾을 수 없습니다: {match}[/yellow]")
except Exception as e:
console.print(f"[yellow]경고: 유효하지 않은 경로 {match}: {e}[/yellow]")
return text, files
def parse_image_placeholders(text: str) -> tuple[str, int]:
"""텍스트 내 이미지 자리 표시자 수를 셉니다.
Args:
text: [image] 또는 [image N] 자리 표시자가 포함될 수 있는 입력 텍스트
Returns:
이미지 자리 표시자 수가 포함된 (텍스트, 개수) 튜플
"""
# Match [image] or [image N] patterns
pattern = r"\[image(?:\s+\d+)?\]"
matches = re.findall(pattern, text, re.IGNORECASE)
return text, len(matches)
def get_bottom_toolbar(session_state: SessionState, session_ref: dict) -> Callable[[], list[tuple[str, str]]]:
"""자동 승인 상태와 BASH 모드를 표시하는 툴바 함수를 반환합니다."""
def toolbar() -> list[tuple[str, str]]:
parts = []
# Check if we're in BASH mode (input starts with !)
try:
session = session_ref.get("session")
if session:
current_text = session.default_buffer.text
if current_text.startswith("!"):
parts.append(("bg:#ff1493 fg:#ffffff bold", " BASH MODE "))
parts.append(("", " | "))
except (AttributeError, TypeError):
# Silently ignore - toolbar is non-critical and called frequently
pass
# Base status message
if session_state.auto_approve:
base_msg = "자동 승인 켜짐 (CTRL+T로 전환)"
base_class = "class:toolbar-green"
else:
base_msg = "수동 승인 (CTRL+T로 전환)"
base_class = "class:toolbar-orange"
parts.append((base_class, base_msg))
# Show exit confirmation hint if active
hint_until = session_state.exit_hint_until
if hint_until is not None:
now = time.monotonic()
if now < hint_until:
parts.append(("", " | "))
parts.append(("class:toolbar-exit", " 종료하려면 Ctrl+C를 한번 더 누르세요 "))
else:
session_state.exit_hint_until = None
return parts
return toolbar
def create_prompt_session(
_assistant_id: str, session_state: SessionState, image_tracker: ImageTracker | None = None
) -> PromptSession:
"""모든 기능이 구성된 PromptSession을 생성합니다."""
# Set default editor if not already set
if "EDITOR" not in os.environ:
os.environ["EDITOR"] = "nano"
# Create key bindings
kb = KeyBindings()
@kb.add("c-c")
def _(event) -> None:
"""종료하려면 짧은 시간 내에 Ctrl+C를 두 번 눌러야 합니다."""
app = event.app
now = time.monotonic()
if session_state.exit_hint_until is not None and now < session_state.exit_hint_until:
handle = session_state.exit_hint_handle
if handle:
handle.cancel()
session_state.exit_hint_handle = None
session_state.exit_hint_until = None
app.invalidate()
app.exit(exception=KeyboardInterrupt())
return
session_state.exit_hint_until = now + EXIT_CONFIRM_WINDOW
handle = session_state.exit_hint_handle
if handle:
handle.cancel()
loop = asyncio.get_running_loop()
app_ref = app
def clear_hint() -> None:
if session_state.exit_hint_until is not None and time.monotonic() >= session_state.exit_hint_until:
session_state.exit_hint_until = None
session_state.exit_hint_handle = None
app_ref.invalidate()
session_state.exit_hint_handle = loop.call_later(EXIT_CONFIRM_WINDOW, clear_hint)
app.invalidate()
# Bind Ctrl+T to toggle auto-approve
@kb.add("c-t")
def _(event) -> None:
"""자동 승인 모드를 토글합니다."""
session_state.toggle_auto_approve()
# Force UI refresh to update toolbar
event.app.invalidate()
# Custom paste handler to detect images
if image_tracker:
from prompt_toolkit.keys import Keys
def _handle_paste_with_image_check(event, pasted_text: str = "") -> None:
"""클립보드에서 이미지를 확인하고, 그렇지 않으면 붙여넣은 텍스트를 삽입합니다."""
# Try to get an image from clipboard
clipboard_image = get_clipboard_image()
if clipboard_image:
# Found an image! Add it to tracker and insert placeholder
placeholder = image_tracker.add_image(clipboard_image)
# Insert placeholder (no confirmation message)
event.current_buffer.insert_text(placeholder)
elif pasted_text:
# No image, insert the pasted text
event.current_buffer.insert_text(pasted_text)
else:
# Fallback: try to get text from prompt_toolkit clipboard
clipboard_data = event.app.clipboard.get_data()
if clipboard_data and clipboard_data.text:
event.current_buffer.insert_text(clipboard_data.text)
@kb.add(Keys.BracketedPaste)
def _(event) -> None:
"""브래킷 붙여넣기(macOS의 Cmd+V)를 처리합니다 - 이미지를 먼저 확인합니다."""
# Bracketed paste provides the pasted text in event.data
pasted_text = event.data if hasattr(event, "data") else ""
_handle_paste_with_image_check(event, pasted_text)
@kb.add("c-v")
def _(event) -> None:
"""Ctrl+V 붙여넣기를 처리합니다 - 이미지를 먼저 확인합니다."""
_handle_paste_with_image_check(event)
# Bind regular Enter to submit (intuitive behavior)
@kb.add("enter")
def _(event) -> None:
"""완성 메뉴가 활성화되지 않은 경우 Enter는 입력을 제출합니다."""
buffer = event.current_buffer
# If completion menu is showing, apply the current completion
if buffer.complete_state:
# Get the current completion (the highlighted one)
current_completion = buffer.complete_state.current_completion
# If no completion is selected (user hasn't navigated), select and apply the first one
if not current_completion and buffer.complete_state.completions:
# Move to the first completion
buffer.complete_next()
# Now apply it
buffer.apply_completion(buffer.complete_state.current_completion)
elif current_completion:
# Apply the already-selected completion
buffer.apply_completion(current_completion)
else:
# No completions available, close menu
buffer.complete_state = None
# Don't submit if buffer is empty or only whitespace
elif buffer.text.strip():
# Normal submit
buffer.validate_and_handle()
# If empty, do nothing (don't submit)
# Alt+Enter for newlines (press ESC then Enter, or Option+Enter on Mac)
@kb.add("escape", "enter")
def _(event) -> None:
"""Alt+Enter는 여러 줄 입력을 위해 줄바꿈을 삽입합니다."""
event.current_buffer.insert_text("\n")
# Ctrl+E to open in external editor
@kb.add("c-e")
def _(event) -> None:
"""현재 입력을 외부 편집기(기본값 nano)에서 엽니다."""
event.current_buffer.open_in_editor()
# Backspace handler to retrigger completions and delete image tags as units
@kb.add("backspace")
def _(event) -> None:
"""백스페이스 처리: 이미지 태그를 단일 단위로 삭제하고 완성을 다시 트리거합니다."""
buffer = event.current_buffer
text_before = buffer.document.text_before_cursor
# Check if cursor is right after an image tag like [image 1] or [image 12]
image_tag_pattern = r"\[image \d+\]$"
match = re.search(image_tag_pattern, text_before)
if match and image_tracker:
# Delete the entire tag
tag_length = len(match.group(0))
buffer.delete_before_cursor(count=tag_length)
# Remove the image from tracker and reset counter
tag_text = match.group(0)
image_num_match = re.search(r"\d+", tag_text)
if image_num_match:
image_num = int(image_num_match.group(0))
# Remove image at index (1-based to 0-based)
if 0 < image_num <= len(image_tracker.images):
image_tracker.images.pop(image_num - 1)
# Reset counter to next available number
image_tracker.next_id = len(image_tracker.images) + 1
else:
# Normal backspace
buffer.delete_before_cursor(count=1)
# Check if we're in a completion context (@ or /)
text = buffer.document.text_before_cursor
if AT_MENTION_RE.search(text) or SLASH_COMMAND_RE.match(text):
# Retrigger completion
buffer.start_completion(select_first=False)
from prompt_toolkit.styles import Style
# Define styles for the toolbar with full-width background colors
toolbar_style = Style.from_dict({
"bottom-toolbar": "noreverse", # Disable default reverse video
"toolbar-green": "bg:#10b981 #000000", # Green for auto-accept ON
"toolbar-orange": "bg:#f59e0b #000000", # Orange for manual accept
"toolbar-exit": "bg:#2563eb #ffffff", # Blue for exit hint
})
# Create session reference dict for toolbar to access session
session_ref = {}
# Create the session
session = PromptSession(
message=HTML(f'<style fg="{COLORS["user"]}">></style> '),
multiline=True, # Keep multiline support but Enter submits
key_bindings=kb,
completer=merge_completers([CommandCompleter(), FilePathCompleter()]),
editing_mode=EditingMode.EMACS,
complete_while_typing=True, # Show completions as you type
complete_in_thread=True, # Async completion prevents menu freezing
mouse_support=False,
enable_open_in_editor=True, # Allow Ctrl+X Ctrl+E to open external editor
bottom_toolbar=get_bottom_toolbar(session_state, session_ref), # Persistent status bar at bottom
style=toolbar_style, # Apply toolbar styling
reserve_space_for_menu=7, # Reserve space for completion menu to show 5-6 results
)
# Store session reference for toolbar to access
session_ref["session"] = session
return session