Skip to content
284 changes: 267 additions & 17 deletions src/app.rs

Large diffs are not rendered by default.

86 changes: 85 additions & 1 deletion src/command/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ pub fn handle_exit<'a>(
Box::pin(async { CommandResult::Success("Exiting...".to_string()) })
}

pub fn handle_status<'a>(
parsed: &'a ParsedCommand,
_sm: &'a mut SessionManager,
) -> Pin<Box<dyn std::future::Future<Output = CommandResult> + Send + 'a>> {
Box::pin(async move {
if !parsed.args.is_empty() {
return CommandResult::Error(
"This command only opens the status dialog. Usage: /status".to_string(),
);
}

CommandResult::Success(String::new())
})
}

pub fn handle_title<'a>(
parsed: &'a ParsedCommand,
_sm: &'a mut SessionManager,
Expand Down Expand Up @@ -248,6 +263,21 @@ pub fn handle_models<'a>(
Box::pin(async move { load_models(parsed).await })
}

pub fn handle_variants<'a>(
parsed: &'a ParsedCommand,
_sm: &'a mut SessionManager,
) -> Pin<Box<dyn std::future::Future<Output = CommandResult> + Send + 'a>> {
let args = parsed.args.clone();
Box::pin(async move {
if !args.is_empty() {
return CommandResult::Error(
"This command only opens the variants dialog. Usage: /variants".to_string(),
);
}
CommandResult::Success(String::new())
})
}

pub async fn load_models(parsed: ParsedCommand) -> CommandResult {
use crate::command::registry::DialogItem;
use crate::model::discovery::Discovery;
Expand Down Expand Up @@ -936,6 +966,22 @@ pub fn register_all_commands(registry: &mut Registry) {
chat_only: false,
});

registry.register(Command {
name: "variants".to_string(),
description: "Switch model variant".to_string(),
handler: handle_variants,
hidden_tokens: vec!["reasoning effort".to_string()],
chat_only: false,
});

registry.register(Command {
name: "status".to_string(),
description: "Show status".to_string(),
handler: handle_status,
hidden_tokens: Vec::new(),
chat_only: false,
});

registry.register(Command {
name: "agents".to_string(),
description: "Switch agent".to_string(),
Expand Down Expand Up @@ -1387,7 +1433,7 @@ mod tests {
async fn test_registry_has_all_commands() {
let registry = create_registry();
let names = registry.get_command_names();
assert_eq!(names.len(), 20);
assert_eq!(names.len(), 22);
assert!(names.contains(&"exit".to_string()));
assert!(names.contains(&"sessions".to_string()));
assert!(names.contains(&"new".to_string()));
Expand All @@ -1406,6 +1452,8 @@ mod tests {
assert!(names.contains(&"skills".to_string()));
assert!(names.contains(&"mcp".to_string()));
assert!(names.contains(&"title".to_string()));
assert!(names.contains(&"variants".to_string()));
assert!(names.contains(&"status".to_string()));
assert!(registry.is_chat_only("compact"));
assert!(registry.is_chat_only("fork"));
assert!(registry.is_chat_only("move"));
Expand All @@ -1429,6 +1477,42 @@ mod tests {
assert_eq!(result, CommandResult::Success("Exiting...".to_string()));
}

#[tokio::test]
async fn test_handle_variants() {
let registry = create_registry();
let parsed = ParsedCommand {
name: "variants".to_string(),
args: vec![],
raw: "/variants".to_string(),
prefs_data: None,
active_model_id: None,
};
let mut session_manager = SessionManager::new();

assert!(matches!(
registry.execute(&parsed, &mut session_manager).await,
CommandResult::Success(message) if message.is_empty()
));
}

#[tokio::test]
async fn test_handle_status() {
let registry = create_registry();
let parsed = ParsedCommand {
name: "status".to_string(),
args: vec![],
raw: "/status".to_string(),
prefs_data: None,
active_model_id: None,
};
let mut session_manager = SessionManager::new();

assert!(matches!(
registry.execute(&parsed, &mut session_manager).await,
CommandResult::Success(message) if message.is_empty()
));
}

#[tokio::test]
async fn test_execute_unknown_command() {
let registry = create_registry();
Expand Down
106 changes: 104 additions & 2 deletions src/model/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -784,13 +784,54 @@ impl Discovery {
model.limit.as_ref().map(|l| l.context)
}

pub fn get_model_name(&self, provider_id: &str, model_id: &str) -> Option<String> {
if let Some(name) = self
.custom_providers
.as_ref()
.and_then(|providers| providers.get(&provider_id.trim().to_ascii_lowercase()))
.and_then(|provider| provider.models.get(model_id))
.and_then(|model| model.name.clone())
{
return Some(name);
}

let entry = self.load_cache_entry().ok()??;
let provider = entry.data.get(provider_id)?;
let model = provider.models.get(model_id)?;
Some(model.name.clone())
}

pub fn get_provider_name(&self, provider_id: &str) -> Option<String> {
if let Some(name) = self
.custom_providers
.as_ref()
.and_then(|providers| providers.get(&provider_id.trim().to_ascii_lowercase()))
.and_then(|provider| provider.name.clone())
{
return Some(name);
}

let entry = self.load_cache_entry().ok()??;
entry
.data
.get(provider_id)
.map(|provider| provider.name.clone())
}

pub fn get_model_reasoning_capability(
&self,
provider_id: &str,
model_id: &str,
) -> Option<crate::model::reasoning::ReasoningCapability> {
let entry = self.load_cache_entry().ok()??;
let provider = entry.data.get(provider_id)?;
let mut providers = self
.load_cache_entry()
.ok()
.flatten()
.map(|entry| entry.data.clone())
.unwrap_or_default();
self.apply_custom_provider_overlays(&mut providers);

let provider = providers.get(provider_id)?;
let model = provider.models.get(model_id)?;
let provider_npm = model
.provider
Expand Down Expand Up @@ -991,6 +1032,67 @@ mod tests {
assert_eq!(models[0].id, "configured-model");
assert_eq!(models[0].name, "Configured Model");
assert!(models[0].attachment);
assert_eq!(
discovery.get_model_name("mygateway", "configured-model"),
Some("Configured Model".to_string())
);
assert_eq!(
discovery.get_provider_name("mygateway"),
Some("My Gateway".to_string())
);
}

#[test]
fn custom_model_reasoning_capability_is_available_without_catalog_cache() {
let providers = HashMap::from([(
"clika".to_string(),
CustomProviderConfig {
name: Some("CliKA".to_string()),
npm: Some("@ai-sdk/openai-compatible".to_string()),
base_url: None,
api_key: None,
models: HashMap::from([(
"gpt-5.6-terra".to_string(),
CustomModelConfig {
name: Some("CliKA gpt-5.6-terra".to_string()),
context_window: None,
max_tokens: None,
attachment: None,
reasoning: Some(true),
reasoning_options: Some(vec![crate::model::reasoning::ReasoningOption {
kind: "effort".to_string(),
values: vec![
"low".to_string(),
"medium".to_string(),
"high".to_string(),
],
}]),
temperature: None,
tool_call: None,
modalities: None,
launch: false,
},
)]),
},
)]);
let discovery = Discovery::new_with_custom(Some(providers)).expect("discovery");

let capability = discovery
.get_model_reasoning_capability("clika", "gpt-5.6-terra")
.expect("reasoning capability");

assert_eq!(
capability.values(),
&[
crate::model::reasoning::ReasoningEffort::Low,
crate::model::reasoning::ReasoningEffort::Medium,
crate::model::reasoning::ReasoningEffort::High,
]
);
assert_eq!(
capability.default_effort(),
Some(crate::model::reasoning::ReasoningEffort::Medium)
);
}

#[test]
Expand Down
43 changes: 38 additions & 5 deletions src/ui/components/dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ pub struct Dialog {
pub actions: Vec<DialogAction>,
bottom_gap_height: u16,
pub position: DialogPosition,
max_height: Option<u16>,
search_visible: bool,
pub pending_delete_id: Option<String>,
collapsible_groups: bool,
collapsed_groups: HashSet<String>,
Expand Down Expand Up @@ -128,6 +130,8 @@ impl Dialog {
actions: Vec::new(),
bottom_gap_height: 1,
position: DialogPosition::Center,
max_height: None,
search_visible: true,
pending_delete_id: None,
collapsible_groups: false,
collapsed_groups: HashSet::new(),
Expand All @@ -143,6 +147,27 @@ impl Dialog {
self
}

pub fn with_max_height(mut self, height: u16) -> Self {
self.max_height = Some(height.max(1));
self
}

pub fn with_search_visible(mut self, visible: bool) -> Self {
self.search_visible = visible;
if !visible {
self.search_query.clear();
}
self
}

fn search_area_height(&self) -> u16 {
if self.search_visible {
SEARCH_AREA_HEIGHT
} else {
0
}
}

pub fn with_collapsible_groups(mut self, enabled: bool) -> Self {
self.collapsible_groups = enabled;
if !enabled {
Expand Down Expand Up @@ -969,14 +994,15 @@ impl Dialog {

let footer_height = self.footer_height();
let total_fixed_height =
1 + 1 + SEARCH_AREA_HEIGHT + self.bottom_gap_height + footer_height;
1 + 1 + self.search_area_height() + self.bottom_gap_height + footer_height;
let (_, padding_y) = self.content_padding();
let padding_total = padding_y * 2;

match self.position {
DialogPosition::Center => {
let dialog_height = self.max_height.unwrap_or(DIALOG_HEIGHT_CENTER);
let list_area_height =
DIALOG_HEIGHT_CENTER.saturating_sub(total_fixed_height + padding_total);
dialog_height.saturating_sub(total_fixed_height + padding_total);
list_area_height as usize
}
DialogPosition::Left | DialogPosition::Right => {
Expand Down Expand Up @@ -1066,7 +1092,7 @@ impl Dialog {
[
ratatui::layout::Constraint::Length(1),
ratatui::layout::Constraint::Length(1),
ratatui::layout::Constraint::Length(SEARCH_AREA_HEIGHT),
ratatui::layout::Constraint::Length(self.search_area_height()),
ratatui::layout::Constraint::Min(0),
ratatui::layout::Constraint::Length(self.bottom_gap_height),
ratatui::layout::Constraint::Length(self.footer_height()),
Expand Down Expand Up @@ -1255,6 +1281,7 @@ impl Dialog {
}
KeyCode::Char('j') if event.modifiers == KeyModifiers::CONTROL => true,
KeyCode::Char('c') if event.modifiers == KeyModifiers::CONTROL => false,
_ if !self.search_visible => false,
_ => {
let previous_query = self.search_query.clone();
input_textarea(&mut self.search_textarea, event);
Expand Down Expand Up @@ -1585,7 +1612,9 @@ impl Dialog {
match self.position {
DialogPosition::Center => {
let dialog_width = area.width.min(DIALOG_WIDTH_CENTER);
let dialog_height = area.height.min(DIALOG_HEIGHT_CENTER);
let dialog_height = area
.height
.min(self.max_height.unwrap_or(DIALOG_HEIGHT_CENTER));

self.dialog_area = Rect {
x: (area.width - dialog_width) / 2,
Expand Down Expand Up @@ -1659,7 +1688,9 @@ impl Dialog {
.alignment(ratatui::layout::Alignment::Right);
frame.render_widget(esc_paragraph, header_chunks[1]);

frame.render_widget(&self.search_textarea, chunks[2]);
if self.search_visible {
frame.render_widget(&self.search_textarea, chunks[2]);
}

let mut content_lines = Vec::new();
let list_area_width = chunks[3].width.saturating_sub(2); // Subtract scrollbar width
Expand Down Expand Up @@ -1886,6 +1917,8 @@ impl Clone for Dialog {
actions: self.actions.clone(),
bottom_gap_height: self.bottom_gap_height,
position: self.position,
max_height: self.max_height,
search_visible: self.search_visible,
pending_delete_id: self.pending_delete_id.clone(),
collapsible_groups: self.collapsible_groups,
collapsed_groups: self.collapsed_groups.clone(),
Expand Down
Loading
Loading