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