Watch
1
0
Fork
You've already forked souveraine
0
souveraine/src/ui/chat/mod.rs
Fimeg 45d25b25a4 pin CI to rust 1.94, fix agents detail pane + settings test
agents: wire the rest of the detail widgets into the section children so
PRIMARY badge etc actually render; drop the HARDCODED debug text.
settings: test needs a provider in the map before navigating to Providers
cat.
clippy: pin CI to rust 1.94 per Cargo.toml rust-version; also apply what
cargo clippy --fix could auto-fix for 1.96 compat.
2026-06-19 12:02:54 -04:00

548 lines
18 KiB
Rust

//! 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 tool_renderers;
pub mod wrap;
pub use render::draw;
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;
pub use crate::core::session::ImageAttachment;
#[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).wrapping_mul(43)
.wrapping_add(upg).wrapping_mul(47)
.wrapping_add(upb).wrapping_mul(53)
.wrapping_add(tar).wrapping_mul(59)
.wrapping_add(tag).wrapping_mul(61)
.wrapping_add(tab).wrapping_mul(67)
.wrapping_add(sur).wrapping_mul(71)
.wrapping_add(sug).wrapping_mul(73)
.wrapping_add(sub).wrapping_mul(79)
}
}
impl Default for ChatPalette {
fn default() -> Self {
Self::from_atmosphere(crate::ui::atmosphere::Atmosphere::Default)
}
}
// ─── Cockpit entry ─────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy)]
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, Copy)]
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)" },
SlashDef { name: "/attach", hint: "Attach an image file" },
];
#[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,
},
Image {
media_type: String,
label: String,
data: String,
dimensions: Option<(u32, u32)>,
ts: Instant,
},
}
#[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 input_cursor: usize,
/// Images attached to the current input, not yet submitted.
pub attached_images: Vec<ImageAttachment>,
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,
/// Total context window size (e.g. 262_000 for kimi-k2.6).
/// None until the first pressure event arrives.
pub context_limit: Option<usize>,
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,
/// Live subconscious reasoning stream — completed lines from the N+1 pass.
/// SubconsciousToken chunks build up in `subconscious_current` (below);
/// when a tool call/result event arrives, that buffer is flushed here as
/// a finalized line and cleared. Cleared on each new pass start.
pub subconscious_stream: Vec<String>,
/// The currently-streaming subconscious line — token chunks append here as
/// they arrive (chunked-replay pattern, mirroring `turn.rs` for the
/// primary). Rendered as the bottom-most, brightest line below the
/// fading history in `subconscious_stream`. Promoted to the history Vec
/// when a non-token event (tool call/result, or a new pass) arrives.
pub subconscious_current: String,
/// Current itinerary route-line for the header strip.
/// Empty string means no active itinerary.
pub itinerary_line: String,
/// The human's preferred name, read from system/human.md frontmatter.
/// Falls back to "you" if unset.
pub human_name: String,
// ── Health vitals (for sidebar) ─────────────────────────────────────
pub backend_mode: String,
pub backend_healthy: bool,
pub n1_passes: u32,
pub n1_active: bool,
pub surfacings_count: u32,
pub last_reflection: Option<String>,
pub last_archivist: Option<String>,
pub last_compaction: Option<(u8, String)>,
pub strain_504: u32,
pub strain_429: u32,
pub strain_other: u32,
}
#[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(),
});
}
}
// Try to load the human's nickname from the agent's memfs.
let human_name = {
let home = std::env::var("HOME").unwrap_or_default();
let p = std::path::PathBuf::from(home)
.join(".souveraine")
.join("agents")
.join(&agent.id)
.join("memory");
crate::core::tools::nickname::read_human_name(&p)
.unwrap_or_else(|| "you".to_string())
};
Ok(Self {
backend,
mode: mode.to_string(),
agent_name: agent.name,
agent_id: agent.id,
human_name,
conversation_id,
messages,
input: String::new(),
input_cursor: 0,
attached_images: Vec::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,
context_limit: None,
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(),
subconscious_stream: Vec::new(),
subconscious_current: String::new(),
itinerary_line: String::new(),
backend_mode: mode.to_string(),
backend_healthy: true,
n1_passes: 0,
n1_active: false,
surfacings_count: 0,
last_reflection: None,
last_archivist: None,
last_compaction: None,
strain_504: 0,
strain_429: 0,
strain_other: 0,
})
}
}