Skip to content

Commit 298852a

Browse files
committed
feat: Add persistent chat history with directory isolation
Implements persistent chat history that survives CLI restarts and is isolated by working directory. Features: - Chat history persists across CLI sessions using SQLite database - History is isolated per working directory (/project/a vs /project/b) - Up arrow navigation works with persistent history - Dual cleanup limits: max 100 entries per directory, 1000 total - Silent failure: history issues don't break CLI functionality - Filters out empty/whitespace-only inputs Implementation: - Database table: chat_history(input TEXT, cwd TEXT) with index on cwd - Uses existing database infrastructure and Os abstraction - Transactional insert+cleanup for data consistency - Early return for empty inputs to avoid unnecessary DB operations Files modified: - crates/chat-cli/src/database/mod.rs: Added ensure_chat_history_table(), add_chat_history_entry(), get_chat_history() - crates/chat-cli/src/cli/chat/input_source.rs: Load history on startup, save on input Edge cases handled: - Database failures (silent) - Empty/whitespace inputs (filtered) - Working directory access failures (fallback to default) - Unicode/special characters (parameterized queries)
1 parent 71c0081 commit 298852a

File tree

6 files changed

+143
-17
lines changed

6 files changed

+143
-17
lines changed

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@ use crate::cli::chat::{
1414
ChatSession,
1515
ChatState,
1616
};
17+
use crate::os::Os;
1718

1819
#[deny(missing_docs)]
1920
#[derive(Debug, PartialEq, Args)]
2021
pub struct ClearArgs;
2122

2223
impl ClearArgs {
23-
pub async fn execute(self, session: &mut ChatSession) -> Result<ChatState, ChatError> {
24+
pub async fn execute(self, os: &Os, session: &mut ChatSession) -> Result<ChatState, ChatError> {
2425
execute!(
2526
session.stderr,
2627
style::SetForegroundColor(Color::DarkGrey),
@@ -41,7 +42,7 @@ impl ClearArgs {
4142
)?;
4243

4344
// Setting `exit_on_single_ctrl_c` for better ux: exit the confirmation dialog rather than the CLI
44-
let user_input = match session.read_user_input("> ".yellow().to_string().as_str(), true) {
45+
let user_input = match session.read_user_input(os, "> ".yellow().to_string().as_str(), true) {
4546
Some(input) => input,
4647
None => "".to_string(),
4748
};

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ impl SlashCommand {
9191
pub async fn execute(self, os: &mut Os, session: &mut ChatSession) -> Result<ChatState, ChatError> {
9292
match self {
9393
Self::Quit => Ok(ChatState::Exit),
94-
Self::Clear(args) => args.execute(session).await,
94+
Self::Clear(args) => args.execute(os, session).await,
9595
Self::Agent(subcommand) => subcommand.execute(os, session).await,
9696
Self::Profile => {
9797
use crossterm::{

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ async fn upgrade_to_pro(os: &mut Os, session: &mut ChatSession) -> Result<(), Ch
148148
"]: ".dark_grey(),
149149
);
150150

151-
let user_input = session.read_user_input(&prompt, true);
151+
let user_input = session.read_user_input(os, &prompt, true);
152152
queue!(
153153
session.stderr,
154154
style::SetForegroundColor(Color::Reset),

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

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,16 @@ mod inner {
3333

3434
impl InputSource {
3535
pub fn new(os: &Os, sender: PromptQuerySender, receiver: PromptQueryResponseReceiver) -> Result<Self> {
36-
Ok(Self(inner::Inner::Readline(rl(os, sender, receiver)?)))
36+
let mut rl = rl(os, sender, receiver)?;
37+
38+
// Load persistent chat history from database
39+
if let Ok(history) = os.database.get_chat_history(os) {
40+
for entry in history {
41+
let _ = rl.add_history_entry(&entry);
42+
}
43+
}
44+
45+
Ok(Self(inner::Inner::Readline(rl)))
3746
}
3847

3948
#[cfg(unix)]
@@ -71,19 +80,18 @@ impl InputSource {
7180
Self(inner::Inner::Mock { index: 0, lines })
7281
}
7382

74-
pub fn read_line(&mut self, prompt: Option<&str>) -> Result<Option<String>, ReadlineError> {
83+
pub fn read_line(&mut self, prompt: Option<&str>, os: &Os) -> Result<Option<String>, ReadlineError> {
7584
match &mut self.0 {
7685
inner::Inner::Readline(rl) => {
7786
let prompt = prompt.unwrap_or_default();
7887
let curr_line = rl.readline(prompt);
7988
match curr_line {
8089
Ok(line) => {
8190
let _ = rl.add_history_entry(line.as_str());
82-
91+
let _ = os.database.add_chat_history_entry(&line, os);
8392
if let Some(helper) = rl.helper_mut() {
8493
helper.update_hinter_history(&line);
8594
}
86-
8795
Ok(Some(line))
8896
},
8997
Err(ReadlineError::Interrupted | ReadlineError::Eof) => Ok(None),
@@ -97,6 +105,7 @@ impl InputSource {
97105
}
98106
}
99107

108+
// For testing mock input source without os dependency
100109
// We're keeping this method for potential future use
101110
#[allow(dead_code)]
102111
pub fn set_buffer(&mut self, content: &str) {
@@ -111,16 +120,17 @@ impl InputSource {
111120
mod tests {
112121
use super::*;
113122

114-
#[test]
115-
fn test_mock_input_source() {
123+
#[tokio::test]
124+
async fn test_mock_input_source() {
116125
let l1 = "Hello,".to_string();
117126
let l2 = "Line 2".to_string();
118127
let l3 = "World!".to_string();
119128
let mut input = InputSource::new_mock(vec![l1.clone(), l2.clone(), l3.clone()]);
129+
let os = crate::os::Os::new().await.unwrap();
120130

121-
assert_eq!(input.read_line(None).unwrap().unwrap(), l1);
122-
assert_eq!(input.read_line(None).unwrap().unwrap(), l2);
123-
assert_eq!(input.read_line(None).unwrap().unwrap(), l3);
124-
assert!(input.read_line(None).unwrap().is_none());
131+
assert_eq!(input.read_line(None, &os).unwrap().unwrap(), l1);
132+
assert_eq!(input.read_line(None, &os).unwrap().unwrap(), l2);
133+
assert_eq!(input.read_line(None, &os).unwrap().unwrap(), l3);
134+
assert!(input.read_line(None, &os).unwrap().is_none());
125135
}
126136
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1567,7 +1567,7 @@ impl ChatSession {
15671567
style::SetAttribute(Attribute::Reset)
15681568
)?;
15691569
let prompt = self.generate_tool_trust_prompt();
1570-
let user_input = match self.read_user_input(&prompt, false) {
1570+
let user_input = match self.read_user_input(os, &prompt, false) {
15711571
Some(input) => input,
15721572
None => return Ok(ChatState::Exit),
15731573
};
@@ -2566,10 +2566,10 @@ impl ChatSession {
25662566
}
25672567

25682568
/// Helper function to read user input with a prompt and Ctrl+C handling
2569-
fn read_user_input(&mut self, prompt: &str, exit_on_single_ctrl_c: bool) -> Option<String> {
2569+
fn read_user_input(&mut self, os: &Os, prompt: &str, exit_on_single_ctrl_c: bool) -> Option<String> {
25702570
let mut ctrl_c = false;
25712571
loop {
2572-
match (self.input_source.read_line(Some(prompt)), ctrl_c) {
2572+
match (self.input_source.read_line(Some(prompt), os), ctrl_c) {
25732573
(Ok(Some(line)), _) => {
25742574
if line.trim().is_empty() {
25752575
continue; // Reprompt if the input is empty

crates/chat-cli/src/database/mod.rs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ const IDC_REGION_KEY: &str = "auth.idc.region";
6262
const CUSTOMIZATION_STATE_KEY: &str = "api.selectedCustomization";
6363
const PROFILE_MIGRATION_KEY: &str = "profile.Migrated";
6464

65+
// Chat history limits
66+
const CHAT_HISTORY_MAX_PER_DIRECTORY: i32 = 100;
67+
const CHAT_HISTORY_MAX_TOTAL: i32 = 1000;
68+
6569
const MIGRATIONS: &[Migration] = migrations![
6670
"000_migration_table",
6771
"001_history_table",
@@ -463,6 +467,71 @@ impl Database {
463467

464468
Ok(map)
465469
}
470+
471+
/// Ensure chat history table exists with index
472+
fn ensure_chat_history_table(&self) -> Result<(), DatabaseError> {
473+
let conn = self.pool.get()?;
474+
conn.execute("CREATE TABLE IF NOT EXISTS chat_history (input TEXT NOT NULL, cwd TEXT NOT NULL)", [])?;
475+
conn.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_cwd ON chat_history(cwd)", [])?;
476+
Ok(())
477+
}
478+
479+
/// Add a chat input to the persistent history
480+
pub fn add_chat_history_entry(&self, input: &str, os: &crate::os::Os) -> Result<(), DatabaseError> {
481+
// Skip empty or whitespace-only inputs
482+
if input.trim().is_empty() {
483+
return Ok(());
484+
}
485+
486+
self.ensure_chat_history_table()?;
487+
let conn = self.pool.get()?;
488+
let cwd = os.env.current_dir().unwrap_or_default().to_string_lossy().to_string();
489+
490+
let tx = conn.unchecked_transaction()?;
491+
tx.execute("INSERT INTO chat_history (input, cwd) VALUES (?1, ?2)", params![input, cwd])?;
492+
493+
// Keep only last entries per directory
494+
tx.execute(
495+
"DELETE FROM chat_history WHERE cwd = ?1 AND rowid < (
496+
SELECT MIN(rowid)
497+
FROM (
498+
SELECT rowid
499+
FROM chat_history
500+
WHERE cwd = ?1
501+
ORDER BY rowid DESC
502+
LIMIT ?2
503+
)
504+
)",
505+
params![cwd, CHAT_HISTORY_MAX_PER_DIRECTORY],
506+
)?;
507+
508+
// Keep only last entries total
509+
tx.execute(
510+
"DELETE FROM chat_history WHERE rowid < (
511+
SELECT MIN(rowid)
512+
FROM (
513+
SELECT rowid
514+
FROM chat_history
515+
ORDER BY rowid DESC
516+
LIMIT ?1
517+
)
518+
)",
519+
params![CHAT_HISTORY_MAX_TOTAL],
520+
)?;
521+
tx.commit()?;
522+
523+
Ok(())
524+
}
525+
526+
/// Get all chat history entries for current working directory
527+
pub fn get_chat_history(&self, os: &crate::os::Os) -> Result<Vec<String>, DatabaseError> {
528+
self.ensure_chat_history_table()?;
529+
let conn = self.pool.get()?;
530+
let cwd = os.env.current_dir().unwrap_or_default().to_string_lossy().to_string();
531+
let mut stmt = conn.prepare("SELECT input FROM chat_history WHERE cwd = ?1")?;
532+
let rows = stmt.query_map([cwd], |row| Ok(row.get(0)?))?;
533+
rows.collect::<Result<Vec<String>, _>>().map_err(Into::into)
534+
}
466535
}
467536

468537
fn max_migration_version<C: Deref<Target = Connection>>(conn: &C) -> Option<i64> {
@@ -625,4 +694,50 @@ mod tests {
625694
store.delete_secret(key).await.unwrap();
626695
assert_eq!(store.get_secret(key).await.unwrap(), None);
627696
}
697+
698+
#[tokio::test]
699+
async fn chat_history_basic_operations() {
700+
let db = Database::new().await.unwrap();
701+
let os = crate::os::Os::new().await.unwrap();
702+
703+
// Add entries
704+
db.add_chat_history_entry("test command 1", &os).unwrap();
705+
db.add_chat_history_entry("test command 2", &os).unwrap();
706+
707+
// Retrieve entries
708+
let history = db.get_chat_history(&os).unwrap();
709+
assert_eq!(history.len(), 2);
710+
assert!(history.contains(&"test command 1".to_string()));
711+
assert!(history.contains(&"test command 2".to_string()));
712+
}
713+
714+
#[tokio::test]
715+
async fn chat_history_empty_input_filtering() {
716+
let db = Database::new().await.unwrap();
717+
let os = crate::os::Os::new().await.unwrap();
718+
719+
// Empty and whitespace inputs should be filtered
720+
db.add_chat_history_entry("", &os).unwrap();
721+
db.add_chat_history_entry(" ", &os).unwrap();
722+
db.add_chat_history_entry("\t\n", &os).unwrap();
723+
db.add_chat_history_entry("valid command", &os).unwrap();
724+
725+
let history = db.get_chat_history(&os).unwrap();
726+
assert_eq!(history.len(), 1);
727+
assert_eq!(history[0], "valid command");
728+
}
729+
730+
#[tokio::test]
731+
async fn chat_history_directory_cleanup() {
732+
let db = Database::new().await.unwrap();
733+
let os = crate::os::Os::new().await.unwrap();
734+
735+
// Add more than max entries per directory to test cleanup
736+
for i in 0..(CHAT_HISTORY_MAX_PER_DIRECTORY + 5) {
737+
db.add_chat_history_entry(&format!("command {}", i), &os).unwrap();
738+
}
739+
740+
let history = db.get_chat_history(&os).unwrap();
741+
assert!(history.len() == CHAT_HISTORY_MAX_PER_DIRECTORY as usize, "Should cleanup to max entries per directory");
742+
}
628743
}

0 commit comments

Comments
 (0)