use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Item {
Setting(usize),
View(usize),
Key(usize),
}
#[derive(Clone, Copy)]
pub enum View {
Flag {
value: fn(&App) -> bool,
set: fn(&mut App, bool),
},
Choice {
value: fn(&App) -> String,
options: fn(&App) -> Vec<String>,
set: fn(&mut App, &str),
},
Number {
value: fn(&App) -> f64,
range: (f64, f64),
set: fn(&mut App, f64),
},
}
pub const VIEWS: [(&str, &str, View); 10] = [
(
"tree",
"Draw the table as repositories and their checkouts",
View::Flag {
value: |app| app.tree,
set: |app, on| {
app.tree = on;
app.save_prefs();
},
},
),
(
"live_only",
"Only sessions that are running",
View::Flag {
value: |app| app.live_only,
set: |app, on| {
app.live_only = on;
app.save_prefs();
},
},
),
(
"age",
"Hide sessions quiet for longer than",
View::Choice {
value: |app| {
app.age_filter
.map(|a| a.key().to_string())
.unwrap_or_default()
},
options: |_| {
std::iter::once(String::new())
.chain(
AGE_OPTIONS
.iter()
.filter_map(|o| o.as_ref())
.map(|a| a.key().to_string()),
)
.collect()
},
set: |app, key| {
app.age_filter = AGE_OPTIONS
.iter()
.filter_map(|o| o.as_ref())
.find(|a| a.key() == key)
.copied();
app.age_cursor = AGE_OPTIONS
.iter()
.position(|o| *o == app.age_filter)
.unwrap_or(AGE_OPTIONS.len() - 1);
app.save_prefs();
},
},
),
(
"cost_floor",
"Only sessions costing at least $X (0 for all)",
View::Number {
value: |app| app.cost_floor,
range: (0.0, 100_000.0),
set: |app, v| {
app.cost_floor = v;
app.save_prefs();
},
},
),
(
"bottom_panel",
"Which panel opens under the table",
View::Choice {
value: |app| super::panels::TABS[app.bottom_tab.min(8)].to_string(),
options: |_| super::panels::TABS.iter().map(|t| t.to_string()).collect(),
set: |app, name| {
app.bottom_tab = super::panels::TABS
.iter()
.position(|t| *t == name)
.unwrap_or(app.bottom_tab);
app.save_prefs();
},
},
),
(
"tool_show_diff",
"Show each edit's diff under its row",
View::Flag {
value: |app| app.tool_show_diff,
set: |app, on| {
app.tool_show_diff = on;
app.save_prefs();
},
},
),
(
"tool_live_only",
"Tool Activity shows this run only",
View::Flag {
value: |app| app.tool_live_only,
set: |app, on| {
app.tool_live_only = on;
app.save_prefs();
},
},
),
(
"subagent_sort",
"What the Subagents panel sorts by",
View::Choice {
value: |app| {
let (col, asc) = app.subagent_sort;
format!("{}{}", col.key(), if asc { " ↑" } else { " ↓" })
},
options: |_| {
let cols = [
super::panels::SubagentSort::Last,
super::panels::SubagentSort::Type,
super::panels::SubagentSort::Model,
super::panels::SubagentSort::Description,
super::panels::SubagentSort::Cost,
super::panels::SubagentSort::Tools,
super::panels::SubagentSort::Context,
super::panels::SubagentSort::Duration,
];
let mut out = Vec::new();
for col in cols {
for asc in [false, true] {
out.push(format!("{}{}", col.key(), if asc { " ↑" } else { " ↓" }));
}
}
out
},
set: |app, spec| {
let (key, asc) = spec.split_once(' ').unwrap_or((spec, ""));
let asc = !asc.is_empty();
app.subagent_sort = (super::panels::SubagentSort::parse(key), asc);
app.save_prefs();
},
},
),
(
"claude_account",
"The Claude account a new tab launches under",
View::Choice {
value: |app| chosen_account(app, crate::pricing::Provider::Claude),
options: |app| account_names(app, crate::pricing::Provider::Claude),
set: |app, name| {
set_account(app, crate::pricing::Provider::Claude, name);
},
},
),
(
"codex_account",
"The Codex account a new tab launches under",
View::Choice {
value: |app| chosen_account(app, crate::pricing::Provider::Codex),
options: |app| account_names(app, crate::pricing::Provider::Codex),
set: |app, name| {
set_account(app, crate::pricing::Provider::Codex, name);
},
},
),
];
fn account_names(_app: &App, provider: crate::pricing::Provider) -> Vec<String> {
let mut names = vec![String::new()];
for profile in crate::config::launchable_for(provider) {
if profile.name != "default" {
names.push(profile.name.clone());
}
}
names
}
fn chosen_account(app: &App, provider: crate::pricing::Provider) -> String {
match app.chosen_profile(provider) {
Some(p) if p.name != "default" => p.name.clone(),
_ => String::new(),
}
}
fn set_account(app: &mut App, provider: crate::pricing::Provider, name: &str) {
let Some(at) = crate::config::launchable_for(provider)
.iter()
.position(|p| p.name == name)
else {
return;
};
app.launch_profile.insert(provider, at);
app.save_prefs();
}
impl App {
pub(super) fn reload_settings(&mut self) {
let Some(path) = &self.settings_file else {
return;
};
let now = crate::config::file_mtime_ms(path);
if now == self.settings_stamp && now != 0 {
return;
}
self.settings_stamp = now;
let mut settings = crate::settings::Settings::load_from(path);
let (keymap, problems) = crate::settings::Keymap::build(&settings);
settings.problems.extend(problems);
self.settings = settings;
self.keymap = keymap;
self.apply_columns();
self.apply_theme();
}
pub(super) fn apply_theme(&mut self) {
super::theme::set_theme(self.settings.theme.as_deref());
}
pub(super) fn apply_columns(&mut self) {
if std::env::var_os("CCTOP_COLUMNS_HIDE").is_some() {
return;
}
self.hidden_columns = match self.settings.hide_columns.as_deref() {
Some(list) => super::columns::parse_hidden(list),
None => Vec::new(),
};
}
fn write_setting(&mut self, table: &str, name: &str, value: Option<toml_edit::Value>) {
let Some(path) = self.settings_file.clone() else {
return;
};
let said = match &value {
Some(v) => format!("{name} = {}", v.to_string().trim()),
None => format!("{name} back to its default"),
};
match crate::settings::write(&path, table, name, value) {
Ok(()) => self.set_status(format!("Saved {said}")),
Err(e) => self.set_status(format!("Could not save: {e}")),
}
self.settings_stamp = 0;
self.reload_settings();
}
pub(super) fn settings_items(&self) -> Vec<Item> {
(0..crate::settings::SETTINGS.len())
.map(Item::Setting)
.chain((0..VIEWS.len()).map(Item::View))
.chain((0..crate::settings::BINDINGS.len()).map(Item::Key))
.collect()
}
pub(super) fn settings_shown(&self) -> Vec<Item> {
let needle = self.settings_filter.to_string();
let needle = needle.trim().to_lowercase();
if needle.is_empty() {
return self.settings_items();
}
self.settings_items()
.into_iter()
.filter(|item| {
let hay = match item {
Item::Setting(i) => {
let (name, default, what) = crate::settings::SETTINGS[*i];
let (now, _) = self.settings.value_of(name);
format!("{name} {default} {what} {now}")
}
Item::View(i) => {
let (name, what, view) = &VIEWS[*i];
format!("{name} {what} {}", self.view_value(view))
}
Item::Key(i) => {
let (action, default, what) = crate::settings::BINDINGS[*i];
format!(
"{action} {default} {what} {}",
self.settings.key_for(action)
)
}
};
hay.to_lowercase().contains(&needle)
})
.collect()
}
pub(super) fn settings_item(&self) -> Option<Item> {
self.settings_shown().get(self.settings_cursor).copied()
}
pub(super) fn view_value(&self, view: &View) -> String {
match view {
View::Flag { value, .. } => if value(self) { "on" } else { "off" }.to_string(),
View::Choice { value, .. } => {
let now = value(self);
match now.is_empty() {
true => "default".to_string(),
false => now,
}
}
View::Number { value, .. } => {
let v = value(self);
if v.fract() == 0.0 && v.abs() < 1e9 {
format!("${v:.0}")
} else {
format!("${v:.2}")
}
}
}
}
pub(super) fn view_is_set(&self, view: &View) -> bool {
match view {
View::Flag { value, .. } => value(self),
View::Number { value, .. } => value(self) != 0.0,
View::Choice { value, options, .. } => {
let first = options(self).first().cloned().unwrap_or_default();
value(self) != first
}
}
}
pub(super) fn settings_activate(&mut self) {
let Some(item) = self.settings_item() else {
return;
};
match item {
Item::Key(_) => self.settings_capture = true,
Item::View(i) => self.view_activate(i),
Item::Setting(i) => self.setting_activate(crate::settings::SETTINGS[i].0),
}
}
fn view_activate(&mut self, i: usize) {
let (name, _, view) = &VIEWS[i];
match view {
View::Flag { value, set } => {
let on = !value(self);
set(self, on);
self.set_status(format!("{name} {}", if on { "on" } else { "off" }));
}
View::Choice {
value,
options,
set,
} => {
let options = options(self);
if options.is_empty() {
return;
}
let now = value(self);
let at = options.iter().position(|o| *o == now).unwrap_or(0);
let next = options[(at + 1) % options.len()].clone();
set(self, &next);
self.set_status(format!("{name} = {}", said(&next)));
}
View::Number { value, .. } => {
self.settings_input = Some(format!("{}", value(self)).into());
}
}
}
fn setting_activate(&mut self, name: &str) {
let (current, _) = self.settings.value_of(name);
match name {
"notify" | "auto_update" | "warn_agents" | "read_screen" => {
let on = current != "true";
self.write_setting("settings", name, Some(on.into()));
if name == "notify" && self.notify.enabled != on {
self.notify.enabled = on;
self.save_prefs();
}
}
"theme" => {
const THEMES: [&str; 4] = ["auto", "light", "dark", "mono"];
let at = THEMES.iter().position(|t| current.trim_matches('"') == *t);
let next = THEMES[at.map_or(0, |i| (i + 1) % THEMES.len())];
self.write_setting("settings", name, Some(next.into()));
}
_ => self.settings_input = Some(current.trim_matches('"').to_string().into()),
}
}
pub(super) fn settings_reset(&mut self) {
let Some(item) = self.settings_item() else {
return;
};
match item {
Item::Setting(i) => {
self.write_setting("settings", crate::settings::SETTINGS[i].0, None)
}
Item::Key(i) => self.write_setting("keys", crate::settings::BINDINGS[i].0, None),
Item::View(i) => self.view_reset(i),
}
}
fn view_reset(&mut self, i: usize) {
let (name, _, view) = &VIEWS[i];
match view {
View::Flag { set, .. } => set(self, false),
View::Choice { options, set, .. } => {
let default = options(self).first().cloned().unwrap_or_default();
set(self, &default);
}
View::Number { set, .. } => set(self, 0.0),
}
self.set_status(format!("{name} = {}", said(&self.view_value(view))));
}
pub(super) fn settings_capture_key(&mut self, key: ratatui::crossterm::event::KeyEvent) {
use crate::settings::BINDINGS;
self.settings_capture = false;
if key.code == ratatui::crossterm::event::KeyCode::Esc {
return;
}
let Some(Item::Key(i)) = self.settings_item() else {
return;
};
let Some((action, default, _)) = BINDINGS.get(i) else {
return;
};
let Some(spec) = crate::settings::key_name(key) else {
self.set_status("That key cannot be written in the config file");
return;
};
let taken = BINDINGS
.iter()
.find(|b| b.0 != *action && self.settings.key_for(b.0) == spec)
.map(|b| b.0);
let value = (spec != *default).then(|| spec.as_str().into());
self.write_setting("keys", action, value);
if let Some(other) = taken {
self.set_status(format!(
"{action} = {spec} — that was {other}'s key, so rebind {other} too"
));
}
}
pub(super) fn settings_commit_input(&mut self) {
use crate::settings::SETTINGS;
let Some(text) = self.settings_input.take() else {
return;
};
let Some(item) = self.settings_item() else {
return;
};
let text = text.trim();
let Item::Setting(i) = item else {
return self.view_commit(item, text);
};
let Some((name, _, _)) = SETTINGS.get(i) else {
return;
};
if text.is_empty() {
return self.write_setting("settings", name, None);
}
match *name {
"compact_threshold" => match text.parse::<f64>() {
Ok(p) if (1.0..=100.0).contains(&p) => {
self.write_setting("settings", name, Some(p.into()))
}
_ => self.set_status("compact_threshold is a percentage, 1 to 100"),
},
"alert_error_calls" => match text.parse::<i64>() {
Ok(n) if n >= 1 => self.write_setting("settings", name, Some(n.into())),
_ => self.set_status("alert_error_calls is a whole number, 1 or more"),
},
"idle_after" => match text.parse::<f64>() {
Ok(h) if h.is_finite() && h > 0.0 => {
let value = match h.fract() == 0.0 && h < i64::MAX as f64 {
true => toml_edit::Value::from(h as i64),
false => toml_edit::Value::from(h),
};
self.write_setting("settings", name, Some(value))
}
_ => self.set_status("idle_after is a number of hours, more than 0"),
},
alert if alert.starts_with("alert_") => match text.parse::<f64>() {
Ok(v) if v.is_finite() && v >= 0.0 && (alert != "alert_errors" || v <= 100.0) => {
let value = match v.fract() == 0.0 && v < i64::MAX as f64 {
true => toml_edit::Value::from(v as i64),
false => toml_edit::Value::from(v),
};
self.write_setting("settings", name, Some(value))
}
_ => self.set_status(format!("{alert} is a number, 0 to turn it off")),
},
_ => self.write_setting("settings", name, Some(text.into())),
}
}
fn view_commit(&mut self, item: Item, text: &str) {
let Item::View(i) = item else {
return;
};
let (name, _, view) = &VIEWS[i];
let View::Number { range, set, .. } = view else {
return;
};
if text.is_empty() {
set(self, 0.0);
return self.set_status(format!("{name} back to its default"));
}
let digits = text.trim_start_matches(['$', ' ']);
match digits.parse::<f64>() {
Ok(v) if v.is_finite() && (range.0..=range.1).contains(&v) => {
set(self, v);
self.set_status(format!("{name} = {v}"));
}
_ => self.set_status(format!("{name} is a number, {} to {}", range.0, range.1)),
}
}
}
fn said(value: &str) -> String {
if value.is_empty() {
"the default".to_string()
} else {
value.to_string()
}
}