mod components;
mod executor;
mod formatting;
mod state;
mod theme;
pub use state::{SharedState, UiCommand, UiState};
use super::adapters::RnkEventSink;
use crate::args::Cli;
use components::{
count_matching_commands, get_selected_command, render_command_suggestions, render_input,
render_model_selector, render_separator, render_status_bar, render_thinking_indicator,
};
use crossterm::terminal;
use executor::{background_loop, executor_loop};
use parking_lot::RwLock;
use rnk::prelude::*;
use sage_core::input::InputChannel;
#[allow(deprecated)]
use sage_core::ui::bridge::{set_global_adapter, set_refresh_callback};
use sage_core::ui::traits::UiContext;
use std::io;
use std::sync::Arc;
use theme::current_theme;
use tokio::sync::mpsc;
use tokio::time::{Duration, sleep};
use rnk::prelude::Box as RnkBox;
static GLOBAL_STATE: std::sync::OnceLock<SharedState> = std::sync::OnceLock::new();
static GLOBAL_CMD_TX: std::sync::OnceLock<mpsc::Sender<UiCommand>> = std::sync::OnceLock::new();
fn app() -> Element {
let app_ctx = use_app();
let state = match GLOBAL_STATE.get() {
Some(s) => s,
None => {
tracing::error!("Global state not initialized");
return Text::new("Error: State not initialized")
.color(Color::Red)
.into_element();
}
};
let cmd_tx = match GLOBAL_CMD_TX.get() {
Some(tx) => tx,
None => {
tracing::error!("Command channel not initialized");
return Text::new("Error: Command channel not initialized")
.color(Color::Red)
.into_element();
}
};
let (term_width, _) = terminal::size().unwrap_or((80, 24));
use_input({
let state = Arc::clone(state);
let cmd_tx = cmd_tx.clone();
move |ch, key| {
let mut command: Option<UiCommand> = None;
{
let mut s = state.write();
if key.ctrl && ch == "c" {
s.should_quit = true;
command = Some(UiCommand::Quit);
} else if key.escape {
if s.model_select_mode {
s.model_select_mode = false;
s.model_select_index = 0;
s.available_models.clear();
} else if s.is_busy {
command = Some(UiCommand::Cancel);
} else {
s.suggestion_index = 0;
}
} else if key.shift && ch == "\t" {
s.permission_mode = s.permission_mode.cycle();
} else if s.is_busy {
return;
} else if s.model_select_mode {
if key.up_arrow {
if s.model_select_index > 0 {
s.model_select_index -= 1;
}
} else if key.down_arrow {
let max = s.available_models.len().saturating_sub(1);
if s.model_select_index < max {
s.model_select_index += 1;
}
} else if key.return_key || (key.tab && !key.shift) {
if let Some(m) = s.available_models.get(s.model_select_index).cloned() {
command = Some(UiCommand::Submit(format!("/model {}", m)));
}
s.model_select_mode = false;
s.model_select_index = 0;
s.available_models.clear();
}
} else if key.up_arrow {
if s.input_text.starts_with('/') && s.suggestion_index > 0 {
s.suggestion_index -= 1;
}
} else if key.down_arrow {
if s.input_text.starts_with('/') {
let max_count = count_matching_commands(&s.input_text);
if s.suggestion_index < max_count.saturating_sub(1) {
s.suggestion_index += 1;
}
}
} else if key.tab && !key.shift {
if let Some(cmd) = get_selected_command(&s.input_text, s.suggestion_index) {
s.input_text = cmd;
s.suggestion_index = 0;
}
} else if key.backspace {
s.input_text.pop();
s.suggestion_index = 0;
} else if key.return_key {
let text = if s.input_text.starts_with('/') {
get_selected_command(&s.input_text, s.suggestion_index)
.unwrap_or_else(|| s.input_text.clone())
} else {
s.input_text.clone()
};
s.input_text.clear();
s.suggestion_index = 0;
if !text.is_empty() {
command = Some(UiCommand::Submit(text));
}
} else if !ch.is_empty() && !key.ctrl && !key.alt {
s.input_text.push_str(ch);
s.suggestion_index = 0;
}
}
if let Some(cmd) = command {
if let Err(e) = cmd_tx.try_send(cmd) {
tracing::warn!("Failed to send UI command: {}", e);
}
}
}
});
let (
is_busy,
input_text,
status_text,
permission_mode,
suggestion_index,
animation_frame,
model_name,
model_select_mode,
available_models,
model_select_index,
) = {
let ui_state = state.read();
if ui_state.should_quit {
drop(ui_state);
app_ctx.exit();
return Text::new("Goodbye!").into_element();
}
(
ui_state.is_busy,
ui_state.input_text.clone(),
ui_state.status_text.clone(),
ui_state.permission_mode,
ui_state.suggestion_index,
ui_state.animation_frame,
ui_state.session.model.clone(),
ui_state.model_select_mode,
ui_state.available_models.clone(),
ui_state.model_select_index,
)
};
let theme = current_theme();
let term_width_usize = term_width as usize;
let mut bottom = RnkBox::new().flex_direction(FlexDirection::Column);
if is_busy {
bottom = bottom.child(render_thinking_indicator(
&status_text,
animation_frame,
theme,
));
bottom = bottom.child(Text::new("").into_element()); }
bottom = bottom.child(render_separator(term_width_usize, theme));
let input = render_input(&input_text, theme, animation_frame);
bottom = bottom.child(input);
bottom = bottom.child(render_separator(term_width_usize, theme));
if model_select_mode && !available_models.is_empty() {
bottom = bottom.child(render_model_selector(
&available_models,
model_select_index,
theme,
));
} else if !is_busy {
if let Some((element, _)) = render_command_suggestions(&input_text, suggestion_index, theme)
{
bottom = bottom.child(element);
}
}
let status_bar = render_status_bar(permission_mode, Some(&model_name), theme);
bottom = bottom.child(status_bar);
bottom.into_element()
}
pub async fn run_rnk_app(cli: &Cli) -> io::Result<()> {
let (model, provider) = match if std::path::Path::new(&cli.config_file).exists() {
sage_core::config::load_config_from_file(&cli.config_file)
} else {
sage_core::config::load_config()
} {
Ok(config) => {
let provider = config.default_provider.clone();
let keys: Vec<_> = config.model_providers.keys().cloned().collect();
let model = config
.model_providers
.get(&provider)
.map(|p| p.model.clone())
.unwrap_or_else(|| format!("no-provider-{}-keys:{:?}", provider, keys));
(model, provider)
}
Err(e) => (format!("err:{}", e), "config-error".to_string()),
};
let (rnk_sink, adapter) = RnkEventSink::with_default_adapter();
#[allow(deprecated)]
set_global_adapter((*adapter).clone());
set_refresh_callback(|| {
rnk::request_render();
});
let ui_context = UiContext::new(Arc::new(rnk_sink));
let mut initial_state = UiState::default();
initial_state.session.model = model.clone();
initial_state.session.provider = provider.clone();
let state: SharedState = Arc::new(RwLock::new(initial_state));
let _ = GLOBAL_STATE.set(Arc::clone(&state));
let (cmd_tx, cmd_rx) = mpsc::channel::<UiCommand>(16);
let _ = GLOBAL_CMD_TX.set(cmd_tx);
let (input_channel, input_handle) = InputChannel::new(16);
let input_handle_task = tokio::spawn(async move {
crate::commands::unified::handle_user_input(input_handle, false).await;
});
let executor_state = Arc::clone(&state);
let executor_ui_context = ui_context.clone();
let config_file = cli.config_file.clone();
let working_dir = cli.working_dir.clone();
let max_steps = cli.max_steps;
let executor_task = tokio::spawn(async move {
executor_loop(
executor_state,
cmd_rx,
input_channel,
executor_ui_context,
config_file,
working_dir,
max_steps,
)
.await;
});
let bg_state = Arc::clone(&state);
let bg_adapter = (*adapter).clone();
let background_task = tokio::spawn(async move {
background_loop(bg_state, bg_adapter).await;
});
sleep(Duration::from_millis(100)).await;
let result = render(app).run();
input_handle_task.abort();
executor_task.abort();
background_task.abort();
result
}
#[cfg(test)]
mod tests {
use super::formatting::{wrap_single_line, wrap_text_with_prefix};
#[test]
fn wrap_text_basic() {
let lines = wrap_single_line("hello world", 20);
assert_eq!(lines, vec!["hello world"]);
}
#[test]
fn wrap_text_long() {
let lines = wrap_single_line("hello world this is a long line", 10);
assert!(lines.len() > 1);
for line in &lines {
assert!(unicode_width::UnicodeWidthStr::width(line.as_str()) <= 10);
}
}
#[test]
fn wrap_with_prefix() {
let lines = wrap_text_with_prefix("user: ", "hello world", 20);
assert!(!lines.is_empty());
assert!(lines[0].starts_with("user: "));
}
}