Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-06-15 15:41:35 -04:00
commit f9122c3230
17 changed files with 173 additions and 117 deletions

View file

@ -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

View file

@ -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"));
}

View file

@ -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))
}
}

View file

@ -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")
}
}

View file

@ -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).

View file

@ -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"));
}

View file

@ -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![

View file

@ -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('⬟');

View file

@ -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 {

View file

@ -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);

View file

@ -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,

View file

@ -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
};

View file

@ -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()

View file

@ -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(),

View file

@ -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
}
}

View file

@ -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) => {

View file

@ -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);