swimmers 0.2.0

Axum server plus TUI for orchestrating Claude Code and Codex agents across tmux panes
Documentation
use super::*;
use swimmers::openrouter_models::cached_or_default_openrouter_candidates;
use swimmers::thought_ui::{
    normalize_thought_model_for_backend, thought_backend_cycle_options, thought_backend_label,
    thought_model_presets, thought_model_presets_hint,
};

const THOUGHT_CONFIG_WIDTH: u16 = 58;
const THOUGHT_CONFIG_HEIGHT: u16 = 12;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ThoughtConfigEditorField {
    Enabled,
    Backend,
    Model,
    Test,
    Save,
    Cancel,
}

#[derive(Clone, Debug)]
pub(crate) struct ThoughtConfigEditorState {
    pub(crate) config: ThoughtConfig,
    pub(crate) daemon_defaults: Option<DaemonDefaults>,
    pub(crate) focus: ThoughtConfigEditorField,
    pub(crate) openrouter_model_presets: Vec<String>,
}

impl ThoughtConfigEditorState {
    pub(crate) fn new(mut config: ThoughtConfig, daemon_defaults: Option<DaemonDefaults>) -> Self {
        config.normalize();
        Self {
            config,
            daemon_defaults,
            focus: ThoughtConfigEditorField::Backend,
            openrouter_model_presets: cached_or_default_openrouter_candidates(),
        }
    }

    pub(crate) fn move_focus(&mut self, delta: isize) {
        const FIELDS: [ThoughtConfigEditorField; 6] = [
            ThoughtConfigEditorField::Enabled,
            ThoughtConfigEditorField::Backend,
            ThoughtConfigEditorField::Model,
            ThoughtConfigEditorField::Test,
            ThoughtConfigEditorField::Save,
            ThoughtConfigEditorField::Cancel,
        ];
        let current = FIELDS
            .iter()
            .position(|field| *field == self.focus)
            .unwrap_or(0) as isize;
        let len = FIELDS.len() as isize;
        let next = (current + delta).rem_euclid(len) as usize;
        self.focus = FIELDS[next];
    }

    pub(crate) fn cycle_backend(&mut self, delta: isize) {
        let options = thought_backend_cycle_options();
        let current = options
            .iter()
            .position(|option| option.eq_ignore_ascii_case(self.config.backend.trim()))
            .unwrap_or(0) as isize;
        let len = options.len() as isize;
        let next = (current + delta).rem_euclid(len) as usize;
        self.config.backend = options[next].to_string();
        self.normalize_model_for_backend();
    }

    pub(crate) fn backend_label(&self) -> &'static str {
        thought_backend_label(&self.config.backend)
    }

    pub(crate) fn daemon_label(&self) -> String {
        match &self.daemon_defaults {
            Some(defaults) => {
                let backend = thought_backend_label(&defaults.backend);
                let model = if defaults.model.trim().is_empty() {
                    "(empty)"
                } else {
                    defaults.model.as_str()
                };
                format!("daemon default: {backend} / {model}")
            }
            None => "daemon default: unavailable".to_string(),
        }
    }

    pub(crate) fn cycle_model_preset(&mut self, delta: isize) -> bool {
        let options = self.model_preset_values();
        if options.is_empty() {
            return false;
        }
        let current = options
            .iter()
            .position(|option| option.eq_ignore_ascii_case(self.config.model.trim()))
            .unwrap_or(0) as isize;
        let len = options.len() as isize;
        let next = (current + delta).rem_euclid(len) as usize;
        self.config.model = options[next].clone();
        true
    }

    pub(crate) fn model_presets_hint(&self) -> Option<&'static str> {
        Some(thought_model_presets_hint(&self.config.backend))
    }

    fn model_preset_values(&self) -> Vec<String> {
        thought_model_presets(&self.config.backend, &self.openrouter_model_presets)
    }

    pub(crate) fn replace_openrouter_model_presets(&mut self, models: Vec<String>) {
        self.openrouter_model_presets = if models.is_empty() {
            cached_or_default_openrouter_candidates()
        } else {
            models
        };
    }

    fn normalize_model_for_backend(&mut self) {
        self.config.model =
            normalize_thought_model_for_backend(&self.config.backend, &self.config.model);
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ThoughtConfigEditorLayout {
    pub(crate) frame: Rect,
    pub(crate) content: Rect,
    pub(crate) enabled_y: u16,
    pub(crate) backend_y: u16,
    pub(crate) model_y: u16,
    pub(crate) buttons_y: u16,
}

pub(crate) fn thought_config_editor_layout(field: Rect) -> ThoughtConfigEditorLayout {
    let width = THOUGHT_CONFIG_WIDTH
        .min(field.width.saturating_sub(2))
        .max(32);
    let height = THOUGHT_CONFIG_HEIGHT
        .min(field.height.saturating_sub(2))
        .max(9);
    let x = field.x + field.width.saturating_sub(width) / 2;
    let y = field.y + field.height.saturating_sub(height) / 2;
    let frame = Rect {
        x,
        y,
        width,
        height,
    };
    let content = Rect {
        x: frame.x + 2,
        y: frame.y + 1,
        width: frame.width.saturating_sub(4),
        height: frame.height.saturating_sub(2),
    };
    ThoughtConfigEditorLayout {
        frame,
        content,
        enabled_y: content.y + 2,
        backend_y: content.y + 3,
        model_y: content.y + 4,
        buttons_y: content.bottom().saturating_sub(1),
    }
}

pub(crate) fn render_thought_config_editor(
    renderer: &mut Renderer,
    editor: &ThoughtConfigEditorState,
    field: Rect,
) {
    let layout = thought_config_editor_layout(field);
    renderer.fill_rect(layout.frame, ' ', Color::Reset);
    renderer.draw_box(layout.frame, Color::White);
    renderer.draw_text(
        layout.content.x,
        layout.content.y,
        "thought config",
        Color::Cyan,
    );
    renderer.draw_text(
        layout.content.x,
        layout.content.y + 1,
        &truncate_label(&editor.daemon_label(), layout.content.width as usize),
        Color::DarkGrey,
    );

    let enabled_prefix = if editor.focus == ThoughtConfigEditorField::Enabled {
        ">"
    } else {
        " "
    };
    let enabled_label = if editor.config.enabled { "on" } else { "off" };
    renderer.draw_text(
        layout.content.x,
        layout.enabled_y,
        &format!("{enabled_prefix} enabled: {enabled_label}"),
        if editor.focus == ThoughtConfigEditorField::Enabled {
            Color::White
        } else {
            Color::DarkGrey
        },
    );

    let backend_prefix = if editor.focus == ThoughtConfigEditorField::Backend {
        ">"
    } else {
        " "
    };
    renderer.draw_text(
        layout.content.x,
        layout.backend_y,
        &format!("{backend_prefix} backend: {}", editor.backend_label()),
        if editor.focus == ThoughtConfigEditorField::Backend {
            Color::White
        } else {
            Color::DarkGrey
        },
    );

    let model_prefix = if editor.focus == ThoughtConfigEditorField::Model {
        ">"
    } else {
        " "
    };
    let input_x = layout.content.x;
    renderer.draw_text(
        input_x,
        layout.model_y,
        &format!("{model_prefix} model: "),
        if editor.focus == ThoughtConfigEditorField::Model {
            Color::White
        } else {
            Color::DarkGrey
        },
    );
    let model_x = input_x + 9;
    let available = layout.content.right().saturating_sub(model_x) as usize;
    let (model_text, model_color) = if editor.config.model.is_empty() {
        ("daemon default".to_string(), Color::DarkGrey)
    } else {
        (tail_text(&editor.config.model, available), Color::White)
    };
    let visible = truncate_label(&model_text, available);
    renderer.draw_text(model_x, layout.model_y, &visible, model_color);
    if editor.focus == ThoughtConfigEditorField::Model {
        let cursor_x = if editor.config.model.is_empty() {
            model_x
        } else {
            model_x + visible.chars().count() as u16
        };
        if cursor_x < layout.content.right() {
            renderer.draw_char(cursor_x, layout.model_y, '|', Color::Yellow);
        }
    }

    renderer.draw_text(
        layout.content.x,
        layout.model_y + 1,
        &truncate_label(
            editor.model_presets_hint().unwrap_or_default(),
            layout.content.width as usize,
        ),
        Color::DarkGrey,
    );
    renderer.draw_text(
        layout.content.x,
        layout.model_y + 2,
        "tab moves  arrows adjust  enter acts  esc cancels",
        Color::DarkGrey,
    );

    let test_color = if editor.focus == ThoughtConfigEditorField::Test {
        Color::White
    } else {
        Color::DarkGrey
    };
    let save_color = if editor.focus == ThoughtConfigEditorField::Save {
        Color::White
    } else {
        Color::DarkGrey
    };
    let cancel_color = if editor.focus == ThoughtConfigEditorField::Cancel {
        Color::White
    } else {
        Color::DarkGrey
    };
    renderer.draw_text(layout.content.x, layout.buttons_y, "[test]", test_color);
    renderer.draw_text(layout.content.x + 8, layout.buttons_y, "[save]", save_color);
    renderer.draw_text(
        layout.content.x + 16,
        layout.buttons_y,
        "[cancel]",
        cancel_color,
    );
}