Skip to content

Commit 46532f4

Browse files
authored
Merge pull request #19 from damassi/feat/save-convo
feat: Add `save conversation` slash command
2 parents 2617551 + f81f48a commit 46532f4

File tree

9 files changed

+257
-5
lines changed

9 files changed

+257
-5
lines changed

src/agent_chat_cli/components/slash_command_menu.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
COMMANDS = [
1212
{"id": "new", "label": "/new - Start new conversation"},
1313
{"id": "clear", "label": "/clear - Clear chat history"},
14+
{"id": "save", "label": "/save - Save conversation to markdown"},
1415
{"id": "exit", "label": "/exit - Exit"},
1516
]
1617

@@ -83,3 +84,5 @@ async def on_option_list_option_selected(
8384
await self.actions.clear()
8485
case "new":
8586
await self.actions.new()
87+
case "save":
88+
await self.actions.save()

src/agent_chat_cli/components/user_input.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,9 @@ async def action_submit(self) -> None:
110110
if menu.is_visible:
111111
option_list = menu.query_one(OptionList)
112112
option_list.action_select()
113-
self.query_one(TextArea).focus()
113+
input_widget = self.query_one(TextArea)
114+
input_widget.clear()
115+
input_widget.focus()
114116
return
115117

116118
input_widget = self.query_one(TextArea)

src/agent_chat_cli/core/actions.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
from agent_chat_cli.utils.enums import ControlCommand
44
from agent_chat_cli.components.messages import RoleType
5+
from agent_chat_cli.components.chat_history import ChatHistory
56
from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt
67
from agent_chat_cli.utils.logger import log_json
8+
from agent_chat_cli.utils.save_conversation import save_conversation
79

810
if TYPE_CHECKING:
911
from agent_chat_cli.app import AgentChatCLIApp
@@ -20,8 +22,8 @@ async def post_user_message(self, message: str) -> None:
2022
await self.app.renderer.add_message(RoleType.USER, message)
2123
await self._query(message)
2224

23-
async def post_system_message(self, message: str) -> None:
24-
await self.app.renderer.add_message(RoleType.SYSTEM, message)
25+
async def post_system_message(self, message: str, thinking: bool = True) -> None:
26+
await self.app.renderer.add_message(RoleType.SYSTEM, message, thinking=thinking)
2527

2628
async def post_app_event(self, event) -> None:
2729
await self.app.renderer.handle_app_event(event)
@@ -63,5 +65,12 @@ async def respond_to_tool_permission(self, response: str) -> None:
6365
else:
6466
await self.post_user_message(response)
6567

68+
async def save(self) -> None:
69+
chat_history = self.app.query_one(ChatHistory)
70+
file_path = save_conversation(chat_history)
71+
await self.post_system_message(
72+
f"Conversation saved to {file_path}", thinking=False
73+
)
74+
6675
async def _query(self, user_input: str) -> None:
6776
await self.app.agent_loop.query_queue.put(user_input)

src/agent_chat_cli/core/renderer.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ async def handle_app_event(self, event: AppEvent) -> None:
5656
if event.type is not AppEventType.RESULT:
5757
await self.app.ui_state.scroll_to_bottom()
5858

59-
async def add_message(self, type: RoleType, content: str) -> None:
59+
async def add_message(
60+
self, type: RoleType, content: str, thinking: bool = True
61+
) -> None:
6062
match type:
6163
case RoleType.USER:
6264
message = Message.user(content)
@@ -70,7 +72,8 @@ async def add_message(self, type: RoleType, content: str) -> None:
7072
chat_history = self.app.query_one(ChatHistory)
7173
chat_history.add_message(message)
7274

73-
self.app.ui_state.start_thinking()
75+
if thinking:
76+
self.app.ui_state.start_thinking()
7477
await self.app.ui_state.scroll_to_bottom()
7578

7679
async def reset_chat_history(self) -> None:
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from datetime import datetime
2+
from pathlib import Path
3+
from textual.widgets import Markdown
4+
5+
from agent_chat_cli.components.messages import (
6+
SystemMessage,
7+
UserMessage,
8+
AgentMessage,
9+
ToolMessage,
10+
)
11+
from agent_chat_cli.components.chat_history import ChatHistory
12+
13+
14+
def save_conversation(chat_history: ChatHistory) -> str:
15+
messages = []
16+
17+
for widget in chat_history.children:
18+
match widget:
19+
case SystemMessage():
20+
messages.append(f"# System\n\n{widget.message}\n")
21+
case UserMessage():
22+
messages.append(f"# You\n\n{widget.message}\n")
23+
case AgentMessage():
24+
markdown_widget = widget.query_one(Markdown)
25+
messages.append(f"# Agent\n\n{markdown_widget.source}\n")
26+
case ToolMessage():
27+
tool_input_str = str(widget.tool_input)
28+
messages.append(
29+
f"# Tool: {widget.tool_name}\n\n```json\n{tool_input_str}\n```\n"
30+
)
31+
32+
content = "\n---\n\n".join(messages)
33+
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
34+
output_dir = Path.home() / ".claude" / "agent-chat-cli"
35+
output_dir.mkdir(parents=True, exist_ok=True)
36+
37+
output_file = output_dir / f"convo-{timestamp}.md"
38+
output_file.write_text(content)
39+
40+
return str(output_file)

tests/components/test_slash_command_menu.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def __init__(self):
1414
self.mock_actions.quit = MagicMock()
1515
self.mock_actions.clear = AsyncMock()
1616
self.mock_actions.new = AsyncMock()
17+
self.mock_actions.save = AsyncMock()
1718

1819
def compose(self) -> ComposeResult:
1920
yield SlashCommandMenu(actions=self.mock_actions)
@@ -83,6 +84,7 @@ async def test_exit_command_calls_quit(self, app):
8384
menu = app.query_one(SlashCommandMenu)
8485
menu.show()
8586

87+
await pilot.press("down")
8688
await pilot.press("down")
8789
await pilot.press("down")
8890
await pilot.press("enter")

tests/components/test_user_input.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def __init__(self):
1616
self.mock_actions.interrupt = AsyncMock()
1717
self.mock_actions.new = AsyncMock()
1818
self.mock_actions.clear = AsyncMock()
19+
self.mock_actions.save = AsyncMock()
1920
self.mock_actions.post_user_message = AsyncMock()
2021

2122
def compose(self) -> ComposeResult:
@@ -113,3 +114,20 @@ async def test_enter_selects_menu_item(self, app):
113114
await pilot.press("enter")
114115

115116
app.mock_actions.new.assert_called_once()
117+
118+
async def test_clears_input_after_filtering_and_selecting(self, app):
119+
async with app.run_test() as pilot:
120+
user_input = app.query_one(UserInput)
121+
text_area = user_input.query_one(TextArea)
122+
123+
await pilot.press("/")
124+
await pilot.press("s")
125+
await pilot.press("a")
126+
await pilot.press("v")
127+
128+
assert text_area.text == "sav"
129+
130+
await pilot.press("enter")
131+
132+
assert text_area.text == ""
133+
app.mock_actions.save.assert_called_once()

tests/core/test_actions.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import pytest
2+
from pathlib import Path
23
from unittest.mock import AsyncMock, MagicMock, patch
34

45
from agent_chat_cli.app import AgentChatCLIApp
@@ -198,3 +199,55 @@ async def test_deny_response_queries_agent(self, mock_agent_loop, mock_config):
198199

199200
calls = mock_agent_loop.query_queue.put.call_args_list
200201
assert any("denied" in str(call).lower() for call in calls)
202+
203+
204+
class TestActionsSave:
205+
async def test_saves_conversation_to_file(
206+
self, mock_agent_loop, mock_config, tmp_path, monkeypatch
207+
):
208+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
209+
210+
app = AgentChatCLIApp()
211+
async with app.run_test():
212+
await app.actions.post_user_message("Hello")
213+
214+
await app.actions.save()
215+
216+
output_dir = tmp_path / ".claude" / "agent-chat-cli"
217+
assert output_dir.exists()
218+
files = list(output_dir.glob("convo-*.md"))
219+
assert len(files) == 1
220+
221+
async def test_adds_system_message_with_file_path(
222+
self, mock_agent_loop, mock_config, tmp_path, monkeypatch
223+
):
224+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
225+
226+
app = AgentChatCLIApp()
227+
async with app.run_test():
228+
chat_history = app.query_one(ChatHistory)
229+
initial_count = len(chat_history.children)
230+
231+
await app.actions.save()
232+
233+
system_messages = chat_history.query(SystemMessage)
234+
assert len(system_messages) == initial_count + 1
235+
last_message = system_messages.last()
236+
assert "Conversation saved to" in last_message.message
237+
assert ".claude/agent-chat-cli/convo-" in last_message.message
238+
239+
async def test_does_not_trigger_thinking(
240+
self, mock_agent_loop, mock_config, tmp_path, monkeypatch
241+
):
242+
from agent_chat_cli.components.thinking_indicator import ThinkingIndicator
243+
244+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
245+
246+
app = AgentChatCLIApp()
247+
async with app.run_test():
248+
app.ui_state.stop_thinking()
249+
250+
await app.actions.save()
251+
252+
thinking_indicator = app.query_one(ThinkingIndicator)
253+
assert thinking_indicator.is_thinking is False
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
from pathlib import Path
2+
3+
from textual.app import App, ComposeResult
4+
from textual.widgets import Markdown
5+
6+
from agent_chat_cli.components.chat_history import ChatHistory
7+
from agent_chat_cli.components.messages import (
8+
SystemMessage,
9+
UserMessage,
10+
AgentMessage,
11+
ToolMessage,
12+
)
13+
from agent_chat_cli.utils.save_conversation import save_conversation
14+
15+
16+
class TestSaveConversation:
17+
async def test_saves_user_and_agent_messages(self, tmp_path, monkeypatch):
18+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
19+
20+
class TestApp(App):
21+
def compose(self) -> ComposeResult:
22+
yield ChatHistory()
23+
24+
app = TestApp()
25+
async with app.run_test():
26+
chat_history = app.query_one(ChatHistory)
27+
28+
user_msg = UserMessage()
29+
user_msg.message = "Hello"
30+
await chat_history.mount(user_msg)
31+
32+
agent_msg = AgentMessage()
33+
agent_msg.message = "Hi there!"
34+
await chat_history.mount(agent_msg)
35+
markdown_widget = agent_msg.query_one(Markdown)
36+
markdown_widget.update("Hi there!")
37+
38+
file_path = save_conversation(chat_history)
39+
40+
assert Path(file_path).exists()
41+
content = Path(file_path).read_text()
42+
assert "# You" in content
43+
assert "Hello" in content
44+
assert "# Agent" in content
45+
assert "Hi there!" in content
46+
47+
async def test_saves_system_messages(self, tmp_path, monkeypatch):
48+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
49+
50+
class TestApp(App):
51+
def compose(self) -> ComposeResult:
52+
yield ChatHistory()
53+
54+
app = TestApp()
55+
async with app.run_test():
56+
chat_history = app.query_one(ChatHistory)
57+
58+
system_msg = SystemMessage()
59+
system_msg.message = "Connection established"
60+
await chat_history.mount(system_msg)
61+
62+
file_path = save_conversation(chat_history)
63+
64+
assert Path(file_path).exists()
65+
content = Path(file_path).read_text()
66+
assert "# System" in content
67+
assert "Connection established" in content
68+
69+
async def test_saves_tool_messages(self, tmp_path, monkeypatch):
70+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
71+
72+
class TestApp(App):
73+
def compose(self) -> ComposeResult:
74+
yield ChatHistory()
75+
76+
app = TestApp()
77+
async with app.run_test():
78+
chat_history = app.query_one(ChatHistory)
79+
80+
tool_msg = ToolMessage()
81+
tool_msg.tool_name = "fetch_url"
82+
tool_msg.tool_input = {"url": "https://example.com"}
83+
await chat_history.mount(tool_msg)
84+
85+
file_path = save_conversation(chat_history)
86+
87+
assert Path(file_path).exists()
88+
content = Path(file_path).read_text()
89+
assert "# Tool: fetch_url" in content
90+
assert "https://example.com" in content
91+
92+
async def test_creates_directory_structure(self, tmp_path, monkeypatch):
93+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
94+
95+
class TestApp(App):
96+
def compose(self) -> ComposeResult:
97+
yield ChatHistory()
98+
99+
app = TestApp()
100+
async with app.run_test():
101+
chat_history = app.query_one(ChatHistory)
102+
file_path = save_conversation(chat_history)
103+
104+
output_dir = tmp_path / ".claude" / "agent-chat-cli"
105+
assert output_dir.exists()
106+
assert Path(file_path).parent == output_dir
107+
108+
async def test_uses_timestamp_in_filename(self, tmp_path, monkeypatch):
109+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
110+
111+
class TestApp(App):
112+
def compose(self) -> ComposeResult:
113+
yield ChatHistory()
114+
115+
app = TestApp()
116+
async with app.run_test():
117+
chat_history = app.query_one(ChatHistory)
118+
file_path = save_conversation(chat_history)
119+
120+
filename = Path(file_path).name
121+
assert filename.startswith("convo-")
122+
assert filename.endswith(".md")

0 commit comments

Comments
 (0)