use std::io::{stdin, stdout, Write};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::Mutex;
use crepuscularity_tui::ratatui::backend::CrosstermBackend;
use crepuscularity_tui::{Template, TemplateContext, TemplateValue};
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use rx4::agent::{
Agent, AgentBudget, Event as Rx4Event, ToolDefinition, ToolEffect, ToolResult, ToolSource,
};
use rx4::mode::Scope;
use rx4::permissions::{ApprovalRequest, Decision};
use rx4::provider::{OpenAIProvider, Provider, Role};
use rx4::subagent::{SubagentConfig, SubagentManager, SubagentStatus};
use rx4::{register_builtin_tools, ToolRegistry};
mod channel_approver;
mod mcp_config;
mod product_policy;
use channel_approver::{ChannelApprover, PendingApproval};
#[cfg(feature = "pi-compat")]
mod pi;
const SPINNER_FRAMES: [&str; 10] = [
"\u{280B}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283C}", "\u{2834}", "\u{2826}", "\u{2827}",
"\u{2807}", "\u{280F}",
];
const MAX_HISTORY: usize = 100;
fn context_color(pct: usize) -> &'static str {
if pct >= 90 {
"red-400"
} else if pct >= 70 {
"amber-400"
} else {
"green-400"
}
}
fn format_tokens(count: usize) -> String {
if count < 1000 {
count.to_string()
} else if count < 10000 {
format!("{:.1}k", count as f64 / 1000.0)
} else if count < 1000000 {
format!("{}k", count / 1000)
} else {
format!("{:.1}M", count as f64 / 1000000.0)
}
}
fn format_cwd() -> String {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
if let Ok(rel) = cwd.strip_prefix(&home) {
if rel.as_os_str().is_empty() {
"~".to_string()
} else {
format!("~/{}", rel.display())
}
} else {
cwd.display().to_string()
}
}
fn git_branch() -> String {
std::process::Command::new("git")
.args(["branch", "--show-current"])
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| String::from_utf8(output.stdout).ok())
.map(|branch| branch.trim().to_string())
.filter(|branch| !branch.is_empty())
.unwrap_or_else(|| "-".to_string())
}
fn executable_on_path(name: &str, path: Option<&std::ffi::OsStr>) -> bool {
path.into_iter()
.flat_map(std::env::split_paths)
.any(|dir| dir.join(name).is_file())
}
fn update_message(path: Option<&std::ffi::OsStr>) -> String {
if executable_on_path("wax", path) {
"update available! run wax install telekinesis".to_string()
} else {
"update available! run brew install telekinesis · Try Wax: cargo install waxpkg".to_string()
}
}
fn history_path() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".telekinesis/input_history.json"))
.unwrap_or_else(|| PathBuf::from(".telekinesis/input_history.json"))
}
fn load_history() -> Vec<String> {
std::fs::read_to_string(history_path())
.ok()
.and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or_default()
}
fn save_history(history: &[String]) {
let path = history_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let trimmed: Vec<&String> = history.iter().take(MAX_HISTORY).collect();
let _ = std::fs::write(path, serde_json::to_string(&trimmed).unwrap_or_default());
}
fn spinner_frame(start: Instant) -> String {
let elapsed = start.elapsed().as_millis();
let idx = ((elapsed / 100) % SPINNER_FRAMES.len() as u128) as usize;
SPINNER_FRAMES[idx].to_string()
}
fn blink_cursor(start: Instant) -> String {
if (start.elapsed().as_millis() / 500).is_multiple_of(2) {
"▏"
} else {
" "
}
.to_string()
}
fn load_template(path: Option<&std::ffi::OsStr>) -> anyhow::Result<Template> {
match path {
Some(path) => Template::from_path(PathBuf::from(path)).map_err(|e| anyhow::anyhow!("{e}")),
None => Ok(Template::from_source(include_str!("../shell.crepus"))),
}
}
#[derive(Clone)]
struct ChatMessage {
role: String,
content: String,
is_tool: bool,
tool_name: String,
is_streaming: bool,
}
struct App {
input: String,
messages: Vec<ChatMessage>,
model: String,
busy: bool,
auto_scroll: bool,
input_history: Vec<String>,
history_index: Option<usize>,
history_draft: String,
input_tokens: usize,
output_tokens: usize,
cost: f64,
spinner_start: Instant,
cursor_start: Instant,
show_header: bool,
permission_prompt: bool,
permission_tool: String,
permission_respond: Option<std::sync::mpsc::SyncSender<Decision>>,
session_name: String,
context_pct: usize,
context_window: usize,
agent: Option<Arc<Mutex<Agent>>>,
event_rx: Option<tokio::sync::mpsc::UnboundedReceiver<AppEvent>>,
approval_rx: Option<std::sync::mpsc::Receiver<PendingApproval>>,
prompt_char: String,
mcp_tools: Vec<String>,
subagent_manager: Option<Arc<std::sync::Mutex<SubagentManager>>>,
}
enum AppEvent {
Rx4(Rx4Event),
Error(String),
Idle,
}
impl App {
fn new() -> Self {
Self {
input: String::new(),
messages: Vec::new(),
model: "no-model".to_string(),
busy: false,
auto_scroll: true,
input_history: load_history(),
history_index: None,
history_draft: String::new(),
input_tokens: 0,
output_tokens: 0,
cost: 0.0,
spinner_start: Instant::now(),
cursor_start: Instant::now(),
show_header: true,
permission_prompt: false,
permission_tool: String::new(),
permission_respond: None,
session_name: "default".to_string(),
context_pct: 0,
context_window: 128_000,
agent: None,
event_rx: None,
approval_rx: None,
prompt_char: ">".to_string(),
mcp_tools: Vec::new(),
subagent_manager: None,
}
}
fn poll_pending_approvals(&mut self) {
let Some(rx) = self.approval_rx.as_ref() else {
return;
};
while let Ok(pending) = rx.try_recv() {
self.permission_prompt = true;
self.permission_tool = format!(
"{} | args: {}",
pending.tool_name,
truncate_args(&pending.arguments, 200)
);
self.permission_respond = Some(pending.respond);
self.messages.push(ChatMessage {
role: "system".to_string(),
content: format!(
"Approval required: {}\nargs: {}\n[y] allow [n] deny",
pending.tool_name,
truncate_args(&pending.arguments, 400)
),
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
}
}
fn resolve_permission(&mut self, allow: bool) {
if let Some(tx) = self.permission_respond.take() {
let _ = tx.send(if allow {
Decision::Allow
} else {
Decision::Deny
});
}
self.permission_prompt = false;
self.permission_tool.clear();
}
fn update_template(&self, tpl: &mut Template) {
tpl.set("input", self.input.clone());
tpl.set("input_len", self.input.chars().count() as i64);
tpl.set("model", self.model.clone());
tpl.set("busy", self.busy);
tpl.set("auto_scroll", self.auto_scroll);
tpl.set("version", env!("CARGO_PKG_VERSION"));
tpl.set("session_name", self.session_name.clone());
tpl.set("show_header", self.show_header);
tpl.set("spinner", spinner_frame(self.spinner_start));
tpl.set("cursor", blink_cursor(self.cursor_start));
tpl.set("prompt_char", self.prompt_char.clone());
tpl.set("permission_prompt", self.permission_prompt);
tpl.set("permission_tool", self.permission_tool.clone());
tpl.set("pwd", format_cwd());
tpl.set("branch", git_branch());
tpl.set(
"update",
update_message(std::env::var_os("PATH").as_deref()),
);
tpl.set("input_tokens", format_tokens(self.input_tokens));
tpl.set("output_tokens", format_tokens(self.output_tokens));
tpl.set("cost", format!("{:.3}", self.cost));
tpl.set("context_pct", self.context_pct.to_string());
tpl.set("context_window", format_tokens(self.context_window));
tpl.set("context_color", context_color(self.context_pct));
let running_subagents = self
.subagent_manager
.as_ref()
.and_then(|manager| manager.lock().ok())
.map(|manager| {
manager
.list()
.iter()
.filter(|handle| {
matches!(
handle.status(),
SubagentStatus::Pending | SubagentStatus::Running
)
})
.count()
})
.unwrap_or_default();
tpl.set("running_subagents", running_subagents as i64);
let msgs: Vec<TemplateContext> = self
.messages
.iter()
.map(|m| {
let mut mc = TemplateContext::new();
mc.set("is_user", m.role == "user");
mc.set("is_tool", m.is_tool);
mc.set("tool_name", m.tool_name.clone());
mc.set("is_streaming", m.is_streaming);
let lines: Vec<TemplateContext> = m
.content
.lines()
.map(|line| {
let mut lc = TemplateContext::new();
lc.set("text", line.to_string());
lc
})
.collect();
mc.set("lines", TemplateValue::List(lines));
mc
})
.collect();
tpl.set("messages", TemplateValue::List(msgs));
}
fn submit_prompt(
&mut self,
agent: &Arc<Mutex<Agent>>,
tx: tokio::sync::mpsc::UnboundedSender<AppEvent>,
) {
let text = self.input.trim().to_string();
if text.is_empty() {
return;
}
self.input_history.insert(0, text.clone());
save_history(&self.input_history);
self.history_index = None;
self.messages.push(ChatMessage {
role: "user".to_string(),
content: text.clone(),
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
self.input.clear();
self.busy = true;
let agent = agent.clone();
tokio::spawn(async move {
let mut agent = agent.lock().await;
let result = agent.prompt(&text).await;
if let Err(e) = result {
let _ = tx.send(AppEvent::Error(e.to_string()));
}
let _ = tx.send(AppEvent::Idle);
});
}
fn handle_event(&mut self, event: AppEvent) {
match event {
AppEvent::Rx4(e) => self.handle_rx4_event(e),
AppEvent::Error(msg) => {
self.messages.push(ChatMessage {
role: "error".to_string(),
content: format!("Error: {msg}"),
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
}
AppEvent::Idle => {
self.busy = false;
}
}
}
fn handle_rx4_event(&mut self, event: Rx4Event) {
if let Rx4Event::ApprovalRequired(req) = &event {
self.permission_prompt = true;
self.permission_tool = format_approval(req);
}
match event {
Rx4Event::AgentStart => {}
Rx4Event::ContextUsage {
used_tokens,
context_window,
..
} => {
self.context_window = context_window;
self.context_pct = used_tokens
.saturating_mul(100)
.checked_div(context_window)
.unwrap_or(0);
}
Rx4Event::Usage { usage, .. } => {
self.input_tokens += usage.input_tokens;
self.output_tokens += usage.output_tokens;
}
Rx4Event::CompactionStart { .. } => {
self.messages.push(ChatMessage {
role: "tool".to_string(),
content: String::new(),
is_tool: true,
tool_name: "compacting context".to_string(),
is_streaming: false,
});
}
Rx4Event::CompactionEnd { result, .. } => {
self.messages.push(ChatMessage {
role: "tool".to_string(),
content: format!("{} tokens remain", result.remaining_tokens),
is_tool: true,
tool_name: "compacted context".to_string(),
is_streaming: false,
});
}
Rx4Event::SkillActivated { name, .. } => {
self.messages.push(ChatMessage {
role: "tool".to_string(),
content: String::new(),
is_tool: true,
tool_name: format!("skill {name}"),
is_streaming: false,
});
}
Rx4Event::ToolSource { tool, source } => {
let activity = match source {
ToolSource::Builtin => None,
ToolSource::Mcp { server } => Some(format!("used {server} (MCP)")),
ToolSource::ComputerUse => Some(format!("used {tool}")),
};
if let Some(tool_name) = activity {
self.messages.push(ChatMessage {
role: "tool".to_string(),
content: String::new(),
is_tool: true,
tool_name,
is_streaming: false,
});
}
}
Rx4Event::TurnStart { .. } => {
self.messages.push(ChatMessage {
role: "assistant".to_string(),
content: String::new(),
is_tool: false,
tool_name: String::new(),
is_streaming: true,
});
}
Rx4Event::MessageStart { role } => {
if role == Role::Assistant
&& self
.messages
.last()
.is_none_or(|m| m.role != "assistant" || !m.content.is_empty())
{
self.messages.push(ChatMessage {
role: "assistant".to_string(),
content: String::new(),
is_tool: false,
tool_name: String::new(),
is_streaming: true,
});
}
}
Rx4Event::MessageDelta { delta } => {
if let Some(msg) = self.messages.last_mut() {
msg.content.push_str(&delta);
}
}
Rx4Event::MessageEnd { content, .. } => {
if let Some(msg) = self.messages.last_mut() {
if !content.is_empty() {
msg.content = content;
}
msg.is_streaming = false;
}
}
Rx4Event::ToolCall(call) => {
self.messages.push(ChatMessage {
role: "tool".to_string(),
content: truncate_args(&call.arguments, 240),
is_tool: true,
tool_name: call.name,
is_streaming: false,
});
}
Rx4Event::ApprovalRequired(req) => {
self.permission_prompt = true;
self.permission_tool = format_approval(&req);
self.messages.push(ChatMessage {
role: "system".to_string(),
content: format!(
"Approval required: {} ({})\nargs: {}",
req.tool_name,
req.reason,
truncate_args(&req.arguments, 400)
),
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
}
Rx4Event::ToolExecutionStart(_) => {}
Rx4Event::ToolExecutionEnd(result) => {
if let Some(msg) = self.messages.last_mut() {
if msg.is_tool {
msg.content = result.content;
}
}
}
Rx4Event::TurnEnd { .. } => {}
Rx4Event::AgentEnd => {
if let Some(msg) = self.messages.last_mut() {
msg.is_streaming = false;
}
}
Rx4Event::Error(msg) => {
self.messages.push(ChatMessage {
role: "error".to_string(),
content: format!("Error: {msg}"),
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
}
Rx4Event::BudgetExceeded { reason } => {
self.messages.push(ChatMessage {
role: "error".to_string(),
content: format!("Budget exceeded: {reason}"),
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
}
}
}
fn history_get(&self) -> String {
if let Some(idx) = self.history_index {
self.input_history.get(idx).cloned().unwrap_or_default()
} else {
String::new()
}
}
fn take_scrollback(&mut self) -> Vec<String> {
let count = self
.messages
.iter()
.take_while(|message| !message.is_streaming)
.count();
self.messages
.drain(..count)
.flat_map(|message| {
if message.is_tool {
std::iter::once(format!("| {}", message.tool_name))
.chain(message.content.lines().map(|line| format!("| {line}")))
.collect::<Vec<_>>()
} else if message.role == "user" {
message
.content
.lines()
.enumerate()
.map(|(index, line)| {
if index == 0 {
format!("> {line}")
} else {
format!(" {line}")
}
})
.collect::<Vec<_>>()
} else {
message
.content
.lines()
.map(str::to_string)
.collect::<Vec<_>>()
}
})
.collect()
}
}
fn run_login(provider: Option<&str>) -> anyhow::Result<()> {
let provider = provider.unwrap_or("grok");
let oauth_provider = match provider {
"grok" | "xai" => rs_ai_oauth::OAuthProvider::Xai,
"openai" | "chatgpt" => rs_ai_oauth::OAuthProvider::ChatGpt,
"claude" | "anthropic" => rs_ai_oauth::OAuthProvider::Claude,
"gemini" | "google" => rs_ai_oauth::OAuthProvider::Gemini,
"copilot" => rs_ai_oauth::OAuthProvider::Copilot,
"kimi" => rs_ai_oauth::OAuthProvider::Kimi,
"antigravity" => rs_ai_oauth::OAuthProvider::Antigravity,
_ => {
eprintln!("Unknown provider: {provider}");
eprintln!("Available: grok, openai, claude, gemini, copilot, kimi, antigravity");
std::process::exit(1);
}
};
println!("Starting OAuth flow for {provider}...");
let tokens = rs_ai_oauth::start_oauth_flow(oauth_provider)?;
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let dir = home.join(".telekinesis");
std::fs::create_dir_all(&dir)?;
let path = dir.join(format!("{provider}_token.json"));
std::fs::write(&path, serde_json::to_string_pretty(&tokens)?)?;
println!("Token saved to {}", path.display());
Ok(())
}
fn choose_provider() -> anyhow::Result<&'static str> {
const PROVIDERS: [(&str, &str); 4] = [
("1", "grok"),
("2", "openai"),
("3", "claude"),
("4", "gemini"),
];
println!("No saved login found. Which provider do you want?");
for (number, provider) in PROVIDERS {
println!(" {number}) {provider}");
}
loop {
print!("Provider [1-4]: ");
stdout().flush()?;
let mut choice = String::new();
if stdin().read_line(&mut choice)? == 0 {
anyhow::bail!("Provider selection cancelled");
}
let choice = choice.trim().to_ascii_lowercase();
if let Some((_, provider)) = PROVIDERS
.iter()
.find(|(number, provider)| choice == *number || choice == *provider)
{
return Ok(provider);
}
println!("Choose 1-4 or enter a provider name.");
}
}
fn saved_token(provider: &str) -> Option<String> {
let path = dirs::home_dir()?
.join(".telekinesis")
.join(format!("{provider}_token.json"));
serde_json::from_str::<rs_ai_oauth::OAuthTokens>(&std::fs::read_to_string(path).ok()?)
.ok()
.map(|tokens| tokens.access_token)
.filter(|token| !token.is_empty())
}
fn setup_provider() -> Option<(Arc<dyn Provider>, String)> {
let providers = [
(
"XAI_API_KEY",
"grok",
"https://api.x.ai/v1",
"xai",
"xAI",
"grok-4.5",
),
(
"OPENAI_API_KEY",
"openai",
"https://api.openai.com/v1",
"openai",
"OpenAI",
"gpt-4o",
),
(
"ANTHROPIC_API_KEY",
"claude",
"https://api.anthropic.com/v1",
"anthropic",
"Anthropic",
"claude-3-5-sonnet-20241022",
),
(
"GOOGLE_API_KEY",
"gemini",
"https://generativelanguage.googleapis.com/v1beta",
"google",
"Google Gemini",
"gemini-2.0-flash",
),
];
providers
.iter()
.find_map(|(env, login, base_url, id, name, model)| {
std::env::var(env)
.ok()
.filter(|key| !key.is_empty())
.or_else(|| saved_token(login))
.map(|key| {
(
Arc::new(OpenAIProvider::with_base_url(*base_url, key, *id, *name))
as Arc<dyn Provider>,
(*model).to_string(),
)
})
})
}
fn run_tui() -> anyhow::Result<()> {
let mut tpl = load_template(std::env::var_os("TELEKINESIS_TEMPLATE").as_deref())?;
let (provider, model) = if let Some(provider) = setup_provider() {
provider
} else {
run_login(Some(choose_provider()?))?;
setup_provider().ok_or_else(|| anyhow::anyhow!("Login completed without a usable token"))?
};
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
let mut agent = Agent::new();
agent.set_scope(Scope::Coding);
let mut tools = ToolRegistry::new();
register_builtin_tools(&mut tools);
rx4::computer_use::register_tools(&mut tools);
let mcp_tools = rt.block_on(connect_mcp_tools(&mut tools));
agent.set_tools(tools);
agent.set_workspace_root(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
agent.load_project_context();
agent.set_model(&model);
agent.set_provider(provider.clone());
let subagent_manager = SubagentManager::new()
.with_provider(provider)
.with_tools(agent.tools.clone());
let subagent_manager = Arc::new(std::sync::Mutex::new(subagent_manager));
let workspace = agent.workspace_root.clone();
agent.set_sandbox(Arc::new(rx4::SandboxManager::new(
rx4::SandboxProfile::Workspace,
workspace,
)));
agent.set_policy(product_policy::tele_coding_policy());
let (approver, approval_rx) = ChannelApprover::pair();
agent.set_approver(Arc::new(approver));
let _ = agent.enable_os_sandbox();
if let Some(home) = dirs::home_dir() {
let mut engine = rx4::SkillEngine::new(home.join(".agents").join("skills"));
if engine.load().is_ok() {
let mut reg = rx4::SkillRegistry::new();
for skill in engine.list() {
reg.register(skill.clone());
}
agent.set_skill_registry(reg);
agent.set_skill_engine(engine);
}
}
agent.set_graph_memory(rx4::GraphMemory::new());
agent.enable_auto_dream(true);
let event_tx_clone = event_tx.clone();
agent.subscribe(move |event: &Rx4Event| {
let _ = event_tx_clone.send(AppEvent::Rx4(event.clone()));
});
let agent = Arc::new(Mutex::new(agent));
let mut app = App::new();
app.model = model;
app.agent = Some(agent.clone());
app.event_rx = Some(event_rx);
app.approval_rx = Some(approval_rx);
app.mcp_tools = mcp_tools;
app.subagent_manager = Some(subagent_manager);
let _rt_guard = rt.enter();
enable_raw_mode()?;
let mut stdout = stdout();
stdout.flush()?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = crepuscularity_tui::ratatui::Terminal::with_options(
backend,
crepuscularity_tui::ratatui::TerminalOptions {
viewport: crepuscularity_tui::ratatui::Viewport::Inline(9),
},
)?;
loop {
let mut pending = Vec::new();
if let Some(rx) = app.event_rx.as_mut() {
while let Ok(event) = rx.try_recv() {
pending.push(event);
}
}
for event in pending {
app.handle_event(event);
}
app.poll_pending_approvals();
let scrollback = app.take_scrollback();
if !scrollback.is_empty() {
terminal.insert_before(scrollback.len() as u16, |buffer| {
use crepuscularity_tui::ratatui::style::Style;
for (index, line) in scrollback.iter().enumerate() {
buffer.set_string(0, index as u16, line, Style::default());
}
})?;
}
app.update_template(&mut tpl);
if !tpl.changed_keys().is_empty() {
terminal.draw(|f| {
if let Err(e) = tpl.draw(f, f.area()) {
use crepuscularity_tui::ratatui::style::Style;
use crepuscularity_tui::ratatui::widgets::Paragraph;
let p = Paragraph::new(format!("Template error: {e}"))
.style(Style::default().fg(crepuscularity_tui::ratatui::style::Color::Red));
f.render_widget(p, f.area());
}
})?;
tpl.mark_rendered();
}
if crossterm::event::poll(std::time::Duration::from_millis(100))? {
if let Event::Key(key) = crossterm::event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
if app.permission_prompt {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
app.resolve_permission(true);
continue;
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
app.resolve_permission(false);
continue;
}
_ => continue,
}
}
match (key.code, key.modifiers) {
(KeyCode::Enter, _) => {
if app.busy {
continue;
}
let text = app.input.trim().to_string();
if text == "/quit" || text == "/exit" {
break;
}
if text.starts_with('/') {
handle_slash_command(&mut app, &text, &agent, &event_tx);
} else if !text.is_empty() {
app.submit_prompt(&agent, event_tx.clone());
}
}
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
if app.busy {
app.busy = false;
} else {
break;
}
}
(KeyCode::Char('d'), KeyModifiers::CONTROL) => {
if app.input.is_empty() {
break;
}
}
(KeyCode::Char('l'), KeyModifiers::CONTROL) => {
let _ = terminal.clear();
}
(KeyCode::Char('b'), KeyModifiers::CONTROL) => {
app.show_header = !app.show_header;
}
(KeyCode::F(1), _) => {
handle_slash_command(&mut app, "/help", &agent, &event_tx);
}
(KeyCode::Up, _) => {
if app.history_index.is_none() && !app.input_history.is_empty() {
app.history_draft = app.input.clone();
app.history_index = Some(0);
app.input = app.history_get();
} else if let Some(idx) = app.history_index {
if idx + 1 < app.input_history.len() {
app.history_index = Some(idx + 1);
app.input = app.history_get();
}
}
}
(KeyCode::Down, _) => {
if let Some(idx) = app.history_index {
if idx == 0 {
app.history_index = None;
app.input = app.history_draft.clone();
} else {
app.history_index = Some(idx - 1);
app.input = app.history_get();
}
}
}
(KeyCode::Backspace, _) => {
app.input.pop();
}
(KeyCode::PageUp, _) => {
app.auto_scroll = false;
}
(KeyCode::PageDown, _) => {
app.auto_scroll = true;
}
(KeyCode::Home, _) => {
app.auto_scroll = false;
}
(KeyCode::End, _) => {
app.auto_scroll = true;
}
(KeyCode::Char(c), _) => {
app.input.push(c);
}
_ => {}
}
}
}
}
disable_raw_mode()?;
terminal.backend_mut().flush()?;
Ok(())
}
fn truncate_args(args: &str, max: usize) -> String {
let flat = args.replace('\n', " ");
if flat.chars().count() <= max {
flat
} else {
let mut out: String = flat.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
}
fn format_approval(req: &ApprovalRequest) -> String {
format!(
"{} — {} | args: {}",
req.tool_name,
req.reason,
truncate_args(&req.arguments, 200)
)
}
async fn connect_mcp_tools(tools: &mut ToolRegistry) -> Vec<String> {
let configs = mcp_config::load();
if configs.is_empty() {
return Vec::new();
}
let mut names = Vec::new();
for cfg in configs {
let transport = match cfg.transport.to_ascii_lowercase().as_str() {
"http" => rx4::McpTransportKind::Http,
"sse" => rx4::McpTransportKind::Sse,
_ => rx4::McpTransportKind::Stdio,
};
let engine_cfg = rx4::McpServerConfig {
name: cfg.name.clone(),
command: cfg.command.clone().unwrap_or_default(),
args: cfg.args.clone(),
env: Default::default(),
transport,
url: cfg.url.clone(),
headers: cfg.headers.clone(),
};
match rx4::McpClient::connect_config(&engine_cfg).await {
Ok(client) => match client.list_tools().await {
Ok(listed) => {
let client = Arc::new(client);
for tool in listed {
let full = format!("mcp__{}__{}", cfg.name, tool.name);
let desc = if tool.description.is_empty() {
format!("MCP tool {} from {}", tool.name, cfg.name)
} else {
tool.description.clone()
};
let params = tool.input_schema.to_string();
let client_c = client.clone();
let remote_name = tool.name.clone();
tools.register(
ToolDefinition::new_boxed(
full.clone(),
desc,
params,
Box::new(move |_ctx, args| {
let client = client_c.clone();
let remote_name = remote_name.clone();
Box::pin(async move {
let value: serde_json::Value = serde_json::from_str(&args)
.unwrap_or_else(|_| serde_json::json!({ "raw": args }));
match client.call_tool(&remote_name, &value).await {
Ok(v) => {
ToolResult::ok(remote_name.clone(), v.to_string())
}
Err(e) => {
ToolResult::err(remote_name.clone(), e.to_string())
}
}
})
}),
)
.with_effect(ToolEffect::Network),
);
names.push(full);
}
}
Err(e) => {
eprintln!("telekinesis: MCP list_tools failed for `{}`: {e}", cfg.name);
}
},
Err(e) => {
eprintln!("telekinesis: MCP connect failed for `{}`: {e}", cfg.name);
}
}
}
names
}
fn handle_slash_command(
app: &mut App,
cmd: &str,
_agent: &Arc<Mutex<Agent>>,
tx: &tokio::sync::mpsc::UnboundedSender<AppEvent>,
) {
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
let command = parts[0];
let arg = parts.get(1).copied().unwrap_or("");
match command {
"/quit" | "/exit" => {}
"/clear" => {
app.messages.clear();
app.input_tokens = 0;
app.output_tokens = 0;
app.cost = 0.0;
}
"/help" => {
app.messages.push(ChatMessage {
role: "system".to_string(),
content: "Commands: /model <name>, /scope <coding|research|plan|ask|computer_use>, /subagent spawn|list|cancel, /budget <max-cost>, /mcp, /todo, /clear, /cost, /help, /quit\nKeys: Ctrl+B toggle header, Ctrl+L clear screen, Ctrl+C interrupt, Up/Down history, PgUp/PgDn scroll chat".to_string(),
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
}
"/model" => {
if arg.is_empty() {
app.messages.push(ChatMessage {
role: "system".to_string(),
content: format!("Current model: {}", app.model),
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
} else {
app.model = arg.to_string();
if let Some(a) = &app.agent {
if let Ok(mut agent) = a.try_lock() {
agent.set_model(arg);
}
}
app.messages.push(ChatMessage {
role: "system".to_string(),
content: format!("Model set to: {arg}"),
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
}
}
"/cost" => {
app.messages.push(ChatMessage {
role: "system".to_string(),
content: format!(
"Input: {} tokens, Output: {} tokens, Cost: ${:.4}",
app.input_tokens, app.output_tokens, app.cost
),
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
}
"/scope" => {
if let Some(a) = &app.agent {
if let Ok(mut agent) = a.try_lock() {
let scope = match arg {
"coding" => Scope::Coding,
"research" => Scope::Research,
"plan" => Scope::Plan,
"ask" => Scope::Ask,
"computer_use" | "computer-use" | "cu" => Scope::ComputerUse,
_ => Scope::Coding,
};
agent.set_scope(scope);
}
}
app.messages.push(ChatMessage {
role: "system".to_string(),
content: format!("Scope set to: {arg}"),
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
}
"/mcp" => {
let path = mcp_config::config_path();
let body = if app.mcp_tools.is_empty() {
format!(
"No MCP tools connected.\nConfig: {}\nFormat: {{\"servers\":[{{\"name\":\"fs\",\"transport\":\"stdio\",\"command\":\"npx\",\"args\":[\"-y\",\"@modelcontextprotocol/server-filesystem\",\".\"]}}]}}\nRemote HTTP/SSE: put url+transport=http|sse in config (host loader documents it; engine stdio works today).",
path.display()
)
} else {
format!(
"MCP tools ({}):\n{}\nConfig: {}",
app.mcp_tools.len(),
app.mcp_tools.join("\n"),
path.display()
)
};
app.messages.push(ChatMessage {
role: "system".to_string(),
content: body,
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
}
"/todo" => {
app.messages.push(ChatMessage {
role: "system".to_string(),
content: "/todo: host surface only. Engine may expose todo tool later — track work in chat or project TODO for now.".to_string(),
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
}
"/budget" => {
if arg.is_empty() {
let msg = if let Some(a) = &app.agent {
if let Ok(agent) = a.try_lock() {
match &agent.budget {
Some(b) => format!(
"Budget: max_cost=${:?}, max_duration={:?}s",
b.max_cost, b.max_duration_seconds
),
None => "No budget set.".to_string(),
}
} else {
"Agent busy.".to_string()
}
} else {
"No agent.".to_string()
};
app.messages.push(ChatMessage {
role: "system".to_string(),
content: msg,
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
} else if let Ok(cost) = arg.parse::<f64>() {
if let Some(a) = &app.agent {
if let Ok(mut agent) = a.try_lock() {
agent.budget = Some(AgentBudget {
max_cost: Some(cost),
..AgentBudget::default()
});
}
}
app.messages.push(ChatMessage {
role: "system".to_string(),
content: format!("Budget max_cost set to ${cost:.4}"),
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
} else {
app.messages.push(ChatMessage {
role: "system".to_string(),
content: format!("Invalid budget: {arg}. Use /budget <max-cost>."),
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
}
}
"/subagent" => {
let sub_parts: Vec<&str> = arg.splitn(2, ' ').collect();
let sub = sub_parts.first().copied().unwrap_or("");
let rest = sub_parts.get(1).copied().unwrap_or("");
match sub {
"spawn" => {
if let Some(mgr) = app.subagent_manager.clone() {
let prompt = rest.to_string();
let name = prompt
.split_whitespace()
.next()
.unwrap_or("subagent")
.to_string();
app.messages.push(ChatMessage {
role: "system".to_string(),
content: format!("Spawning subagent '{name}'..."),
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
let event_tx = tx.clone();
tokio::task::spawn_blocking(move || {
let result = (|| -> Result<String, String> {
let mut mgr =
mgr.lock().map_err(|e| format!("manager lock error: {e}"))?;
let workspace =
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let handle = mgr
.spawn(
SubagentConfig {
name: name.clone(),
workspace_isolation: true,
..SubagentConfig::default()
},
&prompt,
&workspace,
)
.map_err(|e| e.to_string())?;
let r = handle.wait_sync();
let status = if r.error.is_some() {
"failed"
} else {
"completed"
};
Ok(format!(
"Subagent {name} {status} — cost: ${:.4}, duration: {}s, output: {}",
r.cost, r.duration_seconds, r.output
))
})();
let msg = match result {
Ok(s) => s,
Err(e) => format!("Subagent error: {e}"),
};
let _ = event_tx.send(AppEvent::Error(msg));
});
} else {
app.messages.push(ChatMessage {
role: "system".to_string(),
content: "Subagent manager not initialized.".to_string(),
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
}
}
"list" => {
if let Some(mgr) = app.subagent_manager.as_ref() {
let body = match mgr.lock() {
Ok(mgr) => {
let handles = mgr.list();
if handles.is_empty() {
"No subagents.".to_string()
} else {
handles
.iter()
.map(|h| {
format!(
"{}: {} [{:?}] depth={} children={} descendants={}",
h.id(),
h.name(),
h.status(),
h.depth(),
h.children().len(),
h.descendant_count()
)
})
.collect::<Vec<_>>()
.join("\n")
}
}
Err(e) => format!("manager lock error: {e}"),
};
app.messages.push(ChatMessage {
role: "system".to_string(),
content: body,
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
}
}
"cancel" => {
if rest.is_empty() {
app.messages.push(ChatMessage {
role: "system".to_string(),
content: "Usage: /subagent cancel <id>".to_string(),
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
} else if let Some(mgr) = app.subagent_manager.as_ref() {
let body = match mgr.lock() {
Ok(mut mgr) => match mgr.cancel(rest) {
Ok(()) => format!("Cancelled subagent {rest}."),
Err(e) => format!("Cancel failed: {e}"),
},
Err(e) => format!("manager lock error: {e}"),
};
app.messages.push(ChatMessage {
role: "system".to_string(),
content: body,
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
}
}
_ => {
app.messages.push(ChatMessage {
role: "system".to_string(),
content: "Usage: /subagent spawn <prompt> | list | cancel <id>".to_string(),
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
}
}
}
_ => {
app.messages.push(ChatMessage {
role: "system".to_string(),
content: format!("Unknown command: {command}. Type /help for available commands."),
is_tool: false,
tool_name: String::new(),
is_streaming: false,
});
}
}
}
fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.len() >= 2 && args[1] == "login" {
return run_login(args.get(2).map(|s| s.as_str()));
}
if args.len() >= 2 && (args[1] == "--help" || args[1] == "-h") {
println!("telekinesis (tk) — AI coding agent TUI");
println!();
println!("USAGE:");
println!(" tk Start interactive TUI");
println!(
" tk login <provider> OAuth login (grok, openai, claude, gemini, copilot, kimi)"
);
println!(" tk --help Show this help");
println!();
println!("ENVIRONMENT:");
println!(" XAI_API_KEY xAI Grok API key");
println!(" OPENAI_API_KEY OpenAI API key");
println!(" ANTHROPIC_API_KEY Anthropic Claude API key");
println!(" GOOGLE_API_KEY Google Gemini API key");
println!();
println!("KEYS:");
println!(" Enter Submit prompt");
println!(" Ctrl+C Interrupt / exit");
println!(" Ctrl+L Clear screen");
println!(" Ctrl+B Toggle header");
println!(" F1 Show help");
println!(" Up/Down Input history");
println!(" PgUp/PgDn Scroll chat view");
println!(" Home/End Jump to top/bottom of chat");
return Ok(());
}
run_tui()
}
#[cfg(test)]
mod tests {
use super::{executable_on_path, load_template, update_message, App, ChatMessage};
use std::ffi::OsString;
#[test]
fn update_message_prefers_wax_and_explains_fallback() {
let dir = tempfile::tempdir().unwrap();
let wax = dir.path().join("wax");
std::fs::write(&wax, "").unwrap();
let path = OsString::from(dir.path());
assert_eq!(
update_message(Some(&path)),
"update available! run wax install telekinesis"
);
assert_eq!(
update_message(Some(OsString::from("").as_os_str())),
"update available! run brew install telekinesis · Try Wax: cargo install waxpkg"
);
assert!(!executable_on_path(
"wax",
Some(OsString::from("").as_os_str())
));
}
#[test]
fn embedded_template_ignores_stale_home_template() {
let home = tempfile::tempdir().unwrap();
std::fs::create_dir(home.path().join(".telekinesis")).unwrap();
std::fs::write(
home.path().join(".telekinesis/shell.crepus"),
"stale template",
)
.unwrap();
let template = load_template(None).unwrap();
assert!(template.source().contains("Telekinesis v{version}"));
assert!(!template.source().contains("stale template"));
}
#[test]
fn explicit_template_override_is_available_for_development() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shell.crepus");
std::fs::write(&path, "div\n \"override\"").unwrap();
assert!(load_template(Some(path.as_os_str())).is_ok());
}
#[test]
fn completed_activity_moves_to_terminal_scrollback() {
let mut app = App::new();
app.messages.push(ChatMessage {
role: "tool".to_string(),
content: "AGENTS.md".to_string(),
is_tool: true,
tool_name: "read".to_string(),
is_streaming: false,
});
assert_eq!(app.take_scrollback(), ["| read", "| AGENTS.md"]);
assert!(app.messages.is_empty());
}
#[test]
fn embedded_template_renders_compact_header_and_prompt() {
use crepuscularity_tui::ratatui::backend::TestBackend;
use crepuscularity_tui::ratatui::Terminal;
let mut template = load_template(None).unwrap();
App::new().update_template(&mut template);
let mut terminal = Terminal::new(TestBackend::new(80, 9)).unwrap();
terminal
.draw(|frame| template.draw(frame, frame.area()).unwrap())
.unwrap();
let output =
terminal
.backend()
.buffer()
.content()
.iter()
.fold(String::new(), |mut output, cell| {
output.push_str(cell.symbol());
output
});
assert!(output.contains("Telekinesis v0.2.14"));
assert!(output.contains("> "));
}
}