use std::time::{Duration, Instant};
use ratatui::Frame;
use ratatui::crossterm::event::{
KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::layout::{Constraint, Flex, Layout, Rect};
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, List, ListItem, Paragraph, Wrap};
use uuid::Uuid;
use crate::app::events::{BackgroundKind, ChildView};
use crate::entities::attachment::AttachmentInfo;
use crate::entities::chat::{ChatSummary, FeedView};
use crate::entities::message::Message;
use crate::entities::message_image::ImageInfo;
use crate::entities::profile::{CharacterNames, Profile, ProfileSummary};
use crate::features::rag_ingest::RagProgress;
use crate::features::spellcheck::SpellChecker;
use crate::features::tools::confirm::ToolDecision;
use crate::shared::api::FinishReason;
use crate::shared::config::AppConfig;
use crate::shared::i18n::{Locale, locale};
use crate::shared::keys;
use crate::shared::server::{ServerStatus, ServerStatuses};
use crate::shared::theme::Palette;
use crate::shared::ui::{ListScroll, dim_background};
use crate::widgets::chat_link_picker::{ChatLinkAction, ChatLinkPickerState};
use crate::widgets::emoji_picker::{EmojiPickerAction, EmojiPickerState};
use crate::widgets::help_dialog::{HelpContext, HelpSection};
use crate::widgets::impersonation_preview;
use crate::widgets::input_box::InputBox;
use crate::widgets::message_feed::{FeedMessage, FeedRole, MessageFeed};
use crate::widgets::profile_list::{ProfileListAction, ProfileListState};
use crate::widgets::status_bar::{self, EscTarget};
pub(crate) static HELP_SECTION: HelpSection = HelpSection {
title: "ui.help.sec.chat",
context: Some(HelpContext::Chat),
rows: &[
("Enter", "ui.help.send"),
("Shift+Enter / Alt+Enter", "ui.help.newline"),
("Shift+←/→/↑/↓", "ui.help.select"),
("Ctrl+A", "ui.help.select_all"),
("Ctrl+C", "ui.help.copy"),
("Ctrl+X", "ui.help.cut"),
("Ctrl+V", "ui.help.paste"),
("Esc", "ui.help.esc"),
("Ctrl+N", "ui.help.new_chat"),
("F2", "ui.help.rename_chat"),
("F5", "ui.help.copy_chat"),
("F6", "ui.help.stop_run"),
("Ctrl+R", "ui.help.regenerate"),
("Ctrl+E", "ui.help.delete_exchange"),
("Ctrl+U", "ui.help.impersonate"),
("Ctrl+K", "ui.help.clear_input"),
("Ctrl+Z / Ctrl+Y", "ui.help.undo_redo"),
("Ctrl+←/→", "ui.help.word_move"),
("Ctrl+Backspace/Delete", "ui.help.word_delete"),
("Home", "ui.help.line_home"),
("End", "ui.help.line_end"),
("Ctrl+Home/End", "ui.help.doc_move"),
("Ctrl+G", "ui.help.spell"),
("Ctrl+P", "ui.help.settings"),
("F3", "ui.help.self_model"),
("F4", "ui.help.changes"),
("F7", "ui.help.tasks"),
("Ctrl+F", "ui.help.find_in_chat"),
("Ctrl+T", "ui.help.thoughts"),
("Ctrl+O", "ui.help.tool_calls"),
("Ctrl+B", "ui.help.emoji"),
("Ctrl+L", "ui.help.chat_links"),
("Ctrl+W", "ui.help.mouse_toggle"),
("ui.help.k.mouse", "ui.help.mouse_action"),
("PageUp/PageDown", "ui.help.scroll"),
],
openers: &["Shift+←/→/↑/↓", "Esc", "Ctrl+K", "Ctrl+P"],
};
const PAGE_SCROLL: usize = 8;
const WHEEL_SCROLL: usize = 3;
const SPELL_DEBOUNCE: Duration = Duration::from_millis(300);
pub type SettingsSnapshot = (
AppConfig,
Vec<Profile>,
Vec<uuid::Uuid>,
crate::features::tools::mcp::McpSnapshot,
Vec<crate::shared::secrets::SecretKey>,
);
#[derive(Debug, Clone, PartialEq)]
pub enum ChatIntent {
Quit,
Send(String),
RegenerateLast,
ContinueLast,
DeleteLastExchange,
Cancel,
Impersonate {
seed: String,
},
CancelImpersonation,
NewChat {
profile_id: Option<Uuid>,
},
CopyChat(Uuid),
RenameChat {
id: Uuid,
title: String,
},
AutoTitleChat(Uuid),
CloneChat(Uuid),
SetChildrenExpanded {
id: Uuid,
expanded: bool,
},
StopSubagentRun {
id: Uuid,
},
StopBackgroundTasks {
kinds: Vec<BackgroundKind>,
},
ExportChat {
id: Uuid,
format: crate::features::export_command::ExportFormat,
path: Option<String>,
},
SearchMessages {
query: String,
},
RagAdd {
path: String,
recursive: bool,
},
RagDelete {
path: String,
},
RagList,
RagRebuild,
Reindex,
Compact,
FileAttach {
path: String,
},
FileRemove {
target: String,
},
FileList,
FileOpen {
target: String,
},
FileFolder,
ProjectAttach {
path: String,
},
ProjectDetach,
ProjectStatus,
OpenChanges,
OpenTasks,
OpenHelp,
ProjectSlot {
slot: crate::entities::workspace::CommandSlot,
action: crate::features::project_command::SlotAction,
},
ImageAttach {
path: String,
},
ImageRemove {
target: String,
},
ImageList,
PasteImage {
text_fallback: bool,
},
Tts(crate::features::tts_command::TtsScope),
TtsStop,
TtsPause,
TtsResume,
OpenSettings,
OpenChatList,
OpenChatLink(Uuid),
OpenSelfModel,
ClearSelfModel,
CreateProfile {
name: String,
},
DeleteProfile(Uuid),
UpdateProfile {
id: Uuid,
edit: Box<crate::features::profiles::ProfileEdit>,
},
UpdateConfig(Box<AppConfig>),
SetMouseCapture(bool),
SetFeedView(FeedView),
CopyToClipboard(String),
ConfirmTool {
generation_id: Uuid,
call_id: String,
decision: ToolDecision,
},
}
#[derive(Debug, Clone, PartialEq)]
enum ConfirmAction {
Regenerate,
DeleteExchange,
DeleteProfile {
id: Uuid,
name: String,
chats: usize,
},
ClearSelfModel,
DeleteImpersonation { id: Uuid, name: String },
}
impl ConfirmAction {
fn intent(&self) -> Option<ChatIntent> {
match self {
ConfirmAction::Regenerate => Some(ChatIntent::RegenerateLast),
ConfirmAction::DeleteExchange => Some(ChatIntent::DeleteLastExchange),
ConfirmAction::DeleteProfile { id, .. } => Some(ChatIntent::DeleteProfile(*id)),
ConfirmAction::ClearSelfModel => Some(ChatIntent::ClearSelfModel),
ConfirmAction::DeleteImpersonation { .. } => None,
}
}
fn prompt(&self, loc: &'static Locale) -> String {
match self {
ConfirmAction::Regenerate => loc.t("ui.confirm.regenerate").to_string(),
ConfirmAction::DeleteExchange => loc.t("ui.confirm.delete_exchange").to_string(),
ConfirmAction::DeleteProfile { name, chats, .. } => loc.tf(
"ui.confirm.delete_profile",
&[("name", name), ("chats", &chats.to_string())],
),
ConfirmAction::ClearSelfModel => loc.t("ui.confirm.clear_self_model").to_string(),
ConfirmAction::DeleteImpersonation { name, .. } => {
loc.tf("ui.confirm.delete_impersonation", &[("name", name)])
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub(super) struct ToolConfirm {
pub(super) generation_id: Uuid,
pub(super) call_id: String,
pub(super) name: String,
pub(super) arguments: String,
pub(super) inputs: Option<crate::features::chat_inputs::ConfirmInputs>,
}
#[derive(Debug, Clone, PartialEq)]
enum SuggestItem {
Replace(String),
AddToDictionary,
}
struct SuggestPopup {
word: String,
row: usize,
start: usize,
end: usize,
items: Vec<SuggestItem>,
selected: usize,
scroll: ListScroll,
}
struct ImpersonationState {
generation_id: Uuid,
text: String,
tick: usize,
done: bool,
}
struct RagBanner {
before: String,
name: String,
location: String,
after: String,
tick: usize,
}
pub struct ChatScreen {
feed: Vec<FeedMessage>,
feed_view: MessageFeed,
active_chat: Option<Uuid>,
child: Option<ChildView>,
title: String,
chats: Vec<ChatSummary>,
profiles: Vec<ProfileSummary>,
profile_overlay: Option<ProfileListState>,
input: InputBox,
statuses: ServerStatuses,
current_gen: Option<Uuid>,
generating: bool,
stream_role: FeedRole,
gen_tokens: u64,
gen_context: Option<u64>,
gen_context_exact: bool,
gen_reasoning: u32,
gen_model: Option<String>,
engine_model: Option<String>,
engine_slots: Option<u32>,
engine_sampling_fields: Option<std::sync::Arc<[String]>>,
spell: Option<SpellChecker>,
spell_dirty: bool,
draft_dirty: bool,
last_edit: Option<Instant>,
suggest: Option<SuggestPopup>,
emoji: Option<EmojiPickerState>,
chat_links: Option<ChatLinkPickerState>,
confirm: Option<ConfirmAction>,
pub(super) tool_confirm: Option<ToolConfirm>,
search: Option<Box<InputBox>>,
search_last: String,
confirm_destructive: bool,
emoji_last: usize,
feed_has_risky: bool,
full_redraw: bool,
settings_snapshot: Option<SettingsSnapshot>,
palette: Palette,
loc: &'static Locale,
mouse_scroll: bool,
reflecting: bool,
background_runs: u32,
consolidating: bool,
self_consolidating: bool,
compacting: bool,
retrying: Option<String>,
subagents: Vec<(Uuid, String)>,
live_turn: Option<Uuid>,
background_run: bool,
transcript: Vec<Message>,
speaking: bool,
esc_target: EscTarget,
rag: Option<RagBanner>,
attachments: Vec<AttachmentInfo>,
staged_images: Vec<ImageInfo>,
impersonation: Option<ImpersonationState>,
pending_text_sep: bool,
pending_thoughts_sep: bool,
}
impl Default for ChatScreen {
fn default() -> Self {
Self::new()
}
}
impl ChatScreen {
pub fn new() -> Self {
Self {
feed: Vec::new(),
feed_view: MessageFeed::new(),
active_chat: None,
child: None,
title: String::new(),
chats: Vec::new(),
profiles: Vec::new(),
profile_overlay: None,
input: InputBox::new(),
statuses: ServerStatuses {
chat: ServerStatus::Connecting,
embed: ServerStatus::NotConfigured,
impersonation: ServerStatus::NotConfigured,
},
current_gen: None,
generating: false,
stream_role: FeedRole::Assistant,
gen_tokens: 0,
gen_context: None,
gen_context_exact: false,
gen_reasoning: 0,
gen_model: None,
spell: None,
spell_dirty: false,
draft_dirty: false,
last_edit: None,
suggest: None,
emoji: None,
emoji_last: 0,
chat_links: None,
feed_has_risky: false,
full_redraw: false,
confirm: None,
tool_confirm: None,
search: None,
search_last: String::new(),
confirm_destructive: false,
settings_snapshot: None,
engine_model: None,
engine_slots: None,
engine_sampling_fields: None,
palette: Palette::default(),
loc: locale(crate::shared::i18n::Lang::default()),
mouse_scroll: false,
reflecting: false,
background_runs: 0,
consolidating: false,
self_consolidating: false,
compacting: false,
retrying: None,
subagents: Vec::new(),
live_turn: None,
background_run: false,
transcript: Vec::new(),
speaking: false,
esc_target: EscTarget::default(),
rag: None,
attachments: Vec::new(),
staged_images: Vec::new(),
impersonation: None,
pending_text_sep: false,
pending_thoughts_sep: false,
}
}
pub fn set_settings(
&mut self,
config: AppConfig,
profiles: Vec<Profile>,
language_locked: Vec<uuid::Uuid>,
mcp: crate::features::tools::mcp::McpSnapshot,
secrets_present: Vec<crate::shared::secrets::SecretKey>,
) {
self.palette = Palette::for_theme(config.interface.theme)
.with_compat(config.interface.terminal_compat);
self.loc = locale(config.interface.language);
self.confirm_destructive = config.interface.confirm_destructive_keys;
self.feed_view
.set_table_row_separators(config.interface.table_row_separators);
self.feed_view
.set_render_mermaid(config.interface.render_mermaid);
self.feed_view
.set_show_model_name(config.interface.show_model_name);
self.settings_snapshot = Some((config, profiles, language_locked, mcp, secrets_present));
}
pub fn set_engine_model(&mut self, model: Option<String>) {
self.engine_model = model;
}
pub fn set_engine_slots(&mut self, slots: Option<u32>) {
self.engine_slots = slots;
}
pub fn set_engine_sampling_fields(&mut self, fields: Option<std::sync::Arc<[String]>>) {
self.engine_sampling_fields = fields;
}
pub fn engine_sampling_fields(&self) -> Option<std::sync::Arc<[String]>> {
self.engine_sampling_fields.clone()
}
pub fn engine_slots(&self) -> Option<u32> {
self.engine_slots
}
pub fn set_character_names(&mut self, names: CharacterNames) {
self.feed_view.set_role_names(names);
}
pub fn settings_snapshot(&self) -> Option<SettingsSnapshot> {
self.settings_snapshot.clone()
}
pub fn clipboard_osc52(&self) -> crate::shared::osc52::Osc52Mode {
self.settings_snapshot
.as_ref()
.map(|(config, ..)| config.interface.clipboard_osc52)
.unwrap_or_default()
}
pub fn self_model_note_order(&self) -> crate::shared::config::NoteOrder {
self.settings_snapshot
.as_ref()
.map(|(config, ..)| config.interface.self_model_note_order)
.unwrap_or_default()
}
pub fn spell_config(&self) -> Option<(bool, &[String])> {
self.settings_snapshot.as_ref().map(|(c, _, _, _, _)| {
(
c.interface.spellcheck_enabled,
c.interface.selected_dictionaries.as_slice(),
)
})
}
pub fn set_spellchecker(&mut self, checker: SpellChecker) {
self.spell = Some(checker);
self.spell_dirty = true;
self.last_edit = None; }
pub fn spellchecker(&self) -> Option<&SpellChecker> {
self.spell.as_ref()
}
pub fn set_server_status(&mut self, statuses: ServerStatuses) {
self.feed_view
.set_engine_missing(statuses.chat == ServerStatus::NotConfigured);
self.statuses = statuses;
}
pub fn server_statuses(&self) -> ServerStatuses {
self.statuses.clone()
}
pub fn set_reflecting(&mut self, active: bool) {
self.reflecting = active;
}
pub fn set_background_runs(&mut self, out: u32) {
self.background_runs = out;
}
pub fn set_consolidating(&mut self, active: bool) {
self.consolidating = active;
}
pub fn set_self_consolidating(&mut self, active: bool) {
self.self_consolidating = active;
}
pub fn set_compacting(&mut self, active: bool) {
self.compacting = active;
}
pub(super) fn task_running(&self, kind: BackgroundKind) -> bool {
match kind {
BackgroundKind::Reflection => self.reflecting,
BackgroundKind::Consolidation => self.consolidating,
BackgroundKind::SelfConsolidation => self.self_consolidating,
BackgroundKind::Compaction => self.compacting,
}
}
pub fn set_speaking(&mut self, active: bool) {
self.speaking = active;
}
pub fn set_esc_target(&mut self, target: EscTarget) {
self.esc_target = target;
}
pub(super) fn background_hint(&self) -> Option<String> {
let subagents_label: Option<String> = self.subagents.last().map(|(_, latest)| {
if self.subagents.len() == 1 {
latest.clone()
} else {
self.loc.tf(
"ui.chat.bg.subagents",
&[("n", &self.subagents.len().to_string()), ("latest", latest)],
)
}
});
let mut parts: Vec<&str> = Vec::new();
if self.reflecting {
parts.push(self.loc.t("ui.chat.bg.reflect"));
}
if self.consolidating {
parts.push(self.loc.t("ui.chat.bg.consolidate"));
}
if self.self_consolidating {
parts.push(self.loc.t("ui.chat.bg.self_consolidate"));
}
if self.compacting {
parts.push(self.loc.t("ui.chat.bg.compact"));
}
let background_runs = (self.background_runs > 0).then(|| {
self.loc.tf(
"ui.chat.bg.background_runs",
&[("n", &self.background_runs.to_string())],
)
});
if let Some(label) = background_runs.as_deref() {
parts.push(label);
}
if let Some(label) = subagents_label.as_deref() {
parts.push(label);
}
if let Some(retry) = &self.retrying {
parts.push(retry);
}
(!parts.is_empty()).then(|| parts.join(" · "))
}
pub fn set_chat_list(&mut self, chats: Vec<ChatSummary>) {
self.chats = chats;
self.refresh_known_chats();
}
pub fn set_child_view(&mut self, child: Option<ChildView>) {
self.child = child;
}
pub fn set_live_turn(&mut self, live_turn: Option<Box<crate::app::events::LiveTurn>>) {
let Some(live) = live_turn else {
self.live_turn = None;
self.background_run = false;
self.subagents.clear();
return;
};
self.live_turn = Some(live.turn);
self.background_run = live.background && self.child.is_some();
if self.child.is_none() {
self.current_gen = Some(live.stream);
self.generating = true;
if live.continues {
self.resume_last_assistant_bubble();
}
} else {
self.begin_generation(live.stream, None);
if live.role == crate::entities::message::MessageRole::User {
self.set_stream_role(FeedRole::User);
}
}
let Some(partial) = live.partial else {
return;
};
if !partial.thoughts.is_empty() {
self.push_thoughts(live.stream, &partial.thoughts);
}
if !partial.text.is_empty() {
self.push_chunk(live.stream, &partial.text);
}
for tool in partial.tools {
self.push_tool_call_started(
live.stream,
tool.call_id.clone(),
tool.name.clone(),
tool.arguments.clone(),
);
if let Some((result, images)) = tool.result {
self.push_tool_call(
live.stream,
tool.call_id,
tool.name,
tool.arguments,
result,
images,
);
}
}
}
pub fn grow_transcript(&mut self, id: Uuid, messages: &[Message]) {
if self.active_chat != Some(id) || self.child.is_none() {
return;
}
self.transcript.extend(messages.iter().cloned());
self.feed = FeedMessage::from_messages(&self.transcript);
if let Some(child) = &self.child {
self.feed
.insert(0, FeedMessage::system(child.system_message.clone()));
}
self.feed_has_risky = self.feed.iter().any(feed::feed_msg_has_risky_glyph);
self.mark_feed_changed();
self.feed_view.scroll_to_bottom_if_following();
}
pub fn reset_transcript(&mut self, id: Uuid, messages: &[Message]) {
if self.active_chat != Some(id) || self.child.is_none() {
return;
}
self.transcript = messages.to_vec();
self.feed = FeedMessage::from_messages(&self.transcript);
if let Some(child) = &self.child {
self.feed
.insert(0, FeedMessage::system(child.system_message.clone()));
}
self.feed_has_risky = self.feed.iter().any(feed::feed_msg_has_risky_glyph);
self.mark_feed_changed();
self.feed_view.scroll_to_bottom_if_following();
}
pub fn read_only(&self) -> bool {
self.child.is_some()
}
pub(super) fn refuse_read_only(&mut self, what: &str) -> bool {
let Some(child) = &self.child else {
return false;
};
let msg = self.loc.tf(
"ui.chat.read_only",
&[("what", what), ("parent", &child.parent_title)],
);
self.push_note(&msg);
true
}
pub(super) fn summary_card(&self, id: Uuid) -> Option<ChatSummary> {
if let Some(chat) = self.chats.iter().find(|c| c.id == id) {
return Some(chat.clone());
}
self.chats.iter().find_map(|chat| {
chat.children
.iter()
.find(|c| c.id == id)
.map(|c| chat.child_card(c))
})
}
pub(super) fn refresh_known_chats(&mut self) {
let profile = self
.active_chat
.and_then(|id| self.summary_card(id))
.map(|c| c.profile_id);
let ids = profile
.map(|p| {
self.chats
.iter()
.filter(|c| c.profile_id == p)
.flat_map(|c| std::iter::once(c.id).chain(c.child_ids()))
.collect()
})
.unwrap_or_default();
self.feed_view.set_known_chats(ids);
}
pub fn set_profile_list(&mut self, profiles: Vec<ProfileSummary>) {
self.profiles = profiles;
}
pub fn chat_summaries(&self) -> Vec<ChatSummary> {
self.chats.clone()
}
pub fn active_chat(&self) -> Option<Uuid> {
self.active_chat
}
pub fn palette(&self) -> Palette {
self.palette
}
pub fn loc(&self) -> &'static Locale {
self.loc
}
}
mod attachments;
mod commands;
mod feed;
mod images;
mod impersonation;
mod input;
mod popups;
mod project;
mod rag;
mod render;
#[cfg(test)]
mod tests;