diff --git a/src/agent_chat_cli/components/slash_command_menu.py b/src/agent_chat_cli/components/slash_command_menu.py index 5a3a074..6f663fa 100644 --- a/src/agent_chat_cli/components/slash_command_menu.py +++ b/src/agent_chat_cli/components/slash_command_menu.py @@ -11,6 +11,7 @@ COMMANDS = [ {"id": "new", "label": "/new - Start new conversation"}, {"id": "clear", "label": "/clear - Clear chat history"}, + {"id": "save", "label": "/save - Save conversation to markdown"}, {"id": "exit", "label": "/exit - Exit"}, ] @@ -83,3 +84,5 @@ async def on_option_list_option_selected( await self.actions.clear() case "new": await self.actions.new() + case "save": + await self.actions.save() diff --git a/src/agent_chat_cli/components/user_input.py b/src/agent_chat_cli/components/user_input.py index cccf091..5b00c96 100644 --- a/src/agent_chat_cli/components/user_input.py +++ b/src/agent_chat_cli/components/user_input.py @@ -110,7 +110,9 @@ async def action_submit(self) -> None: if menu.is_visible: option_list = menu.query_one(OptionList) option_list.action_select() - self.query_one(TextArea).focus() + input_widget = self.query_one(TextArea) + input_widget.clear() + input_widget.focus() return input_widget = self.query_one(TextArea) diff --git a/src/agent_chat_cli/core/actions.py b/src/agent_chat_cli/core/actions.py index c8e421e..3bf9d78 100644 --- a/src/agent_chat_cli/core/actions.py +++ b/src/agent_chat_cli/core/actions.py @@ -2,8 +2,10 @@ from agent_chat_cli.utils.enums import ControlCommand from agent_chat_cli.components.messages import RoleType +from agent_chat_cli.components.chat_history import ChatHistory from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt from agent_chat_cli.utils.logger import log_json +from agent_chat_cli.utils.save_conversation import save_conversation if TYPE_CHECKING: from agent_chat_cli.app import AgentChatCLIApp @@ -20,8 +22,8 @@ async def post_user_message(self, message: str) -> None: await self.app.renderer.add_message(RoleType.USER, message) await self._query(message) - async def post_system_message(self, message: str) -> None: - await self.app.renderer.add_message(RoleType.SYSTEM, message) + async def post_system_message(self, message: str, thinking: bool = True) -> None: + await self.app.renderer.add_message(RoleType.SYSTEM, message, thinking=thinking) async def post_app_event(self, event) -> None: await self.app.renderer.handle_app_event(event) @@ -63,5 +65,12 @@ async def respond_to_tool_permission(self, response: str) -> None: else: await self.post_user_message(response) + async def save(self) -> None: + chat_history = self.app.query_one(ChatHistory) + file_path = save_conversation(chat_history) + await self.post_system_message( + f"Conversation saved to {file_path}", thinking=False + ) + async def _query(self, user_input: str) -> None: await self.app.agent_loop.query_queue.put(user_input) diff --git a/src/agent_chat_cli/core/renderer.py b/src/agent_chat_cli/core/renderer.py index 6f64596..443467f 100644 --- a/src/agent_chat_cli/core/renderer.py +++ b/src/agent_chat_cli/core/renderer.py @@ -56,7 +56,9 @@ async def handle_app_event(self, event: AppEvent) -> None: if event.type is not AppEventType.RESULT: await self.app.ui_state.scroll_to_bottom() - async def add_message(self, type: RoleType, content: str) -> None: + async def add_message( + self, type: RoleType, content: str, thinking: bool = True + ) -> None: match type: case RoleType.USER: message = Message.user(content) @@ -70,7 +72,8 @@ async def add_message(self, type: RoleType, content: str) -> None: chat_history = self.app.query_one(ChatHistory) chat_history.add_message(message) - self.app.ui_state.start_thinking() + if thinking: + self.app.ui_state.start_thinking() await self.app.ui_state.scroll_to_bottom() async def reset_chat_history(self) -> None: diff --git a/src/agent_chat_cli/utils/save_conversation.py b/src/agent_chat_cli/utils/save_conversation.py new file mode 100644 index 0000000..6d61ece --- /dev/null +++ b/src/agent_chat_cli/utils/save_conversation.py @@ -0,0 +1,40 @@ +from datetime import datetime +from pathlib import Path +from textual.widgets import Markdown + +from agent_chat_cli.components.messages import ( + SystemMessage, + UserMessage, + AgentMessage, + ToolMessage, +) +from agent_chat_cli.components.chat_history import ChatHistory + + +def save_conversation(chat_history: ChatHistory) -> str: + messages = [] + + for widget in chat_history.children: + match widget: + case SystemMessage(): + messages.append(f"# System\n\n{widget.message}\n") + case UserMessage(): + messages.append(f"# You\n\n{widget.message}\n") + case AgentMessage(): + markdown_widget = widget.query_one(Markdown) + messages.append(f"# Agent\n\n{markdown_widget.source}\n") + case ToolMessage(): + tool_input_str = str(widget.tool_input) + messages.append( + f"# Tool: {widget.tool_name}\n\n```json\n{tool_input_str}\n```\n" + ) + + content = "\n---\n\n".join(messages) + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + output_dir = Path.home() / ".claude" / "agent-chat-cli" + output_dir.mkdir(parents=True, exist_ok=True) + + output_file = output_dir / f"convo-{timestamp}.md" + output_file.write_text(content) + + return str(output_file) diff --git a/tests/components/test_slash_command_menu.py b/tests/components/test_slash_command_menu.py index e63cd96..ce06245 100644 --- a/tests/components/test_slash_command_menu.py +++ b/tests/components/test_slash_command_menu.py @@ -14,6 +14,7 @@ def __init__(self): self.mock_actions.quit = MagicMock() self.mock_actions.clear = AsyncMock() self.mock_actions.new = AsyncMock() + self.mock_actions.save = AsyncMock() def compose(self) -> ComposeResult: yield SlashCommandMenu(actions=self.mock_actions) @@ -83,6 +84,7 @@ async def test_exit_command_calls_quit(self, app): menu = app.query_one(SlashCommandMenu) menu.show() + await pilot.press("down") await pilot.press("down") await pilot.press("down") await pilot.press("enter") diff --git a/tests/components/test_user_input.py b/tests/components/test_user_input.py index a145197..394835f 100644 --- a/tests/components/test_user_input.py +++ b/tests/components/test_user_input.py @@ -16,6 +16,7 @@ def __init__(self): self.mock_actions.interrupt = AsyncMock() self.mock_actions.new = AsyncMock() self.mock_actions.clear = AsyncMock() + self.mock_actions.save = AsyncMock() self.mock_actions.post_user_message = AsyncMock() def compose(self) -> ComposeResult: @@ -113,3 +114,20 @@ async def test_enter_selects_menu_item(self, app): await pilot.press("enter") app.mock_actions.new.assert_called_once() + + async def test_clears_input_after_filtering_and_selecting(self, app): + async with app.run_test() as pilot: + user_input = app.query_one(UserInput) + text_area = user_input.query_one(TextArea) + + await pilot.press("/") + await pilot.press("s") + await pilot.press("a") + await pilot.press("v") + + assert text_area.text == "sav" + + await pilot.press("enter") + + assert text_area.text == "" + app.mock_actions.save.assert_called_once() diff --git a/tests/core/test_actions.py b/tests/core/test_actions.py index 9328af8..e960c2c 100644 --- a/tests/core/test_actions.py +++ b/tests/core/test_actions.py @@ -1,4 +1,5 @@ import pytest +from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch from agent_chat_cli.app import AgentChatCLIApp @@ -198,3 +199,55 @@ async def test_deny_response_queries_agent(self, mock_agent_loop, mock_config): calls = mock_agent_loop.query_queue.put.call_args_list assert any("denied" in str(call).lower() for call in calls) + + +class TestActionsSave: + async def test_saves_conversation_to_file( + self, mock_agent_loop, mock_config, tmp_path, monkeypatch + ): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + app = AgentChatCLIApp() + async with app.run_test(): + await app.actions.post_user_message("Hello") + + await app.actions.save() + + output_dir = tmp_path / ".claude" / "agent-chat-cli" + assert output_dir.exists() + files = list(output_dir.glob("convo-*.md")) + assert len(files) == 1 + + async def test_adds_system_message_with_file_path( + self, mock_agent_loop, mock_config, tmp_path, monkeypatch + ): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + app = AgentChatCLIApp() + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + initial_count = len(chat_history.children) + + await app.actions.save() + + system_messages = chat_history.query(SystemMessage) + assert len(system_messages) == initial_count + 1 + last_message = system_messages.last() + assert "Conversation saved to" in last_message.message + assert ".claude/agent-chat-cli/convo-" in last_message.message + + async def test_does_not_trigger_thinking( + self, mock_agent_loop, mock_config, tmp_path, monkeypatch + ): + from agent_chat_cli.components.thinking_indicator import ThinkingIndicator + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.stop_thinking() + + await app.actions.save() + + thinking_indicator = app.query_one(ThinkingIndicator) + assert thinking_indicator.is_thinking is False diff --git a/tests/utils/test_save_conversation.py b/tests/utils/test_save_conversation.py new file mode 100644 index 0000000..4c388d5 --- /dev/null +++ b/tests/utils/test_save_conversation.py @@ -0,0 +1,122 @@ +from pathlib import Path + +from textual.app import App, ComposeResult +from textual.widgets import Markdown + +from agent_chat_cli.components.chat_history import ChatHistory +from agent_chat_cli.components.messages import ( + SystemMessage, + UserMessage, + AgentMessage, + ToolMessage, +) +from agent_chat_cli.utils.save_conversation import save_conversation + + +class TestSaveConversation: + async def test_saves_user_and_agent_messages(self, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + class TestApp(App): + def compose(self) -> ComposeResult: + yield ChatHistory() + + app = TestApp() + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + + user_msg = UserMessage() + user_msg.message = "Hello" + await chat_history.mount(user_msg) + + agent_msg = AgentMessage() + agent_msg.message = "Hi there!" + await chat_history.mount(agent_msg) + markdown_widget = agent_msg.query_one(Markdown) + markdown_widget.update("Hi there!") + + file_path = save_conversation(chat_history) + + assert Path(file_path).exists() + content = Path(file_path).read_text() + assert "# You" in content + assert "Hello" in content + assert "# Agent" in content + assert "Hi there!" in content + + async def test_saves_system_messages(self, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + class TestApp(App): + def compose(self) -> ComposeResult: + yield ChatHistory() + + app = TestApp() + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + + system_msg = SystemMessage() + system_msg.message = "Connection established" + await chat_history.mount(system_msg) + + file_path = save_conversation(chat_history) + + assert Path(file_path).exists() + content = Path(file_path).read_text() + assert "# System" in content + assert "Connection established" in content + + async def test_saves_tool_messages(self, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + class TestApp(App): + def compose(self) -> ComposeResult: + yield ChatHistory() + + app = TestApp() + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + + tool_msg = ToolMessage() + tool_msg.tool_name = "fetch_url" + tool_msg.tool_input = {"url": "https://example.com"} + await chat_history.mount(tool_msg) + + file_path = save_conversation(chat_history) + + assert Path(file_path).exists() + content = Path(file_path).read_text() + assert "# Tool: fetch_url" in content + assert "https://example.com" in content + + async def test_creates_directory_structure(self, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + class TestApp(App): + def compose(self) -> ComposeResult: + yield ChatHistory() + + app = TestApp() + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + file_path = save_conversation(chat_history) + + output_dir = tmp_path / ".claude" / "agent-chat-cli" + assert output_dir.exists() + assert Path(file_path).parent == output_dir + + async def test_uses_timestamp_in_filename(self, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + class TestApp(App): + def compose(self) -> ComposeResult: + yield ChatHistory() + + app = TestApp() + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + file_path = save_conversation(chat_history) + + filename = Path(file_path).name + assert filename.startswith("convo-") + assert filename.endswith(".md")