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:
parent
a7e909d39d
commit
61bf34177e
27 changed files with 3896 additions and 9 deletions
80
Cargo.lock
generated
80
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
49
src/main.rs
49
src/main.rs
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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()?;
|
||||
|
|
|
|||
|
|
@ -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
174
src/ui/screens/agents.rs
Normal 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
408
src/ui/screens/chat.rs
Normal 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
19
src/ui/screens/cron.rs
Normal 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
13
src/ui/screens/mod.rs
Normal 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;
|
||||
19
src/ui/screens/presence.rs
Normal file
19
src/ui/screens/presence.rs
Normal 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" }
|
||||
}
|
||||
3
src/ui/screens/settings/field_grid.rs
Normal file
3
src/ui/screens/settings/field_grid.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
//! Settings field grid — builds tuie Grid cells from `fields_for_category()`.
|
||||
|
||||
// Stub — will be implemented in Phase 3.
|
||||
123
src/ui/screens/settings/mod.rs
Normal file
123
src/ui/screens/settings/mod.rs
Normal 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
465
src/ui/screens/splash.rs
Normal 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
405
src/ui/screens/welcome.rs
Normal 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
96
src/ui/theme.rs
Normal 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
564
src/ui/tuie_app.rs
Normal 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
55
src/ui/widgets/brand_title.rs
Normal file
55
src/ui/widgets/brand_title.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
248
src/ui/widgets/chat_bubble.rs
Normal file
248
src/ui/widgets/chat_bubble.rs
Normal 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
|
||||
}
|
||||
60
src/ui/widgets/chat_input.rs
Normal file
60
src/ui/widgets/chat_input.rs
Normal 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
122
src/ui/widgets/cockpit.rs
Normal 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
114
src/ui/widgets/menu_list.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
228
src/ui/widgets/message_list.rs
Normal file
228
src/ui/widgets/message_list.rs
Normal 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
15
src/ui/widgets/mod.rs
Normal 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;
|
||||
92
src/ui/widgets/phase_bar.rs
Normal file
92
src/ui/widgets/phase_bar.rs
Normal 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("");
|
||||
}
|
||||
}
|
||||
74
src/ui/widgets/portrait.rs
Normal file
74
src/ui/widgets/portrait.rs
Normal 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()
|
||||
}
|
||||
254
src/ui/widgets/responsive.rs
Normal file
254
src/ui/widgets/responsive.rs
Normal 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
214
src/ui/widgets/tool_card.rs
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue