use super::popups::{render_confirm, render_suggest, render_tool_confirm};
use super::*;
use crate::shared::wrap;
impl ChatScreen {
pub(super) fn model_meta(&self) -> String {
use crate::shared::config::ServerMode;
let Some((cfg, _, _, _, _)) = &self.settings_snapshot else {
return String::new();
};
let Some(name) = cfg
.engine
.active_model_name()
.or_else(|| self.engine_model.clone())
else {
return String::new();
};
let ctx = cfg.engine.managed.context_size;
if cfg.engine.mode == ServerMode::Managed && ctx > 0 {
format!("{name} · {}k ctx", (ctx + 512) / 1024)
} else {
name
}
}
pub(super) fn status_model<'a>(
&'a self,
background: Option<&'a str>,
attachments: Option<&'a str>,
staged_images: Option<&'a str>,
) -> status_bar::StatusModel<'a> {
status_bar::StatusModel {
statuses: &self.statuses,
generating: self.generating,
tokens: self.gen_tokens,
context: self.gen_context,
context_exact: self.gen_context_exact,
reasoning: self.gen_reasoning,
mouse_scroll: self.mouse_scroll,
background,
speaking: self.speaking,
attachments,
staged_images,
esc_target: self.esc_target,
read_only: self.read_only(),
background_run: self.background_run,
}
}
pub fn spinner_due(&self) -> bool {
self.rag.as_ref().is_some_and(|b| b.spinner.due())
|| self.impersonation.as_ref().is_some_and(|i| i.spinner.due())
}
pub fn render(&mut self, frame: &mut Frame) {
let _ = self.maybe_recheck_spelling();
let area_w = frame.area().width;
let content_lines = if let Some(imp) = &self.impersonation {
visual_line_count(&imp.text, area_w.saturating_sub(2).max(1) as usize)
} else {
self.input.content_rows(area_w)
};
let input_h = input_height(
content_lines,
self.input_rows_ceiling(),
frame.area().height,
);
let banner_h: u16 = if self.rag.is_some() { 1 } else { 0 };
let background = self.background_hint();
let files = self.attachments_hint();
let images = self.staged_images_hint();
let status_h = status_bar::height(
frame.area().width as usize,
&self.status_model(background.as_deref(), files.as_deref(), images.as_deref()),
&self.palette,
self.loc,
);
let [feed_area, banner_area, input_area, status_area] = Layout::vertical([
Constraint::Min(3),
Constraint::Length(banner_h),
Constraint::Length(input_h),
Constraint::Length(status_h),
])
.areas(frame.area());
let title = if self.title.is_empty() {
crate::shared::credits::APP_NAME.to_string()
} else {
self.title.clone()
};
let meta = self.model_meta();
self.feed_view.render(
frame,
feed_area,
&title,
&meta,
&self.feed,
&self.palette,
self.loc,
);
if let Some(banner) = &mut self.rag {
let spinner = banner.spinner.glyph(self.palette.glyphs().spinner);
let prefix = format!("{spinner} RAG: ");
let avail = (banner_area.width as usize).saturating_sub(wrap::str_width(&prefix));
let line = Line::from(vec![
Span::styled(prefix, self.palette.accent_style()),
Span::from(banner.fit(avail)),
]);
frame.render_widget(line, banner_area);
}
status_bar::render(
frame,
status_area,
&self.status_model(background.as_deref(), files.as_deref(), images.as_deref()),
&self.palette,
self.loc,
);
self.render_input_area(frame, input_area);
self.render_overlays(frame);
}
fn render_input_area(&mut self, frame: &mut Frame, input_area: Rect) {
if let Some(imp) = &mut self.impersonation {
let spinner = imp.spinner.glyph(self.palette.glyphs().spinner);
impersonation_preview::render(
frame,
input_area,
&imp.text,
spinner,
imp.done,
&self.palette,
self.loc,
);
} else if let Some(field) = &mut self.search {
let title = match self.feed_view.match_position() {
Some((n, total)) => self.loc.tf(
"ui.chat.search.counter",
&[("n", &n.to_string()), ("total", &total.to_string())],
),
None => self.loc.t("ui.chat.search.none").to_string(),
};
field.render(
frame,
input_area,
crate::widgets::input_box::RenderOpts {
title: &title,
focused: true,
command: false,
placeholder: self.loc.t("ui.chat.search.placeholder"),
},
&self.palette,
);
} else {
let input_title = if self.read_only() {
self.loc.t("ui.chat.input.read_only").to_string()
} else if self.generating {
self.loc.t("ui.chat.input.generating").to_string()
} else {
self.loc.tf(
"ui.chat.input.idle",
&[("newline", crate::shared::keys::newline_chord())],
)
};
let focused = self.profile_overlay.is_none()
&& self.suggest.is_none()
&& self.emoji.is_none()
&& self.chat_links.is_none()
&& self.confirm.is_none();
let command = self.input_is_command();
let input_title = if command && self.palette.mono {
format!("{} · {input_title}", self.loc.t("ui.chat.input.command"))
} else {
input_title
};
self.input.render(
frame,
input_area,
crate::widgets::input_box::RenderOpts {
title: input_title.as_str(),
focused,
command,
placeholder: self.loc.t("ui.chat.input.placeholder"),
},
&self.palette,
);
}
}
fn render_overlays(&mut self, frame: &mut Frame) {
if let Some(overlay) = &mut self.profile_overlay {
overlay.render(frame, frame.area(), &self.palette, self.loc);
}
if let Some(popup) = &mut self.suggest {
dim_background(frame, &self.palette);
render_suggest(frame, popup, &self.palette, self.loc);
}
if let Some(picker) = &self.emoji {
dim_background(frame, &self.palette);
picker.render(frame, frame.area(), &self.palette, self.loc);
}
if let Some(picker) = &mut self.chat_links {
dim_background(frame, &self.palette);
picker.render(frame, frame.area(), &self.palette, self.loc);
}
if let Some(pending) = &self.tool_confirm {
dim_background(frame, &self.palette);
render_tool_confirm(frame, pending, &self.palette, self.loc);
}
if let Some(action) = &self.confirm {
dim_background(frame, &self.palette);
render_confirm(frame, action, &self.palette, self.loc);
}
}
}
pub(super) fn input_height(content_rows: usize, ceiling: u16, screen_h: u16) -> u16 {
const BORDER: u16 = 2;
let half_window = (screen_h / 2).saturating_sub(BORDER);
let rows = ceiling.min(half_window).max(1);
content_rows.clamp(1, usize::from(rows)) as u16 + BORDER
}
pub(super) fn visual_line_count(text: &str, width: usize) -> usize {
if width == 0 {
return 1;
}
text.split('\n')
.map(|line| {
let chars: Vec<char> = line.chars().collect();
crate::shared::wrap::wrap_ranges(&chars, width).len().max(1)
})
.sum::<usize>()
.max(1)
}
pub(super) fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let [h] = Layout::horizontal([Constraint::Length(width.min(area.width))])
.flex(Flex::Center)
.areas(area);
let [v] = Layout::vertical([Constraint::Length(height.min(area.height))])
.flex(Flex::Center)
.areas(h);
v
}