Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 49 additions & 9 deletions src/agent_chat_cli/components/slash_command_menu.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,76 @@
from typing import Callable

from textual.widget import Widget
from textual.app import ComposeResult
from textual.containers import VerticalScroll
from textual.widgets import OptionList
from textual.widgets.option_list import Option

from agent_chat_cli.core.actions import Actions

COMMANDS = [
{"id": "new", "label": "/new - Start new conversation"},
{"id": "clear", "label": "/clear - Clear chat history"},
{"id": "exit", "label": "/exit - Exit"},
]


class SlashCommandMenu(Widget):
def __init__(self, actions: Actions) -> None:
def __init__(
self, actions: Actions, on_filter_change: Callable[[str], None] | None = None
) -> None:
super().__init__()
self.actions = actions
self.filter_text = ""
self.on_filter_change = on_filter_change

def compose(self) -> ComposeResult:
yield OptionList(
Option("/new - Start new conversation", id="new"),
Option("/clear - Clear chat history", id="clear"),
Option("/exit - Exit", id="exit"),
)
yield OptionList(*[Option(cmd["label"], id=cmd["id"]) for cmd in COMMANDS])

def show(self) -> None:
self.filter_text = ""
self.add_class("visible")
option_list = self.query_one(OptionList)
option_list.highlighted = 0
option_list.focus()
self._refresh_options()

scroll_containers = self.app.query(VerticalScroll)
if scroll_containers:
scroll_containers.first().scroll_end(animate=False)

def hide(self) -> None:
self.remove_class("visible")
self.filter_text = ""

@property
def is_visible(self) -> bool:
return self.has_class("visible")

def _refresh_options(self) -> None:
option_list = self.query_one(OptionList)
option_list.clear_options()

filtered = [
cmd for cmd in COMMANDS if self.filter_text.lower() in cmd["id"].lower()
]

for cmd in filtered:
option_list.add_option(Option(cmd["label"], id=cmd["id"]))

if filtered:
option_list.highlighted = 0

option_list.focus()

def on_key(self, event) -> None:
if not self.is_visible:
return

if event.is_printable and event.character:
self.filter_text += event.character
self._refresh_options()

if self.on_filter_change:
self.on_filter_change(event.character)

async def on_option_list_option_selected(
self, event: OptionList.OptionSelected
) -> None:
Expand Down
43 changes: 32 additions & 11 deletions src/agent_chat_cli/components/user_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,25 @@ def compose(self) -> ComposeResult:
show_line_numbers=False,
soft_wrap=True,
)
yield SlashCommandMenu(actions=self.actions)
yield SlashCommandMenu(
actions=self.actions, on_filter_change=self._on_filter_change
)

def _on_filter_change(self, char: str) -> None:
text_area = self.query_one(TextArea)
if char == Key.BACKSPACE.value:
text_area.action_delete_left()
else:
text_area.insert(char)

def on_mount(self) -> None:
input_widget = self.query_one(TextArea)
input_widget.focus()

def on_descendant_blur(self, event: DescendantBlur) -> None:
if not self.display:
return

menu = self.query_one(SlashCommandMenu)

if isinstance(event.widget, TextArea) and not menu.is_visible:
Expand Down Expand Up @@ -68,20 +80,29 @@ def _insert_newline(self, event) -> None:
input_widget.insert("\n")

def _close_menu(self, event) -> None:
if event.key not in (Key.ESCAPE.value, Key.BACKSPACE.value, Key.DELETE.value):
return

event.stop()
event.prevent_default()

menu = self.query_one(SlashCommandMenu)
menu.hide()

input_widget = self.query_one(TextArea)
input_widget.focus()

if event.key == Key.ESCAPE.value:
event.stop()
event.prevent_default()
menu.hide()
input_widget = self.query_one(TextArea)
input_widget.clear()
input_widget.focus()
return

if event.key in (Key.BACKSPACE.value, Key.DELETE.value):
if menu.filter_text:
menu.filter_text = menu.filter_text[:-1]
menu._refresh_options()
self.query_one(TextArea).action_delete_left()
else:
event.stop()
event.prevent_default()
menu.hide()
input_widget = self.query_one(TextArea)
input_widget.clear()
input_widget.focus()

async def action_submit(self) -> None:
menu = self.query_one(SlashCommandMenu)
Expand Down
67 changes: 67 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from textual.widgets import TextArea

from agent_chat_cli.app import AgentChatCLIApp
from agent_chat_cli.components.slash_command_menu import SlashCommandMenu
from agent_chat_cli.components.thinking_indicator import ThinkingIndicator
from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt
from agent_chat_cli.components.user_input import UserInput
Expand Down Expand Up @@ -161,3 +162,69 @@ async def test_escape_triggers_interrupt_when_menu_not_visible(
await pilot.press("escape")

assert app.ui_state.interrupting is True


class TestSlashCommandMenuBehavior:
async def test_slash_opens_menu(self, mock_agent_loop, mock_config):
app = AgentChatCLIApp()
async with app.run_test() as pilot:
await pilot.press("/")

menu = app.query_one(SlashCommandMenu)
assert menu.is_visible is True

async def test_escape_closes_menu_and_clears_input(
self, mock_agent_loop, mock_config
):
app = AgentChatCLIApp()
async with app.run_test() as pilot:
await pilot.press("/")
await pilot.press("c")
await pilot.press("escape")

menu = app.query_one(SlashCommandMenu)
text_area = app.query_one(UserInput).query_one(TextArea)

assert menu.is_visible is False
assert text_area.text == ""

async def test_typing_filters_menu_and_shows_in_textarea(
self, mock_agent_loop, mock_config
):
app = AgentChatCLIApp()
async with app.run_test() as pilot:
await pilot.press("/")
await pilot.press("c", "l")

menu = app.query_one(SlashCommandMenu)
text_area = app.query_one(UserInput).query_one(TextArea)

assert menu.filter_text == "cl"
assert text_area.text == "cl"

async def test_backspace_removes_filter_character(
self, mock_agent_loop, mock_config
):
app = AgentChatCLIApp()
async with app.run_test() as pilot:
await pilot.press("/")
await pilot.press("c", "l")
await pilot.press("backspace")

menu = app.query_one(SlashCommandMenu)
text_area = app.query_one(UserInput).query_one(TextArea)

assert menu.filter_text == "c"
assert text_area.text == "c"
assert menu.is_visible is True

async def test_backspace_on_empty_filter_closes_menu(
self, mock_agent_loop, mock_config
):
app = AgentChatCLIApp()
async with app.run_test() as pilot:
await pilot.press("/")
await pilot.press("backspace")

menu = app.query_one(SlashCommandMenu)
assert menu.is_visible is False