Skip to content

Commit 542f02a

Browse files
ei-gradcodex
andcommitted
fix: integrate reviewed CLI and runtime fixes
Add an explicit --files list grammar for shell-expanded inputs while preserving the established single and repeated -f behavior. This avoids classifying positional prompt tokens by filesystem existence and makes the file/prompt boundary explicit. Apply command preludes before --info with defined REPL fallback semantics, parse editor commands into an executable plus arguments without invoking a shell, and reuse that parser for REPL buffer editing. Move local image reads to Tokio, use the configured warning color for generated shell commands, and document installation and local-agent layouts. Adapt the bounded fixes discussed in: sigoden#1451 sigoden#1509 sigoden#1534 sigoden#1533 sigoden#1512 sigoden#1525 sigoden#1383 sigoden#1437 sigoden#1486 Co-Authored-By: Codex CLI <noreply@openai.com>
1 parent 4dbbec4 commit 542f02a

8 files changed

Lines changed: 487 additions & 44 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ reedline = "0.40.0"
2222
serde = { version = "1.0.152", features = ["derive"] }
2323
serde_json = { version = "1.0.93", features = ["preserve_order"] }
2424
serde_yaml = "0.9.17"
25-
tokio = { version = "1.34.0", features = ["rt", "time", "macros", "signal", "rt-multi-thread"] }
25+
tokio = { version = "1.34.0", features = ["fs", "rt", "time", "macros", "signal", "rt-multi-thread"] }
2626
tokio-graceful = "0.2.2"
2727
tokio-stream = { version = "0.1.15", default-features = false, features = ["sync"] }
2828
crossterm = "0.28.1"

README.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ AIChat is an all-in-one LLM CLI tool featuring Shell Assistant, CMD & REPL Mode,
1212

1313
- **Rust Developers:** `cargo install aichat`
1414
- **Homebrew/Linuxbrew Users:** `brew install aichat`
15+
- **[mise](https://mise.jdx.dev/) Users:** `mise use --global aqua:sigoden/aichat@latest`
1516
- **Pacman Users**: `pacman -S aichat`
1617
- **Windows Scoop Users:** `scoop install aichat`
18+
- **Windows WinGet Users:** `winget install --exact --id sigoden.AIChat`
1719
- **Android Termux Users:** `pkg install aichat`
1820

1921
### Pre-built Binaries
@@ -36,6 +38,8 @@ Explore powerful command-line functionalities with AIChat's CMD mode.
3638

3739
Experience an interactive Chat-REPL with features like tab autocompletion, multi-line input support, history search, configurable keybindings, and custom REPL prompts.
3840

41+
Editor commands from the `editor` configuration, `VISUAL`, or `EDITOR` may include arguments. They use POSIX shell-word quoting on every platform, so executable paths containing spaces must be quoted, for example `"C:\Program Files\Helix\hx.exe" --wait`.
42+
3943
![aichat-repl](https://github.com/user-attachments/assets/218fab08-cdae-4c3b-bcf8-39b6651f1362)
4044

4145
### Shell Assistant
@@ -54,6 +58,7 @@ Accept diverse input forms such as stdin, local files and directories, and remot
5458
| STDIN | `cat data.txt \| aichat` | |
5559
| Last Reply | | `.file %%` |
5660
| Local files | `aichat -f image.png -f data.txt` | `.file image.png data.txt` |
61+
| Shell-expanded files | `aichat --files src/*.rs -- explain` | `.file src/a.rs src/b.rs -- explain` |
5762
| Local directories | `aichat -f dir/` | `.file dir/` |
5863
| Remote URLs | `aichat -f https://example.com` | `.file https://example.com` |
5964
| External commands | ```aichat -f '`git diff`'``` | ```.file `git diff` ``` |
@@ -105,6 +110,38 @@ AI Agent = Instructions (Prompt) + Tools (Function Callings) + Documents (RAG).
105110

106111
![aichat-agent](https://github.com/user-attachments/assets/0b7e687d-e642-4e8a-b1c1-d2d9b2da2b6b)
107112

113+
A minimal local agent requires an agent definition and an entry in `agents.txt` under the functions directory. The agent-specific `config.yaml` is optional and only overrides runtime configuration such as the model, temperature, or default variables.
114+
115+
```text
116+
<aichat-config-dir>/
117+
functions/
118+
agents.txt # contains: my-agent
119+
agents/
120+
my-agent/
121+
index.yaml # required agent definition
122+
functions.json # optional tools
123+
agents/
124+
my-agent/
125+
config.yaml # optional agent-specific config
126+
```
127+
128+
Example `functions/agents.txt`:
129+
130+
```text
131+
my-agent
132+
```
133+
134+
Example `functions/agents/my-agent/index.yaml`:
135+
136+
```yaml
137+
name: my-agent
138+
description: Helps with local project tasks.
139+
instructions: |
140+
You are a concise assistant for this project.
141+
```
142+
143+
After creating these files, run `aichat --list-agents` to confirm that the agent is discoverable, then use it with `aichat -a my-agent`.
144+
108145
### Local Server Capabilities
109146

110147
AIChat includes a lightweight built-in HTTP server for easy deployment.
@@ -169,4 +206,4 @@ Copyright (c) 2023-2025 aichat-developers.
169206

170207
AIChat is made available under the terms of either the MIT License or the Apache License 2.0, at your option.
171208

172-
See the LICENSE-APACHE and LICENSE-MIT files for license details.
209+
See the LICENSE-APACHE and LICENSE-MIT files for license details.

src/cli.rs

Lines changed: 119 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ pub struct Cli {
5151
/// Include files, directories, or URLs
5252
#[clap(short = 'f', long, value_name = "FILE")]
5353
pub file: Vec<String>,
54+
/// Include multiple shell-expanded files; terminate the list with `--`
55+
#[clap(
56+
long = "files",
57+
value_name = "FILE",
58+
num_args = 1..,
59+
value_terminator = "--"
60+
)]
61+
expanded_files: Vec<String>,
5462
/// Turn off stream mode
5563
#[clap(short = 'S', long)]
5664
pub no_stream: bool,
@@ -87,19 +95,35 @@ pub struct Cli {
8795
}
8896

8997
impl Cli {
98+
pub fn files(&self) -> Vec<String> {
99+
self.file
100+
.iter()
101+
.chain(&self.expanded_files)
102+
.cloned()
103+
.collect()
104+
}
105+
106+
pub fn has_files(&self) -> bool {
107+
!self.file.is_empty() || !self.expanded_files.is_empty()
108+
}
109+
90110
pub fn text(&self) -> Result<Option<String>> {
91111
let mut stdin_text = String::new();
92112
if !stdin().is_terminal() {
93113
let _ = stdin()
94114
.read_to_string(&mut stdin_text)
95115
.context("Invalid stdin pipe")?;
96116
};
117+
Ok(self.text_with_stdin(&stdin_text))
118+
}
119+
120+
fn text_with_stdin(&self, stdin_text: &str) -> Option<String> {
97121
match self.text.is_empty() {
98122
true => {
99123
if stdin_text.is_empty() {
100-
Ok(None)
124+
None
101125
} else {
102-
Ok(Some(stdin_text))
126+
Some(stdin_text.to_string())
103127
}
104128
}
105129
false => {
@@ -111,19 +135,108 @@ impl Cli {
111135
.collect::<Vec<_>>()
112136
.join(" ");
113137
if stdin_text.is_empty() {
114-
Ok(Some(text))
138+
Some(text)
115139
} else {
116-
Ok(Some(format!("{text} -- {stdin_text}")))
140+
Some(format!("{text} -- {stdin_text}"))
117141
}
118142
} else {
119143
let text = self.text.join(" ");
120144
if stdin_text.is_empty() {
121-
Ok(Some(text))
145+
Some(text)
122146
} else {
123-
Ok(Some(format!("{text}\n{stdin_text}")))
147+
Some(format!("{text}\n{stdin_text}"))
124148
}
125149
}
126150
}
127151
}
128152
}
129153
}
154+
155+
#[cfg(test)]
156+
mod tests {
157+
use super::*;
158+
159+
fn parse(args: &[&str]) -> Cli {
160+
Cli::try_parse_from(args).unwrap()
161+
}
162+
163+
#[test]
164+
fn preserves_single_and_repeated_file_options() {
165+
let cli = parse(&["aichat", "-f", "one.md", "explain"]);
166+
assert_eq!(cli.files(), ["one.md"]);
167+
assert_eq!(cli.text_with_stdin(""), Some("explain".into()));
168+
169+
let cli = parse(&["aichat", "-f", "one.md", "--file", "two.md", "compare"]);
170+
assert_eq!(cli.files(), ["one.md", "two.md"]);
171+
assert_eq!(cli.text_with_stdin(""), Some("compare".into()));
172+
}
173+
174+
#[test]
175+
fn parses_explicit_shell_expanded_file_list() {
176+
let cli = parse(&[
177+
"aichat", "--files", "src/a.rs", "src/b.rs", "--", "review", "these",
178+
]);
179+
assert_eq!(cli.files(), ["src/a.rs", "src/b.rs"]);
180+
assert_eq!(cli.text_with_stdin(""), Some("review these".into()));
181+
}
182+
183+
#[test]
184+
fn preserves_literal_file_values_without_inference() {
185+
let cli = parse(&[
186+
"aichat",
187+
"--files",
188+
"src/*.rs",
189+
"docs/",
190+
"https://example.com/context",
191+
"%%",
192+
"file with spaces.md",
193+
"--",
194+
"summarize",
195+
]);
196+
assert_eq!(
197+
cli.files(),
198+
[
199+
"src/*.rs",
200+
"docs/",
201+
"https://example.com/context",
202+
"%%",
203+
"file with spaces.md",
204+
]
205+
);
206+
assert_eq!(cli.text_with_stdin(""), Some("summarize".into()));
207+
}
208+
209+
#[test]
210+
fn does_not_reclassify_prompt_tokens_as_files() {
211+
let cli = parse(&["aichat", "-f", "context.md", "README.md", "explain"]);
212+
assert_eq!(cli.files(), ["context.md"]);
213+
assert_eq!(cli.text_with_stdin(""), Some("README.md explain".into()));
214+
}
215+
216+
#[test]
217+
fn combines_explicit_files_with_stdin_and_command_modes() {
218+
let cli = parse(&[
219+
"aichat",
220+
"--execute",
221+
"--files",
222+
"script one.sh",
223+
"script-two.sh",
224+
"--",
225+
"inspect",
226+
]);
227+
assert!(cli.execute);
228+
assert!(cli.has_files());
229+
assert_eq!(cli.text_with_stdin("piped"), Some("inspect\npiped".into()));
230+
231+
let cli = parse(&["aichat", "--code", "--files", "src/a.rs", "--", "rewrite"]);
232+
assert!(cli.code);
233+
assert_eq!(cli.files(), ["src/a.rs"]);
234+
}
235+
236+
#[test]
237+
fn accepts_stdin_without_prompt_text() {
238+
let cli = parse(&["aichat"]);
239+
assert_eq!(cli.text_with_stdin("from stdin"), Some("from stdin".into()));
240+
assert_eq!(cli.text_with_stdin(""), None);
241+
}
242+
}

src/config/input.rs

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use crate::utils::{base64_encode, is_loader_protocol, sha256, AbortSignal};
99

1010
use anyhow::{bail, Context, Result};
1111
use indexmap::IndexSet;
12-
use std::{collections::HashMap, fs::File, io::Read};
12+
use std::collections::HashMap;
1313
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
1414

1515
const IMAGE_EXTS: [&str; 5] = ["png", "jpeg", "jpg", "webp", "gif"];
@@ -574,6 +574,7 @@ async fn load_documents(
574574
for file_path in local_files {
575575
if is_image(&file_path) {
576576
let contents = read_media_to_data_url(&file_path)
577+
.await
577578
.with_context(|| format!("Unable to read media '{file_path}'"))?;
578579
data_urls.insert(sha256(&contents), file_path);
579580
medias.push(contents)
@@ -628,21 +629,51 @@ fn is_image(path: &str) -> bool {
628629
.unwrap_or_default()
629630
}
630631

631-
fn read_media_to_data_url(image_path: &str) -> Result<String> {
632+
async fn read_media_to_data_url(image_path: &str) -> Result<String> {
633+
let buffer = tokio::fs::read(image_path)
634+
.await
635+
.with_context(|| format!("Failed to read media bytes from '{image_path}'"))?;
636+
media_bytes_to_data_url(image_path, &buffer)
637+
}
638+
639+
fn media_bytes_to_data_url(image_path: &str, buffer: &[u8]) -> Result<String> {
632640
let extension = get_patch_extension(image_path).unwrap_or_default();
633641
let mime_type = match extension.as_str() {
634642
"png" => "image/png",
635643
"jpg" | "jpeg" => "image/jpeg",
636644
"webp" => "image/webp",
637645
"gif" => "image/gif",
638-
_ => bail!("Unexpected media type"),
646+
_ => bail!("Unexpected media type for '{image_path}'"),
639647
};
640-
let mut file = File::open(image_path)?;
641-
let mut buffer = Vec::new();
642-
file.read_to_end(&mut buffer)?;
643648

644649
let encoded_image = base64_encode(buffer);
645650
let data_url = format!("data:{mime_type};base64,{encoded_image}");
646651

647652
Ok(data_url)
648653
}
654+
655+
#[cfg(test)]
656+
mod media_tests {
657+
use super::*;
658+
659+
#[test]
660+
fn media_bytes_keep_the_existing_data_url_format() {
661+
let data_url = media_bytes_to_data_url("sample.png", &[0, 1, 2, 255]).unwrap();
662+
assert_eq!(data_url, "data:image/png;base64,AAEC/w==");
663+
}
664+
665+
#[test]
666+
fn unsupported_media_error_includes_the_path() {
667+
let error = media_bytes_to_data_url("sample.bmp", &[1, 2, 3]).unwrap_err();
668+
assert_eq!(error.to_string(), "Unexpected media type for 'sample.bmp'");
669+
}
670+
671+
#[tokio::test]
672+
async fn async_media_read_error_includes_the_path() {
673+
let path =
674+
std::env::temp_dir().join(format!("aichat-missing-media-{}.png", std::process::id()));
675+
let path = path.to_string_lossy();
676+
let error = read_media_to_data_url(&path).await.unwrap_err();
677+
assert!(error.to_string().contains(path.as_ref()));
678+
}
679+
}

0 commit comments

Comments
 (0)