use crate::agent::{Agent, ApprovalRequest, StreamPiece};
use crossterm::{
event::{self, DisableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use magi_core::schema::Mode;
use magi_rs::vault::{SecretStore, VaultError};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
Frame, Terminal,
};
use std::io;
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
pub type SharedSecretStore = Arc<Mutex<dyn SecretStore + Send>>;
fn handle_login(store: Option<&SharedSecretStore>, api_key: &str) -> AgentResponse {
match store {
Some(ss) => {
let mut guard = ss.lock().unwrap_or_else(|p| p.into_inner());
match guard.set("ANTHROPIC_API_KEY", api_key) {
Ok(()) => AgentResponse::Info("API key stored in the vault.".to_string()),
Err(e) => AgentResponse::Error(e.to_string()),
}
}
None => AgentResponse::Info("ephemeral session: key not persisted".to_string()),
}
}
fn handle_logout(store: Option<&SharedSecretStore>) -> AgentResponse {
match store {
Some(ss) => {
let mut guard = ss.lock().unwrap_or_else(|p| p.into_inner());
match guard.remove("ANTHROPIC_API_KEY") {
Ok(()) => AgentResponse::Info("Logged out successfully.".to_string()),
Err(VaultError::SecretNotFound(_)) => {
AgentResponse::Info("no stored session".to_string())
}
Err(e) => AgentResponse::Error(e.to_string()),
}
}
None => AgentResponse::Info("no stored session".to_string()),
}
}
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum AppMode {
Normal,
Selection,
Visual, }
pub(crate) fn parse_consult_command(trimmed: &str) -> Option<&str> {
let rest = trimmed.strip_prefix("/consult")?;
if rest.is_empty() {
return Some("");
}
Some(rest.strip_prefix(' ')?.trim())
}
pub(crate) const SPINNER_FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
pub(crate) fn next_spinner_frame(frame: usize) -> usize {
(frame + 1) % SPINNER_FRAMES.len()
}
pub(crate) fn thinking_indicator(frame: usize) -> String {
format!(
"🤔 MAGI Pensando… {}",
SPINNER_FRAMES[frame % SPINNER_FRAMES.len()]
)
}
pub(crate) fn parse_toggle_show_thinking(trimmed: &str) -> bool {
trimmed.trim() == "/toggle-show-thinking"
}
pub(crate) fn parse_init_config(trimmed: &str) -> bool {
trimmed.trim() == "/init-config"
}
pub enum UiEvent {
Input(String),
Clear,
Login,
Logout,
Consult(String),
Quit,
}
#[derive(Debug)]
pub enum AgentResponse {
Text(String),
Error(String),
Info(String),
StreamDelta(String),
ReasoningDelta(String),
Notice(String),
}
pub struct App {
pub input: String,
pub cursor_position: usize,
pub selection_start: Option<usize>,
pub messages: Vec<String>,
pub event_tx: mpsc::Sender<UiEvent>,
pub response_rx: mpsc::Receiver<AgentResponse>,
pub approval_rx: mpsc::Receiver<ApprovalRequest>,
pub pending_approval: Option<ApprovalRequest>,
pub mode: AppMode,
pub selected_index: usize,
pub visual_cursor: usize,
pub visual_selection_start: Option<usize>,
pub streaming: bool,
pub scroll_offset: usize,
pub last_max_scroll: usize,
pub last_viewport_height: usize,
pub show_thinking: bool,
pub thinking_active: bool,
pub spinner_frame: usize,
}
impl App {
pub fn new(
event_tx: mpsc::Sender<UiEvent>,
response_rx: mpsc::Receiver<AgentResponse>,
approval_rx: mpsc::Receiver<ApprovalRequest>,
) -> Self {
Self {
input: String::new(),
cursor_position: 0,
selection_start: None,
messages: Vec::new(),
event_tx,
response_rx,
approval_rx,
pending_approval: None,
mode: AppMode::Normal,
selected_index: 0,
visual_cursor: 0,
visual_selection_start: None,
streaming: false,
scroll_offset: 0,
last_max_scroll: 0,
last_viewport_height: 0,
show_thinking: false,
thinking_active: false,
spinner_frame: 0,
}
}
pub fn toggle_show_thinking(&mut self) -> bool {
self.show_thinking = !self.show_thinking;
self.show_thinking
}
pub fn on_reasoning(&mut self, delta: String) {
if self.show_thinking {
self.append_stream_delta(delta);
} else {
self.thinking_active = true;
}
}
pub fn move_cursor_left(&mut self, select: bool) {
if select && self.selection_start.is_none() {
self.selection_start = Some(self.cursor_position);
} else if !select {
self.selection_start = None;
}
if self.cursor_position > 0 {
let indices = self.input.char_indices().rev();
for (idx, _) in indices {
if idx < self.cursor_position {
self.cursor_position = idx;
return;
}
}
self.cursor_position = 0;
}
}
pub fn move_cursor_right(&mut self, select: bool) {
if select && self.selection_start.is_none() {
self.selection_start = Some(self.cursor_position);
} else if !select {
self.selection_start = None;
}
if self.cursor_position < self.input.len() {
let indices = self.input.char_indices();
for (idx, _) in indices {
if idx > self.cursor_position {
self.cursor_position = idx;
return;
}
}
self.cursor_position = self.input.len();
}
}
pub fn insert_char(&mut self, c: char) {
self.delete_selection();
if !self.input.is_char_boundary(self.cursor_position) {
self.cursor_position = 0; }
self.input.insert(self.cursor_position, c);
self.cursor_position += c.len_utf8();
}
pub fn delete_char(&mut self) {
if self.selection_start.is_some() {
self.delete_selection();
return;
}
if self.cursor_position > 0 {
self.move_cursor_left(false);
let prev_pos = self.cursor_position;
if self.input.is_char_boundary(prev_pos) {
self.input.remove(prev_pos);
}
}
}
pub fn delete_selection(&mut self) {
if let Some(start) = self.selection_start {
let end = self.cursor_position;
let (from, to) = if start < end {
(start, end)
} else {
(end, start)
};
if self.input.is_char_boundary(from) && self.input.is_char_boundary(to) {
self.input.drain(from..to);
self.cursor_position = from;
}
self.selection_start = None;
}
}
pub fn get_selected_text(&self) -> Option<String> {
self.selection_start.and_then(|start| {
let end = self.cursor_position;
let (from, to) = if start < end {
(start, end)
} else {
(end, start)
};
if self.input.is_char_boundary(from) && self.input.is_char_boundary(to) {
Some(self.input[from..to].to_string())
} else {
None
}
})
}
pub fn push_message(&mut self, message: String) {
self.messages.push(message);
self.scroll_offset = 0;
}
pub fn push_notice(&mut self, text: String) {
let notice = format!("⚠ {}", text);
self.messages.push(notice);
self.scroll_offset = 0;
}
pub fn append_stream_delta(&mut self, delta: String) {
self.thinking_active = false;
self.scroll_offset = 0;
if self.streaming {
if let Some(last) = self.messages.last_mut() {
last.push_str(&delta);
return;
}
}
self.messages.push(format!("Magi Agent: {}", delta));
self.streaming = true;
}
pub fn finalize_stream(&mut self) {
self.streaming = false;
self.thinking_active = false;
}
}
pub async fn run_tui_ext(
agent: Agent,
startup_notices: Vec<String>,
consult: Option<std::sync::Arc<magi_core::orchestrator::Magi>>,
workspace_root: std::path::PathBuf,
magi_auto_approve: bool,
secret_store: Option<SharedSecretStore>,
) -> anyhow::Result<()> {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = disable_raw_mode();
let mut stdout = io::stdout();
let _ = execute!(stdout, LeaveAlternateScreen, DisableMouseCapture);
let _ =
Terminal::new(CrosstermBackend::new(io::stdout())).and_then(|mut t| t.show_cursor());
original_hook(panic_info);
}));
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let (event_tx, mut event_rx) = mpsc::channel(100);
let (response_tx, response_rx) = mpsc::channel(100);
let (approval_tx, approval_rx) = mpsc::channel(100);
for notice in startup_notices {
let _ = response_tx.send(AgentResponse::Info(notice)).await;
}
let mut runner_agent = agent;
runner_agent.set_approval_channel(approval_tx);
let mut consult_magi_runner = consult;
tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
match event {
UiEvent::Input(text) => {
let (chunk_tx, mut chunk_rx) = mpsc::channel::<StreamPiece>(100);
let forward_tx = response_tx.clone();
let forwarder = tokio::spawn(async move {
while let Some(piece) = chunk_rx.recv().await {
let resp = match piece {
StreamPiece::Content(s) => AgentResponse::StreamDelta(s),
StreamPiece::Reasoning(s) => AgentResponse::ReasoningDelta(s),
StreamPiece::Notice(s) => AgentResponse::Notice(s),
};
if forward_tx.send(resp).await.is_err() {
break;
}
}
});
let result = runner_agent.query_streaming(&text, chunk_tx).await;
let _ = forwarder.await;
match result {
Ok(_) => {
let _ = response_tx.send(AgentResponse::Text(String::new())).await;
}
Err(e) => {
let _ = response_tx.send(AgentResponse::Error(e.to_string())).await;
}
}
}
UiEvent::Clear => {
runner_agent.clear_history();
}
UiEvent::Consult(query) => {
let magi = match consult_magi_runner.as_ref() {
Some(m) => m.clone(),
None => {
let _ = response_tx
.send(AgentResponse::Error(
"consult requires a configured LLM provider — run /login or set a provider.".to_string(),
))
.await;
continue;
}
};
if query.len() > crate::tools::consult::MAX_QUERY_LEN {
let _ = response_tx
.send(AgentResponse::Error(format!(
"consult query too large ({} bytes; max {})",
query.len(),
crate::tools::consult::MAX_QUERY_LEN
)))
.await;
continue;
}
let _ = response_tx
.send(AgentResponse::Info(
"MAGI deliberating — 3 model calls…".to_string(),
))
.await;
let join =
tokio::spawn(async move { magi.analyze(&Mode::Analysis, &query).await })
.await;
match join {
Ok(Ok(report)) => {
let body = if report.degraded {
format!(
"[DEGRADED: fewer than 3 agents responded — consensus may be unreliable]\n\n{}",
report.report
)
} else {
report.report
};
let body = crate::agent::Agent::sanitize_text(&body);
let _ = response_tx.send(AgentResponse::Text(body)).await;
}
Ok(Err(e)) => {
eprintln!("[consult] analyze failed: {e}");
let _ = response_tx
.send(AgentResponse::Error(
"MAGI consult failed — check your provider/credentials and try again."
.to_string(),
))
.await;
}
Err(join_err) => {
eprintln!("[consult] analyze panicked: {join_err}");
let _ = response_tx
.send(AgentResponse::Error(
"MAGI consult crashed unexpectedly; the session is still alive."
.to_string(),
))
.await;
}
}
}
UiEvent::Login => {
let oauth = crate::services::oauth::OAuthService::new();
let url = oauth.get_authorize_url();
let _ = response_tx.send(AgentResponse::Info(url)).await;
match oauth.start_callback_server().await {
Ok(code) => {
let _ = response_tx
.send(AgentResponse::Info("Authenticating...".to_string()))
.await;
match oauth.exchange_code_for_token(&code).await {
Ok(token) => match oauth.create_raw_api_key(&token).await {
Ok(api_key) => {
let login_result =
handle_login(secret_store.as_ref(), &api_key);
let failed =
matches!(login_result, AgentResponse::Error(_));
let _ = response_tx.send(login_result).await;
if !failed {
let model = std::env::var("ANTHROPIC_MODEL")
.unwrap_or_else(|_| {
crate::DEFAULT_MODEL.to_string()
});
let was_static = runner_agent.provider_is_static();
let banner = if was_static {
format!("Successfully logged in! Now using Magi API (model: {model}) — no restart needed; prior canned replies cleared.")
} else {
format!("Re-authenticated. Now using Magi API (model: {model}) — conversation kept.")
};
let provider_arc: std::sync::Arc<
dyn crate::agent::provider::Provider,
> = std::sync::Arc::new(
crate::agent::provider::AnthropicProvider::new(
api_key,
model.clone(),
),
);
runner_agent.set_provider(provider_arc.clone());
let new_magi = std::sync::Arc::new(magi_core::orchestrator::Magi::new(
std::sync::Arc::new(crate::agent::magi_adapter::MagiCoreProviderAdapter::new(
provider_arc, "anthropic", model,
)),
));
runner_agent.register_or_replace_tool(Box::new(
crate::tools::consult::ConsultTool::new(
new_magi.clone(),
magi_auto_approve,
),
));
consult_magi_runner = Some(new_magi);
if was_static {
runner_agent.clear_history();
}
let _ =
response_tx.send(AgentResponse::Info(banner)).await;
}
}
Err(e) => {
let _ = response_tx
.send(AgentResponse::Error(format!(
"Failed to create API key: {}",
e
)))
.await;
}
},
Err(e) => {
let _ = response_tx
.send(AgentResponse::Error(format!(
"OAuth exchange failed: {}",
e
)))
.await;
}
}
}
Err(e) => {
let _ = response_tx
.send(AgentResponse::Error(format!(
"Callback server error: {}",
e
)))
.await;
}
}
}
UiEvent::Logout => {
let _ = response_tx.send(handle_logout(secret_store.as_ref())).await;
}
UiEvent::Quit => break,
}
}
let _ = runner_agent.on_session_close().await;
});
let app = App::new(event_tx, response_rx, approval_rx);
let res = run_app(&mut terminal, app, workspace_root).await;
let _ = disable_raw_mode();
let _ = execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
);
let _ = terminal.show_cursor();
if let Err(err) = res {
eprintln!("TUI Error: {:?}", err)
}
Ok(())
}
async fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut app: App,
workspace_root: std::path::PathBuf,
) -> io::Result<()> {
loop {
terminal.draw(|f| ui(f, &mut app))?;
while let Ok(response) = app.response_rx.try_recv() {
match response {
AgentResponse::StreamDelta(delta) => app.append_stream_delta(delta),
AgentResponse::ReasoningDelta(delta) => app.on_reasoning(delta),
AgentResponse::Text(t) => {
if t.is_empty() {
app.finalize_stream();
} else {
app.push_message(format!("Magi Agent: {}", t));
}
}
AgentResponse::Error(e) => {
app.finalize_stream();
app.push_message(format!("Error: {}", e));
}
AgentResponse::Info(i) => {
app.finalize_stream();
app.push_message(format!("System: {}", i));
}
AgentResponse::Notice(n) => {
app.push_notice(n);
}
}
}
while let Ok(req) = app.approval_rx.try_recv() {
app.push_message(format!("APPROVAL REQUIRED: Execute {}?", req.tool_name));
app.push_message("Press 'y' to approve, 'c' or 'Esc' to deny.".to_string());
app.pending_approval = Some(req);
}
if event::poll(std::time::Duration::from_millis(50))? {
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
match app.mode {
AppMode::Selection => {
match key.code {
KeyCode::Up if app.selected_index > 0 => {
app.selected_index -= 1;
}
KeyCode::Down
if app.selected_index < app.messages.len().saturating_sub(1) =>
{
app.selected_index += 1;
}
KeyCode::Enter => {
app.mode = AppMode::Visual;
app.visual_cursor = 0;
app.visual_selection_start = None;
}
KeyCode::Char('y') => {
if let Some(msg) = app.messages.get(app.selected_index) {
if let Ok(mut clipboard) = arboard::Clipboard::new() {
let _ = clipboard.set_text(msg.clone());
app.push_message("System: Message copied".to_string());
}
}
app.mode = AppMode::Normal;
}
KeyCode::Esc | KeyCode::Char('q') => {
app.mode = AppMode::Normal;
}
_ => {}
}
continue;
}
AppMode::Visual => {
let msg = app
.messages
.get(app.selected_index)
.cloned()
.unwrap_or_default();
match key.code {
KeyCode::Left => {
if key.modifiers.contains(KeyModifiers::SHIFT)
&& app.visual_selection_start.is_none()
{
app.visual_selection_start = Some(app.visual_cursor);
} else if !key.modifiers.contains(KeyModifiers::SHIFT) {
app.visual_selection_start = None;
}
if app.visual_cursor > 0 {
let indices = msg.char_indices().rev();
for (idx, _) in indices {
if idx < app.visual_cursor {
app.visual_cursor = idx;
break;
}
}
}
}
KeyCode::Right => {
if key.modifiers.contains(KeyModifiers::SHIFT)
&& app.visual_selection_start.is_none()
{
app.visual_selection_start = Some(app.visual_cursor);
} else if !key.modifiers.contains(KeyModifiers::SHIFT) {
app.visual_selection_start = None;
}
if app.visual_cursor < msg.len() {
let indices = msg.char_indices();
for (idx, _) in indices {
if idx > app.visual_cursor {
app.visual_cursor = idx;
break;
}
}
}
}
KeyCode::Enter => {
if let (Some(msg_ref), Some(start)) = (
app.messages.get(app.selected_index),
app.visual_selection_start,
) {
let (from, to) = if start < app.visual_cursor {
(start, app.visual_cursor)
} else {
(app.visual_cursor, start)
};
if msg_ref.is_char_boundary(from)
&& msg_ref.is_char_boundary(to)
{
if let Ok(mut clipboard) = arboard::Clipboard::new() {
let _ =
clipboard.set_text(msg_ref[from..to].to_string());
app.push_message("System: Fragment copied".to_string());
}
}
}
app.mode = AppMode::Normal;
}
KeyCode::Esc | KeyCode::Char('q') => {
app.mode = AppMode::Selection;
}
_ => {}
}
continue;
}
AppMode::Normal => {
match (key.code, key.modifiers) {
(KeyCode::Char('v'), KeyModifiers::CONTROL) => {
if let Ok(mut clipboard) = arboard::Clipboard::new() {
if let Ok(text) = clipboard.get_text() {
for c in text.chars() {
app.insert_char(c);
}
}
}
continue;
}
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
if let Some(selected) = app.get_selected_text() {
if let Ok(mut clipboard) = arboard::Clipboard::new() {
let _ = clipboard.set_text(selected);
app.push_message("System: Selection copied".to_string());
}
continue;
} else {
let _ = app.event_tx.send(UiEvent::Quit).await;
return Ok(());
}
}
(KeyCode::Char('s'), KeyModifiers::CONTROL) => {
if !app.messages.is_empty() {
app.mode = AppMode::Selection;
app.selected_index = app.messages.len().saturating_sub(1);
}
continue;
}
(KeyCode::Left, m) => {
app.move_cursor_left(m.contains(KeyModifiers::SHIFT));
continue;
}
(KeyCode::Right, m) => {
app.move_cursor_right(m.contains(KeyModifiers::SHIFT));
continue;
}
(KeyCode::PageUp, _) => {
let page = app.last_viewport_height.saturating_sub(1).max(1);
app.scroll_offset =
(app.scroll_offset + page).min(app.last_max_scroll);
continue;
}
(KeyCode::PageDown, _) => {
let page = app.last_viewport_height.saturating_sub(1).max(1);
app.scroll_offset = app.scroll_offset.saturating_sub(page);
continue;
}
(KeyCode::Home, _) => {
app.scroll_offset = app.last_max_scroll;
continue;
}
(KeyCode::End, _) => {
app.scroll_offset = 0;
continue;
}
(KeyCode::Up, _) => {
app.scroll_offset =
(app.scroll_offset + 1).min(app.last_max_scroll);
continue;
}
(KeyCode::Down, _) => {
app.scroll_offset = app.scroll_offset.saturating_sub(1);
continue;
}
_ => {}
}
if let Some(req) = app.pending_approval.take() {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
let _ = req.tx.send(true);
app.push_message("User: Approved".to_string());
}
KeyCode::Char('c') | KeyCode::Char('C') | KeyCode::Esc => {
let _ = req.tx.send(false);
app.push_message("User: Denied".to_string());
}
_ => {
app.pending_approval = Some(req);
}
}
continue;
}
match key.code {
KeyCode::Enter => {
let input = app.input.drain(..).collect::<String>();
app.cursor_position = 0;
let trimmed = input.trim();
if !trimmed.is_empty() {
if let Some(query) = parse_consult_command(trimmed) {
if query.is_empty() {
app.push_message(
"Usage: /consult <question> — forces MAGI multi-perspective analysis (3 model calls)"
.to_string(),
);
} else {
app.push_message(format!("User: /consult {query}"));
let _ = app
.event_tx
.send(UiEvent::Consult(query.to_string()))
.await;
}
continue;
}
if parse_toggle_show_thinking(trimmed) {
let verbose = app.toggle_show_thinking();
let mode = if verbose {
"VERBOSE (full reasoning shown — for debugging)"
} else {
"COMPACT (activity indicator only)"
};
app.push_message(format!(
"System: thinking display -> {mode}"
));
continue;
}
if parse_init_config(trimmed) {
match crate::defaults::write_default_config(&workspace_root)
{
Ok(path) => app.push_message(format!(
"System: wrote default magi.toml to {}",
path.display()
)),
Err(e) => app.push_message(format!(
"System: could not write magi.toml ({e})"
)),
}
continue;
}
match trimmed {
"/exit" | "/quit" => {
let _ = app.event_tx.send(UiEvent::Quit).await;
return Ok(());
}
"/clear" => {
app.messages.clear();
let _ = app.event_tx.send(UiEvent::Clear).await;
continue;
}
"/login" => {
let _ = app.event_tx.send(UiEvent::Login).await;
continue;
}
"/logout" => {
let _ = app.event_tx.send(UiEvent::Logout).await;
continue;
}
"/help" => {
app.push_message("Available commands:".to_string());
app.push_message(
" /login, /logout - Identity management"
.to_string(),
);
app.push_message(
" /exit, /quit - Exit the application"
.to_string(),
);
app.push_message(
" /clear - Clear session history"
.to_string(),
);
app.push_message(
" /consult <q> - Force MAGI multi-perspective analysis (3 model calls)"
.to_string(),
);
app.push_message(
" /help - Show this help message"
.to_string(),
);
app.push_message(
" /toggle-show-thinking - Reasoning display: indicator (default) <-> verbose"
.to_string(),
);
app.push_message(
" /init-config - Write a default magi.toml to the workspace"
.to_string(),
);
continue;
}
_ => {}
}
app.push_message(format!("User: {}", trimmed));
let _ = app
.event_tx
.send(UiEvent::Input(trimmed.to_string()))
.await;
}
}
KeyCode::Char(c) => {
app.insert_char(c);
}
KeyCode::Backspace => {
app.delete_char();
}
KeyCode::Esc => {
let _ = app.event_tx.send(UiEvent::Quit).await;
return Ok(());
}
_ => {}
}
}
}
}
}
}
}
fn char_display_width(c: char) -> usize {
UnicodeWidthChar::width(c).unwrap_or(0)
}
fn push_hard_split(word: &str, width: usize, out: &mut Vec<String>) {
let mut chunk = String::new();
let mut chunk_w = 0usize;
for ch in word.chars() {
let cw = char_display_width(ch);
if chunk_w + cw > width && !chunk.is_empty() {
out.push(std::mem::take(&mut chunk));
chunk_w = 0;
}
chunk.push(ch);
chunk_w += cw;
}
if !chunk.is_empty() {
out.push(chunk);
}
}
fn wrap_message(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![text.to_string()];
}
let mut out: Vec<String> = Vec::new();
for paragraph in text.split('\n') {
if paragraph.is_empty() {
out.push(String::new());
continue;
}
if UnicodeWidthStr::width(paragraph) <= width {
out.push(paragraph.to_string());
continue;
}
let mut line = String::new();
let mut line_w: usize = 0;
for word in paragraph.split_whitespace() {
let w = UnicodeWidthStr::width(word);
if w > width {
if !line.is_empty() {
out.push(std::mem::take(&mut line));
line_w = 0;
}
push_hard_split(word, width, &mut out);
continue;
}
let need = if line.is_empty() { w } else { w + 1 };
if line_w + need > width {
out.push(std::mem::take(&mut line));
line_w = 0;
}
if !line.is_empty() {
line.push(' ');
line_w += 1;
}
line.push_str(word);
line_w += w;
}
if !line.is_empty() {
out.push(line);
}
}
if out.is_empty() {
out.push(String::new());
}
out
}
fn effective_selection(mode: AppMode, selected_index: usize, messages_len: usize) -> Option<usize> {
if messages_len == 0 {
return None;
}
match mode {
AppMode::Selection | AppMode::Visual => Some(selected_index),
AppMode::Normal => Some(messages_len - 1),
}
}
fn effective_highlight_symbol(mode: AppMode) -> &'static str {
if matches!(mode, AppMode::Selection | AppMode::Visual) {
">> "
} else {
""
}
}
fn max_scroll(total: usize, height: usize) -> usize {
total.saturating_sub(height)
}
fn scroll_window(total: usize, height: usize, offset: usize) -> std::ops::Range<usize> {
if height == 0 || total == 0 {
return 0..0;
}
if total <= height {
return 0..total;
}
let max_off = total - height;
let clamped = offset.min(max_off);
let start = total - height - clamped;
start..(start + height)
}
const MAX_INPUT_ROWS: usize = 6;
fn input_pane_rows(input: &str, width: usize, max: usize) -> usize {
wrap_message(input, width).len().clamp(1, max.max(1))
}
fn ui(f: &mut Frame, app: &mut App) {
let area = f.size();
let input_content_w = (area.width as usize).saturating_sub(4);
let input_rows = input_pane_rows(&app.input, input_content_w, MAX_INPUT_ROWS);
let input_pane_height = (input_rows as u16).saturating_add(2);
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([Constraint::Min(1), Constraint::Length(input_pane_height)].as_ref())
.split(area);
let inner_width = chunks[0].width.saturating_sub(2) as usize; let inner_height = chunks[0].height.saturating_sub(2) as usize;
if app.mode == AppMode::Normal {
let mut all_lines: Vec<String> = Vec::new();
for (i, m) in app.messages.iter().enumerate() {
if i > 0 {
all_lines.push(String::new());
}
all_lines.extend(wrap_message(m, inner_width));
}
if app.thinking_active {
if !all_lines.is_empty() {
all_lines.push(String::new());
}
all_lines.extend(wrap_message(
&thinking_indicator(app.spinner_frame),
inner_width,
));
app.spinner_frame = next_spinner_frame(app.spinner_frame);
}
let total = all_lines.len();
app.last_viewport_height = inner_height;
app.last_max_scroll = max_scroll(total, inner_height);
if app.scroll_offset > app.last_max_scroll {
app.scroll_offset = app.last_max_scroll;
}
let range = scroll_window(total, inner_height, app.scroll_offset);
let notice_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::DIM);
let visible: Vec<Line> = all_lines[range]
.iter()
.map(|l| {
if l.starts_with('⚠') {
Line::styled(l.clone(), notice_style)
} else {
Line::from(l.clone())
}
})
.collect();
let title = if app.scroll_offset > 0 {
format!(
"Conversation History [scrolled ↑{} · PgDn/End → bottom]",
app.scroll_offset
)
} else {
"Conversation History".to_string()
};
let conversation = Paragraph::new(Text::from(visible))
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(conversation, chunks[0]);
} else {
let messages: Vec<ListItem> = app
.messages
.iter()
.enumerate()
.map(|(i, m)| {
let mut style = Style::default();
if i == app.selected_index {
style = style
.bg(Color::Blue)
.fg(Color::White)
.add_modifier(Modifier::BOLD);
}
let lines: Vec<Line> = wrap_message(m, inner_width)
.into_iter()
.map(Line::from)
.collect();
ListItem::new(Text::from(lines)).style(style)
})
.collect();
let mut state = ListState::default();
if let Some(idx) = effective_selection(app.mode, app.selected_index, app.messages.len()) {
state.select(Some(idx));
}
let messages_list = List::new(messages)
.block(
Block::default()
.borders(Borders::ALL)
.title("Conversation History"),
)
.highlight_symbol(effective_highlight_symbol(app.mode));
f.render_stateful_widget(messages_list, chunks[0], &mut state);
}
let input_title = match app.mode {
AppMode::Selection => {
"SELECT MESSAGE (Enter to select text, 'y' to copy whole, Esc to exit)"
}
AppMode::Visual => "VISUAL SELECTION MODE",
_ if app.pending_approval.is_some() => "WAITING FOR APPROVAL (y/c)",
_ => "Input (↑↓/PgUp/PgDn/Home/End Scroll, Ctrl+S Copy, Shift+←→ Select)",
};
let input_block = Block::default().borders(Borders::ALL).title(input_title);
if input_rows <= 1 {
let mut input_text = Text::raw(app.input.as_str());
if let Some(start) = app.selection_start {
let (from, to) = if start < app.cursor_position {
(start, app.cursor_position)
} else {
(app.cursor_position, start)
};
if app.input.is_char_boundary(from) && app.input.is_char_boundary(to) {
let spans = vec![
Span::raw(&app.input[..from]),
Span::styled(
&app.input[from..to],
Style::default().bg(Color::White).fg(Color::Black),
),
Span::raw(&app.input[to..]),
];
input_text = Text::from(Line::from(spans));
}
}
f.render_widget(Paragraph::new(input_text).block(input_block), chunks[1]);
if app.mode == AppMode::Normal {
let col = UnicodeWidthStr::width(&app.input[..app.cursor_position]) as u16;
f.set_cursor(chunks[1].x + col + 1, chunks[1].y + 1);
}
} else {
let wrapped = wrap_message(&app.input, input_content_w);
let total = wrapped.len();
let shown = total.min(MAX_INPUT_ROWS);
let start = total - shown;
let visible: Vec<Line> = wrapped[start..]
.iter()
.map(|l| Line::from(l.clone()))
.collect();
f.render_widget(
Paragraph::new(Text::from(visible)).block(input_block),
chunks[1],
);
if app.mode == AppMode::Normal {
let prefix = wrap_message(&app.input[..app.cursor_position], input_content_w);
let cur_row_abs = prefix.len().saturating_sub(1);
let cur_col =
UnicodeWidthStr::width(prefix.last().map(String::as_str).unwrap_or("")) as u16;
let cur_row_vis = cur_row_abs.saturating_sub(start) as u16;
f.set_cursor(chunks[1].x + cur_col + 1, chunks[1].y + cur_row_vis + 1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn vault_fixture() -> SharedSecretStore {
let conn = rusqlite::Connection::open_in_memory().expect("mem db");
let dek =
magi_rs::vault::MaskedDek::new(zeroize::Zeroizing::new(vec![5u8; 32])).expect("32B");
let store = magi_rs::vault::wire(Arc::new(Mutex::new(conn)), dek).expect("wire");
Arc::new(Mutex::new(store)) as SharedSecretStore
}
#[test]
fn test_handle_login_stores_key_in_vault() {
let ss = vault_fixture();
let resp = handle_login(Some(&ss), "sk-fresh-key");
assert!(matches!(resp, AgentResponse::Info(_)));
let mut guard = ss.lock().unwrap();
assert_eq!(
guard.get("ANTHROPIC_API_KEY").unwrap().as_str(),
"sk-fresh-key"
);
}
#[test]
fn test_handle_login_without_vault_reports_ephemeral() {
let resp = handle_login(None, "sk-fresh-key");
match resp {
AgentResponse::Info(msg) => assert!(msg.to_lowercase().contains("ephemeral")),
other => panic!("expected an Info notice, got {other:?}"),
}
}
#[test]
fn test_handle_logout_removes_key() {
let ss = vault_fixture();
{
let mut guard = ss.lock().unwrap();
guard.set("ANTHROPIC_API_KEY", "sk-to-remove").unwrap();
}
let resp = handle_logout(Some(&ss));
assert!(matches!(resp, AgentResponse::Info(_)));
let mut guard = ss.lock().unwrap();
assert!(matches!(
guard.get("ANTHROPIC_API_KEY"),
Err(VaultError::SecretNotFound(_))
));
}
#[test]
fn test_handle_logout_absent_key_reports_no_session() {
let ss = vault_fixture();
let resp = handle_logout(Some(&ss));
match resp {
AgentResponse::Info(msg) => assert!(msg.to_lowercase().contains("no stored session")),
other => panic!("expected an Info notice, got {other:?}"),
}
let resp2 = handle_logout(None);
match resp2 {
AgentResponse::Info(msg) => assert!(msg.to_lowercase().contains("no stored session")),
other => panic!("expected an Info notice, got {other:?}"),
}
}
#[tokio::test]
async fn test_app_cursor_logic() {
let (event_tx, _) = mpsc::channel(1);
let (_, response_rx) = mpsc::channel(1);
let (_, approval_rx) = mpsc::channel(1);
let mut app = App::new(event_tx, response_rx, approval_rx);
app.insert_char('a');
app.insert_char('c');
assert_eq!(app.input, "ac");
assert_eq!(app.cursor_position, 2);
app.move_cursor_left(false);
app.insert_char('b');
assert_eq!(app.input, "abc");
assert_eq!(app.cursor_position, 2);
app.delete_char();
assert_eq!(app.input, "ac");
assert_eq!(app.cursor_position, 1);
}
#[tokio::test]
async fn test_unicode_character_boundary_panic() {
let (event_tx, _) = mpsc::channel(1);
let (_, response_rx) = mpsc::channel(1);
let (_, approval_rx) = mpsc::channel(1);
let mut app = App::new(event_tx, response_rx, approval_rx);
app.insert_char('á');
assert_eq!(app.cursor_position, 2);
app.move_cursor_left(false);
assert_eq!(app.cursor_position, 0);
app.insert_char('x');
assert_eq!(app.input, "xá");
}
#[test]
fn test_wrap_message_normal_word_wrap() {
let out = wrap_message("the quick brown fox jumps over the lazy dog", 12);
for line in &out {
assert!(line.chars().count() <= 12, "line {line:?} > 12");
}
assert_eq!(out.join(" "), "the quick brown fox jumps over the lazy dog");
}
#[test]
fn test_wrap_message_preserves_embedded_newlines() {
let out = wrap_message("hello world\n\nsecond paragraph here", 20);
assert!(
out.iter().any(|l| l.is_empty()),
"expected an empty line for the blank paragraph: {out:?}"
);
assert!(out.iter().any(|l| l == "hello world"));
assert!(out.iter().any(|l| l.contains("second paragraph")));
}
#[test]
fn test_wrap_message_breaks_oversized_word() {
let out = wrap_message("supercalifragilisticexpialidocious", 5);
for line in &out {
assert!(line.chars().count() <= 5, "line {line:?} > 5");
}
assert!(!out.is_empty());
assert_eq!(out.join(""), "supercalifragilisticexpialidocious");
}
#[test]
fn test_wrap_message_handles_multibyte_utf8() {
let out = wrap_message("La capital de Venezuela es Caracas — está al norte", 18);
for line in &out {
assert!(line.chars().count() <= 18, "line {line:?} > 18 chars");
}
}
#[test]
fn test_wrap_message_width_zero_yields_at_least_one_line() {
let out = wrap_message("anything", 0);
assert_eq!(out, vec!["anything".to_string()]);
}
#[test]
fn test_wrap_message_empty_input() {
let out = wrap_message("", 80);
assert_eq!(out, vec!["".to_string()]);
}
#[test]
fn test_wrap_message_preserves_leading_indent_when_fits() {
assert_eq!(
wrap_message(" - bullet item", 80),
vec![" - bullet item".to_string()]
);
}
#[test]
fn test_wrap_message_preserves_internal_spacing_when_fits() {
assert_eq!(
wrap_message("col1 col2", 80),
vec!["col1 col2".to_string()]
);
}
#[test]
fn test_wrap_message_wraps_by_display_width_for_wide_chars() {
assert_eq!(
wrap_message("あいうえお", 6),
vec!["あいう".to_string(), "えお".to_string()]
);
}
#[test]
fn test_effective_selection_normal_mode_follows_tail() {
assert_eq!(effective_selection(AppMode::Normal, 0, 5), Some(4));
assert_eq!(effective_selection(AppMode::Normal, 99, 5), Some(4)); }
#[test]
fn test_effective_selection_selection_and_visual_use_index() {
assert_eq!(effective_selection(AppMode::Selection, 2, 5), Some(2));
assert_eq!(effective_selection(AppMode::Visual, 0, 5), Some(0));
assert_eq!(effective_selection(AppMode::Visual, 4, 5), Some(4));
}
#[test]
fn test_effective_selection_empty_messages_yields_none() {
assert_eq!(effective_selection(AppMode::Normal, 0, 0), None);
assert_eq!(effective_selection(AppMode::Selection, 0, 0), None);
assert_eq!(effective_selection(AppMode::Visual, 0, 0), None);
}
#[test]
fn test_effective_highlight_symbol_by_mode() {
assert_eq!(effective_highlight_symbol(AppMode::Selection), ">> ");
assert_eq!(effective_highlight_symbol(AppMode::Visual), ">> ");
assert_eq!(effective_highlight_symbol(AppMode::Normal), "");
}
#[test]
fn test_max_scroll_is_overflow_above_viewport() {
assert_eq!(max_scroll(10, 4), 6); assert_eq!(max_scroll(4, 4), 0); assert_eq!(max_scroll(3, 10), 0); }
#[test]
fn test_scroll_window_offset_zero_pins_to_bottom() {
assert_eq!(scroll_window(10, 5, 0), 5..10);
}
#[test]
fn test_scroll_window_offset_scrolls_up_by_lines() {
assert_eq!(scroll_window(10, 5, 2), 3..8);
}
#[test]
fn test_scroll_window_clamps_offset_to_top() {
assert_eq!(scroll_window(10, 5, 999), 0..5);
}
#[test]
fn test_scroll_window_shorter_than_viewport_shows_all() {
assert_eq!(scroll_window(3, 5, 0), 0..3);
assert_eq!(scroll_window(3, 5, 99), 0..3); }
#[test]
fn test_scroll_window_degenerate_zero_height_or_empty() {
assert_eq!(scroll_window(10, 0, 0), 0..0);
assert_eq!(scroll_window(0, 5, 0), 0..0);
}
#[test]
fn test_input_pane_rows_minimum_one() {
assert_eq!(input_pane_rows("", 10, 6), 1);
assert_eq!(input_pane_rows("short", 10, 6), 1);
}
#[test]
fn test_input_pane_rows_grows_with_wrapped_input() {
assert_eq!(input_pane_rows(&"a".repeat(25), 10, 6), 3);
}
#[test]
fn test_input_pane_rows_clamped_to_max() {
assert_eq!(input_pane_rows(&"a".repeat(100), 10, 6), 6);
}
#[test]
fn test_parse_toggle_thinking_command() {
assert!(super::parse_toggle_show_thinking("/toggle-show-thinking"));
assert!(super::parse_toggle_show_thinking(
" /toggle-show-thinking "
));
assert!(!super::parse_toggle_show_thinking("/toggle"));
assert!(!super::parse_toggle_show_thinking("hello"));
}
#[test]
fn test_parse_init_config_command() {
assert!(parse_init_config("/init-config"));
assert!(parse_init_config(" /init-config "));
assert!(!parse_init_config("/init-configurator"));
assert!(!parse_init_config("/help"));
}
#[test]
fn test_spinner_frame_cycles() {
let n = SPINNER_FRAMES.len();
assert_eq!(next_spinner_frame(0), 1);
assert_eq!(next_spinner_frame(n - 1), 0);
}
#[test]
fn test_thinking_indicator_has_label_and_a_spinner_glyph() {
let s = thinking_indicator(0);
assert!(s.contains("Pensando"), "indicator text: {s:?}");
assert!(
s.ends_with(SPINNER_FRAMES[0]),
"ends with spinner glyph: {s:?}"
);
}
#[test]
fn test_show_thinking_defaults_off_and_toggles() {
let (event_tx, _) = mpsc::channel(1);
let (_, response_rx) = mpsc::channel(1);
let (_, approval_rx) = mpsc::channel(1);
let mut app = App::new(event_tx, response_rx, approval_rx);
assert!(
!app.show_thinking,
"default is the compact indicator (mode B)"
);
assert!(app.toggle_show_thinking()); assert!(app.show_thinking);
assert!(!app.toggle_show_thinking()); assert!(!app.show_thinking);
}
#[test]
fn test_reasoning_compact_mode_shows_indicator_not_text() {
let (event_tx, _) = mpsc::channel(1);
let (_, response_rx) = mpsc::channel(1);
let (_, approval_rx) = mpsc::channel(1);
let mut app = App::new(event_tx, response_rx, approval_rx);
app.on_reasoning("secret thoughts".to_string());
assert!(app.thinking_active);
assert!(
app.messages.is_empty(),
"reasoning text must NOT appear in messages in compact mode"
);
app.append_stream_delta("Answer".to_string());
assert!(!app.thinking_active);
assert!(app.messages.last().unwrap().contains("Answer"));
}
#[test]
fn test_reasoning_verbose_mode_appends_text() {
let (event_tx, _) = mpsc::channel(1);
let (_, response_rx) = mpsc::channel(1);
let (_, approval_rx) = mpsc::channel(1);
let mut app = App::new(event_tx, response_rx, approval_rx);
app.show_thinking = true; app.on_reasoning("visible thoughts".to_string());
assert!(
app.messages.last().unwrap().contains("visible thoughts"),
"verbose mode streams the reasoning into the message"
);
}
#[test]
fn test_finalize_stream_clears_thinking_indicator() {
let (event_tx, _) = mpsc::channel(1);
let (_, response_rx) = mpsc::channel(1);
let (_, approval_rx) = mpsc::channel(1);
let mut app = App::new(event_tx, response_rx, approval_rx);
app.on_reasoning("thinking".to_string());
assert!(app.thinking_active);
app.finalize_stream();
assert!(!app.thinking_active, "end-of-turn must drop the indicator");
}
#[test]
fn test_parse_consult_command() {
assert_eq!(
super::parse_consult_command("/consult should we X?"),
Some("should we X?")
);
assert_eq!(super::parse_consult_command("/consult"), Some(""));
assert_eq!(super::parse_consult_command("hello"), None);
assert_eq!(super::parse_consult_command("/consultation"), None);
}
#[test]
fn test_push_notice_stores_with_warning_prefix() {
let (event_tx, _) = mpsc::channel(1);
let (_, response_rx) = mpsc::channel(1);
let (_, approval_rx) = mpsc::channel(1);
let mut app = App::new(event_tx, response_rx, approval_rx);
app.push_notice("memory: context assembly failed".to_string());
assert_eq!(
app.messages.len(),
1,
"push_notice must add exactly one message"
);
let stored = &app.messages[0];
assert!(
stored.starts_with("⚠ "),
"notice message must start with the ⚠ prefix; got: {stored:?}"
);
assert!(
stored.contains("context assembly failed"),
"notice message must contain the original text; got: {stored:?}"
);
}
#[test]
fn test_push_notice_scrolls_to_tail() {
let (event_tx, _) = mpsc::channel(1);
let (_, response_rx) = mpsc::channel(1);
let (_, approval_rx) = mpsc::channel(1);
let mut app = App::new(event_tx, response_rx, approval_rx);
app.scroll_offset = 5;
app.push_notice("any notice".to_string());
assert_eq!(
app.scroll_offset, 0,
"push_notice must reset scroll_offset to 0 (follow-tail)"
);
}
#[test]
fn test_full_report_renders_without_panic() {
let (event_tx, _) = mpsc::channel(1);
let (_, response_rx) = mpsc::channel(1);
let (_, approval_rx) = mpsc::channel(1);
let mut app = App::new(event_tx, response_rx, approval_rx);
let report = format!(
"+{}+\n| MAGI VERDICT |\n+{}+\nMelchior: APPROVE — café ☕ {}\n",
"=".repeat(50),
"=".repeat(50),
"x".repeat(500)
);
for line in report.lines() {
app.push_message(line.to_string());
}
assert!(
app.messages.iter().any(|m| m.contains("MAGI VERDICT")),
"expected a message containing 'MAGI VERDICT', got: {:?}",
app.messages
);
}
}