pub mod api_client;
pub mod transcript;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AiBackend {
Cli,
Api,
}
impl AiBackend {
pub fn parse(s: &str) -> Self {
match s.to_ascii_lowercase().as_str() {
"api" | "http" | "direct" => AiBackend::Api,
_ => AiBackend::Cli,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SuggestBackend {
Unset,
ClaudeCode,
ClaudeApi,
Local,
}
impl SuggestBackend {
pub fn parse(s: &str) -> Self {
match s.to_ascii_lowercase().as_str() {
"claude-code" | "cc" | "sub" | "subscription" => SuggestBackend::ClaudeCode,
"claude-api" | "claude" | "api" => SuggestBackend::ClaudeApi,
"local" | "candle" => SuggestBackend::Local,
_ => SuggestBackend::Unset,
}
}
pub fn as_str(self) -> &'static str {
match self {
SuggestBackend::Unset => "unset",
SuggestBackend::ClaudeCode => "claude-code",
SuggestBackend::ClaudeApi => "claude-api",
SuggestBackend::Local => "local",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AiProduct {
Claude,
Codex,
}
impl AiProduct {
pub fn key(self) -> &'static str {
match self {
AiProduct::Claude => "claude",
AiProduct::Codex => "codex",
}
}
pub fn sub_binary(self) -> &'static str {
match self {
AiProduct::Claude => "claude",
AiProduct::Codex => "codex",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RoutingBackend {
Sub,
Api,
Auto,
Off,
}
impl RoutingBackend {
pub fn parse(s: &str) -> Self {
match s.to_ascii_lowercase().as_str() {
"sub" | "subscription" | "cli" | "cc" | "claude-code" => RoutingBackend::Sub,
"api" | "http" | "direct" | "claude-api" => RoutingBackend::Api,
"off" | "disable" | "disabled" => RoutingBackend::Off,
_ => RoutingBackend::Auto,
}
}
pub fn as_str(self) -> &'static str {
match self {
RoutingBackend::Sub => "sub",
RoutingBackend::Api => "api",
RoutingBackend::Auto => "auto",
RoutingBackend::Off => "off",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolvedBackend {
Sub,
Api,
Off,
}
pub fn configured_backend(ai: &toml::Value, product: AiProduct) -> RoutingBackend {
if let Some(s) = ai
.get("routing")
.and_then(|r| r.get(product.key()))
.and_then(|p| p.get("backend"))
.and_then(|v| v.as_str())
{
return RoutingBackend::parse(s);
}
match product {
AiProduct::Claude => match ai.get("backend").and_then(|v| v.as_str()) {
Some(s) => {
let low = s.to_ascii_lowercase();
match low.as_str() {
"api" | "http" | "direct" => RoutingBackend::Api,
"cli" | "sub" | "subscription" | "cc" | "claude-code" => RoutingBackend::Sub,
"off" | "disable" | "disabled" => RoutingBackend::Off,
_ => RoutingBackend::Auto,
}
}
None => RoutingBackend::Auto,
},
AiProduct::Codex => RoutingBackend::Auto,
}
}
pub fn has_legacy_and_new_claude(ai: &toml::Value) -> bool {
let new = ai
.get("routing")
.and_then(|r| r.get("claude"))
.and_then(|p| p.get("backend"))
.is_some();
let legacy = ai.get("backend").is_some();
new && legacy
}
pub fn resolve_backend(ai: &toml::Value, product: AiProduct) -> ResolvedBackend {
match configured_backend(ai, product) {
RoutingBackend::Sub => ResolvedBackend::Sub,
RoutingBackend::Api => ResolvedBackend::Api,
RoutingBackend::Off => ResolvedBackend::Off,
RoutingBackend::Auto => {
let (bin, key_env) = match product {
AiProduct::Claude => ("claude", "ANTHROPIC_API_KEY"),
AiProduct::Codex => ("codex", "OPENAI_API_KEY"),
};
let has_bin = crate::integration_detect::is_binary_installed(bin);
let has_key = std::env::var(key_env)
.ok()
.filter(|s| !s.trim().is_empty())
.is_some();
if has_bin {
ResolvedBackend::Sub
} else if has_key {
ResolvedBackend::Api
} else {
ResolvedBackend::Sub
}
}
}
}
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
#[derive(Debug, Clone)]
pub struct ApplyTarget {
pub path: PathBuf,
pub start: usize,
pub end: usize,
}
pub fn first_code_block(md: &str) -> Option<String> {
let mut in_block = false;
let mut out = String::new();
for line in md.lines() {
let is_fence = {
let t = line.trim_start();
t.starts_with("```") || t.starts_with("~~~")
};
if !in_block {
if is_fence {
in_block = true;
}
continue;
}
if is_fence {
return Some(out.trim_end_matches('\n').to_string());
}
out.push_str(line);
out.push('\n');
}
in_block.then(|| out.trim_end_matches('\n').to_string())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiffLine {
Ctx(String),
Del(String),
Add(String),
}
pub fn line_diff(old: &str, new: &str) -> Vec<DiffLine> {
const CTX: usize = 3;
let o: Vec<&str> = old.split('\n').collect();
let n: Vec<&str> = new.split('\n').collect();
let mut pre = 0;
while pre < o.len() && pre < n.len() && o[pre] == n[pre] {
pre += 1;
}
let mut suf = 0;
while suf < o.len() - pre && suf < n.len() - pre && o[o.len() - 1 - suf] == n[n.len() - 1 - suf]
{
suf += 1;
}
let mut out: Vec<DiffLine> = Vec::new();
let push_ctx = |lines: &[&str], from_end: bool, out: &mut Vec<DiffLine>| {
if lines.len() <= CTX {
for l in lines {
out.push(DiffLine::Ctx(l.to_string()));
}
} else if from_end {
out.push(DiffLine::Ctx(format!(
"… {} unchanged lines …",
lines.len() - CTX
)));
for l in &lines[lines.len() - CTX..] {
out.push(DiffLine::Ctx(l.to_string()));
}
} else {
for l in &lines[..CTX] {
out.push(DiffLine::Ctx(l.to_string()));
}
out.push(DiffLine::Ctx(format!(
"… {} unchanged lines …",
lines.len() - CTX
)));
}
};
if pre > 0 {
push_ctx(&o[..pre], true, &mut out);
}
for l in &o[pre..o.len() - suf] {
out.push(DiffLine::Del(l.to_string()));
}
for l in &n[pre..n.len() - suf] {
out.push(DiffLine::Add(l.to_string()));
}
if suf > 0 {
push_ctx(&o[o.len() - suf..], false, &mut out);
}
out
}
#[derive(Debug, Clone)]
pub struct PendingApply {
pub target: ApplyTarget,
pub code: String,
pub diff: Vec<DiffLine>,
}
pub struct AiPane {
pub title: String,
pub prompt: String,
pub session_id: String,
pub job_id: u64,
pub state: AiState,
pub scroll: usize,
pub target: Option<ApplyTarget>,
pub pending_apply: Option<PendingApply>,
pub cancel: Arc<AtomicBool>,
}
pub enum AiState {
Asking,
Streaming(String),
Done(String),
Failed(String),
Live {
path: PathBuf,
last_len: u64,
turns: Vec<transcript::Turn>,
},
}
impl AiPane {
pub fn new(
title: impl Into<String>,
prompt: String,
session_id: String,
job_id: u64,
cancel: Arc<AtomicBool>,
) -> Self {
AiPane {
title: title.into(),
prompt,
session_id,
job_id,
state: AiState::Asking,
scroll: 0,
target: None,
pending_apply: None,
cancel,
}
}
pub fn live(session_id: String, path: PathBuf) -> Self {
let turns = transcript::read(&path);
let last_len = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
let short: String = session_id.chars().take(8).collect();
AiPane {
title: format!("claude session {short}"),
prompt: format!("session {short}"),
session_id,
job_id: 0,
state: AiState::Live {
path,
last_len,
turns,
},
scroll: usize::MAX, target: None,
pending_apply: None,
cancel: Arc::new(AtomicBool::new(false)),
}
}
pub fn is_live(&self) -> bool {
matches!(self.state, AiState::Live { .. })
}
pub fn answer_text(&self) -> Option<&str> {
match &self.state {
AiState::Streaming(s) | AiState::Done(s) => Some(s.as_str()),
AiState::Failed(s) => Some(s.as_str()),
AiState::Asking | AiState::Live { .. } => None,
}
}
pub fn tab_title(&self) -> String {
let marker = match self.state {
AiState::Asking | AiState::Streaming(_) => "…",
AiState::Failed(_) => "✗",
AiState::Done(_) => "✦",
AiState::Live { .. } => "●",
};
format!("{} {marker}", self.title)
}
}
const CLI: &str = "claude";
#[derive(Debug, Clone)]
pub enum AiMsg {
Delta(String),
Done(String),
Failed(String),
Usage {
input_tokens: u64,
output_tokens: u64,
},
ConfirmTool { summary: String },
}
pub fn stream_to_channel(
prompt: &str,
session_id: &str,
cancel: &AtomicBool,
sink: std::sync::mpsc::Sender<(u64, AiMsg)>,
job_id: u64,
) {
stream_cli_to_channel(
CLI,
&["-p", "--session-id", session_id, prompt],
cancel,
sink,
job_id,
"Claude Code",
);
}
pub fn stream_codex_to_channel(
prompt: &str,
cancel: &AtomicBool,
sink: std::sync::mpsc::Sender<(u64, AiMsg)>,
job_id: u64,
) {
stream_cli_to_channel(
"codex",
&["exec", prompt],
cancel,
sink,
job_id,
"Codex CLI",
);
}
fn stream_cli_to_channel(
bin: &str,
args: &[&str],
cancel: &AtomicBool,
sink: std::sync::mpsc::Sender<(u64, AiMsg)>,
job_id: u64,
friendly_name: &str,
) {
use std::io::Read;
use std::process::Stdio;
let mut child = match Command::new(bin)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
let _ = sink.send((
job_id,
AiMsg::Failed(format!(
"running `{bin}`: {e} — is the {friendly_name} on PATH?"
)),
));
return;
}
};
let mut so = child.stdout.take().expect("piped stdout");
let mut se = child.stderr.take().expect("piped stderr");
let chunk_sink = sink.clone();
let so_h = std::thread::spawn(move || {
let mut acc = Vec::new();
let mut buf = [0u8; 4096];
loop {
match so.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
acc.extend_from_slice(&buf[..n]);
let _ = chunk_sink.send((
job_id,
AiMsg::Delta(String::from_utf8_lossy(&buf[..n]).into_owned()),
));
}
}
}
acc
});
let se_h = std::thread::spawn(move || {
let mut v = Vec::new();
let _ = se.read_to_end(&mut v);
v
});
let mut killed = false;
loop {
if !killed && cancel.load(Ordering::Relaxed) {
let _ = child.kill();
killed = true;
}
match child.try_wait() {
Ok(Some(status)) => {
let out = so_h.join().unwrap_or_default();
let err = se_h.join().unwrap_or_default();
let _ = sink.send((job_id, settle(killed, status.success(), &out, &err)));
return;
}
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(40)),
Err(e) => {
let _ = sink.send((job_id, AiMsg::Failed(format!("waiting on `{bin}`: {e}"))));
return;
}
}
}
}
fn settle(killed: bool, success: bool, stdout: &[u8], stderr: &[u8]) -> AiMsg {
if killed {
return AiMsg::Failed("cancelled".to_string());
}
let out = String::from_utf8_lossy(stdout);
if success {
let s = out.trim();
return if s.is_empty() {
AiMsg::Failed("(empty response)".to_string())
} else {
AiMsg::Done(s.to_string())
};
}
let err = String::from_utf8_lossy(stderr);
let m = [err.trim(), out.trim()]
.into_iter()
.find(|s| !s.is_empty())
.unwrap_or("`claude -p` failed");
AiMsg::Failed(m.lines().next().unwrap_or(m).to_string())
}
pub fn one_shot(prompt: &str, session_id: &str) -> Result<String, String> {
one_shot_cancellable(prompt, session_id, &AtomicBool::new(false))
}
pub fn one_shot_cancellable(
prompt: &str,
session_id: &str,
cancel: &AtomicBool,
) -> Result<String, String> {
use std::io::Read;
use std::process::Stdio;
let mut child = Command::new(CLI)
.args(["-p", "--session-id", session_id])
.arg(prompt)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("running `{CLI} -p`: {e} — is the Claude Code CLI on PATH?"))?;
let mut so = child.stdout.take().expect("piped stdout");
let mut se = child.stderr.take().expect("piped stderr");
let so_h = std::thread::spawn(move || {
let mut v = Vec::new();
let _ = so.read_to_end(&mut v);
v
});
let se_h = std::thread::spawn(move || {
let mut v = Vec::new();
let _ = se.read_to_end(&mut v);
v
});
let mut killed = false;
loop {
if !killed && cancel.load(Ordering::Relaxed) {
let _ = child.kill();
killed = true;
}
match child.try_wait() {
Ok(Some(status)) => {
let out = so_h.join().unwrap_or_default();
let err = se_h.join().unwrap_or_default();
if killed {
return Err("cancelled".to_string());
}
let stdout = String::from_utf8_lossy(&out);
if status.success() {
let s = stdout.trim();
return if s.is_empty() {
Err("(empty response)".to_string())
} else {
Ok(s.to_string())
};
}
let stderr = String::from_utf8_lossy(&err);
let msg = [stderr.trim(), stdout.trim()]
.into_iter()
.find(|s| !s.is_empty())
.unwrap_or("`claude -p` failed");
return Err(msg.lines().next().unwrap_or(msg).to_string());
}
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(40)),
Err(e) => return Err(format!("waiting on `{CLI} -p`: {e}")),
}
}
}
pub fn gen_session_id() -> String {
let mut b = [0u8; 16];
let filled = {
use std::io::Read;
std::fs::File::open("/dev/urandom")
.and_then(|mut f| f.read_exact(&mut b))
.is_ok()
};
if !filled {
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
^ ((std::process::id() as u128) << 64);
let mut z = seed;
for chunk in b.chunks_mut(8) {
z = z.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut x = z as u64;
x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
x ^= x >> 31;
for (i, by) in chunk.iter_mut().enumerate() {
*by = x.to_le_bytes()[i];
}
}
}
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
let mut s = String::with_capacity(36);
for (i, by) in b.iter().enumerate() {
if matches!(i, 4 | 6 | 8 | 10) {
s.push('-');
}
s.push_str(&format!("{by:02x}"));
}
s
}
fn fenced(code: &str, lang: &str) -> String {
format!("```{lang}\n{}\n```", code.trim_end_matches('\n'))
}
pub fn action_prompt(what: &str, code: &str, lang: &str) -> String {
let block = fenced(code, lang);
match what {
"explain" => format!(
"Explain what this {lang} code does, concisely. Cover its purpose, the \
non-obvious bits, and anything that looks wrong.\n\n{block}"
),
"fix" => format!(
"Find and fix any bugs in this {lang} code. Reply with the corrected code \
in a single fenced block, then a short bullet list of what you changed.\n\n{block}"
),
"refactor" => format!(
"Refactor this {lang} code for clarity without changing behaviour. Reply \
with the refactored code in a single fenced block, then a short note on \
what you did.\n\n{block}"
),
"write_tests" => format!(
"Write thorough unit tests for this {lang} code (idiomatic for the language; \
cover the edge cases). Reply with the test code in a single fenced block.\n\n{block}"
),
_ => format!("Look at this {lang} code:\n\n{block}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_code_block_extracts_the_fence() {
let md = "Here's the fix:\n\n```rust\nfn x() -> i32 { 1 }\n```\n\n- changed the return\n";
assert_eq!(first_code_block(md).as_deref(), Some("fn x() -> i32 { 1 }"));
assert_eq!(first_code_block("no code here").as_deref(), None);
assert_eq!(first_code_block("```\na\nb\n").as_deref(), Some("a\nb"));
assert_eq!(
first_code_block("```\nfirst\n```\n```\nsecond\n```").as_deref(),
Some("first")
);
}
#[test]
fn settle_picks_the_right_outcome() {
assert!(matches!(settle(true, false, b"x", b""), AiMsg::Failed(m) if m == "cancelled"));
assert!(matches!(settle(false, true, b" hello \n", b""), AiMsg::Done(m) if m == "hello"));
assert!(
matches!(settle(false, true, b" \n ", b""), AiMsg::Failed(m) if m == "(empty response)")
);
assert!(
matches!(settle(false, false, b"", b"boom: bad\nmore"), AiMsg::Failed(m) if m == "boom: bad")
);
assert!(
matches!(settle(false, false, b"stdout err", b""), AiMsg::Failed(m) if m == "stdout err")
);
assert!(
matches!(settle(false, false, b"", b""), AiMsg::Failed(m) if m == "`claude -p` failed")
);
}
#[test]
fn line_diff_trims_common_prefix_and_suffix() {
use DiffLine::*;
let d = line_diff("a\nb\nOLD\nc\nd", "a\nb\nNEW1\nNEW2\nc\nd");
assert_eq!(
d,
vec![
Ctx("a".into()),
Ctx("b".into()),
Del("OLD".into()),
Add("NEW1".into()),
Add("NEW2".into()),
Ctx("c".into()),
Ctx("d".into()),
]
);
assert_eq!(
line_diff("a\nb", "a\nb\nc"),
vec![Ctx("a".into()), Ctx("b".into()), Add("c".into())]
);
let d = line_diff("1\n2\n3\n4\n5\nX", "1\n2\n3\n4\n5\nY");
assert_eq!(d[0], Ctx("… 2 unchanged lines …".into()));
assert_eq!(
&d[1..],
&[
Ctx("3".into()),
Ctx("4".into()),
Ctx("5".into()),
Del("X".into()),
Add("Y".into())
]
);
let d = line_diff("same\ntext", "same\ntext");
assert!(d.iter().all(|l| matches!(l, Ctx(_))));
}
#[test]
fn action_prompt_includes_code_and_lang() {
let p = action_prompt("explain", "fn x() {}", "rust");
assert!(p.contains("```rust\nfn x() {}\n```"));
assert!(p.to_lowercase().contains("explain"));
let p = action_prompt("write_tests", "def f(): pass", "python");
assert!(p.contains("```python"));
assert!(p.to_lowercase().contains("test"));
}
fn ai_from(s: &str) -> toml::Value {
toml::from_str::<toml::Value>(s).unwrap()
}
#[test]
fn configured_backend_defaults_to_auto_when_empty() {
let ai = ai_from("");
assert_eq!(
configured_backend(&ai, AiProduct::Claude),
RoutingBackend::Auto
);
assert_eq!(
configured_backend(&ai, AiProduct::Codex),
RoutingBackend::Auto
);
}
#[test]
fn configured_backend_reads_new_key_per_product() {
let ai = ai_from(
r#"
[routing.claude]
backend = "sub"
[routing.codex]
backend = "off"
"#,
);
assert_eq!(
configured_backend(&ai, AiProduct::Claude),
RoutingBackend::Sub
);
assert_eq!(
configured_backend(&ai, AiProduct::Codex),
RoutingBackend::Off
);
}
#[test]
fn configured_backend_migrates_legacy_backend_key_for_claude() {
let ai = ai_from(r#"backend = "cli""#);
assert_eq!(
configured_backend(&ai, AiProduct::Claude),
RoutingBackend::Sub
);
assert_eq!(
configured_backend(&ai, AiProduct::Codex),
RoutingBackend::Auto
);
let ai = ai_from(r#"backend = "api""#);
assert_eq!(
configured_backend(&ai, AiProduct::Claude),
RoutingBackend::Api
);
}
#[test]
fn configured_backend_new_key_wins_over_legacy() {
let ai = ai_from(
r#"
backend = "api"
[routing.claude]
backend = "sub"
"#,
);
assert_eq!(
configured_backend(&ai, AiProduct::Claude),
RoutingBackend::Sub
);
assert!(has_legacy_and_new_claude(&ai));
}
#[test]
fn has_legacy_and_new_flags_dual_config_only() {
let ai = ai_from(r#"backend = "cli""#);
assert!(!has_legacy_and_new_claude(&ai));
let ai = ai_from(
r#"[routing.claude]
backend = "sub""#,
);
assert!(!has_legacy_and_new_claude(&ai));
let ai = ai_from(
r#"
backend = "cli"
[routing.claude]
backend = "api"
"#,
);
assert!(has_legacy_and_new_claude(&ai));
}
#[test]
fn routing_backend_parse_covers_synonyms() {
assert_eq!(RoutingBackend::parse("sub"), RoutingBackend::Sub);
assert_eq!(RoutingBackend::parse("subscription"), RoutingBackend::Sub);
assert_eq!(RoutingBackend::parse("cli"), RoutingBackend::Sub);
assert_eq!(RoutingBackend::parse("cc"), RoutingBackend::Sub);
assert_eq!(RoutingBackend::parse("claude-code"), RoutingBackend::Sub);
assert_eq!(RoutingBackend::parse("api"), RoutingBackend::Api);
assert_eq!(RoutingBackend::parse("claude-api"), RoutingBackend::Api);
assert_eq!(RoutingBackend::parse("http"), RoutingBackend::Api);
assert_eq!(RoutingBackend::parse("off"), RoutingBackend::Off);
assert_eq!(RoutingBackend::parse("disabled"), RoutingBackend::Off);
assert_eq!(RoutingBackend::parse(""), RoutingBackend::Auto);
assert_eq!(RoutingBackend::parse("nonsense"), RoutingBackend::Auto);
assert_eq!(RoutingBackend::parse("auto"), RoutingBackend::Auto);
}
#[test]
fn resolve_backend_passes_explicit_choices_through() {
let ai = ai_from(
r#"[routing.claude]
backend = "off""#,
);
assert_eq!(
resolve_backend(&ai, AiProduct::Claude),
ResolvedBackend::Off
);
let ai = ai_from(
r#"[routing.claude]
backend = "sub""#,
);
assert_eq!(
resolve_backend(&ai, AiProduct::Claude),
ResolvedBackend::Sub
);
let ai = ai_from(
r#"[routing.claude]
backend = "api""#,
);
assert_eq!(
resolve_backend(&ai, AiProduct::Claude),
ResolvedBackend::Api
);
let ai = ai_from(
r#"[routing.codex]
backend = "off""#,
);
assert_eq!(resolve_backend(&ai, AiProduct::Codex), ResolvedBackend::Off);
let ai = ai_from(
r#"[routing.codex]
backend = "sub""#,
);
assert_eq!(resolve_backend(&ai, AiProduct::Codex), ResolvedBackend::Sub);
let ai = ai_from(
r#"[routing.codex]
backend = "api""#,
);
assert_eq!(resolve_backend(&ai, AiProduct::Codex), ResolvedBackend::Api);
}
#[test]
fn resolve_backend_migrates_legacy_cli_to_sub() {
let ai = ai_from(r#"backend = "cli""#);
assert_eq!(
resolve_backend(&ai, AiProduct::Claude),
ResolvedBackend::Sub
);
}
}