mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:23:33 +00:00
feat(tui/model): restore /model selector and presets
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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, ¤t_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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user