Skip to content

Commit 3d7318b

Browse files
feat: add /clear command, fix use_aws parameters bug, add tilde expansion, minor fixes (#677)
1 parent eca5038 commit 3d7318b

File tree

7 files changed

+388
-303
lines changed

7 files changed

+388
-303
lines changed

crates/q_cli/src/cli/chat/conversation_state.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@ impl ConversationState {
9494
}
9595
}
9696

97+
/// Clears the conversation history.
98+
pub fn clear(&mut self) {
99+
self.next_message = None;
100+
self.history.clear();
101+
}
102+
97103
pub async fn append_new_user_message(&mut self, input: String) {
98104
debug_assert!(self.next_message.is_none(), "next_message should not exist");
99105
if let Some(next_message) = self.next_message.as_ref() {

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

Lines changed: 294 additions & 279 deletions
Large diffs are not rendered by default.

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use rustyline::{
2323
use winnow::stream::AsChar;
2424

2525
const MODIFIERS: &[&str] = &["@history", "@git", "@env"];
26+
const COMMANDS: &[&str] = &["/clear"];
2627

2728
pub struct ChatCompleater {}
2829

@@ -50,6 +51,12 @@ impl Completer for ChatCompleater {
5051
.filter(|p| p.starts_with(word))
5152
.map(|s| (*s).to_owned())
5253
.collect()
54+
} else if word.starts_with('/') {
55+
COMMANDS
56+
.iter()
57+
.filter(|p| p.starts_with(word))
58+
.map(|s| (*s).to_owned())
59+
.collect()
5360
} else {
5461
Vec::new()
5562
},

crates/q_cli/src/cli/chat/tools/fs_read.rs

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ use std::collections::VecDeque;
22
use std::fs::Metadata;
33
use std::io::Write;
44
use std::os::unix::fs::PermissionsExt;
5-
use std::path::PathBuf;
65

76
use crossterm::queue;
87
use crossterm::style::{
@@ -22,6 +21,7 @@ use super::{
2221
InvokeOutput,
2322
OutputKind,
2423
format_path,
24+
sanitize_path_tool_arg,
2525
stylize_output_if_able,
2626
};
2727

@@ -46,10 +46,8 @@ impl FsRead {
4646
}
4747

4848
pub async fn invoke(&self, ctx: &Context, updates: &mut impl Write) -> Result<InvokeOutput> {
49-
// Required for testing scenarios: since the path is passed directly as a command argument,
50-
// we need to pass it through the Context first.
51-
let path = ctx.fs().chroot_path_str(&self.path);
52-
let is_file = ctx.fs().symlink_metadata(&self.path).await?.is_file();
49+
let path = sanitize_path_tool_arg(ctx, &self.path);
50+
let is_file = ctx.fs().symlink_metadata(&path).await?.is_file();
5351

5452
if is_file {
5553
if let Some((start, Some(end))) = self.read_range()? {
@@ -114,7 +112,7 @@ impl FsRead {
114112
let max_depth = self.read_range()?.map_or(0, |(d, _)| d);
115113
let mut result = Vec::new();
116114
let mut dir_queue = VecDeque::new();
117-
dir_queue.push_back((PathBuf::from(path), 0));
115+
dir_queue.push_back((path, 0));
118116
while let Some((path, depth)) = dir_queue.pop_front() {
119117
if depth > max_depth {
120118
break;
@@ -239,11 +237,12 @@ impl FsRead {
239237
}
240238

241239
pub async fn validate(&mut self, ctx: &Context) -> Result<()> {
242-
if !PathBuf::from(&self.path).exists() {
240+
let path = sanitize_path_tool_arg(ctx, &self.path);
241+
if !path.exists() {
243242
bail!("'{}' does not exist", self.path);
244243
}
245244

246-
let is_file = ctx.fs().symlink_metadata(&self.path).await?.is_file();
245+
let is_file = ctx.fs().symlink_metadata(&path).await?.is_file();
247246
self.ty = Some(is_file);
248247

249248
Ok(())

crates/q_cli/src/cli/chat/tools/fs_write.rs

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
use std::borrow::Cow;
22
use std::io::Write;
3-
use std::path::PathBuf;
43

54
use crossterm::queue;
65
use crossterm::style::{
@@ -18,6 +17,7 @@ use serde::Deserialize;
1817
use super::{
1918
InvokeOutput,
2019
format_path,
20+
sanitize_path_tool_arg,
2121
stylize_output_if_able,
2222
};
2323

@@ -46,8 +46,9 @@ impl FsWrite {
4646
let cwd = ctx.env().current_dir()?;
4747
match self {
4848
FsWrite::Create { path, file_text } => {
49-
let relative_path = format_path(cwd, fs.chroot_path(path));
50-
let invoke_description = if fs.exists(path) {
49+
let path = sanitize_path_tool_arg(ctx, path);
50+
let relative_path = format_path(cwd, fs.chroot_path(&path));
51+
let invoke_description = if fs.exists(&path) {
5152
"Replacing the current file contents at"
5253
} else {
5354
"Creating a new file at"
@@ -63,12 +64,13 @@ impl FsWrite {
6364
Ok(Default::default())
6465
},
6566
FsWrite::StrReplace { path, old_str, new_str } => {
67+
let path = sanitize_path_tool_arg(ctx, path);
6668
let file = fs.read_to_string(&path).await?;
6769
let matches = file.match_indices(old_str).collect::<Vec<_>>();
6870
queue!(
6971
updates,
7072
style::SetForegroundColor(Color::Green),
71-
style::Print(format!("Updating {}", format_path(cwd, path))),
73+
style::Print(format!("Updating {}", format_path(cwd, &path))),
7274
style::ResetColor,
7375
style::Print("\n"),
7476
)?;
@@ -160,13 +162,13 @@ impl FsWrite {
160162
insert_line,
161163
new_str,
162164
} => {
163-
let path = format_path(cwd, path);
164-
let file = stylize_output_if_able(ctx, &path, new_str, Some(*insert_line), Some("+"));
165+
let relative_path = format_path(cwd, path);
166+
let file = stylize_output_if_able(ctx, &relative_path, new_str, Some(*insert_line), Some("+"));
165167
queue!(
166168
updates,
167169
style::Print("Path: "),
168170
style::SetForegroundColor(Color::Green),
169-
style::Print(path),
171+
style::Print(relative_path),
170172
style::ResetColor,
171173
style::Print("\n\nContents:\n"),
172174
style::Print(file),
@@ -175,20 +177,20 @@ impl FsWrite {
175177
Ok(())
176178
},
177179
FsWrite::StrReplace { path, old_str, new_str } => {
178-
let path = format_path(cwd, path);
179-
let file = ctx.fs().read_to_string_sync(&path)?;
180+
let relative_path = format_path(cwd, path);
181+
let file = ctx.fs().read_to_string_sync(&relative_path)?;
180182
// TODO: we should pass some additional lines as context before and after the file.
181183
let (start_line, _) = match line_number_at(&file, old_str) {
182184
Some((start_line, end_line)) => (Some(start_line), Some(end_line)),
183185
_ => (None, None),
184186
};
185-
let old_str = stylize_output_if_able(ctx, &path, old_str, start_line, Some("-"));
186-
let new_str = stylize_output_if_able(ctx, &path, new_str, start_line, Some("+"));
187+
let old_str = stylize_output_if_able(ctx, &relative_path, old_str, start_line, Some("-"));
188+
let new_str = stylize_output_if_able(ctx, &relative_path, new_str, start_line, Some("+"));
187189
queue!(
188190
updates,
189191
style::Print("Path: "),
190192
style::SetForegroundColor(Color::Green),
191-
style::Print(path),
193+
style::Print(relative_path),
192194
style::ResetColor,
193195
style::Print("\n\n"),
194196
style::Print("Replacing:\n"),
@@ -204,15 +206,16 @@ impl FsWrite {
204206
}
205207
}
206208

207-
pub async fn validate(&mut self, _ctx: &Context) -> Result<()> {
209+
pub async fn validate(&mut self, ctx: &Context) -> Result<()> {
208210
match self {
209211
FsWrite::Create { path, .. } => {
210212
if path.is_empty() {
211213
bail!("Path must not be empty")
212214
};
213215
},
214216
FsWrite::StrReplace { path, .. } | FsWrite::Insert { path, .. } => {
215-
if !PathBuf::from(&path).exists() {
217+
let path = sanitize_path_tool_arg(ctx, path);
218+
if !path.exists() {
216219
bail!("The provided path must exist in order to replace or insert contents into it")
217220
}
218221
},

crates/q_cli/src/cli/chat/tools/mod.rs

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ impl Tool {
6767
}
6868
}
6969

70+
/// Whether or not the tool should prompt the user for consent before [Self::invoke] is called.
71+
pub fn requires_consent(&self, _ctx: &Context) -> bool {
72+
match self {
73+
Tool::FsRead(_) => false,
74+
Tool::FsWrite(_) => true,
75+
Tool::ExecuteBash(_) => true,
76+
Tool::UseAws(_) => false,
77+
}
78+
}
79+
7080
/// Invokes the tool asynchronously
7181
pub async fn invoke(&self, context: &Context, updates: &mut impl Write) -> Result<InvokeOutput> {
7282
match self {
@@ -185,9 +195,26 @@ pub fn serde_value_to_document(value: serde_json::Value) -> Document {
185195
}
186196
}
187197

198+
/// Performs tilde expansion and other required sanitization modifications for handling tool use
199+
/// path arguments.
200+
///
201+
/// Required since path arguments are defined by the model.
188202
#[allow(dead_code)]
189-
fn path_expand(_ctx: &Context, _path: impl AsRef<Path>) -> PathBuf {
190-
PathBuf::new()
203+
fn sanitize_path_tool_arg(ctx: &Context, path: impl AsRef<Path>) -> PathBuf {
204+
let mut res = PathBuf::new();
205+
// Expand `~` only if it is the first part.
206+
let mut path = path.as_ref().components();
207+
match path.next() {
208+
Some(p) if p.as_os_str() == "~" => {
209+
res.push(ctx.env().home().unwrap_or_default());
210+
},
211+
Some(p) => res.push(p),
212+
None => return res,
213+
}
214+
res.push(path);
215+
// For testing scenarios, we need to make sure paths are appropriately handled in chroot test
216+
// file systems since they are passed directly from the model.
217+
ctx.fs().chroot_path(res)
191218
}
192219

193220
/// Converts `path` to a relative path according to the current working directory `cwd`.
@@ -341,6 +368,8 @@ fn stylized_file(
341368

342369
#[cfg(test)]
343370
mod tests {
371+
use fig_os_shim::EnvProvider;
372+
344373
use super::*;
345374

346375
#[test]
@@ -352,4 +381,28 @@ mod tests {
352381
assert_eq!(terminal_width(100), 3);
353382
assert_eq!(terminal_width(999), 3);
354383
}
384+
385+
#[tokio::test]
386+
async fn test_tilde_path_expansion() {
387+
let ctx = Context::builder().with_test_home().await.unwrap().build_fake();
388+
389+
let actual = sanitize_path_tool_arg(&ctx, "~");
390+
assert_eq!(
391+
actual,
392+
ctx.fs().chroot_path(ctx.env().home().unwrap()),
393+
"tilde should expand"
394+
);
395+
let actual = sanitize_path_tool_arg(&ctx, "~/hello");
396+
assert_eq!(
397+
actual,
398+
ctx.fs().chroot_path(ctx.env().home().unwrap().join("hello")),
399+
"tilde should expand"
400+
);
401+
let actual = sanitize_path_tool_arg(&ctx, "/~");
402+
assert_eq!(
403+
actual,
404+
ctx.fs().chroot_path("/~"),
405+
"tilde should not expand when not the first component"
406+
);
407+
}
355408
}

crates/q_cli/src/cli/chat/tools/use_aws.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ impl UseAws {
6666
command.arg(&self.service_name).arg(&self.operation_name);
6767
if let Some(parameters) = &self.parameters {
6868
for (param_name, val) in parameters {
69+
// Model might sometimes give parameters capitalized.
70+
let param_name = param_name.to_lowercase();
6971
if param_name.starts_with("--") {
7072
command.arg(param_name).arg(val);
7173
} else {

0 commit comments

Comments
 (0)