Skip to main content

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