Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-05-19 10:26:16 -04:00
commit ca2762a302
9 changed files with 2926 additions and 3265 deletions

File diff suppressed because it is too large Load diff

127
src/ui/chat/cockpit.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}