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