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