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