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