//! Wired chat screen — bubbles, streaming, surfacing. //! //! The state owns: //! - A `Box` constructed at App startup (typically `LocalBackend`). //! - A turn-events channel (`mpsc::Receiver`) 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, pub mode: String, pub agent_name: String, pub agent_id: String, pub conversation_id: String, pub messages: Vec, pub input: String, pub scroll: u16, pub turn_rx: Option>, 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, /// Recent subconscious surfacings + reflections for the cockpit pane. pub cockpit_log: Vec, /// Monotonic tick counter for animation timings. pub tick: u64, /// When the current turn started (for spinner animation). pub turn_started: Option, /// Receiver for `/model` listing results from async Bifrost call. pub model_rx: Option>, /// Consciousness events (surfacing, reflection, archivist) since last drain. /// Forwarded to the Scene by App after each tick. pub pending_consciousness: Vec, } impl ChatState { pub async fn connect( config: Arc>, agent_name_pref: &str, ) -> Result { // 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, &'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 Set the active model ! 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: ! 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::(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 — 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 = 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> = 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> { 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>` (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> { 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> = 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 { 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::>(); 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::>(); 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() } }