use std::{
collections::HashMap,
env,
path::Path,
sync::mpsc::{self, Receiver, Sender},
thread,
time::Duration,
};
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::{
DefaultTerminal, Frame,
layout::{Alignment, Constraint, Flex, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use crate::{
launch::{self, AuthOperation, AuthStatus, Tool},
profile::{Profile, Store},
};
const DITTO_PURPLE: Color = Color::Rgb(190, 134, 255);
const CLAUDE_ORANGE: Color = Color::Rgb(222, 133, 93);
const CODEX_GREEN: Color = Color::Rgb(104, 201, 154);
const OPENCODE_CYAN: Color = Color::Rgb(103, 199, 209);
const OMP_BLUE: Color = Color::Rgb(96, 165, 250);
const TOOL_COLUMN: usize = 13;
const SPINNER: [&str; 8] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠇"];
const TICK: Duration = Duration::from_millis(110);
const MINIMUM_WIDTH: u16 = 60;
const MINIMUM_HEIGHT: u16 = 20;
const WIDE_FOOTER_WIDTH: u16 = 88;
const DEFAULT_MARK: &str = "★";
type Shortcut = (&'static str, &'static str, Color);
const SELECT: Shortcut = ("↑↓", "select", DITTO_PURPLE);
const NEW: Shortcut = ("n", "new", Color::Gray);
const RENAME: Shortcut = ("e", "rename", Color::Gray);
const DEFAULT: Shortcut = ("d", "default", Color::Gray);
const SIGN_IN: Shortcut = ("l", "sign in", Color::Gray);
const SIGN_OUT: Shortcut = ("L", "sign out", Color::Gray);
const REFRESH: Shortcut = ("r", "refresh", Color::Gray);
const QUIT: Shortcut = ("q", "quit", Color::Gray);
const TOOL_SHORTCUTS: [Shortcut; 4] = [
("c", "Claude Code", CLAUDE_ORANGE),
("x", "Codex", CODEX_GREEN),
("o", "opencode", OPENCODE_CYAN),
("p", "OMP", OMP_BLUE),
];
const WIDE_SHORTCUT_ROW: [Shortcut; 8] = [
SELECT, NEW, RENAME, DEFAULT, SIGN_IN, SIGN_OUT, REFRESH, QUIT,
];
const NARROW_SHORTCUT_ROWS: [&[Shortcut]; 2] = [
&[SELECT, NEW, RENAME, DEFAULT],
&[SIGN_IN, SIGN_OUT, REFRESH, QUIT],
];
pub enum UiAction {
Launch {
tool: Tool,
profile: Profile,
},
Authenticate {
operation: AuthOperation,
tool: Tool,
profile: Profile,
},
}
enum Mode {
Browsing,
Creating {
input: String,
error: Option<String>,
},
Renaming {
original: String,
input: String,
error: Option<String>,
},
Notice {
title: &'static str,
message: String,
},
ChoosingTool {
operation: AuthOperation,
},
ConfirmingLogout {
tool: Tool,
},
}
#[derive(Clone, Copy, Default)]
struct ProfileAuth {
generation: u64,
claude: Option<AuthStatus>,
codex: Option<AuthStatus>,
opencode: Option<AuthStatus>,
omp: Option<AuthStatus>,
}
impl ProfileAuth {
fn get(&self, tool: Tool) -> Option<AuthStatus> {
match tool {
Tool::Claude => self.claude,
Tool::Codex => self.codex,
Tool::Opencode => self.opencode,
Tool::Omp => self.omp,
}
}
fn set(&mut self, tool: Tool, status: AuthStatus) {
match tool {
Tool::Claude => self.claude = Some(status),
Tool::Codex => self.codex = Some(status),
Tool::Opencode => self.opencode = Some(status),
Tool::Omp => self.omp = Some(status),
}
}
fn pending(&self) -> bool {
Tool::ALL.iter().any(|tool| self.get(*tool).is_none())
}
}
struct Probe {
profile: String,
generation: u64,
tool: Tool,
status: AuthStatus,
}
struct App<'a> {
store: &'a Store,
profiles: Vec<Profile>,
selected: usize,
mode: Mode,
auth: HashMap<String, ProfileAuth>,
generation: u64,
sender: Sender<Probe>,
receiver: Receiver<Probe>,
spinner: usize,
has_auth_environment: bool,
default_profile: Option<String>,
}
impl<'a> App<'a> {
fn new(
store: &'a Store,
profiles: Vec<Profile>,
initial_profile: Option<&str>,
default_profile: Option<String>,
) -> Self {
let selected = initial_profile
.and_then(|name| profiles.iter().position(|profile| profile.name == name))
.unwrap_or(0);
let (sender, receiver) = mpsc::channel();
let mut app = Self {
store,
profiles,
selected,
mode: Mode::Browsing,
auth: HashMap::new(),
generation: 0,
sender,
receiver,
spinner: 0,
has_auth_environment: auth_environment_is_set(),
default_profile,
};
app.probe_selected();
app
}
fn selected_profile(&self) -> &Profile {
&self.profiles[self.selected]
}
fn selected_auth(&self) -> ProfileAuth {
self.auth
.get(&self.selected_profile().name)
.copied()
.unwrap_or_default()
}
fn move_to(&mut self, index: usize) {
let last = self.profiles.len().saturating_sub(1);
self.selected = index.min(last);
if !self.auth.contains_key(&self.selected_profile().name) {
self.probe_selected();
}
}
fn probe_selected(&mut self) {
let profile = self.selected_profile().clone();
self.generation += 1;
let generation = self.generation;
self.auth.insert(
profile.name.clone(),
ProfileAuth {
generation,
..ProfileAuth::default()
},
);
for tool in Tool::ALL {
let sender = self.sender.clone();
let profile = profile.clone();
thread::spawn(move || {
let status = launch::auth_status(tool, &profile);
let _ = sender.send(Probe {
profile: profile.name,
generation,
tool,
status,
});
});
}
}
fn collect_probes(&mut self) -> bool {
let mut changed = false;
while let Ok(probe) = self.receiver.try_recv() {
if let Some(auth) = self.auth.get_mut(&probe.profile)
&& auth.generation == probe.generation
{
auth.set(probe.tool, probe.status);
changed = true;
}
}
changed
}
fn waiting_on_probes(&self) -> bool {
self.selected_auth().pending()
}
fn handle_key(&mut self, key: KeyEvent) -> Result<Action> {
if key.kind != KeyEventKind::Press {
return Ok(Action::Continue);
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return Ok(Action::Quit);
}
if key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
return Ok(Action::Continue);
}
match &mut self.mode {
Mode::Browsing => Ok(match key.code {
KeyCode::Char('q') | KeyCode::Esc => Action::Quit,
KeyCode::Up | KeyCode::Char('k') => {
self.move_to(self.selected.saturating_sub(1));
Action::Continue
}
KeyCode::Down | KeyCode::Char('j') => {
self.move_to(self.selected + 1);
Action::Continue
}
KeyCode::Home => {
self.move_to(0);
Action::Continue
}
KeyCode::End => {
self.move_to(usize::MAX);
Action::Continue
}
KeyCode::Char('n') => {
self.mode = Mode::Creating {
input: String::new(),
error: None,
};
Action::Continue
}
KeyCode::Char('e') => {
if self.selected_profile().managed {
self.mode = Mode::Renaming {
original: self.selected_profile().name.clone(),
input: String::new(),
error: None,
};
} else {
self.mode = Mode::Notice {
title: " Cannot rename ",
message: "The default profile represents your existing setup and cannot be renamed."
.to_owned(),
};
}
Action::Continue
}
KeyCode::Char('l') => {
self.mode = Mode::ChoosingTool {
operation: AuthOperation::Login,
};
Action::Continue
}
KeyCode::Char('L') => {
self.mode = Mode::ChoosingTool {
operation: AuthOperation::Logout,
};
Action::Continue
}
KeyCode::Char('d') => {
self.toggle_default();
Action::Continue
}
KeyCode::Char('r') => {
self.probe_selected();
Action::Continue
}
KeyCode::Char('c') => Action::Launch(Tool::Claude),
KeyCode::Char('x') => Action::Launch(Tool::Codex),
KeyCode::Char('o') => Action::Launch(Tool::Opencode),
KeyCode::Char('p') => Action::Launch(Tool::Omp),
_ => Action::Continue,
}),
Mode::Creating { input, error } => match key.code {
KeyCode::Esc => {
self.mode = Mode::Browsing;
Ok(Action::Continue)
}
KeyCode::Enter => match self.store.create_profile(input) {
Ok(profile) => {
self.select_after_change(&profile.name)?;
Ok(Action::Continue)
}
Err(create_error) => {
*error = Some(create_error.to_string());
Ok(Action::Continue)
}
},
KeyCode::Backspace => {
input.pop();
*error = None;
Ok(Action::Continue)
}
KeyCode::Char(character) if input.len() < 32 => {
input.push(character);
*error = None;
Ok(Action::Continue)
}
_ => Ok(Action::Continue),
},
Mode::Renaming {
original,
input,
error,
} => match key.code {
KeyCode::Esc => {
self.mode = Mode::Browsing;
Ok(Action::Continue)
}
KeyCode::Enter => {
let original = original.clone();
let signs_out = rename_signs_out(&self.auth, &original);
match self.store.rename_profile(&original, input) {
Ok(profile) => {
self.auth.remove(&original);
self.select_after_change(&profile.name)?;
if signs_out {
self.mode = Mode::Notice {
title: " Claude Code signed out ",
message: format!(
"Claude Code ties its credentials to the profile \
directory, which the rename moved. Press l to sign \
'{}' back in.",
profile.name
),
};
}
Ok(Action::Continue)
}
Err(rename_error) => {
if let Mode::Renaming { error, .. } = &mut self.mode {
*error = Some(rename_error.to_string());
}
Ok(Action::Continue)
}
}
}
KeyCode::Backspace => {
input.pop();
*error = None;
Ok(Action::Continue)
}
KeyCode::Char(character) if input.len() < 32 => {
input.push(character);
*error = None;
Ok(Action::Continue)
}
_ => Ok(Action::Continue),
},
Mode::Notice { .. } => match key.code {
KeyCode::Enter | KeyCode::Esc | KeyCode::Char('q') => {
self.mode = Mode::Browsing;
Ok(Action::Continue)
}
_ => Ok(Action::Continue),
},
Mode::ChoosingTool { operation } => {
let operation = *operation;
let tool = match key.code {
KeyCode::Esc => {
self.mode = Mode::Browsing;
return Ok(Action::Continue);
}
KeyCode::Char('c') => Tool::Claude,
KeyCode::Char('x') => Tool::Codex,
KeyCode::Char('o') => Tool::Opencode,
_ => return Ok(Action::Continue),
};
if operation == AuthOperation::Logout {
self.mode = Mode::ConfirmingLogout { tool };
Ok(Action::Continue)
} else {
Ok(Action::Authenticate { operation, tool })
}
}
Mode::ConfirmingLogout { tool } => match key.code {
KeyCode::Char('y') | KeyCode::Enter => Ok(Action::Authenticate {
operation: AuthOperation::Logout,
tool: *tool,
}),
KeyCode::Char('n') | KeyCode::Esc => {
self.mode = Mode::Browsing;
Ok(Action::Continue)
}
_ => Ok(Action::Continue),
},
}
}
fn is_default(&self, name: &str) -> bool {
self.default_profile.as_deref() == Some(name)
}
fn toggle_default(&mut self) {
let name = self.selected_profile().name.clone();
let pinned = (!self.is_default(&name)).then_some(name);
match self.store.set_default_profile_name(pinned.as_deref()) {
Ok(()) => self.default_profile = pinned,
Err(error) => {
self.mode = Mode::Notice {
title: " Cannot set default ",
message: format!("{error:#}"),
};
}
}
}
fn select_after_change(&mut self, name: &str) -> Result<()> {
self.profiles = self.store.list_profiles()?;
self.default_profile = self.store.default_profile_name()?;
self.selected = self
.profiles
.iter()
.position(|candidate| candidate.name == name)
.unwrap_or(0);
self.probe_selected();
self.mode = Mode::Browsing;
Ok(())
}
fn draw(&mut self, frame: &mut Frame) {
let area = frame.area();
if area.width < MINIMUM_WIDTH || area.height < MINIMUM_HEIGHT {
self.draw_too_small(frame, area);
return;
}
let narrow = area.width < WIDE_FOOTER_WIDTH;
let footer_height = 4 + u16::from(narrow) + u16::from(self.has_auth_environment);
let sections = Layout::vertical([
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(footer_height),
])
.split(area);
self.draw_header(frame, sections[0]);
self.draw_profiles(frame, sections[1]);
self.draw_footer(frame, sections[2], narrow);
self.draw_modal(frame, area);
}
fn draw_too_small(&self, frame: &mut Frame, area: Rect) {
let message = Paragraph::new(vec![
Line::styled("Ditto CLI", Style::new().fg(DITTO_PURPLE).bold()),
Line::default(),
Line::raw(format!(
"Resize to at least {MINIMUM_WIDTH}×{MINIMUM_HEIGHT}."
)),
Line::styled(
format!("This terminal is {}×{}.", area.width, area.height),
Style::new().fg(Color::DarkGray),
),
])
.alignment(Alignment::Center)
.wrap(Wrap { trim: true });
frame.render_widget(Clear, area);
frame.render_widget(message, centered_rect(90, 4.min(area.height), area));
}
fn draw_header(&self, frame: &mut Frame, area: Rect) {
let header = Paragraph::new(Line::from(vec![
Span::styled("Ditto CLI", Style::new().fg(DITTO_PURPLE).bold()),
Span::styled(
" choose a profile, then a tool",
Style::new().fg(Color::Gray),
),
]))
.alignment(Alignment::Center)
.block(Block::bordered().border_style(Style::new().fg(DITTO_PURPLE)));
frame.render_widget(header, area);
}
fn draw_profiles(&self, frame: &mut Frame, area: Rect) {
let columns = Layout::horizontal([Constraint::Length(26), Constraint::Min(30)]).split(area);
let items = self.profiles.iter().map(|profile| {
let suffix = if profile.managed { "" } else { " existing" };
let mark = if self.is_default(&profile.name) {
format!(" {DEFAULT_MARK}")
} else {
String::new()
};
ListItem::new(Line::from(vec![
Span::raw(&profile.name),
Span::styled(suffix, Style::new().fg(Color::DarkGray)),
Span::styled(mark, Style::new().fg(Color::Yellow)),
]))
});
let profile_list = List::new(items)
.block(Block::new().title(" Profiles ").borders(Borders::ALL))
.highlight_symbol("› ")
.highlight_style(
Style::new()
.fg(Color::Black)
.bg(DITTO_PURPLE)
.add_modifier(Modifier::BOLD),
);
let mut list_state = ListState::default().with_selected(Some(self.selected));
frame.render_stateful_widget(profile_list, columns[0], &mut list_state);
let details = self.profile_details(columns[1].width.saturating_sub(2) as usize);
frame.render_widget(
Paragraph::new(details).block(
Block::new()
.title(" Selected profile ")
.borders(Borders::ALL),
),
columns[1],
);
}
fn profile_details(&self, width: usize) -> Text<'static> {
let profile = self.selected_profile();
let auth = self.selected_auth();
let home = self.store.user_home();
let kind = if profile.managed {
"Isolated profile"
} else {
"Your existing setup"
};
let mut lines = vec![
Line::from(vec![
Span::styled(profile.name.clone(), Style::new().fg(DITTO_PURPLE).bold()),
Span::styled(format!(" {kind}"), Style::new().fg(Color::DarkGray)),
]),
Line::default(),
];
if self.is_default(&profile.name) {
lines.push(Line::styled(
format!("{DEFAULT_MARK} Used when no profile is named"),
Style::new().fg(Color::Yellow),
));
lines.push(Line::default());
}
lines.push(Line::styled("Sign-in status", Style::new().bold()));
lines.extend(Tool::ALL.map(|tool| status_row(tool, auth.get(tool), self.spinner)));
lines.push(Line::default());
lines.push(Line::styled("Profile directories", Style::new().bold()));
lines.extend(Tool::ALL.map(|tool| {
let path = match tool {
Tool::Claude => profile.claude_home.clone(),
Tool::Codex => profile.codex_home.clone(),
Tool::Opencode => profile.opencode.data_dir(),
Tool::Omp => profile.omp_home.clone(),
};
let path = shorten_home(&path, home);
Line::from(vec![
Span::styled(
format!("{:<TOOL_COLUMN$}", tool.label()),
Style::new().fg(tool_color(tool)),
),
Span::styled(
truncate_start(&path, width.saturating_sub(TOOL_COLUMN)),
Style::new().fg(Color::DarkGray),
),
])
}));
if !profile.managed {
lines.push(Line::default());
lines.push(Line::styled(
"Press n to create an isolated profile.",
Style::new().fg(Color::DarkGray),
));
}
Text::from(lines)
}
fn draw_footer(&self, frame: &mut Frame, area: Rect, narrow: bool) {
let mut lines = vec![shortcut_line(&TOOL_SHORTCUTS)];
if narrow {
lines.extend(NARROW_SHORTCUT_ROWS.iter().map(|row| shortcut_line(row)));
} else {
lines.push(shortcut_line(&WIDE_SHORTCUT_ROW));
}
if self.has_auth_environment {
lines.push(Line::styled(
"An API-key environment variable is set and may override the saved login.",
Style::new().fg(Color::Yellow),
));
}
frame.render_widget(
Paragraph::new(lines)
.alignment(Alignment::Center)
.block(Block::bordered().border_style(Style::new().fg(Color::DarkGray))),
area,
);
}
fn draw_modal(&self, frame: &mut Frame, area: Rect) {
match &self.mode {
Mode::Browsing => {}
Mode::Creating { input, error } => {
let mut lines = vec![
Line::raw("Use lowercase letters, numbers, '.', '-' or '_'."),
Line::styled(format!("> {input}"), Style::new().fg(DITTO_PURPLE).bold()),
Line::default(),
Line::styled(
"Enter create · Esc cancel",
Style::new().fg(Color::DarkGray),
),
];
if let Some(error) = error {
lines[2] = Line::styled(error.clone(), Style::new().fg(Color::Red));
}
render_popup(frame, centered_rect(64, 8, area), " New profile ", lines);
}
Mode::Renaming {
original,
input,
error,
} => {
let mut lines = vec![
Line::raw(format!("New name for '{original}':")),
Line::styled(format!("> {input}"), Style::new().fg(DITTO_PURPLE).bold()),
Line::default(),
Line::styled(
"Enter rename · Esc cancel",
Style::new().fg(Color::DarkGray),
),
];
if let Some(error) = error {
lines[2] = Line::styled(error.clone(), Style::new().fg(Color::Red));
} else if rename_signs_out(&self.auth, original) {
lines[2] = Line::styled(
"Claude Code will need a fresh sign-in afterwards.",
Style::new().fg(Color::Yellow),
);
}
render_popup(frame, centered_rect(64, 8, area), " Rename profile ", lines);
}
Mode::Notice { title, message } => {
let lines = vec![
Line::raw(message.clone()),
Line::default(),
Line::styled("Enter or Esc close", Style::new().fg(Color::DarkGray)),
];
render_popup(
frame,
centered_rect(64, notice_height(message, area), area),
title,
lines,
);
}
Mode::ChoosingTool { operation } => {
let lines = vec![
Line::raw(format!(
"{} to '{}' with:",
operation.label(),
self.selected_profile().name
)),
Line::default(),
shortcut_line(&[
("c", "Claude Code", CLAUDE_ORANGE),
("x", "Codex", CODEX_GREEN),
("o", "opencode", OPENCODE_CYAN),
]),
Line::default(),
Line::styled(
"OMP signs in and out from its own prompt.",
Style::new().fg(Color::DarkGray),
),
Line::default(),
Line::styled("Esc cancel", Style::new().fg(Color::DarkGray)),
];
render_popup(
frame,
centered_rect(62, 10, area),
&format!(" {} ", operation.label()),
lines,
);
}
Mode::ConfirmingLogout { tool } => {
let lines = vec![
Line::raw(format!(
"Sign out of {} for '{}'?",
tool.label(),
self.selected_profile().name
)),
Line::default(),
Line::styled(
"Enter or y confirm · n cancel",
Style::new().fg(Color::Yellow),
),
];
render_popup(
frame,
centered_rect(62, 7, area),
" Confirm sign out ",
lines,
);
}
}
}
}
enum Action {
Continue,
Quit,
Launch(Tool),
Authenticate {
operation: AuthOperation,
tool: Tool,
},
}
pub fn run(
store: &Store,
profiles: Vec<Profile>,
initial_profile: Option<&str>,
default_profile: Option<String>,
) -> Result<Option<UiAction>> {
let app = App::new(store, profiles, initial_profile, default_profile);
let mut terminal = ratatui::init();
let guard = TerminalGuard;
let result = run_loop(&mut terminal, app);
drop(guard);
result
}
fn run_loop(terminal: &mut DefaultTerminal, mut app: App<'_>) -> Result<Option<UiAction>> {
let mut dirty = true;
loop {
if dirty {
terminal.draw(|frame| app.draw(frame))?;
dirty = false;
}
if event::poll(TICK)? {
match event::read()? {
Event::Key(key) => {
match app.handle_key(key)? {
Action::Continue => {}
Action::Quit => return Ok(None),
Action::Launch(tool) => {
return Ok(Some(UiAction::Launch {
tool,
profile: app.selected_profile().clone(),
}));
}
Action::Authenticate { operation, tool } => {
return Ok(Some(UiAction::Authenticate {
operation,
tool,
profile: app.selected_profile().clone(),
}));
}
}
dirty = true;
}
Event::Resize(..) => dirty = true,
_ => {}
}
} else if app.waiting_on_probes() {
app.spinner = app.spinner.wrapping_add(1);
dirty = true;
}
if app.collect_probes() {
dirty = true;
}
}
}
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
ratatui::restore();
}
}
fn tool_color(tool: Tool) -> Color {
match tool {
Tool::Claude => CLAUDE_ORANGE,
Tool::Codex => CODEX_GREEN,
Tool::Opencode => OPENCODE_CYAN,
Tool::Omp => OMP_BLUE,
}
}
fn tool_row(tool: Tool, symbol: &str, label: &str, state_color: Color) -> Line<'static> {
Line::from(vec![
Span::styled(
format!("{:<TOOL_COLUMN$}", tool.label()),
Style::new().fg(tool_color(tool)),
),
Span::styled(format!("{symbol} {label}"), Style::new().fg(state_color)),
])
}
fn status_row(tool: Tool, status: Option<AuthStatus>, spinner: usize) -> Line<'static> {
let (symbol, label, color) = match status {
None => (
SPINNER[spinner % SPINNER.len()],
"Checking",
Color::DarkGray,
),
Some(AuthStatus::SignedIn) => ("●", "Signed in", Color::Green),
Some(AuthStatus::SignedOut) => ("○", "Sign in required", Color::Yellow),
Some(AuthStatus::Unavailable) => ("–", "Not available", Color::DarkGray),
};
tool_row(tool, symbol, label, color)
}
fn shorten_home(path: &Path, user_home: &Path) -> String {
match path.strip_prefix(user_home) {
Ok(relative) => format!("~/{}", relative.display()),
Err(_) => path.display().to_string(),
}
}
fn truncate_start(text: &str, budget: usize) -> String {
let length = text.chars().count();
if length <= budget {
return text.to_owned();
}
if budget <= 1 {
return "…".repeat(budget);
}
let mut truncated = String::from("…");
truncated.extend(text.chars().skip(length - budget + 1));
truncated
}
fn shortcut_line(shortcuts: &[(&str, &str, Color)]) -> Line<'static> {
let mut spans = Vec::with_capacity(shortcuts.len() * 3);
for (index, (key, label, color)) in shortcuts.iter().enumerate() {
if index > 0 {
spans.push(Span::styled(" · ", Style::new().fg(Color::DarkGray)));
}
spans.push(Span::styled(
(*key).to_owned(),
Style::new().fg(*color).bold(),
));
spans.push(Span::raw(format!(" {label}")));
}
Line::from(spans)
}
fn render_popup(frame: &mut Frame, area: Rect, title: &str, lines: Vec<Line<'static>>) {
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(lines).wrap(Wrap { trim: false }).block(
Block::new()
.title(title.to_owned())
.borders(Borders::ALL)
.border_style(Style::new().fg(DITTO_PURPLE)),
),
area,
);
}
fn rename_signs_out(auth: &HashMap<String, ProfileAuth>, name: &str) -> bool {
auth.get(name)
.and_then(|auth| auth.claude)
.is_some_and(|status| status == AuthStatus::SignedIn)
}
fn notice_height(message: &str, area: Rect) -> u16 {
let inner = usize::from(area.width * 64 / 100).saturating_sub(2).max(1);
let wrapped = u16::try_from(message.chars().count().div_ceil(inner)).unwrap_or(u16::MAX);
wrapped.saturating_add(4).clamp(7, area.height)
}
fn centered_rect(percent_x: u16, height: u16, area: Rect) -> Rect {
let vertical = Layout::vertical([Constraint::Length(height.min(area.height))])
.flex(Flex::Center)
.split(area)[0];
Layout::horizontal([Constraint::Percentage(percent_x)])
.flex(Flex::Center)
.split(vertical)[0]
}
fn auth_environment_is_set() -> bool {
[
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"OPENAI_API_KEY",
"OPENCODE_API_KEY",
]
.iter()
.any(|name| env::var_os(name).is_some())
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
#[test]
fn footer_rows_fit_the_widths_that_select_them() {
let wide = shortcut_line(&WIDE_SHORTCUT_ROW).width() as u16 + 2;
assert!(wide <= WIDE_FOOTER_WIDTH, "{wide} exceeds the wide footer");
assert!(
wide > WIDE_FOOTER_WIDTH - 1,
"the wide footer is {} wider than it needs to be, so terminals \
that could show one row are given two",
WIDE_FOOTER_WIDTH - wide
);
for row in NARROW_SHORTCUT_ROWS {
let width = shortcut_line(row).width() as u16 + 2;
assert!(width <= MINIMUM_WIDTH, "{width} exceeds the narrow footer");
}
let tools = shortcut_line(&TOOL_SHORTCUTS).width() as u16 + 2;
assert!(tools <= MINIMUM_WIDTH, "{tools} exceeds the narrow footer");
}
#[test]
fn abbreviates_paths_inside_the_home_directory() {
let home = PathBuf::from("/Users/rey");
assert_eq!(
shorten_home(&home.join(".ditto/profiles/work/claude"), &home),
"~/.ditto/profiles/work/claude"
);
assert_eq!(
shorten_home(Path::new("/opt/shared/claude"), &home),
"/opt/shared/claude"
);
}
#[test]
fn keeps_the_tail_of_a_path_that_does_not_fit() {
assert_eq!(
truncate_start("~/.ditto/work/claude", 40),
"~/.ditto/work/claude"
);
assert_eq!(truncate_start("~/.ditto/work/claude", 12), "…work/claude");
assert_eq!(truncate_start("abc", 1), "…");
assert_eq!(truncate_start("abc", 0), "");
assert_eq!(truncate_start("→→→→", 3), "…→→");
}
#[test]
fn reports_probes_as_pending_until_every_tool_answers() {
let mut auth = ProfileAuth::default();
assert!(auth.pending());
auth.set(Tool::Claude, AuthStatus::SignedIn);
auth.set(Tool::Codex, AuthStatus::SignedOut);
assert!(auth.pending());
auth.set(Tool::Opencode, AuthStatus::SignedIn);
assert!(auth.pending());
auth.set(Tool::Omp, AuthStatus::SignedOut);
assert!(!auth.pending());
assert_eq!(auth.get(Tool::Opencode), Some(AuthStatus::SignedIn));
assert_eq!(auth.get(Tool::Omp), Some(AuthStatus::SignedOut));
}
}