use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use super::super::app::{
App, ConfigDraft, ConfigFocus, ConfigRow, InputState, MODEL_PRESETS, config_rows,
};
use super::super::theme;
use super::panes::{
SELECTOR_WIDTH, active_dot, draw_selector_list, head_cols, highlight_row, label_style,
name_color, picker_row, section_box, section_box_verbatim,
};
const KEY_W: usize = 11;
pub(super) fn draw(frame: &mut Frame<'_>, area: Rect, app: &App) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(SELECTOR_WIDTH), Constraint::Min(20)])
.split(area);
let profiles_focused = app.config_focus == ConfigFocus::Profiles;
draw_selector(frame, cols[0], app, profiles_focused);
draw_settings(frame, cols[1], app);
}
fn draw_selector(frame: &mut Frame<'_>, area: Rect, app: &App, focused: bool) {
let cfg = app.config();
let count = cfg.profiles.len();
let sel = app.profile_cursor.min(count);
draw_selector_list(frame, area, "accounts", focused, sel, |w| {
let mut rows: Vec<_> = cfg
.profiles
.iter()
.enumerate()
.map(|(i, p)| {
picker_row(
i == sel,
focused,
p.name.to_string(),
name_color(cfg.is_active(&p.name)),
w,
)
})
.collect();
rows.push(picker_row(
count == sel,
focused,
"+ new".to_string(),
theme::accent(),
w,
));
rows
});
}
struct Snap {
title: String,
name: String,
base_url: String,
api_key: String,
model: String,
opus: String,
sonnet: String,
haiku: String,
subagent: String,
env: Vec<(String, String)>,
auto_start: bool,
is_active: bool,
provider: Option<&'static str>,
}
impl Snap {
fn blank(title: &str) -> Snap {
Snap {
title: title.to_string(),
name: String::new(),
base_url: String::new(),
api_key: String::new(),
model: String::new(),
opus: String::new(),
sonnet: String::new(),
haiku: String::new(),
subagent: String::new(),
env: Vec::new(),
auto_start: false,
is_active: false,
provider: None,
}
}
}
fn build_snap(app: &App, with_text: bool) -> Snap {
let text = |s: &Option<String>| {
if with_text {
s.clone().unwrap_or_default()
} else {
String::new()
}
};
let cfg = app.config();
if app.profile_cursor >= cfg.profiles.len() {
return Snap::blank("+ new account");
}
match cfg.profiles.get(app.profile_cursor) {
Some(p) => Snap {
is_active: cfg.is_active(&p.name),
title: p.name.to_string(),
name: if with_text {
p.name.to_string()
} else {
String::new()
},
base_url: text(&p.base_url),
api_key: text(&p.api_key),
model: text(&p.models.default),
opus: text(&p.models.opus),
sonnet: text(&p.models.sonnet),
haiku: text(&p.models.haiku),
subagent: text(&p.models.subagent),
env: p.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
auto_start: p.auto_start,
provider: p.provider.map(|p| p.display_name()),
},
None => Snap::blank("settings"),
}
}
fn draw_settings(frame: &mut Frame<'_>, area: Rect, app: &App) {
let actions_focused = app.config_focus == ConfigFocus::Actions;
let draft = app.config_draft.as_ref();
let snap = build_snap(app, draft.is_none());
let is_profile_name = app.profile_cursor < app.config().profiles.len();
let block = if is_profile_name {
section_box_verbatim(&snap.title, actions_focused, false)
} else {
section_box(&snap.title, actions_focused, false)
};
let inner = block.inner(area);
frame.render_widget(block, area);
let rows = config_rows(app);
let cursor = app.config_action_cursor.min(rows.len().saturating_sub(1));
draw_settings_rows(frame, inner, app, &rows, cursor, &snap, actions_focused);
}
fn draw_settings_rows(
frame: &mut Frame<'_>,
inner: Rect,
app: &App,
rows: &[ConfigRow],
cursor: usize,
snap: &Snap,
actions_focused: bool,
) {
let draft = app.config_draft.as_ref();
let editing = draft.and_then(|d| d.active);
let armed_delete = draft.map(|d| d.armed_delete).unwrap_or(false);
let is_api = !row_input(draft, snap, ConfigRow::BaseUrl)
.value
.trim()
.is_empty();
let (type_value, type_style) = if is_api {
("API", theme::accent())
} else {
("OAuth", theme::accent())
};
let mut type_spans = vec![
Span::styled(format!("type{}", " ".repeat(KEY_W - 4)), theme::label()),
Span::styled(type_value, type_style),
];
if snap.is_active {
let left_w = KEY_W + type_value.chars().count();
let indicator_w = "● active".chars().count();
let pad = (inner.width as usize)
.saturating_sub(left_w)
.saturating_sub(indicator_w);
type_spans.push(Span::raw(" ".repeat(pad)));
type_spans.extend(active_dot());
}
let mut lines: Vec<Line<'static>> = vec![Line::from(type_spans)];
let provider_label = if is_api { snap.provider } else { None };
if let Some(label) = provider_label {
lines.push(Line::from(vec![
Span::styled(format!("provider{}", " ".repeat(KEY_W - 8)), theme::label()),
Span::styled(label, theme::accent()),
]));
}
lines.push(Line::from(""));
let mut edit_caret: Option<(u16, InputState, ConfigRow)> = None;
let mut line_idx: u16 = if provider_label.is_some() { 3 } else { 2 };
for (i, row) in rows.iter().enumerate() {
let selected = actions_focused && i == cursor;
let is_editing = editing == Some(*row);
let input = row_input(draft, snap, *row);
let line = detail_row(*row, selected, is_editing, armed_delete, snap, &input);
if is_editing {
edit_caret = Some((line_idx, input, *row));
}
lines.push(if selected {
highlight_row(line, inner.width as usize)
} else {
line
});
line_idx += 1;
if selected
&& !is_editing
&& let Some(text) = row_hint(*row)
{
lines.push(Line::from(vec![
Span::styled(" └ ", Style::default().fg(theme::line_color())),
Span::styled(text, theme::faint()),
]));
line_idx += 1;
}
}
frame.render_widget(Paragraph::new(lines).style(theme::base()), inner);
if let Some((ly, input, row)) = edit_caret {
let prefix_cols = 2 + row_label_cols(row, snap) + head_cols(&input);
let cx = inner.x.saturating_add(prefix_cols as u16);
let cy = inner.y.saturating_add(ly);
frame.set_cursor_position((cx, cy));
}
}
fn row_label_cols(row: ConfigRow, snap: &Snap) -> usize {
match row {
ConfigRow::EnvEntry(i) => {
let key_len = snap.env.get(i).map(|(k, _)| k.chars().count()).unwrap_or(0);
key_len + KEY_W.saturating_sub(key_len).max(1)
}
_ => KEY_W,
}
}
fn row_input(draft: Option<&ConfigDraft>, snap: &Snap, row: ConfigRow) -> InputState {
draft
.and_then(|d| d.field(row))
.cloned()
.unwrap_or_else(|| InputState::new(snap_value(snap, row)))
}
fn snap_value(snap: &Snap, row: ConfigRow) -> &str {
match row {
ConfigRow::Name => &snap.name,
ConfigRow::BaseUrl => &snap.base_url,
ConfigRow::ApiKey => &snap.api_key,
ConfigRow::Model => &snap.model,
ConfigRow::OpusModel => &snap.opus,
ConfigRow::SonnetModel => &snap.sonnet,
ConfigRow::HaikuModel => &snap.haiku,
ConfigRow::SubagentModel => &snap.subagent,
ConfigRow::EnvEntry(i) => snap.env.get(i).map(|(_, v)| v.as_str()).unwrap_or(""),
ConfigRow::EnvAdd
| ConfigRow::ModelOverrideAdd
| ConfigRow::AutoStart
| ConfigRow::Delete
| ConfigRow::Create => "",
}
}
fn row_hint(row: ConfigRow) -> Option<&'static str> {
match row {
ConfigRow::BaseUrl => Some("custom api endpoint; empty = claude.ai oauth"),
ConfigRow::ApiKey => Some("x-api-key for a non-oauth endpoint"),
ConfigRow::Model => {
Some("default model for this account; space cycles, ⏎ sets a custom id")
}
ConfigRow::OpusModel => Some("what the `opus` alias resolves to (full model id)"),
ConfigRow::SonnetModel => Some("what the `sonnet` alias resolves to (full model id)"),
ConfigRow::HaikuModel => Some("what the `haiku` alias resolves to (full model id)"),
ConfigRow::SubagentModel => Some("model forced for every subagent in this account"),
ConfigRow::EnvEntry(_) => Some("custom env var merged into settings.json while active"),
ConfigRow::EnvAdd => Some("add a custom settings.json env var to this account"),
ConfigRow::AutoStart => Some("launch a session on idle to arm the 5h window"),
ConfigRow::ModelOverrideAdd => {
Some("pin what an alias resolves to, or force the subagent model")
}
ConfigRow::Name | ConfigRow::Delete | ConfigRow::Create => None,
}
}
fn detail_row(
row: ConfigRow,
selected: bool,
editing: bool,
armed_delete: bool,
snap: &Snap,
input: &InputState,
) -> Line<'static> {
let arrow = if editing {
Span::styled(format!("{} ", theme::edit_glyph()), theme::accent())
} else if selected {
Span::styled("❯ ", theme::accent())
} else {
Span::raw(" ")
};
match row {
ConfigRow::Name => kv_field(arrow, "name", input, editing, selected, false),
ConfigRow::BaseUrl => kv_field(arrow, "base url", input, editing, selected, false),
ConfigRow::ApiKey => kv_field(arrow, "api key", input, editing, selected, true),
ConfigRow::Model if !editing => model_cycle_line(arrow, &input.value, selected),
ConfigRow::Model => kv_field(arrow, "model", input, editing, selected, false),
ConfigRow::OpusModel => kv_field(arrow, "opus", input, editing, selected, false),
ConfigRow::SonnetModel => kv_field(arrow, "sonnet", input, editing, selected, false),
ConfigRow::HaikuModel => kv_field(arrow, "haiku", input, editing, selected, false),
ConfigRow::SubagentModel => kv_field(arrow, "subagent", input, editing, selected, false),
ConfigRow::EnvEntry(i) => {
let key = snap.env.get(i).map(|(k, _)| k.clone()).unwrap_or_default();
let mask = env_key_is_secret(&key);
kv_field(arrow, &key, input, editing, selected, mask)
}
ConfigRow::EnvAdd if editing => kv_field(arrow, "key", input, editing, selected, false),
ConfigRow::EnvAdd => Line::from(vec![arrow, Span::styled("+ add env", theme::accent())]),
ConfigRow::ModelOverrideAdd => Line::from(vec![
arrow,
Span::styled("+ model override", theme::accent()),
]),
ConfigRow::AutoStart => {
let (value, style) = if snap.auto_start {
(theme::toggle_on().to_string(), theme::accent())
} else {
(theme::toggle_off().to_string(), theme::faint())
};
kv_static(arrow, "auto-start", value, style, selected)
}
ConfigRow::Delete => {
let label = if armed_delete {
"press again to delete".to_string()
} else {
"delete account".to_string()
};
Line::from(vec![
arrow,
Span::styled(label, theme::danger().add_modifier(Modifier::BOLD)),
])
}
ConfigRow::Create => {
Line::from(vec![arrow, Span::styled("create account", theme::accent())])
}
}
}
fn kv_field(
arrow: Span<'static>,
key: &str,
input: &InputState,
editing: bool,
focused: bool,
mask_value: bool,
) -> Line<'static> {
let pad = KEY_W.saturating_sub(key.chars().count()).max(1);
let mut spans = vec![
arrow,
Span::styled(format!("{key}{}", " ".repeat(pad)), label_style(focused)),
];
spans.extend(value_spans(input, editing, mask_value));
Line::from(spans)
}
fn kv_static(
arrow: Span<'static>,
key: &str,
value: String,
value_style: Style,
focused: bool,
) -> Line<'static> {
let pad = KEY_W.saturating_sub(key.chars().count()).max(1);
Line::from(vec![
arrow,
Span::styled(format!("{key}{}", " ".repeat(pad)), label_style(focused)),
Span::styled(value, value_style),
])
}
fn env_key_is_secret(key: &str) -> bool {
let upper = key.to_ascii_uppercase();
["KEY", "TOKEN", "SECRET", "AUTH"]
.iter()
.any(|needle| upper.contains(needle))
}
fn value_spans(input: &InputState, editing: bool, mask_value: bool) -> Vec<Span<'static>> {
if !editing {
if input.value.is_empty() {
return vec![Span::styled("—", theme::faint())];
}
let display = if mask_value {
"••••••••".to_string()
} else {
input.value.clone()
};
return vec![Span::styled(display, theme::accent())];
}
let body = Style::default()
.fg(theme::text_color())
.bg(theme::bg_sunken());
vec![Span::styled(input.value.clone(), body)]
}
fn model_cycle_line(arrow: Span<'static>, current: &str, selected: bool) -> Line<'static> {
let pad = KEY_W.saturating_sub("model".len()).max(1);
let mut spans = vec![
arrow,
Span::styled(format!("model{}", " ".repeat(pad)), label_style(selected)),
];
let mut options: Vec<(&str, bool)> = vec![("default", current.is_empty())];
options.extend(MODEL_PRESETS.iter().map(|p| (*p, *p == current)));
for (i, (label, active)) in options.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" "));
}
spans.push(model_cycle_option(label, *active, selected));
}
if !current.is_empty() && !MODEL_PRESETS.contains(¤t) {
spans.push(Span::styled(format!(" {current}"), theme::accent()));
}
Line::from(spans)
}
fn model_cycle_option(label: &str, active: bool, row_selected: bool) -> Span<'static> {
if active {
let text = if row_selected {
format!("[{label}]")
} else {
format!(" {label} ")
};
Span::styled(text, theme::accent())
} else {
Span::styled(label.to_string(), theme::faint())
}
}