Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
547 changes: 332 additions & 215 deletions crates/arcan-tui/src/app.rs

Large diffs are not rendered by default.

243 changes: 243 additions & 0 deletions crates/arcan-tui/src/command.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
/// Parsed TUI command from user input.
#[derive(Debug, PartialEq, Eq)]
pub enum Command {
/// Clear the conversation log.
Clear,
/// Show available commands.
Help,
/// Model inspection or switching.
Model(ModelSubcommand),
/// Submit an approval decision.
Approve {
approval_id: String,
decision: String,
reason: Option<String>,
},
/// Toggle or browse sessions.
Sessions,
/// Fetch and display agent state.
State,
/// Send a plain message to the agent.
SendMessage(String),
}

/// Model-related subcommands.
#[derive(Debug, PartialEq, Eq)]
pub enum ModelSubcommand {
/// Show the current model.
ShowCurrent,
/// Set the provider (and optionally the model).
Set {
provider: String,
model: Option<String>,
},
}

/// Parse a user input string into a `Command`.
///
/// Slash-prefixed inputs are parsed as commands; everything else is a message.
pub fn parse(input: &str) -> Result<Command, String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err("Empty input".to_string());
}

if !trimmed.starts_with('/') {
return Ok(Command::SendMessage(trimmed.to_string()));
}

let (cmd, args) = trimmed
.split_once(' ')
.map(|(c, a)| (c, a.trim()))
.unwrap_or((trimmed, ""));

match cmd {
"/clear" => Ok(Command::Clear),
"/help" => Ok(Command::Help),
"/model" => parse_model(args),
"/approve" => parse_approve(args),
"/sessions" => Ok(Command::Sessions),
"/state" => Ok(Command::State),
unknown => Err(format!(
"Unknown command: {unknown}. Type /help for available commands."
)),
}
}

fn parse_model(args: &str) -> Result<Command, String> {
if args.is_empty() {
return Ok(Command::Model(ModelSubcommand::ShowCurrent));
}

if args.contains(char::is_whitespace) {
return Err("Usage: /model | /model <provider> | /model <provider>:<model>".to_string());
}

if let Some((provider, model)) = args.split_once(':') {
if provider.is_empty() || model.is_empty() {
return Err("Usage: /model <provider>:<model> (both values are required)".to_string());
}
return Ok(Command::Model(ModelSubcommand::Set {
provider: provider.to_string(),
model: Some(model.to_string()),
}));
}

Ok(Command::Model(ModelSubcommand::Set {
provider: args.to_string(),
model: None,
}))
}

fn parse_approve(args: &str) -> Result<Command, String> {
let parts: Vec<&str> = args.split_whitespace().collect();
if parts.len() < 2 {
return Err("Usage: /approve <id> <yes|no> [reason]".to_string());
}

let approval_id = parts[0].to_string();
let decision = match parts[1].to_ascii_lowercase().as_str() {
"yes" | "y" | "approved" | "approve" => "approved".to_string(),
"no" | "n" | "denied" | "deny" => "denied".to_string(),
invalid => {
return Err(format!(
"Invalid approval decision '{invalid}'. Use yes/no."
));
}
};

let reason = if parts.len() > 2 {
Some(parts[2..].join(" "))
} else {
None
};

Ok(Command::Approve {
approval_id,
decision,
reason,
})
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn plain_message() {
assert_eq!(
parse("hello world").unwrap(),
Command::SendMessage("hello world".to_string())
);
}

#[test]
fn clear_command() {
assert_eq!(parse("/clear").unwrap(), Command::Clear);
}

#[test]
fn help_command() {
assert_eq!(parse("/help").unwrap(), Command::Help);
}

#[test]
fn model_show() {
assert_eq!(
parse("/model").unwrap(),
Command::Model(ModelSubcommand::ShowCurrent)
);
}

#[test]
fn model_set_provider() {
assert_eq!(
parse("/model mock").unwrap(),
Command::Model(ModelSubcommand::Set {
provider: "mock".to_string(),
model: None,
})
);
}

#[test]
fn model_set_provider_with_model() {
assert_eq!(
parse("/model ollama:qwen2.5").unwrap(),
Command::Model(ModelSubcommand::Set {
provider: "ollama".to_string(),
model: Some("qwen2.5".to_string()),
})
);
}

#[test]
fn model_rejects_incomplete() {
let err = parse("/model ollama:").unwrap_err();
assert!(err.contains("required"), "got: {err}");
}

#[test]
fn model_rejects_spaces() {
let err = parse("/model ollama qwen").unwrap_err();
assert!(err.contains("Usage"), "got: {err}");
}

#[test]
fn approve_yes() {
assert_eq!(
parse("/approve ap-1 yes because").unwrap(),
Command::Approve {
approval_id: "ap-1".to_string(),
decision: "approved".to_string(),
reason: Some("because".to_string()),
}
);
}

#[test]
fn approve_no_reason() {
assert_eq!(
parse("/approve ap-2 no").unwrap(),
Command::Approve {
approval_id: "ap-2".to_string(),
decision: "denied".to_string(),
reason: None,
}
);
}

#[test]
fn approve_missing_args() {
let err = parse("/approve ap-1").unwrap_err();
assert!(err.contains("Usage"), "got: {err}");
}

#[test]
fn approve_invalid_decision() {
let err = parse("/approve ap-1 maybe").unwrap_err();
assert!(err.contains("Invalid"), "got: {err}");
}

#[test]
fn unknown_command() {
let err = parse("/foobar").unwrap_err();
assert!(err.contains("Unknown"), "got: {err}");
}

#[test]
fn empty_input() {
assert!(parse("").is_err());
assert!(parse(" ").is_err());
}

#[test]
fn sessions_command() {
assert_eq!(parse("/sessions").unwrap(), Command::Sessions);
}

#[test]
fn state_command() {
assert_eq!(parse("/state").unwrap(), Command::State);
}
}
69 changes: 69 additions & 0 deletions crates/arcan-tui/src/event.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
use arcan_core::protocol::AgentEvent;
use crossterm::event::{self, Event, KeyEvent};
use std::time::Duration;
use tokio::sync::mpsc;

/// Unified TUI event type merging terminal, network, and timer events.
pub enum TuiEvent {
/// A key press from the terminal.
Key(KeyEvent),
/// Periodic tick for UI refresh and animation.
Tick,
/// An agent event from the daemon SSE stream.
Network(AgentEvent),
/// Terminal resize event.
Resize(u16, u16),
}

/// Spawn background producers that merge terminal input, network events,
/// and periodic ticks into a single `mpsc::Receiver<TuiEvent>`.
///
/// - Terminal events are read on a dedicated OS thread (crossterm is blocking).
/// - Network events are forwarded from the given receiver.
/// - Ticks are emitted whenever the crossterm poll times out.
pub fn event_pump(
network_rx: mpsc::Receiver<AgentEvent>,
tick_rate: Duration,
) -> mpsc::Receiver<TuiEvent> {
let (tx, rx) = mpsc::channel(256);

// Terminal events — must run on a dedicated OS thread (crossterm is blocking)
let term_tx = tx.clone();
std::thread::spawn(move || {
loop {
if event::poll(tick_rate).unwrap_or(false) {
match event::read() {
Ok(Event::Key(key)) => {
if term_tx.blocking_send(TuiEvent::Key(key)).is_err() {
break;
}
}
Ok(Event::Resize(w, h)) => {
if term_tx.blocking_send(TuiEvent::Resize(w, h)).is_err() {
break;
}
}
_ => {}
}
} else {
// Poll timeout = emit tick
if term_tx.blocking_send(TuiEvent::Tick).is_err() {
break;
}
}
}
});

// Forward network events into the unified channel
let net_tx = tx;
tokio::spawn(async move {
let mut network_rx = network_rx;
while let Some(agent_event) = network_rx.recv().await {
if net_tx.send(TuiEvent::Network(agent_event)).await.is_err() {
break;
}
}
});

rx
}
68 changes: 68 additions & 0 deletions crates/arcan-tui/src/focus.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/// Focus targets in the TUI layout.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum FocusTarget {
ChatLog,
#[default]
InputBar,
SessionBrowser,
StateInspector,
}

impl FocusTarget {
/// Cycle to the next focus target (Tab key behavior).
/// Only cycles through targets that are currently visible.
pub fn next(self) -> Self {
match self {
Self::ChatLog => Self::InputBar,
Self::InputBar => Self::ChatLog,
Self::SessionBrowser => Self::InputBar,
Self::StateInspector => Self::InputBar,
}
}

/// Cycle through all panels including side panels (Shift+Tab or explicit).
pub fn next_all(self) -> Self {
match self {
Self::ChatLog => Self::InputBar,
Self::InputBar => Self::SessionBrowser,
Self::SessionBrowser => Self::StateInspector,
Self::StateInspector => Self::ChatLog,
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn focus_cycles_between_targets() {
let focus = FocusTarget::InputBar;
assert_eq!(focus.next(), FocusTarget::ChatLog);
assert_eq!(focus.next().next(), FocusTarget::InputBar);
}

#[test]
fn default_focus_is_input_bar() {
assert_eq!(FocusTarget::default(), FocusTarget::InputBar);
}

#[test]
fn next_all_cycles_through_all_panels() {
let mut focus = FocusTarget::ChatLog;
focus = focus.next_all(); // InputBar
assert_eq!(focus, FocusTarget::InputBar);
focus = focus.next_all(); // SessionBrowser
assert_eq!(focus, FocusTarget::SessionBrowser);
focus = focus.next_all(); // StateInspector
assert_eq!(focus, FocusTarget::StateInspector);
focus = focus.next_all(); // ChatLog (wrap)
assert_eq!(focus, FocusTarget::ChatLog);
}

#[test]
fn side_panels_tab_back_to_input() {
assert_eq!(FocusTarget::SessionBrowser.next(), FocusTarget::InputBar);
assert_eq!(FocusTarget::StateInspector.next(), FocusTarget::InputBar);
}
}
Loading