use super::popups::{
confirm_prompt, render_confirm, render_suggest, render_tool_confirm, suggest_min_size,
tool_prompt,
};
use super::*;
use crate::shared::wrap;
use crate::widgets::impersonation_preview::Preview;
use crate::widgets::input_box::RenderOpts;
use crate::widgets::message_feed::{FeedCaption, FeedFrame};
pub(super) const CHAT_MIN_SIZE: MinSize = MinSize::new(20, 3);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct ChatChrome {
pub status_rows: u16,
pub input_border: bool,
pub feed_border: bool,
}
impl ChatChrome {
pub(super) const fn at(rows: u16) -> Self {
Self {
status_rows: match rows {
0..=5 => 0,
6..=10 => 1,
_ => status_bar::HINT_ROWS_MAX,
},
input_border: rows >= 10,
feed_border: rows >= 8,
}
}
const fn feed_min_rows(self) -> u16 {
if self.feed_border { 3 } else { 1 }
}
}
impl ChatScreen {
pub(super) fn model_meta(&self) -> FeedCaption {
use crate::shared::config::ServerMode;
let Some((cfg, _, _, _, _)) = &self.settings_snapshot else {
return FeedCaption::default();
};
let Some(name) = cfg
.engine
.active_model_name()
.or_else(|| self.engine_model.clone())
else {
return FeedCaption::default();
};
let ctx = cfg.engine.managed.context_size;
let detail = (cfg.engine.mode == ServerMode::Managed && ctx > 0)
.then(|| format!("{}k ctx", (ctx + 512) / 1024));
FeedCaption { name, detail }
}
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 min_size(&self, area: Rect) -> MinSize {
self.popup_needs(area)
.map_or(CHAT_MIN_SIZE, |popup| CHAT_MIN_SIZE.max(popup))
}
pub fn has_popup(&self) -> bool {
self.popup_needs(Rect::default()).is_some()
}
fn popup_needs(&self, area: Rect) -> Option<MinSize> {
let (palette, loc) = (&self.palette, self.loc);
[
self.profile_overlay
.as_ref()
.map(|_| ProfileListState::min_size(loc)),
self.suggest.as_ref().map(|_| suggest_min_size(loc)),
self.emoji.as_ref().map(|_| EmojiPickerState::min_size(loc)),
self.chat_links
.as_ref()
.map(|_| ChatLinkPickerState::min_size(loc)),
self.local_servers
.as_ref()
.map(|_| LocalServerPickerState::min_size(loc)),
self.tool_confirm
.as_ref()
.map(|pending| tool_prompt(pending, palette, loc).min_size(palette, area)),
self.confirm
.as_ref()
.map(|action| confirm_prompt(action, palette, loc).min_size(palette, area)),
]
.into_iter()
.flatten()
.reduce(MinSize::max)
}
pub fn render(&mut self, frame: &mut Frame) {
let _ = self.maybe_recheck_spelling();
let chrome = ChatChrome::at(frame.area().height);
let area_w = frame.area().width;
let input_title = self.input_title(chrome);
let input_opts = self.input_opts(&input_title, chrome);
let content_lines = if let Some(imp) = &self.impersonation {
visual_line_count(&imp.text, Preview::text_width(area_w).max(1))
} else {
self.input.content_rows(area_w, &input_opts)
};
let input_h = input_height(
content_lines,
self.input_rows_ceiling(),
frame.area().height,
chrome.input_border,
);
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,
chrome.status_rows,
);
let [feed_area, banner_area, input_area, status_area] =
chat_areas(frame.area(), chrome, banner_h, input_h, status_h);
let title = if self.title.is_empty() {
crate::shared::credits::APP_NAME.to_string()
} else {
self.title.clone()
};
let caption = self.model_meta();
let header = if chrome.feed_border {
FeedFrame::Bordered {
title: &title,
caption: &caption,
}
} else {
FeedFrame::Bare
};
self.feed_view.render(
frame,
feed_area,
header,
&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, input_opts, chrome);
self.render_overlays(frame);
}
fn input_title(&self, chrome: ChatChrome) -> String {
let command_word = (self.input_is_command() && self.palette.mono)
.then(|| self.loc.t("ui.chat.input.command").to_string());
if !chrome.input_border {
return command_word.unwrap_or_default();
}
let hint = 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())],
)
};
match command_word {
Some(word) => format!("{word} · {hint}"),
None => hint,
}
}
fn input_opts<'a>(&self, title: &'a str, chrome: ChatChrome) -> RenderOpts<'a> {
let focused = self.profile_overlay.is_none()
&& self.suggest.is_none()
&& self.emoji.is_none()
&& self.chat_links.is_none()
&& self.local_servers.is_none()
&& self.confirm.is_none()
&& self.tool_confirm.is_none();
RenderOpts {
title,
focused,
command: self.input_is_command(),
placeholder: self.loc.t("ui.chat.input.placeholder"),
bare: !chrome.input_border,
}
}
fn render_input_area(
&mut self,
frame: &mut Frame,
input_area: Rect,
input_opts: RenderOpts,
chrome: ChatChrome,
) {
if let Some(imp) = &mut self.impersonation {
let spinner = imp.spinner.glyph(self.palette.glyphs().spinner);
let preview = Preview {
text: &imp.text,
spinner,
done: imp.done,
bare: !chrome.input_border,
};
impersonation_preview::render(frame, input_area, &preview, &self.palette, self.loc);
} else if let Some(field) = &mut self.search {
let position = self.feed_view.match_position();
let title = match (chrome.input_border, position) {
(true, Some((n, total))) => self.loc.tf(
"ui.chat.search.counter",
&[("n", &n.to_string()), ("total", &total.to_string())],
),
(true, None) => self.loc.t("ui.chat.search.none").to_string(),
(false, position) => {
let (n, total) = position.unwrap_or((0, 0));
self.loc.tf(
"ui.chat.search.tag",
&[("n", &n.to_string()), ("total", &total.to_string())],
)
}
};
field.render(
frame,
input_area,
RenderOpts {
title: &title,
focused: true,
command: false,
placeholder: self.loc.t("ui.chat.search.placeholder"),
bare: !chrome.input_border,
},
&self.palette,
);
} else {
self.input
.render(frame, input_area, input_opts, &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(list) = &mut self.local_servers {
dim_background(frame, &self.palette);
list.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 chat_areas(
area: Rect,
chrome: ChatChrome,
banner_h: u16,
input_h: u16,
status_h: u16,
) -> [Rect; 4] {
let input_h = input_h.min(
area.height
.saturating_sub(chrome.feed_min_rows() + banner_h + status_h),
);
let mut bottom = area.bottom();
let mut above = |rows: u16| {
let rows = rows.min(bottom - area.y);
bottom -= rows;
Rect {
y: bottom,
height: rows,
..area
}
};
let status = above(status_h);
let input = above(input_h);
let banner = above(banner_h);
let feed = above(u16::MAX);
[feed, banner, input, status]
}
pub(super) fn input_height(
content_rows: usize,
ceiling: u16,
screen_h: u16,
bordered: bool,
) -> u16 {
let (chrome, share) = if bordered {
(2, screen_h / 2)
} else {
(0, screen_h / 3)
};
let rows = ceiling.min(share.saturating_sub(chrome)).max(1);
content_rows.clamp(1, usize::from(rows)) as u16 + chrome
}
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
}