feat: implement model management commands and overlays for enhanced user interaction

This commit is contained in:
asepharyana
2026-07-12 02:06:46 +07:00
parent 9f272d5973
commit 662e269f2c
8 changed files with 403 additions and 26 deletions
+46
View File
@@ -60,6 +60,14 @@ pub enum Action {
name: String, name: String,
command: String, command: String,
}, },
ModelList,
ModelUse {
provider: String,
},
ModelAdd {
name: String,
base_url: String,
},
} }
pub fn apply_action(state: &mut AppStateRest, action: Action) { pub fn apply_action(state: &mut AppStateRest, action: Action) {
@@ -184,6 +192,44 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
} }
} }
} }
Action::ModelList => {
state.misc.selected_index = 0;
state.misc.overlay = Overlay::ModelSelector;
state.dirty = true;
}
Action::ModelUse { provider } => {
if state.app_config.providers.contains_key(&provider) {
let default_model = state.app_config.providers[&provider]
.default_model.clone()
.unwrap_or_else(|| "claude-opus-4-8".to_string());
state.settings.provider = provider.clone();
state.settings.model = default_model;
let _ = state.settings.save();
state.push_toast(Toast::new(ToastKind::Success,
format!("Switched to provider '{}' (model: {})", provider, state.settings.model)));
} else {
state.push_toast(Toast::new(ToastKind::Error,
format!("Unknown provider '{}'. Use /model ls to see available ones.", provider)));
}
state.dirty = true;
}
Action::ModelAdd { name, base_url } => {
if base_url.is_empty() {
state.push_toast(Toast::new(ToastKind::Error, "Usage: /model add <name> <base_url>".to_string()));
} else {
let cfg = crate::model::app_config::ProviderConfig {
api_base: base_url.clone(),
api_key_env: None,
default_model: Some("claude-opus-4-8".to_string()),
};
state.app_config.providers.insert(name.clone(), cfg);
state.push_toast(Toast::new(ToastKind::Success,
format!("Added provider '{}' at {}", name, base_url)));
state.push_toast(Toast::new(ToastKind::Info,
"Use /model use <name> to switch, or /edit app_config.json to configure further".to_string()));
state.dirty = true;
}
}
Action::CloseOverlay => { Action::CloseOverlay => {
// If the overlay is the Editor, dismiss it properly first // If the overlay is the Editor, dismiss it properly first
if state.misc.overlay == Overlay::Editor { if state.misc.overlay == Overlay::Editor {
+18
View File
@@ -34,6 +34,9 @@ pub fn apply_command(command: Command) -> Vec<Action> {
Command::Mode(mode) => { Command::Mode(mode) => {
vec![Action::SwitchMode(mode)] vec![Action::SwitchMode(mode)]
} }
Command::ClearConfirm => {
vec![Action::OpenOverlay(Overlay::ClearConfirm)]
}
Command::Clear => { Command::Clear => {
vec![Action::SystemNote { vec![Action::SystemNote {
kind: "clear".to_string(), kind: "clear".to_string(),
@@ -43,12 +46,27 @@ pub fn apply_command(command: Command) -> Vec<Action> {
Command::Login { provider } => { Command::Login { provider } => {
vec![Action::StartOAuth { provider }] vec![Action::StartOAuth { provider }]
} }
Command::Edit(path) if path == "." || path.is_empty() => {
vec![Action::SystemNote {
kind: "info".to_string(),
message: "Usage: /edit <path>\nOpens a file for inline editing.\nExample: /edit src/main.rs".to_string(),
}]
}
Command::Edit(path) => { Command::Edit(path) => {
vec![Action::OpenEditor { path }] vec![Action::OpenEditor { path }]
} }
Command::McpAdd { name, command } => { Command::McpAdd { name, command } => {
vec![Action::McpAdd { name, command }] vec![Action::McpAdd { name, command }]
} }
Command::ModelList => {
vec![Action::ModelList]
}
Command::ModelUse { provider } => {
vec![Action::ModelUse { provider }]
}
Command::ModelAdd { name, base_url } => {
vec![Action::ModelAdd { name, base_url }]
}
Command::Unknown(cmd) => { Command::Unknown(cmd) => {
vec![Action::SystemNote { vec![Action::SystemNote {
kind: "error".to_string(), kind: "error".to_string(),
+41 -10
View File
@@ -62,6 +62,7 @@ pub struct InputState {
pub autocomplete_prefix: String, pub autocomplete_prefix: String,
pub autocomplete_candidates: Vec<String>, pub autocomplete_candidates: Vec<String>,
pub autocomplete_idx: usize, pub autocomplete_idx: usize,
pub autocomplete_visible: bool,
} }
const COMMANDS: &[&str] = &[ const COMMANDS: &[&str] = &[
@@ -87,6 +88,9 @@ const COMMANDS: &[&str] = &[
"/login openai", "/login openai",
"/edit", "/edit",
"/mcp add", "/mcp add",
"/model",
"/model ls",
"/model add",
]; ];
impl InputState { impl InputState {
@@ -99,22 +103,25 @@ impl InputState {
autocomplete_prefix: String::new(), autocomplete_prefix: String::new(),
autocomplete_candidates: Vec::new(), autocomplete_candidates: Vec::new(),
autocomplete_idx: 0, autocomplete_idx: 0,
autocomplete_visible: false,
} }
} }
pub fn tab_complete(&mut self) { pub fn close_autocomplete(&mut self) {
self.autocomplete_visible = false;
self.autocomplete_candidates.clear();
self.autocomplete_prefix.clear();
self.autocomplete_idx = 0;
}
pub fn open_autocomplete(&mut self) {
let trimmed = self.buffer.trim().to_string(); let trimmed = self.buffer.trim().to_string();
if trimmed.is_empty() { if trimmed.is_empty() || !trimmed.starts_with('/') {
return; self.close_autocomplete();
}
if !trimmed.starts_with('/') {
return; return;
} }
let prefix = trimmed.to_lowercase(); let prefix = trimmed.to_lowercase();
if prefix != self.autocomplete_prefix || self.autocomplete_candidates.is_empty() {
self.autocomplete_candidates = COMMANDS self.autocomplete_candidates = COMMANDS
.iter() .iter()
.filter(|c| c.starts_with(&prefix)) .filter(|c| c.starts_with(&prefix))
@@ -122,13 +129,37 @@ impl InputState {
.collect(); .collect();
self.autocomplete_prefix = prefix; self.autocomplete_prefix = prefix;
self.autocomplete_idx = 0; self.autocomplete_idx = 0;
} else { self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
self.autocomplete_idx = (self.autocomplete_idx + 1) % self.autocomplete_candidates.len();
} }
pub fn cycle_autocomplete(&mut self, forward: bool) {
let n = self.autocomplete_candidates.len();
if n == 0 { return; }
if forward {
self.autocomplete_idx = (self.autocomplete_idx + 1) % n;
} else {
self.autocomplete_idx = if self.autocomplete_idx == 0 { n - 1 } else { self.autocomplete_idx - 1 };
}
}
pub fn select_autocomplete(&mut self) -> bool {
if let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx) { if let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx) {
self.buffer = candidate.clone(); self.buffer = candidate.clone();
self.cursor = self.buffer.len(); self.cursor = self.buffer.len();
self.close_autocomplete();
true
} else {
false
}
}
pub fn tab_complete(&mut self) {
// Legacy inline tab-complete — used as a fallback when the dropdown
// isn't visible yet. Opens the dropdown on the first Tab press.
if !self.autocomplete_visible {
self.open_autocomplete();
} else {
self.cycle_autocomplete(true);
} }
} }
+4
View File
@@ -85,6 +85,10 @@ pub enum Overlay {
Learning, Learning,
Usage, Usage,
Loading, Loading,
ModelSelector,
ClearConfirm,
ModePicker,
LoginPicker,
} }
impl Overlay { impl Overlay {
+28 -6
View File
@@ -12,12 +12,21 @@ pub enum Command {
LessonList, LessonList,
Mode(ModeKind), Mode(ModeKind),
Clear, Clear,
ClearConfirm,
Login { provider: String }, Login { provider: String },
Edit(String), Edit(String),
McpAdd { McpAdd {
name: String, name: String,
command: String, command: String,
}, },
ModelList,
ModelUse {
provider: String,
},
ModelAdd {
name: String,
base_url: String,
},
Unknown(String), Unknown(String),
} }
@@ -33,7 +42,9 @@ pub fn parse_command(text: &str) -> Command {
match cmd { match cmd {
"/help" => Command::Help, "/help" => Command::Help,
"/quit" => Command::Quit, "/quit" => Command::Quit,
"/clear" if arg1.is_empty() => Command::ClearConfirm,
"/clear" => Command::Clear, "/clear" => Command::Clear,
"/mode" if arg1.is_empty() => Command::Help, // open help to show available modes
"/mode" => { "/mode" => {
let mode = match arg1 { let mode = match arg1 {
"chat" | "c" => ModeKind::Chat, "chat" | "c" => ModeKind::Chat,
@@ -53,25 +64,36 @@ pub fn parse_command(text: &str) -> Command {
"/lesson" if arg1 == "reject" && !arg2.is_empty() => Command::LessonReject(arg2.to_string()), "/lesson" if arg1 == "reject" && !arg2.is_empty() => Command::LessonReject(arg2.to_string()),
"/lesson" if !arg1.is_empty() => Command::LessonCreate(arg1.to_string()), "/lesson" if !arg1.is_empty() => Command::LessonCreate(arg1.to_string()),
"/lesson" => Command::LessonList, "/lesson" => Command::LessonList,
"/login" if arg1.is_empty() => Command::LessonList,
"/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() }, "/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() },
"/login" => Command::Login { provider: "zen".to_string() },
"/edit" if !arg1.is_empty() => Command::Edit(arg1.to_string()), "/edit" if !arg1.is_empty() => Command::Edit(arg1.to_string()),
"/edit" => Command::Edit(".".to_string()), "/edit" => Command::Edit(".".to_string()),
"/mcp" if arg1.is_empty() => {
Command::Mode(ModeKind::Mcp)
}
"/mcp" if arg1 == "add" && !arg2.is_empty() => { "/mcp" if arg1 == "add" && !arg2.is_empty() => {
// /mcp add <name> <command> [args...]
// name is the first word of arg2, the rest is the command
let rest = arg2.trim(); let rest = arg2.trim();
if let Some(space) = rest.find(' ') { if let Some(space) = rest.find(' ') {
let name = rest[..space].to_string(); let name = rest[..space].to_string();
let command = rest[space + 1..].trim().to_string(); let command = rest[space + 1..].trim().to_string();
Command::McpAdd { name, command } Command::McpAdd { name, command }
} else { } else {
Command::McpAdd { Command::McpAdd { name: rest.to_string(), command: String::new() }
name: rest.to_string(),
command: String::new(),
} }
} }
"/model" if arg1 == "ls" || arg1 == "list" => Command::ModelList,
"/model" if arg1 == "add" && !arg2.is_empty() => {
let rest = arg2.trim();
if let Some(space) = rest.find(' ') {
let name = rest[..space].to_string();
let base_url = rest[space + 1..].trim().to_string();
Command::ModelAdd { name, base_url }
} else {
Command::ModelAdd { name: rest.to_string(), base_url: String::new() }
} }
}
"/model" if arg1.is_empty() => Command::ModelList,
"/model" => Command::ModelUse { provider: arg1.to_string() },
_ => Command::Unknown(cmd.to_string()), _ => Command::Unknown(cmd.to_string()),
} }
} }
+109 -3
View File
@@ -63,6 +63,11 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
vec![Action::CloseOverlay] vec![Action::CloseOverlay]
} }
KeyCode::Enter => { KeyCode::Enter => {
if state.input.autocomplete_visible {
state.input.select_autocomplete();
state.dirty = true;
return Vec::new();
}
if state.misc.overlay.is_active() { if state.misc.overlay.is_active() {
return handle_overlay_enter(state); return handle_overlay_enter(state);
} }
@@ -73,9 +78,19 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
vec![Action::SubmitInput(text)] vec![Action::SubmitInput(text)]
} }
KeyCode::Backspace => { KeyCode::Backspace => {
if state.input.autocomplete_visible {
state.input.close_autocomplete();
state.dirty = true;
return Vec::new();
}
vec![Action::DeleteChar] vec![Action::DeleteChar]
} }
KeyCode::Delete => { KeyCode::Delete => {
if state.input.autocomplete_visible {
state.input.close_autocomplete();
state.dirty = true;
return Vec::new();
}
vec![Action::DeleteCharRight] vec![Action::DeleteCharRight]
} }
KeyCode::Left => { KeyCode::Left => {
@@ -85,7 +100,11 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
vec![Action::CursorRight] vec![Action::CursorRight]
} }
KeyCode::Up => { KeyCode::Up => {
if key.modifiers.contains(KeyModifiers::CONTROL) { if state.input.autocomplete_visible {
state.input.cycle_autocomplete(false);
state.dirty = true;
Vec::new()
} else if key.modifiers.contains(KeyModifiers::CONTROL) {
vec![Action::HistoryUp] vec![Action::HistoryUp]
} else if state.misc.overlay == Overlay::Effort { } else if state.misc.overlay == Overlay::Effort {
mode::effort::cycle_effort(state); mode::effort::cycle_effort(state);
@@ -100,12 +119,30 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
state.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 }; state.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 };
state.dirty = true; state.dirty = true;
Vec::new() Vec::new()
} else if state.misc.overlay == Overlay::ModelSelector {
let n = state.app_config.providers.len();
state.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 };
state.dirty = true;
Vec::new()
} else if state.misc.overlay == Overlay::ModePicker {
state.misc.selected_index = if state.misc.selected_index == 0 { 3 } else { state.misc.selected_index - 1 };
state.dirty = true;
Vec::new()
} else if state.misc.overlay == Overlay::LoginPicker {
let n = crate::app::mode::onboard_provider::PROVIDERS.len();
state.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 };
state.dirty = true;
Vec::new()
} else { } else {
vec![Action::ScrollUp] vec![Action::ScrollUp]
} }
} }
KeyCode::Down => { KeyCode::Down => {
if key.modifiers.contains(KeyModifiers::CONTROL) { if state.input.autocomplete_visible {
state.input.cycle_autocomplete(true);
state.dirty = true;
Vec::new()
} else if key.modifiers.contains(KeyModifiers::CONTROL) {
vec![Action::HistoryDown] vec![Action::HistoryDown]
} else if state.misc.overlay == Overlay::Effort { } else if state.misc.overlay == Overlay::Effort {
mode::effort::cycle_effort(state); mode::effort::cycle_effort(state);
@@ -120,6 +157,20 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
state.misc.selected_index = if n == 0 { 0 } else { (state.misc.selected_index + 1) % n }; state.misc.selected_index = if n == 0 { 0 } else { (state.misc.selected_index + 1) % n };
state.dirty = true; state.dirty = true;
Vec::new() Vec::new()
} else if state.misc.overlay == Overlay::ModelSelector {
let n = state.app_config.providers.len();
state.misc.selected_index = if n == 0 { 0 } else { (state.misc.selected_index + 1) % n };
state.dirty = true;
Vec::new()
} else if state.misc.overlay == Overlay::ModePicker {
state.misc.selected_index = (state.misc.selected_index + 1) % 4;
state.dirty = true;
Vec::new()
} else if state.misc.overlay == Overlay::LoginPicker {
let n = crate::app::mode::onboard_provider::PROVIDERS.len();
state.misc.selected_index = (state.misc.selected_index + 1) % n;
state.dirty = true;
Vec::new()
} else { } else {
vec![Action::ScrollDown] vec![Action::ScrollDown]
} }
@@ -158,7 +209,11 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
vec![Action::OpenOverlay(Overlay::KeyInput)] vec![Action::OpenOverlay(Overlay::KeyInput)]
} }
KeyCode::Esc => { KeyCode::Esc => {
if state.misc.overlay.is_active() { if state.input.autocomplete_visible {
state.input.close_autocomplete();
state.dirty = true;
Vec::new()
} else if state.misc.overlay.is_active() {
vec![Action::CloseOverlay] vec![Action::CloseOverlay]
} else { } else {
Vec::new() Vec::new()
@@ -166,7 +221,11 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
} }
KeyCode::Tab => { KeyCode::Tab => {
if state.input.buffer.starts_with('/') { if state.input.buffer.starts_with('/') {
if state.input.autocomplete_visible {
state.input.cycle_autocomplete(true);
} else {
state.input.tab_complete(); state.input.tab_complete();
}
state.dirty = true; state.dirty = true;
} }
Vec::new() Vec::new()
@@ -178,6 +237,10 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
vec![Action::OpenOverlay(Overlay::Usage)] vec![Action::OpenOverlay(Overlay::Usage)]
} }
KeyCode::Char(c) => { KeyCode::Char(c) => {
if state.input.autocomplete_visible {
state.input.close_autocomplete();
state.dirty = true;
}
vec![Action::InsertChar(c)] vec![Action::InsertChar(c)]
} }
_ => Vec::new(), _ => Vec::new(),
@@ -251,6 +314,49 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
mode::rewind::rewind_to(state, idx); mode::rewind::rewind_to(state, idx);
Vec::new() Vec::new()
} }
Overlay::ModelSelector => {
let providers: Vec<String> = state.app_config.providers.keys().cloned().collect();
if let Some(provider) = providers.get(state.misc.selected_index) {
if let Some(cfg) = state.app_config.providers.get(provider) {
let model = cfg.default_model.clone().unwrap_or_else(|| "claude-opus-4-8".to_string());
state.settings.provider = provider.clone();
state.settings.model = model.clone();
let _ = state.settings.save();
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Success,
format!("Switched to {} / {}", provider, model),
));
}
}
state.misc.overlay = Overlay::None;
state.dirty = true;
Vec::new()
}
Overlay::ClearConfirm => {
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Info,
"Transcript cleared".to_string(),
));
state.misc.overlay = Overlay::None;
state.dirty = true;
Vec::new()
}
Overlay::ModePicker => {
let mode = state.mode.cycle();
state.mode = mode;
state.push_toast(crate::app::state::types::Toast::new(
crate::app::state::types::ToastKind::Info,
format!("Mode: {}", mode.name()),
));
state.misc.overlay = Overlay::None;
state.dirty = true;
Vec::new()
}
Overlay::LoginPicker => {
state.misc.overlay = Overlay::OnboardProvider;
state.dirty = true;
Vec::new()
}
_ => Vec::new(), _ => Vec::new(),
} }
} }
+4
View File
@@ -230,6 +230,10 @@ fn apply_client_update(
Some("Learning") => Overlay::Learning, Some("Learning") => Overlay::Learning,
Some("Usage") => Overlay::Usage, Some("Usage") => Overlay::Usage,
Some("Loading") => Overlay::Loading, Some("Loading") => Overlay::Loading,
Some("ModelSelector") => Overlay::ModelSelector,
Some("ClearConfirm") => Overlay::ClearConfirm,
Some("ModePicker") => Overlay::ModePicker,
Some("LoginPicker") => Overlay::LoginPicker,
_ => Overlay::None, _ => Overlay::None,
}; };
+146
View File
@@ -558,10 +558,156 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ
.block(block); .block(block);
frame.render_widget(paragraph, overlay_area); frame.render_widget(paragraph, overlay_area);
} }
crate::app::state::types::Overlay::ModelSelector => {
let block = block.title(" Model Selector ");
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(
format!("Current: {} / {}", state.settings.provider, state.settings.model),
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled("", Style::default())),
Line::from(Span::styled("Providers:", Style::default().fg(Theme::DIM))),
];
let providers: Vec<(&String, &crate::model::app_config::ProviderConfig)> = state.app_config.providers.iter().collect();
for (i, (name, cfg)) in providers.iter().enumerate() {
let is_current = *name == &state.settings.provider;
let is_selected = i == state.misc.selected_index;
let prefix = if is_selected { "" } else { " " };
let model_str = cfg.default_model.as_deref().unwrap_or("(any)");
let label = format!("{}{} ({})", prefix, name, model_str);
let style = if is_current {
Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD)
} else if is_selected {
Style::default().fg(Theme::BG).bg(Theme::HIGHLIGHT)
} else {
Style::default().fg(Theme::TEXT)
};
lines.push(Line::from(Span::styled(label, style)));
}
lines.push(Line::from(Span::styled("", Style::default())));
lines.push(Line::from(Span::styled(
"↑↓ navigate · Enter select · Esc close · /model add <name> <url> to add",
Style::default().fg(Theme::DIM),
)));
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::ClearConfirm => {
let block = block.title(" Clear Transcript ");
let lines = vec![
Line::from(Span::styled(
"Clear all messages from the transcript?",
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled("", Style::default())),
Line::from(Span::styled(
"Enter to confirm · Esc to cancel",
Style::default().fg(Theme::DIM),
)),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::ModePicker => {
let block = block.title(" Mode ");
let modes = [
("Auto", "auto-approve tools"),
("Normal", "ask before risky tools"),
("Plan", "plan-only mode"),
("Yolo", "full auto"),
];
let current = state.mode.name();
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(
format!("Current: {}", current),
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled("", Style::default())),
];
for (i, (name, desc)) in modes.iter().enumerate() {
let is_selected = i == state.misc.selected_index;
let prefix = if is_selected { "" } else { " " };
let label = format!("{}{} ({})", prefix, name, desc);
let style = if *name == current {
Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD).add_modifier(Modifier::ITALIC)
} else if is_selected {
Style::default().fg(Theme::BG).bg(Theme::HIGHLIGHT)
} else {
Style::default().fg(Theme::TEXT)
};
lines.push(Line::from(Span::styled(label, style)));
}
lines.push(Line::from(Span::styled("", Style::default())));
lines.push(Line::from(Span::styled(
"↑↓ navigate · Enter select · Esc close",
Style::default().fg(Theme::DIM),
)));
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
crate::app::state::types::Overlay::LoginPicker => {
let block = block.title(" Login ");
let providers = crate::app::mode::onboard_provider::PROVIDERS;
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(
"Select a provider to log in:",
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled("", Style::default())),
];
for (i, p) in providers.iter().enumerate() {
let is_selected = i == state.misc.selected_index;
let prefix = if is_selected { "" } else { " " };
let style = if is_selected {
Style::default().fg(Theme::BG).bg(Theme::HIGHLIGHT)
} else {
Style::default().fg(Theme::TEXT)
};
lines.push(Line::from(Span::styled(format!("{}{}", prefix, p), style)));
}
lines.push(Line::from(Span::styled("", Style::default())));
lines.push(Line::from(Span::styled(
"↑↓ navigate · Enter select · Esc close",
Style::default().fg(Theme::DIM),
)));
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, overlay_area);
}
} }
} }
fn render_input_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { fn render_input_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
// Render autocomplete dropdown if visible
if state.input.autocomplete_visible && !state.input.autocomplete_candidates.is_empty() {
let n = state.input.autocomplete_candidates.len().min(10) as u16;
let dropdown_height = n + 2; // border + items
let dropdown_area = Rect {
x: area.x,
y: area.y.saturating_sub(dropdown_height),
width: area.width.min(40),
height: dropdown_height,
};
let dropdown_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER))
.title(" Commands ");
let mut lines: Vec<Line> = Vec::new();
let selected = state.input.autocomplete_idx;
for (i, candidate) in state.input.autocomplete_candidates.iter().enumerate().take(10) {
let prefix = if i == selected { "" } else { " " };
let style = if i == selected {
Style::default().fg(Theme::BG).bg(Theme::HIGHLIGHT)
} else {
Style::default().fg(Theme::TEXT)
};
let label = format!("{}{}", prefix, candidate);
lines.push(Line::from(Span::styled(label, style)));
}
let dropdown = Paragraph::new(lines).block(dropdown_block);
frame.render_widget(dropdown, dropdown_area);
}
let block = Block::default() let block = Block::default()
.borders(Borders::TOP) .borders(Borders::TOP)
.border_style(Style::default().fg(Theme::BORDER)); .border_style(Style::default().fg(Theme::BORDER));