Watch
1
0
Fork
You've already forked souveraine
0
souveraine/src/ui/chat.rs
Fimeg 9a9a31916c feat: subconscious agent identity architecture - first-class separation
- AgentType enum (Primary, Subconscious, Subagent) with AgentIdentity
- Directory routing: agents/{id}/, subconscious-agents/{id}-sub/, subagents/{parent}/{id}/
- Auto-create subconscious agents with own memory, persona, ledger, inbox
- Ledger lives in subconscious agent space (not primary)
- TUI: CockpitPane and BuddyPanel for consciousness visibility
- Aster tool loop: full tool access with 5 rounds, uncapped output
- Config: max_tokens Option for uncapped subconscious output

Builds clean, 0 errors.
2026-05-10 11:20:20 -04:00

876 lines
31 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, oneshot, RwLock};
use crate::backend::{Backend, BackendEvent};
use crate::bridge::bifrost::BifrostClient;
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>,
/// Receiver for `/model` listing results from async Bifrost call.
pub model_rx: Option<oneshot::Receiver<String>>,
/// Consciousness events (surfacing, reflection, archivist) since last drain.
/// Forwarded to the Scene by App after each tick.
pub pending_consciousness: Vec<BackendEvent>,
}
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,
model_rx: None,
pending_consciousness: Vec::new(),
})
}
const HELP_TEXT: &'static str = "Available commands:
/help Show this help
/clear Clear chat history
/model List available models
/model <name> Set the active model
!<command> Run a shell command (Linux/macOS)
Use Tab to toggle the cockpit pane.";
/// Submit the current input. Returns `true` if the input was handled
/// (slash command, bang command, or sent to backend).
pub fn submit(&mut self) -> bool {
if self.busy || self.input.trim().is_empty() {
return false;
}
let trimmed = self.input.trim().to_string();
self.input.clear();
// Slash commands
if trimmed.starts_with('/') {
return self.handle_slash_command(&trimmed);
}
// Bang commands: !<cmd>
if trimmed.starts_with('!') {
let cmd = trimmed[1..].trim();
if !cmd.is_empty() {
self.handle_bang_command(cmd);
}
return true;
}
// Normal chat message
let text = trimmed;
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;
});
true
}
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.starts_with("/model") {
return self.handle_model_command(trimmed);
}
// Unknown command
let cmd = trimmed.split_whitespace().next().unwrap_or(trimmed);
self.system_message(format!(
"Unknown command: {}\nType /help for available commands.",
cmd
));
true
}
fn handle_model_command(&mut self, input: &str) -> bool {
let rest = input.strip_prefix("/model").unwrap_or("").trim();
// /model <name> — set model (synchronous, fast)
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;
}
// /model — list models (async, uses oneshot to get result back)
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,
);
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));
}
}
}
/// Push a system message for display (from slash commands, etc.)
pub fn system_message(&mut self, text: String) {
self.messages.push(ChatMessage::System {
text,
ts: Instant::now(),
});
}
/// Push an error message for display
pub fn error_message(&mut self, text: String) {
self.messages.push(ChatMessage::System {
text: format!("✕ {}", text),
ts: Instant::now(),
});
}
/// Drain pending events from the active turn channel (non-blocking).
/// Call once per UI tick.
pub fn drain_events(&mut self) {
// Check for /model listing result
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;
}
}
// 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: source.clone(),
content: content.clone(),
priority: priority.clone(),
ts: Instant::now(),
});
// Buffer for scene dispatch
self.pending_consciousness.push(BackendEvent::Surfacing { source, content, priority });
}
BackendEvent::Reflection(content) => {
self.cockpit_log.push(format!("reflection — {}", content));
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(format!("archivist · {:.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::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 · !cmd bash · {} ", 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() }
}