use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
use tokio::sync::mpsc;
use crate::channels::{AgentUpdate, UserCommand};
use crate::config::Provider;
#[derive(Clone, Debug)]
pub enum ChatMessage {
User(String),
Agent(String),
System(String),
}
#[derive(Clone, Debug)]
pub enum AppStatus {
Connected,
Disconnected,
Processing,
}
pub struct AppState {
pub messages: Vec<ChatMessage>,
pub input: String,
pub input_cursor: usize,
pub status: AppStatus,
pub chat_scroll: usize,
chat_follow: bool,
pub project_name: String,
pub active_network: String,
pub active_account: String,
pub active_provider: String,
pub active_model: String,
agent_streaming: bool,
explain_mode: bool,
}
impl AppState {
pub fn new() -> Self {
Self {
messages: vec![ChatMessage::System(
"Welcome to Procyon. Press Ctrl+C to quit. Type /help for commands.".to_string(),
)],
input: String::new(),
input_cursor: 0,
status: AppStatus::Connected,
chat_scroll: 0,
chat_follow: true,
project_name: "No project".to_string(),
active_network: "testnet".to_string(),
active_account: "None".to_string(),
active_provider: "anthropic".to_string(),
active_model: "claude-sonnet-5".to_string(),
agent_streaming: false,
explain_mode: false,
}
}
fn cursor_byte_offset(&self) -> usize {
self.input
.char_indices()
.nth(self.input_cursor)
.map(|(i, _)| i)
.unwrap_or(self.input.len())
}
fn input_char_count(&self) -> usize {
self.input.chars().count()
}
pub fn scroll_back(&mut self, lines: usize) {
self.chat_follow = false;
self.chat_scroll = self.chat_scroll.saturating_sub(lines);
}
pub fn scroll_forward(&mut self, lines: usize) {
self.chat_scroll = self.chat_scroll.saturating_add(lines);
}
pub fn resolve_scroll(&mut self, max_scroll: usize) -> usize {
if self.chat_follow || self.chat_scroll >= max_scroll {
self.chat_follow = true;
self.chat_scroll = max_scroll;
}
self.chat_scroll
}
pub fn is_following_chat(&self) -> bool {
self.chat_follow
}
pub fn is_explaining(&self) -> bool {
self.explain_mode
}
pub fn handle_key(
&mut self,
key: crossterm::event::KeyEvent,
user_tx: &mpsc::UnboundedSender<UserCommand>,
) -> bool {
if key.kind != KeyEventKind::Press {
return false;
}
match (key.modifiers, key.code) {
(KeyModifiers::CONTROL, KeyCode::Char('c')) => return true,
(KeyModifiers::CONTROL, KeyCode::Char('d')) => {
self.messages
.push(ChatMessage::System("Deploying contract...".to_string()));
let _ = user_tx.send(UserCommand::SendPrompt(
"deploy the current contract".to_string(),
));
}
(KeyModifiers::CONTROL, KeyCode::Char('t')) => {
self.messages
.push(ChatMessage::System("Running tests...".to_string()));
let _ = user_tx.send(UserCommand::SendPrompt("run tests".to_string()));
}
(KeyModifiers::CONTROL, KeyCode::Char('b')) => {
self.messages
.push(ChatMessage::System("Building project...".to_string()));
let _ = user_tx.send(UserCommand::SendPrompt("build the project".to_string()));
}
(KeyModifiers::NONE, KeyCode::Enter) => {
if !self.input.trim().is_empty() {
let msg = self.input.trim().to_string();
if msg.starts_with('/') {
self.handle_command(&msg, user_tx);
} else {
self.messages.push(ChatMessage::User(msg.clone()));
let _ = user_tx.send(UserCommand::SendPrompt(msg));
}
self.input.clear();
self.input_cursor = 0;
}
}
(KeyModifiers::NONE | KeyModifiers::SHIFT, KeyCode::Char(c)) => {
let at = self.cursor_byte_offset();
self.input.insert(at, c);
self.input_cursor += 1;
}
(KeyModifiers::NONE, KeyCode::Backspace) => {
if self.input_cursor > 0 {
self.input_cursor -= 1;
let at = self.cursor_byte_offset();
self.input.remove(at);
}
}
(KeyModifiers::NONE, KeyCode::Delete) => {
if self.input_cursor < self.input_char_count() {
let at = self.cursor_byte_offset();
self.input.remove(at);
}
}
(KeyModifiers::NONE, KeyCode::Left) => {
if self.input_cursor > 0 {
self.input_cursor -= 1;
}
}
(KeyModifiers::NONE, KeyCode::Right) => {
if self.input_cursor < self.input_char_count() {
self.input_cursor += 1;
}
}
(KeyModifiers::NONE, KeyCode::Home) | (KeyModifiers::CONTROL, KeyCode::Char('a')) => {
self.input_cursor = 0;
}
(KeyModifiers::NONE, KeyCode::End) | (KeyModifiers::CONTROL, KeyCode::Char('e')) => {
self.input_cursor = self.input_char_count();
}
(KeyModifiers::NONE, KeyCode::Up) => self.scroll_back(1),
(KeyModifiers::NONE, KeyCode::Down) => self.scroll_forward(1),
(KeyModifiers::NONE, KeyCode::PageUp) => self.scroll_back(10),
(KeyModifiers::NONE, KeyCode::PageDown) => self.scroll_forward(10),
_ => {}
}
false
}
fn handle_command(&mut self, cmd: &str, user_tx: &mpsc::UnboundedSender<UserCommand>) {
let parts: Vec<&str> = cmd.split_whitespace().collect();
let command = parts[0];
match command {
"/help" => {
self.messages.push(ChatMessage::System(
"Available commands:\n\
/help - Show this help\n\
/clear - Clear chat history\n\
/status - Show connection status\n\
/project - Show project info\n\
/network <net> - Switch network (local/testnet/mainnet)\n\
/explain - Toggle explain mode\n\
/model - Show model status and suggestions\n\
/model set <prov> <mdl> - Switch provider and model\n\
/model provider <name> - Switch provider only\n\
/model model <name> - Switch model only\n\
\n\
Keyboard shortcuts:\n\
Ctrl+C - Quit\n\
Ctrl+D - Deploy contract\n\
Ctrl+T - Run tests\n\
Ctrl+B - Build project\n\
Up/Down - Scroll chat"
.to_string(),
));
}
"/clear" => {
self.messages.clear();
self.messages
.push(ChatMessage::System("Chat cleared.".to_string()));
}
"/status" => {
let status = match self.status {
AppStatus::Connected => "Connected",
AppStatus::Disconnected => "Disconnected",
AppStatus::Processing => "Processing",
};
self.messages.push(ChatMessage::System(format!(
"Status: {}\nNetwork: {}\nAccount: {}",
status, self.active_network, self.active_account
)));
}
"/project" => {
self.messages.push(ChatMessage::System(format!(
"Project: {}\nNetwork: {}",
self.project_name, self.active_network
)));
}
"/explain" => {
self.explain_mode = !self.explain_mode;
let _ = user_tx.send(UserCommand::SetExplain(self.explain_mode));
self.messages.push(ChatMessage::System(
if self.explain_mode {
"Explain mode on: the agent will narrate each step it takes."
} else {
"Explain mode off."
}
.to_string(),
));
}
"/network" => {
if let Some(network) = parts.get(1) {
match *network {
"local" | "testnet" | "mainnet" => {
self.active_network = network.to_string();
self.messages.push(ChatMessage::System(format!(
"Network switched to {}",
network
)));
}
_ => {
self.messages.push(ChatMessage::System(
"Invalid network. Use: local, testnet, or mainnet".to_string(),
));
}
}
} else {
self.messages.push(ChatMessage::System(format!(
"Current network: {}",
self.active_network
)));
}
}
"/model" => {
let sub = parts.get(1).copied();
match sub {
None | Some("status") => {
let provider: Provider =
self.active_provider.parse().unwrap_or(Provider::Anthropic);
let mut msg = format!(
"Provider: {}\nModel: {}\n\nAvailable models:",
self.active_provider, self.active_model
);
for model in provider.suggested_models() {
msg.push_str(&format!("\n {}", model));
}
self.messages.push(ChatMessage::System(msg));
}
Some("set") => {
let provider_str = parts.get(2);
let model_str = parts.get(3);
match (provider_str, model_str) {
(Some(p), Some(m)) => match p.parse::<Provider>() {
Ok(provider) => {
let _ = user_tx.send(UserCommand::SwitchModel {
provider,
model: m.to_string(),
});
self.active_provider = p.to_string();
self.active_model = m.to_string();
}
Err(e) => {
self.messages.push(ChatMessage::System(e));
}
},
_ => {
self.messages.push(ChatMessage::System(
"Usage: /model set <provider> <model>".to_string(),
));
}
}
}
Some("provider") => match parts.get(2) {
Some(p) => match p.parse::<Provider>() {
Ok(provider) => {
let _ = user_tx.send(UserCommand::SwitchModel {
provider,
model: self.active_model.clone(),
});
self.active_provider = p.to_string();
self.messages.push(ChatMessage::System(format!(
"Provider switched to {}",
p
)));
}
Err(e) => {
self.messages.push(ChatMessage::System(e));
}
},
None => {
self.messages.push(ChatMessage::System(
"Usage: /model provider <name>".to_string(),
));
}
},
Some("model") => match parts.get(2) {
Some(m) => {
let _ = user_tx.send(UserCommand::SwitchModel {
provider: self
.active_provider
.parse()
.unwrap_or(Provider::Anthropic),
model: m.to_string(),
});
self.active_model = m.to_string();
self.messages
.push(ChatMessage::System(format!("Model switched to {}", m)));
}
None => {
self.messages.push(ChatMessage::System(
"Usage: /model model <name>".to_string(),
));
}
},
Some(unknown) => {
self.messages.push(ChatMessage::System(format!(
"Unknown subcommand: {}. Use: status, set, provider, model",
unknown
)));
}
}
}
_ => {
self.messages.push(ChatMessage::System(format!(
"Unknown command: {}. Type /help for available commands.",
command
)));
}
}
}
pub fn handle_agent_update(&mut self, update: AgentUpdate) {
match update {
AgentUpdate::ResponseChunk(text) => {
if !self.agent_streaming {
self.messages.push(ChatMessage::Agent(String::new()));
self.agent_streaming = true;
}
if let Some(ChatMessage::Agent(buf)) = self.messages.last_mut() {
buf.push_str(&text);
}
self.status = AppStatus::Processing;
}
AgentUpdate::ResponseEnd => {
self.end_stream();
self.status = AppStatus::Connected;
}
AgentUpdate::Status(text) => {
self.end_stream();
self.messages.push(ChatMessage::System(text));
self.status = AppStatus::Processing;
}
AgentUpdate::Error(text) => {
self.end_stream();
self.messages
.push(ChatMessage::System(format!("Error: {}", text)));
self.status = AppStatus::Disconnected;
}
}
}
fn end_stream(&mut self) {
if !self.agent_streaming {
return;
}
self.agent_streaming = false;
if matches!(self.messages.last(), Some(ChatMessage::Agent(t)) if t.is_empty()) {
self.messages.pop();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyEvent;
fn press(state: &mut AppState, code: KeyCode, modifiers: KeyModifiers) {
let (tx, _rx) = mpsc::unbounded_channel();
state.handle_key(KeyEvent::new(code, modifiers), &tx);
}
fn type_str(state: &mut AppState, text: &str) {
for c in text.chars() {
let modifiers = if c.is_uppercase() {
KeyModifiers::SHIFT
} else {
KeyModifiers::NONE
};
press(state, KeyCode::Char(c), modifiers);
}
}
#[test]
fn types_multibyte_text_without_panicking() {
let mut state = AppState::new();
type_str(&mut state, "ação corrigida");
assert_eq!(state.input, "ação corrigida");
assert_eq!(state.input_cursor, 14);
}
#[test]
fn types_uppercase_characters() {
let mut state = AppState::new();
type_str(&mut state, "Deploy");
assert_eq!(state.input, "Deploy");
}
#[test]
fn backspace_removes_whole_multibyte_char() {
let mut state = AppState::new();
type_str(&mut state, "ação");
press(&mut state, KeyCode::Backspace, KeyModifiers::NONE);
assert_eq!(state.input, "açã");
assert_eq!(state.input_cursor, 3);
press(&mut state, KeyCode::Backspace, KeyModifiers::NONE);
assert_eq!(state.input, "aç");
assert_eq!(state.input_cursor, 2);
}
#[test]
fn inserts_at_cursor_inside_multibyte_text() {
let mut state = AppState::new();
type_str(&mut state, "ção");
press(&mut state, KeyCode::Home, KeyModifiers::NONE);
type_str(&mut state, "a");
assert_eq!(state.input, "ação");
}
#[test]
fn delete_at_end_of_multibyte_text_is_noop() {
let mut state = AppState::new();
type_str(&mut state, "ç");
press(&mut state, KeyCode::Delete, KeyModifiers::NONE);
assert_eq!(state.input, "ç");
}
#[test]
fn streaming_chunks_accumulate_into_one_message() {
let mut state = AppState::new();
let before = state.messages.len();
for chunk in ["Olá", ", ", "mundo"] {
state.handle_agent_update(AgentUpdate::ResponseChunk(chunk.to_string()));
}
state.handle_agent_update(AgentUpdate::ResponseEnd);
assert_eq!(state.messages.len(), before + 1);
assert!(
matches!(state.messages.last(), Some(ChatMessage::Agent(t)) if t == "Olá, mundo"),
"got {:?}",
state.messages.last()
);
}
#[test]
fn status_between_chunks_splits_agent_messages() {
let mut state = AppState::new();
state.messages.clear();
state.handle_agent_update(AgentUpdate::ResponseChunk("antes".to_string()));
state.handle_agent_update(AgentUpdate::Status("Using tool: build".to_string()));
state.handle_agent_update(AgentUpdate::ResponseChunk("depois".to_string()));
state.handle_agent_update(AgentUpdate::ResponseEnd);
let rendered: Vec<_> = state
.messages
.iter()
.map(|m| match m {
ChatMessage::User(t) | ChatMessage::Agent(t) | ChatMessage::System(t) => t.as_str(),
})
.collect();
assert_eq!(rendered, vec!["antes", "Using tool: build", "depois"]);
}
#[test]
fn stream_with_no_text_leaves_no_empty_message() {
let mut state = AppState::new();
let before = state.messages.len();
state.handle_agent_update(AgentUpdate::ResponseEnd);
assert_eq!(state.messages.len(), before);
}
fn submit(state: &mut AppState, text: &str) -> mpsc::UnboundedReceiver<UserCommand> {
let (tx, rx) = mpsc::unbounded_channel();
for c in text.chars() {
state.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE), &tx);
}
state.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &tx);
rx
}
fn last_system_message(state: &AppState) -> String {
match state.messages.last() {
Some(ChatMessage::System(t)) => t.clone(),
other => panic!("expected a system message, got {:?}", other),
}
}
#[test]
fn explain_is_not_an_unknown_command() {
let mut state = AppState::new();
submit(&mut state, "/explain");
let msg = last_system_message(&state);
assert!(
!msg.contains("Unknown command"),
"/explain is advertised in /help but was rejected: {}",
msg
);
}
#[test]
fn explain_toggles_and_reports_both_directions() {
let mut state = AppState::new();
assert!(!state.is_explaining());
submit(&mut state, "/explain");
assert!(state.is_explaining());
assert!(last_system_message(&state).contains("on"));
submit(&mut state, "/explain");
assert!(!state.is_explaining());
assert!(last_system_message(&state).contains("off"));
}
#[test]
fn explain_tells_the_agent_task() {
let mut state = AppState::new();
let mut rx = submit(&mut state, "/explain");
match rx.try_recv() {
Ok(UserCommand::SetExplain(true)) => {}
other => panic!("expected SetExplain(true), got {:?}", other),
}
let mut rx = submit(&mut state, "/explain");
match rx.try_recv() {
Ok(UserCommand::SetExplain(false)) => {}
other => panic!("expected SetExplain(false), got {:?}", other),
}
}
#[test]
fn every_command_in_help_is_handled() {
let mut state = AppState::new();
submit(&mut state, "/help");
let help = last_system_message(&state);
let advertised: Vec<String> = help
.lines()
.filter_map(|line| line.split_whitespace().next())
.filter(|word| word.starts_with('/'))
.map(|word| word.to_string())
.collect();
assert!(advertised.len() >= 6, "parsed too few: {:?}", advertised);
for command in advertised {
let mut probe = AppState::new();
submit(&mut probe, &command);
let reply = last_system_message(&probe);
assert!(
!reply.contains("Unknown command"),
"{} is listed in /help but not handled",
command
);
}
}
#[test]
fn model_without_args_shows_current() {
let mut state = AppState::new();
submit(&mut state, "/model");
let msg = last_system_message(&state);
assert!(msg.contains("Provider: anthropic"), "got: {}", msg);
assert!(msg.contains("Model: claude-sonnet-5"), "got: {}", msg);
assert!(msg.contains("Available models:"), "got: {}", msg);
}
#[test]
fn model_status_shows_current() {
let mut state = AppState::new();
submit(&mut state, "/model status");
let msg = last_system_message(&state);
assert!(msg.contains("Provider: anthropic"), "got: {}", msg);
assert!(msg.contains("Model: claude-sonnet-5"), "got: {}", msg);
}
#[test]
fn model_set_switches_both() {
let mut state = AppState::new();
let mut rx = submit(&mut state, "/model set ollama llama3.2");
assert_eq!(state.active_provider, "ollama");
assert_eq!(state.active_model, "llama3.2");
match rx.try_recv() {
Ok(UserCommand::SwitchModel { provider, model }) => {
assert_eq!(provider, Provider::Ollama);
assert_eq!(model, "llama3.2");
}
other => panic!("expected SwitchModel, got {:?}", other),
}
}
#[test]
fn model_set_requires_two_args() {
let mut state = AppState::new();
submit(&mut state, "/model set ollama");
let msg = last_system_message(&state);
assert!(msg.contains("Usage: /model set"), "got: {}", msg);
}
#[test]
fn model_provider_switches_provider() {
let mut state = AppState::new();
let mut rx = submit(&mut state, "/model provider deepseek");
assert_eq!(state.active_provider, "deepseek");
assert_eq!(state.active_model, "claude-sonnet-5");
match rx.try_recv() {
Ok(UserCommand::SwitchModel { provider, model }) => {
assert_eq!(provider, Provider::Deepseek);
assert_eq!(model, "claude-sonnet-5");
}
other => panic!("expected SwitchModel, got {:?}", other),
}
}
#[test]
fn model_provider_requires_arg() {
let mut state = AppState::new();
submit(&mut state, "/model provider");
let msg = last_system_message(&state);
assert!(msg.contains("Usage: /model provider"), "got: {}", msg);
}
#[test]
fn model_model_switches_model() {
let mut state = AppState::new();
let mut rx = submit(&mut state, "/model model gpt-4o");
assert_eq!(state.active_provider, "anthropic");
assert_eq!(state.active_model, "gpt-4o");
match rx.try_recv() {
Ok(UserCommand::SwitchModel { provider, model }) => {
assert_eq!(provider, Provider::Anthropic);
assert_eq!(model, "gpt-4o");
}
other => panic!("expected SwitchModel, got {:?}", other),
}
}
#[test]
fn model_model_requires_arg() {
let mut state = AppState::new();
submit(&mut state, "/model model");
let msg = last_system_message(&state);
assert!(msg.contains("Usage: /model model"), "got: {}", msg);
}
#[test]
fn model_rejects_unknown_subcommand() {
let mut state = AppState::new();
submit(&mut state, "/model foobar");
let msg = last_system_message(&state);
assert!(msg.contains("Unknown subcommand"), "got: {}", msg);
}
#[test]
fn model_set_rejects_unknown_provider() {
let mut state = AppState::new();
submit(&mut state, "/model set fakeprovider gpt-4o");
let msg = last_system_message(&state);
assert!(msg.contains("Unknown provider"), "got: {}", msg);
}
}