use super::render::centered_rect;
use super::*;
impl ChatScreen {
pub(super) fn open_suggestions(&mut self) {
let Some(spell) = &self.spell else { return };
let (row, col) = self.input.cursor();
let lines = self.input.line_strings();
let Some(line) = lines.get(row) else { return };
let Some(word) = spell.misspelled_word_at(line, col) else {
return;
};
let mut items: Vec<SuggestItem> = spell
.suggest(&word.text)
.into_iter()
.map(SuggestItem::Replace)
.collect();
items.push(SuggestItem::AddToDictionary);
self.suggest = Some(SuggestPopup {
word: word.text,
row,
start: word.start,
end: word.end,
items,
selected: 0,
scroll: ListScroll::default(),
});
}
pub(super) fn handle_tool_confirm_key(&mut self, key: KeyEvent) -> Option<ChatIntent> {
if key.code == KeyCode::F(10)
|| (key.modifiers.contains(KeyModifiers::CONTROL)
&& keys::hotkey_char(&key) == Some('q'))
{
self.tool_confirm = None;
return Some(ChatIntent::Quit);
}
let decision = match key.code {
KeyCode::Enter => ToolDecision::Allow,
KeyCode::Esc => ToolDecision::Deny,
KeyCode::Char(_) if keys::hotkey_char(&key) == Some('a') => ToolDecision::AllowForTurn,
_ => return None,
};
let pending = self.tool_confirm.take()?;
Some(ChatIntent::ConfirmTool {
generation_id: pending.generation_id,
call_id: pending.call_id,
decision,
})
}
pub(super) fn handle_confirm_key(&mut self, key: KeyEvent) -> Option<ChatIntent> {
let action = self.confirm.clone()?;
if key.code == KeyCode::F(10)
|| (key.modifiers.contains(KeyModifiers::CONTROL)
&& keys::hotkey_char(&key) == Some('q'))
{
self.confirm = None;
return Some(ChatIntent::Quit);
}
match key.code {
KeyCode::Enter => {
self.confirm = None;
self.confirmed_intent(action)
}
KeyCode::Esc => {
self.confirm = None;
None
}
_ => None,
}
}
fn confirmed_intent(&mut self, action: ConfirmAction) -> Option<ChatIntent> {
match action {
ConfirmAction::DeleteImpersonation { id, name } => self.delete_impersonation(id, &name),
other => {
if self.generating {
return None;
}
other.intent()
}
}
}
pub(super) fn handle_suggest_key(&mut self, key: KeyEvent) {
let Some(popup) = &mut self.suggest else {
return;
};
match key.code {
KeyCode::Esc => self.suggest = None,
KeyCode::Up => {
popup.selected = popup.selected.saturating_sub(1);
return;
}
KeyCode::Down => {
popup.selected = (popup.selected + 1).min(popup.items.len().saturating_sub(1));
return;
}
KeyCode::Enter => self.apply_suggestion(),
_ => return,
}
self.request_full_redraw();
}
pub(super) fn apply_suggestion(&mut self) {
let Some(popup) = self.suggest.take() else {
return;
};
match popup.items.get(popup.selected) {
Some(SuggestItem::Replace(word)) => {
self.input
.replace_range(popup.row, popup.start, popup.end, word);
}
Some(SuggestItem::AddToDictionary) => {
if let Some(spell) = &mut self.spell
&& let Err(err) = spell.add_to_personal(&popup.word)
{
tracing::warn!(error = %err, "failed to append to the personal dictionary");
}
}
None => {}
}
self.mark_input_changed();
}
pub(super) fn open_chat_links(&mut self) {
let linked = self.feed_view.chat_links();
let cards: Vec<ChatSummary> = linked
.iter()
.filter_map(|id| self.summary_card(*id))
.collect();
if cards.is_empty() {
self.push_note(self.loc.t("ui.chat.links_none"));
return;
}
self.chat_links = Some(ChatLinkPickerState::new(cards, self.active_chat));
}
pub(super) fn handle_chat_link_key(&mut self, key: KeyEvent) -> Option<ChatIntent> {
let picker = self.chat_links.as_mut()?;
match picker.on_key(key) {
ChatLinkAction::None => None,
ChatLinkAction::Cancel => {
self.chat_links = None;
None
}
ChatLinkAction::Open(id) => {
self.chat_links = None;
if self.active_chat == Some(id) {
self.push_note(self.loc.t("ui.chat.links_here"));
return None;
}
Some(ChatIntent::OpenChatLink(id))
}
}
}
pub(super) fn handle_local_server_key(&mut self, key: KeyEvent) -> Option<ChatIntent> {
if key.code == KeyCode::F(10)
|| (key.modifiers.contains(KeyModifiers::CONTROL)
&& keys::hotkey_char(&key) == Some('q'))
{
self.local_servers = None;
return Some(ChatIntent::Quit);
}
let list = self.local_servers.as_mut()?;
match list.on_key(key) {
LocalServerAction::None => None,
LocalServerAction::Cancel => {
self.local_servers = None;
None
}
LocalServerAction::Pick(offer) => {
self.local_servers = None;
Some(ChatIntent::UseLocalServer(offer))
}
}
}
pub(super) fn handle_emoji_key(&mut self, key: KeyEvent) {
let Some(picker) = &mut self.emoji else {
return;
};
let action = picker.on_key(key);
let selected = picker.selected();
match action {
EmojiPickerAction::None => {}
EmojiPickerAction::Cancel => {
self.emoji_last = selected;
self.emoji = None;
self.request_full_redraw();
}
EmojiPickerAction::Pick(emoji) => {
self.emoji_last = selected;
self.emoji = None;
self.request_full_redraw();
self.input.insert_str(&emoji);
self.mark_input_changed();
}
}
}
}
const SUGGEST_WIDTH: u16 = 40;
pub(super) fn suggest_min_size(loc: &'static Locale) -> MinSize {
MinSize::new(legend_width(loc.t("ui.suggest.footer")), 5)
}
pub(super) fn render_suggest(
frame: &mut Frame,
popup: &mut SuggestPopup,
palette: &Palette,
loc: &'static Locale,
) {
let rows = (popup.items.len() as u16 + 2).min(frame.area().height);
let legend = loc.t("ui.suggest.footer");
let area = centered_rect(SUGGEST_WIDTH.max(legend_width(legend)), rows, frame.area());
frame.render_widget(Clear, area);
let block = palette
.panel(popup.word.clone(), true)
.title_bottom(Line::from(Span::styled(legend, palette.muted_style())));
let selected = popup.selected.min(popup.items.len().saturating_sub(1));
let items: Vec<ListItem> = popup
.items
.iter()
.enumerate()
.map(|(i, item)| {
let rail = if i == selected {
Span::styled("▌ ", Style::new().fg(palette.success))
} else {
Span::raw(" ")
};
let body = match item {
SuggestItem::Replace(word) => {
Span::styled(word.clone(), Style::new().fg(palette.text))
}
SuggestItem::AddToDictionary => Span::styled(
format!("{} {}", palette.glyphs().add, loc.t("ui.suggest.add")),
palette.success_style(),
)
.italic(),
};
ListItem::new(Line::from(vec![rail, body]))
})
.collect();
let list = List::new(items)
.block(block)
.highlight_style(Style::new().bg(palette.keycap_bg));
let len = popup.items.len();
popup.scroll.render(
frame,
list,
area,
len,
area.height.saturating_sub(2) as usize, Some(selected),
);
}
pub(super) fn render_confirm(
frame: &mut Frame,
action: &ConfirmAction,
palette: &Palette,
loc: &'static Locale,
) {
confirm_prompt(action, palette, loc).render(frame, palette);
}
pub(super) fn confirm_prompt(
action: &ConfirmAction,
palette: &Palette,
loc: &'static Locale,
) -> Prompt {
crate::shared::ui::confirm_prompt(
palette,
loc.t("ui.confirm.title"),
&action.prompt(loc),
loc.t("ui.confirm.footer"),
)
}
pub(super) fn render_tool_confirm(
frame: &mut Frame,
pending: &ToolConfirm,
palette: &Palette,
loc: &'static Locale,
) {
tool_prompt(pending, palette, loc).render(frame, palette);
}
pub(super) fn tool_prompt(
pending: &ToolConfirm,
palette: &Palette,
loc: &'static Locale,
) -> Prompt {
use crate::features::tools::present::{ArgDetail, ToolBlock, present};
let shown = present(&pending.name, &pending.arguments, "", ArgDetail::Compact);
let header = match &shown.header_suffix {
Some(suffix) => format!("{}({suffix})", pending.name),
None => pending.name.clone(),
};
let mut body: Vec<Line<'static>> = vec![
Line::from(Span::styled(
loc.t("ui.confirm.tool.question"),
Style::new().fg(palette.text),
)),
Line::from(Span::styled(header, Style::new().fg(palette.accent))),
];
if let Some(inputs) = &pending.inputs {
if let Some((count, bytes)) = inputs.over_cap {
use crate::entities::attachment::format_bytes;
use crate::features::chat_inputs::{MAX_INPUT_BYTES, MAX_INPUT_FILES};
body.push(Line::from(Span::styled(
loc.tf(
"ui.confirm.tool.over_cap",
&[
("count", &count.to_string()),
("size", &format_bytes(bytes as usize)),
("max_files", &MAX_INPUT_FILES.to_string()),
("max_size", &format_bytes(MAX_INPUT_BYTES as usize)),
],
),
Style::new().fg(palette.warning),
)));
}
let files: Vec<String> = inputs
.files
.iter()
.map(|f| match &f.resolved {
Some((name, bytes)) => format!(
"{name} ({})",
crate::entities::attachment::format_bytes(*bytes as usize)
),
None => loc.tf("ui.confirm.tool.file_unknown", &[("handle", &f.handle)]),
})
.collect();
let net = if inputs.net {
loc.t("ui.confirm.tool.net_on")
} else {
loc.t("ui.confirm.tool.net_off")
};
let line = if files.is_empty() {
net.to_string()
} else {
loc.tf(
"ui.confirm.tool.inputs",
&[("files", &files.join(", ")), ("net", net)],
)
};
body.push(Line::from(Span::styled(
line,
Style::new().fg(palette.text),
)));
}
for block in &shown.args {
let text = match block {
ToolBlock::Code { text, .. } | ToolBlock::Plain(text) | ToolBlock::Markdown(text) => {
text.as_str()
}
ToolBlock::Console(_) => continue,
};
for line in text.lines().take(ARG_PREVIEW_LINES) {
body.push(Line::from(Span::styled(
line.to_string(),
palette.muted_style(),
)));
}
if text.lines().count() > ARG_PREVIEW_LINES {
body.push(Line::from(Span::styled("…", palette.muted_style())));
}
}
Prompt {
title: loc.t("ui.confirm.tool.title").to_string(),
body,
legend: loc.t("ui.confirm.tool.footer").to_string(),
max_width: 72,
min_rows: 0,
trim: false,
}
}
const ARG_PREVIEW_LINES: usize = 12;