pub(crate) mod headless;
mod input;
mod render;
pub(crate) mod state;
mod theme;
use std::io::{self, Stdout, Write as _};
use std::time::{Duration, Instant};
use astrid_core::SessionId;
use astrid_types::Topic;
use astrid_types::ipc::IpcMessage;
use crossterm::{
event::{
DisableBracketedPaste, EnableBracketedPaste, KeyboardEnhancementFlags,
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
},
execute,
terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
supports_keyboard_enhancement,
},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use crate::socket_client::SocketClient;
use state::{App, MessageRole, PendingAction, UiState};
type Term = Terminal<CrosstermBackend<Stdout>>;
fn init_terminal() -> io::Result<(Term, bool)> {
enable_raw_mode()?;
let mut stdout = io::stdout();
let keyboard_enhanced = matches!(supports_keyboard_enhancement(), Ok(true));
if keyboard_enhanced {
execute!(
stdout,
EnterAlternateScreen,
EnableBracketedPaste,
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
)?;
} else {
execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
}
let backend = CrosstermBackend::new(stdout);
Ok((Terminal::new(backend)?, keyboard_enhanced))
}
fn restore_terminal(terminal: &mut Term, keyboard_enhanced: bool) -> io::Result<()> {
disable_raw_mode()?;
if keyboard_enhanced {
execute!(
terminal.backend_mut(),
PopKeyboardEnhancementFlags,
DisableBracketedPaste,
LeaveAlternateScreen
)?;
} else {
execute!(
terminal.backend_mut(),
DisableBracketedPaste,
LeaveAlternateScreen
)?;
}
terminal.show_cursor()?;
Ok(())
}
pub(crate) async fn run(
client: &mut SocketClient,
session_id: &SessionId,
workspace: Option<std::path::PathBuf>,
model_name: &str,
) -> anyhow::Result<()> {
let working_dir = workspace
.as_ref()
.map_or_else(|| "no workspace".to_string(), |p| p.display().to_string());
let session_id_short = session_id.0.to_string()[..8].to_string();
let mut app = App::new(working_dir, model_name.to_string(), session_id_short);
let (mut terminal, keyboard_enhanced) = init_terminal()?;
let req = astrid_core::kernel_api::KernelRequest::GetCommands;
if let Ok(val) = serde_json::to_value(req) {
let msg = astrid_types::ipc::IpcMessage::new(
Topic::kernel_request("get_commands"),
astrid_types::ipc::IpcPayload::RawJson(val),
session_id.0,
);
let _ = client.send_message(msg).await;
}
let result = run_loop(&mut terminal, &mut app, client, session_id).await;
let _ = restore_terminal(&mut terminal, keyboard_enhanced);
result
}
async fn run_loop(
terminal: &mut Term,
app: &mut App,
client: &mut SocketClient,
session_id: &SessionId,
) -> anyhow::Result<()> {
let render_interval = Duration::from_millis(16);
let mut last_render = Instant::now();
loop {
if last_render.elapsed() >= render_interval {
app.terminal_height = terminal.size()?.height;
terminal.draw(|frame| render::render_frame(frame, app))?;
last_render = Instant::now();
}
handle_pending_actions(app, client, session_id, terminal).await?;
if crossterm::event::poll(Duration::from_millis(10))? {
input::handle_input(app)?;
}
if let Some((_, time)) = &app.status_message
&& time.elapsed() > Duration::from_secs(5)
{
app.status_message = None;
}
match tokio::time::timeout(Duration::from_millis(1), client.read_message()).await {
Ok(Ok(Some(ref message))) => {
handle_daemon_event(app, message);
},
Ok(Ok(None)) => {
app.push_notice("Connection to kernel lost.");
app.state = UiState::Error {
message: "Connection to kernel lost. Press Q to quit.".to_string(),
};
break;
},
Ok(Err(e)) => {
app.push_notice(&format!("Event error: {e}"));
break;
},
Err(_) => {
},
}
if let UiState::Thinking { start_time, dots } = &app.state {
let elapsed = start_time.elapsed();
let new_dots = ((elapsed.as_millis() / 500) % 4) as usize;
if new_dots != *dots {
app.state = UiState::Thinking {
start_time: *start_time,
dots: new_dots,
};
}
}
if app.should_quit {
let msg = astrid_types::ipc::IpcMessage::new(
Topic::client_disconnect(),
astrid_types::ipc::IpcPayload::Disconnect {
reason: Some("quit".to_string()),
},
session_id.0,
);
let _ = client.send_message(msg).await;
break;
}
}
Ok(())
}
#[expect(clippy::too_many_lines)]
pub(crate) fn handle_daemon_event(app: &mut App, message: &IpcMessage) {
{
if let astrid_types::ipc::IpcPayload::AgentResponse { text, is_final, .. } =
&message.payload
{
if !text.is_empty() && !matches!(app.state, UiState::Streaming { .. }) {
app.state = UiState::Streaming {
start_time: Instant::now(),
};
}
app.stream_buffer.push_str(text);
if *is_final {
if !app.stream_buffer.is_empty() {
let response = std::mem::take(&mut app.stream_buffer);
let trimmed = response
.trim_start_matches(&['\n', '\r'] as &[char])
.to_string();
app.push_message(MessageRole::Assistant, trimmed);
}
app.state = UiState::Idle;
app.scroll_offset = 0;
}
} else if let astrid_types::ipc::IpcPayload::OnboardingRequired { capsule_id, fields } =
&message.payload
{
if fields.is_empty() {
app.push_notice(&format!(
"Capsule '{capsule_id}' reported missing configuration but provided no fields."
));
return;
}
let msg = format!("Action required: Capsule '{capsule_id}' requires configuration.");
app.push_notice(&msg);
app.status_message = Some((msg, Instant::now()));
let first = fields.first();
let is_first_enum = first.is_some_and(|f| {
matches!(
f.field_type,
astrid_types::ipc::OnboardingFieldType::Enum(_)
)
});
let enum_selected = first.map_or(0, input::default_enum_position);
let default_val = first.and_then(|f| f.default.clone()).unwrap_or_default();
app.state = UiState::Onboarding {
capsule_id: capsule_id.clone(),
fields: fields.clone(),
current_idx: 0,
answers: std::collections::HashMap::new(),
enum_selected,
enum_scroll_offset: 0,
current_array_items: Vec::new(),
};
let is_first_array = first.is_some_and(|f| {
matches!(f.field_type, astrid_types::ipc::OnboardingFieldType::Array)
});
input::prefill_field_input(app, is_first_enum || is_first_array, &default_val);
} else if let astrid_types::ipc::IpcPayload::ElicitRequest {
request_id,
capsule_id,
field,
} = &message.payload
{
let msg = format!("Capsule '{capsule_id}' is requesting input: {}", field.key);
app.push_notice(&msg);
app.status_message = Some((msg, Instant::now()));
app.elicit_request_id = Some(*request_id);
let is_enum = matches!(
field.field_type,
astrid_types::ipc::OnboardingFieldType::Enum(_)
);
let is_array = matches!(
field.field_type,
astrid_types::ipc::OnboardingFieldType::Array
);
let enum_selected = input::default_enum_position(field);
let default_val = field.default.clone().unwrap_or_default();
app.state = UiState::Onboarding {
capsule_id: capsule_id.clone(),
fields: vec![field.clone()],
current_idx: 0,
answers: std::collections::HashMap::new(),
enum_selected,
enum_scroll_offset: 0,
current_array_items: Vec::new(),
};
input::prefill_field_input(app, is_enum || is_array, &default_val);
} else if let astrid_types::ipc::IpcPayload::SelectionRequired {
request_id,
title,
options,
callback_topic,
} = &message.payload
{
if options.is_empty() {
app.push_notice("No options available.");
} else {
app.state = UiState::Selection {
title: title.clone(),
options: options.clone(),
selected: 0,
scroll_offset: 0,
callback_topic: callback_topic.to_string(),
request_id: request_id.clone(),
};
}
} else if let astrid_types::ipc::IpcPayload::ApprovalRequired {
request_id,
action,
resource,
reason,
} = &message.payload
{
let approval = state::ApprovalRequest {
id: request_id.clone(),
tool_name: action.clone(),
description: resource.clone(),
details: vec![
("Action".into(), action.clone()),
("Resource".into(), resource.clone()),
("Reason".into(), reason.clone()),
],
};
app.push_notice(&format!("Approval required: {action} on {resource}"));
app.pending_approvals.push(approval);
if !matches!(app.state, UiState::AwaitingApproval) {
app.state = UiState::AwaitingApproval;
}
} else if let astrid_types::ipc::IpcPayload::RawJson(val) = &message.payload
&& let Ok(astrid_core::kernel_api::KernelResponse::Commands(cmds)) =
serde_json::from_value::<astrid_core::kernel_api::KernelResponse>(val.clone())
{
app.slash_commands = vec![
state::SlashCommandDef {
name: "/help".to_string(),
description: "Show available commands".to_string(),
},
state::SlashCommandDef {
name: "/clear".to_string(),
description: "Clear conversation history".to_string(),
},
state::SlashCommandDef {
name: "/install".to_string(),
description: "Install a capsule from a path or registry".to_string(),
},
state::SlashCommandDef {
name: "/refresh".to_string(),
description: "Reload all installed capsules into the OS".to_string(),
},
state::SlashCommandDef {
name: "/quit".to_string(),
description: "Disconnect from the daemon".to_string(),
},
];
for cmd in &cmds {
if cmd.kind != astrid_core::kernel_api::CommandKind::Slash {
continue;
}
app.slash_commands.push(state::SlashCommandDef {
name: format!("/{}", cmd.name),
description: format!("{} (via {})", cmd.description, cmd.provider_capsule),
});
}
tracing::debug!(
dynamic_commands = cmds.len(),
total = app.slash_commands.len(),
"Refreshed slash command palette"
);
}
if message.topic == "astrid.v1.capsules_loaded" {
app.pending_actions
.push(state::PendingAction::RefreshCommands);
if app.hydration_reply_topic.is_none() {
app.pending_actions
.push(state::PendingAction::HydrateSession);
}
}
if let Some(expected_topic) = &app.hydration_reply_topic
&& message.topic == *expected_topic
&& let astrid_types::ipc::IpcPayload::Custom { data } = &message.payload
&& let Some(messages) = data.get("messages")
{
app.hydration_reply_topic = None;
match serde_json::from_value::<Vec<astrid_types::llm::Message>>(messages.clone()) {
Ok(history) => {
for msg in &history {
let role = match msg.role {
astrid_types::llm::MessageRole::User => MessageRole::User,
astrid_types::llm::MessageRole::Assistant => MessageRole::Assistant,
_ => continue,
};
let content = msg.text().unwrap_or_default().to_string();
if !content.is_empty() {
app.push_message(role, content);
}
}
tracing::debug!(message_count = history.len(), "Hydrated session history");
},
Err(e) => {
tracing::warn!("Failed to parse session history: {e}");
},
}
}
if message.topic == "registry.v1.response.get_providers" {
if let astrid_types::ipc::IpcPayload::Custom { data } = &message.payload
&& let Some(providers) = data.as_array()
{
if providers.is_empty() {
app.push_notice("No LLM providers are currently loaded.");
} else {
use std::fmt::Write as _;
let mut text = String::from("Available Models:\n");
for p in providers {
let id = p.get("id").and_then(|v| v.as_str()).unwrap_or("?");
let desc = p.get("description").and_then(|v| v.as_str()).unwrap_or("");
let capsule = p.get("capsule").and_then(|v| v.as_str()).unwrap_or("?");
let _ = writeln!(text, " - {id} — {desc} (via {capsule})");
}
text.push_str("\nUse /models <model_id> to switch.");
app.push_message(MessageRole::LocalUi, text);
}
}
} else if message.topic == "registry.v1.response.set_active_model" {
if let astrid_types::ipc::IpcPayload::Custom { data } = &message.payload {
if let Some(model) = data
.get("active_model")
.and_then(|m| m.get("id"))
.and_then(|v| v.as_str())
{
app.push_notice(&format!("Active model set to: {model}"));
app.model_name = model.to_string();
} else if let Some(err) = data.get("error").and_then(|v| v.as_str()) {
app.push_notice(&format!("Failed to set model: {err}"));
}
}
} else if message.topic == "registry.v1.active_model_changed"
&& let astrid_types::ipc::IpcPayload::Custom { data } = &message.payload
&& let Some(id) = data.get("id").and_then(|v| v.as_str())
{
app.model_name = id.to_string();
}
}
}
#[expect(clippy::too_many_lines)]
async fn handle_pending_actions(
app: &mut App,
client: &mut SocketClient,
session_id: &SessionId,
terminal: &mut Term,
) -> anyhow::Result<()> {
let actions: Vec<PendingAction> = app.pending_actions.drain(..).collect();
for action in actions {
match action {
PendingAction::Approve {
request_id,
decision,
} => {
let decision_str = match decision {
state::ApprovalDecisionKind::Once => "approve",
state::ApprovalDecisionKind::Session => "approve_session",
state::ApprovalDecisionKind::Always => "approve_always",
};
let response_topic = Topic::approval_response(&request_id);
let response = astrid_types::ipc::IpcPayload::ApprovalResponse {
request_id,
decision: decision_str.into(),
reason: None,
};
let msg =
astrid_types::ipc::IpcMessage::new(response_topic, response, session_id.0);
if client.send_message(msg).await.is_err() {
tracing::warn!("Failed to send approval response to daemon");
app.push_notice("Warning: failed to send approval to daemon (will timeout).");
} else {
app.push_notice(&format!("Action approved ({decision_str})."));
}
},
PendingAction::Deny { request_id, reason } => {
let response_topic = Topic::approval_response(&request_id);
let response = astrid_types::ipc::IpcPayload::ApprovalResponse {
request_id,
decision: "deny".into(),
reason,
};
let msg =
astrid_types::ipc::IpcMessage::new(response_topic, response, session_id.0);
if client.send_message(msg).await.is_err() {
tracing::warn!("Failed to send denial response to daemon");
app.push_notice("Warning: failed to send denial to daemon (will timeout).");
} else {
app.push_notice("Action denied.");
}
},
PendingAction::CancelTurn => {
let cancel_payload = astrid_types::ipc::IpcPayload::UserInput {
text: String::new(),
session_id: session_id.0.to_string(),
context: Some(serde_json::json!({"action": "cancel_turn"})),
};
let msg = astrid_types::ipc::IpcMessage::new(
Topic::user_prompt(),
cancel_payload,
session_id.0,
);
if let Err(e) = client.send_message(msg).await {
app.push_notice(&format!("Failed to send cancellation signal: {e}"));
} else {
app.state = UiState::Interrupted;
}
},
PendingAction::SendInput(content) => {
if content.starts_with('/') {
handle_slash_command(&content, app, client, session_id, terminal).await;
} else {
app.push_message(MessageRole::User, content.clone());
app.scroll_offset = 0;
app.state = UiState::Thinking {
start_time: Instant::now(),
dots: 0,
};
if let Err(e) =
crate::socket_client::send_input_as_active_agent(client, content).await
{
app.push_notice(&format!("Failed to send input: {e}"));
app.state = UiState::Error {
message: format!("Send failed: {e}"),
};
}
}
},
PendingAction::SubmitSelection {
callback_topic,
request_id,
selected_id,
selected_label,
} => {
app.push_notice(&format!("Selected: {selected_label}"));
let msg = astrid_types::ipc::IpcMessage::new(
Topic::from_raw(callback_topic),
astrid_types::ipc::IpcPayload::Custom {
data: serde_json::json!({
"request_id": request_id,
"selected_id": selected_id,
}),
},
session_id.0,
);
let _ = client.send_message(msg).await;
},
PendingAction::RefreshCommands => {
let req = astrid_core::kernel_api::KernelRequest::GetCommands;
if let Ok(val) = serde_json::to_value(req) {
let msg = astrid_types::ipc::IpcMessage::new(
Topic::kernel_request("get_commands"),
astrid_types::ipc::IpcPayload::RawJson(val),
session_id.0,
);
let _ = client.send_message(msg).await;
}
},
PendingAction::SubmitOnboarding {
capsule_id,
answers,
} => {
if let Ok(home) = astrid_core::dirs::AstridHome::resolve() {
let principal = crate::principal::current();
let ph = home.principal_home(&principal);
let env_path = ph.env_dir().join(format!("{capsule_id}.env.json"));
if let Ok(json) = serde_json::to_string_pretty(&answers) {
if let Err(e) = write_env_file(&env_path, &json) {
app.push_notice(&format!("Failed to save configuration: {e}"));
} else {
let msg = "Configuration saved. Refreshing Kernel...";
app.push_notice(msg);
app.status_message = Some((msg.to_string(), Instant::now()));
let req = astrid_core::kernel_api::KernelRequest::ReloadCapsules;
if let Ok(val) = serde_json::to_value(req) {
let ipc_msg = astrid_types::ipc::IpcMessage::new(
Topic::kernel_request("reload_capsules"),
astrid_types::ipc::IpcPayload::RawJson(val),
session_id.0,
);
let _ = client.send_message(ipc_msg).await;
}
}
}
}
},
PendingAction::SubmitElicitResponse {
request_id,
value,
values,
} => {
let response_topic = Topic::elicit_response(request_id);
let response = astrid_types::ipc::IpcPayload::ElicitResponse {
request_id,
value,
values,
};
let msg =
astrid_types::ipc::IpcMessage::new(response_topic, response, session_id.0);
let _ = client.send_message(msg).await;
app.push_notice("Lifecycle input submitted.");
},
PendingAction::HydrateSession => {
let correlation_id = uuid::Uuid::new_v4().to_string();
let reply_topic = Topic::session_response("get_messages", &correlation_id);
app.hydration_reply_topic = Some(reply_topic);
let hydration_req = serde_json::json!({
"session_id": session_id.0.to_string(),
"correlation_id": correlation_id,
});
let msg = astrid_types::ipc::IpcMessage::new(
Topic::from_raw("session.v1.request.get_messages"),
astrid_types::ipc::IpcPayload::RawJson(hydration_req),
session_id.0,
);
let _ = client.send_message(msg).await;
},
}
}
Ok(())
}
#[expect(clippy::too_many_lines)]
async fn handle_slash_command(
cmd: &str,
app: &mut App,
client: &mut SocketClient,
session_id: &SessionId,
terminal: &mut Term,
) {
let parts: Vec<&str> = cmd.split_whitespace().collect();
if parts.is_empty() {
return;
}
match parts[0] {
"/quit" | "/exit" | "/q" => {
app.should_quit = true;
},
"/clear" => {
app.messages.clear();
app.nexus_stream.clear();
app.stream_buffer.clear();
},
"/install" => {
app.push_message(MessageRole::User, cmd.to_string());
if parts.len() < 2 {
app.push_notice("Usage: /install <path-to-capsule-or-directory>");
} else {
let source = parts[1];
let msg = format!("Installing capsule from: {source}...");
app.push_notice(&msg);
app.status_message = Some((msg, Instant::now()));
let _ = terminal.draw(|frame| render::render_frame(frame, app));
match crate::commands::capsule::install::install_capsule(source, None, false).await
{
Ok(()) => {
let success_msg =
"Installation complete. Sending refresh signal to Kernel...";
app.push_notice(success_msg);
app.status_message = Some((success_msg.to_string(), Instant::now()));
let req = astrid_core::kernel_api::KernelRequest::ReloadCapsules;
if let Ok(val) = serde_json::to_value(req) {
let msg = astrid_types::ipc::IpcMessage::new(
Topic::kernel_request("reload_capsules"),
astrid_types::ipc::IpcPayload::RawJson(val),
session_id.0,
);
let _ = client.send_message(msg).await;
}
let req = astrid_core::kernel_api::KernelRequest::GetCommands;
if let Ok(val) = serde_json::to_value(req) {
let msg = astrid_types::ipc::IpcMessage::new(
Topic::kernel_request("get_commands"),
astrid_types::ipc::IpcPayload::RawJson(val),
session_id.0,
);
let _ = client.send_message(msg).await;
}
},
Err(e) => {
app.push_notice(&format!("Failed to install capsule: {e}"));
},
}
}
},
"/refresh" => {
app.push_message(MessageRole::User, cmd.to_string());
app.push_notice("Sending refresh signal to daemon...");
let req = astrid_core::kernel_api::KernelRequest::ReloadCapsules;
if let Ok(val) = serde_json::to_value(req) {
let msg = astrid_types::ipc::IpcMessage::new(
Topic::kernel_request("reload_capsules"),
astrid_types::ipc::IpcPayload::RawJson(val),
session_id.0,
);
let _ = client.send_message(msg).await;
}
let req = astrid_core::kernel_api::KernelRequest::GetCommands;
if let Ok(val) = serde_json::to_value(req) {
let msg = astrid_types::ipc::IpcMessage::new(
Topic::kernel_request("get_commands"),
astrid_types::ipc::IpcPayload::RawJson(val),
session_id.0,
);
let _ = client.send_message(msg).await;
}
},
"/help" | "?" => {
app.push_message(MessageRole::User, cmd.to_string());
app.push_message(
MessageRole::LocalUi,
"**Available UI Commands:**\n\
- `/help` - Show this message\n\
- `/clear` - Clear the local terminal screen\n\
- `/install` - Install and load a capsule\n\
- `/refresh` - Reload all capsules into the OS\n\
- `/quit` - Disconnect from the daemon\n\
\n\
Capsule commands (from installed capsules) also appear in the palette."
.to_string(),
);
let req = astrid_core::kernel_api::KernelRequest::GetCommands;
if let Ok(val) = serde_json::to_value(req) {
let msg = astrid_types::ipc::IpcMessage::new(
Topic::kernel_request("get_commands"),
astrid_types::ipc::IpcPayload::RawJson(val),
session_id.0,
);
let _ = client.send_message(msg).await;
}
},
_ => {
let msg = astrid_types::ipc::IpcMessage::new(
Topic::cli_command_execute(),
astrid_types::ipc::IpcPayload::UserInput {
text: cmd.to_string(),
session_id: session_id.0.to_string(),
context: None,
},
session_id.0,
);
if let Err(e) = client.send_message(msg).await {
app.push_notice(&format!("Failed to send command to Kernel: {e}"));
} else {
app.push_message(MessageRole::User, cmd.to_string());
app.state = UiState::Thinking {
start_time: Instant::now(),
dots: 0,
};
}
},
}
}
fn write_env_file(path: &std::path::Path, contents: &str) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
file.write_all(contents.as_bytes())?;
file.flush()?;
Ok(())
}
#[cfg(not(unix))]
{
std::fs::write(path, contents)
}
}