1
0
This commit is contained in:
2026-03-03 16:02:20 +09:00
parent 43cd4afa1e
commit 9e9b974051
2 changed files with 70 additions and 218 deletions

205
src/ai.rs
View File

@@ -1,33 +1,20 @@
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::process::{Child, Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::sync::mpsc; use std::sync::mpsc;
use std::thread; use std::thread;
use serde_json::Value; use serde_json::Value;
#[derive(Clone, Debug)]
pub struct SessionStatus {
pub id: usize,
pub state: String,
pub label: String,
}
/// Messages sent from the reader thread to the main display loop.
pub enum OutputEvent { pub enum OutputEvent {
/// Start of a new AI response.
StreamStart, StreamStart,
/// A chunk of text to display immediately.
StreamChunk(String), StreamChunk(String),
/// End of the current response.
StreamEnd, StreamEnd,
} }
pub struct ClaudeManager { pub struct ClaudeManager {
stdin: Arc<Mutex<std::process::ChildStdin>>, stdin: std::process::ChildStdin,
status: Arc<Mutex<Vec<SessionStatus>>>, state: Arc<Mutex<String>>,
output_rx: mpsc::Receiver<OutputEvent>, output_rx: mpsc::Receiver<OutputEvent>,
id_tx: mpsc::Sender<usize>,
next_id: usize,
child_pid: u32, child_pid: u32,
} }
@@ -45,119 +32,81 @@ impl ClaudeManager {
.map_err(|e| format!("failed to start claude: {}", e))?; .map_err(|e| format!("failed to start claude: {}", e))?;
let child_pid = child.id(); let child_pid = child.id();
let stdin = child.stdin.take() let stdin = child.stdin.take().ok_or("failed to capture stdin")?;
.ok_or("failed to capture claude stdin")?; let stdout = child.stdout.take().ok_or("failed to capture stdout")?;
let stdin = Arc::new(Mutex::new(stdin));
let child_stdout = child.stdout.take()
.ok_or("failed to capture claude stdout")?;
let status: Arc<Mutex<Vec<SessionStatus>>> = Arc::new(Mutex::new(Vec::new())); let state = Arc::new(Mutex::new("idle".to_string()));
let (output_tx, output_rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
let (id_tx, id_rx) = mpsc::channel::<usize>(); let state_bg = state.clone();
let status_clone = status.clone();
// Background thread: read Claude's stdout, stream responses
thread::spawn(move || { thread::spawn(move || {
Self::reader_loop(child, child_stdout, id_rx, output_tx, status_clone); Self::reader_loop(child, stdout, tx, state_bg);
}); });
Ok(Self { Ok(Self { stdin, state, output_rx: rx, child_pid })
stdin,
status,
output_rx,
id_tx,
next_id: 1,
child_pid,
})
} }
fn reader_loop( fn reader_loop(
mut child: Child, mut child: std::process::Child,
stdout: std::process::ChildStdout, stdout: std::process::ChildStdout,
id_rx: mpsc::Receiver<usize>, tx: mpsc::Sender<OutputEvent>,
output_tx: mpsc::Sender<OutputEvent>, state: Arc<Mutex<String>>,
status: Arc<Mutex<Vec<SessionStatus>>>,
) { ) {
let reader = BufReader::new(stdout); let reader = BufReader::new(stdout);
let mut current_id: Option<usize> = None;
let mut stream_started = false; let mut stream_started = false;
let mut last_text_len: usize = 0; let mut last_text_len: usize = 0;
for line in reader.lines() { for line in reader.lines().flatten() {
let line = match line { if line.trim().is_empty() { continue; }
Ok(l) => l,
Err(_) => break,
};
if line.trim().is_empty() {
continue;
}
let json: Value = match serde_json::from_str(&line) { let json: Value = match serde_json::from_str(&line) {
Ok(v) => v, Ok(v) => v,
Err(_) => continue, Err(_) => continue,
}; };
let msg_type = json.get("type").and_then(|t| t.as_str()); match json.get("type").and_then(|t| t.as_str()) {
match msg_type {
Some("system") => {}
Some("assistant") => { Some("assistant") => {
// Pick up the session ID for this response let content = json.pointer("/message/content").and_then(|c| c.as_array());
if current_id.is_none() { for item in content.into_iter().flatten() {
current_id = id_rx.try_recv().ok();
}
// Stream text chunks
if let Some(content) = json.pointer("/message/content").and_then(|c| c.as_array()) {
for item in content {
match item.get("type").and_then(|t| t.as_str()) { match item.get("type").and_then(|t| t.as_str()) {
Some("text") => { Some("text") => {
if let Some(text) = item.get("text").and_then(|t| t.as_str()) { if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
// Send stream start on first chunk
if !stream_started { if !stream_started {
let _ = output_tx.send(OutputEvent::StreamStart); let _ = tx.send(OutputEvent::StreamStart);
stream_started = true; stream_started = true;
last_text_len = 0; last_text_len = 0;
} }
// Only send the new part (delta)
if text.len() > last_text_len { if text.len() > last_text_len {
let delta = &text[last_text_len..]; let _ = tx.send(OutputEvent::StreamChunk(
let _ = output_tx.send(OutputEvent::StreamChunk(delta.to_string())); text[last_text_len..].to_string(),
));
last_text_len = text.len(); last_text_len = text.len();
} }
} }
Self::update_status(&status, current_id, "responding..."); Self::set_state(&state, "responding...");
} }
Some("tool_use") => { Some("tool_use") => {
let name = item.get("name") let name = item.get("name")
.and_then(|n| n.as_str()) .and_then(|n| n.as_str())
.unwrap_or("tool"); .unwrap_or("tool");
Self::update_status(&status, current_id, &format!("running: {}...", name)); Self::set_state(&state, &format!("running: {name}..."));
} }
_ => {} _ => {}
} }
} }
} }
}
Some("result") => { Some("result") => {
// If we never streamed text, send the result field
if !stream_started { if !stream_started {
if let Some(result) = json.get("result").and_then(|r| r.as_str()) { if let Some(text) = json.get("result").and_then(|r| r.as_str()) {
if !result.is_empty() { if !text.is_empty() {
let _ = output_tx.send(OutputEvent::StreamStart); let _ = tx.send(OutputEvent::StreamStart);
let _ = output_tx.send(OutputEvent::StreamChunk(result.to_string())); let _ = tx.send(OutputEvent::StreamChunk(text.to_string()));
} }
} }
} }
let _ = output_tx.send(OutputEvent::StreamEnd); let _ = tx.send(OutputEvent::StreamEnd);
stream_started = false; stream_started = false;
last_text_len = 0; last_text_len = 0;
Self::set_state(&state, "idle");
// Remove session from status
if let Some(id) = current_id.take() {
if let Ok(mut st) = status.lock() {
st.retain(|s| s.id != id);
}
}
} }
_ => {} _ => {}
} }
@@ -165,107 +114,35 @@ impl ClaudeManager {
let _ = child.wait(); let _ = child.wait();
} }
fn update_status( fn set_state(state: &Arc<Mutex<String>>, s: &str) {
status: &Arc<Mutex<Vec<SessionStatus>>>, if let Ok(mut st) = state.lock() { *st = s.to_string(); }
id: Option<usize>,
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) {
pub fn send(&mut self, input: &str) -> usize { Self::set_state(&self.state, "thinking...");
// Find lowest available ID (reuse completed IDs)
let id = if let Ok(st) = self.status.lock() {
let mut id = 1;
let used: Vec<usize> = 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!({ let msg = serde_json::json!({
"type": "user", "type": "user",
"message": { "message": { "role": "user", "content": input }
"role": "user",
"content": input
}
}); });
let _ = writeln!(self.stdin, "{}", msg);
if let Ok(mut stdin) = self.stdin.lock() { let _ = self.stdin.flush();
let _ = writeln!(stdin, "{}", msg);
let _ = stdin.flush();
} }
id
}
/// Try to receive one output event. Non-blocking.
pub fn try_recv(&self) -> Option<OutputEvent> { pub fn try_recv(&self) -> Option<OutputEvent> {
self.output_rx.try_recv().ok() self.output_rx.try_recv().ok()
} }
/// Cancel all in-progress AI sessions by sending SIGINT to the child process.
pub fn cancel(&mut self) { pub fn cancel(&mut self) {
if let Ok(mut st) = self.status.lock() { Self::set_state(&self.state, "idle");
if st.is_empty() { unsafe { libc::kill(self.child_pid as i32, libc::SIGINT); }
return;
}
st.clear();
}
// Send SIGINT to Claude child process
unsafe {
libc::kill(self.child_pid as i32, libc::SIGINT);
}
// Drain any pending events
while self.output_rx.try_recv().is_ok() {} while self.output_rx.try_recv().is_ok() {}
} }
/// Check if there are any active sessions.
pub fn is_busy(&self) -> bool { pub fn is_busy(&self) -> bool {
self.status.lock().map(|st| !st.is_empty()).unwrap_or(false) self.state.lock().map(|s| *s != "idle").unwrap_or(false)
} }
/// Get current status string for status bar.
pub fn status_line(&self) -> String { pub fn status_line(&self) -> String {
let st = match self.status.lock() { self.state.lock().map(|s| s.clone()).unwrap_or_else(|_| "error".to_string())
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::<Vec<_>>()
.join(" | ")
}
} }
} }

View File

@@ -2,24 +2,14 @@ use std::env;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; 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 { pub fn execute(input: &str) -> i32 {
let trimmed = input.trim(); let trimmed = input.trim();
// Handle cd specially - it must affect our process
if trimmed == "cd" || trimmed.starts_with("cd ") { if trimmed == "cd" || trimmed.starts_with("cd ") {
return handle_cd(trimmed); return handle_cd(trimmed);
} }
// Run everything else through sh -c for full shell semantics match Command::new("sh").arg("-c").arg(trimmed).status() {
// (pipes, redirects, globs, etc.)
let status = Command::new("sh")
.arg("-c")
.arg(trimmed)
.status();
match status {
Ok(s) => s.code().unwrap_or(1), Ok(s) => s.code().unwrap_or(1),
Err(e) => { Err(e) => {
eprintln!("aishell: {}", e); eprintln!("aishell: {}", e);
@@ -28,53 +18,38 @@ pub fn execute(input: &str) -> i32 {
} }
} }
fn home_dir() -> Option<String> {
env::var("HOME").ok()
}
fn handle_cd(input: &str) -> i32 { fn handle_cd(input: &str) -> i32 {
let target = input.strip_prefix("cd").unwrap().trim(); let target = input.strip_prefix("cd").unwrap().trim();
let dir: PathBuf = if target.is_empty() { let dir: PathBuf = if target.is_empty() {
// cd with no args → home directory match home_dir() {
match env::var("HOME") { Some(h) => PathBuf::from(h),
Ok(home) => PathBuf::from(home), None => { eprintln!("aishell: cd: HOME not set"); return 1; }
Err(_) => {
eprintln!("aishell: cd: HOME not set");
return 1;
}
} }
} else if target.starts_with('~') { } else if target.starts_with('~') {
// Expand ~ to HOME match home_dir() {
match env::var("HOME") { Some(h) => PathBuf::from(target.replacen('~', &h, 1)),
Ok(home) => PathBuf::from(target.replacen('~', &home, 1)), None => { eprintln!("aishell: cd: HOME not set"); return 1; }
Err(_) => {
eprintln!("aishell: cd: HOME not set");
return 1;
}
} }
} else if target == "-" { } else if target == "-" {
// cd - → previous directory
match env::var("OLDPWD") { match env::var("OLDPWD") {
Ok(old) => { Ok(old) => { println!("{}", old); PathBuf::from(old) }
println!("{}", old); Err(_) => { eprintln!("aishell: cd: OLDPWD not set"); return 1; }
PathBuf::from(old)
}
Err(_) => {
eprintln!("aishell: cd: OLDPWD not set");
return 1;
}
} }
} else { } else {
PathBuf::from(target) PathBuf::from(target)
}; };
// Save current dir as OLDPWD
if let Ok(cwd) = env::current_dir() { if let Ok(cwd) = env::current_dir() {
env::set_var("OLDPWD", cwd); env::set_var("OLDPWD", cwd);
} }
match env::set_current_dir(&dir) { match env::set_current_dir(&dir) {
Ok(_) => 0, Ok(_) => 0,
Err(e) => { Err(e) => { eprintln!("aishell: cd: {}: {}", dir.display(), e); 1 }
eprintln!("aishell: cd: {}: {}", dir.display(), e);
1
}
} }
} }