feat: implement model management commands and overlays for enhanced user interaction
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ pub enum Overlay {
|
|||||||
Learning,
|
Learning,
|
||||||
Usage,
|
Usage,
|
||||||
Loading,
|
Loading,
|
||||||
|
ModelSelector,
|
||||||
|
ClearConfirm,
|
||||||
|
ModePicker,
|
||||||
|
LoginPicker,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Overlay {
|
impl Overlay {
|
||||||
|
|||||||
@@ -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
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user