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.
548 lines
18 KiB
Rust
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,
|
|
})
|
|
}
|
|
}
|