Add autocomplete and multiline support in HermesCLI input

- Introduced SlashCommandCompleter for command autocompletion, enhancing user experience by suggesting commands as users type.
- Enabled multiline input with Shift+Enter, allowing users to enter longer messages more conveniently.
- Implemented paste detection to handle large text inputs, saving them to temporary files and replacing them with compact references in the input area.
- Updated input area styling and hint display to improve usability and feedback during agent operation.
This commit is contained in:
teknium1
2026-02-17 21:47:54 -08:00
parent 54cbf30c14
commit d7cef744ec
2 changed files with 99 additions and 15 deletions

112
cli.py
View File

@@ -33,10 +33,13 @@ from prompt_toolkit.styles import Style as PTStyle
from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit.application import Application
from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
from prompt_toolkit.layout.dimension import Dimension
from prompt_toolkit.widgets import TextArea
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.completion import Completer, Completion
import threading
import queue
import tempfile
# Load environment variables first
from dotenv import load_dotenv
@@ -548,6 +551,26 @@ COMMANDS = {
}
class SlashCommandCompleter(Completer):
"""Autocomplete for /commands in the input area."""
def get_completions(self, document, complete_event):
text = document.text_before_cursor
# Only complete at the start of input, after /
if not text.startswith("/"):
return
word = text[1:] # strip the leading /
for cmd, desc in COMMANDS.items():
cmd_name = cmd[1:] # strip leading / from key
if cmd_name.startswith(word):
yield Completion(
cmd_name,
start_position=-len(word),
display=cmd,
display_meta=desc,
)
def save_config_value(key_path: str, value: any) -> bool:
"""
Save a value to the active config file at the specified key path.
@@ -1521,14 +1544,16 @@ class HermesCLI:
text = event.app.current_buffer.text.strip()
if text:
if self._agent_running and not text.startswith("/"):
# Agent is working - route to interrupt queue for chat() to pick up
self._interrupt_queue.put(text)
else:
# Agent idle, or it's a command - route to normal input queue
self._pending_input.put(text)
# Clear the buffer
event.app.current_buffer.reset()
@kb.add('s-enter')
def handle_shift_enter(event):
"""Shift+Enter inserts a newline for multi-line input."""
event.current_buffer.insert_text('\n')
@kb.add('c-c')
def handle_ctrl_c(event):
"""Handle Ctrl+C - interrupt agent or force exit on double press.
@@ -1570,22 +1595,57 @@ class HermesCLI:
return [('class:prompt-working', ' ')]
return [('class:prompt', ' ')]
# Create the input area widget with persistent history across sessions
# Create the input area with multiline (shift+enter), autocomplete, and paste handling
input_area = TextArea(
height=1,
height=Dimension(min=1, max=8, preferred=1),
prompt=get_prompt,
style='class:input-area',
multiline=False,
wrap_lines=False,
multiline=True,
wrap_lines=True,
history=FileHistory(str(self._history_file)),
completer=SlashCommandCompleter(),
complete_while_typing=True,
)
# Spacer line above input that absorbs spinner output so it
# doesn't overlap the prompt_toolkit cursor
def get_spacer_height():
# Paste collapsing: detect large pastes and save to temp file
_paste_counter = [0]
def _on_text_changed(buf):
"""Detect large pastes and collapse them to a file reference."""
text = buf.text
line_count = text.count('\n')
# Heuristic: if text jumps to 5+ lines in one change, it's a paste
if line_count >= 5 and not text.startswith('/'):
_paste_counter[0] += 1
# Save to temp file
paste_dir = Path(os.path.expanduser("~/.hermes/pastes"))
paste_dir.mkdir(parents=True, exist_ok=True)
paste_file = paste_dir / f"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt"
paste_file.write_text(text, encoding="utf-8")
# Replace buffer with compact reference
buf.text = f"[Pasted text #{_paste_counter[0]}: {line_count + 1} lines → {paste_file}]"
buf.cursor_position = len(buf.text)
input_area.buffer.on_text_changed += _on_text_changed
# Hint line above input: shows placeholder when agent is working
# and the user hasn't typed anything yet. Disappears when idle
# or when the user starts typing.
def get_hint_text():
if not cli_ref._agent_running:
return []
buf = input_area.buffer
if buf.text:
return []
return [('class:hint', ' type here to interrupt')]
def get_hint_height():
return 1 if cli_ref._agent_running else 0
spacer = Window(height=get_spacer_height)
spacer = Window(
content=FormattedTextControl(get_hint_text),
height=get_hint_height,
)
# Layout with dynamic spacer and input at bottom
layout = Layout(
@@ -1601,6 +1661,12 @@ class HermesCLI:
'input-area': '#FFF8DC',
'prompt': '#FFF8DC',
'prompt-working': '#888888 italic',
'hint': '#555555 italic',
'completion-menu': 'bg:#1a1a2e #FFF8DC',
'completion-menu.completion': 'bg:#1a1a2e #FFF8DC',
'completion-menu.completion.current': 'bg:#333355 #FFD700',
'completion-menu.meta.completion': 'bg:#1a1a2e #888888',
'completion-menu.meta.completion.current': 'bg:#333355 #FFBF00',
})
# Create the application
@@ -1635,13 +1701,31 @@ class HermesCLI:
app.exit()
continue
# Expand paste references back to full content
import re as _re
paste_match = _re.match(r'\[Pasted text #\d+: \d+ lines → (.+)\]', user_input)
if paste_match:
paste_path = Path(paste_match.group(1))
if paste_path.exists():
full_text = paste_path.read_text(encoding="utf-8")
line_count = full_text.count('\n') + 1
print(f"\n💬 You: [Pasted text: {line_count} lines]")
user_input = full_text
else:
print(f"\n💬 You: {user_input}")
else:
# Echo multi-line input compactly
if '\n' in user_input:
first_line = user_input.split('\n')[0]
line_count = user_input.count('\n') + 1
print(f"\n💬 You: {first_line} (+{line_count - 1} lines)")
else:
print(f"\n💬 You: {user_input}")
# Regular chat - run agent
self._agent_running = True
app.invalidate() # Refresh status line
# Echo the user's input so it stays visible in scrollback
print(f"\n💬 You: {user_input}")
try:
self.chat(user_input)
finally:

View File

@@ -2280,7 +2280,7 @@ class AIAgent:
thinking_spinner.stop("")
else:
face = random.choice(["(◕‿◕✿)", "ヾ(^∇^)", "(≧◡≦)", "✧٩(ˊᗜˋ*)و✧", "(*^▽^*)"])
thinking_spinner.stop(f"{face} got it! ({api_duration:.1f}s)")
thinking_spinner.stop(f"{face} ⚕ ready ({api_duration:.1f}s)")
thinking_spinner = None
if not self.quiet_mode: