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
29 changes: 23 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,11 @@ on:
branches: [main]

jobs:
test:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install uv
uses: astral-sh/setup-uv@v4

Expand All @@ -26,8 +21,30 @@ jobs:
- name: Run linter
run: uv run ruff check src tests

typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v4

- name: Install dependencies
run: uv sync --all-groups

- name: Run type checker
run: uv run mypy src

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v4

- name: Install dependencies
run: uv sync --all-groups

- name: Run tests
run: uv run pytest
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ console:
uv run textual console -x SYSTEM -x EVENT -x DEBUG -x INFO

dev:
uv run textual run --dev -c chat
LOG_LEVEL=NOTSET uv run textual run --dev -c chat

lint:
uv run ruff check --fix src
Expand Down
2 changes: 1 addition & 1 deletion src/agent_chat_cli/components/header.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def compose(self) -> ComposeResult:
yield Spacer()

yield Label(
"[dim]Type your message and press Enter. Type 'exit' to quit.[/dim]",
"[dim]Type your message and press Enter. Press / for commands.[/dim]",
id="header-instructions",
classes="header-instructions",
)
Expand Down
45 changes: 45 additions & 0 deletions src/agent_chat_cli/components/slash_command_menu.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from textual.widget import Widget
from textual.app import ComposeResult
from textual.widgets import OptionList
from textual.widgets.option_list import Option

from agent_chat_cli.core.actions import Actions


class SlashCommandMenu(Widget):
def __init__(self, actions: Actions) -> None:
super().__init__()
self.actions = actions

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"),
)

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

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

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

async def on_option_list_option_selected(
self, event: OptionList.OptionSelected
) -> None:
self.hide()

match event.option_id:
case "exit":
self.actions.quit()
case "clear":
await self.actions.clear()
case "new":
await self.actions.new()
5 changes: 3 additions & 2 deletions src/agent_chat_cli/components/tool_permission_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from agent_chat_cli.components.flex import Flex
from agent_chat_cli.components.spacer import Spacer
from agent_chat_cli.utils import get_tool_info
from agent_chat_cli.utils.enums import Key
from agent_chat_cli.utils.logger import log_json

if TYPE_CHECKING:
Expand All @@ -22,7 +23,7 @@ class ToolPermissionPrompt(Widget):
tool_input: dict[str, Any] = reactive({}, init=False) # type: ignore[assignment]

BINDINGS = [
Binding("enter", "submit", "Submit", priority=True),
Binding(Key.ENTER.value, "submit", "Submit", priority=True),
]

def __init__(self, actions: "Actions") -> None:
Expand Down Expand Up @@ -92,7 +93,7 @@ def on_descendant_blur(self) -> None:
input_widget.focus()

async def on_key(self, event) -> None:
if event.key == "escape":
if event.key == Key.ESCAPE.value:
log_json({"event": "permission_escape_pressed"})

event.stop()
Expand Down
74 changes: 56 additions & 18 deletions src/agent_chat_cli/components/user_input.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
from textual.widget import Widget
from textual.app import ComposeResult
from textual.widgets import TextArea
from textual.widgets import TextArea, OptionList
from textual.binding import Binding
from textual.events import DescendantBlur

from agent_chat_cli.components.caret import Caret
from agent_chat_cli.components.flex import Flex
from agent_chat_cli.components.slash_command_menu import SlashCommandMenu
from agent_chat_cli.core.actions import Actions
from agent_chat_cli.utils.enums import ControlCommand
from agent_chat_cli.utils.enums import Key


class UserInput(Widget):
BINDINGS = [
Binding("enter", "submit", "Submit", priority=True),
Binding(Key.ENTER.value, "submit", "Submit", priority=True),
]

def __init__(self, actions: Actions) -> None:
Expand All @@ -27,38 +28,75 @@ def compose(self) -> ComposeResult:
show_line_numbers=False,
soft_wrap=True,
)
yield SlashCommandMenu(actions=self.actions)

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

def on_descendant_blur(self, event: DescendantBlur) -> None:
if isinstance(event.widget, TextArea):
menu = self.query_one(SlashCommandMenu)

if isinstance(event.widget, TextArea) and not menu.is_visible:
event.widget.focus(scroll_visible=False)
elif isinstance(event.widget, OptionList) and menu.is_visible:
menu.hide()
self.query_one(TextArea).focus(scroll_visible=False)

def on_text_area_changed(self, event: TextArea.Changed) -> None:
menu = self.query_one(SlashCommandMenu)
text = event.text_area.text

if text == Key.SLASH.value:
event.text_area.clear()
menu.show()

async def on_key(self, event) -> None:
if event.key == "ctrl+j":
event.stop()
event.prevent_default()
input_widget = self.query_one(TextArea)
input_widget.insert("\n")
if event.key == Key.CTRL_J.value:
self._insert_newline(event)
return

async def action_submit(self) -> None:
menu = self.query_one(SlashCommandMenu)

if menu.is_visible:
self._close_menu(event)

def _insert_newline(self, event) -> None:
event.stop()
event.prevent_default()
input_widget = self.query_one(TextArea)
user_message = input_widget.text.strip()
input_widget.insert("\n")

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

if user_message.lower() == ControlCommand.EXIT.value:
self.actions.quit()
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:
input_widget.clear()

async def action_submit(self) -> None:
menu = self.query_one(SlashCommandMenu)

if menu.is_visible:
option_list = menu.query_one(OptionList)
option_list.action_select()
self.query_one(TextArea).focus()
return

input_widget.clear()
input_widget = self.query_one(TextArea)
user_message = input_widget.text.strip()

if user_message.lower() == ControlCommand.CLEAR.value:
await self.actions.interrupt()
await self.actions.new()
if not user_message:
return

input_widget.clear()
await self.actions.submit_user_message(user_message)
8 changes: 5 additions & 3 deletions src/agent_chat_cli/core/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,16 @@ async def interrupt(self) -> None:
await self.app.agent_loop.client.interrupt()
self.app.ui_state.stop_thinking()

async def new(self) -> None:
await self.app.agent_loop.query_queue.put(ControlCommand.NEW_CONVERSATION)

async def clear(self) -> None:
chat_history = self.app.query_one(ChatHistory)
await chat_history.remove_children()

self.app.ui_state.stop_thinking()

async def new(self) -> None:
await self.app.agent_loop.query_queue.put(ControlCommand.NEW_CONVERSATION)
await self.clear()

async def respond_to_tool_permission(self, response: str) -> None:
log_json(
{
Expand Down
2 changes: 2 additions & 0 deletions src/agent_chat_cli/core/agent_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ async def start(self) -> None:
if user_input == ControlCommand.NEW_CONVERSATION:
await self.client.disconnect()

self.session_id = None

mcp_servers = {
name: config.model_dump()
for name, config in self.available_servers.items()
Expand Down
17 changes: 17 additions & 0 deletions src/agent_chat_cli/core/styles.tcss
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,20 @@ TextArea {
.tool-message {
padding-left: 2;
}

SlashCommandMenu {
height: auto;
max-height: 10;
display: none;
}

SlashCommandMenu.visible {
display: block;
}

SlashCommandMenu OptionList {
height: auto;
max-height: 10;
border: solid $primary;
background: $surface;
}
9 changes: 9 additions & 0 deletions src/agent_chat_cli/utils/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,12 @@ class ControlCommand(Enum):
NEW_CONVERSATION = "new_conversation"
EXIT = "exit"
CLEAR = "clear"


class Key(Enum):
ENTER = "enter"
ESCAPE = "escape"
BACKSPACE = "backspace"
DELETE = "delete"
CTRL_J = "ctrl+j"
SLASH = "/"
5 changes: 4 additions & 1 deletion src/agent_chat_cli/utils/logger.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import json
import logging
import os
from typing import Any

from textual.logging import TextualHandler


def setup_logging():
level = os.getenv("LOG_LEVEL", "INFO").upper()

logging.basicConfig(
level="NOTSET",
level=level,
handlers=[TextualHandler()],
)

Expand Down
Loading