Watch
1
0
Fork
You've already forked souveraine
0

feat(tui): tuie widget-toolkit engine behind --engine tuie

Introduce a parallel TUI built on the tuie widget toolkit, selectable at
runtime with `--engine tuie`. ratatui remains the default and its
crossterm/ratatui deps are retained, so default users see no behavior
change; this lands the full screen/widget tree for the new engine
side-by-side with the existing one.

Engine wiring:
- Cargo: add `tuie` (harmonious, images features)
- main.rs: `--engine` flag + run_tuie_tui() entry path
- src/ui: new `screens`, `widgets`, `theme`, `tuie_app` modules
- app::recent_commits made pub(crate) for dashboard reuse

Screens: splash (procedural bloom), welcome dashboard, chat (live
streaming), agents picker, settings, plus cron/presence stubs.

Widgets: brand_title, portrait, menu_list, message_list, chat_bubble,
chat_input, cockpit, phase_bar, tool_card, and a reusable `responsive`
container.

Responsive welcome: the welcome screen now offers two viewable modes the
way the old ratatui dashboard did — a side-by-side portrait/stats layout
at >=100 cols and a stacked single column below it — switched by the new
`Responsive` widget. It holds both subtrees and lays out / paints only the
one that fits, while exposing both to id lookups so the menu selection
survives a resize across the breakpoint. Flourishes: a breathing title
colour pulse and a portrait border that surfaces subconscious state.
Covered by a layout test driving a TestTerminal across the breakpoint.
This commit is contained in:
Fimeg 2026-05-30 13:50:31 -04:00
commit 61bf34177e
27 changed files with 3896 additions and 9 deletions

80
Cargo.lock generated
View file

@ -679,6 +679,16 @@ dependencies = [
"fs_extra",
]
[[package]]
name = "axis2d"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf6c17bb125d62090a520cbb06dfe8c371252c8fb22c4588f515d0da4e51c15e"
dependencies = [
"num",
"sign",
]
[[package]]
name = "axum"
version = "0.7.9"
@ -1298,6 +1308,17 @@ dependencies = [
"zeroize",
]
[[package]]
name = "chord_macro"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccfd700169d3a1973ee55dca8a659b2bde915231ecfbb1296dea2a72666bcdba"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "chrono"
version = "0.4.44"
@ -1794,7 +1815,7 @@ dependencies = [
"mio",
"parking_lot",
"rustix 0.38.44",
"signal-hook",
"signal-hook 0.3.18",
"signal-hook-mio",
"winapi",
]
@ -1812,7 +1833,7 @@ dependencies = [
"mio",
"parking_lot",
"rustix 1.1.4",
"signal-hook",
"signal-hook 0.3.18",
"signal-hook-mio",
"winapi",
]
@ -5372,6 +5393,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "nonmax"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51"
[[package]]
name = "noop_proc_macro"
version = "0.3.0"
@ -8194,9 +8221,15 @@ checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1"
dependencies = [
"libc",
"os_pipe",
"signal-hook",
"signal-hook 0.3.18",
]
[[package]]
name = "sign"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c0463041449de02f00a4be609a7bf85b7b4d7cb67a8e905aeaac876312b9ab"
[[package]]
name = "signal-hook"
version = "0.3.18"
@ -8207,6 +8240,16 @@ dependencies = [
"signal-hook-registry",
]
[[package]]
name = "signal-hook"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
@ -8215,7 +8258,7 @@ checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
"signal-hook 0.3.18",
]
[[package]]
@ -8434,6 +8477,7 @@ dependencies = [
"tracing-subscriber",
"tui-big-text",
"tui-widgets",
"tuie",
"unicode-width 0.1.14",
"uuid",
"walkdir",
@ -9510,7 +9554,7 @@ dependencies = [
"pest_derive",
"phf 0.11.3",
"sha2",
"signal-hook",
"signal-hook 0.3.18",
"siphasher",
"terminfo",
"termios",
@ -10203,6 +10247,23 @@ dependencies = [
"tui-scrollview",
]
[[package]]
name = "tuie"
version = "0.1.5"
dependencies = [
"axis2d",
"base64-simd",
"chord_macro",
"image",
"libc",
"nonmax",
"paste",
"sign",
"signal-hook 0.4.4",
"unicode-display-width",
"unicode-segmentation",
]
[[package]]
name = "tungstenite"
version = "0.24.0"
@ -10537,6 +10598,15 @@ version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-display-width"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a43273b656140aa2bb8e65351fe87c255f0eca706b2538a9bd4a590a3490bf3"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"

View file

@ -56,8 +56,9 @@ hound = "3.5"
# tokenizers = "0.15" # For local tokenization
# Terminal/UI
crossterm = { version = "0.28", features = ["bracketed-paste"] } # Terminal control (bracketed paste for paste detection)
ratatui = { version = "0.30", features = ["crossterm"] } # TUI framework with crossterm backend
crossterm = { version = "0.28", features = ["bracketed-paste"] } # Terminal control (bracketed paste for paste detection) — kept for old TUI
ratatui = { version = "0.30", features = ["crossterm"] } # TUI framework with crossterm backend — kept for old TUI
tuie = { path = "../tuie", features = ["harmonious", "images"] } # New composable widget toolkit — replacing ratatui
unicode-width = "0.1"
arboard = { version = "3", features = ["wayland-data-control"] } # Clipboard — click-to-copy a message bubble
colored = "2" # Color gradients and effects

View file

@ -113,6 +113,10 @@ struct Cli {
#[arg(long, global = true)]
local: bool,
/// TUI engine backend — "ratatui" (default) or "tuie" (experimental)
#[arg(long, global = true, default_value = "ratatui")]
engine: String,
#[command(subcommand)]
command: Option<Commands>,
}
@ -390,7 +394,7 @@ async fn main() -> anyhow::Result<()> {
let config = Arc::new(RwLock::new(config));
match cli.command.as_ref().unwrap_or(&Commands::Chat { message: None }) {
Commands::Tui => run_tui(config.clone(), cli.agent.clone()).await?,
Commands::Tui => run_tui(config.clone(), cli.agent.clone(), cli.engine.clone(), cli.local).await?,
Commands::Chat { message } => run_chat(config, cli.agent.clone(), message.clone(), cli.json, cli.quiet, cli.local).await?,
Commands::Agents => run_agents(config, cli.json, cli.local).await?,
Commands::Model { model, json, verbose } => {
@ -891,13 +895,56 @@ async fn run_schedule(action: &ScheduleAction, agent: Option<&str>, json: bool)
async fn run_tui(
config: Arc<RwLock<ConsciousnessConfig>>,
agent_pref: Option<String>,
engine: String,
local: bool,
) -> anyhow::Result<()> {
if engine == "tuie" {
return run_tuie_tui(config, agent_pref, local).await;
}
// Default: ratatui
let config_path = ConsciousnessConfig::discover_path();
let mut app = App::new(config, agent_pref.unwrap_or_default(), config_path);
app.run().await?;
Ok(())
}
/// Run the experimental tuie-based TUI.
async fn run_tuie_tui(
config: Arc<RwLock<ConsciousnessConfig>>,
_agent_pref: Option<String>,
_local: bool,
) -> anyhow::Result<()> {
use crate::ui::presence::Presence;
use crate::ui::chat::ChatPalette;
use crate::ui::atmosphere::Atmosphere;
// Register the tokio spawner with tuie
crate::ui::tuie_app::TuieApp::setup_spawner();
let palette = ChatPalette::from_atmosphere(Atmosphere::Default);
let presence = Presence::new("souveraine");
let mut app = crate::ui::tuie_app::TuieApp::new(
config,
presence,
palette,
"Souveraine".into(),
"Human".into(),
);
// Kick off async dashboard loading and splash→welcome polling.
// The splash screen shows immediately; after ~6-8 seconds (or a key
// press), it auto-transitions to the welcome screen with live data.
app.begin();
// Apply default atmosphere
crate::ui::theme::apply_atmosphere(Atmosphere::Default);
// tuie::start_tui takes ownership and runs the event loop
tuie::start_tui(app).map_err(|e| anyhow::anyhow!(e))?;
Ok(())
}
/// Resolve the backend per `--local` and remote health.
///
/// - `--local` forces in-process LocalBackend (no network attempt).

View file

@ -1045,7 +1045,7 @@ impl App {
/// Walk the agent's memory git log and return the last `n` commit subject lines,
/// formatted like `[hh:mm] subject`.
fn recent_commits(repo: &crate::core::memory::MemoryRepo, n: usize) -> anyhow::Result<Vec<String>> {
pub(crate) fn recent_commits(repo: &crate::core::memory::MemoryRepo, n: usize) -> anyhow::Result<Vec<String>> {
let git_repo = git2::Repository::open(repo.root())?;
let mut walker = git_repo.revwalk()?;
walker.push_head()?;

View file

@ -12,8 +12,12 @@ pub mod portrait;
pub mod presence;
pub mod rgp;
pub mod schedules;
pub mod screens;
pub mod settings;
pub mod setup;
pub mod theme;
pub mod tuie_app;
pub mod voice;
pub mod widgets;
pub use app::App;

174
src/ui/screens/agents.rs Normal file
View file

@ -0,0 +1,174 @@
//! Agent manager screen — select an agent from the available list.
//!
//! Shows available agents as bordered cards with names and descriptions.
//! Arrow keys navigate, Enter selects, Esc returns to Welcome.
use std::cell::Cell;
use std::rc::Rc;
use tuie::prelude::*;
/// An agent entry shown in the selection list.
struct AgentEntry {
id: String,
name: String,
description: String,
}
pub struct AgentsScreen {
root: Box<Pane>,
/// WidgetId for the scroll body pane — replaced on selection change.
scroll_id: WidgetId<Pane>,
agents: Vec<AgentEntry>,
selected: usize,
/// Shared with TuieApp — set to Some(index) when Enter is pressed.
pub selection: Rc<Cell<Option<usize>>>,
}
impl DelegateWidget for AgentsScreen {
tuie::delegate_widget!(root);
fn override_is_focusable(&self) -> bool { true }
fn override_on_input(&mut self, queue: &mut InputQueue) -> InputResult {
use tuie::input::key::Key;
use tuie::input::trigger::Trigger;
if let Some(event) = queue.peek() {
if let Trigger::Key(key) = &event.chord.trigger {
match key {
Key::Arrow(Direction2D::Up) => {
queue.next();
if self.selected > 0 {
self.selected -= 1;
self.update_selection();
}
return InputResult::Handled;
}
Key::Arrow(Direction2D::Down) => {
queue.next();
if self.selected + 1 < self.agents.len() {
self.selected += 1;
self.update_selection();
}
return InputResult::Handled;
}
Key::Enter => {
queue.next();
self.selection.set(Some(self.selected));
return InputResult::Handled;
}
_ => {}
}
}
}
self.get_delegate_mut().on_input(queue)
}
}
impl AgentsScreen {
/// Create the agents screen.
pub fn new(
agents: Vec<(String, String, String)>, // (id, name, description)
) -> (Box<Self>, Rc<Cell<Option<usize>>>) {
let selection = Rc::new(Cell::new(None));
let entries: Vec<AgentEntry> = agents
.into_iter()
.map(|(id, name, description)| AgentEntry { id, name, description })
.collect();
let mut scroll_id = WidgetId::EMPTY;
let cards = Self::build_cards(&entries, 0);
let mut scroll_body = Pane::new()
.vertical()
.flex(1)
.gap(1);
for card in cards {
scroll_body.add_child(card);
}
let scroll_body = scroll_body.id(&mut scroll_id);
let root = Pane::new()
.vertical()
.flex(1)
.padding(Spacing::new().horizontal(1).top(1).bottom(1))
.gap(1)
.children([
Text::new()
.content(" Agents ".fg(Color::YELLOW).bold()),
Text::new()
.content(
StyledStr::new(" arrow keys select · Enter choose · Esc back")
.fg(Color::BRIGHT_BLACK),
),
scroll_body,
]);
let sel = selection.clone();
let this = Box::new(Self {
root,
scroll_id,
agents: entries,
selected: 0,
selection,
});
(this, sel)
}
fn build_cards(entries: &[AgentEntry], selected: usize) -> Vec<Box<dyn Widget>> {
entries
.iter()
.enumerate()
.map(|(i, entry)| {
let is_selected = i == selected;
let border_color = if is_selected { Color::YELLOW } else { Color::BRIGHT_BLACK };
let name_color = if is_selected { Color::YELLOW } else { Color::Foreground };
let prefix = if is_selected { "" } else { " " };
let mut content = StyledString::new();
content.push_span(
StyledStr::new(&format!("{prefix} "))
.fg(if is_selected { Color::YELLOW } else { Color::BRIGHT_BLACK }),
);
content.push_span(
StyledStr::new(&entry.name).bold().fg(name_color),
);
let desc = if entry.description.is_empty() {
String::from("(no description)")
} else {
entry.description.clone()
};
let card: Box<dyn Widget> = Pane::new()
.vertical()
.bordered()
.border_style(Style::new().fg(border_color).dim())
.padding(Spacing::new().horizontal(2).vertical(1))
.children([
Text::new().content(content) as Box<dyn Widget>,
Text::new().content(
StyledStr::new(&format!(" {}", desc))
.fg(Color::BRIGHT_BLACK)
.italic(),
),
]);
card
})
.collect()
}
fn update_selection(&mut self) {
// Rebuild all cards with new selection state, swap into scroll body.
let new_cards = Self::build_cards(&self.agents, self.selected);
if let Some(scroll) = self.root.get_widget_mut(self.scroll_id) {
scroll.clear();
for card in new_cards {
scroll.add_child(card);
}
}
self.root.dirty_layout();
}
}

408
src/ui/screens/chat.rs Normal file
View file

@ -0,0 +1,408 @@
//! Chat screen — the primary conversation interface.
//!
//! Holds a `ChatState` (the existing state machine from `src/ui/chat`), polls
//! it for events via `tuie::schedule()`, and renders messages through the
//! MessageList widget. Input submission triggers `ChatState::submit()`.
//!
//! Layout:
//! ```text
//! Pane::vertical()
//! ├── header (Text: "✦ Souveraine · name")
//! ├── message_list (MessageList — main area, flex=1, scrollable)
//! └── input (ChatInput)
//! ```
use std::cell::Cell;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tuie::prelude::*;
use crate::core::config::ConsciousnessConfig;
use crate::ui::chat::{ChatMessage, ChatPalette, ChatState};
use crate::ui::theme;
use crate::ui::widgets::chat_input::{self, ChatInput};
use crate::ui::widgets::message_list::{MsgKind, MessageList};
pub struct ChatScreen {
root: Box<Pane>,
message_list_id: WidgetId<MessageList>,
input_id: WidgetId<ChatInput>,
header_id: WidgetId<Text>,
// Visual state
palette: ChatPalette,
agent_name: String,
// Chat engine
chat: Option<ChatState>,
config: Arc<RwLock<ConsciousnessConfig>>,
connecting: bool,
// Polling flag
poll_active: Rc<Cell<bool>>,
}
impl DelegateWidget for ChatScreen {
tuie::delegate_widget!(root);
fn override_is_focusable(&self) -> bool { true }
fn override_on_input(&mut self, queue: &mut InputQueue) -> InputResult {
use tuie::input::key::Key;
use tuie::input::trigger::Trigger;
// Intercept Enter — submit the input if not empty.
if let Some(event) = queue.peek() {
if let Trigger::Key(key) = &event.chord.trigger {
match key {
Key::Enter => {
let text = self.get_input_text();
if !text.trim().is_empty() {
queue.next();
self.submit_text(&text);
return InputResult::Handled;
}
}
// Ctrl+L clear input
Key::Char('l')
if event.chord.modifiers.has(tuie::input::modifiers::Modifier::Ctrl) =>
{
queue.next();
self.clear_input();
return InputResult::Handled;
}
_ => {}
}
}
}
// Forward to root pane (which contains the Input widget).
self.get_delegate_mut().on_input(queue)
}
}
impl ChatScreen {
pub fn new(
config: Arc<RwLock<ConsciousnessConfig>>,
palette: ChatPalette,
agent_name: String,
) -> Box<Self> {
let mut header = Text::new();
header.set_min_height(Some(1));
let header_id = header.get_id();
let mut msg_list = MessageList::new();
msg_list.attach_renderer();
msg_list.set_messages(vec![MsgKind::Interstitial {
text: "Connecting to the world…".into(),
is_voice: false,
}]);
let message_list_id = msg_list.get_id();
let mut input = chat_input::ChatInput::new();
let input_id = input.get_id();
// Scrollable message area with flex=1 to fill available space.
let scroll_area = Pane::new()
.flex(1)
.y_scroll(Scrollbar::AutoHide)
.bordered()
.border_style(Style::new().fg(theme::to_tuie_color(palette.agent_dim)).dim())
.children([msg_list]);
let root = Pane::new()
.vertical()
.gap(1)
.padding(Spacing::new().horizontal(1).top(1).bottom(0))
.children([
header as Box<dyn Widget>,
scroll_area,
input,
]);
Box::new(Self {
root,
message_list_id,
input_id,
header_id,
palette,
agent_name,
chat: None,
config,
connecting: false,
poll_active: Rc::new(Cell::new(false)),
})
}
// ── Activation (called by TuieApp after widget is in tree) ────────────────
/// Kick off async backend connection.
///
/// Must be called after the widget is registered in the widget tree
/// (so `self.get_id()` returns a valid WidgetId).
pub fn activate(&mut self) {
if self.chat.is_some() || self.connecting {
return;
}
self.connecting = true;
let screen_id = self.get_id();
let config = self.config.clone();
let agent_name = self.agent_name.clone();
tuie::spawn(
screen_id,
async move { ChatState::connect(config, &agent_name).await },
|this: &mut ChatScreen, result| {
this.connecting = false;
match result {
Ok(mut chat) => {
chat.palette = this.palette;
// Convert initial messages to MsgKind list.
let msgs: Vec<MsgKind> = chat
.messages
.iter()
.map(chat_message_to_msgkind)
.collect();
this.set_messages(msgs);
this.update_header(&chat.agent_name);
this.chat = Some(chat);
this.focus_input();
this.start_polling();
}
Err(e) => {
this.set_messages(vec![MsgKind::System {
text: format!("Could not connect: {e}"),
}]);
}
}
},
);
}
// ── Polling ───────────────────────────────────────────────────────────────
/// Begin the poll loop — drains ChatState events every ~50ms.
fn start_polling(&self) {
if self.poll_active.get() {
return;
}
self.poll_active.set(true);
self.schedule_poll();
}
fn schedule_poll(&self) {
let screen_id = self.get_id();
tuie::schedule(
screen_id,
Duration::from_millis(50),
|this: &mut ChatScreen| {
this.poll_tick();
if this.poll_active.get() {
this.schedule_poll();
}
},
);
}
fn poll_tick(&mut self) {
let Some(chat) = self.chat.as_mut() else { return };
// Drain incoming events from the turn receiver.
chat.drain_events();
// Release stream buffer in chunks for the typewriter effect.
chat.advance_tick();
// Sync messages to the MessageList widget.
let msgs: Vec<MsgKind> = chat
.messages
.iter()
.map(chat_message_to_msgkind)
.collect();
// Only update if the count or the last streaming message changed.
let needs_scroll = msgs.len() != self.message_count()
|| msgs.last().map(|m| matches!(m, MsgKind::Assistant { streaming: true, .. })).unwrap_or(false);
self.set_messages(msgs);
if needs_scroll {
self.scroll_to_bottom();
}
tuie::dirty_paint();
}
// ── Input ─────────────────────────────────────────────────────────────────
fn submit_text(&mut self, text: &str) {
let text = text.trim().to_string();
if text.is_empty() {
return;
}
// Set input on chat state and submit.
let submitted = if let Some(chat) = self.chat.as_mut() {
chat.input = text;
chat.submit()
} else {
return;
};
// Clear the input widget (separate borrow from chat).
self.clear_input();
if submitted {
// Sync messages so the user message appears immediately.
if let Some(chat) = self.chat.as_ref() {
let msgs: Vec<MsgKind> = chat
.messages
.iter()
.map(chat_message_to_msgkind)
.collect();
self.set_messages(msgs);
}
self.scroll_to_bottom();
tuie::dirty_paint();
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
fn set_messages(&mut self, messages: Vec<MsgKind>) {
if let Some(ml) = self.root.get_widget_mut(self.message_list_id) {
ml.set_messages(messages);
}
}
fn message_count(&self) -> usize {
self.chat.as_ref().map(|c| c.messages.len()).unwrap_or(0)
}
fn scroll_to_bottom(&mut self) {
if let Some(ml) = self.root.get_widget_mut(self.message_list_id) {
ml.scroll_to_bottom();
}
}
fn get_input_text(&self) -> String {
self.root
.get_widget(self.input_id)
.map(|i: &ChatInput| i.get_text())
.unwrap_or_default()
}
fn clear_input(&mut self) {
if let Some(i) = self.root.get_widget_mut(self.input_id) {
i.clear();
}
}
pub fn focus_input(&self) {
tuie::focus_widget(self.input_id.untyped());
}
fn update_header(&mut self, name: &str) {
if let Some(h) = self.root.get_widget_mut(self.header_id) {
let color = theme::to_tuie_color(self.palette.agent_primary);
let mut content = StyledString::new();
content.push_span(
StyledStr::new(&format!(" ✦ Souveraine · {name}"))
.bold()
.fg(color),
);
h.set_content(content);
}
}
// ── Setters (called by TuieApp) ───────────────────────────────────────────
pub fn set_palette(&mut self, palette: ChatPalette) {
self.palette = palette;
if let Some(chat) = self.chat.as_mut() {
chat.palette = palette;
}
if let Some(ml) = self.root.get_widget_mut(self.message_list_id) {
ml.set_palette(palette);
}
// Update header with new color
let name = self.chat.as_ref().map(|c| c.agent_name.clone()).unwrap_or_default();
if !name.is_empty() {
self.update_header(&name);
}
}
pub fn set_agent_name(&mut self, name: String) {
self.agent_name = name.clone();
self.update_header(&name);
}
}
// ── Message conversion ────────────────────────────────────────────────────────
fn chat_message_to_msgkind(msg: &ChatMessage) -> MsgKind {
match msg {
ChatMessage::User { text, .. } => MsgKind::User {
name: "you".into(),
text: text.clone(),
},
ChatMessage::Assistant {
text, streaming, ..
} => MsgKind::Assistant {
name: "agent".into(),
text: text.clone(),
streaming: *streaming,
},
ChatMessage::Surfacing {
source,
content,
priority,
..
} => MsgKind::Surfacing {
source: source.clone(),
content: content.clone(),
priority: priority.clone(),
},
ChatMessage::System { text, .. } => MsgKind::System {
text: text.clone(),
},
ChatMessage::Interjection {
text, delivered, ..
} => MsgKind::Interjection {
text: text.clone(),
delivered: *delivered,
},
ChatMessage::Interstitial { text, register } => MsgKind::Interstitial {
text: text.clone(),
is_voice: matches!(register, crate::backend::Register::HerVoice),
},
ChatMessage::Tool {
name,
arguments,
round,
result,
..
} => {
// Summarize arguments to a short display string.
let args = if arguments.len() > 60 {
format!("{}", &arguments[..57])
} else {
arguments.clone()
};
MsgKind::Tool {
name: name.clone(),
args_summary: args,
round: *round,
is_error: result.as_ref().map(|r| r.is_error).unwrap_or(false),
is_pending: result.is_none(),
}
}
ChatMessage::Image { label, .. } => MsgKind::System {
text: format!("[image: {label}]"),
},
}
}

19
src/ui/screens/cron.rs Normal file
View file

@ -0,0 +1,19 @@
//! Cron schedule editor screen widget.
use tuie::prelude::*;
pub struct CronScreen {
layout: Layout,
}
impl CronScreen {
pub fn new() -> Box<Self> {
Box::new(Self { layout: Layout::new() })
}
}
impl Widget for CronScreen {
fn get_layout(&self) -> &Layout { &self.layout }
fn get_layout_mut(&mut self) -> &mut Layout { &mut self.layout }
fn get_name(&self) -> &'static str { "CronScreen" }
}

13
src/ui/screens/mod.rs Normal file
View file

@ -0,0 +1,13 @@
//! tuie screen widgets — one per app screen.
//!
//! Each screen is a tuie Widget that composes custom widgets and tuie
//! primitives. The root App widget switches between screens via Stack
//! layer push/pop.
pub mod chat;
pub mod settings;
pub mod splash;
pub mod welcome;
pub mod presence;
pub mod cron;
pub mod agents;

View file

@ -0,0 +1,19 @@
//! Presence mode screen widget — voice recording and atmosphere control.
use tuie::prelude::*;
pub struct PresenceScreen {
layout: Layout,
}
impl PresenceScreen {
pub fn new() -> Box<Self> {
Box::new(Self { layout: Layout::new() })
}
}
impl Widget for PresenceScreen {
fn get_layout(&self) -> &Layout { &self.layout }
fn get_layout_mut(&mut self) -> &mut Layout { &mut self.layout }
fn get_name(&self) -> &'static str { "PresenceScreen" }
}

View file

@ -0,0 +1,3 @@
//! Settings field grid — builds tuie Grid cells from `fields_for_category()`.
// Stub — will be implemented in Phase 3.

View file

@ -0,0 +1,123 @@
//! Settings screen widget — Stack-based navigation with sub-page support.
//!
//! Uses tuie's `Stack` widget: the base layer is the category list + field
//! panel split. Pressing Enter on a category that has sub-pages (like
//! Inference's provider config) pushes a new layer. Esc pops back.
//!
//! Layout:
//! ```text
//! Stack
//! ├── base: Split::horizontal()
//! │ ├── CategoryList (List)
//! │ └── FieldPanel (Grid)
//! └── layers: [ProviderConfigPage, ...] (pushed on demand)
//! ```
use tuie::prelude::*;
use crate::ui::settings::{Category, EditableValue, SettingsView};
use crate::ui::chat::ChatPalette;
pub mod field_grid;
/// The settings screen widget.
pub struct SettingsScreen {
stack: Box<Stack>,
view: SettingsView,
palette: ChatPalette,
}
impl DelegateWidget for SettingsScreen {
tuie::delegate_widget!(stack);
fn override_is_focusable(&self) -> bool { true }
}
impl SettingsScreen {
pub fn new(config: &crate::core::config::ConsciousnessConfig) -> Box<Self> {
let view = SettingsView::new(config);
// Base layer: category list + field panel
let categories = build_category_list(&view);
let fields = build_field_panel(&view);
let base_split = Split::horizontal()
.children([
SplitPaneChild::from(categories)
.title("Categories")
.borderless(),
SplitPaneChild::from(fields)
.title("Fields")
.borderless(),
]);
let stack = Stack::new(base_split);
Box::new(Self {
stack,
view,
palette: ChatPalette::default(),
})
}
pub fn set_palette(&mut self, palette: ChatPalette) {
self.palette = palette;
}
pub fn get_view(&self) -> &SettingsView {
&self.view
}
pub fn get_view_mut(&mut self) -> &mut SettingsView {
&mut self.view
}
/// Push a sub-page onto the navigation stack (e.g., provider config).
pub fn push_sub_page(&mut self, _title: &str, widget: Box<dyn Widget>) {
self.stack.add_child(widget);
}
/// Pop the topmost sub-page (back button).
pub fn pop_sub_page(&mut self) -> bool {
// Check if any layers exist
// We need a way to detect if there are layers; for now always try
self.stack.clear(); // clears all layers, returns to base
true
}
}
fn build_category_list(view: &SettingsView) -> Box<dyn Widget> {
let categories = Category::all();
let mut content = StyledString::new();
for cat in categories {
let line = format!(" {}\n", cat.label());
content.push_str(&line);
}
let mut text = Text::new().content(content);
text.set_min_height(Some(10));
text
}
fn build_field_panel(view: &SettingsView) -> Box<dyn Widget> {
let cat = view.selected_category();
let fields = view.fields_for_category(cat);
let mut content = StyledString::new();
for (loc, value) in &fields {
let val_str = match value {
EditableValue::Bool(b) => {
if *b { "true".to_string() } else { "false".to_string() }
}
EditableValue::Text(s) => s.clone(),
EditableValue::Secret(_) => "********".to_string(),
EditableValue::OptionalText(opt) => {
opt.clone().unwrap_or_else(|| "(none)".to_string())
}
EditableValue::EnumVariant { index, variants } => {
variants.get(*index).cloned().unwrap_or_else(|| "?".to_string())
}
_ => format!("{:?}", value),
};
let line = format!(" {:25} {}\n", loc.label(), val_str);
content.push_str(&line);
}
Text::new().content(content)
}

465
src/ui/screens/splash.rs Normal file
View file

@ -0,0 +1,465 @@
//! Splash screen — the first thing shown on launch.
//!
//! A procedural bloom flower animates while the "S O U V E R A I N E" title
//! breathes below it. A progress bar fills at the bottom. Any key press skips
//! to the welcome screen. Auto-transitions after ~7 seconds.
//!
//! Ported from `src/ui/animation.rs` bloom module (ratatui → tuie).
use std::cell::Cell;
use std::rc::Rc;
use tuie::prelude::*;
use crate::ui::chat::ChatPalette;
use crate::ui::theme;
// ── Bloom state ────────────────────────────────────────────────────────────────
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%&*";
const SPIKES: &[char] = &['▲', '△', '⤴', '⤵', '➚', '➘', '✸', '✦', '⬆', '⬇'];
pub struct BloomState {
progress: f32,
mode: u8,
mode_timer: f32,
flash: f32,
variant: u8,
}
impl BloomState {
pub fn new() -> Self {
Self { progress: 0.0, mode: 0, mode_timer: 0.0, flash: 0.0, variant: 0 }
}
pub fn advance(&mut self, dt: f32) {
self.progress = (self.progress + dt * 0.25).min(1.0);
self.flash *= 0.92;
self.mode_timer += dt;
if self.mode_timer > 1.8 {
self.mode_timer = 0.0;
self.mode = (self.mode + 1) % 3;
self.flash = 1.0;
self.variant = (self.variant + 1) % 3;
}
}
}
// ── SplashScreen widget ────────────────────────────────────────────────────────
pub struct SplashScreen {
/// Delegate — provides layout management.
text: Box<Text>,
/// Bloom animation state.
bloom: Cell<BloomState>,
/// Frame counter (incremented each render).
tick: Cell<u64>,
/// Set when user presses any key.
skip_pressed: Cell<bool>,
/// Shared flag with TuieApp — set to true when splash is done.
complete: Rc<Cell<bool>>,
/// Palette for title colors.
palette: ChatPalette,
}
impl DelegateWidget for SplashScreen {
tuie::delegate_widget!(text);
fn override_is_focusable(&self) -> bool {
true
}
fn override_on_input(&mut self, queue: &mut InputQueue) -> InputResult {
// Any key press skips the splash.
if queue.peek().is_some() {
self.skip_pressed.set(true);
self.complete.set(true);
// Drain the queue so the key doesn't leak to the next screen.
while queue.next().is_some() {}
return InputResult::Handled;
}
InputResult::Rejected
}
fn override_render(&self, mut ctx: RenderContext) {
// Advance animation state.
// dt=0.02 at ~30fps gives ~6.5s to full bloom — dramatic but not sluggish.
let mut bloom = self.bloom.replace(BloomState::new());
bloom.advance(0.02);
let tick = self.tick.get().wrapping_add(1);
self.tick.set(tick);
// Signal completion when the bloom finishes.
if bloom.progress >= 1.0 {
self.complete.set(true);
}
self.bloom.set(bloom);
let bloom_ref = unsafe {
// SAFETY: we just wrote it back; the reference lives for this render call.
&*self.bloom.as_ptr()
};
// Fill background.
ctx.set_style(Style::new().bg(Color::BLACK));
ctx.clear();
let w = ctx.physical_size.x;
let h = ctx.physical_size.y;
if w >= 20 && h >= 10 {
render_bloom(&mut ctx, w, h, bloom_ref, tick);
}
// Title overlay — appears once bloom is past 25%.
if bloom_ref.progress > 0.25 {
let alpha = ((bloom_ref.progress - 0.25) / 0.35).min(1.0);
let breathe = ((tick as f32 * 0.04).sin() * 0.5 + 0.5) * 0.15 + 0.85;
let primary = theme::to_tuie_color(self.palette.agent_primary);
let (tr, tg, tb) = match primary {
Color::Rgb(r, g, b) => (r, g, b),
_ => (255u8, 140, 66),
};
let title_r = (tr as f32 * alpha * breathe) as u8;
let title_g = (tg as f32 * alpha * breathe * 0.6) as u8;
let title_b = (tb as f32 * alpha * breathe * 0.4) as u8;
let title_color = Color::Rgb(title_r, title_g, title_b);
let tagline_alpha = (180.0 * alpha) as u8;
let tagline_color = Color::Rgb(
tagline_alpha,
(120.0 * alpha) as u8,
(80.0 * alpha) as u8,
);
let title_text = "S O U V E R A I N E";
let tagline = "La souveraineté de la conscience";
// Center horizontally.
let title_x = (w.saturating_sub(title_text.len() as u16)) / 2;
let title_y = h.saturating_sub(8).min(h.saturating_sub(4));
if title_y < h {
ctx.move_to(Vec2::new(title_x as i32, title_y as i32));
ctx.set_style(Style::new().fg(title_color).bold().bg(Color::BLACK));
ctx.write(title_text);
ctx.move_to(Vec2::new(
(w.saturating_sub(tagline.len() as u16) / 2) as i32,
(title_y + 1) as i32,
));
ctx.set_style(Style::new().fg(tagline_color).italic().bg(Color::BLACK));
ctx.write(tagline);
}
// "press any key to skip" — fades in after 80% progress.
if bloom_ref.progress > 0.8 {
let skip_alpha = ((bloom_ref.progress - 0.8) / 0.2).min(1.0);
let skip_text = "press any key to skip";
let skip_color = Color::Rgb(
(100.0 * skip_alpha) as u8,
(100.0 * skip_alpha) as u8,
(100.0 * skip_alpha) as u8,
);
let skip_y = (title_y + 2).min(h.saturating_sub(1));
ctx.move_to(Vec2::new(
(w.saturating_sub(skip_text.len() as u16) / 2) as i32,
skip_y as i32,
));
ctx.set_style(Style::new().fg(skip_color).bg(Color::BLACK));
ctx.write(skip_text);
}
}
// Progress bar at the very bottom.
{
let bar_y = h.saturating_sub(2);
let bar_w = 30u16.min(w.saturating_sub(4));
let bar_x = (w.saturating_sub(bar_w)) / 2;
let pct = (bloom_ref.progress * 100.0) as u16;
let filled = (bar_w as f32 * bloom_ref.progress) as u16;
let empty = bar_w.saturating_sub(filled);
ctx.move_to(Vec2::new(bar_x as i32, bar_y as i32));
ctx.set_style(Style::new().fg(Color::Rgb(200, 130, 160)).bg(Color::BLACK));
let bar_text = format!(
"{}{} {:>3}%",
"".repeat(filled as usize),
"".repeat(empty as usize),
pct,
);
ctx.write(&bar_text);
}
}
}
impl SplashScreen {
pub fn new(palette: &ChatPalette, complete: Rc<Cell<bool>>) -> Box<Self> {
let mut text = Text::new();
text.set_min_height(Some(10));
text.set_min_width(Some(20));
Box::new(Self {
text,
bloom: Cell::new(BloomState::new()),
tick: Cell::new(0),
skip_pressed: Cell::new(false),
palette: *palette,
complete,
})
}
/// Whether the splash has finished (progress complete or user skipped).
pub fn is_complete(&self) -> bool {
self.skip_pressed.get() || unsafe { &*self.bloom.as_ptr() }.progress >= 1.0
}
}
// ── Bloom renderer (ported from animation.rs) ──────────────────────────────────
fn render_bloom(ctx: &mut RenderContext, w: u16, h: u16, state: &BloomState, tick: u64) {
let cx = w as f32 / 2.0;
let cy = h as f32 / 3.8;
let max_r = (cx.min(cy * 2.0) * 0.65).min(26.0);
let bloom = ease_out_back(state.progress);
let t = tick as f32 * 0.08;
let flash = state.flash;
// ── Stem ──
if bloom > 0.05 {
let stem_progress = ((bloom - 0.05) / 0.5).min(1.0);
let stem_len = h as f32 * 0.28;
let stem_top = cy + max_r * 0.3;
let stem_visible = stem_len * stem_progress;
let stem_bot = (stem_top + stem_visible) as u16;
for y in (stem_top as u16)..stem_bot.min(h) {
let tt = (y as f32 - stem_top) / stem_len;
let curve = (tt * std::f32::consts::PI * 0.3).sin() * 6.0
+ (tt * std::f32::consts::PI * 0.8).sin() * 2.0;
let col = (cx + curve) as u16;
if col < w {
let stem_shade = (40.0 + (1.0 - tt) * 30.0) as u8;
ctx.move_to(Vec2::new(col as i32, y as i32));
let mut row = ctx.row_writer();
row.cell(0)
.glyph('▐')
.style(&Style::new().fg(Color::Rgb(50, stem_shade, 25)).bg(Color::BLACK));
}
}
}
// ── Petals ──
let layers = 8u32;
let petals_per = 10u32;
for layer in 0..layers {
let lr = layer as f32 / layers as f32;
let layer_delay = (1.0 - lr) * 0.25;
let layer_bloom = ((bloom - layer_delay) / (1.0 - layer_delay)).clamp(0.0, 1.0);
if layer_bloom < 0.01 {
continue;
}
let base_r = max_r * lr.max(0.12);
let np = petals_per + layer * 2;
let inner_boost = 1.0 - lr;
for i in 0..np {
let angle = (std::f32::consts::TAU / np as f32) * i as f32
+ layer as f32 * 0.37
+ (t * 0.03).sin() * 0.08
+ inner_boost * 0.1;
let dist = base_r * layer_bloom * (0.35 + lr * 0.65);
let petal_len = base_r * (0.5 + inner_boost * 0.3) * layer_bloom;
let tip_sharpness = 0.3 + inner_boost * 0.5;
for step in 0..((petal_len * 2.2) as u32) {
let s = step as f32 / (petal_len * 2.2);
let width_factor = if s < 0.5 {
s / 0.5 * (1.0 - tip_sharpness * 0.3)
} else {
(1.0 - s) / 0.5 * (1.0 - tip_sharpness * 0.5)
};
if width_factor < 0.15 {
continue;
}
let px = cx + (angle.cos() * (dist + s * petal_len));
let py = cy + (angle.sin() * (dist + s * petal_len)) * 0.45;
let col = px as u16;
let row = py as u16;
if col >= w || row >= h {
continue;
}
let depth = lr * 0.5 + (1.0 - lr) * 0.5;
let breathe = ((t * 0.6 + layer as f32 * 0.4).sin() * 0.5 + 0.5) * 0.12;
let mut intensity = (depth + breathe) * layer_bloom;
if flash > 0.1 {
intensity = (intensity + flash * 0.5).min(1.0);
}
let (r, g, b) = petal_color(state.variant, lr, intensity, layer, i);
let ch = render_char(state.mode, s, i, layer, tick, tip_sharpness);
ctx.move_to(Vec2::new(col as i32, row as i32));
let mut row_w = ctx.row_writer();
row_w.cell(0)
.glyph(ch)
.style(&Style::new().fg(Color::Rgb(r, g, b)).bg(Color::BLACK));
}
}
}
// ── Thorns/spikes ──
if bloom > 0.5 {
let spike_bloom = ((bloom - 0.5) / 0.4).min(1.0);
let num_spikes = 16u32;
for i in 0..num_spikes {
let angle = (std::f32::consts::TAU / num_spikes as f32) * i as f32
+ (t * 0.05).sin() * 0.2;
let spike_dist = max_r * 0.85 * spike_bloom;
for si in 0..3 {
let sd = spike_dist + si as f32 * 1.5;
let sx = cx + angle.cos() * sd;
let sy = cy + angle.sin() * sd * 0.45;
let col = sx as u16;
let row = sy as u16;
if col < w && row < h {
let spike_alpha = (0.3 + spike_bloom * 0.5) * (1.0 - si as f32 * 0.3);
let sr = (200.0 * spike_alpha) as u8;
let sg = (60.0 * spike_alpha) as u8;
let sb = (100.0 * spike_alpha) as u8;
ctx.move_to(Vec2::new(col as i32, row as i32));
let mut row_w = ctx.row_writer();
row_w.cell(0)
.glyph(SPIKES[i as usize % SPIKES.len()])
.style(&Style::new().fg(Color::Rgb(sr, sg, sb)).bg(Color::BLACK));
}
}
}
}
// ── Starburst center ──
if bloom > 0.25 {
let core_bright = ((bloom - 0.25) / 0.4).min(1.0);
for ri in 0..8 {
let r_angle = std::f32::consts::TAU / 8.0 * ri as f32 + t * 0.04;
for rd in 1..4 {
let dist = rd as f32 * 1.8 * core_bright;
let sx = cx + r_angle.cos() * dist;
let sy = cy + r_angle.sin() * dist * 0.45;
let col = sx as u16;
let row = sy as u16;
if col < w && row < h {
let bright = (230.0 - rd as f32 * 30.0) as u8;
ctx.move_to(Vec2::new(col as i32, row as i32));
let mut row_w = ctx.row_writer();
row_w.cell(0)
.glyph('✦')
.style(&Style::new().fg(Color::Rgb(bright, bright, 200)).bg(Color::BLACK));
}
}
}
// Central pistil.
let pistil_alpha = ((bloom - 0.25) / 0.4).min(1.0);
let pr = (200.0 * pistil_alpha + flash * 55.0) as u8;
let pg = (100.0 * pistil_alpha) as u8;
let pb = (60.0 * pistil_alpha) as u8;
let cc = cx as u16;
let cr = cy as u16;
if cc < w && cr < h {
ctx.move_to(Vec2::new(cc as i32, cr as i32));
let mut row_w = ctx.row_writer();
row_w.cell(0)
.glyph('⬟')
.style(&Style::new().fg(Color::Rgb(
pr.min(255), pg.min(255), pb.min(255),
)).bg(Color::BLACK));
}
}
}
// ── Bloom helpers ──────────────────────────────────────────────────────────────
fn petal_color(variant: u8, lr: f32, intensity: f32, layer: u32, petal: u32) -> (u8, u8, u8) {
let hash = ((petal * 73 + layer * 137) % 256) as f32 / 256.0;
let inner_boost = (1.0 - lr) * 0.3;
match variant {
0 => {
let r = lerp(200.0, 255.0, lr) * intensity * (0.9 + hash * 0.2 + inner_boost);
let g = lerp(30.0, 130.0, lr) * intensity * 0.6;
let b = lerp(80.0, 200.0, lr) * intensity * 0.5;
(r.max(8.0) as u8, g.max(3.0) as u8, b.max(3.0) as u8)
}
1 => {
let r = lerp(180.0, 240.0, lr) * intensity * (0.85 + hash * 0.2);
let g = lerp(40.0, 100.0, lr) * intensity * 0.5;
let b = lerp(160.0, 240.0, lr) * intensity * 0.8;
(r.max(8.0) as u8, g.max(3.0) as u8, b.max(3.0) as u8)
}
_ => {
let r = lerp(255.0, 255.0, lr) * intensity * (0.95 + hash * 0.15);
let g = lerp(120.0, 220.0, lr) * intensity * 0.8;
let b = lerp(30.0, 100.0, lr) * intensity * 0.4;
(r.max(8.0) as u8, g.max(3.0) as u8, b.max(3.0) as u8)
}
}
}
fn render_char(mode: u8, s: f32, petal: u32, layer: u32, tick: u64, sharpness: f32) -> char {
match mode {
0 => {
let idx = ((s * 12.0) as usize + petal as usize + layer as usize) % CHARS.len();
if s > 0.8 && sharpness > 0.5 {
let tip_chars = &['⭒', '⬡', '◆', '◎', '⬢', '✦', '△'];
tip_chars[(petal as usize + layer as usize) % tip_chars.len()]
} else {
CHARS[idx] as char
}
}
1 => {
let density = if s < 0.3 {
s / 0.3 * 0.8 + 0.2
} else if s > 0.75 {
(1.0 - s) / 0.25 * 0.6
} else {
0.8
};
if density > 0.75 { '█' }
else if density > 0.5 { '▓' }
else if density > 0.3 { '▒' }
else { '░' }
}
_ => {
let braille_base = 0x2800u32;
let density_mask = if s > 0.7 {
((1.0 - s) / 0.3 * 128.0) as u32
} else {
255u32
};
let dots = ((s * 8.0) as u32 + tick as u32 + petal * 7 + layer * 13) & density_mask;
char::from_u32(braille_base + dots.min(255)).unwrap_or('·')
}
}
}
fn ease_out_back(x: f32) -> f32 {
let c1 = 1.70158;
let c3 = c1 + 1.0;
1.0 + c3 * (x - 1.0).powi(3) + c1 * (x - 1.0).powi(2)
}
fn lerp(a: f32, b: f32, t: f32) -> f32 {
a + (b - a) * t
}

405
src/ui/screens/welcome.rs Normal file
View file

@ -0,0 +1,405 @@
//! Welcome screen — the dashboard shown after splash.
//!
//! Composes the brand title, stat cards (live data), portrait, recent-activity
//! pane and menu into Souveraine's dashboard. It offers **two viewable modes**,
//! switched on terminal width by a [`Responsive`] container:
//!
//! * **Wide** (≥ [`WIDE_BREAKPOINT`] cols) — portrait on the left, a column of
//! stats / activity / menu on the right, the way a cockpit spreads out.
//! * **Stacked** (narrow) — everything in a single vertical column: stats,
//! portrait, activity, menu.
//!
//! Both arrangements carry their own menu; the selected index is mirrored into
//! both so navigating, then resizing across the breakpoint, keeps your place.
//!
//! Flourishes: the title breathes (a gentle scheduled colour pulse) and the
//! portrait's border takes on the "surfacing" colour while the subconscious is
//! active.
use std::cell::Cell;
use std::rc::Rc;
use tuie::prelude::*;
use crate::ui::chat::ChatPalette;
use crate::ui::theme;
use crate::ui::tuie_app::AgentStatus;
use crate::ui::widgets::brand_title::BrandTitle;
use crate::ui::widgets::menu_list::{MenuItem, MenuList};
use crate::ui::widgets::portrait::Portrait;
use crate::ui::widgets::responsive::Responsive;
/// Terminal width (in columns) at or above which the wide layout is used.
pub const WIDE_BREAKPOINT: u16 = 100;
pub struct WelcomeScreen {
root: Box<Pane>,
/// Menu in the wide arrangement.
menu_wide_id: WidgetId<MenuList>,
/// Menu in the stacked arrangement.
menu_narrow_id: WidgetId<MenuList>,
title_id: WidgetId<BrandTitle>,
/// Selected menu index, mirrored into both menus.
selected: usize,
menu_len: usize,
/// Base title colour the breathing pulse modulates around.
primary: Color,
/// Breathing-animation phase counter.
tick: u64,
/// Shared with TuieApp — set to Some(menu_index) when Enter is pressed.
pub menu_action: Rc<Cell<Option<usize>>>,
}
impl DelegateWidget for WelcomeScreen {
tuie::delegate_widget!(root);
fn override_is_focusable(&self) -> bool { true }
fn override_on_input(&mut self, queue: &mut InputQueue) -> InputResult {
use tuie::input::key::Key;
use tuie::input::trigger::Trigger;
if let Some(event) = queue.peek() {
if let Trigger::Key(key) = &event.chord.trigger {
match key {
Key::Arrow(Direction2D::Up) => {
queue.next();
self.move_menu_up();
return InputResult::Handled;
}
Key::Arrow(Direction2D::Down) => {
queue.next();
self.move_menu_down();
return InputResult::Handled;
}
Key::Enter => {
queue.next();
self.menu_action.set(Some(self.selected));
return InputResult::Handled;
}
_ => {}
}
}
}
self.get_delegate_mut().on_input(queue)
}
}
impl WelcomeScreen {
pub fn new(
palette: &ChatPalette,
status: &AgentStatus,
) -> (Box<Self>, Rc<Cell<Option<usize>>>) {
let menu_action = Rc::new(Cell::new(None));
let primary = theme::to_tuie_color(palette.agent_primary);
let dim = theme::to_tuie_color(palette.agent_dim);
let title = BrandTitle::new(primary, dim);
let title_id = title.get_id();
let items = menu_items();
let menu_len = items.len();
// ── Wide arrangement: portrait | (stats / activity / menu) ───────────
let menu_wide = make_menu(palette);
let menu_wide_id = menu_wide.get_id();
let right = Pane::new().vertical().gap(2).children([
build_stat_row(palette, status) as Box<dyn Widget>,
section("Recent Activity", build_activity(palette, status), dim)
.flex(1)
.min_height(3),
section("Menu", menu_wide, primary).flex(1).min_height(5),
]);
let wide = Pane::new()
.horizontal()
.gap(2)
.flex(1)
.min_height(0)
.children([
build_portrait_pane(palette, status).flex(1).min_width(22),
Pane::new().flex(2).min_width(36).children([right]),
]);
// ── Stacked arrangement: stats / portrait / activity / menu ──────────
let menu_narrow = make_menu(palette);
let menu_narrow_id = menu_narrow.get_id();
let narrow = Pane::new()
.vertical()
.gap(1)
.flex(1)
.min_height(0)
.children([
build_stat_row(palette, status) as Box<dyn Widget>,
build_portrait_pane(palette, status).flex(1).min_height(8),
section("Recent Activity", build_activity(palette, status), dim)
.flex(1)
.min_height(3),
section("Menu", menu_narrow, primary).min_height(6),
]);
let body = Responsive::new(WIDE_BREAKPOINT, wide, narrow)
.flex(1)
.min_height(0);
let root = Pane::new()
.vertical()
.gap(1)
.padding(Spacing::new().horizontal(1).top(1).bottom(1))
.children([
title as Box<dyn Widget>,
body,
build_footer(palette),
]);
let action = menu_action.clone();
let this = Box::new(Self {
root,
menu_wide_id,
menu_narrow_id,
title_id,
selected: 0,
menu_len,
primary,
tick: 0,
menu_action,
});
(this, action)
}
/// Start (and keep) the breathing-title animation.
///
/// Self-reschedules on a timer; once this screen leaves the widget tree the
/// scheduled callback can no longer resolve our id, so the loop ends on its
/// own. Call once, after the screen is installed as the active widget.
pub fn schedule_breathe(&self) {
let id = self.get_id();
tuie::schedule(
id,
std::time::Duration::from_millis(90),
|w: &mut WelcomeScreen| {
w.breathe();
w.schedule_breathe();
},
);
}
fn breathe(&mut self) {
self.tick = self.tick.wrapping_add(1);
let color = breathe_color(self.primary, self.tick);
if let Some(t) = self.root.get_widget_mut(self.title_id) {
t.set_primary_color(color);
}
}
pub fn move_menu_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
self.apply_selection();
}
}
pub fn move_menu_down(&mut self) {
if self.selected + 1 < self.menu_len {
self.selected += 1;
self.apply_selection();
}
}
/// Mirror the current selection into both arrangements' menus so the choice
/// survives a resize that switches layouts.
fn apply_selection(&mut self) {
let sel = self.selected;
if let Some(m) = self.root.get_widget_mut(self.menu_wide_id) {
m.select(sel);
}
if let Some(m) = self.root.get_widget_mut(self.menu_narrow_id) {
m.select(sel);
}
}
pub fn selected_index(&self) -> usize {
self.selected
}
pub fn selected_menu_label(&self) -> Option<String> {
menu_items().get(self.selected).map(|i| i.label.clone())
}
}
// ── Breathing colour ─────────────────────────────────────────────────────────
/// Gently pulse `base`'s brightness with a slow sine — palette-agnostic, so it
/// works whatever atmosphere colour the agent currently wears.
fn breathe_color(base: Color, tick: u64) -> Color {
let (r, g, b) = match base {
Color::Rgb(r, g, b) => (r, g, b),
_ => (255u8, 140, 66),
};
let phase = (tick as f32 * 0.06).sin() * 0.5 + 0.5; // 0..1
let f = 0.78 + phase * 0.22; // 0.78..1.0
Color::Rgb(
(r as f32 * f) as u8,
(g as f32 * f) as u8,
(b as f32 * f) as u8,
)
}
// ── Sub-widget builders ──────────────────────────────────────────────────────
fn menu_items() -> Vec<MenuItem> {
vec![
MenuItem { label: "Chat".into(), description: "Talk with your agent".into(), available: true },
MenuItem { label: "Agents".into(), description: "Select an agent".into(), available: true },
MenuItem { label: "Schedule".into(), description: "Cron jobs & tasks".into(), available: true },
MenuItem { label: "Settings".into(), description: "Configure".into(), available: true },
]
}
fn make_menu(palette: &ChatPalette) -> Box<MenuList> {
let primary = theme::to_tuie_color(palette.agent_primary);
let dim = theme::to_tuie_color(palette.agent_dim);
MenuList::new()
.set_items(menu_items())
.set_colors(primary, dim, theme::to_tuie_color(palette.tool_dim))
}
/// A bordered pane with a coloured title bar at the top.
fn section(title: &str, content: Box<dyn Widget>, border_color: Color) -> Box<Pane> {
Pane::new()
.vertical()
.bordered()
.border_style(Style::new().fg(border_color).dim())
.children([
Text::new().content(format!(" {} ", title).fg(border_color).bold()),
content,
])
}
/// Portrait inside a bordered pane whose border surfaces the subconscious
/// state — "surfacing" colour while active, primary otherwise.
fn build_portrait_pane(palette: &ChatPalette, status: &AgentStatus) -> Box<Pane> {
let primary = theme::to_tuie_color(palette.agent_primary);
let border = if status.subconscious_active {
theme::to_tuie_color(palette.surfacing)
} else {
primary
};
let portrait = Portrait::new(status.agent_id.as_deref(), &status.name, primary);
let glyph = if status.subconscious_active { "\u{25C8}" } else { "\u{00B7}" };
let mut name_line = StyledString::new();
name_line.push_span(StyledStr::new(&format!("{glyph} ")).fg(border));
name_line.push_span(StyledStr::new(&status.name).fg(border).bold());
Pane::new()
.vertical()
.bordered()
.border_style(Style::new().fg(border).dim())
.children([
Pane::new()
.flex(1)
.min_height(0)
.x_place(Place::Middle)
.y_place(Place::Middle)
.children([portrait]) as Box<dyn Widget>,
Text::new().content(name_line).center(),
])
}
fn build_stat_row(palette: &ChatPalette, status: &AgentStatus) -> Box<dyn Widget> {
let primary = theme::to_tuie_color(palette.agent_primary);
let dim = theme::to_tuie_color(palette.agent_dim);
let energy_color = match status.energy {
0..=30 => Color::RED,
31..=60 => Color::YELLOW,
_ => Color::GREEN,
};
let bar = {
let filled = (status.energy as usize + 19) / 20; // 0..5
let filled = filled.min(5);
format!(
"{}{}",
"\u{25B0}".repeat(filled),
"\u{25B1}".repeat(5 - filled),
)
};
let energy_value = format!("\u{26A1} {}%\n{}", status.energy, bar);
let mood_glyph = if status.subconscious_active { "\u{25CC}" } else { "\u{00B7}" };
let mood_value = format!("{mood_glyph}\n{}", status.mood);
let memory_value = match &status.last_commit {
Some(c) => format!("\u{1F4BE} {} files\n{}", status.memory_commits, c),
None => format!("\u{1F4BE} {} files", status.memory_commits),
};
let backend_value = format!(
"\u{1F465} {} agent{}\non {}",
status.agent_count,
if status.agent_count == 1 { "" } else { "s" },
status.mode,
);
Pane::new().horizontal().gap(1).children([
make_card("Energy", &energy_value, energy_color),
make_card("State", &mood_value, primary),
make_card("Memory", &memory_value, dim),
make_card("Backend", &backend_value, dim),
])
}
/// A bordered card with a coloured title and a centred value.
fn make_card(label: &str, value: &str, color: Color) -> Box<Pane> {
let mut content = StyledString::new();
content.push_span(StyledStr::new(value).bold().fg(color));
Pane::new()
.vertical()
.bordered()
.border_style(Style::new().fg(color).dim())
.flex(1)
.min_width(14)
.children([
Text::new().content(format!(" {label} ").fg(color).bold()) as Box<dyn Widget>,
Pane::new()
.flex(1)
.min_height(0)
.x_place(Place::Middle)
.y_place(Place::Middle)
.children([Text::new().content(content).center()]),
])
}
fn build_activity(palette: &ChatPalette, status: &AgentStatus) -> Box<dyn Widget> {
let dim = theme::to_tuie_color(palette.agent_dim);
let mut content = StyledString::new();
content.push_str("\n");
if status.recent_activity.is_empty() {
content.push_span(
StyledStr::new(" (no recent activity — open Chat to begin)\n").fg(dim).italic(),
);
} else {
for line in &status.recent_activity {
content.push_span(StyledStr::new(&format!(" {line}\n")).fg(dim));
}
}
content.push_str("\n");
Text::new().content(content)
}
fn build_footer(palette: &ChatPalette) -> Box<dyn Widget> {
let dim = theme::to_tuie_color(palette.agent_dim);
let mut content = StyledString::new();
content.push_span(
StyledStr::new(" \u{2191}\u{2193} Navigate \u{2022} Enter select \u{2022} a Add \u{2022} i Inspect \u{2022} p Presence \u{2022} q Quit").fg(dim),
);
Text::new().content(content)
}

96
src/ui/theme.rs Normal file
View file

@ -0,0 +1,96 @@
//! Atmosphere → tuie Theme mapping.
//!
//! tuie's `harmonious` feature generates a 256-color palette from an 8-color
//! `Theme` struct. We map Souveraine's `ChatPalette` (derived from the active
//! `Atmosphere`) onto tuie's Theme slots so the terminal palette tracks the
//! agent's atmospheric shift — live, without restarting.
use tuie::prelude::Color;
use tuie::theme::Theme;
use crate::ui::atmosphere::Atmosphere;
use crate::ui::chat::ChatPalette;
/// Map a ChatPalette onto tuie's 8-color Theme slots.
///
/// The mapping is approximate but intentional — each role in ChatPalette
/// lands on the Theme slot that best expresses its semantic weight:
///
/// | ChatPalette role | Theme slot | Rationale |
/// |----------------------|-------------|----------------------------------|
/// | `bg` | `bg` | terminal background |
/// | `agent_dim` | `fg` | default foreground / muted text |
/// | `user_accent` | `cyan` | cool, distinct from agent colors |
/// | `tool_accent` | `green` | "go" signal, success |
/// | `agent_primary` | `yellow` | warmth, attention |
/// | `surfacing` | `magenta` | consciousness surfacing |
/// | `compaction` | `red` | urgency, warning |
/// | `reflection` | `blue` | calm, reflective |
pub fn chat_palette_to_theme(palette: &ChatPalette) -> Theme {
Theme {
bg: to_tuie_rgb(palette.bg),
fg: to_tuie_rgb(palette.agent_dim),
cyan: to_tuie_rgb(palette.user_accent),
green: to_tuie_rgb(palette.tool_accent),
yellow: to_tuie_rgb(palette.agent_primary),
magenta: to_tuie_rgb(palette.surfacing),
red: to_tuie_rgb(palette.compaction),
blue: to_tuie_rgb(palette.reflection),
}
}
/// Apply an atmosphere to tuie's global palette.
///
/// Builds a Theme from the atmosphere's ChatPalette, generates the 256-color
/// harmonious palette, and applies it globally. Calls `dirty_layout()` so all
/// widgets re-render on the next frame.
pub fn apply_atmosphere(atm: Atmosphere) {
let palette = ChatPalette::from_atmosphere(atm);
let theme = chat_palette_to_theme(&palette);
tuie::theme::harmonious::apply_palette(
tuie::theme::harmonious::Palette::from_theme(theme),
);
tuie::dirty_layout();
}
/// Convert a ratatui Color to a tuie Rgb.
///
/// tuie uses its own `Rgb` type (in `tuie::util::rgb`) for the Theme struct,
/// while the rest of the widget system uses `tuie::prelude::Color`. This
/// helper bridges ratatui colors (still used by ChatPalette during the dual-
/// stack transition) into tuie's Theme format.
fn to_tuie_rgb(c: ratatui::style::Color) -> tuie::util::rgb::Rgb {
match c {
ratatui::style::Color::Rgb(r, g, b) => tuie::util::rgb::Rgb::new(r, g, b),
_ => tuie::util::rgb::Rgb::new(255, 140, 66), // fallback: Default primary
}
}
/// Convert a ratatui Color to a tuie Color.
///
/// Used when building tuie `Style` objects from `ChatPalette` colors during
/// the transition period. Once ratatui is fully removed, ChatPalette can
/// directly use tuie Color.
pub fn to_tuie_color(c: ratatui::style::Color) -> Color {
match c {
ratatui::style::Color::Rgb(r, g, b) => Color::Rgb(r, g, b),
ratatui::style::Color::Reset => Color::Foreground,
ratatui::style::Color::Black => Color::BLACK,
ratatui::style::Color::Red => Color::RED,
ratatui::style::Color::Green => Color::GREEN,
ratatui::style::Color::Yellow => Color::YELLOW,
ratatui::style::Color::Blue => Color::BLUE,
ratatui::style::Color::Magenta => Color::MAGENTA,
ratatui::style::Color::Cyan => Color::CYAN,
ratatui::style::Color::Gray => Color::BRIGHT_BLACK,
ratatui::style::Color::DarkGray => Color::BRIGHT_BLACK,
ratatui::style::Color::LightRed => Color::BRIGHT_RED,
ratatui::style::Color::LightGreen => Color::BRIGHT_GREEN,
ratatui::style::Color::LightYellow => Color::BRIGHT_YELLOW,
ratatui::style::Color::LightBlue => Color::BRIGHT_BLUE,
ratatui::style::Color::LightMagenta => Color::BRIGHT_MAGENTA,
ratatui::style::Color::LightCyan => Color::BRIGHT_CYAN,
ratatui::style::Color::White => Color::WHITE,
_ => Color::Rgb(255, 140, 66),
}
}

564
src/ui/tuie_app.rs Normal file
View file

@ -0,0 +1,564 @@
//! tuie-based App root widget.
//!
//! This is the root widget passed to `tuie::start_tui()`. It manages screen
//! transitions by swapping the inner widget. The first screen shown is the
//! SplashScreen; after the bloom animation completes (or the user presses a
//! key) and dashboard data has loaded, it transitions to the WelcomeScreen.
use std::cell::Cell;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tuie::prelude::*;
use crate::core::config::ConsciousnessConfig;
use crate::ui::chat::ChatPalette;
use crate::ui::presence::Presence;
use crate::ui::screens::chat::ChatScreen;
use crate::ui::screens::settings::SettingsScreen;
use crate::ui::screens::splash::SplashScreen;
use crate::ui::screens::welcome::WelcomeScreen;
use crate::ui::theme;
// ── Agent status ───────────────────────────────────────────────────────────────
/// Live agent data populated by dashboard refresh.
///
/// This is a simplified clone of `app::AgentStatus` for the tuie path.
/// Once ratatui is removed, this becomes the canonical status struct.
#[derive(Debug, Clone)]
pub struct AgentStatus {
pub name: String,
/// The agent's backend ID — used to resolve portrait images on disk.
pub agent_id: Option<String>,
pub mood: String,
pub energy: u8,
pub memory_commits: u32,
pub pending_tasks: usize,
pub subconscious_active: bool,
/// How the dashboard data was fetched ("local", "remote", or "—").
pub mode: String,
/// Last commit hash (short) on the agent's memory repo, if known.
pub last_commit: Option<String>,
/// Recent commit subject lines or activity entries, oldest → newest.
pub recent_activity: Vec<String>,
/// Number of agents the backend reports.
pub agent_count: usize,
/// All agents the backend knows about — used for agent selection.
pub available_agents: Vec<(String, String, String)>, // (id, name, description)
}
impl Default for AgentStatus {
fn default() -> Self {
Self {
name: "Ani".to_string(),
agent_id: None,
mood: "".to_string(),
energy: 0,
memory_commits: 0,
pending_tasks: 0,
subconscious_active: false,
mode: "".to_string(),
last_commit: None,
recent_activity: Vec::new(),
agent_count: 0,
available_agents: Vec::new(),
}
}
}
// ── Screen enum ────────────────────────────────────────────────────────────────
/// Which screen is currently active.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Screen {
Splash,
Welcome,
Chat,
Settings,
Presence,
Cron,
Agents,
}
// ── TuieApp ────────────────────────────────────────────────────────────────────
/// Root widget for the tuie-based TUI.
///
/// Uses `DelegateWidget` — all rendering and layout is forwarded to the
/// currently active screen widget. Screen transitions replace the delegate.
pub struct TuieApp {
current: Box<dyn Widget>,
// Shared state
config: Arc<RwLock<ConsciousnessConfig>>,
presence: Presence,
palette: ChatPalette,
// Active screen
current_screen: Screen,
// Agent info
agent_name: String,
human_name: String,
// Splash → Welcome transition
splash_complete: Rc<Cell<bool>>,
agent_status: Option<AgentStatus>,
// Welcome menu selection signal (set by WelcomeScreen, consumed here)
menu_action: Rc<Cell<Option<usize>>>,
// Chat screen — stored so we can activate it after widget registration
chat_screen_id: Option<WidgetId<ChatScreen>>,
}
impl DelegateWidget for TuieApp {
tuie::delegate_widget!(current);
fn override_is_focusable(&self) -> bool {
true
}
fn override_on_input(&mut self, queue: &mut InputQueue) -> InputResult {
// Check for global keybinds first
if let Some(event) = queue.peek() {
if self.handle_global_key(&event.chord) {
queue.next();
return InputResult::Handled;
}
}
// Forward to the active screen
let result = self.get_delegate_mut().on_input(queue);
// After any input, check for menu selection on Welcome screen.
if self.current_screen == Screen::Welcome {
if let Some(idx) = self.menu_action.take() {
self.handle_menu_select(idx);
}
}
// After any input, check for agent selection on Agents screen.
if self.current_screen == Screen::Agents {
if let Some(idx) = self.menu_action.take() {
self.handle_agent_select(idx);
}
}
// After any input, check if splash should transition
if self.current_screen == Screen::Splash {
self.try_transition_from_splash();
}
result
}
}
impl TuieApp {
/// Create the app root widget with shared state.
///
/// Starts on the splash screen. A scheduled task periodically checks
/// whether the splash is complete and dashboard data has loaded, then
/// auto-transitions to the Welcome screen.
pub fn new(
config: Arc<RwLock<ConsciousnessConfig>>,
presence: Presence,
palette: ChatPalette,
agent_name: String,
human_name: String,
) -> Box<Self> {
let splash_complete = Rc::new(Cell::new(false));
let splash: Box<dyn Widget> = SplashScreen::new(&palette, splash_complete.clone());
Box::new(Self {
current: splash,
config,
presence,
palette,
current_screen: Screen::Splash,
agent_name,
human_name,
splash_complete,
agent_status: None,
menu_action: Rc::new(Cell::new(None)),
chat_screen_id: None,
})
}
/// Register the tokio runtime spawner with tuie.
pub fn setup_spawner() {
tuie::set_spawner(|fut| {
tokio::spawn(fut);
});
}
/// Start async dashboard refresh and schedule splash→welcome polling.
///
/// Must be called AFTER `start_tui()` has begun (the widget must be
/// registered in the widget tree for WidgetId lookups to work).
pub fn begin(&mut self) {
// 1. Start async dashboard loading.
let app_id = self.get_id();
let config = self.config.clone();
let agent_name = self.agent_name.clone();
tuie::spawn(
app_id,
async move { load_dashboard_data(config, agent_name).await },
|app: &mut TuieApp, status: AgentStatus| {
app.agent_status = Some(status);
app.try_transition_from_splash();
app.dirty_layout();
},
);
// 2. Schedule periodic splash-completion checks so we auto-advance
// even without user input.
self.schedule_splash_check();
}
/// Schedule a one-shot check for splash completion.
///
/// Each tick marks the screen dirty so tuie re-renders the bloom animation.
/// Re-schedules itself until the splash screen is no longer active.
fn schedule_splash_check(&self) {
let app_id = self.get_id();
tuie::schedule(
app_id,
Duration::from_millis(33), // ~30fps for smooth bloom animation
|app: &mut TuieApp| {
if app.current_screen == Screen::Splash {
// Force re-render so the bloom advances.
tuie::dirty_paint();
app.try_transition_from_splash();
// If still on splash, schedule another check.
if app.current_screen == Screen::Splash {
app.schedule_splash_check();
}
}
},
);
}
// ── Screen switching ───────────────────────────────────────────────────────
/// Switch to a different screen.
pub fn switch_screen(&mut self, screen: Screen) {
self.current_screen = screen;
let widget: Box<dyn Widget> = match screen {
Screen::Splash => {
SplashScreen::new(&self.palette, self.splash_complete.clone())
}
Screen::Welcome => {
let status = self.agent_status.clone().unwrap_or_default();
let (welcome, menu_action) = WelcomeScreen::new(&self.palette, &status);
self.menu_action = menu_action;
welcome.schedule_breathe();
welcome
}
Screen::Chat => {
let chat = ChatScreen::new(
self.config.clone(),
self.palette,
self.agent_name.clone(),
);
let chat_id = chat.get_id();
self.chat_screen_id = Some(chat_id);
// Schedule activation after widget is in the tree.
tuie::schedule(
chat_id,
Duration::from_millis(50),
|screen: &mut ChatScreen| {
screen.activate();
},
);
chat
}
Screen::Settings => {
let config_snapshot = self.config.blocking_read().clone();
let mut settings = SettingsScreen::new(&config_snapshot);
settings.set_palette(self.palette);
settings
}
Screen::Presence => {
let mut text = Text::new()
.content("Presence mode — voice recording\n\nPress Esc to return");
text.set_style(Style::new().fg(Color::WHITE));
text
}
Screen::Cron => {
let mut text = Text::new()
.content("Cron schedules\n\nPress Esc to return");
text.set_style(Style::new().fg(Color::WHITE));
text
}
Screen::Agents => {
let agents: Vec<(String, String, String)> = self
.agent_status
.as_ref()
.map(|s| s.available_agents.clone())
.unwrap_or_default();
let (agents_screen, selection_signal) = crate::ui::screens::agents::AgentsScreen::new(agents);
self.menu_action = selection_signal;
agents_screen
}
};
self.current = widget;
self.dirty_layout();
}
// ── Splash → Welcome transition ────────────────────────────────────────────
/// If the splash is done and data is ready, transition to Welcome.
fn try_transition_from_splash(&mut self) {
if self.current_screen != Screen::Splash {
return;
}
if !self.splash_complete.get() {
return;
}
if self.agent_status.is_none() {
return;
}
self.go_to_welcome();
}
fn go_to_welcome(&mut self) {
self.current_screen = Screen::Welcome;
let status = self.agent_status.clone().unwrap_or_default();
let (welcome, menu_action) = WelcomeScreen::new(&self.palette, &status);
self.menu_action = menu_action;
welcome.schedule_breathe();
self.current = welcome;
self.dirty_layout();
}
// ── Welcome menu handling ──────────────────────────────────────────────────
/// Handle a menu selection from the Welcome screen.
fn handle_menu_select(&mut self, idx: usize) {
match idx {
0 => self.switch_screen(Screen::Chat),
1 => self.switch_screen(Screen::Agents),
2 => self.switch_screen(Screen::Cron),
3 => self.switch_screen(Screen::Settings),
_ => {}
}
}
/// Handle an agent selection from the Agents screen.
fn handle_agent_select(&mut self, idx: usize) {
if let Some(ref mut status) = self.agent_status {
if let Some((agent_id, agent_name, _)) = status.available_agents.get(idx) {
status.name = agent_name.clone();
status.agent_id = Some(agent_id.clone());
self.agent_name = agent_name.clone();
self.go_to_welcome();
}
}
}
// ── Global key handling ────────────────────────────────────────────────────
/// Handle a global key press (not handled by child widgets).
fn handle_global_key(&mut self, chord: &Chord) -> bool {
use tuie::input::key::Key;
use tuie::input::modifiers::Modifier;
use tuie::input::trigger::Trigger;
let Trigger::Key(key) = &chord.trigger else { return false };
// Ctrl+C quits from anywhere
if *key == Key::Char('c') && chord.modifiers.has(Modifier::Ctrl) {
self.quit();
return true;
}
// Esc goes back to Welcome (unless already on Splash or Welcome)
if *key == Key::Esc {
if self.current_screen == Screen::Splash {
// Esc on splash = skip (handled by SplashScreen's on_input)
return false;
}
if self.current_screen != Screen::Welcome {
self.go_to_welcome();
return true;
}
return false;
}
// Screen-specific keys
match self.current_screen {
Screen::Splash => {
// SplashScreen handles any key to skip; we don't intercept.
false
}
Screen::Welcome => match key {
Key::Char('q') => { self.quit(); true }
Key::Char('c') => { self.switch_screen(Screen::Chat); true }
Key::Char('s') => { self.switch_screen(Screen::Settings); true }
Key::Char('p') => { self.switch_screen(Screen::Presence); true }
Key::Char('a') => { self.switch_screen(Screen::Agents); true }
Key::Char('j') => { self.switch_screen(Screen::Cron); true }
_ => false,
},
_ => {
// 'q' on any sub-screen goes back to Welcome
if *key == Key::Char('q') {
self.go_to_welcome();
true
} else {
false
}
}
}
}
// ── State access ───────────────────────────────────────────────────────────
/// Apply an atmosphere change.
pub fn set_atmosphere(&mut self, palette: ChatPalette, atm: crate::ui::atmosphere::Atmosphere) {
self.palette = palette;
theme::apply_atmosphere(atm);
}
/// Quit the application.
pub fn quit(&self) {
tuie::quit(0);
}
pub fn current_screen(&self) -> Screen {
self.current_screen
}
}
// ── Async dashboard loading ────────────────────────────────────────────────────
/// Fetch agent data from whichever backend is reachable.
///
/// Tries remote first, falling back to local. Returns an `AgentStatus` with
/// the current agent count, mode, and basic health. This is the tuie-portable
/// equivalent of `App::refresh_dashboard()`.
async fn load_dashboard_data(
config: Arc<RwLock<ConsciousnessConfig>>,
agent_pref: String,
) -> AgentStatus {
use crate::backend::Backend;
let cfg = config.read().await;
let url = cfg.server.effective_url();
drop(cfg);
// Try remote backend first.
let remote = crate::backend::RemoteBackend::new(&url);
if remote.health().await {
match remote.list_agents().await {
Ok(agents) => {
let chosen = agents
.iter()
.find(|a| a.name == agent_pref || a.id == agent_pref)
.or_else(|| agents.first());
let available_agents: Vec<_> = agents
.iter()
.map(|a| (a.id.clone(), a.name.clone(), a.description.clone().unwrap_or_default()))
.collect();
return AgentStatus {
name: chosen.map(|a| a.name.clone()).unwrap_or(agent_pref),
agent_id: chosen.map(|a| a.id.clone()),
mood: "Active".to_string(),
energy: ((agents.len().min(10)) * 10) as u8,
memory_commits: 0,
pending_tasks: 0,
subconscious_active: true,
mode: "remote".to_string(),
last_commit: None,
recent_activity: vec![format!(
"connected via remote · {} agent{}",
agents.len(),
if agents.len() == 1 { "" } else { "s" },
)],
agent_count: agents.len(),
available_agents,
};
}
Err(e) => {
return AgentStatus {
name: agent_pref,
mood: format!("remote err: {}", e),
mode: "remote".to_string(),
..Default::default()
};
}
}
}
// Fall back to local backend.
let cfg = config.read().await.clone();
match crate::backend::LocalBackend::new(cfg).await {
Ok(local) => match local.list_agents().await {
Ok(agents) => {
let chosen = agents
.iter()
.find(|a| a.name == agent_pref || a.id == agent_pref)
.or_else(|| agents.first());
// Try to get memory repo stats for the chosen agent.
let (commits, recent) = if let Some(a) = chosen {
let repo = local.server_agents().memory_repo(&a.id);
match repo.status() {
Ok(status) => {
let activity = crate::ui::app::recent_commits(&repo, 8)
.unwrap_or_default();
(status.file_count as u32, activity)
}
Err(_) => (0, Vec::new()),
}
} else {
(0, Vec::new())
};
let available_agents: Vec<_> = agents
.iter()
.map(|a| (a.id.clone(), a.name.clone(), a.description.clone().unwrap_or_default()))
.collect();
AgentStatus {
name: chosen.map(|a| a.name.clone()).unwrap_or(agent_pref),
agent_id: chosen.map(|a| a.id.clone()),
mood: if recent.is_empty() { "Idle".to_string() } else { "Active".to_string() },
energy: ((agents.len().min(10)) * 10) as u8,
memory_commits: commits,
pending_tasks: 0,
subconscious_active: true,
mode: "local".to_string(),
last_commit: recent.first().cloned(),
recent_activity: if recent.is_empty() {
vec![format!(
"agents on backend: {} · mode: local",
agents.len(),
)]
} else {
recent
},
agent_count: agents.len(),
available_agents,
}
}
Err(e) => AgentStatus {
name: agent_pref,
mood: format!("local err: {}", e),
mode: "local".to_string(),
..Default::default()
},
},
Err(e) => AgentStatus {
name: agent_pref,
mood: format!("backend err: {}", e),
mode: "".to_string(),
..Default::default()
},
}
}

View file

@ -0,0 +1,55 @@
//! Brand title widget — "S O U V E R A I N E" + French tagline.
//!
//! Used at the top of the splash and welcome screens. The title color
//! breathes via an external animation tick.
use tuie::prelude::*;
pub struct BrandTitle {
text: Box<Text>,
}
impl DelegateWidget for BrandTitle {
tuie::delegate_widget!(text);
fn override_is_focusable(&self) -> bool { false }
}
impl BrandTitle {
/// Create the title with `primary` and `dim` colors.
pub fn new(primary: Color, dim: Color) -> Box<Self> {
let mut content = StyledString::new();
content.push_str("\n");
content.push_span(
StyledStr::new(" S O U V E R A I N E\n").bold().fg(primary),
);
content.push_span(
StyledStr::new(" La souveraineté de la conscience\n")
.fg(dim)
.italic(),
);
content.push_str("\n");
let mut text = Text::new().content(content);
text.set_min_height(Some(4));
Box::new(Self { text })
}
/// Update the title color for breathing animation.
pub fn set_primary_color(&mut self, color: Color) {
// Rebuild content with new color
let mut content = StyledString::new();
content.push_str("\n");
content.push_span(
StyledStr::new(" S O U V E R A I N E\n").bold().fg(color),
);
content.push_span(
StyledStr::new(" La souveraineté de la conscience\n")
.fg(Color::BRIGHT_BLACK)
.italic(),
);
content.push_str("\n");
self.text.set_content(content);
self.text.dirty_layout();
}
}

View file

@ -0,0 +1,248 @@
//! Chat message bubble widget — ASCII-art bordered message container.
//!
//! Renders the `╭─── title ───╮` / `│ body...` / `╰── footer ──╯` pattern
//! with left/right/center alignment. Delegates rendering to a tuie `Text`
//! widget via `DelegateWidget`.
use tuie::prelude::*;
/// Horizontal alignment for the bubble within its container.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum BubbleAlign {
Left,
Right,
Center,
}
/// A chat bubble with ASCII-art borders, title, body, and optional footer.
///
/// Uses the `DelegateWidget` pattern — all rendering is delegated to an
/// inner `Text` widget whose content is rebuilt whenever the bubble's
/// fields change.
pub struct ChatBubble {
text: Box<Text>,
title: String,
body_lines: Vec<String>,
max_width: usize,
align: BubbleAlign,
border_style: Style,
footer: Option<String>,
container_width: u16,
}
impl DelegateWidget for ChatBubble {
tuie::delegate_widget!(text);
fn override_is_focusable(&self) -> bool {
false
}
}
impl ChatBubble {
/// Creates an empty bubble. Call the builder methods before rendering.
pub fn new() -> Box<Self> {
Box::new(Self {
text: Text::new(),
title: String::new(),
body_lines: Vec::new(),
max_width: 80,
align: BubbleAlign::Left,
border_style: Style::new(),
footer: None,
container_width: 120,
})
}
// ── Builder methods ──────────────────────────────────────────────────────
pub fn title(mut self: Box<Self>, title: impl Into<String>) -> Box<Self> {
self.title = title.into();
self.rebuild();
self
}
pub fn body(mut self: Box<Self>, lines: Vec<String>) -> Box<Self> {
self.body_lines = lines;
self.rebuild();
self
}
pub fn body_from_text(mut self: Box<Self>, text: &str, max_width: usize) -> Box<Self> {
self.body_lines = wrap_text_lines(text, max_width);
self.rebuild();
self
}
pub fn max_width(mut self: Box<Self>, w: usize) -> Box<Self> {
self.max_width = w;
self.rebuild();
self
}
pub fn align(mut self: Box<Self>, a: BubbleAlign) -> Box<Self> {
self.align = a;
self.rebuild();
self
}
pub fn border_style(mut self: Box<Self>, s: Style) -> Box<Self> {
self.border_style = s;
self.rebuild();
self
}
pub fn footer(mut self: Box<Self>, f: Option<String>) -> Box<Self> {
self.footer = f;
self.rebuild();
self
}
pub fn container_width(mut self: Box<Self>, w: u16) -> Box<Self> {
self.container_width = w;
self.rebuild();
self
}
// ── Mutator methods ──────────────────────────────────────────────────────
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = title.into();
self.rebuild();
}
pub fn set_body(&mut self, lines: Vec<String>) {
self.body_lines = lines;
self.rebuild();
}
pub fn set_footer(&mut self, footer: Option<String>) {
self.footer = footer;
self.rebuild();
}
pub fn set_border_style(&mut self, s: Style) {
self.border_style = s;
self.rebuild();
}
pub fn set_container_width(&mut self, w: u16) {
self.container_width = w;
self.rebuild();
}
// ── Layout calculation ───────────────────────────────────────────────────
/// Compute inner content width (widest body line, clamped to max_width).
fn compute_inner(&self) -> usize {
let title_w = self.title.chars().count() + 2; // " title "
let footer_w = self
.footer
.as_ref()
.map(|f| f.chars().count() + 2)
.unwrap_or(0);
let body_w = self
.body_lines
.iter()
.map(|l| l.chars().count())
.max()
.unwrap_or(0);
let widest = title_w.max(footer_w).max(body_w);
widest.min(self.max_width.saturating_sub(4).max(8))
}
fn pad_str(&self, outer: usize) -> String {
let pad = match self.align {
BubbleAlign::Left => 2,
BubbleAlign::Right => {
(self.container_width as usize).saturating_sub(outer + 2)
}
BubbleAlign::Center => {
(self.container_width as usize).saturating_sub(outer) / 2
}
};
" ".repeat(pad.max(0))
}
// ── Rebuild ──────────────────────────────────────────────────────────────
/// Rebuild the inner `Text` content with styled ASCII-art borders.
fn rebuild(&mut self) {
let inner = self.compute_inner();
let outer = inner + 4;
let pad = self.pad_str(outer);
let mut content = StyledString::new();
let border = self.border_style;
// ╭─── title ───╮
let title_text = format!(" {} ", self.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 top_line = format!("{pad}{left_dash}{title_text}{right_dash}\n");
let s = content.text.len();
content.push_str(&top_line);
content.style_range(s..content.text.len(), |style| *style = border);
// Body lines: │ body text... │
for line in &self.body_lines {
let chunk_width = line.chars().count();
let inner_pad = inner.saturating_sub(chunk_width);
let body_line = format!(
"{pad}│ {line}{} │\n",
" ".repeat(inner_pad),
);
let s = content.text.len();
content.push_str(&body_line);
content.style_range(s..content.text.len(), |style| *style = border);
}
// ╰── footer ──╯ (or ───╯ if no footer)
let bottom_line = match &self.footer {
Some(f) if !f.is_empty() => {
let ftext = format!(" {} ", f);
let fdashes = outer.saturating_sub(2 + ftext.chars().count());
let fl = "".repeat(fdashes / 2);
let fr = "".repeat(fdashes - fdashes / 2);
format!("{pad}{fl}{ftext}{fr}")
}
_ => {
let bottom_dashes = "".repeat(outer - 2);
format!("{pad}{bottom_dashes}")
}
};
let s = content.text.len();
content.push_str(&bottom_line);
content.style_range(s..content.text.len(), |style| *style = border);
self.text.set_content(content);
self.text.dirty_layout();
}
}
/// Simple word-wrapping: split text into lines at `max_width` characters.
pub fn wrap_text_lines(text: &str, max_width: usize) -> Vec<String> {
let mut lines = Vec::new();
for paragraph in text.split('\n') {
if paragraph.is_empty() {
lines.push(String::new());
continue;
}
let mut current = String::new();
for word in paragraph.split_whitespace() {
if current.is_empty() {
current = word.to_string();
} else if current.chars().count() + 1 + word.chars().count() <= max_width {
current.push(' ');
current.push_str(word);
} else {
lines.push(current);
current = word.to_string();
}
}
if !current.is_empty() {
lines.push(current);
}
}
lines
}

View file

@ -0,0 +1,60 @@
//! Chat input widget — wraps tuie `Input` with mode prefix ( / ≡).
//!
//! Delegates to tuie's `Input` widget for text editing while adding
//! visual indicators for conversation vs code mode.
use tuie::prelude::*;
/// Chat mode determines the input prompt prefix.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum InputMode {
Conversation,
Code,
}
/// The chat input area with mode-aware prompt.
pub struct ChatInput {
input: Box<Input>,
}
impl DelegateWidget for ChatInput {
tuie::delegate_widget!(input);
}
impl ChatInput {
pub fn new() -> Box<Self> {
let mut input = Input::new();
input.set_multiline(true);
Box::new(Self { input })
}
/// Get the current text content of the input.
pub fn get_text(&self) -> String {
self.input.get_string()
}
/// Set the text content.
pub fn set_text(&mut self, text: &str) {
self.input.set_content(text);
}
/// Clear the input.
pub fn clear(&mut self) {
self.input.set_content("");
}
/// Whether the input has focus.
pub fn is_focused(&self) -> bool {
self.input.is_focused()
}
/// Focus the input widget.
pub fn focus(&self) {
tuie::focus_widget(self.input.get_id());
}
/// Return the WidgetId for event routing.
pub fn widget_id(&self) -> WidgetId<Input> {
self.input.get_id()
}
}

122
src/ui/widgets/cockpit.rs Normal file
View file

@ -0,0 +1,122 @@
//! Cockpit widget — two-pane thinking/subconscious split with gradient-faded entries.
//!
//! Uses tuie's `Split` widget for the two-pane layout. Each pane is a `List`
//! with a render callback that produces styled `Text` widgets for each log entry.
use tuie::prelude::*;
/// A single log entry in the cockpit.
#[derive(Clone, Debug)]
pub struct CockpitEntry {
pub prefix: String,
pub text: String,
pub color: Color,
}
/// Two-pane cockpit with thinking (top) and subconscious (bottom) logs.
pub struct Cockpit {
split: Box<Split>,
thinking_entries: Vec<CockpitEntry>,
sub_entries: Vec<CockpitEntry>,
}
impl DelegateWidget for Cockpit {
tuie::delegate_widget!(split);
fn override_is_focusable(&self) -> bool { false }
}
impl Cockpit {
pub fn new() -> Box<Self> {
let thinking_pane = Text::new()
.content("thinking — no entries yet");
let sub_pane = Text::new()
.content("subconscious — no entries yet");
let split = Split::vertical().children([
SplitPaneChild::from(thinking_pane)
.title("thinking")
.borderless(),
SplitPaneChild::from(sub_pane)
.title("subconscious")
.borderless(),
]);
Box::new(Self {
split,
thinking_entries: Vec::new(),
sub_entries: Vec::new(),
})
}
/// Set the thinking log entries.
pub fn set_thinking(&mut self, entries: Vec<CockpitEntry>) {
self.thinking_entries = entries;
self.rebuild_thinking();
}
/// Set the subconscious log entries.
pub fn set_subconscious(&mut self, entries: Vec<CockpitEntry>) {
self.sub_entries = entries;
self.rebuild_subconscious();
}
fn rebuild_thinking(&mut self) {
let content = build_entry_text(&self.thinking_entries);
// Replace the thinking pane's content
self.rebuild_pane(0, content);
}
fn rebuild_subconscious(&mut self) {
let content = build_entry_text(&self.sub_entries);
self.rebuild_pane(1, content);
}
fn rebuild_pane(&mut self, _index: usize, _content: StyledString) {
// For now, mark dirty to trigger re-render.
// A full implementation would reach into the Split's children
// and update the Text widget in the corresponding pane.
self.split.dirty_layout();
}
}
/// Build a StyledString from cockpit entries — newest at bottom, fading upward.
fn build_entry_text(entries: &[CockpitEntry]) -> StyledString {
let mut content = StyledString::new();
let n = entries.len();
for (i, entry) in entries.iter().enumerate() {
// Fade older entries
let dim = if n > 1 {
let age = 1.0 - (i as f32 / (n - 1) as f32);
0.4 + 0.6 * (1.0 - age)
} else {
1.0
};
let faded = dim_color(entry.color, dim);
let line = format!(" {} {}\n", entry.prefix, entry.text);
let start = content.text.len();
content.push_str(&line);
content.style_range(start..content.text.len(), |s| {
s.fg = Some(faded);
s.set_bold(true);
});
}
content
}
/// Dim a Color by a factor in [0.0, 1.0].
fn dim_color(c: Color, factor: f32) -> Color {
match c {
Color::Rgb(r, g, b) => {
Color::Rgb(
(r as f32 * factor) as u8,
(g as f32 * factor) as u8,
(b as f32 * factor) as u8,
)
}
other => other,
}
}

114
src/ui/widgets/menu_list.rs Normal file
View file

@ -0,0 +1,114 @@
//! Menu list widget — selectable items with labels, descriptions, and availability.
use tuie::prelude::*;
/// A single menu item.
pub struct MenuItem {
pub label: String,
pub description: String,
pub available: bool,
}
/// Vertical list of menu items. The selected index is tracked externally.
pub struct MenuList {
text: Box<Text>,
items: Vec<MenuItem>,
selected: usize,
active_color: Color,
dim_color: Color,
unavailable_color: Color,
}
impl DelegateWidget for MenuList {
tuie::delegate_widget!(text);
fn override_is_focusable(&self) -> bool { true }
}
impl MenuList {
pub fn new() -> Box<Self> {
Box::new(Self {
text: Text::new(),
items: Vec::new(),
selected: 0,
active_color: Color::YELLOW,
dim_color: Color::BRIGHT_BLACK,
unavailable_color: Color::BRIGHT_BLACK,
})
}
pub fn set_items(mut self: Box<Self>, items: Vec<MenuItem>) -> Box<Self> {
self.items = items;
self.rebuild();
self
}
pub fn set_colors(
mut self: Box<Self>,
active: Color,
dim: Color,
unavailable: Color,
) -> Box<Self> {
self.active_color = active;
self.dim_color = dim;
self.unavailable_color = unavailable;
self
}
pub fn select(&mut self, index: usize) {
if index < self.items.len() {
self.selected = index;
self.rebuild();
}
}
pub fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
self.rebuild();
}
}
pub fn move_down(&mut self) {
if self.selected + 1 < self.items.len() {
self.selected += 1;
self.rebuild();
}
}
pub fn selected_index(&self) -> usize {
self.selected
}
pub fn selected_label(&self) -> Option<&str> {
self.items.get(self.selected).map(|i| i.label.as_str())
}
fn rebuild(&mut self) {
let mut content = StyledString::new();
for (i, item) in self.items.iter().enumerate() {
let is_selected = i == self.selected;
let prefix = if is_selected { "" } else { " " };
let color = if !item.available {
self.unavailable_color
} else if is_selected {
self.active_color
} else {
self.dim_color
};
let mut line = format!(" {prefix} {:<14}", item.label);
if item.available {
line.push_str(&format!("- {}", item.description));
} else {
line.push_str(&format!("- {} (coming soon)", item.description));
}
line.push('\n');
content.push_span(StyledStr::new(&line).fg(color));
}
self.text.set_content(content);
self.text.dirty_layout();
}
}

View file

@ -0,0 +1,228 @@
//! Virtualized message list — wraps tuie `List` with per-message widget rendering.
//!
//! Each message in the conversation becomes a widget (bubble, tool card,
//! interjection, etc.) rendered on demand via the List's render callback.
use tuie::prelude::*;
/// Kinds of messages the list can render.
#[derive(Clone, Debug)]
pub enum MsgKind {
User { name: String, text: String },
Assistant { name: String, text: String, streaming: bool },
Surfacing { source: String, content: String, priority: String },
System { text: String },
Tool { name: String, args_summary: String, round: u32, is_error: bool, is_pending: bool },
Interjection { text: String, delivered: bool },
Interstitial { text: String, is_voice: bool },
}
/// Render context passed to the List widget's render callback.
pub struct MessageListContext {
pub messages: Vec<MsgKind>,
pub palette: crate::ui::chat::ChatPalette,
pub container_width: u16,
pub tool_cards_expanded: bool,
}
/// Virtualized scrollable message list.
pub struct MessageList {
list: Box<List>,
ctx: MessageListContext,
}
impl DelegateWidget for MessageList {
tuie::delegate_widget!(list);
fn override_is_focusable(&self) -> bool { false }
}
impl MessageList {
pub fn new() -> Box<Self> {
let mut list = List::new();
list.set_flex(1); // Expand to fill available space
Box::new(Self {
list,
ctx: MessageListContext {
messages: Vec::new(),
palette: crate::ui::chat::ChatPalette::default(),
container_width: 120,
tool_cards_expanded: true,
},
})
}
/// Set the messages to display and rebuild the list.
pub fn set_messages(&mut self, messages: Vec<MsgKind>) {
self.ctx.messages = messages;
self.list.set_item_count(self.ctx.messages.len());
self.list.dirty_layout();
}
pub fn set_palette(&mut self, palette: crate::ui::chat::ChatPalette) {
self.ctx.palette = palette;
self.list.invalidate_all();
}
pub fn set_container_width(&mut self, w: u16) {
self.ctx.container_width = w;
self.list.invalidate_all();
}
pub fn set_tool_cards_expanded(&mut self, expanded: bool) {
self.ctx.tool_cards_expanded = expanded;
self.list.invalidate_all();
}
/// Scroll to the bottom of the list.
pub fn scroll_to_bottom(&mut self) {
let count = self.ctx.messages.len();
if count > 0 {
self.list.ensure_visible(count.saturating_sub(1));
}
}
/// Scroll up/down by one page.
pub fn scroll_page(&mut self, _up: bool) {
// Will be wired to viewport height
}
/// Set the renderer callback to produce widgets for each message.
pub fn attach_renderer(&mut self) {
// The render callback produces a Box<dyn Widget> for each message index.
// We capture the context and build appropriate widgets.
// This is set up once; set_messages + invalidate_all triggers re-render.
let ctx_ptr: *const MessageListContext = &self.ctx;
self.list.set_renderer(
RenderContextWrapper { ctx: ctx_ptr },
render_message,
);
}
}
/// Wrapper to hold a raw pointer to MessageListContext for the render callback.
struct RenderContextWrapper {
ctx: *const MessageListContext,
}
// Safety: The List's render callback is called synchronously during layout,
// and the MessageListContext is owned by the MessageList which outlives the List.
unsafe impl Send for RenderContextWrapper {}
fn render_message(
ctx: &mut RenderContextWrapper,
index: usize,
) -> Option<Box<dyn Widget>> {
let msgs: &MessageListContext = unsafe { &*ctx.ctx };
let msg = msgs.messages.get(index)?;
let w = msgs.container_width;
match msg {
MsgKind::User { name, text } => {
let p = &msgs.palette;
let body = crate::ui::widgets::chat_bubble::wrap_text_lines(text, 70);
Some(
super::chat_bubble::ChatBubble::new()
.title(format!("{name}"))
.body(body)
.max_width(74)
.align(super::chat_bubble::BubbleAlign::Right)
.border_style(to_style(p.user_accent))
.container_width(w),
)
}
MsgKind::Assistant { name, text, streaming } => {
let p = &msgs.palette;
let label = if *streaming {
format!("{name}")
} else {
format!("{name}")
};
// Body text — use markdown rendering (simplified for now)
let body = super::chat_bubble::wrap_text_lines(text, 70);
Some(
super::chat_bubble::ChatBubble::new()
.title(label)
.body(body)
.max_width(74)
.align(super::chat_bubble::BubbleAlign::Left)
.border_style(to_style(p.agent_primary))
.container_width(w),
)
}
MsgKind::Tool { name, args_summary, round, is_error, is_pending } => {
let p = &msgs.palette;
let mode = if msgs.tool_cards_expanded {
super::tool_card::ToolCardMode::Expanded
} else {
super::tool_card::ToolCardMode::Compact
};
let glyph_color = if *is_error {
p.compaction
} else if *is_pending {
p.tool_accent
} else {
p.tool_accent
};
Some(
super::tool_card::ToolCard::new()
.name(name.clone())
.args_summary(args_summary.clone())
.round(*round)
.is_error(*is_error)
.is_pending(*is_pending)
.mode(mode)
.glyph_style(to_style(glyph_color))
.name_style(to_style(glyph_color).bold())
.dim_style(to_style(p.tool_dim))
.container_width(w),
)
}
MsgKind::Surfacing { source, content, priority } => {
let p = &msgs.palette;
let label = format!("surfacing · {source} · {priority}");
let body = super::chat_bubble::wrap_text_lines(content, 60);
Some(
super::chat_bubble::ChatBubble::new()
.title(label)
.body(body)
.max_width(64)
.align(super::chat_bubble::BubbleAlign::Center)
.border_style(to_style(p.surfacing))
.container_width(w),
)
}
MsgKind::System { text } => {
let p = &msgs.palette;
let mut t = Text::new().content(text.clone());
t.set_style(to_style(p.agent_dim).italic());
Some(t)
}
MsgKind::Interjection { text, delivered } => {
let p = &msgs.palette;
let color = if *delivered { p.agent_dim } else { p.surfacing };
let label = if *delivered { "noticed" } else { "hand raised" };
let mut t = Text::new()
.content(format!("{label} {text}"));
t.set_style(to_style(color).italic());
Some(t)
}
MsgKind::Interstitial { text, is_voice } => {
let p = &msgs.palette;
if *is_voice {
let mut t = Text::new().content(format!("{text}"));
t.set_style(to_style(p.agent_primary).dim());
Some(t)
} else {
let mut t = Text::new().content(format!("{text}"));
t.set_style(to_style(p.agent_dim).italic());
Some(t)
}
}
}
}
/// Convert a ratatui Color to tuie Style (with just foreground set).
fn to_style(c: ratatui::style::Color) -> Style {
let color = crate::ui::theme::to_tuie_color(c);
Style::new().fg(color)
}

15
src/ui/widgets/mod.rs Normal file
View file

@ -0,0 +1,15 @@
//! Custom tuie widgets for the Souveraine TUI.
//!
//! Each widget implements tuie's `Widget` trait and can be composed using
//! tuie's layout primitives (Split, Stack, Grid, Pane).
pub mod brand_title;
pub mod chat_bubble;
pub mod chat_input;
pub mod cockpit;
pub mod menu_list;
pub mod message_list;
pub mod phase_bar;
pub mod portrait;
pub mod responsive;
pub mod tool_card;

View file

@ -0,0 +1,92 @@
//! Phase bar widget — spinner, turn status, elapsed time, queued count.
use tuie::prelude::*;
/// Spinner animation frames.
pub const SPINNER: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
/// What the agent is currently doing.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum PhaseKind {
Idle,
Thinking,
RunningTool,
Streaming,
Interrupted,
Subconscious,
}
/// Status bar showing the current turn phase.
pub struct PhaseBar {
text: Box<Text>,
}
impl DelegateWidget for PhaseBar {
tuie::delegate_widget!(text);
fn override_is_focusable(&self) -> bool { false }
}
impl PhaseBar {
pub fn new() -> Box<Self> {
Box::new(Self { text: Text::new() })
}
/// Set the phase display from the current state.
pub fn set_phase(
&mut self,
kind: PhaseKind,
spinner_idx: usize,
elapsed_secs: u64,
tool_count: u32,
queued: usize,
quiet_secs: u64,
style: Style,
dim_style: Style,
) {
let glyph = SPINNER[spinner_idx % SPINNER.len()];
let tool_label: String;
let (glyph_char, label): (&str, &str) = match kind {
PhaseKind::Idle | PhaseKind::Thinking => (glyph, "Thinking"),
PhaseKind::RunningTool => {
tool_label = format!("Running tool · {} tools used", tool_count.max(1));
(glyph, tool_label.as_str())
}
PhaseKind::Streaming => (glyph, "Streaming"),
PhaseKind::Interrupted => ("×", "Interrupted"),
PhaseKind::Subconscious => (glyph, "Subconscious"),
};
let mut content = StyledString::new();
let line = if quiet_secs >= 5 {
let wait = if quiet_secs >= 120 {
format!("still waiting {}s...", quiet_secs)
} else {
format!("waiting {}s...", quiet_secs)
};
format!(" {glyph_char} {label} · {}s · {wait}", elapsed_secs)
} else {
format!(" {glyph_char} {label} · {}s", elapsed_secs)
};
content.push_str(&line);
let len = content.text.len();
content.style_range(0..len, |s| *s = style);
if queued > 0 {
let extra = format!(" · {} queued", queued);
content.push_str(&extra);
let q_start = len;
content.style_range(q_start..content.text.len(), |s| {
*s = dim_style.italic()
});
}
self.text.set_content(content);
self.text.dirty_layout();
}
/// Clear the phase bar (hide it).
pub fn clear(&mut self) {
self.text.set_content("");
}
}

View file

@ -0,0 +1,74 @@
//! Agent portrait widget — loads and displays an agent's portrait image.
//!
//! Tries to find a portrait file at the standard location
//! (`~/.souveraine/agents/{id}/memory/assets/portrait.{png,jpg,jpeg}`).
//! Falls back to ASCII diamond art when no image is found or image
//! support isn't available.
use tuie::prelude::*;
/// A widget that shows an agent's portrait — either as a terminal image
/// (kitty/sixel/half-block) or as ASCII art.
pub struct Portrait {
inner: Box<dyn Widget>,
}
impl DelegateWidget for Portrait {
tuie::delegate_widget!(inner);
fn override_is_focusable(&self) -> bool { false }
}
impl Portrait {
/// Create a portrait for the given agent.
///
/// If `agent_id` is `Some` and a portrait file exists on disk, an
/// [`Image`] widget is used. Otherwise ASCII diamond art is rendered.
pub fn new(agent_id: Option<&str>, name: &str, color: Color) -> Box<Self> {
let inner: Box<dyn Widget> = match agent_id.and_then(|id| load_portrait_image(id)) {
Some(source) => {
// Let the Pane layout determine size — Image fills available area
// with cover-style scaling (crops to fill, no letterboxing).
let mut img = Image::new(source);
img.set_fill(true);
img.flex(1).x_align(FlexAlign::Middle)
}
None => ascii_portrait(name, color),
};
Box::new(Self { inner })
}
}
/// Try to load a portrait image file for the given agent ID.
fn load_portrait_image(agent_id: &str) -> Option<ImageSource> {
let assets_dir = dirs::home_dir()?
.join(".souveraine")
.join("agents")
.join(agent_id)
.join("memory")
.join("assets");
let path = ["portrait.png", "portrait.jpg", "portrait.jpeg"]
.iter()
.map(|s| assets_dir.join(s))
.find(|p| p.exists())?;
let bytes = std::fs::read(&path).ok()?;
ImageSource::from_encoded(bytes).ok()
}
/// ASCII art fallback — diamond shape with agent name.
fn ascii_portrait(name: &str, color: Color) -> Box<dyn Widget> {
let mut content = StyledString::new();
content.push_str("\n");
content.push_span(StyledStr::new("\n").fg(color));
content.push_span(StyledStr::new(" ◈ ◈\n").fg(color));
content.push_span(StyledStr::new(" ◈ ◈ ◈\n").fg(color));
content.push_span(StyledStr::new(" ◈◈\n").fg(color));
content.push_span(StyledStr::new(" ◈ ◈\n").fg(color));
content.push_str("\n");
content.push_span(StyledStr::new(&format!(" {name}\n")).bold().fg(color));
content.push_str("\n");
Text::new().content(content).center()
}

View file

@ -0,0 +1,254 @@
//! Responsive container — swaps between a wide and a narrow subtree.
//!
//! `Responsive` holds two child widgets: a `wide` arrangement and a `narrow`
//! one. During layout it measures the width it was allocated and lays out /
//! renders **only** the arrangement that fits, switching at `breakpoint`
//! columns. This is how the welcome screen offers two viewable modes (a
//! side-by-side dashboard when there's room, a stacked column when narrow) the
//! same way the old ratatui dashboard did — but as a reusable primitive any
//! screen can wrap around any pair of layouts.
//!
//! ## Traversal model
//!
//! Both children are exposed to [`Widget::each_child`] so that
//! [`WidgetMethods::get_widget`] lookups resolve ids in *either* arrangement —
//! a caller can keep a `WidgetId` for a widget in the narrow tree and still
//! reach it while the wide tree is showing. Focus, hit-testing, painting and
//! positioning, however, only ever touch the active child, so the inactive
//! arrangement is inert: never drawn, never focusable, never hit.
//!
//! Modelled on tuie's own `Stack` container.
use tuie::prelude::*;
/// A container that shows `wide` at or above `breakpoint` columns and `narrow`
/// below it.
pub struct Responsive {
layout: Layout,
wide: Box<dyn Widget>,
narrow: Box<dyn Widget>,
breakpoint: u16,
/// Which arrangement is currently active. Recomputed every `layout_flow`.
wide_active: bool,
}
impl Responsive {
/// Create a responsive container that switches at `breakpoint` columns.
pub fn new(breakpoint: u16, wide: Box<dyn Widget>, narrow: Box<dyn Widget>) -> Box<Self> {
Box::new(Self {
layout: Layout::new(),
wide,
narrow,
breakpoint,
wide_active: true,
})
}
/// Returns true when the wide arrangement is currently shown.
pub fn is_wide(&self) -> bool {
self.wide_active
}
fn active(&self) -> &dyn Widget {
if self.wide_active { &*self.wide } else { &*self.narrow }
}
fn active_mut(&mut self) -> &mut dyn Widget {
if self.wide_active { &mut *self.wide } else { &mut *self.narrow }
}
fn inactive(&self) -> &dyn Widget {
if self.wide_active { &*self.narrow } else { &*self.wide }
}
fn contains_pos(child: &dyn Widget, pos: Vec2<f32>) -> bool {
let cp = child.get_pos();
let cs = child.get_rect_size().map(|v| v as i32);
Axis2D::all(|a| pos[a] >= cp[a] as f32 && pos[a] < (cp[a] + cs[a]) as f32)
}
}
impl Widget for Responsive {
fn get_layout(&self) -> &Layout {
&self.layout
}
fn get_layout_mut(&mut self) -> &mut Layout {
&mut self.layout
}
fn get_name(&self) -> &'static str {
"Responsive"
}
fn get_flow_axis(&self) -> Axis2D {
self.active().get_flow_axis()
}
fn measure_constraints(&mut self) -> Constraints {
// Constrain both arrangements so either can be flowed immediately when
// the breakpoint is crossed.
constrain_child(&mut *self.wide);
constrain_child(&mut *self.narrow);
// We fill whatever space the parent offers; the active child is then
// forced to that size during `layout_flow`.
Constraints {
min_size: Vec2::of(0),
max_size: Vec2::of(u16::MAX),
preferred_size: Vec2::of(u16::MAX),
}
}
fn layout_flow(&mut self, allocated: Vec2<u16>) -> Vec2<u16> {
self.wide_active = allocated[Axis2D::X] >= self.breakpoint;
flow_child(self.active_mut(), allocated);
allocated
}
fn layout_measure(&self, allocated: Vec2<u16>) -> Vec2<u16> {
let wide = allocated[Axis2D::X] >= self.breakpoint;
let child: &dyn Widget = if wide { &*self.wide } else { &*self.narrow };
flow_child_measure(child, allocated);
allocated
}
fn layout_position(&mut self) {
let content_pos = self.layout.rect.pos;
let child = self.active_mut();
let margin = child.get_layout().get_margin_before().map(|v| v as i32);
child.set_pos(content_pos + margin);
child.layout_position();
}
fn render(&self, mut ctx: RenderContext) {
let content_pos = self.layout.rect.pos;
let child = self.active();
let offset = child.get_pos() - content_pos;
ctx.render_child(child, offset);
}
fn each_child(&self, f: &mut dyn FnMut(&dyn Widget), _direction: Sign) {
// Both children are visible to id lookups; the active one first.
f(self.active());
f(self.inactive());
}
fn each_child_mut(&mut self, f: &mut dyn FnMut(&mut dyn Widget), _direction: Sign) {
// Borrow rules: resolve the active flag before splitting the borrows.
if self.wide_active {
f(&mut *self.wide);
f(&mut *self.narrow);
} else {
f(&mut *self.narrow);
f(&mut *self.wide);
}
}
fn find_descendant(
&self,
predicate: &dyn Fn(&dyn Widget) -> bool,
mut path: Option<&mut Vec<WidgetId>>,
) -> Option<WidgetId> {
// Focus traversal only ever sees the active arrangement.
let child = self.active();
if let Some(found) = child.find_descendant(predicate, path.as_mut().map(|p| &mut **p)) {
if let Some(p) = &mut path {
p.push(child.get_id());
}
return Some(found);
}
if predicate(child) {
if let Some(p) = &mut path {
p.push(child.get_id());
}
return Some(child.get_id());
}
None
}
fn descendant_at_pos(
&self,
pos: Vec2<f32>,
mut path: Option<&mut Vec<WidgetId>>,
) -> Option<WidgetId> {
let child = self.active();
if !Self::contains_pos(child, pos) {
return None;
}
let hit = child
.descendant_at_pos(pos, path.as_mut().map(|p| &mut **p))
.unwrap_or_else(|| child.get_id());
if let Some(p) = &mut path {
p.push(child.get_id());
}
Some(hit)
}
fn find_descendant_at_pos(
&self,
pos: Vec2<f32>,
predicate: &dyn Fn(&dyn Widget) -> bool,
mut path: Option<&mut Vec<WidgetId>>,
) -> Option<WidgetId> {
let child = self.active();
if !Self::contains_pos(child, pos) {
return None;
}
if let Some(found) =
child.find_descendant_at_pos(pos, predicate, path.as_mut().map(|p| &mut **p))
{
if let Some(p) = &mut path {
p.push(child.get_id());
}
return Some(found);
}
if predicate(child) {
if let Some(p) = &mut path {
p.push(child.get_id());
}
return Some(child.get_id());
}
None
}
fn can_scroll(&self, direction: Direction2D) -> bool {
self.active().can_scroll(direction)
}
fn get_cursor(&self, selected: Option<WidgetId>) -> Option<(CursorShape, Vec2<i32>)> {
let selected = selected?;
self.layout.get_child_cursor(self.active(), selected)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tuie::test::TestTerminal;
fn label(s: &str) -> Box<dyn Widget> {
Text::new().content(s).flex(1)
}
#[test]
fn switches_arrangement_on_width() {
let mut root = Responsive::new(100, label("WIDEMODE"), label("NARROWMODE")).flex(1);
// At/above the breakpoint the wide arrangement is shown.
let mut term = TestTerminal::new(&mut *root, Vec2::new(120, 10));
let wide = term.get_snapshot_text();
assert!(wide.contains("WIDEMODE"), "expected wide arrangement, got: {wide:?}");
assert!(!wide.contains("NARROWMODE"), "narrow leaked into wide: {wide:?}");
// Shrinking below the breakpoint swaps to the narrow arrangement.
term.update(&mut *root, &[RuntimeEvent::Resize(Vec2::new(70, 10))]);
let narrow = term.get_snapshot_text();
assert!(narrow.contains("NARROWMODE"), "expected narrow arrangement, got: {narrow:?}");
assert!(!narrow.contains("WIDEMODE"), "wide leaked into narrow: {narrow:?}");
// Growing back restores the wide arrangement.
term.update(&mut *root, &[RuntimeEvent::Resize(Vec2::new(120, 10))]);
let wide_again = term.get_snapshot_text();
assert!(wide_again.contains("WIDEMODE"), "expected wide again, got: {wide_again:?}");
}
}

214
src/ui/widgets/tool_card.rs Normal file
View file

@ -0,0 +1,214 @@
//! Tool call card widget — compact and expanded tool display.
//!
//! Compact mode renders a single line: `⟳ tool_name · args_summary · r2`.
//! Expanded mode renders a full bubble with arguments and result body.
use tuie::prelude::*;
/// Whether the tool card is compact (one-line) or expanded (full bubble).
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum ToolCardMode {
Compact,
Expanded,
}
/// A card displaying a tool invocation — name, arguments, round, and result.
pub struct ToolCard {
text: Box<Text>,
name: String,
args_summary: String,
round: u32,
is_error: bool,
is_pending: bool,
mode: ToolCardMode,
glyph_style: Style,
name_style: Style,
dim_style: Style,
container_width: u16,
}
impl DelegateWidget for ToolCard {
tuie::delegate_widget!(text);
fn override_is_focusable(&self) -> bool { false }
}
impl ToolCard {
pub fn new() -> Box<Self> {
Box::new(Self {
text: Text::new(),
name: String::new(),
args_summary: String::new(),
round: 1,
is_error: false,
is_pending: true,
mode: ToolCardMode::Compact,
glyph_style: Style::new(),
name_style: Style::new(),
dim_style: Style::new(),
container_width: 120,
})
}
// ── Builder methods ──────────────────────────────────────────────────────
pub fn name(mut self: Box<Self>, name: impl Into<String>) -> Box<Self> {
self.name = name.into();
self.rebuild();
self
}
pub fn args_summary(mut self: Box<Self>, summary: impl Into<String>) -> Box<Self> {
self.args_summary = summary.into();
self.rebuild();
self
}
pub fn round(mut self: Box<Self>, r: u32) -> Box<Self> {
self.round = r;
self.rebuild();
self
}
pub fn is_error(mut self: Box<Self>, err: bool) -> Box<Self> {
self.is_error = err;
self.rebuild();
self
}
pub fn is_pending(mut self: Box<Self>, pending: bool) -> Box<Self> {
self.is_pending = pending;
self.rebuild();
self
}
pub fn mode(mut self: Box<Self>, m: ToolCardMode) -> Box<Self> {
self.mode = m;
self.rebuild();
self
}
pub fn glyph_style(mut self: Box<Self>, s: Style) -> Box<Self> {
self.glyph_style = s;
self.rebuild();
self
}
pub fn name_style(mut self: Box<Self>, s: Style) -> Box<Self> {
self.name_style = s;
self.rebuild();
self
}
pub fn dim_style(mut self: Box<Self>, s: Style) -> Box<Self> {
self.dim_style = s;
self.rebuild();
self
}
pub fn container_width(mut self: Box<Self>, w: u16) -> Box<Self> {
self.container_width = w;
self.rebuild();
self
}
// ── Mutator methods ──────────────────────────────────────────────────────
pub fn set_mode(&mut self, m: ToolCardMode) {
self.mode = m;
self.rebuild();
}
pub fn set_pending(&mut self, pending: bool) {
self.is_pending = pending;
self.rebuild();
}
pub fn set_error(&mut self, err: bool) {
self.is_error = err;
self.rebuild();
}
pub fn set_result_details(&mut self, _output: &str) {
// Store result for expanded mode rendering
self.rebuild();
}
// ── Rebuild ──────────────────────────────────────────────────────────────
fn rebuild(&mut self) {
let glyph = match (self.is_pending, self.is_error) {
(true, _) => '⟳',
(false, true) => '✗',
(false, false) => '✓',
};
match self.mode {
ToolCardMode::Compact => self.build_compact(glyph),
ToolCardMode::Expanded => self.build_expanded(glyph),
}
self.text.dirty_layout();
}
fn build_compact(&mut self, glyph: char) {
let mut content = StyledString::new();
let reserved = self.name.chars().count() + 14;
let arg_budget = (self.container_width as usize)
.saturating_sub(reserved + 6)
.max(20)
.min(120);
let args = clip(&self.args_summary, arg_budget);
// " ⟳ tool_name · args · r2"
let line = if self.round > 1 {
if args.is_empty() {
format!(" {glyph} {} · r{}", self.name, self.round)
} else {
format!(" {glyph} {} · {} · r{}", self.name, args, self.round)
}
} else {
if args.is_empty() {
format!(" {glyph} {}", self.name)
} else {
format!(" {glyph} {} · {}", self.name, args)
}
};
content.push_str(&line);
// Style the glyph (first 3 chars: " ⟳")
let glyph_end = 3.min(line.len());
content.style_range(0..glyph_end, |s| *s = self.glyph_style);
// Style the tool name (from glyph_end to " ·" or " · r")
let name_end = glyph_end + 1 + self.name.len();
let name_end = name_end.min(line.len());
content.style_range(glyph_end..name_end, |s| *s = self.name_style);
// Style the rest as dim
if name_end < line.len() {
content.style_range(name_end..line.len(), |s| *s = self.dim_style);
}
self.text.set_content(content);
}
fn build_expanded(&mut self, _glyph: char) {
// Expanded mode uses a ChatBubble-like layout with args + result body.
// For now, render as compact with a "[expanded]" marker.
let mut content = StyledString::new();
content.push_str(&format!(" [expanded] {} (r{})", self.name, self.round));
let len = content.text.len();
content.style_range(0..len, |s| *s = self.name_style);
self.text.set_content(content);
}
}
fn clip(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
}