feat(tui/model): restore /model selector and presets

This commit is contained in:
James Peter
2025-09-16 13:42:07 +10:00
parent 0509750ed3
commit 84fbdda104
12 changed files with 887 additions and 196 deletions
+3
View File
@@ -101,6 +101,9 @@ npm install -g @anthropic-ai/claude-code @google/gemini-cli && claude "Just chec
# Change reasoning level
/reasoning low|medium|high
# Switch models or effort presets
/model
# Start new conversation
/new
```
+10
View File
@@ -838,6 +838,11 @@ impl App<'_> {
widget.handle_mcp_command(command_args);
}
}
SlashCommand::Model => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_model_command(command_args);
}
}
SlashCommand::Reasoning => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_reasoning_command(command_args);
@@ -1013,6 +1018,11 @@ impl App<'_> {
widget.show_agent_editor_ui(name);
}
}
AppEvent::UpdateModelSelection { model, effort } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.apply_model_selection(model, effort);
}
}
AppEvent::UpdateReasoningEffort(new_effort) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_reasoning_effort(new_effort);
+6
View File
@@ -71,6 +71,12 @@ pub(crate) enum AppEvent {
/// Signal that agents are about to start (triggered when /plan, /solve, /code commands are entered)
PrepareAgents,
/// Update the model and optional reasoning effort preset
UpdateModelSelection {
model: String,
effort: Option<ReasoningEffort>,
},
/// Update the reasoning effort level
UpdateReasoningEffort(ReasoningEffort),
+269 -138
View File
@@ -1,6 +1,6 @@
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Rect, Margin};
use ratatui::layout::{Alignment, Margin, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
@@ -12,6 +12,17 @@ use super::bottom_pane_view::BottomPaneView;
use super::form_text_field::FormTextField;
use super::BottomPane;
#[derive(Debug)]
struct AgentEditorLayout {
lines: Vec<Line<'static>>,
ro_offset: u16,
wr_offset: u16,
instr_offset: u16,
ro_height: u16,
wr_height: u16,
instr_height: u16,
}
#[derive(Debug)]
pub(crate) struct AgentEditorView {
name: String,
@@ -27,6 +38,22 @@ pub(crate) struct AgentEditorView {
}
impl AgentEditorView {
fn clear_rect(buf: &mut Buffer, rect: Rect) {
if rect.width == 0 || rect.height == 0 {
return;
}
let style = Style::default()
.bg(crate::colors::background())
.fg(crate::colors::text());
for y in rect.y..rect.y.saturating_add(rect.height) {
for x in rect.x..rect.x.saturating_add(rect.width) {
let cell = &mut buf[(x, y)];
cell.set_symbol(" ");
cell.set_style(style);
}
}
}
pub fn new(
name: String,
enabled: bool,
@@ -95,6 +122,172 @@ impl AgentEditorView {
v
}
fn layout(&self, content_width: u16, max_height: Option<u16>) -> AgentEditorLayout {
let single_box_h: u16 = 3;
let instr_inner_width = content_width.saturating_sub(4);
let desired_instr_inner = self.instr.desired_height(instr_inner_width).min(8);
let mut instr_box_h = desired_instr_inner.saturating_add(2);
let top_block: u16 = 3; // blank, title, blank
let enabled_block: u16 = 2; // row + spacer
let ro_label: u16 = 1;
let ro_spacer: u16 = 1;
let wr_label: u16 = 1;
let wr_spacer: u16 = 1;
let instr_label: u16 = 1;
let spacer_before_buttons: u16 = 1;
let buttons_block: u16 = 1;
let footer_lines_default: u16 = 2;
let base_fixed_top = top_block
+ enabled_block
+ ro_label
+ single_box_h
+ ro_spacer
+ wr_label
+ single_box_h
+ wr_spacer
+ instr_label;
let mut footer_lines = footer_lines_default;
let mut spacer_after_instr = spacer_before_buttons;
if let Some(height) = max_height {
let mut fixed_after_box = spacer_before_buttons + buttons_block + footer_lines;
if base_fixed_top.saturating_add(instr_box_h).saturating_add(fixed_after_box) > height {
footer_lines = 0;
}
fixed_after_box = spacer_before_buttons + buttons_block + footer_lines;
if base_fixed_top.saturating_add(instr_box_h).saturating_add(fixed_after_box) > height {
let min_ih: u16 = 3;
let available_for_box = height
.saturating_sub(base_fixed_top)
.saturating_sub(fixed_after_box);
instr_box_h = instr_box_h.min(available_for_box).max(min_ih);
}
fixed_after_box = spacer_before_buttons + buttons_block + footer_lines;
if base_fixed_top.saturating_add(instr_box_h).saturating_add(fixed_after_box) > height {
spacer_after_instr = 0;
}
}
let sel = |idx: usize| {
if self.field == idx {
Style::default()
.bg(crate::colors::selection())
.add_modifier(Modifier::BOLD)
} else {
Style::default()
}
};
let label = |idx: usize| {
if self.field == idx {
Style::default()
.fg(crate::colors::primary())
.add_modifier(Modifier::BOLD)
} else {
Style::default()
}
};
let mut lines: Vec<Line<'static>> = Vec::new();
let mut cursor: u16 = 0;
// Top spacer, title, spacer
lines.push(Line::from(""));
cursor = cursor.saturating_add(1);
lines.push(Line::from(Span::styled(
format!("Agents » Edit Agent » {}", self.name),
Style::default().add_modifier(Modifier::BOLD),
)));
cursor = cursor.saturating_add(1);
lines.push(Line::from(""));
cursor = cursor.saturating_add(1);
// Enabled toggle + spacer
let chk = if self.enabled { "[on ]" } else { "[off]" };
lines.push(Line::from(vec![
Span::styled("Enabled:", label(0)),
Span::raw(" "),
Span::styled(chk, sel(0)),
]));
cursor = cursor.saturating_add(1);
lines.push(Line::from(""));
cursor = cursor.saturating_add(1);
// Read-only params label and box
lines.push(Line::from(Span::styled("Read-only Params", label(1))));
cursor = cursor.saturating_add(1);
let ro_offset = cursor;
for _ in 0..single_box_h {
lines.push(Line::from(""));
cursor = cursor.saturating_add(1);
}
lines.push(Line::from(""));
cursor = cursor.saturating_add(1);
// Write params label and box
lines.push(Line::from(Span::styled("Write Params", label(2))));
cursor = cursor.saturating_add(1);
let wr_offset = cursor;
for _ in 0..single_box_h {
lines.push(Line::from(""));
cursor = cursor.saturating_add(1);
}
lines.push(Line::from(""));
cursor = cursor.saturating_add(1);
// Instructions label and box
lines.push(Line::from(Span::styled("Instructions", label(3))));
cursor = cursor.saturating_add(1);
let instr_offset = cursor;
for _ in 0..instr_box_h {
lines.push(Line::from(""));
cursor = cursor.saturating_add(1);
}
if spacer_after_instr > 0 {
lines.push(Line::from(""));
cursor = cursor.saturating_add(1);
}
// Buttons row
let save_style = sel(4).fg(crate::colors::success());
let cancel_style = sel(5).fg(crate::colors::text());
lines.push(Line::from(vec![
Span::styled("[ Save ]", save_style),
Span::raw(" "),
Span::styled("[ Cancel ]", cancel_style),
]));
cursor = cursor.saturating_add(1);
if footer_lines > 0 {
lines.push(Line::from(""));
cursor = cursor.saturating_add(1);
lines.push(Line::from(vec![
Span::styled("↑↓", Style::default().fg(crate::colors::function())),
Span::styled(" Navigate ", Style::default().fg(crate::colors::text_dim())),
Span::styled("Enter", Style::default().fg(crate::colors::success())),
Span::styled(" Save/Close ", Style::default().fg(crate::colors::text_dim())),
Span::styled("Esc", Style::default().fg(crate::colors::error())),
Span::styled(" Cancel", Style::default().fg(crate::colors::text_dim())),
]));
cursor = cursor.saturating_add(1);
}
debug_assert_eq!(cursor as usize, lines.len());
AgentEditorLayout {
lines,
ro_offset,
wr_offset,
instr_offset,
ro_height: single_box_h,
wr_height: single_box_h,
instr_height: instr_box_h,
}
}
}
impl<'a> BottomPaneView<'a> for AgentEditorView {
@@ -140,35 +333,9 @@ impl<'a> BottomPaneView<'a> for AgentEditorView {
fn is_complete(&self) -> bool { self.complete }
fn desired_height(&self, width: u16) -> u16 {
// Match the layout math used in the Command editor so buttons never clip.
// inner(width) = width-2 (borders); content = inner-1 (left pad used by Paragraph)
let inner_w = width.saturating_sub(2);
let content_w = inner_w.saturating_sub(1).max(10);
// Single‑line input boxes are 3 rows each (inner 1 + borders 2)
let single_box_h: u16 = 3;
// Instructions box: compute desired inner height from the field, cap to 8 rows visible
let instr_inner_w = content_w.saturating_sub(4); // borders(2) + padding(2)
let desired_instr_inner = self.instr.desired_height(instr_inner_w);
let instr_box_h = desired_instr_inner.min(8).saturating_add(2);
// Total content rows including consistent spacing above/below each section
let content_rows: u16 = 1 // top spacer
+ 1 // title
+ 1 // spacer after title
+ 1 // Enabled row
+ 1 // spacer
+ 1 // RO label
+ single_box_h
+ 1 // spacer
+ 1 // WR label
+ single_box_h
+ 1 // spacer
+ 1 // Instructions label
+ instr_box_h
+ 1 // spacer
+ 1 // buttons row
+ 1; // bottom spacer
// Add the outer block borders (already counted by area height), but clamp to a sane range
content_rows.clamp(12, 60)
let content_width = width.saturating_sub(4).max(1);
let layout = self.layout(content_width, None);
layout.lines.len().saturating_add(2) as u16
}
fn render(&self, area: Rect, buf: &mut Buffer) {
@@ -184,63 +351,8 @@ impl<'a> BottomPaneView<'a> for AgentEditorView {
let content = Rect { x: inner.x.saturating_add(1), y: inner.y, width: inner.width.saturating_sub(2), height: inner.height };
let sel = |idx: usize| if self.field == idx { Style::default().bg(crate::colors::selection()).add_modifier(Modifier::BOLD) } else { Style::default() };
let label = |idx: usize| if self.field == idx { Style::default().fg(crate::colors::primary()).add_modifier(Modifier::BOLD) } else { Style::default() };
let mut lines: Vec<Line<'static>> = Vec::new();
// Compute a responsive layout that guarantees the buttons are visible
// even on small terminals (e.g., 80x24). We cap the instructions box
// height to whatever fits after accounting for all fixed rows.
let top_block: u16 = 3; // spacer + title + spacer
let enabled_block: u16 = 2; // row + spacer
let ro_label: u16 = 1;
let ro_box: u16 = 3; // single-line input box
let ro_spacer: u16 = 1;
let wr_label: u16 = 1;
let wr_box: u16 = 3;
let wr_spacer: u16 = 1;
let instr_label: u16 = 1;
let spacer_before_buttons: u16 = 1;
let buttons_block: u16 = 1;
// Footer (optional): blank + key hints; drop if there isn't room.
let footer_lines_default: u16 = 2;
// Base rows up to (but not including) the instructions box
let base_fixed_top = top_block + enabled_block + ro_label + ro_box + ro_spacer + wr_label + wr_box + wr_spacer + instr_label;
// Desired instructions inner height (without borders)
let instr_inner_width = content.width.saturating_sub(4);
let desired_instr_inner = self.instr.desired_height(instr_inner_width).min(8);
let desired_instr_box_h = desired_instr_inner.saturating_add(2); // add borders
// Compute how many rows remain and choose the largest box height that keeps buttons visible.
let mut footer_lines = footer_lines_default;
let mut ih = desired_instr_box_h;
let fixed_after_box = spacer_before_buttons + buttons_block + footer_lines;
if base_fixed_top.saturating_add(ih).saturating_add(fixed_after_box) > content.height {
// First, try dropping footer entirely
footer_lines = 0;
}
let fixed_after_box = spacer_before_buttons + buttons_block + footer_lines;
if base_fixed_top.saturating_add(ih).saturating_add(fixed_after_box) > content.height {
// Reduce the instructions box height as needed, with a minimum of 3 (borders + 1 line)
let min_ih: u16 = 3;
let available_for_box = content
.height
.saturating_sub(base_fixed_top)
.saturating_sub(fixed_after_box);
ih = ih.min(available_for_box).max(min_ih);
}
// As a last resort, if even min layout doesn't fit, clamp ih and spacer to keep buttons visible.
let mut spacer_before_buttons_actual = spacer_before_buttons;
if base_fixed_top.saturating_add(ih).saturating_add(spacer_before_buttons_actual + buttons_block + footer_lines) > content.height {
spacer_before_buttons_actual = 0;
}
// Top spacer then bold breadcrumb‑style title like the command editor
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(format!("Agents » Edit Agent » {}", self.name), Style::default().add_modifier(Modifier::BOLD))));
lines.push(Line::from(""));
if !self.installed {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(Span::styled("Not installed", Style::default().fg(crate::colors::warning()).add_modifier(Modifier::BOLD))));
lines.push(Line::from(Span::styled(self.install_hint.clone(), Style::default().fg(crate::colors::text_dim()))));
lines.push(Line::from(""));
@@ -261,41 +373,8 @@ impl<'a> BottomPaneView<'a> for AgentEditorView {
return;
}
// Enabled toggle
let chk = if self.enabled { "[on ]" } else { "[off]" };
lines.push(Line::from(vec![Span::styled("Enabled:", label(0)), Span::raw(" "), Span::styled(chk, sel(0))]));
lines.push(Line::from(""));
// Reserve single-line boxes for params (3 rows each) with one blank spacer between groups
let single_box_h: u16 = 3;
lines.push(Line::from(Span::styled("Read-only Params", label(1))));
for _ in 0..single_box_h { lines.push(Line::from("")); }
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("Write Params", label(2))));
for _ in 0..single_box_h { lines.push(Line::from("")); }
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("Instructions", label(3))));
// Reserve lines for the instructions box based on computed `ih`
for _ in 0..ih { lines.push(Line::from("")); }
if spacer_before_buttons_actual > 0 { lines.push(Line::from("")); }
// Buttons
let save_style = sel(4).fg(crate::colors::success());
let cancel_style = sel(5).fg(crate::colors::text());
lines.push(Line::from(vec![Span::styled("[ Save ]", save_style), Span::raw(" "), Span::styled("[ Cancel ]", cancel_style)]));
// Footer (optional, only when room exists)
if footer_lines > 0 {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("↑↓", Style::default().fg(crate::colors::function())),
Span::styled(" Navigate ", Style::default().fg(crate::colors::text_dim())),
Span::styled("Enter", Style::default().fg(crate::colors::success())),
Span::styled(" Save/Close ", Style::default().fg(crate::colors::text_dim())),
Span::styled("Esc", Style::default().fg(crate::colors::error())),
Span::styled(" Cancel", Style::default().fg(crate::colors::text_dim())),
]));
}
let layout = self.layout(content.width, Some(content.height));
let AgentEditorLayout { lines, ro_offset, wr_offset, instr_offset, ro_height, wr_height, instr_height } = layout;
Paragraph::new(lines)
.alignment(Alignment::Left)
@@ -304,42 +383,94 @@ impl<'a> BottomPaneView<'a> for AgentEditorView {
.render(content, buf);
// Draw input boxes at the same y offsets we reserved above
let mut y = content.y; // start at top of content
// Skip top spacer + title + spacer
y = y.saturating_add(top_block);
// Enabled row + spacer
y = y.saturating_add(enabled_block);
// RO params box (3 rows)
let ro_rect = Rect { x: content.x, y, width: content.width, height: single_box_h };
let ro_rect = Rect { x: content.x, y: content.y.saturating_add(ro_offset), width: content.width, height: ro_height };
let ro_block = Block::default()
.borders(Borders::ALL)
.title(Line::from(" Read-only Params "))
.border_style(if self.field == 1 { Style::default().fg(crate::colors::primary()).add_modifier(Modifier::BOLD) } else { Style::default().fg(crate::colors::border()) });
let ro_inner = ro_block.inner(ro_rect).inner(Margin::new(1, 0));
let ro_inner_rect = ro_block.inner(ro_rect);
let ro_inner = ro_inner_rect.inner(Margin::new(1, 0));
ro_block.render(ro_rect, buf);
Self::clear_rect(buf, ro_inner_rect);
self.params_ro.render(ro_inner, buf, self.field == 1);
// After RO box + spacer
y = y.saturating_add(single_box_h + 1);
// WR params box (3 rows)
let wr_rect = Rect { x: content.x, y, width: content.width, height: single_box_h };
let wr_rect = Rect { x: content.x, y: content.y.saturating_add(wr_offset), width: content.width, height: wr_height };
let wr_block = Block::default()
.borders(Borders::ALL)
.title(Line::from(" Write Params "))
.border_style(if self.field == 2 { Style::default().fg(crate::colors::primary()).add_modifier(Modifier::BOLD) } else { Style::default().fg(crate::colors::border()) });
let wr_inner = wr_block.inner(wr_rect).inner(Margin::new(1, 0));
let wr_inner_rect = wr_block.inner(wr_rect);
let wr_inner = wr_inner_rect.inner(Margin::new(1, 0));
wr_block.render(wr_rect, buf);
Self::clear_rect(buf, wr_inner_rect);
self.params_wr.render(wr_inner, buf, self.field == 2);
// After WR box + spacer
y = y.saturating_add(single_box_h + 1);
// Instructions (multi-line; height consistent with reserved space above)
let instr_rect = Rect { x: content.x, y, width: content.width, height: ih };
let instr_rect = Rect { x: content.x, y: content.y.saturating_add(instr_offset), width: content.width, height: instr_height };
let instr_block = Block::default().borders(Borders::ALL).border_style(if self.field == 3 { Style::default().fg(crate::colors::primary()).add_modifier(Modifier::BOLD) } else { Style::default().fg(crate::colors::border()) });
let instr_block = instr_block.title(Line::from(" Instructions "));
let instr_inner = instr_block.inner(instr_rect).inner(Margin::new(1, 0));
let instr_inner_rect = instr_block.inner(instr_rect);
let instr_inner = instr_inner_rect.inner(Margin::new(1, 0));
instr_block.render(instr_rect, buf);
Self::clear_rect(buf, instr_inner_rect);
self.instr.render(instr_inner, buf, self.field == 3);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{buffer::Buffer, layout::Rect};
use std::sync::mpsc::channel;
#[test]
fn write_params_margin_stays_clear() {
let (high_tx, _high_rx) = channel();
let (bulk_tx, _bulk_rx) = channel();
let sender = AppEventSender::new_dual(high_tx, bulk_tx);
let view = AgentEditorView::new(
"claude".into(),
true,
None,
Some(vec!["--dangerously-skip-permissions".into()]),
None,
"claude".into(),
sender,
);
let area = Rect { x: 0, y: 0, width: 80, height: 24 };
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let preferred_layout = view.layout(area.width.saturating_sub(4), None);
assert_eq!(view.desired_height(area.width), preferred_layout.lines.len().saturating_add(2) as u16);
let spacer_idx = (preferred_layout.instr_offset + preferred_layout.instr_height) as usize;
assert!(preferred_layout.lines[spacer_idx].spans.iter().all(|span| span.content.trim().is_empty()));
let buttons_idx = spacer_idx + 1;
let buttons_line = &preferred_layout.lines[buttons_idx];
let buttons_text: String = buttons_line
.spans
.iter()
.map(|span| span.content.clone().into_owned())
.collect();
assert!(buttons_text.contains("[ Save ]"));
let mut found = false;
for y in area.y..area.y + area.height {
let mut row = String::with_capacity(area.width as usize);
for x in area.x..area.x + area.width {
row.push_str(buf[(x, y)].symbol());
}
if let Some(idx) = row.find("--dangerously-skip-permissions") {
assert!(idx > 0, "text unexpectedly starts at column 0");
let prev = row[..idx].chars().last();
assert_eq!(prev, Some(' '));
found = true;
break;
}
}
assert!(found, "write params text not found in rendered buffer");
}
}
@@ -30,6 +30,75 @@ impl AgentsOverviewView {
}
fn total_rows(&self) -> usize { self.agents.len().saturating_add(self.commands.len()).saturating_add(1) /* Add new… */ }
fn build_lines(&self) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
// Agents section
lines.push(Line::from(Span::styled("Agents", Style::default().add_modifier(Modifier::BOLD))));
for (i, (name, enabled, installed, _cmd)) in self.agents.iter().enumerate() {
let sel = i == self.selected;
let dot_style = if *enabled && *installed { Style::default().fg(crate::colors::success()) } else { Style::default().fg(crate::colors::text()) };
let name_style = if sel { Style::default().fg(crate::colors::primary()).add_modifier(Modifier::BOLD) } else { Style::default() };
let mut spans = vec![
Span::styled(if sel { "› " } else { " " }, if sel { Style::default().fg(crate::colors::primary()) } else { Style::default() }),
Span::styled("•", dot_style),
Span::raw(" "),
Span::styled(name.clone(), name_style),
];
if sel {
spans.push(Span::raw(" "));
spans.push(Span::styled("(press Enter to configure)", Style::default().fg(crate::colors::text_dim())));
}
lines.push(Line::from(spans));
}
// Spacer between sections (always a single blank row)
lines.push(Line::from(""));
// Commands section
lines.push(Line::from(Span::styled("Commands", Style::default().add_modifier(Modifier::BOLD))));
for (j, cmd) in self.commands.iter().enumerate() {
let idx = self.agents.len() + j;
let sel = idx == self.selected;
let name_style = if sel { Style::default().fg(crate::colors::primary()).add_modifier(Modifier::BOLD) } else { Style::default() };
let mut spans = vec![
Span::styled(if sel { "› " } else { " " }, if sel { Style::default().fg(crate::colors::primary()) } else { Style::default() }),
Span::styled(format!("/{}", cmd), name_style),
];
if sel {
spans.push(Span::raw(" "));
spans.push(Span::styled("(press Enter to configure)", Style::default().fg(crate::colors::text_dim())));
}
lines.push(Line::from(spans));
}
// Add new… row
let add_idx = self.agents.len() + self.commands.len();
let add_sel = add_idx == self.selected;
let mut add_spans = vec![
Span::styled(if add_sel { "› " } else { " " }, if add_sel { Style::default().fg(crate::colors::primary()) } else { Style::default() }),
Span::styled("Add new…", if add_sel { Style::default().fg(crate::colors::primary()).add_modifier(Modifier::BOLD) } else { Style::default() }),
];
if add_sel {
add_spans.push(Span::raw(" "));
add_spans.push(Span::styled("(press Enter to add)", Style::default().fg(crate::colors::text_dim())));
}
lines.push(Line::from(add_spans));
// Footer with key hints (prefixed by a single blank line)
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("↑↓", Style::default().fg(crate::colors::function())),
Span::styled(" Navigate ", Style::default().fg(crate::colors::text_dim())),
Span::styled("Enter", Style::default().fg(crate::colors::success())),
Span::styled(" Configure ", Style::default().fg(crate::colors::text_dim())),
Span::styled("Esc", Style::default().fg(crate::colors::error())),
Span::styled(" Close", Style::default().fg(crate::colors::text_dim())),
]));
lines
}
}
impl<'a> BottomPaneView<'a> for AgentsOverviewView {
@@ -71,7 +140,10 @@ impl<'a> BottomPaneView<'a> for AgentsOverviewView {
fn is_complete(&self) -> bool { self.is_complete }
fn desired_height(&self, _width: u16) -> u16 { 18 }
fn desired_height(&self, _width: u16) -> u16 {
let lines = self.build_lines();
lines.len().saturating_add(2) as u16
}
fn render(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
@@ -84,62 +156,7 @@ impl<'a> BottomPaneView<'a> for AgentsOverviewView {
let inner = block.inner(area);
block.render(area, buf);
let mut lines: Vec<Line<'static>> = Vec::new();
// Agents section
lines.push(Line::from(Span::styled("Agents", Style::default().add_modifier(Modifier::BOLD))));
for (i, (name, enabled, installed, _cmd)) in self.agents.iter().enumerate() {
let sel = i == self.selected;
// Active = enabled and installed. Show green when truly runnable, otherwise dim.
let dot = "•";
let dot_style = if *enabled && *installed { Style::default().fg(crate::colors::success()) } else { Style::default().fg(crate::colors::text()) };
let name_style = if sel { Style::default().fg(crate::colors::primary()).add_modifier(Modifier::BOLD) } else { Style::default() };
let mut spans = vec![
Span::styled(if sel { "› " } else { " " }, if sel { Style::default().fg(crate::colors::primary()) } else { Style::default() }),
Span::styled(dot, dot_style),
Span::raw(" "),
Span::styled(name.clone(), name_style),
];
if sel {
spans.push(Span::raw(" "));
spans.push(Span::styled("(press Enter to configure)", Style::default().fg(crate::colors::text_dim())));
}
lines.push(Line::from(spans));
}
lines.push(Line::from(""));
// Commands section
lines.push(Line::from(Span::styled("Commands", Style::default().add_modifier(Modifier::BOLD))));
for (j, cmd) in self.commands.iter().enumerate() {
let idx = self.agents.len() + j;
let sel = idx == self.selected;
let name_style = if sel { Style::default().fg(crate::colors::primary()).add_modifier(Modifier::BOLD) } else { Style::default() };
let mut spans = vec![
Span::styled(if sel { "› " } else { " " }, if sel { Style::default().fg(crate::colors::primary()) } else { Style::default() }),
Span::styled(format!("/{}", cmd), name_style),
];
if sel { spans.push(Span::raw(" ")); spans.push(Span::styled("(press Enter to configure)", Style::default().fg(crate::colors::text_dim()))); }
lines.push(Line::from(spans));
}
// Add new… row
let add_idx = self.agents.len() + self.commands.len();
let add_sel = add_idx == self.selected;
let mut add_spans = vec![
Span::styled(if add_sel { "› " } else { " " }, if add_sel { Style::default().fg(crate::colors::primary()) } else { Style::default() }),
Span::styled("Add new…", if add_sel { Style::default().fg(crate::colors::primary()).add_modifier(Modifier::BOLD) } else { Style::default() }),
];
if add_sel { add_spans.push(Span::raw(" ")); add_spans.push(Span::styled("(press Enter to add)", Style::default().fg(crate::colors::text_dim()))); }
lines.push(Line::from(add_spans));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("↑↓", Style::default().fg(crate::colors::function())),
Span::styled(" Navigate ", Style::default().fg(crate::colors::text_dim())),
Span::styled("Enter", Style::default().fg(crate::colors::success())),
Span::styled(" Configure ", Style::default().fg(crate::colors::text_dim())),
Span::styled("Esc", Style::default().fg(crate::colors::error())),
Span::styled(" Close", Style::default().fg(crate::colors::text_dim())),
]));
let lines = self.build_lines();
Paragraph::new(lines)
.alignment(Alignment::Left)
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()))
+17
View File
@@ -26,6 +26,7 @@ mod popup_consts;
mod agent_editor_view;
mod agents_overview_view;
mod reasoning_selection_view;
mod model_selection_view;
mod scroll_state;
mod selection_popup_common;
pub mod list_selection_view;
@@ -50,10 +51,12 @@ pub(crate) use chat_composer::InputResult;
use codex_core::protocol::Op;
use approval_modal_view::ApprovalModalView;
use codex_common::model_presets::ModelPreset;
use codex_core::config_types::ReasoningEffort;
use codex_core::config_types::TextVerbosity;
use codex_core::config_types::ThemeName;
use reasoning_selection_view::ReasoningSelectionView;
use model_selection_view::ModelSelectionView;
use theme_selection_view::ThemeSelectionView;
use verbosity_selection_view::VerbositySelectionView;
@@ -447,6 +450,20 @@ impl BottomPane<'_> {
self.request_redraw()
}
/// Show the model selection UI
pub fn show_model_selection(
&mut self,
presets: Vec<ModelPreset>,
current_model: String,
current_effort: ReasoningEffort,
) {
let view = ModelSelectionView::new(presets, current_model, current_effort, self.app_event_tx.clone());
self.active_view = Some(Box::new(view));
// Status shown in composer title now
self.status_view_active = false;
self.request_redraw()
}
/// Show the reasoning selection UI
pub fn show_reasoning_selection(&mut self, current_effort: ReasoningEffort) {
let view = ReasoningSelectionView::new(current_effort, self.app_event_tx.clone());
@@ -0,0 +1,287 @@
use codex_common::model_presets::ModelPreset;
use codex_core::config_types::ReasoningEffort;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use super::bottom_pane_view::BottomPaneView;
use super::BottomPane;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Alignment;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Borders;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
pub(crate) struct ModelSelectionView {
presets: Vec<ModelPreset>,
selected_index: usize,
current_model: String,
current_effort: ReasoningEffort,
app_event_tx: AppEventSender,
is_complete: bool,
}
impl ModelSelectionView {
pub fn new(
presets: Vec<ModelPreset>,
current_model: String,
current_effort: ReasoningEffort,
app_event_tx: AppEventSender,
) -> Self {
let initial_index = Self::initial_selection(&presets, &current_model, current_effort);
Self {
presets,
selected_index: initial_index,
current_model,
current_effort,
app_event_tx,
is_complete: false,
}
}
fn initial_selection(
presets: &[ModelPreset],
current_model: &str,
current_effort: ReasoningEffort,
) -> usize {
// Prefer an exact match on model + effort, fall back to first model match, then first entry.
if let Some((idx, _)) = presets.iter().enumerate().find(|(_, preset)| {
preset.model.eq_ignore_ascii_case(current_model)
&& Self::preset_effort(preset) == current_effort
}) {
return idx;
}
if let Some((idx, _)) = presets
.iter()
.enumerate()
.find(|(_, preset)| preset.model.eq_ignore_ascii_case(current_model))
{
return idx;
}
0
}
fn preset_effort(preset: &ModelPreset) -> ReasoningEffort {
preset
.effort
.map(ReasoningEffort::from)
.unwrap_or(ReasoningEffort::Medium)
}
fn format_model_header(model: &str) -> String {
if let Some((prefix, rest)) = model.split_once('-') {
format!("{}-{}", prefix.to_ascii_uppercase(), rest)
} else {
let mut chars = model.chars();
match chars.next() {
Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
None => String::new(),
}
}
}
fn move_selection_up(&mut self) {
if self.presets.is_empty() {
return;
}
self.selected_index = if self.selected_index == 0 {
self.presets.len() - 1
} else {
self.selected_index - 1
};
}
fn move_selection_down(&mut self) {
if self.presets.is_empty() {
return;
}
self.selected_index = (self.selected_index + 1) % self.presets.len();
}
fn confirm_selection(&mut self) {
if let Some(preset) = self.presets.get(self.selected_index) {
let effort = Self::preset_effort(preset);
let _ = self.app_event_tx.send(AppEvent::UpdateModelSelection {
model: preset.model.to_string(),
effort: Some(effort),
});
}
self.is_complete = true;
}
}
impl<'a> BottomPaneView<'a> for ModelSelectionView {
fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Up,
modifiers: KeyModifiers::NONE,
..
} => {
self.move_selection_up();
}
KeyEvent {
code: KeyCode::Down,
modifiers: KeyModifiers::NONE,
..
} => {
self.move_selection_down();
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
self.confirm_selection();
}
KeyEvent {
code: KeyCode::Esc,
modifiers: KeyModifiers::NONE,
..
} => {
self.is_complete = true;
}
_ => {}
}
}
fn is_complete(&self) -> bool {
self.is_complete
}
fn desired_height(&self, _width: u16) -> u16 {
// Title + current selection + spacing + presets + footer
(self.presets.len() as u16 + 6).max(9)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::border()))
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()))
.title(" Select Model & Reasoning ")
.title_alignment(Alignment::Center);
let inner_area = block.inner(area);
block.render(area, buf);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(vec![
Span::styled("Current model: ", Style::default().fg(crate::colors::text_dim())),
Span::styled(
self.current_model.clone(),
Style::default()
.fg(crate::colors::warning())
.add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(vec![
Span::styled("Reasoning effort: ", Style::default().fg(crate::colors::text_dim())),
Span::styled(
format!("{}", self.current_effort),
Style::default()
.fg(crate::colors::warning())
.add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(""));
let mut previous_model: Option<&str> = None;
for (idx, preset) in self.presets.iter().enumerate() {
if previous_model
.map(|m| !m.eq_ignore_ascii_case(&preset.model))
.unwrap_or(true)
{
if !lines.is_empty() {
lines.push(Line::from(""));
}
lines.push(Line::from(vec![Span::styled(
Self::format_model_header(&preset.model),
Style::default()
.fg(crate::colors::text_bright())
.add_modifier(Modifier::BOLD),
)]));
previous_model = Some(&preset.model);
}
let is_selected = idx == self.selected_index;
let preset_effort = Self::preset_effort(preset);
let is_current = preset.model.eq_ignore_ascii_case(&self.current_model)
&& preset_effort == self.current_effort;
let is_default_effort = preset.effort.is_none();
let mut style = Style::default().fg(crate::colors::text());
if is_selected {
style = style
.bg(crate::colors::selection())
.add_modifier(Modifier::BOLD);
}
if is_current {
style = style.fg(crate::colors::success());
}
let prefix = if is_selected { "› " } else { " " };
let mut spans = vec![
Span::raw(" "),
Span::raw(prefix),
Span::styled(preset.label.to_string(), style),
];
let detail = if is_default_effort {
format!("({} · default)", preset_effort)
} else {
format!("({})", preset_effort)
};
spans.push(Span::raw(" "));
spans.push(Span::styled(
detail,
Style::default().fg(crate::colors::text_dim()),
));
if !preset.description.is_empty() {
spans.push(Span::raw(" "));
spans.push(Span::styled(
preset.description,
Style::default().fg(crate::colors::dim()),
));
}
lines.push(Line::from(spans));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("↑↓", Style::default().fg(crate::colors::light_blue())),
Span::raw(" Navigate "),
Span::styled("Enter", Style::default().fg(crate::colors::success())),
Span::raw(" Select "),
Span::styled("Esc", Style::default().fg(crate::colors::error())),
Span::raw(" Cancel"),
]));
let padded = Rect {
x: inner_area.x.saturating_add(1),
y: inner_area.y,
width: inner_area.width.saturating_sub(1),
height: inner_area.height,
};
let paragraph = Paragraph::new(lines)
.alignment(Alignment::Left)
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()));
paragraph.render(padded, buf);
}
}
+183
View File
@@ -11,12 +11,15 @@ use std::sync::atomic::Ordering;
use ratatui::style::Modifier;
use ratatui::style::Style;
use codex_common::model_presets::{builtin_model_presets, ModelPreset};
use codex_core::ConversationManager;
use codex_core::config::Config;
use codex_core::config_types::ReasoningEffort;
use codex_core::config_types::TextVerbosity;
use codex_core::model_family::{derive_default_model_family, find_family_for_model};
use codex_login::AuthManager;
use codex_login::AuthMode;
use codex_protocol::mcp_protocol::AuthMode as McpAuthMode;
mod diff_handlers;
mod diff_ui;
@@ -4783,6 +4786,186 @@ impl ChatWidget<'_> {
self.request_redraw();
}
fn available_model_presets(&self) -> Vec<ModelPreset> {
let auth_mode = if self.config.using_chatgpt_auth {
Some(McpAuthMode::ChatGPT)
} else {
Some(McpAuthMode::ApiKey)
};
builtin_model_presets(auth_mode)
}
fn preset_effort_for_model(preset: &ModelPreset) -> ReasoningEffort {
preset
.effort
.map(ReasoningEffort::from)
.unwrap_or(ReasoningEffort::Medium)
}
fn find_model_preset(&self, input: &str, presets: &[ModelPreset]) -> Option<ModelPreset> {
if presets.is_empty() {
return None;
}
let input_lower = input.to_ascii_lowercase();
let collapsed_input: String = input_lower
.chars()
.filter(|c| !c.is_ascii_whitespace() && *c != '-')
.collect();
let mut fallback_medium: Option<ModelPreset> = None;
let mut fallback_none: Option<ModelPreset> = None;
let mut fallback_first: Option<ModelPreset> = None;
for &preset in presets.iter() {
let preset_effort = Self::preset_effort_for_model(&preset);
let id_lower = preset.id.to_ascii_lowercase();
if Self::candidate_matches(&input_lower, &collapsed_input, &id_lower) {
return Some(preset);
}
let label_lower = preset.label.to_ascii_lowercase();
if Self::candidate_matches(&input_lower, &collapsed_input, &label_lower) {
return Some(preset);
}
let effort_lower = preset_effort.to_string().to_ascii_lowercase();
let model_lower = preset.model.to_ascii_lowercase();
let spaced = format!("{model_lower} {effort_lower}");
if Self::candidate_matches(&input_lower, &collapsed_input, &spaced) {
return Some(preset);
}
let dashed = format!("{model_lower}-{effort_lower}");
if Self::candidate_matches(&input_lower, &collapsed_input, &dashed) {
return Some(preset);
}
if model_lower == input_lower
|| Self::candidate_matches(&input_lower, &collapsed_input, &model_lower)
{
if fallback_medium.is_none() && preset_effort == ReasoningEffort::Medium {
fallback_medium = Some(preset);
}
if fallback_none.is_none() && preset.effort.is_none() {
fallback_none = Some(preset);
}
if fallback_first.is_none() {
fallback_first = Some(preset);
}
}
}
fallback_medium.or(fallback_none).or(fallback_first)
}
fn candidate_matches(input: &str, collapsed_input: &str, candidate: &str) -> bool {
let candidate_lower = candidate.to_ascii_lowercase();
if candidate_lower == input {
return true;
}
let candidate_collapsed: String = candidate_lower
.chars()
.filter(|c| !c.is_ascii_whitespace() && *c != '-')
.collect();
candidate_collapsed == collapsed_input
}
pub(crate) fn handle_model_command(&mut self, command_args: String) {
if self.is_task_running() {
let message = "'/model' is disabled while a task is in progress.".to_string();
self.history_push(history_cell::new_error_event(message));
return;
}
let presets = self.available_model_presets();
if presets.is_empty() {
let message = "No model presets are available. Update your configuration to define models.".to_string();
self.history_push(history_cell::new_error_event(message));
return;
}
let trimmed = command_args.trim();
if !trimmed.is_empty() {
if let Some(preset) = self.find_model_preset(trimmed, &presets) {
let effort = Self::preset_effort_for_model(&preset);
self.apply_model_selection(preset.model.to_string(), Some(effort));
} else {
let message = format!(
"Unknown model preset: '{}'. Use /model with no arguments to open the selector.",
trimmed
);
self.history_push(history_cell::new_error_event(message));
}
return;
}
self.bottom_pane.show_model_selection(
presets,
self.config.model.clone(),
self.config.model_reasoning_effort,
);
}
pub(crate) fn apply_model_selection(
&mut self,
model: String,
effort: Option<ReasoningEffort>,
) {
let trimmed = model.trim();
if trimmed.is_empty() {
return;
}
let mut updated = false;
if !self.config.model.eq_ignore_ascii_case(trimmed) {
self.config.model = trimmed.to_string();
let family = find_family_for_model(&self.config.model)
.unwrap_or_else(|| derive_default_model_family(&self.config.model));
self.config.model_family = family;
updated = true;
}
if let Some(new_effort) = effort {
if self.config.model_reasoning_effort != new_effort {
self.config.model_reasoning_effort = new_effort;
updated = true;
}
}
if updated {
let op = Op::ConfigureSession {
provider: self.config.model_provider.clone(),
model: self.config.model.clone(),
model_reasoning_effort: self.config.model_reasoning_effort,
model_reasoning_summary: self.config.model_reasoning_summary,
model_text_verbosity: self.config.model_text_verbosity,
user_instructions: self.config.user_instructions.clone(),
base_instructions: self.config.base_instructions.clone(),
approval_policy: self.config.approval_policy.clone(),
sandbox_policy: self.config.sandbox_policy.clone(),
disable_response_storage: self.config.disable_response_storage,
notify: self.config.notify.clone(),
cwd: self.config.cwd.clone(),
resume_path: None,
};
self.submit_op(op);
}
let placement = self.ui_placement_for_now();
self.push_system_cell(
history_cell::new_model_output(
&self.config.model,
self.config.model_reasoning_effort,
),
placement,
Some("ui:model".to_string()),
None,
);
self.request_redraw();
}
pub(crate) fn handle_reasoning_command(&mut self, command_args: String) {
// command_args contains only the arguments after the command (e.g., "high" not "/reasoning high")
let trimmed = command_args.trim();
+13 -1
View File
@@ -12,7 +12,7 @@ pub(crate) fn strip_bash_lc_and_escape(command: &[String]) -> String {
// exactly three items
[first, second, third]
// first two must be "bash", "-lc"
if first == "bash" && second == "-lc" =>
if is_bash_like(first) && second == "-lc" =>
{
third.clone() // borrow `third`
}
@@ -20,6 +20,18 @@ pub(crate) fn strip_bash_lc_and_escape(command: &[String]) -> String {
}
}
fn is_bash_like(cmd: &str) -> bool {
let trimmed = cmd.trim_matches('"').trim_matches('\'');
if trimmed.eq_ignore_ascii_case("bash") || trimmed.eq_ignore_ascii_case("bash.exe") {
return true;
}
Path::new(trimmed)
.file_name()
.and_then(|s| s.to_str())
.map(|name| name.eq_ignore_ascii_case("bash") || name.eq_ignore_ascii_case("bash.exe"))
.unwrap_or(false)
}
/// If `path` is absolute and inside $HOME, return the part *after* the home
/// directory; otherwise, return the path as-is. Note if `path` is the homedir,
/// this will return and empty path.
+22
View File
@@ -4387,6 +4387,12 @@ fn popular_commands_lines() -> Vec<Line<'static>> {
Span::from(SlashCommand::Reasoning.description())
.style(Style::default().add_modifier(Modifier::DIM)),
]));
lines.push(Line::from(vec![
Span::styled("/model", Style::default().fg(crate::colors::primary())),
Span::from(" - "),
Span::from(SlashCommand::Model.description())
.style(Style::default().add_modifier(Modifier::DIM)),
]));
lines.push(Line::from(vec![
Span::styled("/resume", Style::default().fg(crate::colors::primary())),
Span::from(" - "),
@@ -6156,6 +6162,22 @@ pub(crate) fn new_reasoning_output(reasoning_effort: &ReasoningEffort) -> PlainH
}
}
pub(crate) fn new_model_output(
model: &str,
effort: ReasoningEffort,
) -> PlainHistoryCell {
let lines = vec![
Line::from(""),
Line::from("Model Selection").fg(crate::colors::keyword()).bold(),
Line::from(format!("Model: {}", model)),
Line::from(format!("Reasoning Effort: {}", effort)),
];
PlainHistoryCell {
lines,
kind: HistoryCellType::Notice,
}
}
// Continue with more factory functions...
// I'll add the rest in the next part to keep this manageable
pub(crate) fn new_status_output(config: &Config, usage: &TokenUsage) -> PlainHistoryCell {
+2
View File
@@ -25,6 +25,7 @@ pub enum SlashCommand {
Mention,
Status,
Theme,
Model,
Reasoning,
Verbosity,
Prompts,
@@ -65,6 +66,7 @@ impl SlashCommand {
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::Theme => "switch between color themes",
SlashCommand::Prompts => "show example prompts",
SlashCommand::Model => "choose what model and reasoning effort to use",
SlashCommand::Agents => "list agents (running and availability)",
SlashCommand::Branch => "create a worktree branch and switch (/branch [task])",
SlashCommand::Github => "GitHub Actions watcher (status/on/off)",
+1
View File
@@ -31,6 +31,7 @@ Notes
- `/theme`: switch between color themes.
- `/verbosity (high|medium|low)`: change text verbosity.
- `/model`: choose what model and reasoning effort to use.
- `/reasoning (minimal|low|medium|high)`: change reasoning effort.
- `/prompts`: show example prompts.
- `/status`: show current session configuration and token usage.