Skip to content

Commit a460a6e

Browse files
authored
Merge pull request #20 from damassi/feat/scroll-through-convos
feat: Add prev/next message navigation
2 parents 46532f4 + 56eff4f commit a460a6e

File tree

7 files changed

+284
-23
lines changed

7 files changed

+284
-23
lines changed

docs/architecture.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,32 @@ Text input with:
103103
- Enter to submit
104104
- Ctrl+J for newlines
105105
- `/` opens slash command menu
106+
- Up/Down arrows to navigate message history (bash-like command history)
106107

107108
**SlashCommandMenu** (`components/slash_command_menu.py`)
108109
Command menu triggered by `/`:
109110
- Fuzzy filtering as you type (text shows in input)
110-
- Commands: `/new`, `/clear`, `/exit`
111+
- Commands: `/new`, `/clear`, `/save`, `/exit`
111112
- Backspace removes filter chars; closes menu when empty
112113
- Escape closes and clears
113114

115+
### Message History Navigation
116+
117+
The UserInput component maintains a bash-like command history:
118+
- **Up arrow**: Navigate backward through previous messages
119+
- **Down arrow**: Navigate forward through history
120+
- **Draft preservation**: Current input is saved when navigating and restored when returning to present
121+
- History persists for the app session
122+
- Empty history is handled gracefully
123+
124+
### Save Conversation
125+
126+
The `/save` slash command saves the current conversation to a markdown file:
127+
- Output location: `~/.claude/agent-chat-cli/convo-{timestamp}.md`
128+
- Includes all message types: system, user, agent, and tool messages
129+
- Tool messages are formatted as JSON code blocks
130+
- Messages are separated by markdown horizontal rules
131+
114132
**ToolPermissionPrompt** (`components/tool_permission_prompt.py`)
115133
Modal prompt for tool permission requests:
116134
- Shows tool name and MCP server

src/agent_chat_cli/components/user_input.py

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ def __init__(self, actions: Actions) -> None:
2020
super().__init__()
2121
self.actions = actions
2222

23+
self.message_history: list[str] = []
24+
self.history_index: int | None = None
25+
self.draft_message: str = ""
26+
2327
def compose(self) -> ComposeResult:
2428
with Flex():
2529
yield Caret()
@@ -64,14 +68,23 @@ def on_text_area_changed(self, event: TextArea.Changed) -> None:
6468
menu.show()
6569

6670
async def on_key(self, event) -> None:
67-
if event.key == Key.CTRL_J.value:
68-
self._insert_newline(event)
69-
return
70-
7171
menu = self.query_one(SlashCommandMenu)
7272

7373
if menu.is_visible:
7474
self._close_menu(event)
75+
return
76+
77+
if event.key == "up":
78+
await self._navigate_history(event, direction=-1)
79+
return
80+
81+
if event.key == "down":
82+
await self._navigate_history(event, direction=1)
83+
return
84+
85+
if event.key == Key.CTRL_J.value:
86+
self._insert_newline(event)
87+
return
7588

7689
def _insert_newline(self, event) -> None:
7790
event.stop()
@@ -104,6 +117,36 @@ def _close_menu(self, event) -> None:
104117
input_widget.clear()
105118
input_widget.focus()
106119

120+
async def _navigate_history(self, event, direction: int) -> None:
121+
event.stop()
122+
event.prevent_default()
123+
124+
input_widget = self.query_one(TextArea)
125+
126+
if direction < 0:
127+
if not self.message_history:
128+
return
129+
130+
if self.history_index is None:
131+
self.draft_message = input_widget.text
132+
self.history_index = len(self.message_history) - 1
133+
elif self.history_index > 0:
134+
self.history_index -= 1
135+
else:
136+
if self.history_index is None:
137+
return
138+
139+
self.history_index += 1
140+
141+
if self.history_index >= len(self.message_history):
142+
self.history_index = None
143+
input_widget.text = self.draft_message
144+
input_widget.move_cursor_relative(rows=999, columns=999)
145+
return
146+
147+
input_widget.text = self.message_history[self.history_index]
148+
input_widget.move_cursor_relative(rows=999, columns=999)
149+
107150
async def action_submit(self) -> None:
108151
menu = self.query_one(SlashCommandMenu)
109152

@@ -121,5 +164,9 @@ async def action_submit(self) -> None:
121164
if not user_message:
122165
return
123166

167+
self.message_history.append(user_message)
168+
self.history_index = None
169+
self.draft_message = ""
170+
124171
input_widget.clear()
125172
await self.actions.post_user_message(user_message)

src/agent_chat_cli/prompts/system.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
You are a helpful AI assistant in a terminal-based chat application.
44

55
You should:
6+
7+
- Address yourself as Agent, not Claude
68
- Provide clear, concise responses
79
- Use markdown formatting when helpful
810
- Be direct and efficient in your communication

src/agent_chat_cli/utils/save_conversation.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
)
1111
from agent_chat_cli.components.chat_history import ChatHistory
1212

13+
CONVERSATION_OUTPUT_DIR = Path.home() / ".claude" / "agent-chat-cli"
14+
1315

1416
def save_conversation(chat_history: ChatHistory) -> str:
1517
messages = []
@@ -31,10 +33,9 @@ def save_conversation(chat_history: ChatHistory) -> str:
3133

3234
content = "\n---\n\n".join(messages)
3335
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+
CONVERSATION_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
3637

37-
output_file = output_dir / f"convo-{timestamp}.md"
38+
output_file = CONVERSATION_OUTPUT_DIR / f"convo-{timestamp}.md"
3839
output_file.write_text(content)
3940

4041
return str(output_file)

tests/components/test_user_input.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,188 @@ async def test_clears_input_after_filtering_and_selecting(self, app):
131131

132132
assert text_area.text == ""
133133
app.mock_actions.save.assert_called_once()
134+
135+
136+
class TestUserInputHistory:
137+
@pytest.fixture
138+
def app(self):
139+
return UserInputApp()
140+
141+
async def test_up_arrow_on_empty_history_does_nothing(self, app):
142+
async with app.run_test() as pilot:
143+
user_input = app.query_one(UserInput)
144+
text_area = user_input.query_one(TextArea)
145+
146+
await pilot.press("up")
147+
148+
assert text_area.text == ""
149+
assert user_input.history_index is None
150+
151+
async def test_up_arrow_loads_last_message(self, app):
152+
async with app.run_test() as pilot:
153+
user_input = app.query_one(UserInput)
154+
text_area = user_input.query_one(TextArea)
155+
156+
text_area.insert("first message")
157+
await pilot.press("enter")
158+
159+
await pilot.press("up")
160+
161+
assert text_area.text == "first message"
162+
assert user_input.history_index == 0
163+
164+
async def test_up_arrow_twice_loads_older_messages(self, app):
165+
async with app.run_test() as pilot:
166+
user_input = app.query_one(UserInput)
167+
text_area = user_input.query_one(TextArea)
168+
169+
text_area.insert("first message")
170+
await pilot.press("enter")
171+
172+
text_area.insert("second message")
173+
await pilot.press("enter")
174+
175+
await pilot.press("up")
176+
assert text_area.text == "second message"
177+
assert user_input.history_index == 1
178+
179+
await pilot.press("up")
180+
assert text_area.text == "first message"
181+
assert user_input.history_index == 0
182+
183+
async def test_up_arrow_at_oldest_does_nothing(self, app):
184+
async with app.run_test() as pilot:
185+
user_input = app.query_one(UserInput)
186+
text_area = user_input.query_one(TextArea)
187+
188+
text_area.insert("only message")
189+
await pilot.press("enter")
190+
191+
await pilot.press("up")
192+
await pilot.press("up")
193+
194+
assert text_area.text == "only message"
195+
assert user_input.history_index == 0
196+
197+
async def test_down_arrow_navigates_forward(self, app):
198+
async with app.run_test() as pilot:
199+
user_input = app.query_one(UserInput)
200+
text_area = user_input.query_one(TextArea)
201+
202+
text_area.insert("first message")
203+
await pilot.press("enter")
204+
205+
text_area.insert("second message")
206+
await pilot.press("enter")
207+
208+
await pilot.press("up")
209+
await pilot.press("up")
210+
assert text_area.text == "first message"
211+
212+
await pilot.press("down")
213+
assert text_area.text == "second message"
214+
assert user_input.history_index == 1
215+
216+
async def test_down_arrow_at_present_does_nothing(self, app):
217+
async with app.run_test() as pilot:
218+
user_input = app.query_one(UserInput)
219+
text_area = user_input.query_one(TextArea)
220+
221+
text_area.insert("test message")
222+
await pilot.press("enter")
223+
224+
await pilot.press("down")
225+
226+
assert text_area.text == ""
227+
assert user_input.history_index is None
228+
229+
async def test_down_arrow_from_history_restores_draft(self, app):
230+
async with app.run_test() as pilot:
231+
user_input = app.query_one(UserInput)
232+
text_area = user_input.query_one(TextArea)
233+
234+
text_area.insert("first message")
235+
await pilot.press("enter")
236+
237+
text_area.insert("my draft")
238+
await pilot.press("up")
239+
assert text_area.text == "first message"
240+
241+
await pilot.press("down")
242+
assert text_area.text == "my draft"
243+
assert user_input.history_index is None
244+
245+
async def test_empty_draft_preserved(self, app):
246+
async with app.run_test() as pilot:
247+
user_input = app.query_one(UserInput)
248+
text_area = user_input.query_one(TextArea)
249+
250+
text_area.insert("first message")
251+
await pilot.press("enter")
252+
253+
await pilot.press("up")
254+
assert text_area.text == "first message"
255+
256+
await pilot.press("down")
257+
assert text_area.text == ""
258+
assert user_input.history_index is None
259+
260+
async def test_submit_adds_to_history(self, app):
261+
async with app.run_test() as pilot:
262+
user_input = app.query_one(UserInput)
263+
text_area = user_input.query_one(TextArea)
264+
265+
text_area.insert("test message")
266+
await pilot.press("enter")
267+
268+
assert len(user_input.message_history) == 1
269+
assert user_input.message_history[0] == "test message"
270+
271+
async def test_submit_resets_history_state(self, app):
272+
async with app.run_test() as pilot:
273+
user_input = app.query_one(UserInput)
274+
text_area = user_input.query_one(TextArea)
275+
276+
text_area.insert("first message")
277+
await pilot.press("enter")
278+
279+
await pilot.press("up")
280+
assert user_input.history_index == 0
281+
282+
text_area.clear()
283+
text_area.insert("second message")
284+
await pilot.press("enter")
285+
286+
assert user_input.history_index is None
287+
assert user_input.draft_message == ""
288+
289+
async def test_multiline_message_in_history(self, app):
290+
async with app.run_test() as pilot:
291+
user_input = app.query_one(UserInput)
292+
text_area = user_input.query_one(TextArea)
293+
294+
text_area.insert("line1")
295+
await pilot.press("ctrl+j")
296+
text_area.insert("line2")
297+
await pilot.press("enter")
298+
299+
await pilot.press("up")
300+
301+
assert "line1\nline2" in text_area.text
302+
303+
async def test_history_with_slash_menu_open(self, app):
304+
async with app.run_test() as pilot:
305+
user_input = app.query_one(UserInput)
306+
text_area = user_input.query_one(TextArea)
307+
menu = user_input.query_one(SlashCommandMenu)
308+
309+
text_area.insert("test message")
310+
await pilot.press("enter")
311+
312+
await pilot.press("/")
313+
assert menu.is_visible is True
314+
315+
await pilot.press("up")
316+
317+
assert text_area.text == ""
318+
assert user_input.history_index is None

tests/core/test_actions.py

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

54
from agent_chat_cli.app import AgentChatCLIApp
@@ -12,6 +11,7 @@
1211
)
1312
from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt
1413
from agent_chat_cli.utils.enums import ControlCommand
14+
from agent_chat_cli.utils import save_conversation
1515

1616

1717
@pytest.fixture
@@ -205,7 +205,8 @@ class TestActionsSave:
205205
async def test_saves_conversation_to_file(
206206
self, mock_agent_loop, mock_config, tmp_path, monkeypatch
207207
):
208-
monkeypatch.setattr(Path, "home", lambda: tmp_path)
208+
output_dir = tmp_path / ".claude" / "agent-chat-cli"
209+
monkeypatch.setattr(save_conversation, "CONVERSATION_OUTPUT_DIR", output_dir)
209210

210211
app = AgentChatCLIApp()
211212
async with app.run_test():
@@ -221,7 +222,8 @@ async def test_saves_conversation_to_file(
221222
async def test_adds_system_message_with_file_path(
222223
self, mock_agent_loop, mock_config, tmp_path, monkeypatch
223224
):
224-
monkeypatch.setattr(Path, "home", lambda: tmp_path)
225+
output_dir = tmp_path / ".claude" / "agent-chat-cli"
226+
monkeypatch.setattr(save_conversation, "CONVERSATION_OUTPUT_DIR", output_dir)
225227

226228
app = AgentChatCLIApp()
227229
async with app.run_test():
@@ -241,7 +243,8 @@ async def test_does_not_trigger_thinking(
241243
):
242244
from agent_chat_cli.components.thinking_indicator import ThinkingIndicator
243245

244-
monkeypatch.setattr(Path, "home", lambda: tmp_path)
246+
output_dir = tmp_path / ".claude" / "agent-chat-cli"
247+
monkeypatch.setattr(save_conversation, "CONVERSATION_OUTPUT_DIR", output_dir)
245248

246249
app = AgentChatCLIApp()
247250
async with app.run_test():

0 commit comments

Comments
 (0)