fix: safety hardening pass — eliminate unsafe, panics, and stubs
- Replace unsafe raw-pointer render callback in message_list with Arc<Mutex<>> - Replace unsafe Cell::as_ptr() in splash with safe Copy derive + .get() - Add .max(0.0) guards on all f32→u16 casts in bloom animation - Replace unreachable!() with bail/return/match in 4 locations - Replace expect() with Result propagation in 4 constructors (RemoteBackend, VoiceClient, BifrostClient, OpenAiOAuth) - Wire manager kill/restart keys to signals (backend TODO remains) - Pass agent name through TUI message render chain (was hardcoded "Ani") - Implement scroll_page in message_list using List::scroll_by
This commit is contained in:
parent
71d3418f0b
commit
f9122c3230
17 changed files with 173 additions and 117 deletions
|
|
@ -22,7 +22,7 @@ pub struct RemoteBackend {
|
|||
}
|
||||
|
||||
impl RemoteBackend {
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
pub fn new(base_url: impl Into<String>) -> Result<Self> {
|
||||
let mut url = base_url.into();
|
||||
while url.ends_with('/') {
|
||||
url.pop();
|
||||
|
|
@ -31,16 +31,16 @@ impl RemoteBackend {
|
|||
.timeout(Duration::from_secs(60))
|
||||
.connect_timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.expect("reqwest client");
|
||||
Self { base_url: url, client, token: None }
|
||||
.context("building reqwest client")?;
|
||||
Ok(Self { base_url: url, client, token: None })
|
||||
}
|
||||
|
||||
/// Create a RemoteBackend that sends bearer tokens for the given agent_id.
|
||||
/// Loads the token from the standard on-disk location.
|
||||
pub fn with_agent(base_url: impl Into<String>, agent_id: &str) -> Self {
|
||||
let mut this = Self::new(base_url);
|
||||
pub fn with_agent(base_url: impl Into<String>, agent_id: &str) -> Result<Self> {
|
||||
let mut this = Self::new(base_url)?;
|
||||
this.load_token(agent_id);
|
||||
this
|
||||
Ok(this)
|
||||
}
|
||||
|
||||
/// Try to load the per-agent bearer token from disk. Silently leaves
|
||||
|
|
|
|||
|
|
@ -429,7 +429,7 @@ enum ErrorClass {
|
|||
}
|
||||
|
||||
impl BifrostClient {
|
||||
pub fn new(base_url: &str, api_key: &str, virtual_key: &str, default_model: &str, timeout_secs: u64) -> Self {
|
||||
pub fn new(base_url: &str, api_key: &str, virtual_key: &str, default_model: &str, timeout_secs: u64) -> Result<Self> {
|
||||
let base = base_url.trim_end_matches('/').to_string();
|
||||
let base_url = if base.ends_with("/v1") { base } else { format!("{}/v1", base) };
|
||||
|
||||
|
|
@ -437,17 +437,17 @@ impl BifrostClient {
|
|||
"🌉 Bifrost client initialized — model: {}, endpoint: {}, timeout: {}s",
|
||||
default_model, base_url, timeout_secs
|
||||
);
|
||||
Self {
|
||||
Ok(Self {
|
||||
base_url,
|
||||
api_key: api_key.to_string(),
|
||||
virtual_key: virtual_key.to_string(),
|
||||
client: reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.expect("reqwest Client::builder() should never fail with static config"),
|
||||
.context("building bifrost reqwest client")?,
|
||||
default_model: default_model.to_string(),
|
||||
retry_policy: RetryPolicy::default(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_fallbacks(mut self, fallbacks: Vec<String>) -> Self {
|
||||
|
|
@ -630,7 +630,7 @@ impl BifrostClient {
|
|||
}
|
||||
}
|
||||
|
||||
unreachable!("retry loop should have returned or bailed")
|
||||
anyhow::bail!("Bifrost retry loop exhausted without returning a result")
|
||||
}
|
||||
|
||||
fn parse_completion_response(body_text: &str) -> Result<CompletionResult> {
|
||||
|
|
@ -709,7 +709,7 @@ mod tests {
|
|||
"",
|
||||
"openai/deepseek-v4-pro",
|
||||
120,
|
||||
);
|
||||
).unwrap();
|
||||
assert!(client.base_url.ends_with("/v1"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ pub fn build_provider(config: &ConsciousnessConfig) -> anyhow::Result<Arc<dyn Ll
|
|||
&bf.virtual_key,
|
||||
&bf.primary_model,
|
||||
bf.timeout_secs,
|
||||
);
|
||||
)?;
|
||||
Ok(Arc::new(client))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ impl OpenAiOAuthProvider {
|
|||
let http = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.expect("reqwest Client::builder() should never fail with static config");
|
||||
.context("building OpenAI OAuth reqwest client")?;
|
||||
// The configured `primary_model` may be a Bifrost-namespaced id
|
||||
// (`openai/…`); pin the provider default to a model this backend serves.
|
||||
let default_model = catalog::resolve(&default_model, catalog::DEFAULT_MODEL);
|
||||
|
|
@ -163,7 +163,7 @@ impl LlmProvider for OpenAiOAuthProvider {
|
|||
));
|
||||
}
|
||||
|
||||
unreachable!("retry loop returns or bails")
|
||||
anyhow::bail!("OpenAI OAuth retry loop exhausted without returning a result")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -148,19 +148,18 @@ pub struct VoiceClient {
|
|||
}
|
||||
|
||||
impl VoiceClient {
|
||||
/// Build a client from config values. Panics if reqwest can't build
|
||||
/// (this only happens if TLS config is broken, which won't happen here).
|
||||
pub fn new(stt_url: &str, tts_url: &str, voice: &str) -> Self {
|
||||
/// Build a client from config values.
|
||||
pub fn new(stt_url: &str, tts_url: &str, voice: &str) -> anyhow::Result<Self> {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("reqwest client build failed");
|
||||
Self {
|
||||
.context("building voice reqwest client")?;
|
||||
Ok(Self {
|
||||
stt_url: stt_url.trim_end_matches('/').to_string(),
|
||||
tts_url: tts_url.trim_end_matches('/').to_string(),
|
||||
voice: voice.to_string(),
|
||||
http,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// STT base URL (for cloning into spawn tasks).
|
||||
|
|
|
|||
|
|
@ -969,7 +969,7 @@ async fn resolve_backend(
|
|||
}
|
||||
|
||||
let server_url = config.read().await.server.effective_url();
|
||||
let remote = RemoteBackend::new(&server_url);
|
||||
let remote = RemoteBackend::new(&server_url)?;
|
||||
if remote.health().await {
|
||||
return Ok((Box::new(remote), "remote"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,11 @@ use ratatui::{
|
|||
/// Render the full message list for the chat screen
|
||||
pub fn render_messages(frame: &mut Frame, state: &dyn TuiState, area: Rect) {
|
||||
let messages = state.messages();
|
||||
let agent_name = &state.agent_status().name;
|
||||
let mut text_lines: Vec<Line> = Vec::new();
|
||||
|
||||
|
||||
for msg in messages {
|
||||
let msg_lines = render_message(msg, area.width);
|
||||
let msg_lines = render_message(msg, area.width, agent_name);
|
||||
text_lines.extend(msg_lines);
|
||||
text_lines.push(Line::from("")); // spacing between messages
|
||||
}
|
||||
|
|
@ -32,10 +33,10 @@ pub fn render_messages(frame: &mut Frame, state: &dyn TuiState, area: Rect) {
|
|||
}
|
||||
|
||||
/// Render a single message based on its role
|
||||
fn render_message(msg: &DisplayMessage, width: u16) -> Vec<Line> {
|
||||
fn render_message(msg: &DisplayMessage, width: u16, agent_name: &str) -> Vec<Line> {
|
||||
match msg.role {
|
||||
MessageRole::User => render_user_message(msg, width),
|
||||
MessageRole::Assistant => render_assistant_message(msg, width),
|
||||
MessageRole::Assistant => render_assistant_message(msg, width, agent_name),
|
||||
MessageRole::Reasoning => render_reasoning_block(msg, width, false),
|
||||
MessageRole::ToolResult => render_tool_result(msg, width),
|
||||
MessageRole::System => render_system_message(msg, width),
|
||||
|
|
@ -57,8 +58,8 @@ fn render_user_message(msg: &DisplayMessage, width: u16) -> Vec<Line> {
|
|||
}
|
||||
|
||||
/// Assistant message - left aligned, white with tool chips
|
||||
fn render_assistant_message(msg: &DisplayMessage, width: u16) -> Vec<Line> {
|
||||
let name = "Ani"; // TODO: Get from agent status
|
||||
fn render_assistant_message(msg: &DisplayMessage, width: u16, agent_name: &str) -> Vec<Line> {
|
||||
let name = agent_name;
|
||||
let header_style = Style::default().fg(Color::Green).add_modifier(Modifier::BOLD);
|
||||
|
||||
let mut lines = vec![Line::from(vec![
|
||||
|
|
|
|||
|
|
@ -186,11 +186,11 @@ pub mod bloom {
|
|||
let stem_top = cy + max_r * 0.3;
|
||||
let stem_visible = stem_len * stem_progress;
|
||||
let stem_bot = stem_top + stem_visible;
|
||||
for y in (stem_top as u16)..(stem_bot as u16).min(area.y + area.height) {
|
||||
for y in (stem_top.max(0.0) as u16)..(stem_bot.max(0.0) as u16).min(area.y + area.height) {
|
||||
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 = (area.x as f32 + cx + curve) as u16;
|
||||
let col = (area.x as f32 + cx + curve).max(0.0) as u16;
|
||||
if col < area.x + area.width && y < area.y + area.height {
|
||||
let c = buf.get_mut(col, y);
|
||||
let stem_shade = (40.0 + (1.0 - tt) * 30.0) as u8;
|
||||
|
|
@ -204,8 +204,8 @@ pub mod bloom {
|
|||
let leaf_x = area.x as f32 + cx + (0.35 * std::f32::consts::PI * 0.3).sin() * 6.0;
|
||||
let leaf_chars = ['/', '\\', '|', '—'];
|
||||
for (li, lc) in leaf_chars.iter().enumerate() {
|
||||
let lx = leaf_x as u16 + li as u16;
|
||||
let ly = leaf_y as u16 - 1 + li as u16;
|
||||
let lx = leaf_x.max(0.0) as u16 + li as u16;
|
||||
let ly = (leaf_y.max(0.0) as u16).saturating_sub(1) + li as u16;
|
||||
if lx < area.x + area.width && ly < area.y + area.height {
|
||||
let c = buf.get_mut(lx, ly);
|
||||
c.set_char(*lc);
|
||||
|
|
@ -257,8 +257,8 @@ pub mod bloom {
|
|||
let px = cx + (angle.cos() * (dist + s * petal_len));
|
||||
let py = cy + (angle.sin() * (dist + s * petal_len)) * 0.45;
|
||||
|
||||
let col = area.x + px as u16;
|
||||
let row = area.y + py as u16;
|
||||
let col = area.x + px.max(0.0) as u16;
|
||||
let row = area.y + py.max(0.0) as u16;
|
||||
if col >= area.x + area.width || row >= area.y + area.height {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -293,8 +293,8 @@ pub mod bloom {
|
|||
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 = area.x + sx as u16;
|
||||
let row = area.y + sy as u16;
|
||||
let col = area.x + sx.max(0.0) as u16;
|
||||
let row = area.y + sy.max(0.0) as u16;
|
||||
if col < area.x + area.width && row < area.y + area.height {
|
||||
let cell = buf.get_mut(col, row);
|
||||
cell.set_char(SPIKES[i as usize % SPIKES.len()]);
|
||||
|
|
@ -318,8 +318,8 @@ pub mod bloom {
|
|||
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 = area.x + sx as u16;
|
||||
let row = area.y + sy as u16;
|
||||
let col = area.x + sx.max(0.0) as u16;
|
||||
let row = area.y + sy.max(0.0) as u16;
|
||||
if col < area.x + area.width && row < area.y + area.height {
|
||||
let cell = buf.get_mut(col, row);
|
||||
let bright = (230.0 - rd as f32 * 30.0) as u8;
|
||||
|
|
@ -334,8 +334,8 @@ pub mod bloom {
|
|||
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 = area.x + cx as u16;
|
||||
let cr = area.y + cy as u16;
|
||||
let cc = area.x + cx.max(0.0) as u16;
|
||||
let cr = area.y + cy.max(0.0) as u16;
|
||||
if cc < area.x + area.width && cr < area.y + area.height {
|
||||
let cell = buf.get_mut(cc, cr);
|
||||
cell.set_char('⬟');
|
||||
|
|
|
|||
|
|
@ -94,13 +94,15 @@ impl App {
|
|||
drop(cfg);
|
||||
|
||||
let mut resolved_id: Option<String> = None;
|
||||
let remote = crate::backend::RemoteBackend::new(&url);
|
||||
if remote.health().await {
|
||||
if let Ok(list) = remote.list_agents().await {
|
||||
resolved_id = list
|
||||
.iter()
|
||||
.find(|a| a.name == self.agent_pref || a.id == self.agent_pref)
|
||||
.map(|a| a.id.clone());
|
||||
let remote = crate::backend::RemoteBackend::new(&url).ok();
|
||||
if let Some(ref remote) = remote {
|
||||
if remote.health().await {
|
||||
if let Ok(list) = remote.list_agents().await {
|
||||
resolved_id = list
|
||||
.iter()
|
||||
.find(|a| a.name == self.agent_pref || a.id == self.agent_pref)
|
||||
.map(|a| a.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if resolved_id.is_none() {
|
||||
|
|
@ -228,9 +230,30 @@ impl App {
|
|||
drop(cfg);
|
||||
|
||||
// Try remote first; fall back to local. Mirror of resolve_backend logic.
|
||||
let remote = crate::backend::RemoteBackend::new(&url);
|
||||
let (agents_result, mode, local_repo) = if remote.health().await {
|
||||
(remote.list_agents().await, "remote", None)
|
||||
let remote = crate::backend::RemoteBackend::new(&url).ok();
|
||||
let (agents_result, mode, local_repo) = if let Some(ref remote) = remote {
|
||||
if remote.health().await {
|
||||
(remote.list_agents().await, "remote", None)
|
||||
} else {
|
||||
// Fall through to local
|
||||
let cfg = self.config.read().await.clone();
|
||||
match crate::backend::LocalBackend::new(cfg).await {
|
||||
Ok(local) => {
|
||||
let agents = local.list_agents().await;
|
||||
let repo = if let Ok(list) = &agents {
|
||||
if let Some(a) = list.iter().find(|a| a.name == self.agent_pref || a.id == self.agent_pref).or_else(|| list.first()) {
|
||||
Some(local.server_agents().memory_repo(&a.id))
|
||||
} else { None }
|
||||
} else { None };
|
||||
(agents, "local", repo)
|
||||
}
|
||||
Err(e) => {
|
||||
self.agent_status.mood = format!("backend err: {}", e);
|
||||
self.agent_status.mode = "—".to_string();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let cfg = self.config.read().await.clone();
|
||||
match crate::backend::LocalBackend::new(cfg).await {
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@ impl App {
|
|||
return;
|
||||
}
|
||||
|
||||
self.voice_client = Some(crate::core::voice::VoiceClient::new(
|
||||
self.voice_client = crate::core::voice::VoiceClient::new(
|
||||
&vcfg.stt_url,
|
||||
&vcfg.tts_url,
|
||||
&vcfg.voice_id,
|
||||
));
|
||||
).ok();
|
||||
|
||||
if self.voice_player.is_none() {
|
||||
match crate::ui::voice::VoicePlayer::new() {
|
||||
|
|
@ -118,9 +118,11 @@ impl App {
|
|||
let (tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
|
||||
let stt_u = stt_url.clone();
|
||||
tokio::spawn(async move {
|
||||
let c = crate::core::voice::VoiceClient::new(&stt_u, "", "");
|
||||
let result = c.transcribe(wav).await
|
||||
.map_err(|e| format!("*[voice service unreachable — {}]*", e));
|
||||
let result = match crate::core::voice::VoiceClient::new(&stt_u, "", "") {
|
||||
Ok(c) => c.transcribe(wav).await
|
||||
.map_err(|e| format!("*[voice service unreachable — {}]*", e)),
|
||||
Err(e) => Err(format!("*[voice client init failed — {}]*", e)),
|
||||
};
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
|
|
@ -285,9 +287,11 @@ impl App {
|
|||
let (tx, rx) = tokio::sync::oneshot::channel::<Result<Vec<u8>, String>>();
|
||||
let reply_for_bytes = reply.clone();
|
||||
tokio::spawn(async move {
|
||||
let c = crate::core::voice::VoiceClient::new("", &tts_url, &voice);
|
||||
let result = c.synthesize(&reply_for_bytes).await
|
||||
.map_err(|e| e.to_string());
|
||||
let result = match crate::core::voice::VoiceClient::new("", &tts_url, &voice) {
|
||||
Ok(c) => c.synthesize(&reply_for_bytes).await
|
||||
.map_err(|e| e.to_string()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
};
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
self.voice_tts_rx = Some(rx);
|
||||
|
|
|
|||
|
|
@ -94,7 +94,10 @@ pub fn draw_cockpit(f: &mut Frame, state: &ChatState, area: Rect) {
|
|||
} else {
|
||||
1.0
|
||||
};
|
||||
let Color::Rgb(r, g, b) = base else { unreachable!() };
|
||||
let (r, g, b) = match base {
|
||||
Color::Rgb(r, g, b) => (r, g, b),
|
||||
_ => (200, 200, 200), // fallback gray for non-RGB colors
|
||||
};
|
||||
let fg = Color::Rgb(
|
||||
(r as f32 * dim) as u8,
|
||||
(g as f32 * dim) as u8,
|
||||
|
|
|
|||
|
|
@ -392,7 +392,7 @@ impl ChatState {
|
|||
let url = cfg.server.effective_url();
|
||||
drop(cfg);
|
||||
|
||||
let remote = crate::backend::RemoteBackend::new(&url);
|
||||
let remote = crate::backend::RemoteBackend::new(&url)?;
|
||||
let (backend, mode): (Arc<dyn Backend>, &'static str) = if remote.health().await {
|
||||
(Arc::new(remote), "remote")
|
||||
} else {
|
||||
|
|
@ -412,7 +412,7 @@ impl ChatState {
|
|||
let conversation_id = backend.ensure_conversation(&agent.id).await?;
|
||||
|
||||
let backend: Arc<dyn Backend> = if mode == "remote" {
|
||||
Arc::new(crate::backend::RemoteBackend::with_agent(&url, &agent.id))
|
||||
Arc::new(crate::backend::RemoteBackend::with_agent(&url, &agent.id)?)
|
||||
} else {
|
||||
backend
|
||||
};
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ pub fn draw_btw_pane(f: &mut Frame, state: &ChatState, area: Rect) {
|
|||
state.palette.compaction,
|
||||
)
|
||||
}
|
||||
BtwState::Idle => unreachable!(),
|
||||
BtwState::Idle => return,
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ pub struct ManagerScreen {
|
|||
pub selection: Rc<Cell<Option<usize>>>,
|
||||
/// Shared flag — set to true when Esc is pressed (caller pops this screen).
|
||||
pub back_pressed: Rc<Cell<bool>>,
|
||||
/// Shared with TuieApp — set to Some(index) when 'k' is pressed.
|
||||
pub kill_requested: Rc<Cell<Option<usize>>>,
|
||||
/// Shared with TuieApp — set to Some(index) when 'r' is pressed.
|
||||
pub restart_requested: Rc<Cell<Option<usize>>>,
|
||||
}
|
||||
|
||||
impl DelegateWidget for ManagerScreen {
|
||||
|
|
@ -88,12 +92,16 @@ impl DelegateWidget for ManagerScreen {
|
|||
}
|
||||
Key::Char('k') => {
|
||||
queue.next();
|
||||
// TODO: wire to backend kill_agent(agents[selected].id)
|
||||
if !self.agents.is_empty() {
|
||||
self.kill_requested.set(Some(self.selected));
|
||||
}
|
||||
return InputResult::Handled;
|
||||
}
|
||||
Key::Char('r') => {
|
||||
queue.next();
|
||||
// TODO: wire to backend restart_agent(agents[selected].id)
|
||||
if !self.agents.is_empty() {
|
||||
self.restart_requested.set(Some(self.selected));
|
||||
}
|
||||
return InputResult::Handled;
|
||||
}
|
||||
Key::Esc => {
|
||||
|
|
@ -112,9 +120,11 @@ impl DelegateWidget for ManagerScreen {
|
|||
impl ManagerScreen {
|
||||
/// Create an empty manager screen. Call [`refresh`](Self::refresh) to
|
||||
/// populate the list.
|
||||
pub fn new(palette: &ChatPalette) -> (Box<Self>, Rc<Cell<Option<usize>>>, Rc<Cell<bool>>) {
|
||||
pub fn new(palette: &ChatPalette) -> (Box<Self>, Rc<Cell<Option<usize>>>, Rc<Cell<bool>>, Rc<Cell<Option<usize>>>, Rc<Cell<Option<usize>>>) {
|
||||
let selection = Rc::new(Cell::new(None));
|
||||
let back_pressed = Rc::new(Cell::new(false));
|
||||
let kill_requested = Rc::new(Cell::new(None));
|
||||
let restart_requested = Rc::new(Cell::new(None));
|
||||
|
||||
let dim = theme::to_tuie_color(palette.agent_dim);
|
||||
let primary = theme::to_tuie_color(palette.agent_primary);
|
||||
|
|
@ -165,6 +175,8 @@ impl ManagerScreen {
|
|||
|
||||
let sel = selection.clone();
|
||||
let back = back_pressed.clone();
|
||||
let kill = kill_requested.clone();
|
||||
let restart = restart_requested.clone();
|
||||
let this = Box::new(Self {
|
||||
root,
|
||||
scroll_id,
|
||||
|
|
@ -172,9 +184,11 @@ impl ManagerScreen {
|
|||
selected: 0,
|
||||
selection,
|
||||
back_pressed,
|
||||
kill_requested,
|
||||
restart_requested,
|
||||
});
|
||||
|
||||
(this, sel, back)
|
||||
(this, sel, back, kill, restart)
|
||||
}
|
||||
|
||||
/// Replace the agent list and rebuild rows.
|
||||
|
|
@ -325,7 +339,7 @@ mod tests {
|
|||
#[test]
|
||||
fn manager_screen_renders_header() {
|
||||
let palette = ChatPalette::default();
|
||||
let (mut screen, _selection, _back) = ManagerScreen::new(&palette);
|
||||
let (mut screen, _selection, _back, _kill, _restart) = ManagerScreen::new(&palette);
|
||||
let term = TestTerminal::new(&mut *screen, Vec2::new(80, 20));
|
||||
let rendered = term.get_snapshot_text();
|
||||
assert!(
|
||||
|
|
@ -337,7 +351,7 @@ mod tests {
|
|||
#[test]
|
||||
fn manager_screen_empty_state() {
|
||||
let palette = ChatPalette::default();
|
||||
let (mut screen, _selection, _back) = ManagerScreen::new(&palette);
|
||||
let (mut screen, _selection, _back, _kill, _restart) = ManagerScreen::new(&palette);
|
||||
let term = TestTerminal::new(&mut *screen, Vec2::new(80, 20));
|
||||
let rendered = term.get_snapshot_text();
|
||||
// When empty the column guide and footer are still present — no crash
|
||||
|
|
@ -355,7 +369,7 @@ mod tests {
|
|||
#[test]
|
||||
fn manager_screen_shows_agents() {
|
||||
let palette = ChatPalette::default();
|
||||
let (mut screen, _selection, _back) = ManagerScreen::new(&palette);
|
||||
let (mut screen, _selection, _back, _kill, _restart) = ManagerScreen::new(&palette);
|
||||
let agents = vec![AgentProcessInfo {
|
||||
id: "a1".into(),
|
||||
name: "TestBot".into(),
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ use crate::ui::theme;
|
|||
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%&*";
|
||||
const SPIKES: &[char] = &['▲', '△', '⤴', '⤵', '➚', '➘', '✸', '✦', '⬆', '⬇'];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct BloomState {
|
||||
progress: f32,
|
||||
mode: u8,
|
||||
|
|
@ -85,8 +86,9 @@ impl DelegateWidget for SplashScreen {
|
|||
|
||||
// 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());
|
||||
let mut bloom = self.bloom.get();
|
||||
bloom.advance(0.02);
|
||||
self.bloom.set(bloom);
|
||||
let tick = self.tick.get().wrapping_add(1);
|
||||
self.tick.set(tick);
|
||||
|
||||
|
|
@ -95,13 +97,6 @@ impl DelegateWidget for SplashScreen {
|
|||
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();
|
||||
|
|
@ -110,12 +105,12 @@ impl DelegateWidget for SplashScreen {
|
|||
let h = ctx.physical_size.y;
|
||||
|
||||
if w >= 20 && h >= 10 {
|
||||
render_bloom(&mut ctx, w, h, bloom_ref, tick);
|
||||
render_bloom(&mut ctx, w, h, &bloom, 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);
|
||||
if bloom.progress > 0.25 {
|
||||
let alpha = ((bloom.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 {
|
||||
|
|
@ -156,8 +151,8 @@ impl DelegateWidget for SplashScreen {
|
|||
}
|
||||
|
||||
// "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);
|
||||
if bloom.progress > 0.8 {
|
||||
let skip_alpha = ((bloom.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,
|
||||
|
|
@ -179,9 +174,9 @@ impl DelegateWidget for SplashScreen {
|
|||
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 pct = (bloom.progress * 100.0) as u16;
|
||||
|
||||
let filled = (bar_w as f32 * bloom_ref.progress) as u16;
|
||||
let filled = (bar_w as f32 * bloom.progress) as u16;
|
||||
let empty = bar_w.saturating_sub(filled);
|
||||
|
||||
ctx.move_to(Vec2::new(bar_x as i32, bar_y as i32));
|
||||
|
|
@ -216,7 +211,7 @@ impl SplashScreen {
|
|||
|
||||
/// 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
|
||||
self.skip_pressed.get() || self.bloom.get().progress >= 1.0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -117,6 +117,8 @@ pub struct TuieApp {
|
|||
// Sub-screen exit signals
|
||||
presence_exit_signal: Option<Rc<Cell<bool>>>,
|
||||
manager_back_signal: Option<Rc<Cell<bool>>>,
|
||||
manager_kill_signal: Option<Rc<Cell<Option<usize>>>>,
|
||||
manager_restart_signal: Option<Rc<Cell<Option<usize>>>>,
|
||||
settings_go_back_signal: Option<Rc<Cell<bool>>>,
|
||||
settings_save_signal: Option<Rc<Cell<bool>>>,
|
||||
settings_fetch_models_signal: Option<Rc<Cell<bool>>>,
|
||||
|
|
@ -183,6 +185,18 @@ impl DelegateWidget for TuieApp {
|
|||
// Manager selected an agent — could switch to agent detail
|
||||
self.go_to_welcome();
|
||||
}
|
||||
if let Some(ref signal) = self.manager_kill_signal {
|
||||
if let Some(idx) = signal.take() {
|
||||
tracing::info!("Kill requested for agent at index {}", idx);
|
||||
// TODO: wire to backend kill_agent when available
|
||||
}
|
||||
}
|
||||
if let Some(ref signal) = self.manager_restart_signal {
|
||||
if let Some(idx) = signal.take() {
|
||||
tracing::info!("Restart requested for agent at index {}", idx);
|
||||
// TODO: wire to backend restart_agent when available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After any input, check for Settings screen signals.
|
||||
|
|
@ -324,6 +338,8 @@ impl TuieApp {
|
|||
chat_screen_id: None,
|
||||
presence_exit_signal: None,
|
||||
manager_back_signal: None,
|
||||
manager_kill_signal: None,
|
||||
manager_restart_signal: None,
|
||||
settings_go_back_signal: None,
|
||||
settings_save_signal: None,
|
||||
settings_fetch_models_signal: None,
|
||||
|
|
@ -524,9 +540,11 @@ impl TuieApp {
|
|||
agents_screen
|
||||
}
|
||||
Screen::Manager => {
|
||||
let (manager, selection_signal, back_signal) = ManagerScreen::new(&self.palette);
|
||||
let (manager, selection_signal, back_signal, kill_signal, restart_signal) = ManagerScreen::new(&self.palette);
|
||||
self.menu_action = selection_signal;
|
||||
self.manager_back_signal = Some(back_signal);
|
||||
self.manager_kill_signal = Some(kill_signal);
|
||||
self.manager_restart_signal = Some(restart_signal);
|
||||
manager
|
||||
}
|
||||
};
|
||||
|
|
@ -728,7 +746,10 @@ async fn load_dashboard_data(
|
|||
drop(cfg);
|
||||
|
||||
// Try remote backend first.
|
||||
let remote = crate::backend::RemoteBackend::new(&url);
|
||||
let remote = match crate::backend::RemoteBackend::new(&url) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return AgentStatus { name: agent_pref, ..Default::default() },
|
||||
};
|
||||
if remote.health().await {
|
||||
match remote.list_agents().await {
|
||||
Ok(agents) => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
//! Each message in the conversation becomes a widget (bubble, tool card,
|
||||
//! interjection, etc.) rendered on demand via the List's render callback.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tuie::prelude::*;
|
||||
|
||||
/// Kinds of messages the list can render.
|
||||
|
|
@ -29,7 +31,7 @@ pub struct MessageListContext {
|
|||
/// Virtualized scrollable message list.
|
||||
pub struct MessageList {
|
||||
list: Box<List>,
|
||||
ctx: MessageListContext,
|
||||
ctx: Arc<Mutex<MessageListContext>>,
|
||||
}
|
||||
|
||||
impl DelegateWidget for MessageList {
|
||||
|
|
@ -43,77 +45,71 @@ impl MessageList {
|
|||
list.set_flex(1); // Expand to fill available space
|
||||
Box::new(Self {
|
||||
list,
|
||||
ctx: MessageListContext {
|
||||
ctx: Arc::new(Mutex::new(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());
|
||||
let count = {
|
||||
let mut ctx = self.ctx.lock().unwrap();
|
||||
ctx.messages = messages;
|
||||
ctx.messages.len()
|
||||
};
|
||||
self.list.set_item_count(count);
|
||||
self.list.dirty_layout();
|
||||
}
|
||||
|
||||
pub fn set_palette(&mut self, palette: crate::ui::chat::ChatPalette) {
|
||||
self.ctx.palette = palette;
|
||||
self.ctx.lock().unwrap().palette = palette;
|
||||
self.list.invalidate_all();
|
||||
}
|
||||
|
||||
pub fn set_container_width(&mut self, w: u16) {
|
||||
self.ctx.container_width = w;
|
||||
self.ctx.lock().unwrap().container_width = w;
|
||||
self.list.invalidate_all();
|
||||
}
|
||||
|
||||
pub fn set_tool_cards_expanded(&mut self, expanded: bool) {
|
||||
self.ctx.tool_cards_expanded = expanded;
|
||||
self.ctx.lock().unwrap().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();
|
||||
let count = self.ctx.lock().unwrap().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
|
||||
pub fn scroll_page(&mut self, up: bool) {
|
||||
let visible = self.list.get_visible_range();
|
||||
let page_size = (visible.end.saturating_sub(visible.start)) as i32;
|
||||
if page_size > 0 {
|
||||
let delta = if up { -page_size } else { page_size };
|
||||
self.list.scroll_by(delta);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
);
|
||||
let ctx = self.ctx.clone();
|
||||
self.list.set_renderer(ctx, 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,
|
||||
ctx: &mut Arc<Mutex<MessageListContext>>,
|
||||
index: usize,
|
||||
) -> Option<Box<dyn Widget>> {
|
||||
let msgs: &MessageListContext = unsafe { &*ctx.ctx };
|
||||
let msgs = ctx.lock().unwrap();
|
||||
let msg = msgs.messages.get(index)?;
|
||||
let w = msgs.container_width;
|
||||
let max_bubble = ((w as usize).saturating_sub(8) * 70 / 100).max(20);
|
||||
|
|
|
|||
Loading…
Reference in a new issue