refact
This commit is contained in:
235
src/ai.rs
235
src/ai.rs
@@ -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();
|
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()) {
|
||||||
// Stream text chunks
|
if !stream_started {
|
||||||
if let Some(content) = json.pointer("/message/content").and_then(|c| c.as_array()) {
|
let _ = tx.send(OutputEvent::StreamStart);
|
||||||
for item in content {
|
stream_started = true;
|
||||||
match item.get("type").and_then(|t| t.as_str()) {
|
last_text_len = 0;
|
||||||
Some("text") => {
|
}
|
||||||
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
|
if text.len() > last_text_len {
|
||||||
// Send stream start on first chunk
|
let _ = tx.send(OutputEvent::StreamChunk(
|
||||||
if !stream_started {
|
text[last_text_len..].to_string(),
|
||||||
let _ = output_tx.send(OutputEvent::StreamStart);
|
));
|
||||||
stream_started = true;
|
last_text_len = text.len();
|
||||||
last_text_len = 0;
|
|
||||||
}
|
|
||||||
// Only send the new part (delta)
|
|
||||||
if text.len() > last_text_len {
|
|
||||||
let delta = &text[last_text_len..];
|
|
||||||
let _ = output_tx.send(OutputEvent::StreamChunk(delta.to_string()));
|
|
||||||
last_text_len = text.len();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Self::update_status(&status, current_id, "responding...");
|
|
||||||
}
|
}
|
||||||
Some("tool_use") => {
|
Self::set_state(&state, "responding...");
|
||||||
let name = item.get("name")
|
|
||||||
.and_then(|n| n.as_str())
|
|
||||||
.unwrap_or("tool");
|
|
||||||
Self::update_status(&status, current_id, &format!("running: {}...", name));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
|
Some("tool_use") => {
|
||||||
|
let name = item.get("name")
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.unwrap_or("tool");
|
||||||
|
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(" | ")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user