Skip to content

Commit 3aa06eb

Browse files
authored
feat: implement persistent CLI history with file storage (#2769)
- Add Drop trait to InputSource for automatic history saving - Replace DefaultHistory with FileHistory for persistence - Store history in ~/.aws/amazonq/cli_history - Refactor ChatHinter to use rustyline's built-in history search - Remove manual history tracking in favor of rustyline's implementation - Add history loading on startup with error handling - Clean up unused hinter history update methods
1 parent 46f1f32 commit 3aa06eb

File tree

3 files changed

+94
-46
lines changed

3 files changed

+94
-46
lines changed

crates/chat-cli/src/cli/chat/input_source.rs

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,33 @@ mod inner {
3131
}
3232
}
3333

34+
impl Drop for InputSource {
35+
fn drop(&mut self) {
36+
self.save_history().unwrap();
37+
}
38+
}
3439
impl InputSource {
3540
pub fn new(os: &Os, sender: PromptQuerySender, receiver: PromptQueryResponseReceiver) -> Result<Self> {
3641
Ok(Self(inner::Inner::Readline(rl(os, sender, receiver)?)))
3742
}
3843

44+
/// Save history to file
45+
pub fn save_history(&mut self) -> Result<()> {
46+
if let inner::Inner::Readline(rl) = &mut self.0 {
47+
if let Some(helper) = rl.helper() {
48+
let history_path = helper.get_history_path();
49+
50+
// Create directory if it doesn't exist
51+
if let Some(parent) = history_path.parent() {
52+
std::fs::create_dir_all(parent)?;
53+
}
54+
55+
rl.append_history(&history_path)?;
56+
}
57+
}
58+
Ok(())
59+
}
60+
3961
#[cfg(unix)]
4062
pub fn put_skim_command_selector(
4163
&mut self,
@@ -78,12 +100,9 @@ impl InputSource {
78100
let curr_line = rl.readline(prompt);
79101
match curr_line {
80102
Ok(line) => {
81-
let _ = rl.add_history_entry(line.as_str());
82-
83-
if let Some(helper) = rl.helper_mut() {
84-
helper.update_hinter_history(&line);
103+
if Self::should_append_history(&line) {
104+
let _ = rl.add_history_entry(line.as_str());
85105
}
86-
87106
Ok(Some(line))
88107
},
89108
Err(ReadlineError::Interrupted | ReadlineError::Eof) => Ok(None),
@@ -97,6 +116,18 @@ impl InputSource {
97116
}
98117
}
99118

119+
fn should_append_history(line: &str) -> bool {
120+
let trimmed = line.trim().to_lowercase();
121+
if trimmed.is_empty() {
122+
return false;
123+
}
124+
125+
if matches!(trimmed.as_str(), "y" | "n" | "t") {
126+
return false;
127+
}
128+
true
129+
}
130+
100131
// We're keeping this method for potential future use
101132
#[allow(dead_code)]
102133
pub fn set_buffer(&mut self, content: &str) {

crates/chat-cli/src/cli/chat/prompt.rs

Lines changed: 53 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::borrow::Cow;
22
use std::cell::RefCell;
3+
use std::path::PathBuf;
34

45
use eyre::Result;
56
use rustyline::completion::{
@@ -13,7 +14,10 @@ use rustyline::highlight::{
1314
Highlighter,
1415
};
1516
use rustyline::hint::Hinter as RustylineHinter;
16-
use rustyline::history::DefaultHistory;
17+
use rustyline::history::{
18+
FileHistory,
19+
SearchDirection,
20+
};
1721
use rustyline::validate::{
1822
ValidationContext,
1923
ValidationResult,
@@ -44,6 +48,7 @@ use super::tool_manager::{
4448
};
4549
use crate::database::settings::Setting;
4650
use crate::os::Os;
51+
use crate::util::directories::chat_cli_bash_history_path;
4752

4853
pub const COMMANDS: &[&str] = &[
4954
"/clear",
@@ -262,31 +267,26 @@ impl Completer for ChatCompleter {
262267

263268
/// Custom hinter that provides shadowtext suggestions
264269
pub struct ChatHinter {
265-
/// Command history for providing suggestions based on past commands
266-
history: Vec<String>,
267270
/// Whether history-based hints are enabled
268271
history_hints_enabled: bool,
272+
history_path: PathBuf,
269273
}
270274

271275
impl ChatHinter {
272276
/// Creates a new ChatHinter instance
273-
pub fn new(history_hints_enabled: bool) -> Self {
277+
pub fn new(history_hints_enabled: bool, history_path: PathBuf) -> Self {
274278
Self {
275-
history: Vec::new(),
276279
history_hints_enabled,
280+
history_path,
277281
}
278282
}
279283

280-
/// Updates the history with a new command
281-
pub fn update_history(&mut self, command: &str) {
282-
let command = command.trim();
283-
if !command.is_empty() && !command.contains('\n') && !command.contains('\r') {
284-
self.history.push(command.to_string());
285-
}
284+
pub fn get_history_path(&self) -> PathBuf {
285+
self.history_path.clone()
286286
}
287287

288-
/// Finds the best hint for the current input
289-
fn find_hint(&self, line: &str) -> Option<String> {
288+
/// Finds the best hint for the current input using rustyline's history
289+
fn find_hint(&self, line: &str, ctx: &Context<'_>) -> Option<String> {
290290
// If line is empty, no hint
291291
if line.is_empty() {
292292
return None;
@@ -300,13 +300,20 @@ impl ChatHinter {
300300
.map(|cmd| cmd[line.len()..].to_string());
301301
}
302302

303-
// Try to find a hint from history if history hints are enabled
303+
// Try to find a hint from rustyline's history if history hints are enabled
304304
if self.history_hints_enabled {
305-
return self.history
306-
.iter()
307-
.rev() // Start from most recent
308-
.find(|cmd| cmd.starts_with(line) && cmd.len() > line.len())
309-
.map(|cmd| cmd[line.len()..].to_string());
305+
let history = ctx.history();
306+
let history_len = history.len();
307+
if history_len == 0 {
308+
return None;
309+
}
310+
311+
if let Ok(Some(search_result)) = history.starts_with(line, history_len - 1, SearchDirection::Reverse) {
312+
let entry = search_result.entry.to_string();
313+
if entry.len() > line.len() {
314+
return Some(entry[line.len()..].to_string());
315+
}
316+
}
310317
}
311318

312319
None
@@ -316,13 +323,13 @@ impl ChatHinter {
316323
impl RustylineHinter for ChatHinter {
317324
type Hint = String;
318325

319-
fn hint(&self, line: &str, pos: usize, _ctx: &Context<'_>) -> Option<Self::Hint> {
326+
fn hint(&self, line: &str, pos: usize, ctx: &Context<'_>) -> Option<Self::Hint> {
320327
// Only provide hints when cursor is at the end of the line
321328
if pos < line.len() {
322329
return None;
323330
}
324331

325-
self.find_hint(line)
332+
self.find_hint(line, ctx)
326333
}
327334
}
328335

@@ -363,9 +370,8 @@ pub struct ChatHelper {
363370
}
364371

365372
impl ChatHelper {
366-
/// Updates the history of the ChatHinter with a new command
367-
pub fn update_hinter_history(&mut self, command: &str) {
368-
self.hinter.update_history(command);
373+
pub fn get_history_path(&self) -> PathBuf {
374+
self.hinter.get_history_path()
369375
}
370376
}
371377

@@ -426,7 +432,7 @@ pub fn rl(
426432
os: &Os,
427433
sender: PromptQuerySender,
428434
receiver: PromptQueryResponseReceiver,
429-
) -> Result<Editor<ChatHelper, DefaultHistory>> {
435+
) -> Result<Editor<ChatHelper, FileHistory>> {
430436
let edit_mode = match os.database.settings.get_string(Setting::ChatEditMode).as_deref() {
431437
Some("vi" | "vim") => EditMode::Vi,
432438
_ => EditMode::Emacs,
@@ -437,21 +443,30 @@ pub fn rl(
437443
.edit_mode(edit_mode)
438444
.build();
439445

440-
// Default to disabled if setting doesn't exist
441446
let history_hints_enabled = os
442447
.database
443448
.settings
444449
.get_bool(Setting::ChatEnableHistoryHints)
445450
.unwrap_or(false);
451+
452+
let history_path = chat_cli_bash_history_path(os)?;
453+
446454
let h = ChatHelper {
447455
completer: ChatCompleter::new(sender, receiver),
448-
hinter: ChatHinter::new(history_hints_enabled),
456+
hinter: ChatHinter::new(history_hints_enabled, history_path),
449457
validator: MultiLineValidator,
450458
};
451459

452460
let mut rl = Editor::with_config(config)?;
453461
rl.set_helper(Some(h));
454462

463+
// Load history from ~/.aws/amazonq/cli_history
464+
if let Err(e) = rl.load_history(&rl.helper().unwrap().get_history_path()) {
465+
if !matches!(e, ReadlineError::Io(ref io_err) if io_err.kind() == std::io::ErrorKind::NotFound) {
466+
eprintln!("Warning: Failed to load history: {}", e);
467+
}
468+
}
469+
455470
// Add custom keybinding for Alt+Enter to insert a newline
456471
rl.bind_sequence(
457472
KeyEvent(KeyCode::Enter, Modifiers::ALT),
@@ -487,6 +502,7 @@ pub fn rl(
487502
mod tests {
488503
use crossterm::style::Stylize;
489504
use rustyline::highlight::Highlighter;
505+
use rustyline::history::DefaultHistory;
490506

491507
use super::*;
492508

@@ -537,7 +553,7 @@ mod tests {
537553
let (_, prompt_response_receiver) = tokio::sync::broadcast::channel::<PromptQueryResult>(5);
538554
let helper = ChatHelper {
539555
completer: ChatCompleter::new(prompt_request_sender, prompt_response_receiver),
540-
hinter: ChatHinter::new(true),
556+
hinter: ChatHinter::new(true, PathBuf::new()),
541557
validator: MultiLineValidator,
542558
};
543559

@@ -553,7 +569,7 @@ mod tests {
553569
let (_, prompt_response_receiver) = tokio::sync::broadcast::channel::<PromptQueryResult>(5);
554570
let helper = ChatHelper {
555571
completer: ChatCompleter::new(prompt_request_sender, prompt_response_receiver),
556-
hinter: ChatHinter::new(true),
572+
hinter: ChatHinter::new(true, PathBuf::new()),
557573
validator: MultiLineValidator,
558574
};
559575

@@ -569,7 +585,7 @@ mod tests {
569585
let (_, prompt_response_receiver) = tokio::sync::broadcast::channel::<PromptQueryResult>(5);
570586
let helper = ChatHelper {
571587
completer: ChatCompleter::new(prompt_request_sender, prompt_response_receiver),
572-
hinter: ChatHinter::new(true),
588+
hinter: ChatHinter::new(true, PathBuf::new()),
573589
validator: MultiLineValidator,
574590
};
575591

@@ -585,7 +601,7 @@ mod tests {
585601
let (_, prompt_response_receiver) = tokio::sync::broadcast::channel::<PromptQueryResult>(5);
586602
let helper = ChatHelper {
587603
completer: ChatCompleter::new(prompt_request_sender, prompt_response_receiver),
588-
hinter: ChatHinter::new(true),
604+
hinter: ChatHinter::new(true, PathBuf::new()),
589605
validator: MultiLineValidator,
590606
};
591607

@@ -604,7 +620,7 @@ mod tests {
604620
let (_, prompt_response_receiver) = tokio::sync::broadcast::channel::<PromptQueryResult>(5);
605621
let helper = ChatHelper {
606622
completer: ChatCompleter::new(prompt_request_sender, prompt_response_receiver),
607-
hinter: ChatHinter::new(true),
623+
hinter: ChatHinter::new(true, PathBuf::new()),
608624
validator: MultiLineValidator,
609625
};
610626

@@ -620,7 +636,7 @@ mod tests {
620636
let (_, prompt_response_receiver) = tokio::sync::broadcast::channel::<PromptQueryResult>(1);
621637
let helper = ChatHelper {
622638
completer: ChatCompleter::new(prompt_request_sender, prompt_response_receiver),
623-
hinter: ChatHinter::new(true),
639+
hinter: ChatHinter::new(true, PathBuf::new()),
624640
validator: MultiLineValidator,
625641
};
626642

@@ -635,7 +651,7 @@ mod tests {
635651
let (_, prompt_response_receiver) = tokio::sync::broadcast::channel::<PromptQueryResult>(1);
636652
let helper = ChatHelper {
637653
completer: ChatCompleter::new(prompt_request_sender, prompt_response_receiver),
638-
hinter: ChatHinter::new(true),
654+
hinter: ChatHinter::new(true, PathBuf::new()),
639655
validator: MultiLineValidator,
640656
};
641657

@@ -650,7 +666,7 @@ mod tests {
650666
let (_, prompt_response_receiver) = tokio::sync::broadcast::channel::<PromptQueryResult>(1);
651667
let helper = ChatHelper {
652668
completer: ChatCompleter::new(prompt_request_sender, prompt_response_receiver),
653-
hinter: ChatHinter::new(true),
669+
hinter: ChatHinter::new(true, PathBuf::new()),
654670
validator: MultiLineValidator,
655671
};
656672

@@ -664,7 +680,7 @@ mod tests {
664680

665681
#[test]
666682
fn test_chat_hinter_command_hint() {
667-
let hinter = ChatHinter::new(true);
683+
let hinter = ChatHinter::new(true, PathBuf::new());
668684

669685
// Test hint for a command
670686
let line = "/he";
@@ -694,11 +710,7 @@ mod tests {
694710

695711
#[test]
696712
fn test_chat_hinter_history_hint_disabled() {
697-
let mut hinter = ChatHinter::new(false);
698-
699-
// Add some history
700-
hinter.update_history("Hello, world!");
701-
hinter.update_history("How are you?");
713+
let hinter = ChatHinter::new(false, PathBuf::new());
702714

703715
// Test hint from history - should be None since history hints are disabled
704716
let line = "How";

crates/chat-cli/src/util/directories.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ type Result<T, E = DirectoryError> = std::result::Result<T, E>;
4444

4545
const WORKSPACE_AGENT_DIR_RELATIVE: &str = ".amazonq/cli-agents";
4646
const GLOBAL_AGENT_DIR_RELATIVE_TO_HOME: &str = ".aws/amazonq/cli-agents";
47+
const CLI_BASH_HISTORY_PATH: &str = ".aws/amazonq/.cli_bash_history";
4748

4849
/// The directory of the users home
4950
///
@@ -158,6 +159,10 @@ pub fn chat_legacy_global_mcp_config(os: &Os) -> Result<PathBuf> {
158159
Ok(home_dir(os)?.join(".aws").join("amazonq").join("mcp.json"))
159160
}
160161

162+
pub fn chat_cli_bash_history_path(os: &Os) -> Result<PathBuf> {
163+
Ok(home_dir(os)?.join(CLI_BASH_HISTORY_PATH))
164+
}
165+
161166
/// Legacy workspace MCP server config path
162167
pub fn chat_legacy_workspace_mcp_config(os: &Os) -> Result<PathBuf> {
163168
let cwd = os.env.current_dir()?;

0 commit comments

Comments
 (0)