Watch
1
0
Fork
You've already forked souveraine
0
souveraine/src/ui/chat.rs
Fimeg d64a6e9794 feat: WIP - Implement companion buddy system for Souveraine TUI
- Added src/ui/buddy.rs with CompanionSprite, BuddyState, and draw functions
- Integrated buddy into app.rs for welcome screen and dashboard
- Added agent selection via 'a' key on welcome screen
- Buddy shows agent name, mood, energy, health, and subconscious status
- WIP: Needs full agent alias creation/removal flow
2026-05-07 02:31:44 -04:00

682 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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).
use std::sync::Arc;
use std::time::Instant;
use anyhow::Result;
use futures::StreamExt;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph, Wrap},
Frame,
};
use tokio::sync::mpsc;
use tokio::sync::RwLock;
use crate::backend::{Backend, BackendEvent};
use crate::core::config::ConsciousnessConfig;
use crate::ui::markdown;
const SURFACING_YELLOW: Color = Color::Rgb(220, 190, 100);
const USER_BLUE: Color = Color::Rgb(120, 170, 240);
const ANI_ORANGE: Color = Color::Rgb(255, 140, 66);
const ANI_DIM: Color = Color::Rgb(180, 120, 80);
const STATUS_GRAY: Color = Color::Rgb(140, 140, 140);
#[derive(Debug, Clone)]
pub enum ChatMessage {
User { text: String, ts: Instant },
Assistant { text: String, ts: Instant, streaming: bool },
Surfacing { source: String, content: String, priority: String, ts: Instant },
System { text: String, ts: Instant },
}
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 turn_rx: Option<mpsc::Receiver<BackendEvent>>,
pub busy: bool,
pub pressure: f32,
/// Cockpit pane visible (Tab toggles).
pub cockpit: bool,
/// Recent thinking/reasoning lines for the cockpit pane.
pub thinking: Vec<String>,
/// Recent subconscious surfacings + reflections for the cockpit pane.
pub cockpit_log: Vec<String>,
/// Monotonic tick counter for animation timings.
pub tick: u64,
/// When the current turn started (for spinner animation).
pub turn_started: Option<Instant>,
}
impl ChatState {
pub async fn connect(
config: Arc<RwLock<ConsciousnessConfig>>,
agent_name_pref: &str,
) -> Result<Self> {
// Pick the backend: try remote first, fall back to local. Mirrors
// `main::resolve_backend` but adapted for the TUI (no quiet/json flags).
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?;
Ok(Self {
backend,
mode: mode.to_string(),
agent_name: agent.name,
agent_id: agent.id,
conversation_id,
messages: vec![ChatMessage::System {
text: "Souveraine ready. Type to begin.".to_string(),
ts: Instant::now(),
}],
input: String::new(),
scroll: 0,
turn_rx: None,
busy: false,
pressure: 0.0,
cockpit: false,
thinking: Vec::new(),
cockpit_log: Vec::new(),
tick: 0,
turn_started: None,
})
}
/// Submit the current input as a user message and start a turn.
pub fn submit(&mut self) {
if self.busy || self.input.trim().is_empty() {
return;
}
let text = std::mem::take(&mut self.input);
let ts = Instant::now();
self.messages.push(ChatMessage::User { text: text.clone(), ts });
self.messages.push(ChatMessage::Assistant {
text: String::new(),
ts,
streaming: true,
});
self.busy = true;
self.turn_started = Some(Instant::now());
let (tx, rx) = mpsc::channel::<BackendEvent>(64);
self.turn_rx = Some(rx);
let backend = self.backend.clone();
let conv_id = self.conversation_id.clone();
tokio::spawn(async move {
match backend.send(&conv_id, &text).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;
});
}
/// Drain pending events from the active turn channel (non-blocking).
/// Call once per UI tick.
pub fn drain_events(&mut self) {
// Two-phase to avoid double-borrowing self: drain into a Vec, then process.
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;
}
for ev in drained {
match ev {
BackendEvent::Token(t) => self.append_streaming(&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(format!("surfacing · {} · {}{}", source, priority, content));
if self.cockpit_log.len() > 200 {
self.cockpit_log.drain(..self.cockpit_log.len() - 200);
}
self.messages.push(ChatMessage::Surfacing {
source,
content,
priority,
ts: Instant::now(),
});
}
BackendEvent::Reflection(content) => {
self.cockpit_log.push(format!("reflection — {}", content));
self.messages.push(ChatMessage::System {
text: format!("reflection: {}", content),
ts: Instant::now(),
});
}
BackendEvent::Archivist { synthesis, pressure } => {
self.pressure = pressure;
self.cockpit_log.push(format!("archivist · {:.0}% — {}", pressure * 100.0, synthesis));
self.messages.push(ChatMessage::System {
text: format!("archivist: {} (pressure {:.0}%)", synthesis, pressure * 100.0),
ts: Instant::now(),
});
}
BackendEvent::Done => {
self.finalize_streaming();
self.busy = false;
self.turn_started = None;
self.turn_rx = None;
return;
}
}
}
if closed {
self.finalize_streaming();
self.busy = false;
self.turn_started = None;
self.turn_rx = None;
}
}
/// Toggle the cockpit side-pane.
pub fn toggle_cockpit(&mut self) {
self.cockpit = !self.cockpit;
}
/// Bump the animation tick. Called once per UI frame.
pub fn advance_tick(&mut self) {
self.tick = self.tick.wrapping_add(1);
}
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,
});
}
fn finalize_streaming(&mut self) {
if let Some(ChatMessage::Assistant { streaming, .. }) = self.messages.last_mut() {
*streaming = false;
}
}
}
// ─── Rendering ───────────────────────────────────────────────────────────
pub fn draw(f: &mut Frame, state: &ChatState) {
let area = f.size();
let vchunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // header
Constraint::Min(5), // body (messages + optional cockpit)
Constraint::Length(3), // input
Constraint::Length(1), // status footer
])
.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]);
}
draw_input(f, state, vchunks[2]);
draw_footer(f, state, vchunks[3]);
}
fn draw_header(f: &mut Frame, state: &ChatState, area: Rect) {
let mode_color = match state.mode.as_str() {
"local" => Color::Rgb(120, 200, 140),
"remote" => Color::Rgb(180, 180, 220),
_ => STATUS_GRAY,
};
let title = Line::from(vec![
Span::styled("✦ Souveraine ", Style::default().fg(ANI_ORANGE).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 draw_messages(f: &mut Frame, state: &ChatState, area: Rect) {
let max_bubble = ((area.width as usize).saturating_sub(8) * 70 / 100).max(20);
let mut lines: Vec<Line<'static>> = Vec::new();
for msg in &state.messages {
match msg {
ChatMessage::User { text, .. } => {
lines.extend(bubble(
"you",
text,
max_bubble,
Style::default().fg(USER_BLUE),
BubbleAlign::Right,
area.width,
));
lines.push(Line::from(""));
}
ChatMessage::Assistant { text, streaming, .. } => {
let label = if *streaming { format!("{}", state.agent_name) } else { state.agent_name.clone() };
let body_lines = if text.is_empty() && *streaming {
vec![Line::from("")]
} else {
markdown::render(text, ANI_ORANGE)
};
lines.extend(bubble_rendered(
&label,
&body_lines,
max_bubble,
Style::default().fg(ANI_ORANGE),
BubbleAlign::Left,
area.width,
));
lines.push(Line::from(""));
}
ChatMessage::Surfacing { source, content, priority, .. } => {
let label = format!("surfacing · {} · {}", source, priority);
lines.extend(bubble(
&label,
content,
max_bubble.min(60),
Style::default().fg(SURFACING_YELLOW),
BubbleAlign::Center,
area.width,
));
lines.push(Line::from(""));
}
ChatMessage::System { text, .. } => {
lines.push(Line::from(Span::styled(
format!(" · {}", text),
Style::default().fg(STATUS_GRAY).add_modifier(Modifier::ITALIC),
)));
lines.push(Line::from(""));
}
}
}
// Auto-scroll to bottom unless the user has manually scrolled up.
let total = lines.len() as u16;
let view = area.height.saturating_sub(2);
let scroll = total.saturating_sub(view).saturating_sub(state.scroll);
let para = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((scroll, 0))
.block(
Block::default()
.borders(Borders::TOP | Borders::BOTTOM)
.border_style(Style::default().fg(ANI_DIM))
.border_type(BorderType::Plain),
);
f.render_widget(para, area);
}
#[derive(Clone, Copy)]
enum BubbleAlign {
Left,
Right,
Center,
}
/// Build a rounded-box bubble (jcode pattern). Returns a vector of styled lines.
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
}
/// Build a rounded-box bubble around pre-rendered markdown lines.
///
/// Like [`bubble`] but accepts `Vec<Line<'static>>` (from the markdown
/// renderer) instead of a plain `&str`. Each line keeps its styled spans
/// (bold, code, headings, etc.) inside the box-drawing borders.
fn bubble_rendered(
title: &str,
body_lines: &[Line<'static>],
max_width: usize,
border: Style,
align: BubbleAlign,
container_width: u16,
) -> Vec<Line<'static>> {
let max_inner = max_width.saturating_sub(4).max(8);
let widest = body_lines
.iter()
.map(|l| l.width())
.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 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 = format!("{}{}", pad_str, "".repeat(outer - 2));
lines.push(Line::from(Span::styled(bottom, border)));
lines
}
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
}
fn draw_input(f: &mut Frame, state: &ChatState, area: Rect) {
let line = if state.busy {
let spinner = SPINNER[(state.tick as usize / 2) % SPINNER.len()];
let elapsed = state
.turn_started
.map(|t| t.elapsed().as_secs())
.unwrap_or(0);
Line::from(vec![
Span::styled(format!(" {} ", spinner), Style::default().fg(ANI_ORANGE).add_modifier(Modifier::BOLD)),
Span::styled(
format!("thinking… {}s", elapsed),
Style::default().fg(ANI_DIM).add_modifier(Modifier::ITALIC),
),
])
} else {
// Cursor blinks at ~2Hz with the tick (assuming 100ms tick rate).
let cursor_visible = (state.tick / 5) % 2 == 0;
let cursor = if cursor_visible { "" } else { " " };
Line::from(vec![
Span::styled(" ", Style::default().fg(ANI_ORANGE).add_modifier(Modifier::BOLD)),
Span::styled(state.input.clone(), Style::default().fg(Color::White)),
Span::styled(cursor, Style::default().fg(ANI_ORANGE)),
])
};
let border_color = if state.busy {
let phase = (state.tick as f32 / 8.0).sin().abs();
// Breathing dim → orange while thinking.
let r = (180.0 + (255.0 - 180.0) * phase) as u8;
let g = (120.0 + (140.0 - 120.0) * phase) as u8;
let b = (80.0 + (66.0 - 80.0) * phase) as u8;
Color::Rgb(r, g, b)
} else {
ANI_ORANGE
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color));
f.render_widget(Paragraph::new(line).block(block), area);
}
const SPINNER: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
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);
// Thinking pane
let thinking_view = state
.thinking
.iter()
.rev()
.take(panes[0].height as usize)
.rev()
.map(|t| Line::from(Span::styled(format!("· {}", t), Style::default().fg(STATUS_GRAY))))
.collect::<Vec<_>>();
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(ANI_DIM))
.title(Span::styled(" thinking ", Style::default().fg(ANI_DIM).add_modifier(Modifier::BOLD))),
);
f.render_widget(thinking, panes[0]);
// Subconscious pane (surfacings, reflections, archivist)
let log_view = state
.cockpit_log
.iter()
.rev()
.take(panes[1].height as usize)
.rev()
.map(|t| Line::from(Span::styled(format!("· {}", t), Style::default().fg(SURFACING_YELLOW))))
.collect::<Vec<_>>();
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(SURFACING_YELLOW))
.title(Span::styled(" subconscious ", Style::default().fg(SURFACING_YELLOW).add_modifier(Modifier::BOLD))),
);
f.render_widget(subconscious, panes[1]);
}
fn draw_footer(f: &mut Frame, state: &ChatState, area: Rect) {
let pressure_pct = (state.pressure * 100.0) as u16;
let pressure_label = format!("ctx {}%", pressure_pct);
let cockpit_hint = if state.cockpit { "Tab close cockpit" } else { "Tab cockpit" };
let footer = Line::from(vec![
Span::styled(
format!(" Esc menu · Enter send · ↑↓ scroll · {} ", cockpit_hint),
Style::default().fg(STATUS_GRAY),
),
Span::raw(""),
Span::styled(format!("conv {}", short(&state.conversation_id)), Style::default().fg(STATUS_GRAY)),
Span::raw(""),
Span::styled(pressure_label, Style::default().fg(STATUS_GRAY)),
]);
f.render_widget(Paragraph::new(footer).alignment(Alignment::Center), area);
}
fn short(s: &str) -> String {
if s.len() <= 8 { s.to_string() } else { s[..8].to_string() }
}