refactor: split chat.rs into focused modules
chat.rs (3265 lines) → 8 files in src/ui/chat/: mod.rs — re-exports, helpers, TopButtonBar events.rs — event/subcommand dispatch, input handling commands.rs — /slash command processor render.rs — main draw function, chat/layout rendering cockpit.rs — subconscious pane (the "cockpit" panel) overlays.rs — overlay/modal draw functions footer.rs — status bar rendering wrap.rs — word-wrap utility
This commit is contained in:
parent
d3d485f9e8
commit
ca2762a302
9 changed files with 2926 additions and 3265 deletions
3265
src/ui/chat.rs
3265
src/ui/chat.rs
File diff suppressed because it is too large
Load diff
127
src/ui/chat/cockpit.rs
Normal file
127
src/ui/chat/cockpit.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, BorderType, Borders, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use ratatui::style::Color;
|
||||
use super::{ChatState, CockpitEntry, CockpitLayout};
|
||||
|
||||
pub fn draw_cockpit(f: &mut Frame, state: &ChatState, area: Rect) {
|
||||
let panes = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(area);
|
||||
|
||||
*state.cockpit_layout.borrow_mut() = CockpitLayout {
|
||||
thinking: panes[0],
|
||||
subconscious: panes[1],
|
||||
};
|
||||
|
||||
let thinking_window = panes[0].height as usize;
|
||||
let thinking_max = state.thinking.len().saturating_sub(thinking_window);
|
||||
let thinking_scroll = (state.thinking_scroll.get() as usize).min(thinking_max);
|
||||
state.thinking_scroll.set(thinking_scroll as u16);
|
||||
let thinking_entries: Vec<&String> = state
|
||||
.thinking
|
||||
.iter()
|
||||
.rev()
|
||||
.skip(thinking_scroll)
|
||||
.take(thinking_window)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect();
|
||||
let mut thinking_view: Vec<Line<'static>> = Vec::with_capacity(thinking_entries.len() * 2);
|
||||
for (i, t) in thinking_entries.iter().enumerate() {
|
||||
if t.starts_with("────") {
|
||||
if i > 0 {
|
||||
thinking_view.push(Line::from(""));
|
||||
}
|
||||
thinking_view.push(Line::from(Span::styled(
|
||||
t.to_string(),
|
||||
Style::default().fg(state.palette.agent_dim),
|
||||
)));
|
||||
thinking_view.push(Line::from(""));
|
||||
} else {
|
||||
thinking_view.push(Line::from(Span::styled(
|
||||
format!("· {}", t),
|
||||
Style::default().fg(state.palette.agent_dim),
|
||||
)));
|
||||
}
|
||||
}
|
||||
let thinking_title = if thinking_scroll > 0 {
|
||||
format!(" thinking ↑{} ", thinking_scroll)
|
||||
} else {
|
||||
" thinking ".to_string()
|
||||
};
|
||||
let thinking = Paragraph::new(thinking_view)
|
||||
.wrap(Wrap { trim: false })
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(state.palette.agent_dim))
|
||||
.title(Span::styled(thinking_title, Style::default().fg(state.palette.agent_dim).add_modifier(Modifier::BOLD))),
|
||||
);
|
||||
f.render_widget(thinking, panes[0]);
|
||||
|
||||
let visible_height = panes[1].height.saturating_sub(2) as usize;
|
||||
let sub_max = state.cockpit_log.len().saturating_sub(visible_height);
|
||||
let sub_scroll = (state.subconscious_scroll.get() as usize).min(sub_max);
|
||||
state.subconscious_scroll.set(sub_scroll as u16);
|
||||
let visible_entries: Vec<&CockpitEntry> = state
|
||||
.cockpit_log
|
||||
.iter()
|
||||
.rev()
|
||||
.skip(sub_scroll)
|
||||
.take(visible_height)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect();
|
||||
let entry_count = visible_entries.len();
|
||||
let log_view: Vec<Line<'static>> = visible_entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, entry)| {
|
||||
let base = entry.color(&state.palette);
|
||||
let dim = if entry_count > 1 {
|
||||
let age = 1.0 - (i as f32 / (entry_count - 1) as f32);
|
||||
0.4 + 0.6 * (1.0 - age)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let Color::Rgb(r, g, b) = base else { unreachable!() };
|
||||
let fg = Color::Rgb(
|
||||
(r as f32 * dim) as u8,
|
||||
(g as f32 * dim) as u8,
|
||||
(b as f32 * dim) as u8,
|
||||
);
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {} ", entry.prefix()),
|
||||
Style::default().fg(fg).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(entry.text.clone(), Style::default().fg(fg)),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
let sub_title = if sub_scroll > 0 {
|
||||
format!(" subconscious ↑{} ", sub_scroll)
|
||||
} else {
|
||||
" subconscious ".to_string()
|
||||
};
|
||||
let subconscious = Paragraph::new(log_view)
|
||||
.wrap(Wrap { trim: false })
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(state.palette.surfacing))
|
||||
.title(Span::styled(sub_title, Style::default().fg(state.palette.surfacing).add_modifier(Modifier::BOLD))),
|
||||
);
|
||||
f.render_widget(subconscious, panes[1]);
|
||||
}
|
||||
408
src/ui/chat/commands.rs
Normal file
408
src/ui/chat/commands.rs
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
use std::cell::RefCell;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Result;
|
||||
use futures::StreamExt;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::backend::{Backend, BackendEvent};
|
||||
use crate::bridge::bifrost::BifrostClient;
|
||||
use crate::core::config::ConsciousnessConfig;
|
||||
|
||||
use super::{
|
||||
BtwForkEvent, BtwState, ChatMessage, ChatMode, ChatState, TurnPhase,
|
||||
};
|
||||
|
||||
impl ChatState {
|
||||
pub const HELP_TEXT: &'static str = "Available commands:
|
||||
/help Show this help
|
||||
/clear Clear chat history
|
||||
/new Start a new conversation
|
||||
/resume List and switch conversations
|
||||
/convos Alias for /resume
|
||||
/model List available models
|
||||
/model <name> Set the active model
|
||||
/btw <text> Interject — delivered to her next LLM round
|
||||
/code Shift to code posture (tools expanded, ≡ prompt)
|
||||
/chat Shift to conversation posture (tools collapsed)
|
||||
/outfit <name> Change agent's outfit (empty to reset)
|
||||
!<command> Run a shell command (Linux/macOS)
|
||||
|
||||
Esc during a turn shows the raise-hand dialog (signal, not kill — she sees *[raised hand]*).
|
||||
You can also type while she works — Enter raises your hand (she sees it next round).
|
||||
Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
|
||||
|
||||
pub fn submit(&mut self) -> bool {
|
||||
if self.input.trim().is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let trimmed = self.input.trim().to_string();
|
||||
self.input.clear();
|
||||
|
||||
if let Some(rest) = trimmed.strip_prefix("/btw ") {
|
||||
let question = rest.trim().to_string();
|
||||
if !question.is_empty() && !self.btw_active() {
|
||||
self.start_btw_fork(question);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if trimmed.starts_with('/') {
|
||||
return self.handle_slash_command(&trimmed);
|
||||
}
|
||||
|
||||
if trimmed.starts_with('!') {
|
||||
let cmd = trimmed[1..].trim();
|
||||
if !cmd.is_empty() {
|
||||
self.handle_bang_command(cmd);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.busy {
|
||||
self.enqueue_interjection(trimmed);
|
||||
return true;
|
||||
}
|
||||
|
||||
let text = trimmed;
|
||||
self.messages.push(ChatMessage::User { text: text.clone(), ts: Instant::now() });
|
||||
self.spawn_turn(text);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn spawn_turn(&mut self, text: String) {
|
||||
self.busy = true;
|
||||
self.tool_calls_this_turn = 0;
|
||||
self.phase = TurnPhase::Thinking;
|
||||
self.turn_started = Some(Instant::now());
|
||||
self.last_event_at = Instant::now();
|
||||
|
||||
let (tx, rx) = mpsc::channel::<BackendEvent>(64);
|
||||
self.turn_rx = Some(rx);
|
||||
|
||||
let cancel = CancellationToken::new();
|
||||
self.cancel_token = Some(cancel.clone());
|
||||
|
||||
let backend = self.backend.clone();
|
||||
let conv_id = self.conversation_id.clone();
|
||||
let interject_queue = self.pending_interjections.clone();
|
||||
tokio::spawn(async move {
|
||||
match backend.send_with_signals(&conv_id, &text, cancel, interject_queue).await {
|
||||
Ok(mut stream) => {
|
||||
while let Some(ev) = stream.next().await {
|
||||
match ev {
|
||||
Ok(e) => {
|
||||
if tx.send(e).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = tx
|
||||
.send(BackendEvent::Token(format!("\n[error] {}\n", err)))
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = tx
|
||||
.send(BackendEvent::Token(format!("\n[connect error] {}\n", err)))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
let _ = tx.send(BackendEvent::Done).await;
|
||||
});
|
||||
}
|
||||
|
||||
pub fn cancel_active_turn(&self) {
|
||||
if let Some(token) = &self.cancel_token {
|
||||
if !token.is_cancelled() {
|
||||
token.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deliver_pending_interjections(&mut self) {
|
||||
if self.busy {
|
||||
return;
|
||||
}
|
||||
let pending: Vec<String> = self
|
||||
.pending_interjections
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|mut q| q.drain(..).collect())
|
||||
.unwrap_or_default();
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
for msg in &mut self.messages {
|
||||
if let ChatMessage::Interjection { delivered, .. } = msg {
|
||||
*delivered = true;
|
||||
}
|
||||
}
|
||||
self.spawn_turn(pending.join("\n"));
|
||||
}
|
||||
|
||||
fn handle_slash_command(&mut self, input: &str) -> bool {
|
||||
let trimmed = input.trim();
|
||||
|
||||
if trimmed == "/help" {
|
||||
self.system_message(Self::HELP_TEXT.to_string());
|
||||
return true;
|
||||
}
|
||||
|
||||
if trimmed == "/clear" {
|
||||
self.messages.clear();
|
||||
self.system_message("Chat cleared.".to_string());
|
||||
return true;
|
||||
}
|
||||
|
||||
if trimmed == "/new" {
|
||||
self.handle_new_conversation();
|
||||
return true;
|
||||
}
|
||||
|
||||
if trimmed == "/resume" || trimmed == "/convos" {
|
||||
self.handle_list_conversations();
|
||||
return true;
|
||||
}
|
||||
|
||||
if trimmed.starts_with("/resume ") {
|
||||
let conv_id = trimmed.strip_prefix("/resume ").unwrap().trim();
|
||||
if !conv_id.is_empty() {
|
||||
self.handle_switch_conversation(conv_id.to_string());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if trimmed.starts_with("/model") {
|
||||
return self.handle_model_command(trimmed);
|
||||
}
|
||||
|
||||
if trimmed == "/code" {
|
||||
self.render_mode = ChatMode::Code;
|
||||
self.system_message(
|
||||
"Code posture. Tool gestures expand; the prompt becomes ≡. Same conversation."
|
||||
.to_string(),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if trimmed == "/chat" {
|
||||
self.render_mode = ChatMode::Conversation;
|
||||
self.system_message(
|
||||
"Conversation posture. Tool gestures collapse; the prompt returns to ›.".to_string(),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if trimmed == "/outfit" || trimmed.starts_with("/outfit ") {
|
||||
let name = trimmed.strip_prefix("/outfit")
|
||||
.map(|s| s.trim())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
self.pending_consciousness.push(BackendEvent::Outfit(name.clone()));
|
||||
if name.is_empty() {
|
||||
self.system_message("Returned to default appearance.".to_string());
|
||||
} else {
|
||||
self.system_message(format!("Changed to **{name}** outfit."));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if trimmed == "/btw" {
|
||||
self.system_message(
|
||||
"Usage: /btw <text> — fork a side-quest conversation.\nThe main chat carries on; the fork streams into a floating pane. Press j to jump to the fork, Esc to dismiss."
|
||||
.to_string(),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
let cmd = trimmed.split_whitespace().next().unwrap_or(trimmed);
|
||||
self.system_message(format!(
|
||||
"Unknown command: {}\nType /help for available commands.",
|
||||
cmd
|
||||
));
|
||||
true
|
||||
}
|
||||
|
||||
fn handle_new_conversation(&mut self) {
|
||||
let backend = self.backend.clone();
|
||||
let agent_id = self.agent_id.clone();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = backend.new_conversation(&agent_id).await;
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
self.system_message("Creating new conversation...".to_string());
|
||||
self.new_conv_rx = Some(rx);
|
||||
}
|
||||
|
||||
pub fn offer_resume_or_new(&mut self) {
|
||||
let backend = self.backend.clone();
|
||||
let agent_id = self.agent_id.clone();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
tokio::spawn(async move {
|
||||
let result = backend.list_conversations(&agent_id).await;
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
self.resume_offer = true;
|
||||
self.convos_rx = Some(rx);
|
||||
}
|
||||
|
||||
fn handle_list_conversations(&mut self) {
|
||||
let backend = self.backend.clone();
|
||||
let agent_id = self.agent_id.clone();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = backend.list_conversations(&agent_id).await;
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
self.system_message("Loading conversations...".to_string());
|
||||
self.convos_rx = Some(rx);
|
||||
}
|
||||
|
||||
pub fn handle_switch_conversation(&mut self, conversation_id: String) {
|
||||
if self.busy {
|
||||
self.raise_hand();
|
||||
self.system_message("Signalled active turn — switching when it finalizes.".to_string());
|
||||
self.switch_pending = Some(conversation_id);
|
||||
return;
|
||||
}
|
||||
self.initiate_switch_load(conversation_id);
|
||||
}
|
||||
|
||||
pub fn initiate_switch_load(&mut self, conversation_id: String) {
|
||||
let backend = self.backend.clone();
|
||||
let conv_id = conversation_id.clone();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = backend.load_conversation(&conv_id).await;
|
||||
let _ = tx.send(result.map(|msgs| (conv_id, msgs)));
|
||||
});
|
||||
|
||||
self.system_message(format!("Switching to {}...", conversation_id));
|
||||
self.switch_rx = Some(rx);
|
||||
}
|
||||
|
||||
fn handle_model_command(&mut self, input: &str) -> bool {
|
||||
let rest = input.strip_prefix("/model").unwrap_or("").trim();
|
||||
|
||||
if !rest.is_empty() && !rest.starts_with('-') {
|
||||
let model_name = rest.to_string();
|
||||
let cfg_path = std::env::current_dir()
|
||||
.map(|d| d.join("souveraine.toml"))
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from("souveraine.toml"));
|
||||
|
||||
match ConsciousnessConfig::load(&cfg_path) {
|
||||
Ok(mut cfg) => {
|
||||
cfg.bifrost.primary_model = model_name.clone();
|
||||
match cfg.save(&cfg_path) {
|
||||
Ok(()) => self.system_message(format!("Set model to: {}", model_name)),
|
||||
Err(e) => self.error_message(format!("Failed to save config: {}", e)),
|
||||
}
|
||||
}
|
||||
Err(e) => self.error_message(format!("Failed to load config: {}", e)),
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
self.system_message("Fetching models from Bifrost…".to_string());
|
||||
|
||||
let cfg_path = std::env::current_dir()
|
||||
.map(|d| d.join("souveraine.toml"))
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from("souveraine.toml"));
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.model_rx = Some(rx);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = match ConsciousnessConfig::load(&cfg_path) {
|
||||
Ok(cfg) => {
|
||||
let bifrost = BifrostClient::new(
|
||||
&cfg.bifrost.base_url,
|
||||
&cfg.bifrost.api_key,
|
||||
&cfg.bifrost.virtual_key,
|
||||
&cfg.bifrost.primary_model,
|
||||
cfg.bifrost.timeout_secs,
|
||||
);
|
||||
let bifrost_models = bifrost.list_models().await.unwrap_or_default();
|
||||
let mut all_models = bifrost_models.clone();
|
||||
for name in cfg.models.keys() {
|
||||
if !all_models.contains(name) {
|
||||
all_models.push(name.clone());
|
||||
}
|
||||
}
|
||||
let mut text = format!(
|
||||
"Selected: {}\nAvailable ({}):\n",
|
||||
cfg.bifrost.primary_model,
|
||||
all_models.len()
|
||||
);
|
||||
for m in &all_models {
|
||||
let marker = if bifrost_models.contains(&m) { "⚡" } else { "⚙" };
|
||||
text.push_str(&format!(" {} {}\n", marker, m));
|
||||
}
|
||||
text
|
||||
}
|
||||
Err(e) => format!("✕ Failed to load config: {}", e),
|
||||
};
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn handle_bang_command(&mut self, cmd: &str) {
|
||||
let output = std::process::Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(out) => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let mut result = String::new();
|
||||
if !stdout.is_empty() {
|
||||
result.push_str(stdout.trim());
|
||||
}
|
||||
if !stderr.is_empty() {
|
||||
if !result.is_empty() {
|
||||
result.push('\n');
|
||||
}
|
||||
result.push_str(stderr.trim());
|
||||
}
|
||||
if result.is_empty() {
|
||||
result = format!("[exit code {}]", out.status.code().unwrap_or(-1));
|
||||
}
|
||||
self.system_message(format!("$ {}\n{}", cmd, result));
|
||||
}
|
||||
Err(e) => {
|
||||
self.error_message(format!("Shell command failed: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn system_message(&mut self, text: String) {
|
||||
self.messages.push(ChatMessage::System {
|
||||
text,
|
||||
ts: Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn error_message(&mut self, text: String) {
|
||||
self.messages.push(ChatMessage::System {
|
||||
text: format!("✕ {}", text),
|
||||
ts: Instant::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
687
src/ui/chat/events.rs
Normal file
687
src/ui/chat/events.rs
Normal file
|
|
@ -0,0 +1,687 @@
|
|||
use std::cell::RefCell;
|
||||
use std::time::Instant;
|
||||
|
||||
use ratatui::layout::Rect;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::backend::BackendEvent;
|
||||
|
||||
use super::{
|
||||
BtwForkEvent, BtwState, ChatMessage, ChatState, CockpitEntry, CockpitKind,
|
||||
MsgLayout, Overlay, SlashDef, ToolResultBlock, TurnPhase, SLASH_COMMANDS,
|
||||
};
|
||||
|
||||
impl ChatState {
|
||||
pub fn flush_delivered_interjections(&mut self) {
|
||||
let queue_empty = self
|
||||
.pending_interjections
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|q| q.is_empty())
|
||||
.unwrap_or(true);
|
||||
if !queue_empty { return; }
|
||||
for msg in self.messages.iter_mut() {
|
||||
if let ChatMessage::Interjection { delivered, .. } = msg {
|
||||
*delivered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn drain_events(&mut self) {
|
||||
self.flush_delivered_interjections();
|
||||
|
||||
self.drain_btw();
|
||||
|
||||
if let Some(rx) = self.model_rx.as_mut() {
|
||||
if let Ok(result) = rx.try_recv() {
|
||||
self.messages.push(ChatMessage::System {
|
||||
text: result,
|
||||
ts: Instant::now(),
|
||||
});
|
||||
self.model_rx = None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(rx) = self.new_conv_rx.as_mut() {
|
||||
if let Ok(result) = rx.try_recv() {
|
||||
match result {
|
||||
Ok(conv_id) => {
|
||||
self.conversation_id = conv_id.clone();
|
||||
self.messages.clear();
|
||||
self.system_message(format!(
|
||||
"New conversation started: {}",
|
||||
&conv_id[..8.min(conv_id.len())]
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
self.system_message(format!("Failed to create conversation: {}", e));
|
||||
}
|
||||
}
|
||||
self.new_conv_rx = None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(rx) = self.convos_rx.as_mut() {
|
||||
if let Ok(result) = rx.try_recv() {
|
||||
let offer = std::mem::take(&mut self.resume_offer);
|
||||
match result {
|
||||
Ok(mut convos) => {
|
||||
if offer {
|
||||
convos.retain(|c| c.id != self.conversation_id);
|
||||
}
|
||||
if convos.is_empty() {
|
||||
if !offer {
|
||||
self.system_message("No saved conversations.".to_string());
|
||||
}
|
||||
} else {
|
||||
self.overlay = Overlay::ConversationPicker {
|
||||
selected: 0,
|
||||
conversations: convos,
|
||||
};
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if !offer {
|
||||
self.system_message(format!("Failed to list conversations: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.convos_rx = None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(rx) = self.switch_rx.as_mut() {
|
||||
if let Ok(result) = rx.try_recv() {
|
||||
match result {
|
||||
Ok((conv_id, messages)) => {
|
||||
self.conversation_id = conv_id.clone();
|
||||
self.messages.clear();
|
||||
for msg in &messages {
|
||||
let text = msg.blocks.iter().filter_map(|b| match b {
|
||||
crate::core::session::ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
}).collect::<Vec<_>>().join("\n");
|
||||
if text.is_empty() { continue; }
|
||||
match msg.role {
|
||||
crate::core::session::MessageRole::User => {
|
||||
self.messages.push(ChatMessage::User { text, ts: Instant::now() });
|
||||
}
|
||||
crate::core::session::MessageRole::Assistant => {
|
||||
self.messages.push(ChatMessage::Assistant {
|
||||
text,
|
||||
ts: Instant::now(),
|
||||
streaming: false,
|
||||
rendered_cache: RefCell::new(None),
|
||||
});
|
||||
}
|
||||
crate::core::session::MessageRole::System => {
|
||||
self.messages.push(ChatMessage::System { text, ts: Instant::now() });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.system_message(format!(
|
||||
"Resumed conversation {} ({} messages)",
|
||||
&conv_id[..8.min(conv_id.len())],
|
||||
messages.len()
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
self.system_message(format!("Failed to switch: {}", e));
|
||||
}
|
||||
}
|
||||
self.switch_rx = None;
|
||||
}
|
||||
}
|
||||
|
||||
let mut drained: Vec<BackendEvent> = Vec::new();
|
||||
let mut closed = false;
|
||||
if let Some(rx) = self.turn_rx.as_mut() {
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(ev) => drained.push(ev),
|
||||
Err(mpsc::error::TryRecvError::Empty) => break,
|
||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if !drained.is_empty() {
|
||||
self.last_event_at = Instant::now();
|
||||
}
|
||||
|
||||
for ev in drained {
|
||||
match ev {
|
||||
BackendEvent::Token(t) => {
|
||||
self.phase = TurnPhase::Streaming;
|
||||
self.stream_buffer.push_str(&t);
|
||||
}
|
||||
BackendEvent::Reasoning(r) => {
|
||||
self.thinking.push(r.clone());
|
||||
if self.thinking.len() > 200 {
|
||||
self.thinking.drain(..self.thinking.len() - 200);
|
||||
}
|
||||
}
|
||||
BackendEvent::Surfacing { source, content, priority } => {
|
||||
self.cockpit_log.push(CockpitEntry {
|
||||
kind: CockpitKind::Surfacing,
|
||||
text: format!("{} · {} — {}", source, priority, content),
|
||||
});
|
||||
if self.cockpit_log.len() > 200 {
|
||||
self.cockpit_log.drain(..self.cockpit_log.len() - 200);
|
||||
}
|
||||
self.messages.push(ChatMessage::Surfacing {
|
||||
source: source.clone(),
|
||||
content: content.clone(),
|
||||
priority: priority.clone(),
|
||||
ts: Instant::now(),
|
||||
});
|
||||
self.pending_consciousness.push(BackendEvent::Surfacing { source, content, priority });
|
||||
}
|
||||
BackendEvent::Reflection(content) => {
|
||||
self.cockpit_log.push(CockpitEntry {
|
||||
kind: CockpitKind::Reflection,
|
||||
text: content.clone(),
|
||||
});
|
||||
self.messages.push(ChatMessage::System {
|
||||
text: format!("reflection: {}", content),
|
||||
ts: Instant::now(),
|
||||
});
|
||||
self.pending_consciousness.push(BackendEvent::Reflection(content));
|
||||
}
|
||||
BackendEvent::Archivist { synthesis, pressure } => {
|
||||
self.pressure = pressure;
|
||||
self.cockpit_log.push(CockpitEntry {
|
||||
kind: CockpitKind::Archivist,
|
||||
text: format!("{:.0}% — {}", pressure * 100.0, synthesis),
|
||||
});
|
||||
self.messages.push(ChatMessage::System {
|
||||
text: format!("archivist: {} (pressure {:.0}%)", synthesis, pressure * 100.0),
|
||||
ts: Instant::now(),
|
||||
});
|
||||
self.pending_consciousness.push(BackendEvent::Archivist { synthesis, pressure });
|
||||
}
|
||||
BackendEvent::CompactionWarning { pressure, tier } => {
|
||||
self.pressure = pressure;
|
||||
let label = match tier { 3 => "critical", 2 => "urgent", _ => "warn" };
|
||||
let kind = match tier {
|
||||
3 => CockpitKind::CompactionCritical,
|
||||
2 => CockpitKind::CompactionUrgent,
|
||||
_ => CockpitKind::CompactionWarn,
|
||||
};
|
||||
self.cockpit_log.push(CockpitEntry {
|
||||
kind,
|
||||
text: format!("{label} · {:.0}%", pressure * 100.0),
|
||||
});
|
||||
self.messages.push(ChatMessage::System {
|
||||
text: format!("context pressure {:.0}% ({label}) — consider `memory compact`", pressure * 100.0),
|
||||
ts: Instant::now(),
|
||||
});
|
||||
self.pending_consciousness.push(BackendEvent::CompactionWarning { pressure, tier });
|
||||
}
|
||||
BackendEvent::ContextPressure(p) => {
|
||||
self.pressure = p;
|
||||
self.pending_consciousness.push(BackendEvent::ContextPressure(p));
|
||||
}
|
||||
BackendEvent::InferenceStrain { attempt, status, model } => {
|
||||
let text = if status == 0 {
|
||||
format!("{} unreachable (attempt {})", model, attempt + 1)
|
||||
} else {
|
||||
format!("{} returned {} (attempt {})", model, status, attempt + 1)
|
||||
};
|
||||
self.cockpit_log.push(CockpitEntry {
|
||||
kind: CockpitKind::InferenceStrain,
|
||||
text,
|
||||
});
|
||||
self.pending_consciousness.push(BackendEvent::InferenceStrain {
|
||||
attempt, status, model: String::new(),
|
||||
});
|
||||
}
|
||||
BackendEvent::ScheduleActive { name } => {
|
||||
self.cockpit_log.push(CockpitEntry {
|
||||
kind: CockpitKind::Reflection,
|
||||
text: format!("schedule: {}", name),
|
||||
});
|
||||
}
|
||||
BackendEvent::ScheduleComplete { name, silent } => {
|
||||
if !silent {
|
||||
self.cockpit_log.push(CockpitEntry {
|
||||
kind: CockpitKind::Reflection,
|
||||
text: format!("schedule done: {}", name),
|
||||
});
|
||||
}
|
||||
}
|
||||
BackendEvent::ToolCall { id, name, arguments, round } => {
|
||||
self.finalize_streaming();
|
||||
self.phase = TurnPhase::Tool;
|
||||
self.tool_calls_this_turn = self.tool_calls_this_turn.saturating_add(1);
|
||||
if !self.thinking.is_empty() {
|
||||
let sep = format!("──── r{} ────", round);
|
||||
let last_is_sep = self.thinking.last().map_or(false, |s| s.starts_with("────"));
|
||||
if !last_is_sep {
|
||||
self.thinking.push(sep);
|
||||
}
|
||||
}
|
||||
self.messages.push(ChatMessage::Tool {
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
round,
|
||||
result: None,
|
||||
ts: Instant::now(),
|
||||
expanded: false,
|
||||
});
|
||||
}
|
||||
BackendEvent::ToolResult { id, name: _, output, is_error } => {
|
||||
let mut bound = false;
|
||||
for msg in self.messages.iter_mut().rev() {
|
||||
if let ChatMessage::Tool { id: tid, result, .. } = msg {
|
||||
if tid == &id && result.is_none() {
|
||||
*result = Some(ToolResultBlock {
|
||||
output: output.clone(),
|
||||
is_error,
|
||||
});
|
||||
bound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !bound {
|
||||
let prefix = if is_error { "[tool error] " } else { "[tool] " };
|
||||
self.messages.push(ChatMessage::System {
|
||||
text: format!("{}{}", prefix, output),
|
||||
ts: Instant::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
BackendEvent::Atmosphere(preset) => {
|
||||
self.pending_consciousness.push(BackendEvent::Atmosphere(preset));
|
||||
}
|
||||
BackendEvent::SubconsciousPass(active) => {
|
||||
self.pending_consciousness.push(BackendEvent::SubconsciousPass(active));
|
||||
}
|
||||
BackendEvent::Outfit(name) => {
|
||||
self.pending_consciousness.push(BackendEvent::Outfit(name));
|
||||
}
|
||||
BackendEvent::Interstitial { text, register } => {
|
||||
if !text.trim().is_empty() {
|
||||
self.messages.push(ChatMessage::Interstitial { text, register });
|
||||
}
|
||||
}
|
||||
BackendEvent::Keepalive => {
|
||||
}
|
||||
BackendEvent::PrimaryComplete => {
|
||||
self.finalize_streaming();
|
||||
self.busy = false;
|
||||
self.cancel_token = None;
|
||||
self.phase = TurnPhase::Subconscious;
|
||||
self.tool_calls_this_turn = 0;
|
||||
}
|
||||
BackendEvent::Done => {
|
||||
self.finalize_streaming();
|
||||
self.busy = false;
|
||||
self.turn_started = None;
|
||||
self.turn_rx = None;
|
||||
self.cancel_token = None;
|
||||
self.phase = TurnPhase::Idle;
|
||||
self.tool_calls_this_turn = 0;
|
||||
if let Some(conv_id) = self.switch_pending.take() {
|
||||
self.initiate_switch_load(conv_id);
|
||||
} else {
|
||||
self.deliver_pending_interjections();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if closed {
|
||||
self.finalize_streaming();
|
||||
self.busy = false;
|
||||
self.turn_started = None;
|
||||
self.turn_rx = None;
|
||||
self.cancel_token = None;
|
||||
self.phase = TurnPhase::Idle;
|
||||
self.tool_calls_this_turn = 0;
|
||||
if let Some(conv_id) = self.switch_pending.take() {
|
||||
self.initiate_switch_load(conv_id);
|
||||
} else {
|
||||
self.deliver_pending_interjections();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
pub fn raise_hand(&mut self) {
|
||||
if let Some(token) = &self.cancel_token {
|
||||
if !token.is_cancelled() {
|
||||
token.cancel();
|
||||
self.phase = TurnPhase::Interrupted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enqueue_interjection(&mut self, text: String) {
|
||||
if !self.busy {
|
||||
self.input = text;
|
||||
self.submit();
|
||||
return;
|
||||
}
|
||||
self.messages.push(ChatMessage::Interjection {
|
||||
text: text.clone(),
|
||||
ts: Instant::now(),
|
||||
delivered: false,
|
||||
});
|
||||
if let Ok(mut q) = self.pending_interjections.lock() {
|
||||
q.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn btw_active(&self) -> bool {
|
||||
!matches!(self.btw_state, BtwState::Idle)
|
||||
}
|
||||
|
||||
pub fn start_btw_fork(&mut self, question: String) {
|
||||
self.btw_state = BtwState::Forking { question: question.clone() };
|
||||
let backend = self.backend.clone();
|
||||
let agent_id = self.agent_id.clone();
|
||||
let conv_id = self.conversation_id.clone();
|
||||
let (tx, rx) = mpsc::channel::<BtwForkEvent>(64);
|
||||
self.btw_rx = Some(rx);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let forked_id = match backend.fork_conversation(&agent_id, &conv_id).await {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
let _ = tx.send(BtwForkEvent::Error(e.to_string())).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = tx.send(BtwForkEvent::Forked { id: forked_id.clone() }).await;
|
||||
|
||||
let mut stream = match backend.send(&forked_id, &question).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = tx.send(BtwForkEvent::Error(e.to_string())).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
use futures::StreamExt;
|
||||
while let Some(ev) = stream.next().await {
|
||||
match ev {
|
||||
Ok(crate::backend::BackendEvent::Token(t)) => {
|
||||
if tx.send(BtwForkEvent::Token(t)).await.is_err() { break; }
|
||||
}
|
||||
Ok(crate::backend::BackendEvent::Done) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let _ = tx.send(BtwForkEvent::Done).await;
|
||||
});
|
||||
}
|
||||
|
||||
pub fn drain_btw(&mut self) {
|
||||
let mut pending_forked_id: Option<String> = None;
|
||||
|
||||
let Some(rx) = &mut self.btw_rx else { return };
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(BtwForkEvent::Forked { id }) => {
|
||||
pending_forked_id = Some(id);
|
||||
}
|
||||
Ok(BtwForkEvent::Token(token)) => {
|
||||
match &mut self.btw_state {
|
||||
BtwState::Forking { question } => {
|
||||
let q = std::mem::take(question);
|
||||
self.btw_state = BtwState::Streaming {
|
||||
question: q,
|
||||
response_so_far: token,
|
||||
};
|
||||
}
|
||||
BtwState::Streaming { response_so_far, .. } => {
|
||||
response_so_far.push_str(&token);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(BtwForkEvent::Done) => {
|
||||
let forked_id = pending_forked_id.take().unwrap_or_default();
|
||||
if let BtwState::Streaming { question, response_so_far } =
|
||||
std::mem::replace(&mut self.btw_state, BtwState::Idle)
|
||||
{
|
||||
self.btw_state = BtwState::Complete {
|
||||
question,
|
||||
response: response_so_far,
|
||||
forked_id,
|
||||
};
|
||||
}
|
||||
self.btw_rx = None;
|
||||
break;
|
||||
}
|
||||
Ok(BtwForkEvent::Error(e)) => {
|
||||
if let BtwState::Forking { question } =
|
||||
std::mem::replace(&mut self.btw_state, BtwState::Idle)
|
||||
{
|
||||
self.btw_state = BtwState::Error { question, error: e };
|
||||
}
|
||||
self.btw_rx = None;
|
||||
break;
|
||||
}
|
||||
Err(mpsc::error::TryRecvError::Empty) => break,
|
||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||
let forked_id = pending_forked_id.take().unwrap_or_default();
|
||||
if let BtwState::Streaming { question, response_so_far } =
|
||||
std::mem::replace(&mut self.btw_state, BtwState::Idle)
|
||||
{
|
||||
self.btw_state = BtwState::Complete {
|
||||
question,
|
||||
response: response_so_far,
|
||||
forked_id,
|
||||
};
|
||||
}
|
||||
self.btw_rx = None;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn btw_dismiss(&mut self) {
|
||||
self.btw_state = BtwState::Idle;
|
||||
self.btw_rx = None;
|
||||
}
|
||||
|
||||
pub fn btw_jump(&mut self) -> Option<String> {
|
||||
if let BtwState::Complete { forked_id, .. } = &self.btw_state {
|
||||
if !forked_id.is_empty() {
|
||||
let id = forked_id.clone();
|
||||
self.btw_state = BtwState::Idle;
|
||||
self.btw_rx = None;
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn toggle_cockpit(&mut self) {
|
||||
self.cockpit = !self.cockpit;
|
||||
}
|
||||
|
||||
pub fn wheel_scroll(&mut self, col: u16, row: u16, up: bool) {
|
||||
let step = |v: u16| if up { v.saturating_add(3) } else { v.saturating_sub(3) };
|
||||
let (thinking, subconscious) = {
|
||||
let cl = self.cockpit_layout.borrow();
|
||||
(cl.thinking, cl.subconscious)
|
||||
};
|
||||
let hit = |r: Rect| {
|
||||
col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height
|
||||
};
|
||||
if self.cockpit && hit(thinking) {
|
||||
self.thinking_scroll.set(step(self.thinking_scroll.get()));
|
||||
} else if self.cockpit && hit(subconscious) {
|
||||
self.subconscious_scroll.set(step(self.subconscious_scroll.get()));
|
||||
} else {
|
||||
self.scroll = step(self.scroll);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_completion(&mut self) {
|
||||
let trimmed = self.input.trim_start();
|
||||
if trimmed.starts_with('/') && !trimmed.contains(' ') && !trimmed.contains('\n') {
|
||||
let query = trimmed;
|
||||
let matches: Vec<&'static SlashDef> = SLASH_COMMANDS
|
||||
.iter()
|
||||
.filter(|cmd| cmd.name.starts_with(query))
|
||||
.collect();
|
||||
if matches.is_empty() || (matches.len() == 1 && matches[0].name == query) {
|
||||
self.overlay = Overlay::None;
|
||||
} else {
|
||||
let selected = match &self.overlay {
|
||||
Overlay::SlashComplete { selected, .. } => (*selected).min(matches.len().saturating_sub(1)),
|
||||
_ => 0,
|
||||
};
|
||||
self.overlay = Overlay::SlashComplete { selected, matches };
|
||||
}
|
||||
} else if matches!(self.overlay, Overlay::SlashComplete { .. }) {
|
||||
self.overlay = Overlay::None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn accept_completion(&mut self) {
|
||||
if let Overlay::SlashComplete { selected, ref matches } = self.overlay {
|
||||
if let Some(cmd) = matches.get(selected) {
|
||||
self.input = cmd.name.to_string();
|
||||
}
|
||||
}
|
||||
self.overlay = Overlay::None;
|
||||
}
|
||||
|
||||
pub fn accept_conversation_pick(&mut self) {
|
||||
if let Overlay::ConversationPicker { selected, ref conversations } = self.overlay {
|
||||
if let Some(conv) = conversations.get(selected) {
|
||||
let conv_id = conv.id.clone();
|
||||
self.overlay = Overlay::None;
|
||||
self.handle_switch_conversation(conv_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.overlay = Overlay::None;
|
||||
}
|
||||
|
||||
pub fn overlay_active(&self) -> bool {
|
||||
!matches!(self.overlay, Overlay::None)
|
||||
}
|
||||
|
||||
pub fn advance_tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
self.release_stream();
|
||||
}
|
||||
|
||||
fn release_stream(&mut self) {
|
||||
if self.stream_buffer.is_empty() {
|
||||
return;
|
||||
}
|
||||
let total = self.stream_buffer.len();
|
||||
let mut take = (total / 3).max(8).min(total);
|
||||
while take > 0 && !self.stream_buffer.is_char_boundary(take) {
|
||||
take -= 1;
|
||||
}
|
||||
if take == 0 {
|
||||
return;
|
||||
}
|
||||
if take < total {
|
||||
if let Some(ws) = self.stream_buffer[..take].rfind(char::is_whitespace) {
|
||||
if take - ws <= 24 {
|
||||
take = ws + 1;
|
||||
while take < total && !self.stream_buffer.is_char_boundary(take) {
|
||||
take += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let chunk: String = self.stream_buffer.drain(..take).collect();
|
||||
self.append_streaming(&chunk);
|
||||
}
|
||||
|
||||
pub fn flush_stream(&mut self) {
|
||||
if !self.stream_buffer.is_empty() {
|
||||
let rest = std::mem::take(&mut self.stream_buffer);
|
||||
self.append_streaming(&rest);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn append_streaming(&mut self, t: &str) {
|
||||
if let Some(ChatMessage::Assistant { text, streaming, .. }) = self.messages.last_mut() {
|
||||
if *streaming {
|
||||
text.push_str(t);
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.messages.push(ChatMessage::Assistant {
|
||||
text: t.to_string(),
|
||||
ts: Instant::now(),
|
||||
streaming: true,
|
||||
rendered_cache: RefCell::new(None),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn finalize_streaming(&mut self) {
|
||||
self.flush_stream();
|
||||
for msg in self.messages.iter_mut().rev() {
|
||||
if let ChatMessage::Assistant { streaming, .. } = msg {
|
||||
if *streaming {
|
||||
*streaming = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message_copy_text(&self, idx: usize) -> Option<String> {
|
||||
match self.messages.get(idx)? {
|
||||
ChatMessage::User { text, .. }
|
||||
| ChatMessage::Assistant { text, .. }
|
||||
| ChatMessage::System { text, .. }
|
||||
| ChatMessage::Interstitial { text, .. } => Some(text.clone()),
|
||||
ChatMessage::Surfacing { content, .. } => Some(content.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn copy_message_at(&mut self, col: u16, row: u16) -> bool {
|
||||
let idx = {
|
||||
let layout = self.msg_layout.borrow();
|
||||
let a = layout.area;
|
||||
if a.height < 3 || row <= a.y || row + 1 >= a.y + a.height {
|
||||
return false;
|
||||
}
|
||||
if col < a.x || col >= a.x + a.width {
|
||||
return false;
|
||||
}
|
||||
let buf_line = (row - a.y - 1) as usize + layout.offset as usize;
|
||||
layout
|
||||
.spans
|
||||
.iter()
|
||||
.find(|(_, s, e)| buf_line >= *s && buf_line < *e)
|
||||
.map(|(i, _, _)| *i)
|
||||
};
|
||||
let Some(idx) = idx else { return false };
|
||||
let Some(text) = self.message_copy_text(idx) else { return false };
|
||||
match arboard::Clipboard::new().and_then(|mut c| c.set_text(text)) {
|
||||
Ok(()) => {
|
||||
self.copy_flash = Some(Instant::now());
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("clipboard copy failed: {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
src/ui/chat/footer.rs
Normal file
45
src/ui/chat/footer.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
|
||||
use super::ChatState;
|
||||
|
||||
pub fn draw_footer(f: &mut Frame, state: &ChatState, area: Rect) {
|
||||
let pressure_pct = (state.pressure * 100.0) as u16;
|
||||
let cockpit_hint = if state.cockpit { "Tab close cockpit" } else { "Tab cockpit" };
|
||||
let tool_hint = if state.tool_cards_expanded { "CTRL+T collapse tools" } else { "CTRL+T expand tools" };
|
||||
let mut spans = vec![
|
||||
Span::styled(
|
||||
format!(" Esc menu · Enter send · {cockpit_hint} · {tool_hint} "),
|
||||
Style::default().fg(state.palette.agent_dim),
|
||||
),
|
||||
Span::raw("│ "),
|
||||
Span::styled(format!("conv {}", short(&state.conversation_id)), Style::default().fg(state.palette.agent_dim)),
|
||||
Span::raw(" │ "),
|
||||
Span::styled(format!("ctx {}%", pressure_pct), Style::default().fg(state.palette.agent_dim)),
|
||||
];
|
||||
if state.scroll > 0 {
|
||||
spans.push(Span::raw(" │ "));
|
||||
spans.push(Span::styled(
|
||||
format!("↓ {} below", state.scroll),
|
||||
Style::default().fg(state.palette.agent_primary),
|
||||
));
|
||||
}
|
||||
if state.copy_flash.map(|t| t.elapsed().as_millis() < 1600).unwrap_or(false) {
|
||||
spans.push(Span::raw(" │ "));
|
||||
spans.push(Span::styled(
|
||||
"⧉ copied",
|
||||
Style::default().fg(state.palette.tool_accent).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
let footer = Line::from(spans);
|
||||
f.render_widget(Paragraph::new(footer).alignment(Alignment::Center), area);
|
||||
}
|
||||
|
||||
pub fn short(s: &str) -> String {
|
||||
if s.len() <= 8 { s.to_string() } else { s[..8].to_string() }
|
||||
}
|
||||
469
src/ui/chat/mod.rs
Normal file
469
src/ui/chat/mod.rs
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
//! Wired chat screen — bubbles, streaming, surfacing.
|
||||
//!
|
||||
//! The state owns:
|
||||
//! - A `Box<dyn Backend>` constructed at App startup (typically `LocalBackend`).
|
||||
//! - A turn-events channel (`mpsc::Receiver<BackendEvent>`) populated by the
|
||||
//! currently-running send task; `None` when idle.
|
||||
//! - A scrollable history of [`ChatMessage`]s.
|
||||
//!
|
||||
//! Visual model — jcode rounded-box pattern:
|
||||
//! - User messages: right-aligned blue bubble.
|
||||
//! - Assistant messages: left-aligned orange bubble; partial message
|
||||
//! appends streaming tokens live.
|
||||
//! - Surfacing items: centered yellow bubble with `[surfacing]` header
|
||||
//! (Constitution Article II.2).
|
||||
|
||||
mod cockpit;
|
||||
mod commands;
|
||||
mod events;
|
||||
mod footer;
|
||||
mod overlays;
|
||||
mod render;
|
||||
pub mod wrap;
|
||||
|
||||
pub use render::draw;
|
||||
pub use footer::short;
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Result;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Color,
|
||||
text::Line,
|
||||
};
|
||||
use tokio::sync::{mpsc, oneshot, RwLock};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::backend::{Backend, BackendEvent};
|
||||
use crate::core::config::ConsciousnessConfig;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum BtwForkEvent {
|
||||
Forked { id: String },
|
||||
Token(String),
|
||||
Done,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
pub const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ChatPalette {
|
||||
pub agent_primary: Color,
|
||||
pub agent_dim: Color,
|
||||
pub user_accent: Color,
|
||||
pub tool_accent: Color,
|
||||
pub tool_dim: Color,
|
||||
pub surfacing: Color,
|
||||
pub reflection: Color,
|
||||
pub archivist: Color,
|
||||
pub compaction: Color,
|
||||
pub bg: Color,
|
||||
}
|
||||
|
||||
impl ChatPalette {
|
||||
pub fn from_atmosphere(atm: crate::ui::atmosphere::Atmosphere) -> Self {
|
||||
Self::from_colors(atm.primary(), atm.secondary(), atm.dim(), atm.bg_tint())
|
||||
}
|
||||
|
||||
pub fn from_colors(
|
||||
primary: Color, secondary: Color, dim: Color, bg: Color,
|
||||
) -> Self {
|
||||
let (pr, pg, pb) = match primary { Color::Rgb(r, g, b) => (r, g, b), _ => (255, 140, 66) };
|
||||
let (sr, sg, sb) = match secondary { Color::Rgb(r, g, b) => (r, g, b), _ => (180, 120, 80) };
|
||||
Self {
|
||||
agent_primary: primary,
|
||||
agent_dim: dim,
|
||||
user_accent: Color::Rgb(
|
||||
(sr / 3).wrapping_add(80),
|
||||
(sg / 3).wrapping_add(100),
|
||||
(sb / 3).wrapping_add(160).min(240),
|
||||
),
|
||||
tool_accent: Color::Rgb(
|
||||
(pr / 3).wrapping_add(80),
|
||||
(pg / 3).wrapping_add(150).min(220),
|
||||
(pb / 3).wrapping_add(160).min(230),
|
||||
),
|
||||
tool_dim: Color::Rgb(
|
||||
(pr / 4).wrapping_add(60),
|
||||
(pg / 4).wrapping_add(100),
|
||||
(pb / 4).wrapping_add(110),
|
||||
),
|
||||
surfacing: Color::Rgb(
|
||||
(pr / 3).wrapping_add(150).min(230),
|
||||
(pg / 3).wrapping_add(140).min(210),
|
||||
(pb / 6).wrapping_add(80),
|
||||
),
|
||||
reflection: Color::Rgb(
|
||||
(sr / 3).wrapping_add(120),
|
||||
(sg / 4).wrapping_add(110),
|
||||
(sb / 3).wrapping_add(160).min(230),
|
||||
),
|
||||
archivist: Color::Rgb(
|
||||
(sr / 4).wrapping_add(80),
|
||||
(sg / 3).wrapping_add(140).min(210),
|
||||
(sb / 3).wrapping_add(130).min(200),
|
||||
),
|
||||
compaction: Color::Rgb(
|
||||
(pr / 3).wrapping_add(170).min(245),
|
||||
(pg / 3).wrapping_add(130).min(200),
|
||||
(pb / 6).wrapping_add(40),
|
||||
),
|
||||
bg,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hash(&self) -> u64 {
|
||||
let into = |c: Color| match c {
|
||||
Color::Rgb(r, g, b) => (r as u64, g as u64, b as u64),
|
||||
_ => (0, 0, 0),
|
||||
};
|
||||
let (apr, apg, apb) = into(self.agent_primary);
|
||||
let (upr, upg, upb) = into(self.user_accent);
|
||||
let (tar, tag, tab) = into(self.tool_accent);
|
||||
let (sur, sug, sub) = into(self.surfacing);
|
||||
apr.wrapping_mul(31)
|
||||
.wrapping_add(apg).wrapping_mul(37)
|
||||
.wrapping_add(apb).wrapping_mul(41)
|
||||
.wrapping_add(upr as u64).wrapping_mul(43)
|
||||
.wrapping_add(upg as u64).wrapping_mul(47)
|
||||
.wrapping_add(upb as u64).wrapping_mul(53)
|
||||
.wrapping_add(tar as u64).wrapping_mul(59)
|
||||
.wrapping_add(tag as u64).wrapping_mul(61)
|
||||
.wrapping_add(tab as u64).wrapping_mul(67)
|
||||
.wrapping_add(sur as u64).wrapping_mul(71)
|
||||
.wrapping_add(sug as u64).wrapping_mul(73)
|
||||
.wrapping_add(sub as u64).wrapping_mul(79)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChatPalette {
|
||||
fn default() -> Self {
|
||||
Self::from_atmosphere(crate::ui::atmosphere::Atmosphere::Default)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─── Cockpit entry ─────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CockpitKind {
|
||||
Surfacing,
|
||||
Reflection,
|
||||
Archivist,
|
||||
CompactionWarn,
|
||||
CompactionUrgent,
|
||||
CompactionCritical,
|
||||
InferenceStrain,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CockpitEntry {
|
||||
pub kind: CockpitKind,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl CockpitEntry {
|
||||
pub fn prefix(&self) -> &'static str {
|
||||
match self.kind {
|
||||
CockpitKind::Surfacing => "◈",
|
||||
CockpitKind::Reflection => "◉",
|
||||
CockpitKind::Archivist => "◆",
|
||||
CockpitKind::CompactionWarn => "▲",
|
||||
CockpitKind::CompactionUrgent => "▲▲",
|
||||
CockpitKind::CompactionCritical => "▲▲▲",
|
||||
CockpitKind::InferenceStrain => "⚡",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn color(&self, palette: &ChatPalette) -> Color {
|
||||
match self.kind {
|
||||
CockpitKind::Surfacing => palette.surfacing,
|
||||
CockpitKind::Reflection => palette.reflection,
|
||||
CockpitKind::Archivist => palette.archivist,
|
||||
CockpitKind::CompactionWarn => palette.compaction,
|
||||
CockpitKind::CompactionUrgent => palette.agent_primary,
|
||||
CockpitKind::CompactionCritical => palette.compaction,
|
||||
CockpitKind::InferenceStrain => palette.compaction,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Overlay ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Overlay {
|
||||
None,
|
||||
SlashComplete {
|
||||
selected: usize,
|
||||
matches: Vec<&'static SlashDef>,
|
||||
},
|
||||
ConversationPicker {
|
||||
selected: usize,
|
||||
conversations: Vec<crate::backend::ConversationInfo>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SlashDef {
|
||||
pub name: &'static str,
|
||||
pub hint: &'static str,
|
||||
}
|
||||
|
||||
pub const SLASH_COMMANDS: &[SlashDef] = &[
|
||||
SlashDef { name: "/help", hint: "Show this help" },
|
||||
SlashDef { name: "/clear", hint: "Clear chat history" },
|
||||
SlashDef { name: "/new", hint: "New conversation" },
|
||||
SlashDef { name: "/resume", hint: "List / switch conversations" },
|
||||
SlashDef { name: "/convos", hint: "Alias for /resume" },
|
||||
SlashDef { name: "/model", hint: "List or set model" },
|
||||
SlashDef { name: "/btw", hint: "Interject — deliver text mid-turn" },
|
||||
SlashDef { name: "/code", hint: "Shift to code posture (tools expanded, ≡ prompt)" },
|
||||
SlashDef { name: "/chat", hint: "Shift to conversation posture (tools collapsed)" },
|
||||
SlashDef { name: "/outfit", hint: "Change agent appearance (outfit name)" },
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MarkdownCache {
|
||||
pub text_len: usize,
|
||||
pub inner_width: usize,
|
||||
pub palette_hash: u64,
|
||||
pub lines: Vec<Line<'static>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ChatMessage {
|
||||
User { text: String, ts: Instant },
|
||||
Assistant {
|
||||
text: String,
|
||||
ts: Instant,
|
||||
streaming: bool,
|
||||
rendered_cache: RefCell<Option<MarkdownCache>>,
|
||||
},
|
||||
Surfacing { source: String, content: String, priority: String, ts: Instant },
|
||||
System { text: String, ts: Instant },
|
||||
Interjection { text: String, ts: Instant, delivered: bool },
|
||||
Interstitial { text: String, register: crate::backend::Register },
|
||||
Tool {
|
||||
id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
round: u32,
|
||||
result: Option<ToolResultBlock>,
|
||||
ts: Instant,
|
||||
expanded: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TurnPhase {
|
||||
Idle,
|
||||
Thinking,
|
||||
Tool,
|
||||
Streaming,
|
||||
Interrupted,
|
||||
Subconscious,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChatMode {
|
||||
Conversation,
|
||||
Code,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolResultBlock {
|
||||
pub output: String,
|
||||
pub is_error: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MsgLayout {
|
||||
pub area: Rect,
|
||||
pub offset: u16,
|
||||
pub spans: Vec<(usize, usize, usize)>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct CockpitLayout {
|
||||
pub thinking: Rect,
|
||||
pub subconscious: Rect,
|
||||
}
|
||||
|
||||
pub struct ChatState {
|
||||
pub backend: Arc<dyn Backend>,
|
||||
pub mode: String,
|
||||
pub agent_name: String,
|
||||
pub agent_id: String,
|
||||
pub conversation_id: String,
|
||||
pub messages: Vec<ChatMessage>,
|
||||
pub input: String,
|
||||
pub scroll: u16,
|
||||
pub msg_layout: RefCell<MsgLayout>,
|
||||
pub copy_flash: Option<Instant>,
|
||||
pub turn_rx: Option<mpsc::Receiver<BackendEvent>>,
|
||||
pub cancel_token: Option<CancellationToken>,
|
||||
pub busy: bool,
|
||||
pub show_esc_overlay: bool,
|
||||
pub tool_calls_this_turn: u32,
|
||||
pub phase: TurnPhase,
|
||||
pub pending_interjections: crate::backend::InterjectionQueue,
|
||||
pub pressure: f32,
|
||||
pub overlay: Overlay,
|
||||
pub cockpit: bool,
|
||||
pub thinking: Vec<String>,
|
||||
pub cockpit_log: Vec<CockpitEntry>,
|
||||
pub thinking_scroll: Cell<u16>,
|
||||
pub subconscious_scroll: Cell<u16>,
|
||||
pub cockpit_layout: RefCell<CockpitLayout>,
|
||||
pub tick: u64,
|
||||
pub turn_started: Option<Instant>,
|
||||
pub last_event_at: Instant,
|
||||
pub model_rx: Option<oneshot::Receiver<String>>,
|
||||
pub pending_consciousness: Vec<BackendEvent>,
|
||||
pub new_conv_rx: Option<oneshot::Receiver<Result<String>>>,
|
||||
pub convos_rx: Option<oneshot::Receiver<Result<Vec<crate::backend::ConversationInfo>>>>,
|
||||
pub switch_rx: Option<oneshot::Receiver<Result<(String, Vec<crate::core::session::ConversationMessage>)>>>,
|
||||
pub switch_pending: Option<String>,
|
||||
pub resume_offer: bool,
|
||||
pub btw_state: BtwState,
|
||||
pub btw_rx: Option<mpsc::Receiver<BtwForkEvent>>,
|
||||
pub tool_cards_expanded: bool,
|
||||
pub render_mode: ChatMode,
|
||||
pub palette: ChatPalette,
|
||||
pub stream_buffer: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BtwState {
|
||||
Idle,
|
||||
Forking { question: String },
|
||||
Streaming { question: String, response_so_far: String },
|
||||
Complete { question: String, response: String, forked_id: String },
|
||||
Error { question: String, error: String },
|
||||
}
|
||||
|
||||
impl ChatState {
|
||||
pub async fn connect(
|
||||
config: Arc<RwLock<ConsciousnessConfig>>,
|
||||
agent_name_pref: &str,
|
||||
) -> Result<Self> {
|
||||
let cfg = config.read().await;
|
||||
let url = cfg.server.effective_url();
|
||||
drop(cfg);
|
||||
|
||||
let remote = crate::backend::RemoteBackend::new(&url);
|
||||
let (backend, mode): (Arc<dyn Backend>, &'static str) = if remote.health().await {
|
||||
(Arc::new(remote), "remote")
|
||||
} else {
|
||||
let cfg = config.read().await.clone();
|
||||
let local = crate::backend::LocalBackend::new(cfg).await?;
|
||||
(Arc::new(local), "local")
|
||||
};
|
||||
|
||||
let agents = backend.list_agents().await?;
|
||||
let agent = agents
|
||||
.iter()
|
||||
.find(|a| a.name == agent_name_pref || a.id == agent_name_pref)
|
||||
.or_else(|| agents.first())
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("no agents available"))?;
|
||||
|
||||
let conversation_id = backend.ensure_conversation(&agent.id).await?;
|
||||
|
||||
let backend: Arc<dyn Backend> = if mode == "remote" {
|
||||
Arc::new(crate::backend::RemoteBackend::with_agent(&url, &agent.id))
|
||||
} else {
|
||||
backend
|
||||
};
|
||||
|
||||
let pending = backend.take_pending_surfacings(&agent.id).await;
|
||||
let mut messages: Vec<ChatMessage> = vec![ChatMessage::System {
|
||||
text: "Souveraine ready. Type to begin.".to_string(),
|
||||
ts: Instant::now(),
|
||||
}];
|
||||
let mut cockpit_log: Vec<CockpitEntry> = Vec::new();
|
||||
if !pending.is_empty() {
|
||||
messages.push(ChatMessage::System {
|
||||
text: format!(
|
||||
"{} observation{} surfaced while you were away (Tab for cockpit).",
|
||||
pending.len(),
|
||||
if pending.len() == 1 { "" } else { "s" },
|
||||
),
|
||||
ts: Instant::now(),
|
||||
});
|
||||
for p in &pending {
|
||||
let (kind, label) = match p.kind.as_str() {
|
||||
"reflection" => (CockpitKind::Reflection, "reflection"),
|
||||
"archivist" => (CockpitKind::Archivist, "archivist"),
|
||||
_ => (CockpitKind::Surfacing, "surfacing"),
|
||||
};
|
||||
cockpit_log.push(CockpitEntry {
|
||||
kind,
|
||||
text: format!("(while away) {}", p.content),
|
||||
});
|
||||
messages.push(ChatMessage::Surfacing {
|
||||
source: if p.source.is_empty() {
|
||||
label.to_string()
|
||||
} else {
|
||||
p.source.clone()
|
||||
},
|
||||
content: p.content.clone(),
|
||||
priority: if p.priority.is_empty() {
|
||||
"heartbeat".to_string()
|
||||
} else {
|
||||
p.priority.clone()
|
||||
},
|
||||
ts: Instant::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
backend,
|
||||
mode: mode.to_string(),
|
||||
agent_name: agent.name,
|
||||
agent_id: agent.id,
|
||||
conversation_id,
|
||||
messages,
|
||||
input: String::new(),
|
||||
scroll: 0,
|
||||
msg_layout: RefCell::new(MsgLayout::default()),
|
||||
copy_flash: None,
|
||||
turn_rx: None,
|
||||
cancel_token: None,
|
||||
busy: false,
|
||||
tool_calls_this_turn: 0,
|
||||
phase: TurnPhase::Idle,
|
||||
pending_interjections: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||
pressure: 0.0,
|
||||
overlay: Overlay::None,
|
||||
cockpit: false,
|
||||
thinking: Vec::new(),
|
||||
cockpit_log,
|
||||
thinking_scroll: Cell::new(0),
|
||||
subconscious_scroll: Cell::new(0),
|
||||
cockpit_layout: RefCell::new(CockpitLayout::default()),
|
||||
tick: 0,
|
||||
turn_started: None,
|
||||
last_event_at: Instant::now(),
|
||||
model_rx: None,
|
||||
pending_consciousness: Vec::new(),
|
||||
new_conv_rx: None,
|
||||
convos_rx: None,
|
||||
switch_rx: None,
|
||||
switch_pending: None,
|
||||
resume_offer: false,
|
||||
btw_state: BtwState::Idle,
|
||||
btw_rx: None,
|
||||
tool_cards_expanded: false,
|
||||
show_esc_overlay: false,
|
||||
render_mode: ChatMode::Conversation,
|
||||
palette: ChatPalette::default(),
|
||||
stream_buffer: String::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
224
src/ui/chat/overlays.rs
Normal file
224
src/ui/chat/overlays.rs
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use super::{BtwState, ChatState, Overlay, SPINNER};
|
||||
use super::wrap::wrap_words;
|
||||
|
||||
pub fn draw_btw_pane(f: &mut Frame, state: &ChatState, area: Rect) {
|
||||
let pane_w = (area.width * 3 / 4).max(40).min(area.width.saturating_sub(6));
|
||||
let pane_h = (area.height * 3 / 5).max(12).min(area.height.saturating_sub(4));
|
||||
let x = (area.width.saturating_sub(pane_w)) / 2;
|
||||
let y = (area.height.saturating_sub(pane_h)) / 2;
|
||||
let pane_area = Rect { x, y, width: pane_w, height: pane_h };
|
||||
|
||||
f.render_widget(Clear, pane_area);
|
||||
|
||||
let inner_w = pane_w.saturating_sub(4) as usize;
|
||||
|
||||
let (title, body, border_color) = match &state.btw_state {
|
||||
BtwState::Forking { question } => {
|
||||
let spinner = SPINNER[(state.tick as usize / 2) % SPINNER.len()];
|
||||
(
|
||||
format!(" btw — {} ", question.chars().take(40).collect::<String>()),
|
||||
vec![Line::from(vec![
|
||||
Span::styled(format!(" {} forking...", spinner), Style::default().fg(state.palette.agent_dim)),
|
||||
])],
|
||||
state.palette.agent_primary,
|
||||
)
|
||||
}
|
||||
BtwState::Streaming { question, response_so_far } => {
|
||||
let truncated: String = response_so_far.chars().take(800).collect();
|
||||
let wrapped = wrap_text(&truncated, inner_w);
|
||||
let q_label = question.chars().take(40).collect::<String>();
|
||||
(
|
||||
format!(" btw — {} ", q_label),
|
||||
wrapped,
|
||||
state.palette.tool_accent,
|
||||
)
|
||||
}
|
||||
BtwState::Complete { question, response, forked_id } => {
|
||||
let truncated: String = response.chars().take(800).collect();
|
||||
let wrapped = wrap_text(&truncated, inner_w);
|
||||
let q_label = question.chars().take(40).collect::<String>();
|
||||
let fork_label = if forked_id.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" fork: {}", &forked_id[..forked_id.len().min(8)])
|
||||
};
|
||||
(
|
||||
format!(" btw — {} {}", q_label, fork_label),
|
||||
{
|
||||
let mut lines = wrapped;
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
"[esc] dismiss · [j] jump to fork",
|
||||
Style::default().fg(state.palette.agent_dim),
|
||||
)));
|
||||
lines
|
||||
},
|
||||
state.palette.surfacing,
|
||||
)
|
||||
}
|
||||
BtwState::Error { question, error } => {
|
||||
let q_label = question.chars().take(40).collect::<String>();
|
||||
(
|
||||
format!(" btw — {} ", q_label),
|
||||
vec![Line::from(Span::styled(
|
||||
format!(" Error: {}", error),
|
||||
Style::default().fg(state.palette.compaction),
|
||||
))],
|
||||
state.palette.compaction,
|
||||
)
|
||||
}
|
||||
BtwState::Idle => unreachable!(),
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.title(title)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(border_color).add_modifier(Modifier::BOLD));
|
||||
|
||||
let para = Paragraph::new(body).block(block).alignment(Alignment::Left).wrap(Wrap { trim: false });
|
||||
f.render_widget(para, pane_area);
|
||||
}
|
||||
|
||||
pub fn draw_overlay(f: &mut Frame, state: &ChatState, full_area: Rect, input_area: Rect) {
|
||||
match &state.overlay {
|
||||
Overlay::None => {}
|
||||
Overlay::SlashComplete { selected, matches } => {
|
||||
let count = matches.len().min(8);
|
||||
let height = count as u16 + 2;
|
||||
let width = 40u16.min(full_area.width.saturating_sub(4));
|
||||
let x = input_area.x + 1;
|
||||
let y = input_area.y.saturating_sub(height);
|
||||
let area = Rect { x, y, width, height };
|
||||
|
||||
f.render_widget(Clear, area);
|
||||
|
||||
let items: Vec<Line<'static>> = matches.iter().enumerate().take(count).map(|(i, cmd)| {
|
||||
let sel = i == *selected;
|
||||
let sel_fg = state.palette.agent_primary;
|
||||
let sel_bg = state.palette.bg;
|
||||
let style = if sel {
|
||||
Style::default().fg(sel_fg).bg(sel_bg).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
let hint_style = if sel {
|
||||
Style::default().fg(state.palette.agent_dim).bg(sel_bg)
|
||||
} else {
|
||||
Style::default().fg(state.palette.agent_dim)
|
||||
};
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {} ", cmd.name), style),
|
||||
Span::styled(format!(" {}", cmd.hint), hint_style),
|
||||
])
|
||||
}).collect();
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(state.palette.agent_dim));
|
||||
let para = Paragraph::new(items).block(block);
|
||||
f.render_widget(para, area);
|
||||
}
|
||||
Overlay::ConversationPicker { selected, conversations } => {
|
||||
let count = conversations.len();
|
||||
let visible = count.min(12);
|
||||
let height = visible as u16 + 4;
|
||||
let width = (full_area.width * 3 / 4).max(40).min(full_area.width.saturating_sub(4));
|
||||
let x = (full_area.width.saturating_sub(width)) / 2;
|
||||
let y = (full_area.height.saturating_sub(height)) / 2;
|
||||
let area = Rect { x, y, width, height };
|
||||
|
||||
f.render_widget(Clear, area);
|
||||
|
||||
let inner_width = (width as usize).saturating_sub(4);
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from(Span::styled(
|
||||
" Conversations — ↑↓ select · Enter resume · n new · Esc dismiss",
|
||||
Style::default().fg(state.palette.agent_dim).add_modifier(Modifier::ITALIC),
|
||||
)));
|
||||
|
||||
let scroll_offset = if *selected >= visible { selected + 1 - visible } else { 0 };
|
||||
for (i, conv) in conversations.iter().enumerate().skip(scroll_offset).take(visible) {
|
||||
let sel = i == *selected;
|
||||
let short_id = &conv.id[..8.min(conv.id.len())];
|
||||
let summary = conv.summary.as_deref().unwrap_or("(no summary)");
|
||||
let label = format!(
|
||||
" {} · {} msgs · {}",
|
||||
short_id, conv.message_count, summary,
|
||||
);
|
||||
let truncated = if label.chars().count() > inner_width {
|
||||
let mut s: String = label.chars().take(inner_width.saturating_sub(1)).collect();
|
||||
s.push('…');
|
||||
s
|
||||
} else {
|
||||
label
|
||||
};
|
||||
|
||||
let style = if sel {
|
||||
Style::default().fg(state.palette.agent_primary).bg(state.palette.bg).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
lines.push(Line::from(Span::styled(truncated, style)));
|
||||
}
|
||||
|
||||
let block = Block::default()
|
||||
.title(Span::styled(
|
||||
" Resume ",
|
||||
Style::default().fg(state.palette.agent_primary).add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(state.palette.agent_primary));
|
||||
let para = Paragraph::new(lines).block(block);
|
||||
f.render_widget(para, area);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_esc_overlay(f: &mut Frame, state: &ChatState, area: Rect) {
|
||||
|
||||
let overlay_w = 28.min(area.width.saturating_sub(4));
|
||||
let overlay_h = 7;
|
||||
let ox = area.x + (area.width - overlay_w) / 2;
|
||||
let oy = area.y + (area.height.saturating_sub(overlay_h)) / 2;
|
||||
let overlay_area = Rect { x: ox, y: oy, width: overlay_w, height: overlay_h };
|
||||
|
||||
f.render_widget(Clear, overlay_area);
|
||||
|
||||
let pal = &state.palette;
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
" Turn in progress ",
|
||||
Style::default().fg(pal.surfacing).add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(" [i] Raise hand", Style::default().fg(pal.agent_primary))),
|
||||
Line::from(Span::styled(" [m] Menu", Style::default().fg(pal.user_accent))),
|
||||
Line::from(Span::styled(" [c] Cancel", Style::default().fg(pal.agent_dim))),
|
||||
];
|
||||
|
||||
let para = Paragraph::new(lines)
|
||||
.alignment(Alignment::Left)
|
||||
.block(Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(pal.agent_dim)));
|
||||
f.render_widget(para, overlay_area);
|
||||
}
|
||||
|
||||
fn wrap_text(text: &str, max_width: usize) -> Vec<Line<'static>> {
|
||||
wrap_words(text, max_width.max(1))
|
||||
.into_iter()
|
||||
.map(|line| Line::from(Span::styled(line, Style::default().fg(Color::White))))
|
||||
.collect()
|
||||
}
|
||||
887
src/ui/chat/render.rs
Normal file
887
src/ui/chat/render.rs
Normal file
|
|
@ -0,0 +1,887 @@
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, BorderType, Borders, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::ui::atmosphere::lerp_color;
|
||||
use crate::ui::markdown;
|
||||
|
||||
use super::{
|
||||
BtwState, ChatMessage, ChatMode, ChatPalette, ChatState, MarkdownCache,
|
||||
MsgLayout, ToolResultBlock, TurnPhase, SPINNER,
|
||||
};
|
||||
use super::wrap::{count_visual_lines, wrap_input_line, wrap_words};
|
||||
use super::cockpit::draw_cockpit;
|
||||
use super::footer::draw_footer;
|
||||
use super::overlays::{draw_btw_pane, draw_esc_overlay, draw_overlay};
|
||||
|
||||
pub fn draw(f: &mut Frame, state: &ChatState) {
|
||||
let area = f.size();
|
||||
|
||||
let input_inner_width = (area.width as usize).saturating_sub(5).max(1);
|
||||
let input_visual_lines = count_visual_lines(&state.input, input_inner_width);
|
||||
let max_input_lines = ((area.height as usize) * 40 / 100).max(1);
|
||||
let input_height = (input_visual_lines.min(max_input_lines) as u16) + 2;
|
||||
|
||||
let phase_height: u16 = if state.busy
|
||||
|| state.phase == TurnPhase::Interrupted
|
||||
|| state.phase == TurnPhase::Subconscious
|
||||
{ 1 } else { 0 };
|
||||
|
||||
let vchunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(5),
|
||||
Constraint::Length(phase_height),
|
||||
Constraint::Length(input_height),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
draw_header(f, state, vchunks[0]);
|
||||
|
||||
if state.cockpit {
|
||||
let body = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Min(40), Constraint::Length(36)])
|
||||
.split(vchunks[1]);
|
||||
draw_messages(f, state, body[0]);
|
||||
draw_cockpit(f, state, body[1]);
|
||||
} else {
|
||||
draw_messages(f, state, vchunks[1]);
|
||||
}
|
||||
|
||||
if phase_height > 0 {
|
||||
draw_phase(f, state, vchunks[2]);
|
||||
}
|
||||
draw_input(f, state, vchunks[3]);
|
||||
draw_footer(f, state, vchunks[4]);
|
||||
|
||||
draw_overlay(f, state, area, vchunks[3]);
|
||||
|
||||
if !matches!(state.btw_state, BtwState::Idle) {
|
||||
draw_btw_pane(f, state, area);
|
||||
}
|
||||
|
||||
if state.show_esc_overlay {
|
||||
draw_esc_overlay(f, state, area);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_phase(f: &mut Frame, state: &ChatState, area: Rect) {
|
||||
let elapsed = state
|
||||
.turn_started
|
||||
.map(|t| t.elapsed().as_secs())
|
||||
.unwrap_or(0);
|
||||
let spinner = SPINNER[(state.tick as usize / 2) % SPINNER.len()];
|
||||
|
||||
let (glyph, label, color) = match state.phase {
|
||||
TurnPhase::Thinking | TurnPhase::Idle => (spinner, "Thinking".to_string(), state.palette.agent_primary),
|
||||
TurnPhase::Tool => {
|
||||
let label = if state.tool_calls_this_turn == 1 {
|
||||
"Running tool · 1 tool used".to_string()
|
||||
} else {
|
||||
format!("Running tool · {} tools used", state.tool_calls_this_turn)
|
||||
};
|
||||
(spinner, label, state.palette.tool_accent)
|
||||
}
|
||||
TurnPhase::Streaming => (spinner, "Streaming".to_string(), state.palette.agent_primary),
|
||||
TurnPhase::Interrupted => ("×", "Interrupted".to_string(), state.palette.compaction),
|
||||
TurnPhase::Subconscious => (spinner, "Subconscious".to_string(), state.palette.surfacing),
|
||||
};
|
||||
let queued = state
|
||||
.pending_interjections
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|q| q.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
let quiet_secs = state.last_event_at.elapsed().as_secs();
|
||||
let liveness = if quiet_secs >= 120 {
|
||||
Some(format!("still waiting {}s...", quiet_secs))
|
||||
} else if quiet_secs >= 5 {
|
||||
Some(format!("waiting {}s...", quiet_secs))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut spans: Vec<Span<'static>> = vec![
|
||||
Span::styled(format!(" {} ", glyph), Style::default().fg(color).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(format!("{}", label), Style::default().fg(color)),
|
||||
Span::styled(format!(" · {}s", elapsed), Style::default().fg(state.palette.agent_dim)),
|
||||
];
|
||||
if let Some(liveness_label) = liveness {
|
||||
spans.push(Span::styled(
|
||||
format!(" · {}", liveness_label),
|
||||
Style::default().fg(state.palette.surfacing).add_modifier(Modifier::ITALIC),
|
||||
));
|
||||
}
|
||||
if queued > 0 {
|
||||
spans.push(Span::styled(
|
||||
format!(" · {} queued", queued),
|
||||
Style::default().fg(state.palette.surfacing).add_modifier(Modifier::ITALIC),
|
||||
));
|
||||
}
|
||||
let line = Line::from(spans);
|
||||
f.render_widget(Paragraph::new(line).alignment(Alignment::Left), area);
|
||||
}
|
||||
|
||||
fn draw_header(f: &mut Frame, state: &ChatState, area: Rect) {
|
||||
let mode_color = match state.mode.as_str() {
|
||||
"local" => state.palette.tool_accent,
|
||||
"remote" => state.palette.agent_primary,
|
||||
_ => state.palette.agent_dim,
|
||||
};
|
||||
let title = Line::from(vec![
|
||||
Span::styled("✦ Souveraine ", Style::default().fg(state.palette.agent_primary).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(format!("· {} ", state.agent_name), Style::default().fg(Color::White)),
|
||||
Span::styled(format!("[{} mode]", state.mode), Style::default().fg(mode_color)),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(title).alignment(Alignment::Center), area);
|
||||
}
|
||||
|
||||
fn entry_intensity(ts: Instant) -> f32 {
|
||||
const ENTRY_MS: f32 = 450.0;
|
||||
const SHIMMER_MAX: f32 = 0.5;
|
||||
let age = ts.elapsed().as_millis() as f32;
|
||||
if age >= ENTRY_MS {
|
||||
return 0.0;
|
||||
}
|
||||
let t = age / ENTRY_MS;
|
||||
(t * std::f32::consts::PI).sin().max(0.0) * SHIMMER_MAX
|
||||
}
|
||||
|
||||
fn msg_entry_ts(msg: &ChatMessage) -> Option<Instant> {
|
||||
match msg {
|
||||
ChatMessage::User { ts, .. }
|
||||
| ChatMessage::Assistant { ts, .. }
|
||||
| ChatMessage::Surfacing { ts, .. }
|
||||
| ChatMessage::System { ts, .. }
|
||||
| ChatMessage::Interjection { ts, .. } => Some(*ts),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn shimmer_lines(lines: &mut [Line<'static>], t: f32) {
|
||||
let blend = |x: u8| (x as f32 + (255.0 - x as f32) * t).round().clamp(0.0, 255.0) as u8;
|
||||
for line in lines.iter_mut() {
|
||||
for span in line.spans.iter_mut() {
|
||||
if let Some(Color::Rgb(r, g, b)) = span.style.fg {
|
||||
span.style.fg = Some(Color::Rgb(blend(r), blend(g), blend(b)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fade_streaming_tail(mut lines: Vec<Line<'static>>, palette: &ChatPalette) -> Vec<Line<'static>> {
|
||||
const FADE: [f32; 3] = [0.62, 0.34, 0.13];
|
||||
let (tr, tg, tb) = match palette.bg {
|
||||
Color::Rgb(r, g, b) => (r, g, b),
|
||||
_ => (12u8, 14u8, 18u8),
|
||||
};
|
||||
let n = lines.len();
|
||||
for (offset, &amount) in FADE.iter().enumerate() {
|
||||
if offset >= n {
|
||||
break;
|
||||
}
|
||||
let blend = |x: u8, t: u8| {
|
||||
(x as f32 + (t as f32 - x as f32) * amount).round().clamp(0.0, 255.0) as u8
|
||||
};
|
||||
for span in lines[n - 1 - offset].spans.iter_mut() {
|
||||
if let Some(Color::Rgb(r, g, b)) = span.style.fg {
|
||||
span.style.fg = Some(Color::Rgb(blend(r, tr), blend(g, tg), blend(b, tb)));
|
||||
}
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn with_stream_cursor(mut lines: Vec<Line<'static>>, accent: Color) -> Vec<Line<'static>> {
|
||||
if let Some(last) = lines.last_mut() {
|
||||
last.spans.push(Span::styled(
|
||||
"▌",
|
||||
Style::default().fg(accent).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn tool_name_pulse(elapsed: Duration) -> Color {
|
||||
let t = (elapsed.as_secs_f32() * 2.0).sin() * 0.5 + 0.5;
|
||||
let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8;
|
||||
Color::Rgb(lerp(80, 186), lerp(139, 200), lerp(220, 255))
|
||||
}
|
||||
|
||||
fn tool_footer_label(indices: &[usize], msgs: &[ChatMessage]) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let mut run: Option<(String, usize)> = None;
|
||||
let flush = |run: &mut Option<(String, usize)>, parts: &mut Vec<String>| {
|
||||
if let Some((n, c)) = run.take() {
|
||||
parts.push(if c > 1 { format!("{} ×{}", n, c) } else { n });
|
||||
}
|
||||
};
|
||||
for &i in indices {
|
||||
if let Some(ChatMessage::Tool { name, .. }) = msgs.get(i) {
|
||||
match &mut run {
|
||||
Some((n, c)) if n == name => *c += 1,
|
||||
_ => {
|
||||
flush(&mut run, &mut parts);
|
||||
run = Some((name.clone(), 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(&mut run, &mut parts);
|
||||
format!("⚙ {}", parts.join(" · "))
|
||||
}
|
||||
|
||||
fn draw_messages(f: &mut Frame, state: &ChatState, area: Rect) {
|
||||
let mdpal = crate::ui::markdown::MarkdownPalette::from_chat_palette(&state.palette);
|
||||
let max_bubble = ((area.width as usize).saturating_sub(8) * 70 / 100).max(20);
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
let show_cards = state.tool_cards_expanded || state.render_mode == ChatMode::Code;
|
||||
let mut fold_footer: std::collections::HashMap<usize, String> =
|
||||
std::collections::HashMap::new();
|
||||
let mut consumed: std::collections::HashSet<usize> = std::collections::HashSet::new();
|
||||
if !show_cards {
|
||||
let msgs = &state.messages;
|
||||
let mut leading: Vec<usize> = Vec::new();
|
||||
for (idx, m) in msgs.iter().enumerate() {
|
||||
match m {
|
||||
ChatMessage::Tool { .. } => leading.push(idx),
|
||||
ChatMessage::Assistant { .. } => {
|
||||
let mut j = idx + 1;
|
||||
while j < msgs.len() && matches!(msgs[j], ChatMessage::Tool { .. }) {
|
||||
j += 1;
|
||||
}
|
||||
let mut owned = std::mem::take(&mut leading);
|
||||
owned.extend((idx + 1)..j);
|
||||
if !owned.is_empty() {
|
||||
for &t in &owned {
|
||||
consumed.insert(t);
|
||||
}
|
||||
fold_footer.insert(idx, tool_footer_label(&owned, msgs));
|
||||
}
|
||||
}
|
||||
_ => leading.clear(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut span_spans: Vec<(usize, usize, usize)> = Vec::new();
|
||||
let mut prev_was_tool = false;
|
||||
for (idx, msg) in state.messages.iter().enumerate() {
|
||||
if consumed.contains(&idx) {
|
||||
continue;
|
||||
}
|
||||
let this_is_tool = matches!(msg, ChatMessage::Tool { .. });
|
||||
if this_is_tool != prev_was_tool && !lines.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
prev_was_tool = this_is_tool;
|
||||
let line_start = lines.len();
|
||||
match msg {
|
||||
ChatMessage::User { text, .. } => {
|
||||
lines.extend(bubble(
|
||||
"⧉ you",
|
||||
text,
|
||||
max_bubble,
|
||||
Style::default().fg(state.palette.user_accent),
|
||||
BubbleAlign::Right,
|
||||
area.width,
|
||||
));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
ChatMessage::Assistant { text, streaming, rendered_cache, .. } => {
|
||||
let label = if *streaming {
|
||||
format!("⧉ {} ◦", state.agent_name)
|
||||
} else {
|
||||
format!("⧉ {}", state.agent_name)
|
||||
};
|
||||
let inner_width = max_bubble.saturating_sub(4).max(8);
|
||||
let body_lines = if text.is_empty() && *streaming {
|
||||
vec![Line::from("…")]
|
||||
} else {
|
||||
let key_len = text.len();
|
||||
let palette_hash = state.palette.hash();
|
||||
let agent_color = state.palette.agent_primary;
|
||||
let cached = rendered_cache.borrow();
|
||||
if let Some(c) = &*cached {
|
||||
if c.text_len == key_len && c.inner_width == inner_width && c.palette_hash == palette_hash {
|
||||
c.lines.clone()
|
||||
} else {
|
||||
drop(cached);
|
||||
let lines = markdown::render_with_width(text, agent_color, Some(inner_width), &mdpal);
|
||||
*rendered_cache.borrow_mut() = Some(MarkdownCache {
|
||||
text_len: key_len,
|
||||
inner_width,
|
||||
palette_hash,
|
||||
lines: lines.clone(),
|
||||
});
|
||||
lines
|
||||
}
|
||||
} else {
|
||||
drop(cached);
|
||||
let lines = markdown::render_with_width(text, agent_color, Some(inner_width), &mdpal);
|
||||
*rendered_cache.borrow_mut() = Some(MarkdownCache {
|
||||
text_len: key_len,
|
||||
inner_width,
|
||||
palette_hash,
|
||||
lines: lines.clone(),
|
||||
});
|
||||
lines
|
||||
}
|
||||
};
|
||||
let body_lines = if *streaming && !text.is_empty() {
|
||||
with_stream_cursor(
|
||||
fade_streaming_tail(body_lines, &state.palette),
|
||||
state.palette.agent_primary,
|
||||
)
|
||||
} else {
|
||||
body_lines
|
||||
};
|
||||
lines.extend(bubble_rendered(
|
||||
&label,
|
||||
&body_lines,
|
||||
max_bubble,
|
||||
Style::default().fg(state.palette.agent_primary),
|
||||
BubbleAlign::Left,
|
||||
area.width,
|
||||
fold_footer.get(&idx).map(|s| s.as_str()),
|
||||
));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
ChatMessage::Surfacing { source, content, priority, .. } => {
|
||||
if priority == "low" {
|
||||
let brief = if content.len() > 90 {
|
||||
format!("{}…", &content[..content.floor_char_boundary(87)])
|
||||
} else {
|
||||
content.clone()
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" · [{}] {}", source, brief),
|
||||
Style::default().fg(state.palette.surfacing).add_modifier(Modifier::ITALIC),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
} else {
|
||||
let label = format!("surfacing · {} · {}", source, priority);
|
||||
lines.extend(bubble(
|
||||
&label,
|
||||
content,
|
||||
max_bubble.min(60),
|
||||
Style::default().fg(state.palette.surfacing),
|
||||
BubbleAlign::Center,
|
||||
area.width,
|
||||
));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
}
|
||||
ChatMessage::System { text, .. } => {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" · {}", text),
|
||||
Style::default().fg(state.palette.agent_dim).add_modifier(Modifier::ITALIC),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
ChatMessage::Tool { name, arguments, round, result, expanded, ts, .. } => {
|
||||
let code_posture = state.render_mode == ChatMode::Code;
|
||||
let expand = *expanded || state.tool_cards_expanded || code_posture;
|
||||
let name_pulse = if result.is_none() {
|
||||
Some(tool_name_pulse(ts.elapsed()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if expand {
|
||||
lines.extend(render_tool_card(
|
||||
name,
|
||||
arguments,
|
||||
*round,
|
||||
result.as_ref(),
|
||||
max_bubble,
|
||||
area.width,
|
||||
&state.palette,
|
||||
name_pulse,
|
||||
));
|
||||
lines.push(Line::from(""));
|
||||
} else {
|
||||
lines.extend(render_tool_card_compact(
|
||||
name,
|
||||
arguments,
|
||||
*round,
|
||||
result.as_ref(),
|
||||
area.width,
|
||||
&state.palette,
|
||||
name_pulse,
|
||||
));
|
||||
}
|
||||
}
|
||||
ChatMessage::Interjection { text, delivered, .. } => {
|
||||
let (glyph, label, color) = if *delivered {
|
||||
("✋", "noticed", state.palette.agent_dim)
|
||||
} else {
|
||||
("✋", "hand raised", state.palette.surfacing)
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {} {} ", glyph, label),
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(text.clone(), Style::default().fg(color).add_modifier(Modifier::ITALIC)),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
ChatMessage::Interstitial { text, register } => {
|
||||
match register {
|
||||
crate::backend::Register::Cenno => {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" ⟡ {} ", text),
|
||||
Style::default()
|
||||
.fg(state.palette.agent_dim)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
)));
|
||||
}
|
||||
crate::backend::Register::HerVoice => {
|
||||
let bar = Style::default()
|
||||
.fg(state.palette.agent_primary)
|
||||
.add_modifier(Modifier::DIM);
|
||||
let body = Style::default().fg(state.palette.agent_primary);
|
||||
let wrap_w = (area.width as usize).saturating_sub(6).max(20);
|
||||
for seg in wrap_words(text, wrap_w) {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" ▌ ", bar),
|
||||
Span::styled(seg, body),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
}
|
||||
if let Some(ts) = msg_entry_ts(msg) {
|
||||
let intensity = entry_intensity(ts);
|
||||
if intensity > 0.0 {
|
||||
shimmer_lines(&mut lines[line_start..], intensity);
|
||||
}
|
||||
}
|
||||
span_spans.push((idx, line_start, lines.len()));
|
||||
}
|
||||
|
||||
let visible_width = area.width.saturating_sub(0) as usize;
|
||||
let mut wrap_remap: Vec<usize> = Vec::with_capacity(lines.len() + 1);
|
||||
let mut wrapped_lines: Vec<Line<'static>> = Vec::with_capacity(lines.len());
|
||||
for line in lines {
|
||||
wrap_remap.push(wrapped_lines.len());
|
||||
wrapped_lines.extend(markdown::wrap_line(line, visible_width));
|
||||
}
|
||||
wrap_remap.push(wrapped_lines.len());
|
||||
let lines = wrapped_lines;
|
||||
for (_, s, e) in span_spans.iter_mut() {
|
||||
*s = wrap_remap.get(*s).copied().unwrap_or(*s);
|
||||
*e = wrap_remap.get(*e).copied().unwrap_or(*e);
|
||||
}
|
||||
|
||||
let trailing_empty = lines
|
||||
.iter()
|
||||
.rev()
|
||||
.take_while(|l| l.spans.iter().all(|s| s.content.trim().is_empty()))
|
||||
.count();
|
||||
let effective_total = lines.len().saturating_sub(trailing_empty);
|
||||
|
||||
let view = area.height.saturating_sub(2) as usize;
|
||||
let max_scroll = effective_total.saturating_sub(view);
|
||||
let user_scroll = (state.scroll as usize).min(max_scroll);
|
||||
let offset = max_scroll.saturating_sub(user_scroll) as u16;
|
||||
|
||||
*state.msg_layout.borrow_mut() = MsgLayout {
|
||||
area,
|
||||
offset,
|
||||
spans: std::mem::take(&mut span_spans),
|
||||
};
|
||||
|
||||
let para = Paragraph::new(lines)
|
||||
.scroll((offset, 0))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::TOP | Borders::BOTTOM)
|
||||
.border_style(Style::default().fg(state.palette.agent_dim))
|
||||
.border_type(BorderType::Plain),
|
||||
);
|
||||
f.render_widget(para, area);
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum BubbleAlign {
|
||||
Left,
|
||||
Right,
|
||||
Center,
|
||||
}
|
||||
|
||||
fn bubble(
|
||||
title: &str,
|
||||
body: &str,
|
||||
max_width: usize,
|
||||
border: Style,
|
||||
align: BubbleAlign,
|
||||
container_width: u16,
|
||||
) -> Vec<Line<'static>> {
|
||||
let max_inner = max_width.saturating_sub(4).max(8);
|
||||
let wrapped = wrap_words(body, max_inner);
|
||||
let widest = wrapped
|
||||
.iter()
|
||||
.map(|s| s.chars().count())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max(title.chars().count() + 2);
|
||||
let inner = widest.min(max_inner);
|
||||
let outer = inner + 4;
|
||||
|
||||
let title_text = format!(" {} ", title);
|
||||
let dashes = outer.saturating_sub(2 + title_text.chars().count());
|
||||
let left_dash = "─".repeat(dashes / 2);
|
||||
let right_dash = "─".repeat(dashes - dashes / 2);
|
||||
|
||||
let pad = match align {
|
||||
BubbleAlign::Left => 2,
|
||||
BubbleAlign::Right => (container_width as usize).saturating_sub(outer + 2),
|
||||
BubbleAlign::Center => (container_width as usize).saturating_sub(outer) / 2,
|
||||
};
|
||||
let pad_str = " ".repeat(pad);
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
let top = format!("{}╭{}{}{}╮", pad_str, left_dash, title_text, right_dash);
|
||||
lines.push(Line::from(Span::styled(top, border)));
|
||||
|
||||
for chunk in &wrapped {
|
||||
let chunk_width = chunk.chars().count();
|
||||
let inner_pad = inner.saturating_sub(chunk_width);
|
||||
let line_str = format!("{}│ {}{} │", pad_str, chunk, " ".repeat(inner_pad));
|
||||
let mut spans = Vec::new();
|
||||
spans.push(Span::raw(pad_str.clone()));
|
||||
spans.push(Span::styled("│ ", border));
|
||||
spans.push(Span::raw(chunk.clone()));
|
||||
if inner_pad > 0 {
|
||||
spans.push(Span::raw(" ".repeat(inner_pad)));
|
||||
}
|
||||
spans.push(Span::styled(" │", border));
|
||||
let _ = line_str;
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
let bottom = format!("{}╰{}╯", pad_str, "─".repeat(outer - 2));
|
||||
lines.push(Line::from(Span::styled(bottom, border)));
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn bubble_rendered(
|
||||
title: &str,
|
||||
body_lines: &[Line<'static>],
|
||||
max_width: usize,
|
||||
border: Style,
|
||||
align: BubbleAlign,
|
||||
container_width: u16,
|
||||
footer: Option<&str>,
|
||||
) -> Vec<Line<'static>> {
|
||||
let max_inner = max_width.saturating_sub(4).max(8);
|
||||
let footer_w = footer
|
||||
.filter(|f| !f.is_empty())
|
||||
.map(|f| f.chars().count() + 2)
|
||||
.unwrap_or(0);
|
||||
let widest = body_lines
|
||||
.iter()
|
||||
.map(|l| l.width())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max(title.chars().count() + 2)
|
||||
.max(footer_w);
|
||||
let inner = widest.min(max_inner);
|
||||
let outer = inner + 4;
|
||||
|
||||
let title_text = format!(" {} ", title);
|
||||
let dashes = outer.saturating_sub(2 + title_text.chars().count());
|
||||
let left_dash = "─".repeat(dashes / 2);
|
||||
let right_dash = "─".repeat(dashes - dashes / 2);
|
||||
|
||||
let pad = match align {
|
||||
BubbleAlign::Left => 2,
|
||||
BubbleAlign::Right => (container_width as usize).saturating_sub(outer + 2),
|
||||
BubbleAlign::Center => (container_width as usize).saturating_sub(outer) / 2,
|
||||
};
|
||||
let pad_str = " ".repeat(pad);
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
let top = format!("{}╭{}{}{}╮", pad_str, left_dash, title_text, right_dash);
|
||||
lines.push(Line::from(Span::styled(top, border)));
|
||||
|
||||
for line in body_lines {
|
||||
let chunk_width = line.width();
|
||||
let inner_pad = inner.saturating_sub(chunk_width);
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
spans.push(Span::raw(pad_str.clone()));
|
||||
spans.push(Span::styled("│ ", border));
|
||||
spans.extend(line.spans.iter().cloned());
|
||||
if inner_pad > 0 {
|
||||
spans.push(Span::raw(" ".repeat(inner_pad)));
|
||||
}
|
||||
spans.push(Span::styled(" │", border));
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
let bottom = match footer {
|
||||
Some(f) if !f.is_empty() => {
|
||||
let ftext = format!(" {} ", f);
|
||||
let fdashes = outer.saturating_sub(2 + ftext.chars().count());
|
||||
let fl = "─".repeat(fdashes / 2);
|
||||
let fr = "─".repeat(fdashes - fdashes / 2);
|
||||
format!("{}╰{}{}{}╯", pad_str, fl, ftext, fr)
|
||||
}
|
||||
_ => format!("{}╰{}╯", pad_str, "─".repeat(outer - 2)),
|
||||
};
|
||||
lines.push(Line::from(Span::styled(bottom, border)));
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn render_tool_card(
|
||||
name: &str,
|
||||
arguments: &str,
|
||||
round: u32,
|
||||
result: Option<&ToolResultBlock>,
|
||||
max_width: usize,
|
||||
container_width: u16,
|
||||
palette: &ChatPalette,
|
||||
name_pulse: Option<Color>,
|
||||
) -> Vec<Line<'static>> {
|
||||
let mdpal = crate::ui::markdown::MarkdownPalette::from_chat_palette(palette);
|
||||
let is_err = result.map(|r| r.is_error).unwrap_or(false);
|
||||
let border_color = if is_err {
|
||||
palette.compaction
|
||||
} else if let Some(p) = name_pulse {
|
||||
p
|
||||
} else {
|
||||
palette.tool_accent
|
||||
};
|
||||
let dim_color = if is_err { palette.compaction } else { palette.tool_dim };
|
||||
let border = Style::default().fg(border_color);
|
||||
|
||||
let glyph = match result {
|
||||
None => '⟳',
|
||||
Some(r) if r.is_error => '✗',
|
||||
Some(_) => '✓',
|
||||
};
|
||||
let title = format!("{} {} · round {}", glyph, name, round);
|
||||
|
||||
let mut body_lines: Vec<Line<'static>> = Vec::new();
|
||||
let inner_width = max_width.saturating_sub(4).max(8);
|
||||
|
||||
let args_summary = summarize_tool_args(arguments);
|
||||
let args_line = Line::from(vec![Span::styled(
|
||||
args_summary,
|
||||
Style::default().fg(dim_color),
|
||||
)]);
|
||||
body_lines.extend(markdown::wrap_line(args_line, inner_width));
|
||||
|
||||
if let Some(r) = result {
|
||||
body_lines.push(Line::from(""));
|
||||
let preview = preview_output(&r.output, 12);
|
||||
let inner_width = max_width.saturating_sub(4).max(8);
|
||||
let rendered = markdown::render_with_width(
|
||||
&preview,
|
||||
if r.is_error { palette.compaction } else { palette.agent_primary },
|
||||
Some(inner_width),
|
||||
&mdpal,
|
||||
);
|
||||
body_lines.extend(rendered);
|
||||
if r.output.lines().count() > 12 {
|
||||
body_lines.push(Line::from(Span::styled(
|
||||
format!(" … ({} more lines)", r.output.lines().count() - 12),
|
||||
Style::default().fg(dim_color).add_modifier(Modifier::ITALIC),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
bubble_rendered(&title, &body_lines, max_width, border, BubbleAlign::Left, container_width, None)
|
||||
}
|
||||
|
||||
fn render_tool_card_compact(
|
||||
name: &str,
|
||||
arguments: &str,
|
||||
round: u32,
|
||||
result: Option<&ToolResultBlock>,
|
||||
container_width: u16,
|
||||
palette: &ChatPalette,
|
||||
name_pulse: Option<Color>,
|
||||
) -> Vec<Line<'static>> {
|
||||
let is_err = result.map(|r| r.is_error).unwrap_or(false);
|
||||
let pending = result.is_none();
|
||||
let pulse = name_pulse.unwrap_or(palette.tool_accent);
|
||||
let (glyph, glyph_color) = match (pending, is_err) {
|
||||
(true, _) => ("⟳", pulse),
|
||||
(false, true) => ("✗", palette.compaction),
|
||||
(false, false) => ("✓", palette.tool_accent),
|
||||
};
|
||||
|
||||
let name_color = if is_err {
|
||||
palette.compaction
|
||||
} else if pending {
|
||||
pulse
|
||||
} else {
|
||||
palette.tool_accent
|
||||
};
|
||||
let dim = if is_err { palette.compaction } else { palette.tool_dim };
|
||||
|
||||
let reserved = name.chars().count() + 14;
|
||||
let arg_budget = (container_width as usize)
|
||||
.saturating_sub(reserved + 6)
|
||||
.max(20)
|
||||
.min(120);
|
||||
let args_summary = clip(&summarize_tool_args(arguments), arg_budget);
|
||||
|
||||
let mut spans: Vec<Span<'static>> = vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(glyph.to_string(), Style::default().fg(glyph_color)),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
name.to_string(),
|
||||
Style::default().fg(name_color).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
];
|
||||
if !args_summary.is_empty() {
|
||||
spans.push(Span::styled(" · ", Style::default().fg(dim)));
|
||||
spans.push(Span::styled(args_summary, Style::default().fg(dim)));
|
||||
}
|
||||
if round > 1 {
|
||||
spans.push(Span::styled(
|
||||
format!(" · r{}", round),
|
||||
Style::default().fg(dim).add_modifier(Modifier::DIM),
|
||||
));
|
||||
}
|
||||
|
||||
let mut out = vec![Line::from(spans)];
|
||||
|
||||
if let Some(r) = result {
|
||||
if r.is_error {
|
||||
if let Some(first_line) = r.output.lines().next() {
|
||||
let trimmed = first_line.trim();
|
||||
if !trimmed.is_empty() {
|
||||
let inner = (container_width as usize).saturating_sub(8).max(20);
|
||||
let preview = clip(trimmed, inner);
|
||||
out.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
preview,
|
||||
Style::default().fg(palette.compaction).add_modifier(Modifier::ITALIC),
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn summarize_tool_args(arguments: &str) -> String {
|
||||
let parsed: Result<serde_json::Value, _> = serde_json::from_str(arguments);
|
||||
match parsed {
|
||||
Ok(serde_json::Value::Object(map)) => {
|
||||
let parts: Vec<String> = map
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let s = match v {
|
||||
serde_json::Value::String(s) => clip(s, 60),
|
||||
other => clip(&other.to_string(), 60),
|
||||
};
|
||||
format!("{}: {}", k, s)
|
||||
})
|
||||
.collect();
|
||||
parts.join(" · ")
|
||||
}
|
||||
Ok(other) => clip(&other.to_string(), 120),
|
||||
Err(_) => clip(arguments, 120),
|
||||
}
|
||||
}
|
||||
|
||||
fn clip(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
|
||||
fn preview_output(s: &str, n: usize) -> String {
|
||||
let lines: Vec<&str> = s.lines().take(n).collect();
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn draw_input(f: &mut Frame, state: &ChatState, area: Rect) {
|
||||
let border_color = if state.busy {
|
||||
let phase = (state.tick as f32 / 8.0).sin().abs();
|
||||
lerp_color(state.palette.agent_dim, state.palette.agent_primary, phase)
|
||||
} else {
|
||||
state.palette.agent_primary
|
||||
};
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(border_color));
|
||||
|
||||
let cursor_visible = (state.tick / 5) % 2 == 0;
|
||||
let cursor_ch: &str = if cursor_visible { "▏" } else { " " };
|
||||
let inner_width = (area.width as usize).saturating_sub(5).max(1);
|
||||
|
||||
let (prefix_str, prefix_color) = match state.render_mode {
|
||||
ChatMode::Conversation => (" › ", state.palette.agent_primary),
|
||||
ChatMode::Code => (" ≡ ", state.palette.tool_accent),
|
||||
};
|
||||
let prefix_style = Style::default().fg(prefix_color).add_modifier(Modifier::BOLD);
|
||||
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
let logical: Vec<&str> = state.input.split('\n').collect();
|
||||
|
||||
for (li, logical_line) in logical.iter().enumerate() {
|
||||
let chars: Vec<char> = logical_line.chars().collect();
|
||||
let chunks = wrap_input_line(&chars, inner_width);
|
||||
for (pos, &(chunk_start, chunk_end)) in chunks.iter().enumerate() {
|
||||
let chunk: String = chars[chunk_start..chunk_end].iter().collect();
|
||||
let is_first = li == 0 && pos == 0;
|
||||
let prefix: Span<'static> = if is_first {
|
||||
Span::styled(prefix_str.to_string(), prefix_style)
|
||||
} else {
|
||||
Span::raw(" ")
|
||||
};
|
||||
let is_last = li == logical.len() - 1 && pos == chunks.len() - 1;
|
||||
let mut spans = vec![prefix, Span::styled(chunk, Style::default().fg(Color::White))];
|
||||
if is_last {
|
||||
spans.push(Span::styled(cursor_ch.to_string(), Style::default().fg(prefix_color)));
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(prefix_str.to_string(), prefix_style),
|
||||
Span::styled(cursor_ch.to_string(), Style::default().fg(prefix_color)),
|
||||
]));
|
||||
}
|
||||
|
||||
let visible_height = area.height.saturating_sub(2) as usize;
|
||||
let scroll = if lines.len() > visible_height {
|
||||
(lines.len() - visible_height) as u16
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let para = Paragraph::new(lines).scroll((scroll, 0)).block(block);
|
||||
f.render_widget(para, area);
|
||||
}
|
||||
79
src/ui/chat/wrap.rs
Normal file
79
src/ui/chat/wrap.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
pub fn wrap_words(text: &str, width: usize) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for paragraph in text.split('\n') {
|
||||
if paragraph.is_empty() {
|
||||
out.push(String::new());
|
||||
continue;
|
||||
}
|
||||
let mut current = String::new();
|
||||
for word in paragraph.split_whitespace() {
|
||||
let w = word.chars().count();
|
||||
if w >= width {
|
||||
if !current.is_empty() {
|
||||
out.push(std::mem::take(&mut current));
|
||||
}
|
||||
// Long word — chunk it.
|
||||
let mut buf = String::new();
|
||||
for ch in word.chars() {
|
||||
if buf.chars().count() + 1 > width {
|
||||
out.push(std::mem::take(&mut buf));
|
||||
}
|
||||
buf.push(ch);
|
||||
}
|
||||
if !buf.is_empty() {
|
||||
out.push(buf);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if current.is_empty() {
|
||||
current.push_str(word);
|
||||
} else if current.chars().count() + 1 + w <= width {
|
||||
current.push(' ');
|
||||
current.push_str(word);
|
||||
} else {
|
||||
out.push(std::mem::take(&mut current));
|
||||
current.push_str(word);
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
out.push(current);
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
out.push(String::new());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn wrap_input_line(chars: &[char], width: usize) -> Vec<(usize, usize)> {
|
||||
let width = width.max(1);
|
||||
let mut chunks = Vec::new();
|
||||
let mut start = 0;
|
||||
while start < chars.len() {
|
||||
let hard_end = (start + width).min(chars.len());
|
||||
let end = if hard_end == chars.len() {
|
||||
hard_end
|
||||
} else {
|
||||
match chars[start..hard_end].iter().rposition(|c| c.is_whitespace()) {
|
||||
Some(rel) => start + rel + 1,
|
||||
None => hard_end,
|
||||
}
|
||||
};
|
||||
chunks.push((start, end));
|
||||
start = end;
|
||||
}
|
||||
if chunks.is_empty() {
|
||||
chunks.push((0, 0));
|
||||
}
|
||||
chunks
|
||||
}
|
||||
|
||||
pub fn count_visual_lines(text: &str, wrap_width: usize) -> usize {
|
||||
let w = wrap_width.max(1);
|
||||
let mut count = 0;
|
||||
for line in text.split('\n') {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
count += wrap_input_line(&chars, w).len();
|
||||
}
|
||||
count.max(1)
|
||||
}
|
||||
Loading…
Reference in a new issue