421 lines
16 KiB
Python
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
|