use crate::cli::tui::data;
use super::*;
fn openclaw_header_default_model_value(data: &UiData) -> String {
if app::openclaw_agents_has_blocking_warning(data) {
return texts::tui_header_config_error().to_string();
}
data.config
.openclaw_agents_defaults
.as_ref()
.and_then(|defaults| defaults.model.as_ref())
.and_then(|model| {
if model.primary.trim().is_empty() {
None
} else {
Some(model.primary.clone())
}
})
.unwrap_or_else(|| texts::none().to_string())
}
fn opencode_configured_provider_count(data: &UiData) -> usize {
data.providers
.rows
.iter()
.filter(|row| row.is_in_config)
.count()
}
fn header_status_label(app_type: &AppType) -> &'static str {
match app_type {
AppType::OpenCode => texts::tui_opencode_config_status_label(),
AppType::OpenClaw => texts::tui_openclaw_agents_primary_model(),
_ => strip_trailing_colon(texts::provider_label()),
}
}
fn header_status_value(app: &App, data: &UiData) -> String {
if matches!(app.app_type, AppType::OpenCode) {
return texts::tui_provider_config_count(
opencode_configured_provider_count(data),
data.providers.rows.len(),
);
}
if matches!(app.app_type, AppType::OpenClaw) {
return openclaw_header_default_model_value(data);
}
data.providers
.rows
.iter()
.find(|row| row.is_current)
.map(|row| data::provider_display_name(&app.app_type, row))
.unwrap_or_else(|| texts::none().to_string())
}
fn fit_header_status_badge(
status_text_full: &str,
available_after_title: u16,
proxy_badge_width: Option<u16>,
) -> Option<String> {
let full_status_badge = format!(" {status_text_full} ");
let full_status_badge_width = UnicodeWidthStr::width(full_status_badge.as_str()) as u16;
let reserved_proxy_width = proxy_badge_width.map(|width| width + 1).unwrap_or(0);
if reserved_proxy_width + full_status_badge_width <= available_after_title {
return Some(full_status_badge);
}
let status_budget = available_after_title.saturating_sub(reserved_proxy_width);
if status_budget < 5 {
return None;
}
let truncated = truncate_to_display_width(status_text_full, status_budget.saturating_sub(4));
Some(format!(" {truncated} "))
}
pub(super) fn render_header(
frame: &mut Frame<'_>,
app: &App,
data: &UiData,
area: Rect,
theme: &super::theme::Theme,
) {
let title_text = format!(" {}", texts::tui_app_title());
let title_width = UnicodeWidthStr::width(title_text.as_str()) as u16;
let title = Paragraph::new(Line::from(vec![Span::styled(
title_text,
if theme.no_color {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
},
)]))
.alignment(Alignment::Left);
let visible_apps = crate::settings::get_visible_apps();
let tabs_line = Line::from(
super::config::ordered_visible_app_types(&visible_apps)
.into_iter()
.enumerate()
.flat_map(|(idx, app_type)| {
let style = if app_type == app.app_type {
active_chip_style(theme)
} else {
inactive_chip_style(theme)
};
let mut spans = Vec::with_capacity(2);
if idx > 0 {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(format!(" {} ", app_type.as_str()), style));
spans
})
.collect::<Vec<_>>(),
);
let proxy_badge = data
.proxy
.routes_current_app_through_proxy(&app.app_type)
.map(|enabled| {
let text = texts::tui_header_proxy_status(enabled);
let style = if enabled {
selection_style(theme)
} else if theme.no_color {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White).bg(theme.surface)
};
(format!(" {text} "), style)
});
let status_text_full = format!(
"{}: {}",
header_status_label(&app.app_type),
header_status_value(app, data)
);
let available_after_title = area.width.saturating_sub(title_width);
let proxy_badge_width = proxy_badge
.as_ref()
.map(|(text, _)| UnicodeWidthStr::width(text.as_str()) as u16);
let status_badge =
fit_header_status_badge(&status_text_full, available_after_title, proxy_badge_width);
let status_badge_width = status_badge
.as_ref()
.map(|text| UnicodeWidthStr::width(text.as_str()) as u16);
let right_width = match (proxy_badge_width, status_badge_width) {
(Some(proxy_width), Some(status_width)) => proxy_width + 1 + status_width,
(Some(proxy_width), None) => proxy_width,
(None, Some(status_width)) => status_width,
(None, None) => 0,
};
let title_area = Rect::new(area.x, area.y, title_width.min(area.width), area.height);
let right_x = area
.right()
.saturating_sub(right_width)
.max(title_area.right());
let right_area = Rect::new(
right_x,
area.y,
area.right().saturating_sub(right_x),
area.height,
);
let tabs_x = title_area.right();
let tabs_right = right_area.x;
let tabs_area = Rect::new(
tabs_x,
area.y,
tabs_right.saturating_sub(tabs_x),
area.height,
);
frame.render_widget(
Paragraph::new(tabs_line).alignment(Alignment::Center),
tabs_area,
);
frame.render_widget(title, title_area);
let right_spans = match (proxy_badge, status_badge) {
(Some((proxy_text, proxy_style)), Some(status_text)) => vec![
Span::styled(proxy_text, proxy_style),
Span::raw(" "),
Span::styled(status_text, selection_style(theme)),
],
(None, Some(status_text)) => vec![Span::styled(status_text, selection_style(theme))],
(Some((proxy_text, proxy_style)), None) => vec![Span::styled(proxy_text, proxy_style)],
(None, None) => Vec::new(),
};
frame.render_widget(
Paragraph::new(Line::from(right_spans)).alignment(Alignment::Right),
right_area,
);
}
pub(super) fn split_nav_label(label: &str) -> (&str, &str) {
if let Some((icon, rest)) = label.split_once(' ') {
(icon, rest)
} else {
("", label)
}
}
pub(super) fn nav_label(item: NavItem) -> &'static str {
match item {
NavItem::Main => texts::menu_home(),
NavItem::Providers => texts::menu_manage_providers(),
NavItem::Mcp => texts::menu_manage_mcp(),
NavItem::Prompts => texts::menu_manage_prompts(),
NavItem::Config => texts::menu_manage_config(),
NavItem::Skills => texts::menu_manage_skills(),
NavItem::OpenClawWorkspace => texts::menu_openclaw_workspace(),
NavItem::OpenClawEnv => texts::menu_openclaw_env(),
NavItem::OpenClawTools => texts::menu_openclaw_tools(),
NavItem::OpenClawAgents => texts::menu_openclaw_agents(),
NavItem::Settings => texts::menu_settings(),
NavItem::Exit => texts::menu_exit(),
}
}
pub(super) fn nav_label_variants(item: NavItem) -> (&'static str, &'static str) {
match item {
NavItem::Main => texts::menu_home_variants(),
NavItem::Providers => texts::menu_manage_providers_variants(),
NavItem::Mcp => texts::menu_manage_mcp_variants(),
NavItem::Prompts => texts::menu_manage_prompts_variants(),
NavItem::Config => texts::menu_manage_config_variants(),
NavItem::Skills => texts::menu_manage_skills_variants(),
NavItem::OpenClawWorkspace => texts::menu_openclaw_workspace_variants(),
NavItem::OpenClawEnv => texts::menu_openclaw_env_variants(),
NavItem::OpenClawTools => texts::menu_openclaw_tools_variants(),
NavItem::OpenClawAgents => texts::menu_openclaw_agents_variants(),
NavItem::Settings => texts::menu_settings_variants(),
NavItem::Exit => texts::menu_exit_variants(),
}
}
pub(super) fn nav_pane_width(theme: &super::theme::Theme) -> u16 {
const NAV_BORDER_WIDTH: u16 = 2;
const NAV_ICON_COL_WIDTH: u16 = 3;
const NAV_COL_SPACING: u16 = 1;
const NAV_TEXT_MIN_WIDTH: u16 = 10;
const NAV_TEXT_EXTRA_WIDTH: u16 = 2;
let highlight_width = UnicodeWidthStr::width(highlight_symbol(theme)) as u16;
let max_text_width = NavItem::ALL
.iter()
.chain(NavItem::OPENCLAW_ALL.iter())
.flat_map(|item| {
let (en, zh) = nav_label_variants(*item);
[en, zh]
})
.map(|label| {
let (_icon, text) = split_nav_label(label);
UnicodeWidthStr::width(text) as u16
})
.max()
.unwrap_or(NAV_TEXT_MIN_WIDTH);
let text_col_width = max_text_width
.saturating_add(NAV_TEXT_EXTRA_WIDTH)
.max(NAV_TEXT_MIN_WIDTH);
NAV_BORDER_WIDTH
.saturating_add(highlight_width)
.saturating_add(NAV_ICON_COL_WIDTH)
.saturating_add(NAV_COL_SPACING)
.saturating_add(text_col_width)
}
pub(super) fn render_nav(
frame: &mut Frame<'_>,
app: &App,
area: Rect,
theme: &super::theme::Theme,
) {
let rows = app.nav_items().iter().map(|item| {
let (icon, text) = split_nav_label(nav_label(*item));
let icon_clean = cell_pad(icon).replace('\u{FE0F}', "");
Row::new(vec![Cell::from(icon_clean), Cell::from(text)])
});
let table = Table::new(rows, [Constraint::Length(3), Constraint::Min(10)])
.column_spacing(1)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(pane_border_style(app, Focus::Nav, theme))
.title(texts::tui_nav_title()),
)
.row_highlight_style(selection_style(theme))
.highlight_symbol(highlight_symbol(theme));
let mut state = TableState::default();
state.select(Some(app.nav_idx));
frame.render_stateful_widget(table, area, &mut state);
}
pub(super) fn render_footer(
frame: &mut Frame<'_>,
app: &App,
data: &UiData,
area: Rect,
theme: &super::theme::Theme,
) {
let current_app_routed = data
.proxy
.routes_current_app_through_proxy(&app.app_type)
.unwrap_or(false);
let app_supports_proxy_control = data.proxy.takeover_enabled_for(&app.app_type).is_some();
let proxy_action_available = matches!(app.route, Route::Main)
&& app_supports_proxy_control
&& (!data.proxy.running || data.proxy.managed_runtime || current_app_routed);
let proxy_footer_label = if current_app_routed {
texts::tui_key_proxy_off()
} else {
texts::tui_key_proxy_on()
};
let spans = if app.filter.active {
vec![Span::styled(
texts::tui_footer_filter_mode(),
Style::default().fg(theme.dim),
)]
} else {
if theme.no_color {
let proxy_segment = if proxy_action_available {
format!(" P {}", proxy_footer_label)
} else {
String::new()
};
vec![Span::styled(
format!(
"{} {} {} {}{}",
texts::tui_footer_group_nav(),
texts::tui_footer_nav_keys(),
texts::tui_footer_group_actions(),
texts::tui_footer_action_keys_global(),
proxy_segment,
),
Style::default(),
)]
} else {
let nav_bg = super::theme::terminal_palette_color((101, 113, 160)); let act_bg = super::theme::terminal_palette_color((248, 248, 248)); let nav_fg = super::theme::terminal_palette_color((255, 255, 255));
let act_fg = super::theme::terminal_palette_color((108, 108, 108));
let nav_label_style = Style::default()
.fg(nav_fg)
.bg(nav_bg)
.add_modifier(Modifier::BOLD);
let act_label_style = Style::default()
.fg(act_fg)
.bg(act_bg)
.add_modifier(Modifier::BOLD);
let nav_key_style = Style::default()
.fg(nav_fg)
.bg(nav_bg)
.add_modifier(Modifier::BOLD);
let nav_desc_style = Style::default().fg(nav_fg).bg(nav_bg);
let act_key_style = Style::default()
.fg(act_fg)
.bg(act_bg)
.add_modifier(Modifier::BOLD);
let act_desc_style = Style::default().fg(act_fg).bg(act_bg);
let nav_sep = Span::styled(" ", nav_desc_style);
let act_sep = Span::styled(" ", act_desc_style);
let nav_items: &[(&str, &str)] = if i18n::is_chinese() {
&[("←→", "菜单/内容"), ("↑↓", "移动")]
} else {
&[("←→", "menu/content"), ("↑↓", "move")]
};
let act_items_base: &[(&str, &str)] = if i18n::is_chinese() {
&[
("[ ]", "切换应用"),
("/", "过滤"),
("Esc", "返回"),
("?", "帮助"),
]
} else {
&[
("[ ]", "switch app"),
("/", "filter"),
("Esc", "back"),
("?", "help"),
]
};
let mut act_items = act_items_base.to_vec();
if proxy_action_available {
act_items.push(("P", proxy_footer_label));
}
let mut v = Vec::new();
v.push(Span::styled(" NAV ", nav_label_style));
for (i, (key, desc)) in nav_items.iter().enumerate() {
if i > 0 {
v.push(nav_sep.clone());
}
v.push(Span::styled(format!(" {} ", key), nav_key_style));
v.push(Span::styled(format!(" {}", desc), nav_desc_style));
}
v.push(Span::styled(" ", nav_desc_style));
v.push(Span::raw(" "));
v.push(Span::styled(" ACT ", act_label_style));
for (i, (key, desc)) in act_items.iter().enumerate() {
if i > 0 {
v.push(act_sep.clone());
}
v.push(Span::styled(format!(" {} ", key), act_key_style));
v.push(Span::styled(format!(" {}", desc), act_desc_style));
}
v.push(Span::styled(" ", act_desc_style));
v
}
};
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
pub(super) fn render_toast(frame: &mut Frame<'_>, app: &App, theme: &super::theme::Theme) {
let Some(toast) = &app.toast else {
return;
};
let content_area = content_pane_rect(frame.area(), theme);
let (prefix, color) = match toast.kind {
ToastKind::Info => (
texts::tui_toast_prefix_info(),
transient_feedback_color(theme, &toast.kind),
),
ToastKind::Success => (
texts::tui_toast_prefix_success(),
transient_feedback_color(theme, &toast.kind),
),
ToastKind::Warning => (
texts::tui_toast_prefix_warning(),
transient_feedback_color(theme, &toast.kind),
),
ToastKind::Error => (
texts::tui_toast_prefix_error(),
transient_feedback_color(theme, &toast.kind),
),
};
let message = format!("{} {}", prefix.trim(), toast.message);
let area = toast_rect(content_area, &message);
frame.render_widget(Clear, area);
let outer = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(color).add_modifier(Modifier::BOLD))
.style(Style::default().bg(theme.surface));
frame.render_widget(outer.clone(), area);
let inner = outer.inner(area);
let text_style = if theme.no_color {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(color)
.bg(theme.surface)
.add_modifier(Modifier::BOLD)
};
frame.render_widget(
Paragraph::new(centered_message_lines(&message, inner.width, inner.height))
.alignment(Alignment::Center)
.style(text_style)
.wrap(Wrap { trim: false }),
inner,
);
}
pub(super) fn toast_rect(content_area: Rect, message: &str) -> Rect {
let max_width = content_area
.width
.saturating_sub(4)
.max(1)
.min(TOAST_MAX_WIDTH);
let min_width = TOAST_MIN_WIDTH.min(max_width);
let width = (UnicodeWidthStr::width(message) as u16)
.saturating_add(8)
.clamp(min_width, max_width);
let inner_width = width.saturating_sub(2).max(1);
let wrapped_lines = wrap_message_lines(message, inner_width).len() as u16;
let max_height = content_area.height.saturating_sub(4).max(1);
let min_height = TOAST_MIN_HEIGHT.min(max_height);
let height = wrapped_lines
.saturating_add(2)
.max(min_height)
.min(max_height);
centered_rect_fixed(width, height, content_area)
}