Skip to content

Commit 942b944

Browse files
echobtfactorydroid
andauthored
fix(mcp): keep server configuration inline in MCP panel (#238)
Replace separate card/modal UI with inline form for Add MCP Server. Configuration now happens directly within the MCP Servers panel instead of opening a separate McpCard overlay. Changes: - Add InlineFormField and InlineFormState types for inline forms - Add form rendering support to InteractiveWidget - Add form input handling to interactive handlers - Add FormSubmitted result variant to InteractiveResult - Modify __add__ action to open inline form instead of McpCard - Add handle_inline_form_submission for processing form data This keeps the UX consistent - all MCP configuration stays in the same panel where servers are listed. Co-authored-by: Droid Agent <droid@factory.ai>
1 parent 62732c6 commit 942b944

File tree

7 files changed

+477
-34
lines changed

7 files changed

+477
-34
lines changed

cortex-tui/src/interactive/builders/mcp.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
//! Builder for MCP server selection.
22
3-
use crate::interactive::state::{InteractiveAction, InteractiveItem, InteractiveState};
3+
use crate::interactive::state::{
4+
InlineFormField, InlineFormState, InteractiveAction, InteractiveItem, InteractiveState,
5+
};
46
use crate::modal::mcp_manager::{McpServerInfo, McpStatus};
57

68
/// Build an interactive state for MCP server management.
@@ -74,6 +76,23 @@ pub fn build_mcp_selector(servers: &[McpServerInfo]) -> InteractiveState {
7476
])
7577
}
7678

79+
/// Build an inline form for adding a new MCP server.
80+
/// This form is displayed within the MCP panel, not as a separate modal.
81+
pub fn build_mcp_add_server_form() -> InlineFormState {
82+
InlineFormState::new("Add MCP Server", "mcp-add")
83+
.with_field(
84+
InlineFormField::new("name", "Name")
85+
.required()
86+
.with_placeholder("server-name"),
87+
)
88+
.with_field(
89+
InlineFormField::new("command", "Command")
90+
.required()
91+
.with_placeholder("npx, uvx, or path/to/binary"),
92+
)
93+
.with_field(InlineFormField::new("args", "Args").with_placeholder("arg1 arg2 ..."))
94+
}
95+
7796
/// Build an interactive state for MCP server actions (for a specific server).
7897
pub fn build_mcp_server_actions(server: &McpServerInfo) -> InteractiveState {
7998
let mut items = Vec::new();

cortex-tui/src/interactive/builders/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ pub use files::{build_context_list, build_context_remove, build_file_browser};
2828
pub use login::{
2929
LoginFlowState, LoginStatus, build_already_logged_in_selector, build_login_selector,
3030
};
31-
pub use mcp::build_mcp_selector;
31+
pub use mcp::{build_mcp_add_server_form, build_mcp_selector};
3232
pub use model::build_model_selector;
3333
// pub use provider::build_provider_selector; // REMOVED (single Cortex provider)
3434
pub use resume_picker::build_resume_picker;

cortex-tui/src/interactive/handlers.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@
22
33
use super::state::{InteractiveAction, InteractiveResult, InteractiveState};
44
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5+
use std::collections::HashMap;
56

67
/// Handle a key event in interactive mode.
78
///
89
/// Returns an `InteractiveResult` indicating what action to take.
910
pub fn handle_interactive_key(state: &mut InteractiveState, key: KeyEvent) -> InteractiveResult {
11+
// If inline form is active, handle form input
12+
if state.is_form_active() {
13+
return handle_form_key(state, key);
14+
}
15+
1016
match key.code {
1117
// Navigation
1218
KeyCode::Up | KeyCode::Char('k')
@@ -139,6 +145,72 @@ pub fn handle_interactive_key(state: &mut InteractiveState, key: KeyEvent) -> In
139145
}
140146
}
141147

148+
/// Handle key events when inline form is active.
149+
fn handle_form_key(state: &mut InteractiveState, key: KeyEvent) -> InteractiveResult {
150+
match key.code {
151+
// Submit form with Enter
152+
KeyCode::Enter => {
153+
if let Some(ref form) = state.inline_form {
154+
if form.is_valid() {
155+
let action_id = form.action_id.clone();
156+
let values: HashMap<String, String> = form
157+
.fields
158+
.iter()
159+
.map(|f| (f.name.clone(), f.value.clone()))
160+
.collect();
161+
state.close_form();
162+
return InteractiveResult::FormSubmitted { action_id, values };
163+
}
164+
}
165+
InteractiveResult::Continue
166+
}
167+
168+
// Cancel form with Esc
169+
KeyCode::Esc => {
170+
state.close_form();
171+
InteractiveResult::Continue
172+
}
173+
174+
// Navigate fields with Tab
175+
KeyCode::Tab => {
176+
if let Some(ref mut form) = state.inline_form {
177+
form.focus_next();
178+
}
179+
InteractiveResult::Continue
180+
}
181+
182+
// Navigate fields with Shift+Tab
183+
KeyCode::BackTab => {
184+
if let Some(ref mut form) = state.inline_form {
185+
form.focus_prev();
186+
}
187+
InteractiveResult::Continue
188+
}
189+
190+
// Type characters into focused field
191+
KeyCode::Char(c) => {
192+
if let Some(ref mut form) = state.inline_form {
193+
if let Some(ref mut field) = form.focused_mut() {
194+
field.value.push(c);
195+
}
196+
}
197+
InteractiveResult::Continue
198+
}
199+
200+
// Backspace
201+
KeyCode::Backspace => {
202+
if let Some(ref mut form) = state.inline_form {
203+
if let Some(ref mut field) = form.focused_mut() {
204+
field.value.pop();
205+
}
206+
}
207+
InteractiveResult::Continue
208+
}
209+
210+
_ => InteractiveResult::Continue,
211+
}
212+
}
213+
142214
/// Check if a character is a shortcut for any item.
143215
fn is_shortcut(state: &InteractiveState, c: char) -> bool {
144216
state.items.iter().any(|item| item.shortcut == Some(c))

cortex-tui/src/interactive/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,6 @@ pub mod state;
5454
pub use handlers::handle_interactive_key;
5555
pub use renderer::InteractiveWidget;
5656
pub use state::{
57-
InputMode, InteractiveAction, InteractiveItem, InteractiveResult, InteractiveState,
57+
InlineFormField, InlineFormState, InputMode, InteractiveAction, InteractiveItem,
58+
InteractiveResult, InteractiveState,
5859
};

cortex-tui/src/interactive/renderer.rs

Lines changed: 166 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
//! Renderer for interactive selection in the input area.
22
3-
use super::state::{InteractiveItem, InteractiveState};
4-
use crate::ui::consts::border;
5-
use cortex_core::style::{
6-
BORDER, CYAN_PRIMARY, SUCCESS, SURFACE_0, SURFACE_1, TEXT, TEXT_DIM, TEXT_MUTED, VOID, WARNING,
7-
};
3+
use super::state::{InlineFormState, InteractiveItem, InteractiveState};
4+
use cortex_core::style::{CYAN_PRIMARY, SUCCESS, SURFACE_1, TEXT, TEXT_DIM, TEXT_MUTED};
85
use ratatui::{
96
buffer::Buffer,
107
layout::{Constraint, Layout, Rect},
@@ -39,6 +36,16 @@ impl<'a> InteractiveWidget<'a> {
3936

4037
/// Calculate the required height for this widget.
4138
pub fn required_height(&self) -> u16 {
39+
// If inline form is active, calculate form height
40+
if let Some(ref form) = self.state.inline_form {
41+
let fields_count = form.fields.len() as u16;
42+
let header_height = 1; // Title
43+
let hints_height = 1;
44+
let border_height = 2;
45+
// Each field takes 2 lines (label + input)
46+
return (fields_count * 2) + header_height + hints_height + border_height;
47+
}
48+
4249
let items_count = self
4350
.state
4451
.filtered_indices
@@ -58,6 +65,12 @@ impl<'a> Widget for InteractiveWidget<'a> {
5865
// Clear the area first
5966
Clear.render(area, buf);
6067

68+
// If inline form is active, render the form instead
69+
if let Some(ref form) = self.state.inline_form {
70+
self.render_form(form, area, buf);
71+
return;
72+
}
73+
6174
// Draw border with rounded corners
6275
let block = Block::default()
6376
.borders(Borders::ALL)
@@ -297,6 +310,154 @@ impl<'a> InteractiveWidget<'a> {
297310
let hints_line = Line::from(spans);
298311
Paragraph::new(hints_line).render(area, buf);
299312
}
313+
314+
/// Render inline form for configuration within the panel.
315+
fn render_form(&self, form: &InlineFormState, area: Rect, buf: &mut Buffer) {
316+
// Draw border with form title
317+
let block = Block::default()
318+
.borders(Borders::ALL)
319+
.border_set(ROUNDED_BORDER)
320+
.border_style(Style::default().fg(CYAN_PRIMARY))
321+
.title(Span::styled(
322+
format!(" {} ", form.title),
323+
Style::default()
324+
.fg(CYAN_PRIMARY)
325+
.add_modifier(Modifier::BOLD),
326+
));
327+
328+
let inner = block.inner(area);
329+
block.render(area, buf);
330+
331+
if inner.height < 3 {
332+
return;
333+
}
334+
335+
// Calculate field layout: each field takes 1 line (label: value)
336+
let fields_count = form.fields.len();
337+
let mut constraints: Vec<Constraint> =
338+
form.fields.iter().map(|_| Constraint::Length(1)).collect();
339+
constraints.push(Constraint::Min(0)); // Spacer
340+
constraints.push(Constraint::Length(1)); // Hints
341+
342+
let chunks = Layout::vertical(constraints).split(inner);
343+
344+
// Render each field
345+
for (i, field) in form.fields.iter().enumerate() {
346+
if i >= chunks.len().saturating_sub(2) {
347+
break;
348+
}
349+
let field_area = chunks[i];
350+
let is_focused = i == form.focused_field;
351+
352+
self.render_form_field(field_area, buf, field, is_focused);
353+
}
354+
355+
// Render form hints
356+
let hints_area = chunks[fields_count + 1];
357+
self.render_form_hints(hints_area, buf);
358+
}
359+
360+
/// Render a single form field.
361+
fn render_form_field(
362+
&self,
363+
area: Rect,
364+
buf: &mut Buffer,
365+
field: &super::state::InlineFormField,
366+
is_focused: bool,
367+
) {
368+
let x = area.x + 1;
369+
370+
// Label
371+
let label_style = if is_focused {
372+
Style::default()
373+
.fg(CYAN_PRIMARY)
374+
.add_modifier(Modifier::BOLD)
375+
} else {
376+
Style::default().fg(TEXT_DIM)
377+
};
378+
379+
let required_marker = if field.required { "*" } else { "" };
380+
let label = format!("{}{}:", field.label, required_marker);
381+
buf.set_string(x, area.y, &label, label_style);
382+
383+
// Value or placeholder
384+
let value_x = x + label.len() as u16 + 1;
385+
let remaining_width = area.width.saturating_sub(value_x - area.x + 1);
386+
387+
if field.value.is_empty() && !is_focused {
388+
// Show placeholder
389+
let placeholder = if field.placeholder.len() > remaining_width as usize {
390+
format!("{}...", &field.placeholder[..remaining_width as usize - 3])
391+
} else {
392+
field.placeholder.clone()
393+
};
394+
buf.set_string(
395+
value_x,
396+
area.y,
397+
&placeholder,
398+
Style::default().fg(TEXT_MUTED),
399+
);
400+
} else {
401+
// Show value with cursor if focused
402+
let display_value = if field.value.len() > remaining_width as usize - 1 {
403+
format!(
404+
"...{}",
405+
&field.value[field.value.len() - (remaining_width as usize - 4)..]
406+
)
407+
} else {
408+
field.value.clone()
409+
};
410+
411+
let value_style = if is_focused {
412+
Style::default().fg(TEXT).bg(SURFACE_1)
413+
} else {
414+
Style::default().fg(TEXT)
415+
};
416+
417+
// Draw input background if focused
418+
if is_focused {
419+
for xi in value_x..(value_x + remaining_width) {
420+
buf[(xi, area.y)].set_bg(SURFACE_1);
421+
}
422+
}
423+
424+
buf.set_string(value_x, area.y, &display_value, value_style);
425+
426+
// Draw cursor
427+
if is_focused {
428+
let cursor_x = value_x + display_value.len() as u16;
429+
if cursor_x < area.x + area.width - 1 {
430+
buf[(cursor_x, area.y)].set_char('_');
431+
buf[(cursor_x, area.y)].set_fg(CYAN_PRIMARY);
432+
}
433+
}
434+
}
435+
}
436+
437+
/// Render hints for the form.
438+
fn render_form_hints(&self, area: Rect, buf: &mut Buffer) {
439+
let hints = vec![("Tab", "next"), ("Enter", "submit"), ("Esc", "cancel")];
440+
441+
let dark_green = Color::Rgb(0, 100, 0);
442+
443+
let mut spans = Vec::new();
444+
for (i, (key, action)) in hints.iter().enumerate() {
445+
if i > 0 {
446+
spans.push(Span::styled(" ", Style::default()));
447+
}
448+
spans.push(Span::styled(
449+
format!("[{}]", key),
450+
Style::default().fg(dark_green),
451+
));
452+
spans.push(Span::styled(
453+
format!(" {}", action),
454+
Style::default().fg(dark_green),
455+
));
456+
}
457+
458+
let hints_line = Line::from(spans);
459+
Paragraph::new(hints_line).render(area, buf);
460+
}
300461
}
301462

302463
#[cfg(test)]

0 commit comments

Comments
 (0)