commit 134358e7ac634dcefb379b9c6f52bd1564727ae1 Author: syui Date: Fri Feb 27 19:03:18 2026 +0900 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e415a1b --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Rust +/target/ +Cargo.lock + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Environment +.env +.env.local +/.claude +/claude.md +/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/src/ai.rs b/src/ai.rs new file mode 100644 index 0000000..795393b --- /dev/null +++ b/src/ai.rs @@ -0,0 +1,239 @@ +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 label: 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 { + // Find lowest available ID (reuse completed IDs) + let id = if let Ok(st) = self.status.lock() { + let mut id = 1; + let used: Vec = st.iter().map(|s| s.id).collect(); + while used.contains(&id) { + id += 1; + } + id + } else { + self.next_id + }; + self.next_id = id + 1; + + // Build label from first chars of input + let label: String = input.chars().take(10).collect(); + + // Add to status + if let Ok(mut st) = self.status.lock() { + st.push(SessionStatus { + id, + state: "thinking...".to_string(), + label, + }); + } + + // 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, s.label)) + .collect::>() + .join(" | ") + } + } +} diff --git a/src/completer.rs b/src/completer.rs new file mode 100644 index 0000000..41bbd73 --- /dev/null +++ b/src/completer.rs @@ -0,0 +1,170 @@ +use std::env; +use std::fs; +use std::path::Path; +use rustyline::completion::{Completer, Pair}; +use rustyline::highlight::Highlighter; +use rustyline::hint::{Hinter, HistoryHinter}; +use rustyline::validate::Validator; +use rustyline::Helper; +use rustyline::Context; + +pub struct ShellHelper { + hinter: HistoryHinter, +} + +impl ShellHelper { + pub fn new() -> Self { + Self { + hinter: HistoryHinter::new(), + } + } +} + +impl Helper for ShellHelper {} +impl Validator for ShellHelper {} + +impl Highlighter for ShellHelper { + fn highlight_hint<'h>(&self, hint: &'h str) -> std::borrow::Cow<'h, str> { + // Gray color for hints + std::borrow::Cow::Owned(format!("\x1b[90m{}\x1b[0m", hint)) + } +} + +impl Hinter for ShellHelper { + type Hint = String; + + fn hint(&self, line: &str, pos: usize, ctx: &Context<'_>) -> Option { + self.hinter.hint(line, pos, ctx) + } +} + +impl Completer for ShellHelper { + type Candidate = Pair; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &Context<'_>, + ) -> rustyline::Result<(usize, Vec)> { + let (start, word) = extract_word(line, pos); + + // If first word → command completion + // Otherwise → file path completion + let is_first_word = line[..start].trim().is_empty(); + + let candidates = if is_first_word { + let mut results = complete_commands(word); + // Also include files in current dir for ./script style + results.extend(complete_path(word)); + results + } else { + complete_path(word) + }; + + Ok((start, candidates)) + } +} + +/// Extract the current word being completed +fn extract_word(line: &str, pos: usize) -> (usize, &str) { + let bytes = &line.as_bytes()[..pos]; + let start = bytes.iter().rposition(|&b| b == b' ').map(|i| i + 1).unwrap_or(0); + (start, &line[start..pos]) +} + +/// Complete commands from PATH +fn complete_commands(prefix: &str) -> Vec { + if prefix.is_empty() { + return Vec::new(); + } + + let path_var = match env::var("PATH") { + Ok(p) => p, + Err(_) => return Vec::new(), + }; + + let mut seen = std::collections::HashSet::new(); + let mut results = Vec::new(); + + for dir in path_var.split(':') { + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(_) => continue, + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with(prefix) && !seen.contains(name.as_ref()) { + seen.insert(name.to_string()); + results.push(Pair { + display: name.to_string(), + replacement: name.to_string(), + }); + } + } + } + + results.sort_by(|a, b| a.display.cmp(&b.display)); + results +} + +/// Complete file/directory paths +fn complete_path(prefix: &str) -> Vec { + let (dir, file_prefix) = if prefix.contains('/') { + let p = Path::new(prefix); + let dir = p.parent().unwrap_or(Path::new(".")); + let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); + // Expand ~ + let dir_str = dir.to_string_lossy(); + let expanded = if dir_str.starts_with('~') { + if let Ok(home) = env::var("HOME") { + Path::new(&dir_str.replacen('~', &home, 1)).to_path_buf() + } else { + dir.to_path_buf() + } + } else { + dir.to_path_buf() + }; + (expanded, file) + } else { + (std::env::current_dir().unwrap_or_default(), prefix.to_string()) + }; + + let entries = match fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => return Vec::new(), + }; + + let mut results = Vec::new(); + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy().to_string(); + if name.starts_with(&file_prefix) { + let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + let replacement = if prefix.contains('/') { + let parent = Path::new(prefix).parent().unwrap_or(Path::new("")); + let mut r = format!("{}/{}", parent.display(), name); + if is_dir { + r.push('/'); + } + r + } else { + let mut r = name.clone(); + if is_dir { + r.push('/'); + } + r + }; + let display = if is_dir { + format!("{}/", name) + } else { + name + }; + results.push(Pair { display, replacement }); + } + } + + results.sort_by(|a, b| a.display.cmp(&b.display)); + results +} 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..893ab4d --- /dev/null +++ b/src/judge.rs @@ -0,0 +1,169 @@ +use std::collections::HashSet; +use std::env; +use std::fs; + +const SHELL_BUILTINS: &[&str] = &[ + ".", ":", "alias", "bg", "bind", "break", "builtin", "caller", "cd", + "command", "compgen", "complete", "compopt", "continue", "declare", + "dirs", "disown", "echo", "enable", "eval", "exec", "exit", "export", + "false", "fc", "fg", "getopts", "hash", "help", "history", "jobs", + "kill", "let", "local", "logout", "mapfile", "popd", "printf", "pushd", + "pwd", "read", "readarray", "readonly", "return", "set", "shift", + "shopt", "source", "suspend", "test", "times", "trap", "true", "type", + "typeset", "ulimit", "umask", "unalias", "unset", "wait", +]; + +/// Cached command set for fast lookup without fork. +pub struct CommandCache { + commands: HashSet, +} + +impl CommandCache { + pub fn new() -> Self { + let mut commands = HashSet::new(); + + // Shell builtins + for &b in SHELL_BUILTINS { + commands.insert(b.to_string()); + } + + // Scan PATH directories + if let Ok(path_var) = env::var("PATH") { + for dir in path_var.split(':') { + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + if let Some(name) = entry.file_name().to_str() { + commands.insert(name.to_string()); + } + } + } + } + } + + Self { commands } + } + + pub fn contains(&self, cmd: &str) -> bool { + self.commands.contains(cmd) + } +} + +/// Determine whether user input should be executed as a shell command. +pub fn is_command(input: &str, cache: &CommandCache) -> 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, + }; + + // Lookup in cached command set (no fork) + cache.contains(first_token) +} + +fn contains_shell_operator(input: &str) -> bool { + 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 { + if let Some(eq_pos) = input.find('=') { + if eq_pos == 0 { + return false; + } + let name = &input[..eq_pos]; + 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 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cache() -> CommandCache { + CommandCache::new() + } + + #[test] + fn test_builtins() { + let c = cache(); + assert!(is_command("cd /tmp", &c)); + assert!(is_command("export FOO=bar", &c)); + assert!(is_command("echo hello", &c)); + assert!(is_command("pwd", &c)); + } + + #[test] + fn test_path_commands() { + let c = cache(); + assert!(is_command("ls", &c)); + assert!(is_command("ls -la", &c)); + assert!(is_command("cat /etc/hostname", &c)); + } + + #[test] + fn test_shell_operators() { + let c = cache(); + assert!(is_command("ls | grep foo", &c)); + assert!(is_command("echo hello > /tmp/out", &c)); + assert!(is_command("cat file1 && cat file2", &c)); + } + + #[test] + fn test_variable_assignment() { + let c = cache(); + assert!(is_command("FOO=bar", &c)); + assert!(is_command("MY_VAR=hello", &c)); + } + + #[test] + fn test_ai_input() { + let c = cache(); + assert!(!is_command("macbookがフリーズする原因を調べて", &c)); + assert!(!is_command("hello world what is this", &c)); + assert!(!is_command("Rustでhello worldを書いて", &c)); + } + + #[test] + fn test_empty() { + let c = cache(); + assert!(!is_command("", &c)); + assert!(!is_command(" ", &c)); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..39950cf --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,5 @@ +pub mod judge; +pub mod executor; +pub mod ai; +pub mod status; +pub mod completer; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..fc6e1d9 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,145 @@ +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::Editor; +use rustyline::Config; +use rustyline::CompletionType; +use aishell::judge::{self, CommandCache}; +use aishell::executor; +use aishell::ai::ClaudeManager; +use aishell::status::StatusBar; +use aishell::completer::ShellHelper; + +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} $ ") +} + +// ascii-image-converter -W 30 -b ai.png +const LOGO: &str = "\ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣿⣿⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣿⣿⣿⣿⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⣿⣿⣿⣿⣿⣿⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⣿⠟⠉⠀⠀⠀⠀⠉⠻⣿⣿⣿⣧⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⢀⣾⣿⣿⣿⡄⠀⠀⠀⠀⠀⠀⠀⠀⢠⣿⣿⣿⣷⡀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⣠⣿⣿⣿⣿⣿⣿⣦⣀⠀⠀⠀⠀⣀⣴⣿⣿⣿⣿⣿⣿⣄⠀⠀⠀⠀ +⠀⠀⢀⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⡀⠀⠀ +⠀⠀⠈⠁⠀⠀⠀⠀⠀⠀⠉⠛⠿⠿⠿⠿⠿⠿⠛⠉⠀⠀⠀⠀⠀⠀⠈⠁⠀⠀"; + +fn main() { + let mut status = StatusBar::new(); + println!("\x1b[38;5;226m{}\x1b[0m\n\x1b[1m aishell\x1b[0m v{}\n", LOGO, env!("CARGO_PKG_VERSION")); + 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)); + let cmd_cache = CommandCache::new(); + 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 config = Config::builder() + .completion_type(CompletionType::List) + .build(); + let mut rl = match Editor::with_config(config) { + Ok(mut editor) => { + editor.set_helper(Some(ShellHelper::new())); + 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, &cmd_cache) { + 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(); + } +}