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