agent_tui/tui/mod.rs
1//! Chat TUI binary — event loop, terminal setup, module wiring.
2
3mod app;
4mod commands;
5mod draw;
6mod gamba;
7mod help_find;
8mod helpers;
9mod highlight;
10mod input;
11mod lifecycle;
12mod lightbox;
13mod markdown;
14mod models;
15mod plugins;
16mod render;
17mod render_model;
18mod render_thread;
19mod settings;
20mod sidecar;
21mod signals;
22mod stream_handler;
23mod theme;
24mod toast;
25mod viewport;
26
27use app::{App, ChatMessage, THINKING_PLACEHOLDER};
28use commands::CommandAction;
29use draw::{boot_effect, build_render_model, quit_effect};
30use helpers::{apply_setting, fetch_usage, rebuild_display_messages};
31use input::InputAction;
32use lifecycle::setup_terminal;
33use render_thread::spawn_render_thread;
34use stream_handler::StreamAction;
35
36use crossterm::event::EventStream;
37use futures::StreamExt;
38use serde_json::json;
39use std::sync::atomic::Ordering;
40use std::time::Instant;
41use synaps_cli::core::session_index::SessionIndexRecord;
42use synaps_cli::runtime::compaction::compact_conversation;
43use synaps_cli::{CancellationToken, Result, Runtime, Session, StreamEvent};
44
45pub async fn run(
46 continue_session: Option<Option<String>>,
47 system: Option<String>,
48 profile: Option<String>,
49 no_extensions: bool,
50) -> Result<()> {
51 // ── Engine boot ──
52 let boot = synaps_cli::engine::setup::boot(synaps_cli::engine::setup::EngineOpts {
53 continue_session: continue_session.clone(),
54 system,
55 profile,
56 no_extensions,
57 })
58 .await?;
59
60 let mut runtime = boot.runtime;
61 let mut config = boot.config;
62 let registry = boot.registry;
63 let keybind_registry = boot.keybind_registry;
64 let mcp_server_count = boot.mcp_server_count;
65 let system_prompt_path = boot.system_prompt_path;
66
67 // Build App from engine boot results
68 let mut app = if boot.continued {
69 let mut app = App::new(boot.session.clone());
70 app.api_messages = boot.api_messages;
71 app.total_input_tokens = boot.total_input_tokens;
72 app.total_output_tokens = boot.total_output_tokens;
73 app.session_cost = boot.session_cost;
74 app.abort_context = boot.abort_context;
75 // mem::take avoids deep-cloning the full history just to satisfy
76 // the borrow checker (P5 in REVIEW.md).
77 let msgs = std::mem::take(&mut app.api_messages);
78 rebuild_display_messages(&msgs, &mut app);
79 app.api_messages = msgs;
80 app.push_msg(ChatMessage::System(format!(
81 "resumed session {}",
82 boot.session.id
83 )));
84 if let Some(ref info) = boot.continue_info {
85 if let Some(ref via) = info.resolved_via {
86 app.push_msg(ChatMessage::System(format!(
87 " ↳ resolved via {} '{}'",
88 via, info.query
89 )));
90 }
91 }
92 if app.abort_context.is_some() {
93 app.push_msg(ChatMessage::System(
94 "⚠ abort context from previous session will be injected into next message"
95 .to_string(),
96 ));
97 }
98 app
99 } else {
100 App::new(boot.session)
101 };
102 app.keybinds = Some(keybind_registry.clone());
103 app.last_turn_context_window = runtime.context_window();
104
105 // Surface config parse warnings once at startup (unknown keys, bad values).
106 for w in &config.warnings {
107 app.push_msg(ChatMessage::System(format!("⚠ config: {}", w)));
108 }
109
110 // First-run guidance: no Anthropic credentials and no provider keys means
111 // the first message will fail — tell the user up front instead.
112 {
113 let has_anthropic = synaps_cli::auth::load_auth()
114 .ok()
115 .flatten()
116 .map(|a| a.anthropic.auth_type == "oauth" && !a.anthropic.access.is_empty())
117 .unwrap_or(false)
118 || std::env::var("ANTHROPIC_API_KEY").is_ok();
119 if !has_anthropic && config.provider_keys.is_empty() {
120 app.push_msg(ChatMessage::System(
121 "👋 No credentials found. To get started:\n • `synaps login` — sign in with Claude Pro/Max (OAuth)\n • or set ANTHROPIC_API_KEY in your environment\n • or add `provider.<name> = <key>` to ~/.synaps-cli/config (groq, openrouter, …) and pick with /model".to_string(),
122 ));
123 }
124 }
125
126 if mcp_server_count > 0 {
127 tracing::info!(
128 "{} MCP servers available (use connect_mcp_server to activate)",
129 mcp_server_count
130 );
131 }
132
133 // ── Terminal setup + render thread ──
134 //
135 // The Terminal is moved into the render thread immediately after creation.
136 // The main task never touches it again. All terminal I/O (draw, clear,
137 // teardown) goes through `render_handle`.
138 //
139 // Terminal size for build_render_model: we call crossterm::terminal::size()
140 // directly — it reads the TTY fd without needing the Terminal object.
141 // See render_thread.rs module comment for the design rationale.
142 let terminal = setup_terminal()?;
143 let (render_handle, boot_done, exit_done) = spawn_render_thread(terminal);
144 // Boot effect is sent via the command channel so the render thread owns it.
145 render_handle.send_boot_fx(boot_effect());
146
147 let mut event_reader = EventStream::new();
148 let (shutdown_signal_tx, mut shutdown_signal_rx) = tokio::sync::mpsc::unbounded_channel();
149 let shutdown_signal_task = signals::spawn_shutdown_signal_task(shutdown_signal_tx);
150 let mut stream: Option<std::pin::Pin<Box<dyn futures::Stream<Item = StreamEvent> + Send>>> =
151 None;
152 let (secret_prompt_tx, secret_prompt_rx) = tokio::sync::mpsc::unbounded_channel();
153 let secret_prompt_handle = synaps_cli::tools::SecretPromptHandle::new(secret_prompt_tx);
154 let secret_prompt_rx = std::sync::Arc::new(std::sync::Mutex::new(secret_prompt_rx));
155 let mut secret_prompts = synaps_cli::tools::SecretPromptQueue::new();
156 let mut cancel_token: Option<CancellationToken> = None;
157 let mut steer_tx: Option<tokio::sync::mpsc::UnboundedSender<String>> = None;
158
159 // ── Engine-managed background tasks (inbox watcher, socket, extensions) ──
160 let background = boot.background;
161 let ext_mgr_shared = boot.ext_manager;
162
163 // Legacy sidecar key migration
164 migrate_sidecar_toggle_key_to_claimed_plugins(®istry.lifecycle_claims());
165
166 if !boot.no_extensions {
167 app.extension_loader_running = true;
168 app.toasts.upsert(
169 toast::Toast::new("extension-loader", "Discovering extensions…")
170 .titled("Extensions")
171 .at(toast::ToastPosition::TOP_CENTER)
172 .ttl(None),
173 );
174 synaps_cli::extensions::loader::spawn_discover_and_load(
175 std::sync::Arc::clone(&ext_mgr_shared),
176 app.extension_loader_tx.clone(),
177 );
178 }
179
180 // on_session_start hook already fired by engine::setup::boot()
181
182 // ── Event loop ──
183 // Track whether the render thread currently has an active boot or exit
184 // effect. The render thread owns the actual Effect values; we track
185 // "has been sent and not yet done" on the main side for the tick throttle.
186 let mut boot_fx_sent = true; // boot_effect() is sent at startup above
187 let mut exit_fx_sent = false;
188 let mut last_draw = Instant::now() - std::time::Duration::from_secs(1);
189 loop {
190 // Only draw when something actually changed. During streaming, coalesce
191 // redraws to ~10fps — deltas (and the spinner) arrive far faster than the
192 // eye can read, and rebuilding/republishing the whole RenderModel per
193 // frame is what burns a core (#131: ~60-69% of a core at 60fps; the
194 // spinner only needs ~10fps). The `!app.streaming` short-circuit below
195 // still renders the final/idle frame immediately, so end-of-turn state
196 // never lags. (Was 16ms/60fps; before that 33ms/30fps.)
197 let throttle = std::time::Duration::from_millis(100);
198 if app.needs_redraw && (!app.streaming || last_draw.elapsed() >= throttle) {
199 // Terminal lives on the render thread — get size via the crossterm
200 // TTY syscall directly (doesn't need the Terminal object).
201 // Skip the frame entirely if the reported size is 0×0 (terminal not
202 // yet ready, or a transient resize event) — publishing a 0×0 model
203 // would produce layout artifacts.
204 let term_size = match crossterm::terminal::size() {
205 Ok((w, h)) if w > 0 && h > 0 => ratatui::layout::Size { width: w, height: h },
206 _ => continue,
207 };
208 app.needs_redraw = false;
209 last_draw = Instant::now();
210 if let Some(model) = build_render_model(
211 &mut app,
212 &runtime,
213 ®istry,
214 &secret_prompts,
215 term_size,
216 ) {
217 render_handle.publish(model);
218 }
219 }
220
221 tokio::select! {
222
223 // ── OS shutdown signals: Ctrl-C from terminal, SIGTERM from systemd/tmux/SSH ──
224 signal = shutdown_signal_rx.recv() => {
225 if let Some(signal) = signal {
226 tracing::info!(signal = signals::signal_label(signal), "chat UI shutdown signal received");
227 // All OS signals map to ImmediateExit (see signals.rs).
228 // The /quit command sends SpawnExitFx to the render thread
229 // and does NOT go through this path, so removing AnimatedExit
230 // from signals does not affect interactive quit.
231 let signals::ShutdownAction::ImmediateExit = signals::shutdown_action(signal);
232 tracing::info!("immediate exit on {:?}", signal);
233 // Cancel any in-flight stream so the tool/subagent is not
234 // orphaned for the full watchdog window.
235 if let Some(ref ct) = cancel_token { ct.cancel(); }
236 // Abort any in-flight compaction so it doesn't hold state
237 // open past the teardown budget.
238 if let Some(ref h) = app.compact_task { h.abort(); }
239 // Fall through to unified bounded-teardown below the loop.
240 break;
241 }
242 }
243
244 // ── Ping results — fires when a model ping completes ──
245 result = app.ping_rx.recv() => {
246 match result {
247 Some((key, status, ms)) => {
248 if app.ping_print {
249 let detail = match status {
250 synaps_cli::runtime::openai::ping::PingStatus::Online => format!("{}ms", ms),
251 synaps_cli::runtime::openai::ping::PingStatus::RateLimited => "429 rate limited".to_string(),
252 synaps_cli::runtime::openai::ping::PingStatus::Unauthorized => "401 unauthorized".to_string(),
253 synaps_cli::runtime::openai::ping::PingStatus::NotFound => "404 not found".to_string(),
254 synaps_cli::runtime::openai::ping::PingStatus::Timeout => "timeout".to_string(),
255 synaps_cli::runtime::openai::ping::PingStatus::Error => "error".to_string(),
256 };
257 app.push_msg(ChatMessage::System(format!(" {} {:<50} — {}", status.icon(), key, detail)));
258 app.ping_pending = app.ping_pending.saturating_sub(1);
259 if app.ping_pending == 0 {
260 app.ping_print = false;
261 }
262 }
263 app.model_health.insert(key, (status, ms));
264 app.request_redraw();
265 }
266 None => {
267 // All ping tasks done (tx dropped) — stop printing
268 app.ping_print = false;
269 }
270 }
271 }
272
273 // ── Expanded model-list results ──
274 result = app.model_list_rx.recv() => {
275 if let Some((provider_key, models_result)) = result {
276 if let Some(state) = app.models.as_mut() {
277 models::set_expanded_models(state, &provider_key, models_result);
278 }
279 app.request_redraw();
280 }
281 }
282
283 // ── Async extension loader progress ──
284 event = app.extension_loader_rx.recv(), if app.extension_loader_running => {
285 if let Some(event) = event {
286 handle_extension_loader_event(&mut app, &runtime, event, &ext_mgr_shared).await;
287 } else {
288 app.extension_loader_running = false;
289 app.toasts.dismiss("extension-loader");
290 }
291 app.request_redraw();
292 }
293
294 // ── Widget events from background extension notification watchers ──
295 Some(widget_event) = app.widget_rx.recv() => {
296 // Only redraw when the widget's VISIBLE content actually changed.
297 // Plugins (d20/jawz-widget/synaps-tasks) re-send unchanged widgets
298 // on a poll loop; redrawing on every one pinned the render loop at
299 // ~30% CPU at idle (#119). The dirty-check in upsert/dismiss makes an
300 // idle session genuinely idle.
301 if handle_widget_event(&mut app, widget_event) {
302 app.request_redraw();
303 }
304 }
305
306 // ── Sidecar events — multiplexed across all hosted sidecars (Phase 8 8B) ──
307 sidecar_event = async {
308 if app.sidecars.is_empty() {
309 let _: () = std::future::pending().await;
310 unreachable!()
311 } else {
312 // Collect (plugin_id, &mut manager) and race them.
313 let mut futures = Vec::with_capacity(app.sidecars.len());
314 for (pid, v) in app.sidecars.iter_mut() {
315 let pid = pid.clone();
316 futures.push(Box::pin(async move {
317 let ev = v.manager.next_event().await;
318 (pid, ev)
319 }));
320 }
321 let ((pid, ev), _, _) = futures::future::select_all(futures).await;
322 (pid, ev)
323 }
324 } => {
325 let (pid, sidecar_event) = sidecar_event;
326 if let Some(event) = sidecar_event {
327 self::sidecar::handle_event(&mut app, &pid, event);
328 app.request_redraw();
329 }
330 }
331
332 // ── Event bus wake — fires instantly when an event is pushed to the queue ──
333 _ = runtime.event_queue().notified() => {
334 let mut event_received = false;
335 while let Some(event) = runtime.event_queue().pop() {
336 event_received = true;
337 let formatted = synaps_cli::events::format_event_for_agent(&event);
338 let severity_str = event.content.severity
339 .as_ref()
340 .map(|s| s.as_str().to_string())
341 .unwrap_or_else(|| "medium".to_string());
342 app.push_msg(ChatMessage::Event {
343 source: event.source.source_type.clone(),
344 severity: severity_str,
345 text: event.content.text.clone(),
346 });
347
348 if app.streaming || app.compact_task.is_some() {
349 // Steer into active stream if possible, otherwise buffer
350 let steered = steer_tx.as_ref()
351 .map(|tx| tx.send(formatted.clone()).is_ok())
352 .unwrap_or(false);
353 if !steered {
354 app.pending_events.push(formatted);
355 }
356 } else {
357 app.api_messages.push(serde_json::json!({
358 "role": "user",
359 "content": formatted
360 }));
361 }
362 app.invalidate();
363 }
364
365 // Auto-trigger model turn when idle — only if we actually received events
366 if event_received && !app.streaming && stream.is_none() && app.compact_task.is_none() && !app.api_messages.is_empty() {
367 if let Some(last) = app.api_messages.last() {
368 if last["role"].as_str() == Some("user") {
369 let ct = CancellationToken::new();
370 let (s_tx, s_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
371 app.streaming = true;
372 app.spinner_frame = 0;
373 stream = Some(runtime.run_stream_with_messages(app.api_messages.clone(), ct.clone(), Some(s_rx), Some(secret_prompt_handle.clone()), false).await);
374 app.push_msg(ChatMessage::Thinking(THINKING_PLACEHOLDER.to_string()));
375 cancel_token = Some(ct);
376 steer_tx = Some(s_tx);
377 }
378 }
379 }
380 }
381
382 // ── Tick: animations + spinner (~60fps when active) ──
383 _ = tokio::time::sleep(std::time::Duration::from_millis(16)), if boot_fx_sent || exit_fx_sent || app.streaming || app.compact_task.is_some() || app.messages.is_empty() || app.logo_dismiss_t.is_some() || app.logo_build_t.is_some() || app.gamba_child.is_some() || secret_prompts.is_active() || !app.toasts.is_empty() || app.plugins.as_ref().is_some_and(|p| p.is_install_active()) => {
384 // Active animations/effects always need a redraw each tick.
385 // messages.is_empty() = idle logo screen — its color gradient
386 // is time-based and needs ticking too (S206 regression: the
387 // dirty-flag loop froze it until first keystroke).
388 // Update local effect-sent flags from the render thread's done signals.
389 if boot_fx_sent && boot_done.load(Ordering::Acquire) {
390 boot_fx_sent = false;
391 }
392 if exit_fx_sent || boot_fx_sent || app.streaming || app.logo_build_t.is_some() || app.logo_dismiss_t.is_some() || app.gamba_child.is_some() || app.messages.is_empty() {
393 app.request_redraw();
394 }
395 secret_prompts.poll_requests(&secret_prompt_rx);
396 if app.toasts.tick() {
397 app.invalidate();
398 }
399 // Tick the in-flight plugin install spinner and reap the
400 // background clone task once it finishes.
401 let mut install_did_work = false;
402 let mut install_finished = false;
403 if let Some(plugins_state) = app.plugins.as_mut() {
404 if plugins_state.is_install_active() {
405 plugins_state.tick_install_spinner();
406 install_did_work = true;
407 if plugins_state.install_ready_to_reap() {
408 install_finished = true;
409 }
410 }
411 }
412 if install_finished {
413 if let Some(plugins_state) = app.plugins.as_mut() {
414 self::plugins::actions::complete_pending_install_clone(
415 plugins_state, ®istry, &config,
416 ).await;
417 }
418 }
419 if install_did_work || install_finished {
420 app.invalidate();
421 }
422 let message_animation_needs_clear = app.needs_clear_for_animation_redraw();
423 if message_animation_needs_clear
424 && crossterm::terminal::size().is_ok_and(|(w, h)| w > 0 && h > 0) {
425 render_handle.send_clear();
426 }
427 if let Some(ref mut t) = app.logo_build_t {
428 *t += 0.025;
429 if *t >= 1.0 { app.logo_build_t = None; }
430 app.request_redraw();
431 }
432 if let Some(ref mut t) = app.logo_dismiss_t {
433 *t += 0.04;
434 if *t >= 1.0 { app.logo_dismiss_t = None; }
435 app.request_redraw();
436 }
437 if app.advance_animations() {
438 // Spinner ticks only affect the tail message (THINKING_PLACEHOLDER,
439 // active tool animation). Mark just the last slot dirty instead of
440 // full invalidation — O(1) instead of O(n) per frame.
441 app.invalidate_last();
442 }
443 if let Some(msg) = app.check_gamba_exited() {
444 // check_gamba_exited() already called restore_terminal();
445 // resume the render thread now that we own the terminal again.
446 render_handle.resume();
447 app.push_msg(ChatMessage::System(msg));
448 app.invalidate(); // invalidate already sets needs_redraw
449 }
450 // Poll background compaction task
451 if app.compact_task.as_ref().is_some_and(|t| t.is_finished()) {
452 let handle = app.compact_task.take().unwrap();
453 let msg_count = app.api_messages.len();
454 match handle.await {
455 Ok(Ok(summary)) => {
456 let old_id = app.session.id.clone();
457 // Find chains pointing at the old head before we swap
458 let chains_to_advance = synaps_cli::chain::find_all_chains_by_head(&old_id)
459 .unwrap_or_default();
460 let new_session = Session::new_from_compaction(&app.session, summary.clone());
461 let new_id = new_session.id.clone();
462 // Save new session FIRST — if we crash after this but before
463 // saving old, the new session still exists and chain is intact
464 app.session = new_session;
465 app.api_messages = app.session.api_messages.clone();
466 app.total_input_tokens = 0;
467 app.total_output_tokens = 0;
468 app.session_cost = 0.0;
469 let msgs = app.api_messages.clone();
470 rebuild_display_messages(&msgs, &mut app);
471 app.save_session().await;
472 // Load old session fresh from disk and update its forward link
473 match synaps_cli::core::session::Session::load(&old_id) {
474 Ok(mut old_session) => {
475 old_session.compacted_into = Some(new_id.clone());
476 // Clear name from old session — it transferred to the new one
477 old_session.name = None;
478 old_session.save().await.ok();
479 }
480 Err(e) => {
481 tracing::warn!("Failed to update old session {}: {}", old_id, e);
482 }
483 }
484 let compaction_event = synaps_cli::extensions::hooks::events::HookEvent::on_compaction(
485 &old_id,
486 &new_id,
487 &summary,
488 msg_count,
489 serde_json::json!({"source": "manual"}),
490 );
491 let _ = runtime.hook_bus().emit(&compaction_event).await;
492
493 // Advance any named chains that pointed at the old head
494 for ch in &chains_to_advance {
495 match synaps_cli::chain::save_chain(&ch.name, &new_id) {
496 Ok(()) => {
497 app.push_msg(ChatMessage::System(format!(
498 "chain '{}' advanced: {} → {}",
499 ch.name, old_id, new_id
500 )));
501 }
502 Err(e) => {
503 app.push_msg(ChatMessage::Error(format!(
504 "failed to advance chain '{}': {}", ch.name, e
505 )));
506 }
507 }
508 }
509 // Flush any events that arrived during compaction
510 for formatted in app.pending_events.drain(..) {
511 app.api_messages.push(serde_json::json!({
512 "role": "user",
513 "content": formatted
514 }));
515 }
516 if let Some(queued) = app.queued_message.take() {
517 app.api_messages.push(serde_json::json!({"role": "user", "content": queued}));
518 app.push_msg(ChatMessage::System(format!("queued message restored: {}", queued)));
519 }
520 app.push_msg(ChatMessage::System(format!(
521 "✓ compacted {} messages → new session {} (from {})",
522 msg_count, new_id, old_id
523 )));
524 }
525 Ok(Err(e)) => {
526 app.push_msg(ChatMessage::Error(format!("compaction failed: {}", e)));
527 }
528 Err(e) => {
529 app.push_msg(ChatMessage::Error(format!("compaction task panicked: {}", e)));
530 }
531 }
532 app.status_text = None;
533 app.invalidate();
534 }
535 if exit_done.load(Ordering::Acquire) {
536 break;
537 }
538 continue;
539 }
540
541 // ── Input: keyboard, mouse, paste ──
542 maybe_event = event_reader.next(), if app.gamba_child.is_none() => {
543 match maybe_event {
544 Some(Ok(event)) => {
545 if secret_prompts.is_active() {
546 match event {
547 crossterm::event::Event::Key(key) => match key.code {
548 crossterm::event::KeyCode::Enter => secret_prompts.submit(),
549 crossterm::event::KeyCode::Esc => secret_prompts.cancel(),
550 crossterm::event::KeyCode::Backspace => secret_prompts.backspace(),
551 crossterm::event::KeyCode::Char(c) => secret_prompts.push_char(c),
552 _ => {}
553 },
554 crossterm::event::Event::Paste(text) => {
555 for ch in text.chars() {
556 secret_prompts.push_char(ch);
557 }
558 }
559 _ => {}
560 }
561 app.request_redraw();
562 continue;
563 }
564 let is_streaming = app.streaming;
565 // Scope the registry read guard to this block so it is
566 // provably released before any later `.await`
567 // (clippy::await_holding_lock) — the guard never spans a
568 // yield point.
569 let action = {
570 let kb_guard = keybind_registry.read().expect("keybind registry poisoned");
571 input::handle_event(event, &mut app, &runtime, is_streaming, ®istry, &kb_guard)
572 };
573 // Input events (keys, mouse, paste, resize) almost always
574 // change visible state (cursor, input buffer, scroll).
575 app.request_redraw();
576 match action {
577 InputAction::None => {}
578 InputAction::HelpFindOutcome => {}
579 InputAction::Quit => {
580 render_handle.send_exit_fx(quit_effect());
581 exit_fx_sent = true;
582 }
583 InputAction::Abort => {
584 if let Some(ref ct) = cancel_token { ct.cancel(); }
585 app.capture_abort_context();
586 if let Some(ref q) = app.queued_message.take() {
587 app.push_msg(ChatMessage::System(format!("dequeued: {}", q)));
588 }
589 // Flush any events that arrived during streaming
590 for formatted in app.pending_events.drain(..) {
591 app.api_messages.push(serde_json::json!({
592 "role": "user",
593 "content": formatted
594 }));
595 }
596 stream = None;
597 cancel_token = None;
598 steer_tx = None;
599 app.streaming = false;
600 app.subagents.clear();
601 // Cancel all running reactive subagents
602 {
603 let mut registry = runtime.subagent_registry().lock().unwrap();
604 for handle in registry.iter_mut_handles() {
605 if handle.status() == synaps_cli::runtime::subagent::SubagentStatus::Running {
606 handle.cancel();
607 }
608 }
609 }
610 let abort_msg = if app.abort_context.is_some() {
611 "aborted — context saved for next message"
612 } else {
613 "aborted"
614 };
615 app.drop_empty_thinking();
616 app.push_msg(ChatMessage::Error(abort_msg.to_string()));
617 app.save_session().await;
618 }
619 InputAction::SlashCommand(cmd, arg) => {
620 let kb_snapshot = {
621 let g = keybind_registry.read().expect("keybind registry poisoned");
622 g.clone()
623 };
624 match commands::handle_command(&cmd, &arg, &mut app, &mut runtime, &system_prompt_path, ®istry, &kb_snapshot).await {
625 CommandAction::None => {}
626 CommandAction::StartStream => {} // reserved for future use
627 CommandAction::Quit => {
628 render_handle.send_exit_fx(quit_effect());
629 exit_fx_sent = true;
630 }
631 CommandAction::LaunchGamba => {
632 drop(event_reader);
633 // Pause the render thread BEFORE touching the terminal —
634 // eliminates the stdout race between terminal.draw() and our mode changes.
635 render_handle.pause();
636 match app.launch_gamba() {
637 Ok(()) => {}
638 Err(msg) => {
639 // launch failed — restore and resume
640 render_handle.resume();
641 app.push_msg(ChatMessage::Error(msg));
642 }
643 }
644 // If gamba launched OK, resume is sent by reclaim/check_gamba_exited.
645 event_reader = EventStream::new();
646 }
647 CommandAction::OpenModels => {
648 app.models = Some(models::ModelsModalState::new());
649 }
650 CommandAction::OpenSettings => {
651 app.settings = Some(settings::SettingsState::new());
652 }
653 CommandAction::OpenPlugins => {
654 let path = synaps_cli::skills::state::PluginsState::default_path();
655 match synaps_cli::skills::state::PluginsState::load_from(&path) {
656 Ok(file) => {
657 app.plugins = Some(plugins::PluginsModalState::new(file));
658 }
659 Err(e) => {
660 app.push_msg(ChatMessage::Error(format!(
661 "failed to load plugins.json: {}", e
662 )));
663 }
664 }
665 }
666 CommandAction::OpenHelpFind { query } => {
667 let registry = synaps_cli::help::HelpRegistry::new(
668 synaps_cli::help::builtin_entries(),
669 registry.plugin_help_entries(),
670 );
671 app.help_find = Some(synaps_cli::help::HelpFindState::new(
672 registry.entries().to_vec(),
673 &query,
674 ));
675 }
676 CommandAction::ReloadPlugins => {
677 synaps_cli::skills::reload_registry(®istry, &config);
678 app.push_msg(ChatMessage::System("plugins reloaded".to_string()));
679 }
680 CommandAction::LoadSkill { skill, arg } => {
681 use synaps_cli::skills::tool::LoadSkillTool;
682
683 let tool_use_id = format!("toolu_skill_{}", uuid::Uuid::new_v4().simple());
684 let body = LoadSkillTool::format_body(&skill);
685
686 app.api_messages.push(json!({
687 "role": "assistant",
688 "content": [{
689 "type": "tool_use",
690 "id": tool_use_id,
691 "name": "load_skill",
692 "input": {"skill": skill.name.clone()}
693 }]
694 }));
695 app.api_messages.push(json!({
696 "role": "user",
697 "content": [{
698 "type": "tool_result",
699 "tool_use_id": tool_use_id,
700 "content": body
701 }]
702 }));
703 let display_name = match &skill.plugin {
704 Some(p) => format!("{}:{}", p, skill.name),
705 None => skill.name.clone(),
706 };
707 app.push_msg(ChatMessage::System(format!("loaded skill: {}", display_name)));
708
709 if !arg.is_empty() {
710 app.api_messages.push(json!({"role": "user", "content": arg.clone()}));
711 app.push_msg(ChatMessage::User(arg));
712 }
713 // Start stream — mirror InputAction::Submit stream-start pattern.
714 let ct = CancellationToken::new();
715 let (s_tx, s_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
716 app.status_text = Some("connecting…".to_string());
717 app.streaming = true;
718 app.spinner_frame = 0;
719 let term_size = crossterm::terminal::size().map(|(w, h)| ratatui::layout::Size { width: w, height: h }).unwrap_or_default();
720 if let Some(model) = build_render_model(&mut app, &runtime, ®istry, &secret_prompts, term_size) {
721 render_handle.publish(model);
722 }
723 stream = Some(runtime.run_stream_with_messages(app.api_messages.clone(), ct.clone(), Some(s_rx), Some(secret_prompt_handle.clone()), false).await);
724 app.status_text = None;
725 app.push_msg(ChatMessage::Thinking(THINKING_PLACEHOLDER.to_string()));
726 cancel_token = Some(ct);
727 steer_tx = Some(s_tx);
728 }
729 CommandAction::PluginCommand { command, arg } => {
730 if matches!(
731 command.backend,
732 synaps_cli::skills::registry::RegisteredPluginCommandBackend::Interactive { .. }
733 ) {
734 let manager = ext_mgr_shared.read().await;
735 commands::execute_interactive_plugin_command_events(
736 &command,
737 &arg,
738 &manager,
739 &mut app,
740 ).await;
741 } else {
742 commands::execute_command_action(
743 CommandAction::PluginCommand { command, arg },
744 &mut app,
745 &runtime,
746 ).await;
747 }
748 }
749 CommandAction::Compact { custom_instructions } => {
750 // Need at least 2 full turns (user + assistant = 2 messages each).
751 if app.api_messages.len() < 4 {
752 app.push_msg(ChatMessage::System(
753 "nothing to compact (need at least 2 turns)".to_string(),
754 ));
755 } else if app.compact_task.is_some() {
756 app.push_msg(ChatMessage::System(
757 "compaction already in progress".to_string(),
758 ));
759 } else {
760 app.push_msg(ChatMessage::System(
761 "compacting conversation...".to_string(),
762 ));
763 app.status_text = Some("compacting…".to_string());
764 app.spinner_frame = 0;
765
766 let msgs = app.api_messages.clone();
767 let rt = runtime.clone();
768 let instr = custom_instructions.clone();
769 let handle = tokio::spawn(async move {
770 compact_conversation(&msgs, &rt, instr.as_deref()).await
771 });
772 app.compact_task = Some(handle);
773 }
774 }
775 CommandAction::Chain => {
776 // Walk the parent_session chain backward from current session
777 let mut chain: Vec<(String, String, usize)> = Vec::new(); // (id, title, msg_count)
778
779 // Current session first
780 chain.push((
781 app.session.id.clone(),
782 if app.session.title.is_empty() { "(untitled)".to_string() } else { app.session.title.clone() },
783 app.api_messages.len(),
784 ));
785
786 // Walk backward through parents
787 let mut current_parent = app.session.parent_session.clone();
788 while let Some(ref parent_id) = current_parent {
789 match synaps_cli::core::session::Session::load(parent_id) {
790 Ok(parent) => {
791 let title = if parent.title.is_empty() { "(untitled)".to_string() } else { parent.title.clone() };
792 let msg_count = parent.api_messages.len();
793 chain.push((parent.id.clone(), title, msg_count));
794 current_parent = parent.parent_session.clone();
795 }
796 Err(_) => {
797 chain.push((parent_id.clone(), "(not found)".to_string(), 0));
798 break;
799 }
800 }
801 }
802
803 // Reverse so root is first
804 chain.reverse();
805
806 if chain.len() <= 1 {
807 app.push_msg(ChatMessage::System("no compaction history — this is the root session".to_string()));
808 } else {
809 let mut lines = vec!["Session chain:".to_string()];
810 for (i, (id, title, msgs)) in chain.iter().enumerate() {
811 let marker = if i == chain.len() - 1 { " ← active" } else { "" };
812 let short_id: String = id.chars().take(19).collect();
813 let short_title: String = title.chars().take(40).collect();
814 lines.push(format!(" {} {} ({} msgs) {}{}",
815 if i == 0 { "●" } else { "→" },
816 short_id, msgs, short_title, marker
817 ));
818 }
819 app.push_msg(ChatMessage::System(lines.join("\n")));
820 }
821
822 // Show any named chain bookmarking the active head
823 match synaps_cli::chain::find_all_chains_by_head(&app.session.id) {
824 Ok(named) if !named.is_empty() => {
825 let names: Vec<String> = named.iter().map(|c| format!("@{}", c.name)).collect();
826 app.push_msg(ChatMessage::System(format!(
827 "bookmarked by: {}", names.join(", ")
828 )));
829 }
830 _ => {}
831 }
832 }
833 CommandAction::ChainList => {
834 match synaps_cli::chain::list_chains() {
835 Ok(chains) if chains.is_empty() => {
836 app.push_msg(ChatMessage::System("no named chains".to_string()));
837 }
838 Ok(chains) => {
839 app.push_msg(ChatMessage::System(format!("{} chain(s):", chains.len())));
840 for c in chains {
841 let active = if c.head == app.session.id { " *" } else { "" };
842 app.push_msg(ChatMessage::System(format!(
843 " @{} → {}{}", c.name, c.head, active
844 )));
845 }
846 }
847 Err(e) => {
848 app.push_msg(ChatMessage::Error(format!("failed to list chains: {}", e)));
849 }
850 }
851 }
852 CommandAction::ChainName { name } => {
853 match synaps_cli::chain::save_chain(&name, &app.session.id) {
854 Ok(()) => {
855 app.push_msg(ChatMessage::System(format!(
856 "chain '{}' → {}", name, app.session.id
857 )));
858 }
859 Err(e) => {
860 app.push_msg(ChatMessage::Error(format!("chain name failed: {}", e)));
861 }
862 }
863 }
864 CommandAction::ChainUnname { name } => {
865 match synaps_cli::chain::delete_chain(&name) {
866 Ok(()) => {
867 app.push_msg(ChatMessage::System(format!("chain '{}' deleted", name)));
868 }
869 Err(e) => {
870 app.push_msg(ChatMessage::Error(format!("chain unname failed: {}", e)));
871 }
872 }
873 }
874 CommandAction::Status => {
875 if runtime.model().contains('/') {
876 app.push_msg(ChatMessage::System("Usage stats are only available for Anthropic models.".to_string()));
877 } else {
878 app.push_msg(ChatMessage::System("Checking usage...".to_string()));
879 match fetch_usage().await {
880 Ok(lines) => {
881 for line in lines {
882 app.push_msg(ChatMessage::System(line));
883 }
884 }
885 Err(e) => app.push_msg(ChatMessage::Error(format!("Usage check failed: {}", e))),
886 }
887 }
888 }
889 CommandAction::ExtensionsStatus => {
890 let manager = ext_mgr_shared.read().await;
891 let snapshots = manager.capability_snapshots().await;
892 let trust_view = manager.provider_trust_view();
893 if snapshots.is_empty() {
894 app.push_msg(ChatMessage::System("No extensions loaded.".to_string()));
895 } else {
896 app.push_msg(ChatMessage::System(format!("Extensions ({}):", snapshots.len())));
897 for snap in &snapshots {
898 app.push_msg(ChatMessage::System(format!(
899 " {} — {} (restarts: {})",
900 snap.id,
901 snap.health.as_str(),
902 snap.restart_count
903 )));
904 if !snap.hooks.is_empty() {
905 let rendered = snap
906 .hooks
907 .iter()
908 .map(|h| match &h.tool_filter {
909 Some(t) => format!("{}[{}]", h.kind, t),
910 None => h.kind.clone(),
911 })
912 .collect::<Vec<_>>()
913 .join(", ");
914 app.push_msg(ChatMessage::System(format!(" hooks: {}", rendered)));
915 }
916 if !snap.tools.is_empty() {
917 let rendered = snap
918 .tools
919 .iter()
920 .map(|t| t.name.clone())
921 .collect::<Vec<_>>()
922 .join(", ");
923 app.push_msg(ChatMessage::System(format!(" tools: {}", rendered)));
924 }
925 // Capability declarations (grouped from the `future` list).
926 // Each entry has a free-form kind declared by the plugin
927 // (e.g. "capture", "ocr", "agent"). Render grouped by kind so
928 // future capability types surface without core changes.
929 if !snap.future.is_empty() {
930 use std::collections::BTreeMap;
931 // kind -> name -> Vec<mode>
932 let mut by_kind: BTreeMap<String, BTreeMap<String, Vec<String>>> = BTreeMap::new();
933 for entry in &snap.future {
934 let bucket = by_kind.entry(entry.kind.clone()).or_default();
935 // entry.name is "<plugin-name> (<mode>)" in the legacy
936 // shim; preserve the existing display behaviour.
937 if let Some(open) = entry.name.rfind(" (") {
938 if entry.name.ends_with(')') {
939 let name = entry.name[..open].to_string();
940 let mode = entry.name[open + 2..entry.name.len() - 1].to_string();
941 bucket.entry(name).or_default().push(mode);
942 continue;
943 }
944 }
945 bucket.entry(entry.name.clone()).or_default();
946 }
947 for (kind, names) in &by_kind {
948 for (name, modes) in names {
949 let modes_str = modes.join("/");
950 if modes_str.is_empty() {
951 app.push_msg(ChatMessage::System(format!(
952 " {}: {}",
953 kind, name
954 )));
955 } else {
956 app.push_msg(ChatMessage::System(format!(
957 " {}: {} [{}]",
958 kind, name, modes_str
959 )));
960 }
961 }
962 }
963 }
964 for provider in &snap.providers {
965 let disabled_suffix = match trust_view.get(&provider.runtime_id) {
966 Some(false) => " [disabled]",
967 _ => "",
968 };
969 app.push_msg(ChatMessage::System(format!(
970 " provider {} — {}{}",
971 provider.runtime_id,
972 provider.display_name,
973 disabled_suffix
974 )));
975 for model in &provider.models {
976 let mut badges: Vec<&str> = Vec::new();
977 if model.tool_use { badges.push("tool-use"); }
978 if model.streaming { badges.push("streaming"); }
979 let label = if badges.is_empty() {
980 model.runtime_id.clone()
981 } else {
982 let suffix = badges.iter().map(|b| format!("[{}]", b)).collect::<Vec<_>>().join(" ");
983 format!("{} {}", model.runtime_id, suffix)
984 };
985 app.push_msg(ChatMessage::System(format!(" model {}", label)));
986 }
987 }
988 // Surface config diagnostics warnings (no values printed).
989 if let Some(diag) = manager.config_diagnostics(&snap.id) {
990 let missing_required: Vec<&str> = diag
991 .entries
992 .iter()
993 .filter(|e| e.required && matches!(e.source, synaps_cli::extensions::config::ConfigSource::Missing))
994 .map(|e| e.key.as_str())
995 .collect();
996 if !missing_required.is_empty() {
997 app.push_msg(ChatMessage::System(format!(
998 " ⚠ missing required config: {}",
999 missing_required.join(", ")
1000 )));
1001 }
1002 // Group provider_missing by provider id.
1003 let mut by_provider: std::collections::BTreeMap<&str, Vec<&str>> = std::collections::BTreeMap::new();
1004 for (pid, key) in &diag.provider_missing {
1005 by_provider.entry(pid.as_str()).or_default().push(key.as_str());
1006 }
1007 for (pid, keys) in by_provider {
1008 app.push_msg(ChatMessage::System(format!(
1009 " ⚠ provider {} missing required config: {}",
1010 pid,
1011 keys.join(", ")
1012 )));
1013 }
1014 }
1015 }
1016 }
1017 }
1018 CommandAction::ExtensionsConfig { id } => {
1019 let manager = ext_mgr_shared.read().await;
1020 let diags: Vec<synaps_cli::extensions::config::ExtensionConfigDiagnostics> = match &id {
1021 Some(want) => match manager.config_diagnostics(want) {
1022 Some(d) => vec![d],
1023 None => {
1024 app.push_msg(ChatMessage::Error(format!(
1025 "extension not found: {}",
1026 want
1027 )));
1028 Vec::new()
1029 }
1030 },
1031 None => manager.all_config_diagnostics(),
1032 };
1033 if diags.is_empty() && id.is_none() {
1034 app.push_msg(ChatMessage::System("No extensions loaded.".to_string()));
1035 }
1036 for diag in diags {
1037 app.push_msg(ChatMessage::System(format!(
1038 "Extension {} config:",
1039 diag.extension_id
1040 )));
1041 if diag.entries.is_empty() {
1042 app.push_msg(ChatMessage::System(" (no manifest config entries)".to_string()));
1043 }
1044 for entry in &diag.entries {
1045 let source_label = match &entry.source {
1046 synaps_cli::extensions::config::ConfigSource::EnvOverride(name) => format!("env override ({})", name),
1047 synaps_cli::extensions::config::ConfigSource::SecretEnv(name) => format!("secret env ({})", name),
1048 synaps_cli::extensions::config::ConfigSource::PluginConfig => "plugin config".to_string(),
1049 synaps_cli::extensions::config::ConfigSource::LegacyConfigKey(name) => format!("legacy config key ({})", name),
1050 synaps_cli::extensions::config::ConfigSource::Default => "default".to_string(),
1051 synaps_cli::extensions::config::ConfigSource::Missing => "missing".to_string(),
1052 };
1053 let req = if entry.required { " [required]" } else { "" };
1054 app.push_msg(ChatMessage::System(format!(
1055 " {}{} — source: {}, has_value: {}",
1056 entry.key, req, source_label, entry.has_value
1057 )));
1058 if let Some(desc) = &entry.description {
1059 app.push_msg(ChatMessage::System(format!(
1060 " description: {}",
1061 desc
1062 )));
1063 }
1064 }
1065 for (pid, key) in &diag.provider_missing {
1066 app.push_msg(ChatMessage::System(format!(
1067 " ⚠ provider {} requires config '{}' (no manifest entry)",
1068 pid, key
1069 )));
1070 }
1071 }
1072 }
1073
1074 CommandAction::ExtensionsTrust(action) => {
1075 use crate::tui::commands::ExtensionsTrustAction;
1076 match action {
1077 ExtensionsTrustAction::List => {
1078 let manager = ext_mgr_shared.read().await;
1079 let providers = manager.provider_summaries();
1080 let trust = synaps_cli::extensions::trust::load_trust_state().unwrap_or_default();
1081 if providers.is_empty() {
1082 app.push_msg(ChatMessage::System("No providers registered.".to_string()));
1083 } else {
1084 app.push_msg(ChatMessage::System(format!("Provider trust ({}):", providers.len())));
1085 for p in providers {
1086 let suffix = match trust.disabled.get(&p.runtime_id) {
1087 Some(entry) if entry.disabled => match &entry.reason {
1088 Some(r) => format!(" [disabled ({})]", r),
1089 None => " [disabled]".to_string(),
1090 },
1091 _ => " [enabled]".to_string(),
1092 };
1093 app.push_msg(ChatMessage::System(format!(
1094 " {}{}",
1095 p.runtime_id, suffix
1096 )));
1097 }
1098 }
1099 }
1100 ExtensionsTrustAction::Enable { runtime_id } => {
1101 match synaps_cli::extensions::trust::load_trust_state() {
1102 Ok(mut state) => {
1103 synaps_cli::extensions::trust::enable_provider(&mut state, &runtime_id);
1104 match synaps_cli::extensions::trust::save_trust_state(&state) {
1105 Ok(()) => app.push_msg(ChatMessage::System(format!(
1106 "Provider '{}' enabled.", runtime_id
1107 ))),
1108 Err(e) => app.push_msg(ChatMessage::Error(format!(
1109 "failed to save trust state: {}", e
1110 ))),
1111 }
1112 }
1113 Err(e) => app.push_msg(ChatMessage::Error(format!(
1114 "failed to load trust state: {}", e
1115 ))),
1116 }
1117 }
1118 ExtensionsTrustAction::Disable { runtime_id, reason } => {
1119 match synaps_cli::extensions::trust::load_trust_state() {
1120 Ok(mut state) => {
1121 synaps_cli::extensions::trust::disable_provider(&mut state, &runtime_id, reason.clone());
1122 match synaps_cli::extensions::trust::save_trust_state(&state) {
1123 Ok(()) => {
1124 let suffix = match &reason {
1125 Some(r) => format!(" [reason: {}]", r),
1126 None => String::new(),
1127 };
1128 app.push_msg(ChatMessage::System(format!(
1129 "Provider '{}' disabled.{}", runtime_id, suffix
1130 )));
1131 }
1132 Err(e) => app.push_msg(ChatMessage::Error(format!(
1133 "failed to save trust state: {}", e
1134 ))),
1135 }
1136 }
1137 Err(e) => app.push_msg(ChatMessage::Error(format!(
1138 "failed to load trust state: {}", e
1139 ))),
1140 }
1141 }
1142 }
1143 }
1144 CommandAction::ExtensionsAudit { tail } => {
1145 // Use bounded tail read — only the last N entries are
1146 // deserialised regardless of how large audit.jsonl has grown.
1147 let read_result = match tail {
1148 Some(n) => synaps_cli::extensions::audit::read_audit_entries_tail(n),
1149 None => synaps_cli::extensions::audit::read_audit_entries(),
1150 };
1151 match read_result {
1152 Ok(entries) => {
1153 let slice = entries;
1154 if slice.is_empty() {
1155 app.push_msg(ChatMessage::System("No audit entries yet.".to_string()));
1156 } else {
1157 app.push_msg(ChatMessage::System(format!("Audit ({} entries):", slice.len())));
1158 for e in slice {
1159 let stream_tag = if e.streamed { "[streamed]" } else { "[complete]" };
1160 let class_part = match &e.error_class {
1161 Some(c) => format!(" class={}", c),
1162 None => String::new(),
1163 };
1164 let tools_part = if e.tools_requested > 0 {
1165 format!(" tools={}", e.tools_requested)
1166 } else {
1167 String::new()
1168 };
1169 app.push_msg(ChatMessage::System(format!(
1170 " {} {}:{} {} outcome={}{}{}",
1171 e.timestamp,
1172 e.provider_id,
1173 e.model_id,
1174 stream_tag,
1175 e.outcome,
1176 class_part,
1177 tools_part,
1178 )));
1179 }
1180 }
1181 }
1182 Err(e) => app.push_msg(ChatMessage::Error(format!(
1183 "failed to read audit log: {}", e
1184 ))),
1185 }
1186 }
1187 CommandAction::ExtensionsMemory(action) => {
1188 use crate::tui::commands::ExtensionsMemoryAction;
1189 match action {
1190 ExtensionsMemoryAction::Namespaces => {
1191 match synaps_cli::memory::store::list_namespaces() {
1192 Ok(nss) if nss.is_empty() => {
1193 app.push_msg(ChatMessage::System(
1194 "No memory namespaces.".to_string(),
1195 ));
1196 }
1197 Ok(nss) => {
1198 app.push_msg(ChatMessage::System(format!(
1199 "Memory namespaces ({}):", nss.len()
1200 )));
1201 for ns in nss {
1202 app.push_msg(ChatMessage::System(format!(" {}", ns)));
1203 }
1204 }
1205 Err(e) => app.push_msg(ChatMessage::Error(format!(
1206 "failed to list memory namespaces: {}", e
1207 ))),
1208 }
1209 }
1210 ExtensionsMemoryAction::Recent { namespace, limit } => {
1211 let q = synaps_cli::memory::store::MemoryQuery {
1212 limit: Some(limit.unwrap_or(20)),
1213 ..Default::default()
1214 };
1215 match synaps_cli::memory::store::query(&namespace, &q) {
1216 Ok(records) if records.is_empty() => {
1217 app.push_msg(ChatMessage::System(format!(
1218 "No records in '{}'.", namespace
1219 )));
1220 }
1221 Ok(records) => {
1222 app.push_msg(ChatMessage::System(format!(
1223 "Recent in '{}' ({}):", namespace, records.len()
1224 )));
1225 for rec in records {
1226 // ISO8601 / RFC3339 UTC from epoch ms via chrono.
1227 let ts = chrono::DateTime::<chrono::Utc>::from_timestamp_millis(
1228 rec.timestamp_ms as i64,
1229 )
1230 .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
1231 .unwrap_or_else(|| rec.timestamp_ms.to_string());
1232 // Truncate content at 80 chars (char-aware).
1233 let mut content: String = rec.content.chars().take(80).collect();
1234 if rec.content.chars().count() > 80 {
1235 content.push('…');
1236 }
1237 let tags = if rec.tags.is_empty() {
1238 "[]".to_string()
1239 } else {
1240 format!("[{}]", rec.tags.join(", "))
1241 };
1242 // NOTE: meta intentionally not displayed (privacy).
1243 app.push_msg(ChatMessage::System(format!(
1244 " {} {} {}", ts, tags, content
1245 )));
1246 }
1247 }
1248 Err(e) => app.push_msg(ChatMessage::Error(format!(
1249 "failed to query memory '{}': {}", namespace, e
1250 ))),
1251 }
1252 }
1253 }
1254 }
1255
1256 CommandAction::Ping => {
1257 app.push_msg(ChatMessage::System("📡 Pinging models...".to_string()));
1258 app.ping_print = true;
1259 let client = runtime.http_client().clone();
1260 let provider_keys = synaps_cli::config::get_provider_keys();
1261 // Count how many models will be pinged
1262 let count: usize = synaps_cli::runtime::openai::registry::providers().iter()
1263 .filter(|s| synaps_cli::runtime::openai::registry::resolve_provider_model(s.key, s.default_model, &provider_keys).is_some())
1264 .map(|s| s.models.len())
1265 .sum();
1266 app.ping_pending = count;
1267 let health_tx = app.ping_tx.clone();
1268 tokio::spawn(async move {
1269 synaps_cli::runtime::openai::ping::ping_all_configured(
1270 &client, &provider_keys, health_tx,
1271 ).await;
1272 });
1273 }
1274
1275 CommandAction::SidecarToggle { plugin_id } => {
1276 // Phase 8 8B: target either the
1277 // claim-supplied plugin id, or fall
1278 // back to the legacy single-slot
1279 // discovery for the unclaimed case.
1280 let all = synaps_cli::sidecar::discovery::discover_all();
1281 let target = plugin_id
1282 .clone()
1283 .or_else(|| all.first().map(|s| s.plugin_name.clone()));
1284 let Some(target_pid) = target else {
1285 app.push_msg(ChatMessage::Error(
1286 "sidecar unavailable: no plugin provides a sidecar binary".to_string()
1287 ));
1288 continue;
1289 };
1290
1291 if app.sidecars.contains_key(&target_pid) {
1292 // Subsequent toggle on existing sidecar — arm flag is source of truth.
1293 let label = app.sidecars.get(&target_pid)
1294 .and_then(|s| s.display_name.as_deref())
1295 .unwrap_or("sidecar")
1296 .to_string();
1297 let v = app.sidecars.get_mut(&target_pid).unwrap();
1298 if v.armed {
1299 v.armed = false;
1300 if let Err(err) = v.manager.release().await {
1301 app.push_msg(ChatMessage::Error(format!("{label} release failed: {err}")));
1302 }
1303 app.push_msg(ChatMessage::System(
1304 format!("{label}: stopping — final transcript will be appended")
1305 ));
1306 } else {
1307 v.armed = true;
1308 if let Err(err) = v.manager.press().await {
1309 v.armed = false;
1310 app.push_msg(ChatMessage::Error(format!("{label} press failed: {err}")));
1311 }
1312 }
1313 } else {
1314 // Spawn new sidecar instance for target_pid.
1315 let Some(discovered) = all.into_iter().find(|s| s.plugin_name == target_pid) else {
1316 app.push_msg(ChatMessage::Error(format!(
1317 "sidecar plugin '{}' not discoverable", target_pid,
1318 )));
1319 continue;
1320 };
1321 let (sidecar_plugin_info, sidecar_spawn_args) = {
1322 let manager = ext_mgr_shared.read().await;
1323 let info = manager.plugin_info(&target_pid).cloned();
1324 let args = match manager.sidecar_spawn_args(&target_pid).await {
1325 Ok(a) => Some(a),
1326 Err(err) => {
1327 tracing::debug!(
1328 plugin = %target_pid,
1329 error = %err,
1330 "sidecar.spawn_args RPC unavailable; using manifest defaults",
1331 );
1332 None
1333 }
1334 };
1335 (info, args)
1336 };
1337 match self::sidecar::SidecarUiState::spawn_for(
1338 discovered,
1339 sidecar_spawn_args,
1340 sidecar_plugin_info.as_ref(),
1341 ).await {
1342 Ok(mut state) => {
1343 let claims = registry.lifecycle_claims();
1344 let display = pick_display_name_for_plugin(
1345 &state.sidecar.plugin_name,
1346 &claims,
1347 );
1348 state.set_display_name(display);
1349 let label = state.display_name.clone()
1350 .unwrap_or_else(|| "sidecar".to_string());
1351 let plugin_key = state.sidecar.plugin_name.clone();
1352 app.sidecars.insert(plugin_key.clone(), state);
1353 app.push_msg(ChatMessage::System(
1354 format!("{label} active — press the toggle again to stop")
1355 ));
1356 if let Some(v) = app.sidecars.get_mut(&plugin_key) {
1357 v.armed = true;
1358 if let Err(err) = v.manager.press().await {
1359 v.armed = false;
1360 v.status = self::sidecar::SidecarUiStatus::Error(err.to_string());
1361 app.push_msg(ChatMessage::Error(format!("{label} press failed: {err}")));
1362 }
1363 }
1364 }
1365 Err(err) => {
1366 app.push_msg(ChatMessage::Error(format!("sidecar unavailable: {err}")));
1367 }
1368 }
1369 }
1370 }
1371
1372 CommandAction::SidecarStatus { plugin_id } => {
1373 // Phase 8 8B: show status for the
1374 // requested plugin, or — when None —
1375 // for the single legacy sidecar (or
1376 // the discovery hint when none have
1377 // been spawned).
1378 let line = if let Some(pid) = plugin_id.as_deref() {
1379 match app.sidecars.get(pid) {
1380 Some(v) => v.status_line(),
1381 None => match synaps_cli::sidecar::discovery::discover_all().into_iter().find(|s| s.plugin_name == pid) {
1382 Some(s) => format!(
1383 "sidecar: not yet started — sidecar available from plugin '{}' at {}",
1384 s.plugin_name, s.binary.display()
1385 ),
1386 None => format!("sidecar: no plugin '{}' provides a sidecar", pid),
1387 },
1388 }
1389 } else if app.sidecars.len() == 1 {
1390 app.sidecars.values().next().unwrap().status_line()
1391 } else if app.sidecars.is_empty() {
1392 match synaps_cli::sidecar::discovery::discover() {
1393 Some(s) => format!(
1394 "sidecar: not yet started — sidecar available from plugin '{}' at {}",
1395 s.plugin_name, s.binary.display()
1396 ),
1397 None => "sidecar: no plugin provides a sidecar binary (install a plugin that declares provides.sidecar)".to_string(),
1398 }
1399 } else {
1400 // Multiple active — list each.
1401 let mut lines: Vec<String> = app.sidecars.values()
1402 .map(|v| v.status_line()).collect();
1403 lines.sort();
1404 lines.join("\n")
1405 };
1406 app.push_msg(ChatMessage::System(line));
1407 }
1408
1409 }
1410 }
1411 InputAction::Submit(input) => {
1412 // Queue input during compaction — will be sent after session swap
1413 if app.compact_task.is_some() {
1414 app.push_msg(ChatMessage::System(format!("queued: {}", input)));
1415 app.queued_message = Some(input);
1416 continue;
1417 }
1418 let display_text = app.user_display_text_for_submission(&input);
1419 app.push_msg(ChatMessage::User(display_text));
1420 app.input_before_paste = None;
1421 app.pasted_char_count = 0;
1422 // Inject abort context if previous response was interrupted
1423 let api_content = if let Some(ref ctx) = app.abort_context {
1424 let combined = format!("{}\n\n{}", ctx, input);
1425 app.abort_context = None;
1426 combined
1427 } else {
1428 input
1429 };
1430 app.api_messages.push(json!({"role": "user", "content": api_content}));
1431 let ct = CancellationToken::new();
1432 let (s_tx, s_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
1433 app.status_text = Some("connecting…".to_string());
1434 app.streaming = true;
1435 app.spinner_frame = 0;
1436 let term_size = crossterm::terminal::size().map(|(w, h)| ratatui::layout::Size { width: w, height: h }).unwrap_or_default();
1437 if let Some(model) = build_render_model(&mut app, &runtime, ®istry, &secret_prompts, term_size) {
1438 render_handle.publish(model);
1439 }
1440 stream = Some(runtime.run_stream_with_messages(app.api_messages.clone(), ct.clone(), Some(s_rx), Some(secret_prompt_handle.clone()), false).await);
1441 app.status_text = None;
1442 app.push_msg(ChatMessage::Thinking(THINKING_PLACEHOLDER.to_string()));
1443 cancel_token = Some(ct);
1444 steer_tx = Some(s_tx);
1445 }
1446 InputAction::StreamingInput(input) => {
1447 // Check for streaming slash commands
1448 if let Some(rest) = input.strip_prefix('/') {
1449 let raw_cmd = rest.split_whitespace().next().unwrap_or("");
1450 let streaming_cmds = commands::to_owned_commands(commands::STREAMING_COMMANDS);
1451 let cmd = commands::resolve_prefix(raw_cmd, &streaming_cmds);
1452 match commands::handle_streaming_command(&cmd, &input, &mut app) {
1453 CommandAction::None => {
1454 // Not a streaming-safe command. If it's still a KNOWN
1455 // command (settings, model, system, etc.), refuse with
1456 // a clear message — don't leak command text into the
1457 // model stream as steering input.
1458 let all_cmds = commands::all_commands_with_skills(®istry);
1459 let resolved_full = commands::resolve_prefix(raw_cmd, &all_cmds);
1460 if all_cmds.iter().any(|c| c == &resolved_full) {
1461 app.push_msg(ChatMessage::System(
1462 format!("/{} can't run while streaming — press Esc to cancel first", resolved_full)
1463 ));
1464 } else {
1465 // Unknown slash text — treat as steering
1466 let steered = steer_tx.as_ref()
1467 .map(|tx| tx.send(input.clone()).is_ok())
1468 .unwrap_or(false);
1469 if steered {
1470 app.push_msg(ChatMessage::System(format!("→ steering: {}", input)));
1471 } else {
1472 app.push_msg(ChatMessage::System(format!("queued: {}", input)));
1473 }
1474 app.queued_message = Some(input);
1475 }
1476 }
1477 CommandAction::Quit => {
1478 render_handle.send_exit_fx(quit_effect());
1479 exit_fx_sent = true;
1480 }
1481 CommandAction::LaunchGamba => {
1482 drop(event_reader);
1483 // Pause the render thread BEFORE touching the terminal —
1484 // eliminates the stdout race between terminal.draw() and our mode changes.
1485 render_handle.pause();
1486 match app.launch_gamba() {
1487 Ok(()) => {}
1488 Err(msg) => {
1489 // launch failed — restore and resume
1490 render_handle.resume();
1491 app.push_msg(ChatMessage::Error(msg));
1492 }
1493 }
1494 // If gamba launched OK, resume is sent by reclaim/check_gamba_exited.
1495 event_reader = EventStream::new();
1496 }
1497 CommandAction::StartStream => {}
1498 CommandAction::OpenModels => {}
1499 CommandAction::OpenSettings => {}
1500 CommandAction::OpenPlugins => {}
1501 CommandAction::OpenHelpFind { .. } => {}
1502 CommandAction::ReloadPlugins => {}
1503 // handle_streaming_command never returns LoadSkill, PluginCommand, or Compact.
1504 CommandAction::LoadSkill { .. } => {}
1505 CommandAction::PluginCommand { .. } => {}
1506 CommandAction::Compact { .. } => {}
1507 CommandAction::Chain => {}
1508 CommandAction::ChainList => {}
1509 CommandAction::ChainName { .. } => {}
1510 CommandAction::ChainUnname { .. } => {}
1511 CommandAction::Status => {}
1512 CommandAction::ExtensionsStatus => {}
1513 CommandAction::ExtensionsConfig { .. } => {}
1514 CommandAction::ExtensionsTrust(_) => {}
1515 CommandAction::ExtensionsAudit { .. } => {}
1516 CommandAction::ExtensionsMemory(_) => {}
1517 CommandAction::Ping => {}
1518 CommandAction::SidecarToggle { .. } => {}
1519 CommandAction::SidecarStatus { .. } => {}
1520 }
1521 } else {
1522 // Normal text during streaming — steer/queue
1523 let steered = steer_tx.as_ref()
1524 .map(|tx| tx.send(input.clone()).is_ok())
1525 .unwrap_or(false);
1526 if steered {
1527 app.push_msg(ChatMessage::System(format!("→ steering: {}", input)));
1528 } else {
1529 app.push_msg(ChatMessage::System(format!("queued: {}", input)));
1530 }
1531 app.queued_message = Some(input);
1532 }
1533 }
1534 InputAction::ModelsApply(model) => {
1535 runtime.set_model(model.clone());
1536 let applied = runtime.model().to_string();
1537 let status = synaps_cli::engine::commands::persist_to_config("model", &applied);
1538 app.session.model = applied.clone();
1539 app.push_msg(ChatMessage::System(format!("model set to: {} {}", applied, status)));
1540 }
1541 InputAction::ModelsExpandProvider(provider_key) => {
1542 if provider_key.contains(':') {
1543 let tx = app.model_list_tx.clone();
1544 let manager = synaps_cli::runtime::openai::extension_manager_for_routing();
1545 tokio::spawn(async move {
1546 let result = if let Some(manager) = manager {
1547 let manager = manager.read().await;
1548 if let Some(provider) = manager.provider(&provider_key) {
1549 Ok(provider.spec.models.iter().map(|model| {
1550 let full_id = synaps_cli::extensions::providers::ProviderRegistry::model_runtime_id(
1551 &provider.plugin_id,
1552 &provider.provider_id,
1553 &model.id,
1554 );
1555 let mut metadata = vec![format!("plugin {}", provider.plugin_id)];
1556 metadata.push(format!("provider {}", provider.provider_id));
1557 if let Some(context) = model.context_window {
1558 metadata.push(if context >= 1_000_000 {
1559 format!("{}M ctx", context / 1_000_000)
1560 } else if context >= 1_000 {
1561 format!("{}K ctx", context / 1_000)
1562 } else {
1563 format!("{context} ctx")
1564 });
1565 }
1566 if model.capabilities.get("tool_use").and_then(|value| value.as_bool()).unwrap_or(false) {
1567 metadata.push("tool-use".to_string());
1568 }
1569 models::ExpandedModelEntry::with_metadata(
1570 full_id,
1571 model.display_name.clone().unwrap_or_else(|| model.id.clone()),
1572 false,
1573 metadata,
1574 )
1575 }).collect())
1576 } else {
1577 Err(format!("extension provider '{}' is not loaded", provider_key))
1578 }
1579 } else {
1580 Err("extension provider registry is not available".to_string())
1581 };
1582 let _ = tx.send((provider_key, result));
1583 });
1584 continue;
1585 }
1586 let client = runtime.http_client().clone();
1587 let provider_keys = synaps_cli::config::get_provider_keys();
1588 let tx = app.model_list_tx.clone();
1589 tokio::spawn(async move {
1590 let result = synaps_cli::runtime::openai::catalog::fetch_catalog_models(
1591 &client,
1592 &provider_key,
1593 &provider_keys,
1594 ).await.map(|models| {
1595 models.into_iter().map(|model| {
1596 let full_id = model.runtime_id();
1597 let label = model.display_label().to_string();
1598 let mut metadata = Vec::new();
1599 if let Some(context) = model.context_tokens {
1600 metadata.push(if context >= 1_000_000 {
1601 format!("{}M ctx", context / 1_000_000)
1602 } else if context >= 1_000 {
1603 format!("{}K ctx", context / 1_000)
1604 } else {
1605 format!("{context} ctx")
1606 });
1607 }
1608 match model.reasoning {
1609 synaps_cli::runtime::openai::catalog::ReasoningSupport::None => {}
1610 synaps_cli::runtime::openai::catalog::ReasoningSupport::Unknown => {}
1611 _ => metadata.push("thinking".to_string()),
1612 }
1613 if model.pricing.has_internal_reasoning_cost() {
1614 metadata.push("reasoning $".to_string());
1615 }
1616 models::ExpandedModelEntry::with_metadata(full_id, label, false, metadata)
1617 }).collect()
1618 });
1619 let _ = tx.send((provider_key, result));
1620 });
1621 }
1622 InputAction::SettingsApply(key, value) => {
1623 apply_setting(key, &value, &mut app, &mut runtime);
1624 }
1625 InputAction::PluginEditorOpen { plugin_id, category, field } => {
1626 let manager = ext_mgr_shared.read().await;
1627 match manager.settings_editor_open(&plugin_id, &category, &field).await
1628 .and_then(settings::plugin_editor::render_from_open_result)
1629 {
1630 Ok(render) => {
1631 if let Some(state) = app.settings.as_mut() {
1632 state.row_error = None;
1633 state.edit_mode = Some(settings::ActiveEditor::PluginCustom {
1634 plugin_id: plugin_id.clone(),
1635 category: category.clone(),
1636 field: field.clone(),
1637 render: settings::plugin_editor::PluginEditorSession {
1638 plugin_id,
1639 category,
1640 field,
1641 render,
1642 },
1643 });
1644 }
1645 }
1646 Err(err) => {
1647 if let Some(state) = app.settings.as_mut() {
1648 state.row_error = Some((
1649 format!("plugin.{}.{}", plugin_id, field),
1650 err,
1651 ));
1652 }
1653 }
1654 }
1655 }
1656 InputAction::PluginEditorKey { plugin_id, category, field, key } => {
1657 let wire_key = settings::plugin_editor::key_to_wire(key);
1658 if wire_key == "Enter" {
1659 let selected = app.settings.as_ref().and_then(|state| {
1660 match &state.edit_mode {
1661 Some(settings::ActiveEditor::PluginCustom { render, .. }) => {
1662 let cursor = render.render.cursor.unwrap_or(0);
1663 render.render.rows.get(cursor).and_then(|r| r.data.clone())
1664 }
1665 _ => None,
1666 }
1667 });
1668 if let Some(value) = selected {
1669 let manager = ext_mgr_shared.read().await;
1670 match manager.settings_editor_commit(&plugin_id, &category, &field, value.clone()).await {
1671 Ok(reply) => {
1672 let effect = settings::plugin_editor::effect_from_commit_reply(
1673 &plugin_id,
1674 &field,
1675 reply,
1676 );
1677 match effect {
1678 settings::plugin_editor::PluginEditorEffect::None => {}
1679 settings::plugin_editor::PluginEditorEffect::ConfigWrite { plugin_id, key, value } => {
1680 match synaps_cli::extensions::config_store::write_plugin_config(&plugin_id, &key, &value) {
1681 Ok(()) => {
1682 if let Some(state) = app.settings.as_mut() {
1683 state.edit_mode = None;
1684 state.row_error = Some((format!("plugin.{}.{}", plugin_id, key), "saved".to_string()));
1685 }
1686 }
1687 Err(err) => {
1688 if let Some(state) = app.settings.as_mut() {
1689 state.row_error = Some((format!("plugin.{}.{}", plugin_id, key), err.to_string()));
1690 }
1691 }
1692 }
1693 }
1694 settings::plugin_editor::PluginEditorEffect::InvokeCommand { plugin_id, command, args } => {
1695 if let Some(state) = app.settings.as_mut() {
1696 state.edit_mode = None;
1697 state.row_error = Some((format!("plugin.{}.{}", plugin_id, field), "download started".to_string()));
1698 }
1699 commands::execute_interactive_plugin_command_by_parts(
1700 &plugin_id,
1701 &command,
1702 args,
1703 &manager,
1704 &mut app,
1705 ).await;
1706 }
1707 }
1708 }
1709 Err(err) => {
1710 if let Some(state) = app.settings.as_mut() {
1711 state.row_error = Some((format!("plugin.{}.{}", plugin_id, field), err));
1712 }
1713 }
1714 }
1715 }
1716 } else {
1717 let manager = ext_mgr_shared.read().await;
1718 match manager.settings_editor_key(&plugin_id, &category, &field, &wire_key).await
1719 .and_then(settings::plugin_editor::render_from_key_result)
1720 {
1721 Ok(Some(render)) => {
1722 if let Some(settings::ActiveEditor::PluginCustom { render: session, .. }) =
1723 app.settings.as_mut().and_then(|s| s.edit_mode.as_mut())
1724 {
1725 session.render = render;
1726 }
1727 }
1728 Ok(None) => {}
1729 Err(err) => {
1730 if let Some(state) = app.settings.as_mut() {
1731 state.row_error = Some((format!("plugin.{}.{}", plugin_id, field), err));
1732 }
1733 }
1734 }
1735 }
1736 }
1737 InputAction::PluginsOutcome(outcome) => {
1738 if let Some(state) = app.plugins.as_mut() {
1739 use self::plugins::InputOutcome as PO;
1740 match outcome {
1741 PO::None | PO::Close => {}
1742 PO::AddMarketplace(url) => {
1743 plugins::actions::apply_add_marketplace(state, url).await;
1744 }
1745 PO::InstallRequested { marketplace, plugin } => {
1746 plugins::actions::apply_install(
1747 state, marketplace, plugin, ®istry, &config,
1748 ).await;
1749 }
1750 PO::TrustAndInstall { plugin_name, host, source, summary } => {
1751 plugins::actions::apply_trust_and_install(
1752 state, plugin_name, host, source, summary, ®istry, &config,
1753 ).await;
1754 }
1755 PO::Uninstall(name) => {
1756 plugins::actions::apply_uninstall(
1757 state, name, ®istry, &config,
1758 ).await;
1759 }
1760 PO::Update(name) => {
1761 plugins::actions::apply_update(
1762 state, name, ®istry, &config,
1763 ).await;
1764 }
1765 PO::RefreshMarketplace(name) => {
1766 plugins::actions::apply_refresh_marketplace(state, name).await;
1767 }
1768 PO::ConfirmPendingInstall => {
1769 plugins::actions::apply_confirm_pending_install(state, ®istry, &config).await;
1770 }
1771 PO::CancelPendingInstall => {
1772 plugins::actions::apply_cancel_pending_install(state);
1773 }
1774 PO::ConfirmPendingUpdate => {
1775 plugins::actions::apply_confirm_pending_update(state, ®istry, &config).await;
1776 }
1777 PO::CancelPendingUpdate => {
1778 plugins::actions::apply_cancel_pending_update(state);
1779 }
1780 PO::RemoveMarketplace(name) => {
1781 plugins::actions::apply_remove_marketplace(
1782 state, name, ®istry, &config,
1783 ).await;
1784 }
1785 PO::TogglePlugin { name, enabled } => {
1786 plugins::actions::apply_toggle_plugin(
1787 state, name, enabled, ®istry, &mut config,
1788 );
1789 }
1790 PO::EnablePluginRequested(name) => {
1791 plugins::actions::confirm_enable_plugin(state, name);
1792 }
1793 }
1794 }
1795 }
1796 InputAction::OpenPluginsMarketplace => {
1797 let path = synaps_cli::skills::state::PluginsState::default_path();
1798 match synaps_cli::skills::state::PluginsState::load_from(&path) {
1799 Ok(file) => {
1800 app.plugins = Some(plugins::PluginsModalState::new_from_settings(file));
1801 }
1802 Err(e) => {
1803 if let Some(s) = app.settings.as_mut() {
1804 s.row_error = Some((
1805 "plugins".to_string(),
1806 format!("failed to load plugins.json: {}", e),
1807 ));
1808 }
1809 }
1810 }
1811 }
1812 InputAction::PingModels => {
1813 let client = runtime.http_client().clone();
1814 let provider_keys = synaps_cli::config::get_provider_keys();
1815 let health_tx = app.ping_tx.clone();
1816 tokio::spawn(async move {
1817 synaps_cli::runtime::openai::ping::ping_all_configured(
1818 &client, &provider_keys, health_tx,
1819 ).await;
1820 });
1821 }
1822 }
1823 }
1824 // FIX C (defense in depth): EventStream yields Err or None when
1825 // crossterm detects the PTY is gone. Break cleanly here.
1826 // NOTE: on some kernels crossterm's EPOLL loop can spin without ever
1827 // yielding Err/None on a dead PTY (the confirmed busy-loop bug). The
1828 // render thread's I/O error path is the backstop: it logs the error
1829 // and keeps rendering until the main loop tears down (does NOT break
1830 // the render loop on a single I/O error).
1831 Some(Err(_)) | None => break,
1832 }
1833 }
1834
1835 // ── Stream events from runtime ──
1836 maybe_event = async {
1837 if let Some(ref mut s) = stream {
1838 s.next().await
1839 } else {
1840 std::future::pending().await
1841 }
1842 } => {
1843 if let Some(event) = maybe_event {
1844 let do_draw = stream_handler::needs_immediate_draw(&event);
1845 let action = stream_handler::handle_stream_event(event, &mut app, &runtime).await;
1846
1847 match action {
1848 StreamAction::Continue => {
1849 // For Done/Error, clear stream state
1850 if !app.streaming {
1851 stream = None;
1852 cancel_token = None;
1853 steer_tx = None;
1854 // Reclaim gamba if running — resume render thread
1855 // after reclaim restores the terminal.
1856 if let Some(msg) = app.reclaim_gamba() {
1857 render_handle.resume();
1858 app.push_msg(ChatMessage::System(msg));
1859 app.invalidate();
1860 }
1861 }
1862 }
1863 StreamAction::AutoSendQueued(queued) => {
1864 // Drop old stream state (important for cleanup)
1865 drop(stream.take());
1866 drop(cancel_token.take());
1867 drop(steer_tx.take());
1868 // Reclaim gamba if running — resume render thread
1869 // after reclaim restores the terminal.
1870 if let Some(msg) = app.reclaim_gamba() {
1871 render_handle.resume();
1872 app.push_msg(ChatMessage::System(msg));
1873 app.invalidate();
1874 }
1875 // Auto-send the queued message
1876 app.push_msg(ChatMessage::User(queued.clone()));
1877 app.scroll_back = 0;
1878 app.scroll_pinned = true;
1879 let api_content = if let Some(ref ctx) = app.abort_context {
1880 let combined = format!("{}\n\n{}", ctx, queued);
1881 app.abort_context = None;
1882 combined
1883 } else {
1884 queued
1885 };
1886 app.api_messages.push(json!({"role": "user", "content": api_content}));
1887 let ct = CancellationToken::new();
1888 let (s_tx, s_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
1889 app.status_text = Some("connecting…".to_string());
1890 app.streaming = true;
1891 app.spinner_frame = 0;
1892 let term_size = crossterm::terminal::size().map(|(w, h)| ratatui::layout::Size { width: w, height: h }).unwrap_or_default();
1893 if let Some(model) = build_render_model(&mut app, &runtime, ®istry, &secret_prompts, term_size) {
1894 render_handle.publish(model);
1895 }
1896 stream = Some(runtime.run_stream_with_messages(app.api_messages.clone(), ct.clone(), Some(s_rx), Some(secret_prompt_handle.clone()), false).await);
1897 app.status_text = None;
1898 app.push_msg(ChatMessage::Thinking(THINKING_PLACEHOLDER.to_string()));
1899 cancel_token = Some(ct);
1900 steer_tx = Some(s_tx);
1901 }
1902 StreamAction::AutoTriggerEvents => {
1903 drop(stream.take());
1904 drop(cancel_token.take());
1905 drop(steer_tx.take());
1906 let ct = CancellationToken::new();
1907 let (s_tx, s_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
1908 app.streaming = true;
1909 app.spinner_frame = 0;
1910 stream = Some(runtime.run_stream_with_messages(app.api_messages.clone(), ct.clone(), Some(s_rx), Some(secret_prompt_handle.clone()), false).await);
1911 app.push_msg(ChatMessage::Thinking(THINKING_PLACEHOLDER.to_string()));
1912 cancel_token = Some(ct);
1913 steer_tx = Some(s_tx);
1914 }
1915 }
1916
1917 if do_draw {
1918 let term_size = crossterm::terminal::size().map(|(w, h)| ratatui::layout::Size { width: w, height: h }).unwrap_or_default();
1919 if let Some(model) = build_render_model(&mut app, &runtime, ®istry, &secret_prompts, term_size) {
1920 render_handle.publish(model);
1921 }
1922 }
1923 }
1924 }
1925 }
1926 }
1927
1928 // ── PART 2: Bounded teardown — two sequential budgets.
1929 //
1930 // All timing constants are defined in signals.rs (single source of truth):
1931 // SAVE_TIMEOUT_SECS — session save + index record (data safety first)
1932 // HOOKS_TIMEOUT_SECS — on_session_end hook emit (concurrent, fail-open)
1933 // TEARDOWN_TIMEOUT_SECS = SAVE_TIMEOUT_SECS + HOOKS_TIMEOUT_SECS
1934 //
1935 // Session save ALWAYS runs first in its own timeout so slow extension
1936 // handlers cannot starve it. Even if the hook budget is exhausted, the
1937 // session data on disk is already safe before hooks are attempted.
1938 {
1939 let session_id = app.session.id.clone();
1940 let api_messages = app.api_messages.clone();
1941
1942 // ── STEP 1: Save session data — own bounded timeout, highest priority ──
1943 let save_fut = async {
1944 app.save_session().await;
1945
1946 let mut index_record = SessionIndexRecord::end(&session_id);
1947 index_record.turns = Some(api_messages.len());
1948 if let Err(err) = synaps_cli::core::session_index::append_record(&index_record) {
1949 tracing::warn!("failed to append session end index record: {}", err);
1950 }
1951 };
1952
1953 match tokio::time::timeout(
1954 std::time::Duration::from_secs(signals::SAVE_TIMEOUT_SECS),
1955 save_fut,
1956 )
1957 .await
1958 {
1959 Ok(()) => tracing::debug!("session save completed"),
1960 Err(_elapsed) => {
1961 tracing::warn!(
1962 budget_secs = signals::SAVE_TIMEOUT_SECS,
1963 "session save timed out — data may be incomplete"
1964 );
1965 lifecycle::emergency_teardown_terminal();
1966 std::process::exit(1);
1967 }
1968 }
1969
1970 // ── STEP 2: Fire on_session_end hook — own bounded timeout, after save ──
1971 //
1972 // emit_concurrent() dispatches all on_session_end handlers simultaneously
1973 // under one shared timeout window instead of N×5 s serial. This is safe
1974 // because on_session_end only allows `Continue` results — handlers are
1975 // independent fire-and-forget notification calls (deck, d20, jawz-widget,
1976 // synaps-tasks each write to their own stores; no ordering dependency).
1977 //
1978 // Ordering-safety evidence: HookKind::OnSessionEnd::allowed_action_names()
1979 // returns &["continue"] exclusively; allows_result() permits only Continue;
1980 // emit_concurrent() merges injections (N/A here) and treats timeouts as
1981 // continue (fail-open). Serial ordering cannot matter when the return
1982 // value is always Continue and handlers touch disjoint state.
1983 let transcript = Some(api_messages);
1984 let hook_event = synaps_cli::extensions::hooks::events::HookEvent::on_session_end(
1985 &session_id,
1986 transcript,
1987 );
1988 match tokio::time::timeout(
1989 std::time::Duration::from_secs(signals::HOOKS_TIMEOUT_SECS),
1990 runtime.hook_bus().emit_concurrent(&hook_event),
1991 )
1992 .await
1993 {
1994 Ok(_) => tracing::debug!("on_session_end hooks completed"),
1995 Err(_elapsed) => {
1996 tracing::warn!(
1997 budget_secs = signals::HOOKS_TIMEOUT_SECS,
1998 "on_session_end hooks timed out — extensions may not have flushed"
1999 );
2000 // Session is already saved above — no data loss here.
2001 // Fall through to normal teardown.
2002 }
2003 }
2004
2005 tracing::debug!("clean teardown completed");
2006 }
2007
2008 // Let extension shutdown continue in the background; exit should not hang on
2009 // extension post/session-end cleanup or slow child-process teardown.
2010 let _extension_shutdown =
2011 synaps_cli::extensions::manager::ExtensionManager::shutdown_all_detached(
2012 std::sync::Arc::clone(&ext_mgr_shared),
2013 );
2014 // Stop the signal-listener thread (signal-hook handle, not a JoinHandle).
2015 shutdown_signal_task.close();
2016
2017 // Shut down background tasks (inbox watcher, socket, session registry)
2018 background.shutdown();
2019
2020 // ── Render-thread teardown ───────────────────────────────────────────────
2021 //
2022 // The render thread owns the Terminal. We send it a Teardown command and
2023 // wait for the ack within the combined SAVE + HOOKS budget already spent
2024 // above. If the ack doesn't arrive the thread is wedged (dead PTY); we
2025 // skip the join and let process exit reap it — see RenderHandle::teardown.
2026 // This self-bounding teardown replaced the old signal watchdog (#116).
2027 //
2028 // The render thread's do_teardown() calls emergency_teardown_terminal()
2029 // (disable_raw_mode + LeaveAlternateScreen + etc.) and show_cursor(), then
2030 // sends the ack and exits its loop. The Terminal is dropped when the
2031 // thread exits — that's safe because crossterm teardown was already done.
2032 let teardown_budget = std::time::Duration::from_secs(
2033 signals::TEARDOWN_TIMEOUT_SECS.saturating_sub(signals::SAVE_TIMEOUT_SECS),
2034 )
2035 .max(std::time::Duration::from_secs(2));
2036 let acked = render_handle.teardown(teardown_budget);
2037 if !acked {
2038 tracing::warn!("render thread did not ack teardown within budget — watchdog is backstop");
2039 // emergency_teardown_terminal is a no-op if the terminal is already
2040 // restored, so calling it here is safe even if the render thread did
2041 // eventually finish teardown after the timeout.
2042 lifecycle::emergency_teardown_terminal();
2043 }
2044
2045 Ok(())
2046}
2047
2048fn handle_widget_event(
2049 app: &mut App,
2050 event: synaps_cli::extensions::widgets::ExtensionWidgetEvent,
2051) -> bool {
2052 use synaps_cli::extensions::widgets::WidgetEvent;
2053 match event.event {
2054 WidgetEvent::Upsert {
2055 id,
2056 lines,
2057 styled_lines,
2058 position,
2059 title,
2060 ttl_secs,
2061 } => {
2062 let pos = match position.as_str() {
2063 "top_left" => toast::ToastPosition::TOP_LEFT,
2064 "top_center" => toast::ToastPosition::TOP_CENTER,
2065 "top_right" => toast::ToastPosition::TOP_RIGHT,
2066 "middle_left" => toast::ToastPosition::MIDDLE_LEFT,
2067 "center" => toast::ToastPosition::CENTER,
2068 "middle_right" => toast::ToastPosition::MIDDLE_RIGHT,
2069 "bottom_left" => toast::ToastPosition::BOTTOM_LEFT,
2070 "bottom_center" => toast::ToastPosition::BOTTOM_CENTER,
2071 "bottom_right" => toast::ToastPosition::BOTTOM_RIGHT,
2072 _ => toast::ToastPosition::TOP_RIGHT,
2073 };
2074 let ttl = ttl_secs.map(std::time::Duration::from_secs);
2075 let mut t = toast::Toast::new(
2076 format!("widget:{}", id),
2077 lines.first().cloned().unwrap_or_default(),
2078 )
2079 .lines(lines)
2080 .at(pos)
2081 .ttl(ttl);
2082 // Convert styled_lines → rich ratatui Lines if present.
2083 if let Some(styled) = styled_lines {
2084 use ratatui::style::Style;
2085 use ratatui::text::{Line, Span};
2086 let rich: Vec<Line<'static>> = styled
2087 .into_iter()
2088 .map(|spans| {
2089 Line::from(
2090 spans
2091 .into_iter()
2092 .map(|s| {
2093 let mut style = Style::default();
2094 if let Some(ref fg) = s.fg {
2095 if let Some(c) = parse_hex_color(fg) {
2096 style = style.fg(c);
2097 }
2098 }
2099 if let Some(ref bg) = s.bg {
2100 if let Some(c) = parse_hex_color(bg) {
2101 style = style.bg(c);
2102 }
2103 }
2104 Span::styled(s.text, style)
2105 })
2106 .collect::<Vec<_>>(),
2107 )
2108 })
2109 .collect();
2110 t = t.rich(rich);
2111 }
2112 if let Some(title) = title {
2113 t = t.titled(title);
2114 }
2115 app.toasts.upsert(t)
2116 }
2117 WidgetEvent::Dismiss { id } => {
2118 app.toasts.dismiss(&format!("widget:{}", id))
2119 }
2120 }
2121}
2122
2123/// Parse a CSS-style hex color string (e.g. "#ff0000") into a ratatui Color.
2124fn parse_hex_color(s: &str) -> Option<ratatui::style::Color> {
2125 let s = s.strip_prefix('#')?;
2126 if s.len() != 6 {
2127 return None;
2128 }
2129 let r = u8::from_str_radix(&s[0..2], 16).ok()?;
2130 let g = u8::from_str_radix(&s[2..4], 16).ok()?;
2131 let b = u8::from_str_radix(&s[4..6], 16).ok()?;
2132 Some(ratatui::style::Color::Rgb(r, g, b))
2133}
2134
2135fn handle_extension_loader_toast(app: &mut App, title: &str, lines: Vec<String>, persistent: bool) {
2136 app.toasts.upsert(
2137 toast::Toast::new("extension-loader", "")
2138 .titled(title)
2139 .lines(lines)
2140 .at(toast::ToastPosition::TOP_CENTER)
2141 .ttl(if persistent {
2142 None
2143 } else {
2144 Some(std::time::Duration::from_secs(5))
2145 }),
2146 );
2147 app.invalidate();
2148}
2149
2150async fn handle_extension_loader_event(
2151 app: &mut App,
2152 runtime: &Runtime,
2153 event: synaps_cli::extensions::loader::ExtensionLoaderEvent,
2154 ext_mgr: &std::sync::Arc<
2155 tokio::sync::RwLock<synaps_cli::extensions::manager::ExtensionManager>,
2156 >,
2157) {
2158 use synaps_cli::extensions::loader::ExtensionLoaderEvent;
2159 match event {
2160 ExtensionLoaderEvent::Started => {
2161 handle_extension_loader_toast(
2162 app,
2163 "Extensions",
2164 vec!["Discovering extensions…".into()],
2165 true,
2166 );
2167 }
2168 ExtensionLoaderEvent::Loaded {
2169 plugin,
2170 loaded,
2171 failed,
2172 } => {
2173 handle_extension_loader_toast(
2174 app,
2175 "Extensions",
2176 vec![
2177 format!(
2178 "Loaded {loaded} extension{}",
2179 if loaded == 1 { "" } else { "s" }
2180 ),
2181 format!("Latest: {plugin}"),
2182 format!("Failures: {failed}"),
2183 ],
2184 true,
2185 );
2186 }
2187 ExtensionLoaderEvent::Failed {
2188 failure,
2189 loaded,
2190 failed,
2191 } => {
2192 handle_extension_loader_toast(
2193 app,
2194 "Extensions",
2195 vec![
2196 format!("Loaded {loaded}, failed {failed}"),
2197 format!("⚠ {}", failure.plugin),
2198 ],
2199 true,
2200 );
2201 app.push_msg(ChatMessage::System(format!(
2202 "⚠ Extension '{}' failed: {}",
2203 failure.plugin,
2204 failure.concise_message()
2205 )));
2206 }
2207 ExtensionLoaderEvent::Finished { loaded, failed } => {
2208 app.extension_loader_running = false;
2209 let handler_count = runtime.hook_bus().handler_count().await;
2210 tracing::info!(
2211 extensions = loaded.len(),
2212 failures = failed.len(),
2213 handlers = handler_count,
2214 "Extension discovery complete"
2215 );
2216 let lines = if failed.is_empty() {
2217 vec![format!(
2218 "✓ Loaded {} extension{}",
2219 loaded.len(),
2220 if loaded.len() == 1 { "" } else { "s" }
2221 )]
2222 } else {
2223 vec![
2224 format!(
2225 "Loaded {} extension{}",
2226 loaded.len(),
2227 if loaded.len() == 1 { "" } else { "s" }
2228 ),
2229 format!("{} failed — see transcript", failed.len()),
2230 ]
2231 };
2232 handle_extension_loader_toast(app, "Extensions", lines, false);
2233
2234 // Spawn a background notification watcher for each loaded extension.
2235 // The watcher forwards widget.* notifications to the TUI via widget_tx.
2236 let handlers = ext_mgr.read().await.handlers();
2237 for (ext_id, handler) in handlers {
2238 let widget_tx = app.widget_tx.clone();
2239 tokio::spawn(async move {
2240 loop {
2241 let (_sub_id, mut rx) = handler.subscribe_notifications().await;
2242 while let Some(frame) = rx.recv().await {
2243 if synaps_cli::extensions::widgets::is_widget_method(&frame.method) {
2244 if let Ok(event) =
2245 synaps_cli::extensions::widgets::parse_widget_event(
2246 &frame.method,
2247 &frame.params,
2248 )
2249 {
2250 let _ = widget_tx.send(
2251 synaps_cli::extensions::widgets::ExtensionWidgetEvent {
2252 extension_id: ext_id.clone(),
2253 event,
2254 },
2255 );
2256 }
2257 }
2258 }
2259 // rx closed (EOF/restart) — resubscribe after a brief delay
2260 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
2261 }
2262 });
2263 }
2264 }
2265 }
2266}
2267
2268/// Phase 8 slice 8A.8: when a plugin has staked a lifecycle claim and
2269/// declared a `settings_category`, copy the legacy global
2270/// `sidecar_toggle_key` value into the plugin-namespaced equivalent
2271/// (`plugins.{plugin}.{cat}._lifecycle_toggle_key`) so the user's
2272/// toggle-key choice follows them across the rename. Idempotent: any
2273/// claim whose new key is already set is skipped, and a missing legacy
2274/// value is a no-op.
2275fn migrate_sidecar_toggle_key_to_claimed_plugins(
2276 claims: &[synaps_cli::skills::registry::LifecycleClaim],
2277) {
2278 const LEGACY: &str = "sidecar_toggle_key";
2279 let Some(legacy_value) = synaps_cli::config::read_config_value(LEGACY) else {
2280 return;
2281 };
2282 let trimmed = legacy_value.trim();
2283 if trimmed.is_empty() {
2284 return;
2285 }
2286 for claim in claims {
2287 let Some(ref cat) = claim.settings_category else {
2288 continue;
2289 };
2290 let new_key = format!("plugins.{}.{}._lifecycle_toggle_key", claim.plugin, cat);
2291 if synaps_cli::config::read_config_value(&new_key).is_some() {
2292 continue;
2293 }
2294 match synaps_cli::config::write_config_value(&new_key, trimmed) {
2295 Ok(()) => tracing::info!(
2296 "sidecar migration: copied global `{}` → `{}` for plugin `{}`",
2297 LEGACY,
2298 new_key,
2299 claim.plugin,
2300 ),
2301 Err(err) => tracing::warn!(
2302 "sidecar migration: failed to copy `{}` → `{}`: {}",
2303 LEGACY,
2304 new_key,
2305 err,
2306 ),
2307 }
2308 }
2309}
2310
2311/// Look up the display name for a sidecar's owning plugin from the
2312/// lifecycle-claim snapshot. Returns `None` if no claim matches.
2313///
2314/// Phase 8 8A.5 follow-up: used post-spawn to populate
2315/// [`SidecarUiState::display_name`] from the registry claim.
2316fn pick_display_name_for_plugin(
2317 plugin_name: &str,
2318 claims: &[synaps_cli::skills::registry::LifecycleClaim],
2319) -> Option<String> {
2320 claims
2321 .iter()
2322 .find(|c| c.plugin == plugin_name)
2323 .map(|c| c.display_name.clone())
2324}
2325
2326#[cfg(test)]
2327mod migration_tests {
2328 use super::*;
2329 use serial_test::serial;
2330 use synaps_cli::skills::registry::LifecycleClaim;
2331
2332 fn make_test_home(subdir: &str) -> std::path::PathBuf {
2333 let dir = std::path::PathBuf::from(format!("/tmp/synaps-mig-test-{}", subdir));
2334 let _ = std::fs::remove_dir_all(&dir);
2335 std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
2336 dir
2337 }
2338
2339 fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
2340 let original = std::env::var("HOME").ok();
2341 std::env::set_var("HOME", home);
2342 f();
2343 if let Some(h) = original {
2344 std::env::set_var("HOME", h);
2345 } else {
2346 std::env::remove_var("HOME");
2347 }
2348 }
2349
2350 fn claim(plugin: &str, command: &str, cat: Option<&str>) -> LifecycleClaim {
2351 LifecycleClaim {
2352 plugin: plugin.to_string(),
2353 command: command.to_string(),
2354 settings_category: cat.map(str::to_string),
2355 display_name: command.to_string(),
2356 importance: 0,
2357 }
2358 }
2359
2360 #[test]
2361 #[serial]
2362 fn migrate_copies_legacy_into_namespaced_key() {
2363 let home = make_test_home("copy-into-namespaced");
2364 let cfg = home.join(".synaps-cli/config");
2365 std::fs::write(&cfg, "sidecar_toggle_key = F2\n").unwrap();
2366 with_home(&home, || {
2367 migrate_sidecar_toggle_key_to_claimed_plugins(&[claim(
2368 "sample-sidecar",
2369 "capture",
2370 Some("capture"),
2371 )]);
2372 let v = synaps_cli::config::read_config_value(
2373 "plugins.sample-sidecar.capture._lifecycle_toggle_key",
2374 );
2375 assert_eq!(v.as_deref(), Some("F2"));
2376 });
2377 }
2378
2379 #[test]
2380 #[serial]
2381 fn migrate_skips_when_new_key_already_set() {
2382 let home = make_test_home("skip-existing");
2383 let cfg = home.join(".synaps-cli/config");
2384 std::fs::write(
2385 &cfg,
2386 "sidecar_toggle_key = F2\nplugins.sample-sidecar.capture._lifecycle_toggle_key = F12\n",
2387 )
2388 .unwrap();
2389 with_home(&home, || {
2390 migrate_sidecar_toggle_key_to_claimed_plugins(&[claim(
2391 "sample-sidecar",
2392 "capture",
2393 Some("capture"),
2394 )]);
2395 let v = synaps_cli::config::read_config_value(
2396 "plugins.sample-sidecar.capture._lifecycle_toggle_key",
2397 );
2398 assert_eq!(
2399 v.as_deref(),
2400 Some("F12"),
2401 "must not overwrite a user-set value"
2402 );
2403 });
2404 }
2405
2406 #[test]
2407 #[serial]
2408 fn migrate_is_noop_when_legacy_unset() {
2409 let home = make_test_home("noop-no-legacy");
2410 let cfg = home.join(".synaps-cli/config");
2411 std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
2412 with_home(&home, || {
2413 migrate_sidecar_toggle_key_to_claimed_plugins(&[claim(
2414 "sample-sidecar",
2415 "capture",
2416 Some("capture"),
2417 )]);
2418 assert!(synaps_cli::config::read_config_value(
2419 "plugins.sample-sidecar.capture._lifecycle_toggle_key"
2420 )
2421 .is_none());
2422 });
2423 }
2424
2425 #[test]
2426 #[serial]
2427 fn migrate_skips_claim_without_settings_category() {
2428 let home = make_test_home("skip-no-category");
2429 let cfg = home.join(".synaps-cli/config");
2430 std::fs::write(&cfg, "sidecar_toggle_key = F8\n").unwrap();
2431 with_home(&home, || {
2432 migrate_sidecar_toggle_key_to_claimed_plugins(&[claim("p", "ocr", None)]);
2433 // No namespaced key written for a claim with no category.
2434 let contents = std::fs::read_to_string(&cfg).unwrap();
2435 assert!(
2436 !contents.contains("_lifecycle_toggle_key"),
2437 "no namespaced key should be written when settings_category is None: {contents}"
2438 );
2439 });
2440 }
2441
2442 #[test]
2443 #[serial]
2444 fn migrate_handles_multiple_claims_in_one_pass() {
2445 let home = make_test_home("multi-claim");
2446 let cfg = home.join(".synaps-cli/config");
2447 std::fs::write(&cfg, "sidecar_toggle_key = C-V\n").unwrap();
2448 with_home(&home, || {
2449 migrate_sidecar_toggle_key_to_claimed_plugins(&[
2450 claim("sample-sidecar", "capture", Some("capture")),
2451 claim("ocr-plugin", "ocr", Some("ocr")),
2452 ]);
2453 assert_eq!(
2454 synaps_cli::config::read_config_value(
2455 "plugins.sample-sidecar.capture._lifecycle_toggle_key"
2456 )
2457 .as_deref(),
2458 Some("C-V")
2459 );
2460 assert_eq!(
2461 synaps_cli::config::read_config_value(
2462 "plugins.ocr-plugin.ocr._lifecycle_toggle_key"
2463 )
2464 .as_deref(),
2465 Some("C-V")
2466 );
2467 });
2468 }
2469}
2470
2471#[cfg(test)]
2472mod display_name_helper_tests {
2473 use super::pick_display_name_for_plugin;
2474 use synaps_cli::skills::registry::LifecycleClaim;
2475
2476 fn claim(plugin: &str, display: &str) -> LifecycleClaim {
2477 LifecycleClaim {
2478 plugin: plugin.into(),
2479 command: "capture".into(),
2480 settings_category: None,
2481 display_name: display.into(),
2482 importance: 0,
2483 }
2484 }
2485
2486 #[test]
2487 fn pick_display_name_for_plugin_returns_match() {
2488 let claims = vec![claim("sample-sidecar", "Sample")];
2489 assert_eq!(
2490 pick_display_name_for_plugin("sample-sidecar", &claims),
2491 Some("Sample".to_string())
2492 );
2493 }
2494
2495 #[test]
2496 fn pick_display_name_for_plugin_returns_none_for_unmatched() {
2497 let claims = vec![claim("sample-sidecar", "Sample")];
2498 assert_eq!(pick_display_name_for_plugin("unknown", &claims), None);
2499 }
2500
2501 #[test]
2502 fn pick_display_name_for_plugin_returns_none_with_empty_claims() {
2503 assert_eq!(pick_display_name_for_plugin("sample-sidecar", &[]), None);
2504 }
2505}