commit fc35ccc271ec2fe9741b32c15cdf97214859312b Author: syui Date: Fri Feb 27 11:54:39 2026 +0900 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c147b48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Rust +/target/ +Cargo.lock + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Environment +.env +.env.local +/.claude +/claude.md diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a80c35e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "aishell" +version = "0.3.0" +edition = "2021" +authors = ["syui"] +description = "aios shell - AI and commands in one stream" + +[lib] +name = "aishell" +path = "src/lib.rs" + +[[bin]] +name = "aishell" +path = "src/main.rs" + +[dependencies] +rustyline = "14.0" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +terminal_size = "0.4" diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f414d1 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# aishell + +A single-stream shell where commands and AI coexist type a command, it runs; type anything else, AI responds. diff --git a/claude.md b/claude.md new file mode 100644 index 0000000..a23984e --- /dev/null +++ b/claude.md @@ -0,0 +1,168 @@ +# aishell + +A single-stream shell where commands and AI coexist. + +## Architecture + +``` +┌──────────────────────────────────────────────┐ +│ aishell │ +│ │ +│ ┌──────────┐ judge ┌───────────────┐ │ +│ │ rustyline │───────────→│ sh -c command │ │ +│ │ (input) │ is_command └───────────────┘ │ +│ └──────────┘ │ +│ │ !is_command │ +│ ▼ │ +│ ┌──────────┐ stdin(JSON) ┌──────────────┐ │ +│ │ send() │─────────────→│ claude │ │ +│ │ (async) │ │ (persistent) │ │ +│ └──────────┘ │ stream-json │ │ +│ └──────────────┘ │ +│ ┌──────────┐ stdout(JSON) │ │ +│ │ reader │←────────────────────┘ │ +│ │ thread │ mpsc channel │ +│ └──────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────┐ │ +│ │ drain() │──→ println! (unified stream) │ +│ └──────────┘ │ +│ │ +│ ┌───────────────────────────────────────┐ │ +│ │ ● [1] responding... | [2] thinking... │ │ +│ └───────────────────────────────────────┘ │ +└──────────────────────────────────────────────┘ +``` + +## Claude Process (ai.rs) + +### Startup + +One persistent process for the entire session: + +``` +claude --input-format stream-json --output-format stream-json \ + --verbose --dangerously-skip-permissions +``` + +- `--input-format stream-json`: accepts JSON lines on stdin +- `--output-format stream-json`: emits JSON events on stdout +- Process stays alive until aishell exits. No restart per message. + +### Input Format + +```json +{"type":"user","message":{"role":"user","content":"user input here"}} +``` + +Written to claude's stdin via `Arc>`. + +### Output Events + +``` +type: "system" → init event (tools list, MCP servers, model info) +type: "assistant" → response content + content[].type: "text" → AI text (accumulated) + content[].type: "tool_use" → tool execution (name shown in status) +type: "result" → turn complete (final text, cost, usage) +``` + +### Thread Model + +``` +Main Thread Background Thread + │ │ + ├─ readline() ├─ BufReader::lines() on claude stdout + ├─ judge::is_command() ├─ serde_json::from_str() each line + ├─ command → executor::execute() ├─ "assistant" → accumulate text, update status + ├─ AI → claude.send() (non-blocking) ├─ "tool_use" → update status + ├─ drain_responses() before prompt ├─ "result" → mpsc::send() completed text + └─ status.set(status_line()) └─ remove session from status vec + +Polling Thread (200ms interval) + ├─ try_recv() completed responses → print immediately + └─ update status bar +``` + +### Shared State + +```rust +stdin: Arc> // main → claude stdin writes +status: Arc>> // both threads read/write status +output_tx: mpsc::Sender // background → main completed responses +output_rx: mpsc::Receiver // main drains with try_recv() +id_tx: mpsc::Sender // main → background session ID assignment +``` + +### Non-blocking Send + +```rust +pub fn send(&mut self, input: &str) -> usize { + let id = self.next_id; // assign session ID + self.next_id += 1; + status.push(SessionStatus { id, state: "thinking..." }); + self.id_tx.send(id); // notify reader thread + writeln!(stdin, "{}", json); // write JSON to claude stdin + stdin.flush(); + // returns immediately — does NOT wait for response + id +} +``` + +## Input Detection (judge.rs) + +Priority order: + +1. Shell operators (`|`, `>`, `<`, `;`, `&`) outside quotes → shell +2. Variable assignment (`FOO=bar`) → shell +3. Shell builtins (cd, echo, export, etc. — 50 builtins) → shell +4. Absolute/relative path to existing file → shell +5. Command found in PATH → shell +6. **None of the above → AI** + +Quote-aware: operators inside `'...'` or `"..."` are ignored. + +## Command Execution (executor.rs) + +- `cd` → `env::set_current_dir()` (changes process directory) +- `cd -` → OLDPWD support, `~` → HOME expansion +- Everything else → `sh -c "input"` (pipes, redirects, globs all work) + +## Status Bar (status.rs) + +Terminal last line reserved for Claude status: + +``` +● idle ← waiting +● [1] thinking... ← processing +● [1] responding... ← generating text +● [1] running: Bash... ← executing tool +● [1] responding... | [2] thinking... ← multiple sessions +``` + +Implementation: +- ANSI escape `\x1b[1;{rows-1}r` sets scroll region excluding last line +- `\x1b7` save cursor → draw status on last row → `\x1b8` restore cursor +- Auto-cleanup on Drop (reset scroll region) + +## Files + +``` +src/ +├── main.rs Main loop: input → judge → execute/AI → drain responses +├── lib.rs Module declarations +├── ai.rs ClaudeManager: persistent process, async send/receive +├── judge.rs is_command(): input classification (6 tests) +├── executor.rs execute(): sh -c + cd handling +└── status.rs StatusBar: ANSI last-line status display +``` + +## Dependencies + +```toml +rustyline = "14.0" # line input + history +serde = "1" # JSON serialization +serde_json = "1" # stream-json protocol parsing +terminal_size = "0.4" # terminal dimensions for status bar +``` diff --git a/src/ai.rs b/src/ai.rs new file mode 100644 index 0000000..7f00a8e --- /dev/null +++ b/src/ai.rs @@ -0,0 +1,224 @@ +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::sync::mpsc; +use std::thread; +use serde_json::Value; + +#[derive(Clone, Debug)] +pub struct SessionStatus { + pub id: usize, + pub state: String, +} + +pub struct ClaudeManager { + stdin: Arc>, + status: Arc>>, + output_rx: mpsc::Receiver, + id_tx: mpsc::Sender, + next_id: usize, +} + +impl ClaudeManager { + pub fn spawn() -> Result { + let mut child = Command::new("claude") + .arg("--input-format").arg("stream-json") + .arg("--output-format").arg("stream-json") + .arg("--verbose") + .arg("--dangerously-skip-permissions") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("failed to start claude: {}", e))?; + + let stdin = child.stdin.take() + .ok_or("failed to capture claude stdin")?; + let stdin = Arc::new(Mutex::new(stdin)); + let child_stdout = child.stdout.take() + .ok_or("failed to capture claude stdout")?; + + let status: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (output_tx, output_rx) = mpsc::channel(); + let (id_tx, id_rx) = mpsc::channel::(); + + let status_clone = status.clone(); + + // Background thread: read Claude's stdout, collect responses + thread::spawn(move || { + Self::reader_loop(child, child_stdout, id_rx, output_tx, status_clone); + }); + + Ok(Self { + stdin, + status, + output_rx, + id_tx, + next_id: 1, + }) + } + + fn reader_loop( + mut child: Child, + stdout: std::process::ChildStdout, + id_rx: mpsc::Receiver, + output_tx: mpsc::Sender, + status: Arc>>, + ) { + let reader = BufReader::new(stdout); + let mut current_text = String::new(); + let mut current_id: Option = None; + + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, + }; + if line.trim().is_empty() { + continue; + } + let json: Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + + let msg_type = json.get("type").and_then(|t| t.as_str()); + match msg_type { + Some("system") => {} + Some("assistant") => { + // Pick up the session ID for this response + if current_id.is_none() { + current_id = id_rx.try_recv().ok(); + } + Self::handle_assistant(&json, &mut current_text, current_id, &status); + } + Some("result") => { + if current_text.is_empty() { + if let Some(result) = json.get("result").and_then(|r| r.as_str()) { + current_text = result.to_string(); + } + } + // Send completed response + let text = std::mem::take(&mut current_text); + if !text.is_empty() { + let _ = output_tx.send(text); + } + // Remove session from status + if let Some(id) = current_id.take() { + if let Ok(mut st) = status.lock() { + st.retain(|s| s.id != id); + } + } + } + _ => {} + } + } + let _ = child.wait(); + } + + fn handle_assistant( + json: &Value, + current_text: &mut String, + current_id: Option, + status: &Arc>>, + ) { + let content = match json.pointer("/message/content").and_then(|c| c.as_array()) { + Some(arr) => arr, + None => return, + }; + + for item in content { + match item.get("type").and_then(|t| t.as_str()) { + Some("text") => { + if let Some(text) = item.get("text").and_then(|t| t.as_str()) { + current_text.push_str(text); + } + Self::update_status(status, current_id, "responding..."); + } + Some("tool_use") => { + let name = item.get("name") + .and_then(|n| n.as_str()) + .unwrap_or("tool"); + Self::update_status(status, current_id, &format!("running: {}...", name)); + } + _ => {} + } + } + } + + fn update_status( + status: &Arc>>, + id: Option, + state: &str, + ) { + let id = match id { + Some(id) => id, + None => return, + }; + if let Ok(mut st) = status.lock() { + if let Some(s) = st.iter_mut().find(|s| s.id == id) { + s.state = state.to_string(); + } + } + } + + /// Send a message to Claude (non-blocking). Returns session ID. + pub fn send(&mut self, input: &str) -> usize { + let id = self.next_id; + self.next_id += 1; + + // Add to status + if let Ok(mut st) = self.status.lock() { + st.push(SessionStatus { + id, + state: "thinking...".to_string(), + }); + } + + // Notify reader thread of the session ID + let _ = self.id_tx.send(id); + + let msg = serde_json::json!({ + "type": "user", + "message": { + "role": "user", + "content": input + } + }); + + if let Ok(mut stdin) = self.stdin.lock() { + let _ = writeln!(stdin, "{}", msg); + let _ = stdin.flush(); + } + + id + } + + /// Drain and print any completed responses. Non-blocking. + pub fn drain_responses(&self) { + while let Ok(text) = self.output_rx.try_recv() { + println!("\n{}", text); + } + } + + /// Try to receive one completed response. Non-blocking. + pub fn try_recv(&self) -> Option { + self.output_rx.try_recv().ok() + } + + /// Get current status string for status bar. + pub fn status_line(&self) -> String { + let st = match self.status.lock() { + Ok(st) => st, + Err(_) => return "error".to_string(), + }; + if st.is_empty() { + "idle".to_string() + } else { + st.iter() + .map(|s| format!("[{}] {}", s.id, s.state)) + .collect::>() + .join(" | ") + } + } +} diff --git a/src/executor.rs b/src/executor.rs new file mode 100644 index 0000000..be63983 --- /dev/null +++ b/src/executor.rs @@ -0,0 +1,80 @@ +use std::env; +use std::path::PathBuf; +use std::process::Command; + +/// Execute a shell command line and print output directly to stdout/stderr. +/// Returns the exit code. +pub fn execute(input: &str) -> i32 { + let trimmed = input.trim(); + + // Handle cd specially - it must affect our process + if trimmed == "cd" || trimmed.starts_with("cd ") { + return handle_cd(trimmed); + } + + // Run everything else through sh -c for full shell semantics + // (pipes, redirects, globs, etc.) + let status = Command::new("sh") + .arg("-c") + .arg(trimmed) + .status(); + + match status { + Ok(s) => s.code().unwrap_or(1), + Err(e) => { + eprintln!("aishell: {}", e); + 127 + } + } +} + +fn handle_cd(input: &str) -> i32 { + let target = input.strip_prefix("cd").unwrap().trim(); + + let dir: PathBuf = if target.is_empty() { + // cd with no args → home directory + match env::var("HOME") { + Ok(home) => PathBuf::from(home), + Err(_) => { + eprintln!("aishell: cd: HOME not set"); + return 1; + } + } + } else if target.starts_with('~') { + // Expand ~ to HOME + match env::var("HOME") { + Ok(home) => PathBuf::from(target.replacen('~', &home, 1)), + Err(_) => { + eprintln!("aishell: cd: HOME not set"); + return 1; + } + } + } else if target == "-" { + // cd - → previous directory + match env::var("OLDPWD") { + Ok(old) => { + println!("{}", old); + PathBuf::from(old) + } + Err(_) => { + eprintln!("aishell: cd: OLDPWD not set"); + return 1; + } + } + } else { + PathBuf::from(target) + }; + + // Save current dir as OLDPWD + if let Ok(cwd) = env::current_dir() { + env::set_var("OLDPWD", cwd); + } + + match env::set_current_dir(&dir) { + Ok(_) => 0, + Err(e) => { + eprintln!("aishell: cd: {}: {}", dir.display(), e); + 1 + } + } +} diff --git a/src/judge.rs b/src/judge.rs new file mode 100644 index 0000000..c35a000 --- /dev/null +++ b/src/judge.rs @@ -0,0 +1,150 @@ +use std::env; +use std::path::Path; + +const BUILTINS: &[&str] = &[ + "cd", "exit", "export", "unset", "alias", "unalias", "source", ".", + "echo", "printf", "test", "[", "set", "shift", "return", "break", + "continue", "eval", "exec", "trap", "wait", "read", "type", "hash", + "ulimit", "umask", "bg", "fg", "jobs", "kill", "pwd", "let", "local", + "declare", "typeset", "readonly", "getopts", "true", "false", ":", + "history", "logout", "popd", "pushd", "dirs", "builtin", "command", + "compgen", "complete", "shopt", "enable", "help", "times", "caller", +]; + +/// Determine whether user input should be executed as a shell command. +pub fn is_command(input: &str) -> bool { + let trimmed = input.trim(); + if trimmed.is_empty() { + return false; + } + + // Shell operators: pipe, redirect, background, semicolon, logical operators + if contains_shell_operator(trimmed) { + return true; + } + + // Variable assignment: FOO=bar + if is_variable_assignment(trimmed) { + return true; + } + + // Extract first token + let first_token = match trimmed.split_whitespace().next() { + Some(t) => t, + None => return false, + }; + + // Shell builtin + if BUILTINS.contains(&first_token) { + return true; + } + + // Absolute or relative path to executable + if first_token.contains('/') { + return Path::new(first_token).exists(); + } + + // Search PATH + is_in_path(first_token) +} + +fn contains_shell_operator(input: &str) -> bool { + // Check for pipes, redirects, etc. outside of quotes + let mut in_single = false; + let mut in_double = false; + let mut prev = '\0'; + + for ch in input.chars() { + match ch { + '\'' if !in_double && prev != '\\' => in_single = !in_single, + '"' if !in_single && prev != '\\' => in_double = !in_double, + '|' | ';' if !in_single && !in_double => return true, + '>' | '<' if !in_single && !in_double => return true, + '&' if !in_single && !in_double => return true, + _ => {} + } + prev = ch; + } + false +} + +fn is_variable_assignment(input: &str) -> bool { + // Pattern: NAME=value (NAME starts with letter/underscore, contains only alnum/underscore) + if let Some(eq_pos) = input.find('=') { + if eq_pos == 0 { + return false; + } + let name = &input[..eq_pos]; + // Must not contain spaces before = + if name.contains(' ') { + return false; + } + let first = name.chars().next().unwrap(); + if !first.is_alphabetic() && first != '_' { + return false; + } + return name.chars().all(|c| c.is_alphanumeric() || c == '_'); + } + false +} + +fn is_in_path(cmd: &str) -> bool { + let path_var = match env::var("PATH") { + Ok(p) => p, + Err(_) => return false, + }; + + for dir in path_var.split(':') { + let full = Path::new(dir).join(cmd); + if full.is_file() { + return true; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_builtins() { + assert!(is_command("cd /tmp")); + assert!(is_command("export FOO=bar")); + assert!(is_command("echo hello")); + assert!(is_command("pwd")); + } + + #[test] + fn test_path_commands() { + assert!(is_command("ls")); + assert!(is_command("ls -la")); + assert!(is_command("cat /etc/hostname")); + } + + #[test] + fn test_shell_operators() { + assert!(is_command("ls | grep foo")); + assert!(is_command("echo hello > /tmp/out")); + assert!(is_command("cat file1 && cat file2")); + } + + #[test] + fn test_variable_assignment() { + assert!(is_command("FOO=bar")); + assert!(is_command("MY_VAR=hello")); + } + + #[test] + fn test_ai_input() { + assert!(!is_command("macbookがフリーズする原因を調べて")); + assert!(!is_command("hello world what is this")); + assert!(!is_command("Rustでhello worldを書いて")); + } + + #[test] + fn test_empty() { + assert!(!is_command("")); + assert!(!is_command(" ")); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..270f248 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,4 @@ +pub mod judge; +pub mod executor; +pub mod ai; +pub mod status; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a4eada3 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,119 @@ +use std::env; +use std::sync::{Arc, atomic::{AtomicBool, Ordering}}; +use std::thread; +use std::time::Duration; +use std::io::{Write, stdout}; +use rustyline::error::ReadlineError; +use rustyline::DefaultEditor; +use aishell::judge; +use aishell::executor; +use aishell::ai::ClaudeManager; +use aishell::status::StatusBar; + +fn prompt_string() -> String { + let cwd = env::current_dir() + .map(|p| { + if let Ok(home) = env::var("HOME") { + if let Some(rest) = p.to_str().and_then(|s| s.strip_prefix(&home)) { + if rest.is_empty() { + return "~".to_string(); + } + return format!("~{rest}"); + } + } + p.display().to_string() + }) + .unwrap_or_else(|_| "?".to_string()); + format!("{cwd} $ ") +} + +fn main() { + let mut status = StatusBar::new(); + status.set("starting claude..."); + + let claude = match ClaudeManager::spawn() { + Ok(c) => c, + Err(e) => { + status.cleanup(); + eprintln!("aishell: {}", e); + std::process::exit(1); + } + }; + + let claude = Arc::new(std::sync::Mutex::new(claude)); + status.set("idle"); + + // Background thread: poll for responses and update status bar + let claude_bg = claude.clone(); + let running = Arc::new(AtomicBool::new(true)); + let running_bg = running.clone(); + + let status_bg = Arc::new(std::sync::Mutex::new(StatusBar::new_without_setup())); + + let status_render = status_bg.clone(); + thread::spawn(move || { + while running_bg.load(Ordering::Relaxed) { + { + let cm = claude_bg.lock().unwrap(); + // Drain responses and print them + while let Some(text) = cm.try_recv() { + // Move cursor to beginning of line, clear it, print response + print!("\r\x1b[2K\n{}\n", text); + stdout().flush().ok(); + } + // Update status bar + let line = cm.status_line(); + if let Ok(mut s) = status_render.lock() { + s.set(&line); + } + } + thread::sleep(Duration::from_millis(200)); + } + }); + + let mut rl = match DefaultEditor::new() { + Ok(editor) => editor, + Err(e) => { + running.store(false, Ordering::Relaxed); + eprintln!("aishell: failed to initialize: {}", e); + std::process::exit(1); + } + }; + + let history_path = env::var("HOME") + .map(|h| format!("{}/.aishell_history", h)) + .unwrap_or_else(|_| ".aishell_history".to_string()); + let _ = rl.load_history(&history_path); + + loop { + let prompt = prompt_string(); + match rl.readline(&prompt) { + Ok(line) => { + let input = line.trim(); + if input.is_empty() { + continue; + } + let _ = rl.add_history_entry(input); + if input == "exit" || input == "quit" { + break; + } + if judge::is_command(input) { + executor::execute(input); + } else { + if let Ok(mut cm) = claude.lock() { + cm.send(input); + } + } + } + Err(ReadlineError::Interrupted) => continue, + Err(ReadlineError::Eof) => break, + Err(e) => { + eprintln!("aishell: {}", e); + break; + } + } + } + + running.store(false, Ordering::Relaxed); + let _ = rl.save_history(&history_path); +} diff --git a/src/status.rs b/src/status.rs new file mode 100644 index 0000000..c15b579 --- /dev/null +++ b/src/status.rs @@ -0,0 +1,71 @@ +use std::io::{Write, stdout}; +use terminal_size::{terminal_size, Width, Height}; + +pub struct StatusBar { + message: String, +} + +impl StatusBar { + pub fn new() -> Self { + let sb = Self { + message: "idle".to_string(), + }; + sb.setup(); + sb + } + + pub fn new_without_setup() -> Self { + Self { + message: "idle".to_string(), + } + } + + fn term_size(&self) -> (u16, u16) { + terminal_size() + .map(|(Width(w), Height(h))| (w, h)) + .unwrap_or((80, 24)) + } + + fn setup(&self) { + let (_, rows) = self.term_size(); + let mut out = stdout(); + write!(out, "\x1b[1;{}r", rows - 1).ok(); + write!(out, "\x1b[1;1H").ok(); + out.flush().ok(); + self.render(); + } + + pub fn set(&mut self, msg: &str) { + self.message = msg.to_string(); + self.render(); + } + + fn render(&self) { + let (cols, rows) = self.term_size(); + let status = format!(" ● {}", self.message); + let padded = format!("{: Self { + Self::new() + } +} + +impl Drop for StatusBar { + fn drop(&mut self) { + self.cleanup(); + } +}