Skip to main content

rustyclaw_tui/app/
app.rs

1// ── App — clean iocraft TUI ─────────────────────────────────────────────────
2//
3// Architecture:
4//
5//   CLI (tokio) ──▶ App::run() ──▶ spawns gateway reader (tokio task)
6//                                  spawns iocraft render  (blocking thread)
7//
8//   Gateway events flow through  std::sync::mpsc::Receiver<GwEvent>
9//   User input flows through     std::sync::mpsc::Sender<UserInput>
10//
11//   The iocraft component owns ALL UI state and runs entirely on smol.
12//   No Arc<Mutex<_>> shared state — just channels.
13
14use anyhow::Result;
15use std::sync::mpsc as sync_mpsc;
16
17use rustyclaw_core::commands::{CommandAction, CommandContext, CommandResponse, handle_command};
18use rustyclaw_core::config::Config;
19use rustyclaw_core::gateway::{
20    ChatMessage, ClientFrame, ClientFrameType, ClientPayload, ServerFrame, deserialize_frame,
21    serialize_frame,
22};
23use rustyclaw_core::secrets::SecretsManager;
24use rustyclaw_core::skills::SkillManager;
25use rustyclaw_core::soul::SoulManager;
26
27use crate::gateway_client;
28
29// ── Channel message types ───────────────────────────────────────────────────
30
31/// Events pushed from the gateway reader into the iocraft render component.
32#[derive(Debug, Clone)]
33pub(crate) enum GwEvent {
34    Disconnected(String),
35    AuthChallenge,
36    Authenticated,
37    ModelReady(String),
38    /// Gateway reloaded config — update model label in status bar
39    ModelReloaded {
40        provider: String,
41        model: String,
42    },
43    Info(String),
44    Success(String),
45    Warning(String),
46    Error(String),
47    StreamStart,
48    Chunk(String),
49    ResponseDone,
50    ThinkingStart,
51    ThinkingDelta,
52    ThinkingEnd,
53    ToolCall {
54        name: String,
55        arguments: String,
56    },
57    ToolResult {
58        result: String,
59    },
60    /// Gateway requests user approval for a tool call (Ask mode)
61    ToolApprovalRequest {
62        id: String,
63        name: String,
64        arguments: String,
65    },
66    /// Gateway requests structured user input (ask_user tool)
67    UserPromptRequest(rustyclaw_core::user_prompt_types::UserPrompt),
68    /// Vault is locked — user needs to provide password
69    VaultLocked,
70    /// Vault was successfully unlocked
71    VaultUnlocked,
72    /// Show secrets info dialog
73    ShowSecrets {
74        secrets: Vec<crate::components::secrets_dialog::SecretInfo>,
75        agent_access: bool,
76        has_totp: bool,
77    },
78    /// Show skills info dialog
79    ShowSkills {
80        skills: Vec<crate::components::skills_dialog::SkillInfo>,
81    },
82    /// Show tool permissions info dialog
83    ShowToolPerms {
84        tools: Vec<crate::components::tool_perms_dialog::ToolPermInfo>,
85    },
86    /// A secrets mutation succeeded — re-fetch the list from the gateway
87    RefreshSecrets,
88    /// Thread list update from gateway (unified tasks + threads)
89    ThreadsUpdate {
90        threads: Vec<crate::action::ThreadInfo>,
91        #[allow(dead_code)]
92        foreground_id: Option<u64>,
93    },
94    /// Thread switch confirmed — clear messages and show context
95    ThreadSwitched {
96        thread_id: u64,
97        context_summary: Option<String>,
98    },
99    /// Hatching identity generated
100    HatchingResponse(String),
101}
102
103/// Messages from the iocraft render component back to tokio.
104#[derive(Debug, Clone)]
105pub(crate) enum UserInput {
106    Chat(String),
107    Command(String),
108    AuthResponse(String),
109    /// User approved or denied a tool call
110    ToolApprovalResponse {
111        id: String,
112        approved: bool,
113    },
114    /// User submitted vault password
115    VaultUnlock(String),
116    /// User responded to a structured prompt
117    UserPromptResponse {
118        id: String,
119        dismissed: bool,
120        value: rustyclaw_core::user_prompt_types::PromptResponseValue,
121    },
122    /// Feed back the completed assistant response for conversation history tracking.
123    AssistantResponse(String),
124    /// Toggle a skill's enabled state
125    ToggleSkill {
126        name: String,
127    },
128    /// Cycle a tool's permission level (Allow → Ask → Deny → SkillOnly → Allow)
129    CycleToolPermission {
130        name: String,
131    },
132    /// Cycle a secret's access policy (OPEN → ASK → AUTH → SKILL)
133    CycleSecretPolicy {
134        name: String,
135        current_policy: String,
136    },
137    /// Delete a secret credential
138    DeleteSecret {
139        name: String,
140    },
141    /// Add a new secret (API key)
142    AddSecret {
143        name: String,
144        value: String,
145    },
146    /// Re-request secrets list from gateway (after a mutation)
147    RefreshSecrets,
148    /// Request current task list from gateway
149    RefreshTasks,
150    /// Request current thread list from gateway
151    RefreshThreads,
152    /// Switch to a different thread
153    ThreadSwitch(u64),
154    /// Create a new thread
155    #[allow(dead_code)]
156    ThreadCreate(String),
157    /// Request identity generation for hatching
158    HatchingRequest,
159    /// Hatching response received - save to SOUL.md
160    HatchingComplete(String),
161    Quit,
162}
163
164// ── App ─────────────────────────────────────────────────────────────────────
165
166pub struct App {
167    config: Config,
168    secrets_manager: SecretsManager,
169    skill_manager: SkillManager,
170    soul_manager: SoulManager,
171    deferred_vault_password: Option<String>,
172}
173
174impl App {
175    pub fn new(config: Config) -> Result<Self> {
176        let secrets_manager = SecretsManager::locked(config.credentials_dir());
177        Self::build(config, secrets_manager)
178    }
179
180    pub fn with_password(config: Config, password: String) -> Result<Self> {
181        let mut app = Self::new(config)?;
182        app.deferred_vault_password = Some(password);
183        Ok(app)
184    }
185
186    pub fn new_locked(config: Config) -> Result<Self> {
187        Self::new(config)
188    }
189
190    pub fn set_deferred_vault_password(&mut self, password: String) {
191        self.deferred_vault_password = Some(password);
192    }
193
194    fn build(config: Config, mut secrets_manager: SecretsManager) -> Result<Self> {
195        if !config.use_secrets {
196            secrets_manager.set_agent_access(false);
197        } else {
198            secrets_manager.set_agent_access(config.agent_access);
199        }
200
201        let skills_dirs = config.skills_dirs();
202        let mut skill_manager = SkillManager::with_dirs(skills_dirs);
203        let _ = skill_manager.load_skills();
204
205        let soul_path = config.soul_path();
206        let mut soul_manager = SoulManager::new(soul_path);
207        let _ = soul_manager.load();
208
209        Ok(Self {
210            config,
211            secrets_manager,
212            skill_manager,
213            soul_manager,
214            deferred_vault_password: None,
215        })
216    }
217
218    /// Run the TUI — this takes over the terminal.
219    pub async fn run(&mut self) -> Result<()> {
220        // Apply deferred vault password if one was provided at startup
221        if let Some(pw) = self.deferred_vault_password.take() {
222            self.secrets_manager.set_password(pw);
223        }
224
225        // Channels: gateway → UI
226        let (gw_tx, gw_rx) = sync_mpsc::channel::<GwEvent>();
227        // Channels: UI → tokio (for sending chat to gateway)
228        let (user_tx, user_rx) = sync_mpsc::channel::<UserInput>();
229
230        // ── Gather static info for the component ────────────────────────
231        // Use the configured agent_name — no need to parse SOUL.md
232        let soul_name = self.config.agent_name.clone();
233        
234        // Check if soul needs hatching (first run or default content)
235        let needs_hatching = self.soul_manager.needs_hatching();
236
237        let provider = self
238            .config
239            .model
240            .as_ref()
241            .map(|m| m.provider.clone())
242            .unwrap_or_default();
243
244        let model = self
245            .config
246            .model
247            .as_ref()
248            .and_then(|m| m.model.clone())
249            .unwrap_or_default();
250
251        let model_label = if provider.is_empty() {
252            String::new()
253        } else if model.is_empty() {
254            provider.clone()
255        } else {
256            format!("{} / {}", provider, model)
257        };
258
259        let gateway_url = self
260            .config
261            .gateway_url
262            .clone()
263            .unwrap_or_else(|| "ws://127.0.0.1:9001".to_string());
264
265        let hint = "Ctrl+C quit · /help commands · ↑↓ scroll".to_string();
266
267        // ── Connect to gateway ──────────────────────────────────────────
268        let gw_tx_conn = gw_tx.clone();
269        let gateway_url_clone = gateway_url.clone();
270
271        // Use a oneshot for the write-half of the WS connection.
272        type WsSink = futures_util::stream::SplitSink<
273            tokio_tungstenite::WebSocketStream<
274                tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
275            >,
276            tokio_tungstenite::tungstenite::Message,
277        >;
278
279        let (sink_tx, sink_rx) = tokio::sync::oneshot::channel::<WsSink>();
280
281        let _reader_handle = tokio::spawn(async move {
282            use futures_util::StreamExt;
283            use tokio_tungstenite::connect_async;
284
285            match connect_async(&gateway_url_clone).await {
286                Ok((ws, _)) => {
287                    let (write, mut read) = StreamExt::split(ws);
288                    let _ = sink_tx.send(write);
289                    // Don't report Connected yet — wait for auth flow.
290                    // The gateway will send AuthChallenge or Hello+Status frames.
291
292                    while let Some(msg) = read.next().await {
293                        match msg {
294                            Ok(tokio_tungstenite::tungstenite::Message::Binary(data)) => {
295                                match deserialize_frame::<ServerFrame>(&data) {
296                                    Ok(frame) => {
297                                        // Check for ModelReady status before action conversion
298                                        // since it maps to a generic Success action otherwise.
299                                        let is_model_ready = matches!(
300                                            &frame.payload,
301                                            rustyclaw_core::gateway::ServerPayload::Status {
302                                                status:
303                                                    rustyclaw_core::gateway::StatusType::ModelReady,
304                                                ..
305                                            }
306                                        );
307                                        if is_model_ready {
308                                            if let rustyclaw_core::gateway::ServerPayload::Status { detail, .. } = &frame.payload {
309                                            let _ = gw_tx_conn.send(GwEvent::ModelReady(detail.clone()));
310                                        }
311                                        } else {
312                                            let fa = gateway_client::server_frame_to_action(&frame);
313                                            if let Some(action) = fa.action {
314                                                let ev = action_to_gw_event(&action);
315                                                if let Some(ev) = ev {
316                                                    let _ = gw_tx_conn.send(ev);
317                                                }
318                                            }
319                                        }
320                                    }
321                                    Err(e) => {
322                                        eprintln!(
323                                            "[rustyclaw] Failed to deserialize server frame ({} bytes): {}",
324                                            data.len(),
325                                            e
326                                        );
327                                        let _ = gw_tx_conn.send(GwEvent::Error(format!(
328                                            "Protocol error: failed to deserialize frame ({}). Gateway/TUI version mismatch?",
329                                            e
330                                        )));
331                                    }
332                                }
333                            }
334                            Ok(tokio_tungstenite::tungstenite::Message::Close(_)) => {
335                                let _ = gw_tx_conn.send(GwEvent::Disconnected("closed".into()));
336                                break;
337                            }
338                            Err(e) => {
339                                let _ = gw_tx_conn.send(GwEvent::Disconnected(e.to_string()));
340                                break;
341                            }
342                            _ => {}
343                        }
344                    }
345                }
346                Err(e) => {
347                    drop(sink_tx);
348                    let _ = gw_tx_conn
349                        .send(GwEvent::Error(format!("Gateway connection failed: {}", e)));
350                    let _ = gw_tx_conn.send(GwEvent::Disconnected(e.to_string()));
351                }
352            }
353        });
354
355        // Try to get the write-half.
356        let mut ws_sink: Option<WsSink> = match sink_rx.await {
357            Ok(s) => Some(s),
358            Err(_) => None,
359        };
360
361        // ── Spawn the iocraft render on a blocking thread ───────────────
362        // Stash the channels in statics so the component can grab them on
363        // first render (via use_const). This avoids ownership issues with
364        // iocraft props.
365        *tui_component::CHANNEL_RX.lock().unwrap() = Some(gw_rx);
366        *tui_component::CHANNEL_TX.lock().unwrap() = Some(user_tx);
367
368        let render_handle = tokio::task::spawn_blocking(move || {
369            use iocraft::prelude::*;
370            smol::block_on(
371                element!(TuiRoot(
372                    soul_name: soul_name,
373                    model_label: model_label,
374                    provider_id: provider.clone(),
375                    hint: hint,
376                    needs_hatching: needs_hatching,
377                ))
378                .fullscreen()
379                .disable_mouse_capture(),
380            )
381        });
382
383        // ── Tokio loop: handle UserInput from UI ────────────────────────
384        let mut conversation: Vec<ChatMessage> = Vec::new();
385        let config = &mut self.config;
386        let secrets_manager = &mut self.secrets_manager;
387        let skill_manager = &mut self.skill_manager;
388
389        loop {
390            // Poll user_rx (non-blocking on tokio side)
391            match user_rx.try_recv() {
392                Ok(UserInput::Chat(text)) => {
393                    conversation.push(ChatMessage::text("user", &text));
394                    if let Some(ref mut sink) = ws_sink {
395                        use futures_util::SinkExt;
396                        let frame = ClientFrame {
397                            frame_type: ClientFrameType::Chat,
398                            payload: ClientPayload::Chat {
399                                messages: conversation.clone(),
400                            },
401                        };
402                        if let Ok(data) = serialize_frame(&frame) {
403                            let _ = sink
404                                .send(tokio_tungstenite::tungstenite::Message::Binary(data.into()))
405                                .await;
406                        }
407                    }
408                }
409                Ok(UserInput::AuthResponse(code)) => {
410                    if let Some(ref mut sink) = ws_sink {
411                        use futures_util::SinkExt;
412                        let frame = ClientFrame {
413                            frame_type: ClientFrameType::AuthResponse,
414                            payload: ClientPayload::AuthResponse { code },
415                        };
416                        if let Ok(data) = serialize_frame(&frame) {
417                            let _ = sink
418                                .send(tokio_tungstenite::tungstenite::Message::Binary(data.into()))
419                                .await;
420                        }
421                    }
422                }
423                Ok(UserInput::ToolApprovalResponse { id, approved }) => {
424                    if let Some(ref mut sink) = ws_sink {
425                        use futures_util::SinkExt;
426                        let frame = ClientFrame {
427                            frame_type: ClientFrameType::ToolApprovalResponse,
428                            payload: ClientPayload::ToolApprovalResponse { id, approved },
429                        };
430                        if let Ok(data) = serialize_frame(&frame) {
431                            let _ = sink
432                                .send(tokio_tungstenite::tungstenite::Message::Binary(data.into()))
433                                .await;
434                        }
435                    }
436                }
437                Ok(UserInput::VaultUnlock(password)) => {
438                    // Unlock locally so /secrets can read the vault
439                    secrets_manager.set_password(password.clone());
440                    if let Some(ref mut sink) = ws_sink {
441                        use futures_util::SinkExt;
442                        let frame = ClientFrame {
443                            frame_type: ClientFrameType::UnlockVault,
444                            payload: ClientPayload::UnlockVault { password },
445                        };
446                        if let Ok(data) = serialize_frame(&frame) {
447                            let _ = sink
448                                .send(tokio_tungstenite::tungstenite::Message::Binary(data.into()))
449                                .await;
450                        }
451                    }
452                }
453                Ok(UserInput::UserPromptResponse {
454                    id,
455                    dismissed,
456                    value,
457                }) => {
458                    if let Some(ref mut sink) = ws_sink {
459                        use futures_util::SinkExt;
460                        let frame = ClientFrame {
461                            frame_type: ClientFrameType::UserPromptResponse,
462                            payload: ClientPayload::UserPromptResponse {
463                                id,
464                                dismissed,
465                                value,
466                            },
467                        };
468                        if let Ok(data) = serialize_frame(&frame) {
469                            let _ = sink
470                                .send(tokio_tungstenite::tungstenite::Message::Binary(data.into()))
471                                .await;
472                        }
473                    }
474                }
475                Ok(UserInput::AssistantResponse(text)) => {
476                    // Feed the completed assistant response into the conversation
477                    // so subsequent Chat frames include the full history.
478                    conversation.push(ChatMessage::text("assistant", &text));
479                }
480                Ok(UserInput::Command(cmd)) => {
481                    let mut ctx = CommandContext {
482                        config,
483                        secrets_manager,
484                        skill_manager,
485                    };
486                    let resp: CommandResponse = handle_command(&cmd, &mut ctx);
487                    // Send feedback to UI via gateway channel
488                    for msg in &resp.messages {
489                        let _ = gw_tx.send(GwEvent::Info(msg.clone()));
490                    }
491                    match resp.action {
492                        CommandAction::Quit => break,
493                        CommandAction::ShowSecrets => {
494                            // Request secrets list from the gateway daemon
495                            // (secrets live in the gateway's vault, not locally).
496                            if let Some(ref mut sink) = ws_sink {
497                                use futures_util::SinkExt;
498                                let frame = ClientFrame {
499                                    frame_type: ClientFrameType::SecretsList,
500                                    payload: ClientPayload::SecretsList,
501                                };
502                                if let Ok(data) = serialize_frame(&frame) {
503                                    let _ = sink
504                                        .send(tokio_tungstenite::tungstenite::Message::Binary(
505                                            data.into(),
506                                        ))
507                                        .await;
508                                }
509                            }
510                        }
511                        CommandAction::ShowSkills => {
512                            let skills_list: Vec<_> = skill_manager
513                                .get_skills()
514                                .iter()
515                                .map(|s| crate::components::skills_dialog::SkillInfo {
516                                    name: s.name.clone(),
517                                    description: s.description.clone().unwrap_or_default(),
518                                    enabled: s.enabled,
519                                })
520                                .collect();
521                            let _ = gw_tx.send(GwEvent::ShowSkills {
522                                skills: skills_list,
523                            });
524                        }
525                        CommandAction::ShowToolPermissions => {
526                            let tool_names = rustyclaw_core::tools::all_tool_names();
527                            let tools: Vec<_> = tool_names
528                                .iter()
529                                .map(|name| {
530                                    let perm = config
531                                        .tool_permissions
532                                        .get(*name)
533                                        .cloned()
534                                        .unwrap_or_default();
535                                    crate::components::tool_perms_dialog::ToolPermInfo {
536                                        name: name.to_string(),
537                                        permission: perm.badge().to_string(),
538                                        summary: rustyclaw_core::tools::tool_summary(name)
539                                            .to_string(),
540                                    }
541                                })
542                                .collect();
543                            let _ = gw_tx.send(GwEvent::ShowToolPerms { tools });
544                        }
545                        CommandAction::ThreadNew(label) => {
546                            // Send thread create to gateway
547                            if let Some(ref mut sink) = ws_sink {
548                                use futures_util::SinkExt;
549                                let frame = ClientFrame {
550                                    frame_type: ClientFrameType::ThreadCreate,
551                                    payload: ClientPayload::ThreadCreate { label },
552                                };
553                                if let Ok(data) = serialize_frame(&frame) {
554                                    let _ = sink
555                                        .send(tokio_tungstenite::tungstenite::Message::Binary(
556                                            data.into(),
557                                        ))
558                                        .await;
559                                }
560                            }
561                        }
562                        CommandAction::ThreadList => {
563                            // Focus sidebar to show threads
564                            let _ = gw_tx.send(GwEvent::Info(
565                                "Press Tab to focus sidebar and navigate threads.".to_string(),
566                            ));
567                        }
568                        CommandAction::ThreadClose(id) => {
569                            // Send thread close to gateway
570                            if let Some(ref mut sink) = ws_sink {
571                                use futures_util::SinkExt;
572                                let frame = ClientFrame {
573                                    frame_type: ClientFrameType::ThreadClose,
574                                    payload: ClientPayload::ThreadClose { thread_id: id },
575                                };
576                                if let Ok(data) = serialize_frame(&frame) {
577                                    let _ = sink
578                                        .send(tokio_tungstenite::tungstenite::Message::Binary(
579                                            data.into(),
580                                        ))
581                                        .await;
582                                }
583                            }
584                        }
585                        CommandAction::ThreadRename(id, new_label) => {
586                            // Send thread rename to gateway
587                            if let Some(ref mut sink) = ws_sink {
588                                use futures_util::SinkExt;
589                                let frame = ClientFrame {
590                                    frame_type: ClientFrameType::ThreadRename,
591                                    payload: ClientPayload::ThreadRename {
592                                        thread_id: id,
593                                        new_label,
594                                    },
595                                };
596                                if let Ok(data) = serialize_frame(&frame) {
597                                    let _ = sink
598                                        .send(tokio_tungstenite::tungstenite::Message::Binary(
599                                            data.into(),
600                                        ))
601                                        .await;
602                                }
603                            }
604                        }
605                        CommandAction::ThreadBackground => {
606                            // Background the current foreground thread by switching
607                            // to thread_id 0 (sentinel: no foreground thread).
608                            if let Some(ref mut sink) = ws_sink {
609                                use futures_util::SinkExt;
610                                let frame = ClientFrame {
611                                    frame_type: ClientFrameType::ThreadSwitch,
612                                    payload: ClientPayload::ThreadSwitch { thread_id: 0 },
613                                };
614                                if let Ok(data) = serialize_frame(&frame) {
615                                    let _ = sink
616                                        .send(tokio_tungstenite::tungstenite::Message::Binary(
617                                            data.into(),
618                                        ))
619                                        .await;
620                                }
621                                let _ = gw_tx.send(GwEvent::Info(
622                                    "Current thread backgrounded. Use /thread fg <id> or sidebar to switch.".to_string(),
623                                ));
624                            }
625                        }
626                        CommandAction::ThreadForeground(id) => {
627                            // Foreground a thread by ID — reuse ThreadSwitch
628                            if let Some(ref mut sink) = ws_sink {
629                                use futures_util::SinkExt;
630                                let frame = ClientFrame {
631                                    frame_type: ClientFrameType::ThreadSwitch,
632                                    payload: ClientPayload::ThreadSwitch { thread_id: id },
633                                };
634                                if let Ok(data) = serialize_frame(&frame) {
635                                    let _ = sink
636                                        .send(tokio_tungstenite::tungstenite::Message::Binary(
637                                            data.into(),
638                                        ))
639                                        .await;
640                                }
641                            }
642                        }
643                        CommandAction::SetModel(model_name) => {
644                            // /model only changes the model, never the provider.
645                            // The model name is used exactly as entered — on
646                            // OpenRouter, IDs like "anthropic/claude-opus-4-20250514"
647                            // include a provider prefix that is part of the model ID,
648                            // not a directive to switch providers.  Use /provider to
649                            // change providers.
650                            let existing_provider = config
651                                .model
652                                .as_ref()
653                                .map(|m| m.provider.clone())
654                                .unwrap_or_else(|| "openrouter".to_string());
655
656                            // Update config — keep the current provider, only change model
657                            config.model = Some(rustyclaw_core::config::ModelProvider {
658                                provider: existing_provider,
659                                model: Some(model_name.clone()),
660                                base_url: config.model.as_ref().and_then(|m| m.base_url.clone()),
661                            });
662
663                            // Save config and tell the gateway to reload so the
664                            // new model takes effect immediately (no restart needed).
665                            if let Err(e) = config.save(None) {
666                                let _ = gw_tx
667                                    .send(GwEvent::Error(format!("Failed to save config: {}", e)));
668                            } else {
669                                let _ = gw_tx.send(GwEvent::Info(format!(
670                                    "Model set to {}. Reloading gateway…",
671                                    model_name
672                                )));
673                                // Send Reload frame so the gateway picks up the new config
674                                if let Some(ref mut sink) = ws_sink {
675                                    use futures_util::SinkExt;
676                                    let frame = ClientFrame {
677                                        frame_type: ClientFrameType::Reload,
678                                        payload: ClientPayload::Reload,
679                                    };
680                                    if let Ok(data) = serialize_frame(&frame) {
681                                        let _ = sink
682                                            .send(tokio_tungstenite::tungstenite::Message::Binary(
683                                                data.into(),
684                                            ))
685                                            .await;
686                                    }
687                                }
688                            }
689                        }
690                        CommandAction::SetProvider(provider_name) => {
691                            // Update config with new provider, keep existing model
692                            let existing_model =
693                                config.model.as_ref().and_then(|m| m.model.clone());
694                            config.model = Some(rustyclaw_core::config::ModelProvider {
695                                provider: provider_name.clone(),
696                                model: existing_model,
697                                base_url: config.model.as_ref().and_then(|m| m.base_url.clone()),
698                            });
699
700                            // Save config and tell the gateway to reload
701                            if let Err(e) = config.save(None) {
702                                let _ = gw_tx
703                                    .send(GwEvent::Error(format!("Failed to save config: {}", e)));
704                            } else {
705                                let _ = gw_tx.send(GwEvent::Info(format!(
706                                    "Provider set to {}. Reloading gateway…",
707                                    provider_name
708                                )));
709                                if let Some(ref mut sink) = ws_sink {
710                                    use futures_util::SinkExt;
711                                    let frame = ClientFrame {
712                                        frame_type: ClientFrameType::Reload,
713                                        payload: ClientPayload::Reload,
714                                    };
715                                    if let Ok(data) = serialize_frame(&frame) {
716                                        let _ = sink
717                                            .send(tokio_tungstenite::tungstenite::Message::Binary(
718                                                data.into(),
719                                            ))
720                                            .await;
721                                    }
722                                }
723                            }
724                        }
725                        CommandAction::GatewayReload => {
726                            // Send Reload frame to the gateway
727                            if let Some(ref mut sink) = ws_sink {
728                                use futures_util::SinkExt;
729                                let frame = ClientFrame {
730                                    frame_type: ClientFrameType::Reload,
731                                    payload: ClientPayload::Reload,
732                                };
733                                if let Ok(data) = serialize_frame(&frame) {
734                                    let _ = sink
735                                        .send(tokio_tungstenite::tungstenite::Message::Binary(
736                                            data.into(),
737                                        ))
738                                        .await;
739                                }
740                            }
741                        }
742                        CommandAction::FetchModels => {
743                            // Spawn an async task to fetch the live model list
744                            // from the provider API and send results back via
745                            // the GwEvent channel.
746                            let provider_id = config
747                                .model
748                                .as_ref()
749                                .map(|m| m.provider.clone())
750                                .unwrap_or_default();
751                            let base_url = config
752                                .model
753                                .as_ref()
754                                .and_then(|m| m.base_url.clone());
755                            // Read the API key: try the encrypted vault first
756                            // (where onboarding stores it), then fall back to
757                            // environment variables.
758                            let api_key = rustyclaw_core::providers::secret_key_for_provider(
759                                &provider_id,
760                            )
761                            .and_then(|key_name| {
762                                secrets_manager
763                                    .get_secret(key_name, true)
764                                    .ok()
765                                    .flatten()
766                                    .or_else(|| std::env::var(key_name).ok())
767                            });
768
769                            let gw_tx2 = gw_tx.clone();
770                            tokio::spawn(async move {
771                                match rustyclaw_core::providers::fetch_models_detailed(
772                                    &provider_id,
773                                    api_key.as_deref(),
774                                    base_url.as_deref(),
775                                )
776                                .await
777                                {
778                                    Ok(models) => {
779                                        let count = models.len();
780                                        let display = rustyclaw_core::providers::display_name_for_provider(&provider_id);
781                                        let _ = gw_tx2.send(GwEvent::Info(format!(
782                                            "{} models from {}:",
783                                            count, display,
784                                        )));
785                                        // Show models in batches to avoid
786                                        // flooding the channel.
787                                        let lines: Vec<String> =
788                                            models.iter().map(|m| m.display_line()).collect();
789                                        for chunk in lines.chunks(20) {
790                                            let _ = gw_tx2.send(GwEvent::Info(
791                                                chunk.join("\n"),
792                                            ));
793                                        }
794                                        let _ = gw_tx2.send(GwEvent::Info(
795                                            "Tip: /model <id> to switch".to_string(),
796                                        ));
797                                    }
798                                    Err(e) => {
799                                        let _ = gw_tx2.send(GwEvent::Error(e));
800                                    }
801                                }
802                            });
803                        }
804                        _ => {}
805                    }
806                }
807                Ok(UserInput::ToggleSkill { name }) => {
808                    if let Some(skill) = skill_manager.get_skills().iter().find(|s| s.name == name)
809                    {
810                        let new_enabled = !skill.enabled;
811                        let _ = skill_manager.set_skill_enabled(&name, new_enabled);
812                        // Re-send updated skills list
813                        let skills_list: Vec<_> = skill_manager
814                            .get_skills()
815                            .iter()
816                            .map(|s| crate::components::skills_dialog::SkillInfo {
817                                name: s.name.clone(),
818                                description: s.description.clone().unwrap_or_default(),
819                                enabled: s.enabled,
820                            })
821                            .collect();
822                        let _ = gw_tx.send(GwEvent::ShowSkills {
823                            skills: skills_list,
824                        });
825                    }
826                }
827                Ok(UserInput::CycleToolPermission { name }) => {
828                    let current = config
829                        .tool_permissions
830                        .get(&name)
831                        .cloned()
832                        .unwrap_or_default();
833                    let next = current.cycle();
834                    config.tool_permissions.insert(name.clone(), next);
835                    let _ = config.save(None);
836                    // Re-send updated tool perms list
837                    let tool_names = rustyclaw_core::tools::all_tool_names();
838                    let tools: Vec<_> = tool_names
839                        .iter()
840                        .map(|tn| {
841                            let perm = config
842                                .tool_permissions
843                                .get(*tn)
844                                .cloned()
845                                .unwrap_or_default();
846                            crate::components::tool_perms_dialog::ToolPermInfo {
847                                name: tn.to_string(),
848                                permission: perm.badge().to_string(),
849                                summary: rustyclaw_core::tools::tool_summary(tn).to_string(),
850                            }
851                        })
852                        .collect();
853                    let _ = gw_tx.send(GwEvent::ShowToolPerms { tools });
854                }
855                Ok(UserInput::CycleSecretPolicy {
856                    name,
857                    current_policy,
858                }) => {
859                    // Cycle OPEN → ASK → AUTH → SKILL → OPEN
860                    let next_policy = match current_policy.as_str() {
861                        "OPEN" => "ask",
862                        "ASK" => "auth",
863                        "AUTH" => "skill_only",
864                        "SKILL" => "always",
865                        _ => "ask",
866                    };
867                    if let Some(ref mut sink) = ws_sink {
868                        use futures_util::SinkExt;
869                        let frame = ClientFrame {
870                            frame_type: ClientFrameType::SecretsSetPolicy,
871                            payload: ClientPayload::SecretsSetPolicy {
872                                name,
873                                policy: next_policy.to_string(),
874                                skills: vec![],
875                            },
876                        };
877                        if let Ok(data) = serialize_frame(&frame) {
878                            let _ = sink
879                                .send(tokio_tungstenite::tungstenite::Message::Binary(data.into()))
880                                .await;
881                        }
882                    }
883                }
884                Ok(UserInput::DeleteSecret { name }) => {
885                    if let Some(ref mut sink) = ws_sink {
886                        use futures_util::SinkExt;
887                        let frame = ClientFrame {
888                            frame_type: ClientFrameType::SecretsDeleteCredential,
889                            payload: ClientPayload::SecretsDeleteCredential { name },
890                        };
891                        if let Ok(data) = serialize_frame(&frame) {
892                            let _ = sink
893                                .send(tokio_tungstenite::tungstenite::Message::Binary(data.into()))
894                                .await;
895                        }
896                    }
897                }
898                Ok(UserInput::AddSecret { name, value }) => {
899                    if let Some(ref mut sink) = ws_sink {
900                        use futures_util::SinkExt;
901                        let frame = ClientFrame {
902                            frame_type: ClientFrameType::SecretsStore,
903                            payload: ClientPayload::SecretsStore { key: name, value },
904                        };
905                        if let Ok(data) = serialize_frame(&frame) {
906                            let _ = sink
907                                .send(tokio_tungstenite::tungstenite::Message::Binary(data.into()))
908                                .await;
909                        }
910                    }
911                }
912                Ok(UserInput::RefreshSecrets) => {
913                    if let Some(ref mut sink) = ws_sink {
914                        use futures_util::SinkExt;
915                        let frame = ClientFrame {
916                            frame_type: ClientFrameType::SecretsList,
917                            payload: ClientPayload::SecretsList,
918                        };
919                        if let Ok(data) = serialize_frame(&frame) {
920                            let _ = sink
921                                .send(tokio_tungstenite::tungstenite::Message::Binary(data.into()))
922                                .await;
923                        }
924                    }
925                }
926                Ok(UserInput::RefreshTasks) => {
927                    if let Some(ref mut sink) = ws_sink {
928                        use futures_util::SinkExt;
929                        let frame = ClientFrame {
930                            frame_type: ClientFrameType::TasksRequest,
931                            payload: ClientPayload::TasksRequest { session: None },
932                        };
933                        if let Ok(data) = serialize_frame(&frame) {
934                            let _ = sink
935                                .send(tokio_tungstenite::tungstenite::Message::Binary(data.into()))
936                                .await;
937                        }
938                    }
939                }
940                Ok(UserInput::ThreadSwitch(thread_id)) => {
941                    if let Some(ref mut sink) = ws_sink {
942                        use futures_util::SinkExt;
943                        let frame = ClientFrame {
944                            frame_type: ClientFrameType::ThreadSwitch,
945                            payload: ClientPayload::ThreadSwitch { thread_id },
946                        };
947                        if let Ok(data) = serialize_frame(&frame) {
948                            let _ = sink
949                                .send(tokio_tungstenite::tungstenite::Message::Binary(data.into()))
950                                .await;
951                        }
952                    }
953                }
954                Ok(UserInput::RefreshThreads) => {
955                    if let Some(ref mut sink) = ws_sink {
956                        use futures_util::SinkExt;
957                        let frame = ClientFrame {
958                            frame_type: ClientFrameType::ThreadList,
959                            payload: ClientPayload::ThreadList,
960                        };
961                        if let Ok(data) = serialize_frame(&frame) {
962                            let _ = sink
963                                .send(tokio_tungstenite::tungstenite::Message::Binary(data.into()))
964                                .await;
965                        }
966                    }
967                }
968                Ok(UserInput::ThreadCreate(label)) => {
969                    if let Some(ref mut sink) = ws_sink {
970                        use futures_util::SinkExt;
971                        let frame = ClientFrame {
972                            frame_type: ClientFrameType::ThreadCreate,
973                            payload: ClientPayload::ThreadCreate { label },
974                        };
975                        if let Ok(data) = serialize_frame(&frame) {
976                            let _ = sink
977                                .send(tokio_tungstenite::tungstenite::Message::Binary(data.into()))
978                                .await;
979                        }
980                    }
981                }
982                Ok(UserInput::HatchingRequest) => {
983                    // Send hatching prompt to gateway as a special chat
984                    if let Some(ref mut sink) = ws_sink {
985                        use futures_util::SinkExt;
986                        let hatching_prompt = crate::components::hatching_dialog::HATCHING_PROMPT;
987                        let messages = vec![
988                            ChatMessage::text("system", hatching_prompt),
989                            ChatMessage::text("user", "Generate my identity."),
990                        ];
991                        let frame = ClientFrame {
992                            frame_type: ClientFrameType::Chat,
993                            payload: ClientPayload::Chat { messages },
994                        };
995                        if let Ok(data) = serialize_frame(&frame) {
996                            let _ = sink
997                                .send(tokio_tungstenite::tungstenite::Message::Binary(data.into()))
998                                .await;
999                        }
1000                    }
1001                }
1002                Ok(UserInput::HatchingComplete(identity)) => {
1003                    // Save identity to SOUL.md
1004                    let soul_path = config.soul_path();
1005                    if let Err(e) = std::fs::write(&soul_path, &identity) {
1006                        tracing::warn!("Failed to write SOUL.md: {}", e);
1007                    } else {
1008                        tracing::info!("Saved hatched identity to {:?}", soul_path);
1009                    }
1010                }
1011                Ok(UserInput::Quit) => break,
1012                Err(sync_mpsc::TryRecvError::Empty) => {}
1013                Err(sync_mpsc::TryRecvError::Disconnected) => break,
1014            }
1015
1016            // Small sleep to avoid busy-spinning
1017            tokio::time::sleep(std::time::Duration::from_millis(16)).await;
1018        }
1019
1020        // Wait for render thread to finish
1021        let _ = render_handle.await;
1022        Ok(())
1023    }
1024}
1025
1026// ── Helpers ─────────────────────────────────────────────────────────────────
1027
1028/// Map an Action enum value to a GwEvent.
1029///
1030/// Every Action that `server_frame_to_action()` can produce MUST be handled
1031/// here — either with a dedicated GwEvent variant or by converting to an
1032/// Info/Success/Warning/Error message so the user always sees feedback.
1033fn action_to_gw_event(action: &crate::action::Action) -> Option<GwEvent> {
1034    use crate::action::Action;
1035    match action {
1036        // ── Gateway lifecycle ───────────────────────────────────────────
1037        Action::GatewayAuthChallenge => Some(GwEvent::AuthChallenge),
1038        Action::GatewayAuthenticated => Some(GwEvent::Authenticated),
1039        Action::GatewayDisconnected(s) => Some(GwEvent::Disconnected(s.clone())),
1040        Action::GatewayVaultLocked => Some(GwEvent::VaultLocked),
1041        Action::GatewayVaultUnlocked => Some(GwEvent::VaultUnlocked),
1042        Action::GatewayReloaded { provider, model } => Some(GwEvent::ModelReloaded {
1043            provider: provider.clone(),
1044            model: model.clone(),
1045        }),
1046
1047        // ── Streaming ───────────────────────────────────────────────────
1048        Action::GatewayStreamStart => Some(GwEvent::StreamStart),
1049        Action::GatewayChunk(t) => Some(GwEvent::Chunk(t.clone())),
1050        Action::GatewayResponseDone => Some(GwEvent::ResponseDone),
1051        Action::GatewayThinkingStart => Some(GwEvent::ThinkingStart),
1052        Action::GatewayThinkingDelta => Some(GwEvent::ThinkingDelta),
1053        Action::GatewayThinkingEnd => Some(GwEvent::ThinkingEnd),
1054
1055        // ── Tool calls and results ──────────────────────────────────────
1056        Action::GatewayToolCall {
1057            name, arguments, ..
1058        } => Some(GwEvent::ToolCall {
1059            name: name.clone(),
1060            arguments: arguments.clone(),
1061        }),
1062        Action::GatewayToolResult { result, .. } => Some(GwEvent::ToolResult {
1063            result: result.clone(),
1064        }),
1065
1066        // ── Interactive: tool approval ──────────────────────────────────
1067        Action::ToolApprovalRequest {
1068            id,
1069            name,
1070            arguments,
1071        } => Some(GwEvent::ToolApprovalRequest {
1072            id: id.clone(),
1073            name: name.clone(),
1074            arguments: arguments.clone(),
1075        }),
1076
1077        // ── Interactive: user prompt ────────────────────────────────────
1078        Action::UserPromptRequest(prompt) => Some(GwEvent::UserPromptRequest(prompt.clone())),
1079
1080        // ── Tasks ───────────────────────────────────────────────────────
1081
1082        // ── Threads ─────────────────────────────────────────────────────
1083        Action::ThreadsUpdate {
1084            threads,
1085            foreground_id,
1086        } => Some(GwEvent::ThreadsUpdate {
1087            threads: threads.clone(),
1088            foreground_id: *foreground_id,
1089        }),
1090        Action::ThreadSwitched {
1091            thread_id,
1092            context_summary,
1093        } => Some(GwEvent::ThreadSwitched {
1094            thread_id: *thread_id,
1095            context_summary: context_summary.clone(),
1096        }),
1097
1098        // ── Generic messages ────────────────────────────────────────────
1099        Action::Info(s) => Some(GwEvent::Info(s.clone())),
1100        Action::Success(s) => Some(GwEvent::Success(s.clone())),
1101        Action::Warning(s) => Some(GwEvent::Warning(s.clone())),
1102        Action::Error(s) => Some(GwEvent::Error(s.clone())),
1103
1104        // ── Secrets results — show as info/success/error messages ───────
1105        Action::SecretsListResult { entries } => {
1106            let secrets: Vec<crate::components::secrets_dialog::SecretInfo> = entries
1107                .iter()
1108                .map(|e| crate::components::secrets_dialog::SecretInfo {
1109                    name: e.name.clone(),
1110                    label: e.label.clone(),
1111                    kind: e.kind.clone(),
1112                    policy: e.policy.clone(),
1113                    disabled: e.disabled,
1114                })
1115                .collect();
1116            Some(GwEvent::ShowSecrets {
1117                secrets,
1118                agent_access: false,
1119                has_totp: false,
1120            })
1121        }
1122        Action::SecretsStoreResult { ok, message } => {
1123            if *ok {
1124                Some(GwEvent::RefreshSecrets)
1125            } else {
1126                Some(GwEvent::Error(format!(
1127                    "Failed to store secret: {}",
1128                    message
1129                )))
1130            }
1131        }
1132        Action::SecretsGetResult { key, value } => {
1133            let display = value.as_deref().unwrap_or("(not found)");
1134            Some(GwEvent::Info(format!("Secret [{}]: {}", key, display)))
1135        }
1136        Action::SecretsPeekResult {
1137            name,
1138            ok,
1139            fields,
1140            message,
1141        } => {
1142            if *ok {
1143                let field_strs: Vec<String> = fields
1144                    .iter()
1145                    .map(|(k, v)| format!("  {}: {}", k, v))
1146                    .collect();
1147                Some(GwEvent::Info(format!(
1148                    "Credential [{}]:\n{}",
1149                    name,
1150                    field_strs.join("\n")
1151                )))
1152            } else {
1153                Some(GwEvent::Error(
1154                    message
1155                        .clone()
1156                        .unwrap_or_else(|| format!("Failed to peek {}", name)),
1157                ))
1158            }
1159        }
1160        Action::SecretsSetPolicyResult { ok, message } => {
1161            if *ok {
1162                Some(GwEvent::RefreshSecrets)
1163            } else {
1164                Some(GwEvent::Error(
1165                    message
1166                        .clone()
1167                        .unwrap_or_else(|| "Failed to update policy".into()),
1168                ))
1169            }
1170        }
1171        Action::SecretsSetDisabledResult {
1172            ok,
1173            cred_name,
1174            disabled,
1175        } => {
1176            let action_word = if *disabled { "disabled" } else { "enabled" };
1177            if *ok {
1178                Some(GwEvent::Success(format!(
1179                    "Credential {} {}",
1180                    cred_name, action_word
1181                )))
1182            } else {
1183                Some(GwEvent::Error(format!(
1184                    "Failed to {} credential {}",
1185                    action_word, cred_name
1186                )))
1187            }
1188        }
1189        Action::SecretsDeleteCredentialResult { ok, cred_name } => {
1190            if *ok {
1191                Some(GwEvent::RefreshSecrets)
1192            } else {
1193                Some(GwEvent::Error(format!(
1194                    "Failed to delete credential {}",
1195                    cred_name
1196                )))
1197            }
1198        }
1199        Action::SecretsHasTotpResult { has_totp } => Some(GwEvent::Info(if *has_totp {
1200            "TOTP is configured".into()
1201        } else {
1202            "TOTP is not configured".into()
1203        })),
1204        Action::SecretsSetupTotpResult { ok, uri, message } => {
1205            if *ok {
1206                Some(GwEvent::Success(format!(
1207                    "TOTP setup complete{}",
1208                    uri.as_ref()
1209                        .map(|u| format!(" — URI: {}", u))
1210                        .unwrap_or_default()
1211                )))
1212            } else {
1213                Some(GwEvent::Error(
1214                    message
1215                        .clone()
1216                        .unwrap_or_else(|| "TOTP setup failed".into()),
1217                ))
1218            }
1219        }
1220        Action::SecretsVerifyTotpResult { ok } => {
1221            if *ok {
1222                Some(GwEvent::Success("TOTP verified".into()))
1223            } else {
1224                Some(GwEvent::Error("TOTP verification failed".into()))
1225            }
1226        }
1227        Action::SecretsRemoveTotpResult { ok } => {
1228            if *ok {
1229                Some(GwEvent::Success("TOTP removed".into()))
1230            } else {
1231                Some(GwEvent::Error("TOTP removal failed".into()))
1232            }
1233        }
1234
1235        // ── Actions that are UI-only (no gateway frame) — show if relevant ──
1236        Action::ToolCommandDone { message, is_error } => {
1237            if *is_error {
1238                Some(GwEvent::Error(message.clone()))
1239            } else {
1240                Some(GwEvent::Success(message.clone()))
1241            }
1242        }
1243
1244        // ── Actions that the TUI doesn't originate from gateway ─────────
1245        // These are internal UI or CLI-only actions. If they somehow arrive
1246        // here, show them so nothing is ever silent.
1247        Action::HatchingResponse(s) => Some(GwEvent::Info(format!("Hatching: {}", s))),
1248        Action::FinishHatching(s) => Some(GwEvent::Success(format!("Hatching complete: {}", s))),
1249
1250        // ── Catch-all: NEVER silently drop ──────────────────────────────
1251        // Any action not explicitly handled above is shown as a warning
1252        // so the user always knows something happened.
1253        other => Some(GwEvent::Warning(format!("Unhandled event: {}", other))),
1254    }
1255}
1256
1257// ── The iocraft TUI root component ──────────────────────────────────────────
1258
1259mod tui_component {
1260    use iocraft::prelude::*;
1261    use std::sync::mpsc as sync_mpsc;
1262    use std::sync::{Arc, Mutex as StdMutex};
1263    use std::time::{Duration, Instant};
1264
1265    use crate::components::root::Root;
1266    use crate::theme;
1267    use crate::types::DisplayMessage;
1268
1269    use super::{GwEvent, UserInput};
1270
1271    #[derive(Default, Props)]
1272    pub struct TuiRootProps {
1273        pub soul_name: String,
1274        pub model_label: String,
1275        /// Active provider ID (e.g. "openrouter") for provider-scoped completions.
1276        pub provider_id: String,
1277        pub hint: String,
1278        /// Whether the soul needs hatching (first run).
1279        pub needs_hatching: bool,
1280    }
1281
1282    // ── Static channels ─────────────────────────────────────────────────
1283    pub(super) static CHANNEL_RX: StdMutex<Option<sync_mpsc::Receiver<GwEvent>>> =
1284        StdMutex::new(None);
1285    pub(super) static CHANNEL_TX: StdMutex<Option<sync_mpsc::Sender<UserInput>>> =
1286        StdMutex::new(None);
1287
1288    #[component]
1289    pub fn TuiRoot(props: &TuiRootProps, mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
1290        let (width, height) = hooks.use_terminal_size();
1291        let mut system = hooks.use_context_mut::<SystemContext>();
1292
1293        // ── Local UI state ──────────────────────────────────────────────
1294        let mut messages: State<Vec<DisplayMessage>> = hooks.use_state(Vec::new);
1295        let mut input_value = hooks.use_state(|| String::new());
1296        let mut gw_status = hooks.use_state(|| rustyclaw_core::types::GatewayStatus::Connecting);
1297        let mut streaming = hooks.use_state(|| false);
1298        let mut stream_start: State<Option<Instant>> = hooks.use_state(|| None);
1299        let mut elapsed = hooks.use_state(|| String::new());
1300        let mut scroll_offset = hooks.use_state(|| 0i32);
1301        let mut spinner_tick = hooks.use_state(|| 0usize);
1302        let mut should_quit = hooks.use_state(|| false);
1303        let mut streaming_buf = hooks.use_state(|| String::new());
1304        let mut dynamic_model_label: State<Option<String>> = hooks.use_state(|| None);
1305        let mut dynamic_provider_id: State<Option<String>> = hooks.use_state(|| None);
1306
1307        // ── Auth dialog state ───────────────────────────────────────────
1308        let mut show_auth_dialog = hooks.use_state(|| false);
1309        let mut auth_code = hooks.use_state(|| String::new());
1310        let mut auth_error = hooks.use_state(|| String::new());
1311
1312        // ── Tool approval dialog state ──────────────────────────────────
1313        let mut show_tool_approval = hooks.use_state(|| false);
1314        let mut tool_approval_id = hooks.use_state(|| String::new());
1315        let mut tool_approval_name = hooks.use_state(|| String::new());
1316        let mut tool_approval_args = hooks.use_state(|| String::new());
1317        let mut tool_approval_selected = hooks.use_state(|| true); // true = Allow
1318
1319        // ── Vault unlock dialog state ───────────────────────────────────
1320        let mut show_vault_unlock = hooks.use_state(|| false);
1321        let mut vault_password = hooks.use_state(|| String::new());
1322        let mut vault_error = hooks.use_state(|| String::new());
1323
1324        // ── Hatching dialog state ───────────────────────────────────────
1325        let mut show_hatching = hooks.use_state(|| props.needs_hatching);
1326        let mut hatching_state: State<crate::components::hatching_dialog::HatchState> =
1327            hooks.use_state(|| crate::components::hatching_dialog::HatchState::Egg);
1328        let mut hatching_tick = hooks.use_state(|| 0usize);
1329        let mut hatching_pending = hooks.use_state(|| false); // True when waiting for hatching response
1330
1331        // ── User prompt dialog state ────────────────────────────────────
1332        let mut show_user_prompt = hooks.use_state(|| false);
1333        let mut user_prompt_id = hooks.use_state(|| String::new());
1334        let mut user_prompt_title = hooks.use_state(|| String::new());
1335        let mut user_prompt_desc = hooks.use_state(|| String::new());
1336        let mut user_prompt_input = hooks.use_state(|| String::new());
1337        let mut user_prompt_type: State<Option<rustyclaw_core::user_prompt_types::PromptType>> =
1338            hooks.use_state(|| None);
1339        let mut user_prompt_selected = hooks.use_state(|| 0usize);
1340
1341        // ── Thread state (unified tasks + threads) ───────────────────────
1342        let mut threads: State<Vec<crate::action::ThreadInfo>> = hooks.use_state(Vec::new);
1343        let mut sidebar_focused = hooks.use_state(|| false);
1344        let mut sidebar_selected = hooks.use_state(|| 0usize);
1345
1346        // ── Command menu (slash-command completions) ────────────────────
1347        let mut command_completions: State<Vec<String>> = hooks.use_state(Vec::new);
1348        let mut command_selected: State<Option<usize>> = hooks.use_state(|| None);
1349
1350        // ── Info dialog state (secrets / skills / tool permissions) ──────
1351        let mut show_secrets_dialog = hooks.use_state(|| false);
1352        let mut secrets_dialog_data: State<Vec<crate::components::secrets_dialog::SecretInfo>> =
1353            hooks.use_state(Vec::new);
1354        let mut secrets_agent_access = hooks.use_state(|| false);
1355        let mut secrets_has_totp = hooks.use_state(|| false);
1356        let mut secrets_selected: State<Option<usize>> = hooks.use_state(|| Some(0));
1357        let mut secrets_scroll_offset = hooks.use_state(|| 0usize);
1358        // Add-secret inline input: 0 = off, 1 = entering name, 2 = entering value
1359        let mut secrets_add_step = hooks.use_state(|| 0u8);
1360        let mut secrets_add_name = hooks.use_state(|| String::new());
1361        let mut secrets_add_value = hooks.use_state(|| String::new());
1362
1363        let mut show_skills_dialog = hooks.use_state(|| false);
1364        let mut skills_dialog_data: State<Vec<crate::components::skills_dialog::SkillInfo>> =
1365            hooks.use_state(Vec::new);
1366        let mut skills_selected: State<Option<usize>> = hooks.use_state(|| Some(0));
1367
1368        let mut show_tool_perms_dialog = hooks.use_state(|| false);
1369        let mut tool_perms_dialog_data: State<
1370            Vec<crate::components::tool_perms_dialog::ToolPermInfo>,
1371        > = hooks.use_state(Vec::new);
1372        let mut tool_perms_selected: State<Option<usize>> = hooks.use_state(|| Some(0));
1373
1374        // Scroll offsets for interactive dialogs
1375        let mut skills_scroll_offset = hooks.use_state(|| 0usize);
1376        let mut tool_perms_scroll_offset = hooks.use_state(|| 0usize);
1377
1378        // ── Channel access ──────────────────────────────────────────────
1379        let gw_rx: Arc<StdMutex<Option<sync_mpsc::Receiver<GwEvent>>>> =
1380            hooks.use_const(|| Arc::new(StdMutex::new(CHANNEL_RX.lock().unwrap().take())));
1381        let user_tx: Arc<StdMutex<Option<sync_mpsc::Sender<UserInput>>>> =
1382            hooks.use_const(|| Arc::new(StdMutex::new(CHANNEL_TX.lock().unwrap().take())));
1383
1384        // ── Poll gateway channel on a timer ─────────────────────────────
1385        hooks.use_future({
1386            let rx_handle = Arc::clone(&gw_rx);
1387            let tx_for_history = Arc::clone(&user_tx);
1388            let tx_for_ticker = Arc::clone(&user_tx);
1389            async move {
1390                loop {
1391                    smol::Timer::after(Duration::from_millis(30)).await;
1392
1393                    if let Ok(guard) = rx_handle.lock() {
1394                        if let Some(ref rx) = *guard {
1395                            while let Ok(ev) = rx.try_recv() {
1396                                match ev {
1397                                    GwEvent::AuthChallenge => {
1398                                        // Gateway wants TOTP — show the dialog
1399                                        gw_status.set(rustyclaw_core::types::GatewayStatus::AuthRequired);
1400                                        show_auth_dialog.set(true);
1401                                        auth_code.set(String::new());
1402                                        auth_error.set(String::new());
1403                                        let mut m = messages.read().clone();
1404                                        m.push(DisplayMessage::info("Authentication required — enter TOTP code"));
1405                                        messages.set(m);
1406                                    }
1407                                    GwEvent::Disconnected(reason) => {
1408                                        gw_status.set(rustyclaw_core::types::GatewayStatus::Disconnected);
1409                                        show_auth_dialog.set(false);
1410                                        let mut m = messages.read().clone();
1411                                        m.push(DisplayMessage::warning(format!("Disconnected: {}", reason)));
1412                                        messages.set(m);
1413                                    }
1414                                    GwEvent::Authenticated => {
1415                                        gw_status.set(rustyclaw_core::types::GatewayStatus::Connected);
1416                                        show_auth_dialog.set(false);
1417                                        let mut m = messages.read().clone();
1418                                        m.push(DisplayMessage::success("Authenticated"));
1419                                        messages.set(m);
1420                                        // Request initial thread list
1421                                        if let Ok(guard) = tx_for_history.lock() {
1422                                            if let Some(ref tx) = *guard {
1423                                                let _ = tx.send(UserInput::RefreshThreads);
1424                                            }
1425                                        }
1426                                    }
1427                                    GwEvent::Info(s) => {
1428                                        // Check for "Model ready" or similar to upgrade status
1429                                        let mut m = messages.read().clone();
1430                                        m.push(DisplayMessage::info(s));
1431                                        messages.set(m);
1432                                    }
1433                                    GwEvent::Success(s) => {
1434                                        let mut m = messages.read().clone();
1435                                        m.push(DisplayMessage::success(s));
1436                                        messages.set(m);
1437                                    }
1438                                    GwEvent::Warning(s) => {
1439                                        // If auth dialog is open, treat warnings as auth retries
1440                                        if show_auth_dialog.get() {
1441                                            auth_error.set(s.clone());
1442                                            auth_code.set(String::new());
1443                                        }
1444                                        let mut m = messages.read().clone();
1445                                        m.push(DisplayMessage::warning(s));
1446                                        messages.set(m);
1447                                    }
1448                                    GwEvent::Error(s) => {
1449                                        // Auth errors close the dialog
1450                                        if show_auth_dialog.get() {
1451                                            show_auth_dialog.set(false);
1452                                            auth_code.set(String::new());
1453                                            auth_error.set(String::new());
1454                                        }
1455                                        // Always stop the spinner / streaming state so
1456                                        // the TUI doesn't get stuck in "Thinking…" after
1457                                        // a provider error (e.g. 400 Bad Request).
1458                                        streaming.set(false);
1459                                        stream_start.set(None);
1460                                        elapsed.set(String::new());
1461                                        streaming_buf.set(String::new());
1462
1463                                        let mut m = messages.read().clone();
1464                                        m.push(DisplayMessage::error(s));
1465                                        messages.set(m);
1466                                    }
1467                                    GwEvent::StreamStart => {
1468                                        streaming.set(true);
1469                                        // Keep the earlier start time if we already
1470                                        // began timing on user submit.
1471                                        if stream_start.get().is_none() {
1472                                            stream_start.set(Some(Instant::now()));
1473                                        }
1474                                        streaming_buf.set(String::new());
1475                                    }
1476                                    GwEvent::Chunk(text) => {
1477                                        let mut buf = streaming_buf.read().clone();
1478                                        buf.push_str(&text);
1479                                        streaming_buf.set(buf);
1480
1481                                        let mut m = messages.read().clone();
1482                                        if let Some(last) = m.last_mut() {
1483                                            if last.role == rustyclaw_core::types::MessageRole::Assistant {
1484                                                last.append(&text);
1485                                            } else {
1486                                                m.push(DisplayMessage::assistant(&text));
1487                                            }
1488                                        } else {
1489                                            m.push(DisplayMessage::assistant(&text));
1490                                        }
1491                                        messages.set(m);
1492                                    }
1493                                    GwEvent::ResponseDone => {
1494                                        // Capture the accumulated assistant text and
1495                                        // send it back to the tokio loop so it gets
1496                                        // appended to the conversation history.
1497                                        let completed_text = streaming_buf.read().clone();
1498                                        
1499                                        // Check if this was a hatching response
1500                                        if hatching_pending.get() {
1501                                            hatching_pending.set(false);
1502                                            // Set hatching state to Awakened with the identity
1503                                            hatching_state.set(
1504                                                crate::components::hatching_dialog::HatchState::Awakened {
1505                                                    identity: completed_text.clone(),
1506                                                }
1507                                            );
1508                                            // Save to SOUL.md
1509                                            if let Ok(guard) = tx_for_history.lock() {
1510                                                if let Some(ref tx) = *guard {
1511                                                    let _ = tx.send(UserInput::HatchingComplete(completed_text));
1512                                                }
1513                                            }
1514                                        } else if !completed_text.is_empty() {
1515                                            if let Ok(guard) = tx_for_history.lock() {
1516                                                if let Some(ref tx) = *guard {
1517                                                    let _ = tx.send(UserInput::AssistantResponse(completed_text));
1518                                                }
1519                                            }
1520                                        }
1521                                        streaming.set(false);
1522                                        stream_start.set(None);
1523                                        elapsed.set(String::new());
1524                                        streaming_buf.set(String::new());
1525                                        // Refresh task list after response (not for hatching)
1526                                        if !hatching_pending.get() {
1527                                            if let Ok(guard) = tx_for_history.lock() {
1528                                                if let Some(ref tx) = *guard {
1529                                                    let _ = tx.send(UserInput::RefreshTasks);
1530                                                }
1531                                            }
1532                                        }
1533                                    }
1534                                    GwEvent::ThinkingStart => {
1535                                        // Thinking is a form of streaming — show spinner
1536                                        streaming.set(true);
1537                                        if stream_start.get().is_none() {
1538                                            stream_start.set(Some(Instant::now()));
1539                                        }
1540                                        let mut m = messages.read().clone();
1541                                        m.push(DisplayMessage::thinking("Thinking…"));
1542                                        messages.set(m);
1543                                    }
1544                                    GwEvent::ThinkingDelta => {
1545                                        // Thinking is ongoing — keep spinner alive
1546                                    }
1547                                    GwEvent::ThinkingEnd => {
1548                                        // Thinking done, but streaming may continue
1549                                        // with chunks. Don't clear streaming here.
1550                                    }
1551                                    GwEvent::ModelReady(detail) => {
1552                                        gw_status.set(rustyclaw_core::types::GatewayStatus::ModelReady);
1553                                        let mut m = messages.read().clone();
1554                                        m.push(DisplayMessage::success(detail));
1555                                        messages.set(m);
1556                                    }
1557                                    GwEvent::ModelReloaded { provider, model } => {
1558                                        gw_status.set(rustyclaw_core::types::GatewayStatus::ModelReady);
1559                                        let label = if provider.is_empty() {
1560                                            String::new()
1561                                        } else if model.is_empty() {
1562                                            provider.clone()
1563                                        } else {
1564                                            format!("{} / {}", provider, model)
1565                                        };
1566                                        let msg_text = if label.is_empty() {
1567                                            "Model switched to (none)".to_string()
1568                                        } else {
1569                                            format!("Model switched to {}", label)
1570                                        };
1571                                        dynamic_provider_id.set(Some(provider));
1572                                        dynamic_model_label.set(Some(label));
1573                                        let mut m = messages.read().clone();
1574                                        m.push(DisplayMessage::success(msg_text));
1575                                        messages.set(m);
1576                                    }
1577                                    GwEvent::ToolCall { name, arguments } => {
1578                                        let msg = if name == "ask_user" {
1579                                            // Don't show raw JSON args for ask_user — the dialog handles it
1580                                            format!("🔧 {} — preparing question…", name)
1581                                        } else {
1582                                            // Pretty-print JSON arguments if possible
1583                                            let pretty = serde_json::from_str::<serde_json::Value>(&arguments)
1584                                                .ok()
1585                                                .and_then(|v| serde_json::to_string_pretty(&v).ok())
1586                                                .unwrap_or(arguments);
1587                                            format!("🔧 {}\n{}", name, pretty)
1588                                        };
1589                                        let mut m = messages.read().clone();
1590                                        m.push(DisplayMessage::tool_call(msg));
1591                                        messages.set(m);
1592                                    }
1593                                    GwEvent::ToolResult { result } => {
1594                                        let preview = if result.len() > 200 {
1595                                            format!("{}…", &result[..200])
1596                                        } else {
1597                                            result
1598                                        };
1599                                        let mut m = messages.read().clone();
1600                                        m.push(DisplayMessage::tool_result(preview));
1601                                        messages.set(m);
1602                                    }
1603                                    GwEvent::ToolApprovalRequest { id, name, arguments } => {
1604                                        // Show tool approval dialog
1605                                        tool_approval_id.set(id);
1606                                        tool_approval_name.set(name.clone());
1607                                        tool_approval_args.set(arguments.clone());
1608                                        tool_approval_selected.set(true);
1609                                        show_tool_approval.set(true);
1610                                        let mut m = messages.read().clone();
1611                                        m.push(DisplayMessage::system(format!(
1612                                            "🔐 Tool approval required: {} — press Enter to allow, Esc to deny",
1613                                            name,
1614                                        )));
1615                                        messages.set(m);
1616                                    }
1617                                    GwEvent::UserPromptRequest(prompt) => {
1618                                        // Show user prompt dialog
1619                                        user_prompt_id.set(prompt.id.clone());
1620                                        user_prompt_title.set(prompt.title.clone());
1621                                        user_prompt_desc.set(
1622                                            prompt.description.clone().unwrap_or_default(),
1623                                        );
1624                                        user_prompt_input.set(String::new());
1625                                        user_prompt_type.set(Some(prompt.prompt_type.clone()));
1626                                        // Set default selection based on prompt type
1627                                        let default_sel = match &prompt.prompt_type {
1628                                            rustyclaw_core::user_prompt_types::PromptType::Select { default, .. } => {
1629                                                default.unwrap_or(0)
1630                                            }
1631                                            rustyclaw_core::user_prompt_types::PromptType::Confirm { default } => {
1632                                                if *default { 0 } else { 1 }
1633                                            }
1634                                            _ => 0,
1635                                        };
1636                                        user_prompt_selected.set(default_sel);
1637                                        show_user_prompt.set(true);
1638
1639                                        // Build informative message based on prompt type
1640                                        let hint = match &prompt.prompt_type {
1641                                            rustyclaw_core::user_prompt_types::PromptType::Select { options, .. } => {
1642                                                let opt_list: Vec<_> = options.iter().map(|o| o.label.as_str()).collect();
1643                                                format!("Options: {}", opt_list.join(", "))
1644                                            }
1645                                            rustyclaw_core::user_prompt_types::PromptType::Confirm { .. } => {
1646                                                "Yes/No".to_string()
1647                                            }
1648                                            _ => "Type your answer".to_string(),
1649                                        };
1650                                        let mut m = messages.read().clone();
1651                                        m.push(DisplayMessage::system(format!(
1652                                            "❓ Agent asks: {} — {}",
1653                                            prompt.title, hint,
1654                                        )));
1655                                        if let Some(desc) = &prompt.description {
1656                                            if !desc.is_empty() {
1657                                                m.push(DisplayMessage::info(desc.clone()));
1658                                            }
1659                                        }
1660                                        messages.set(m);
1661                                    }
1662                                    GwEvent::VaultLocked => {
1663                                        gw_status.set(rustyclaw_core::types::GatewayStatus::VaultLocked);
1664                                        show_vault_unlock.set(true);
1665                                        vault_password.set(String::new());
1666                                        vault_error.set(String::new());
1667                                        let mut m = messages.read().clone();
1668                                        m.push(DisplayMessage::warning(
1669                                            "🔒 Vault is locked — enter password to unlock".to_string(),
1670                                        ));
1671                                        messages.set(m);
1672                                    }
1673                                    GwEvent::VaultUnlocked => {
1674                                        show_vault_unlock.set(false);
1675                                        vault_password.set(String::new());
1676                                        vault_error.set(String::new());
1677                                        let mut m = messages.read().clone();
1678                                        m.push(DisplayMessage::success("🔓 Vault unlocked".to_string()));
1679                                        messages.set(m);
1680                                    }
1681                                    GwEvent::ShowSecrets { secrets, agent_access, has_totp } => {
1682                                        secrets_dialog_data.set(secrets);
1683                                        secrets_agent_access.set(agent_access);
1684                                        secrets_has_totp.set(has_totp);
1685                                        if !show_secrets_dialog.get() {
1686                                            // First open — reset selection and scroll
1687                                            secrets_selected.set(Some(0));
1688                                            secrets_scroll_offset.set(0);
1689                                            secrets_add_step.set(0);
1690                                        }
1691                                        show_secrets_dialog.set(true);
1692                                    }
1693                                    GwEvent::ShowSkills { skills } => {
1694                                        skills_dialog_data.set(skills);
1695                                        if !show_skills_dialog.get() {
1696                                            // First open — reset selection and scroll
1697                                            skills_selected.set(Some(0));
1698                                            skills_scroll_offset.set(0);
1699                                        }
1700                                        show_skills_dialog.set(true);
1701                                    }
1702                                    GwEvent::ShowToolPerms { tools } => {
1703                                        tool_perms_dialog_data.set(tools);
1704                                        if !show_tool_perms_dialog.get() {
1705                                            // First open — reset selection and scroll
1706                                            tool_perms_selected.set(Some(0));
1707                                            tool_perms_scroll_offset.set(0);
1708                                        }
1709                                        show_tool_perms_dialog.set(true);
1710                                    }
1711                                    GwEvent::RefreshSecrets => {
1712                                        // Gateway mutation succeeded — re-fetch list
1713                                        if let Ok(guard) = tx_for_history.lock() {
1714                                            if let Some(ref tx) = *guard {
1715                                                let _ = tx.send(UserInput::RefreshSecrets);
1716                                            }
1717                                        }
1718                                    }
1719                                    GwEvent::ThreadsUpdate {
1720                                        threads: thread_list,
1721                                        foreground_id: _,
1722                                    } => {
1723                                        threads.set(thread_list);
1724                                        // Update sidebar_selected to stay in bounds
1725                                        let count = threads.read().len();
1726                                        if count > 0 && sidebar_selected.get() >= count {
1727                                            sidebar_selected.set(count - 1);
1728                                        }
1729                                    }
1730                                    GwEvent::ThreadSwitched {
1731                                        thread_id,
1732                                        context_summary,
1733                                    } => {
1734                                        // Clear messages for the new thread
1735                                        let mut m = Vec::new();
1736                                        m.push(DisplayMessage::info(format!(
1737                                            "Switched to thread (id: {})",
1738                                            thread_id
1739                                        )));
1740                                        // Show context summary if available
1741                                        if let Some(summary) = context_summary {
1742                                            m.push(DisplayMessage::assistant(format!(
1743                                                "[Previous context]\n\n{}",
1744                                                summary
1745                                            )));
1746                                        }
1747                                        messages.set(m);
1748                                        // Unfocus sidebar after switch
1749                                        sidebar_focused.set(false);
1750                                    }
1751                                    GwEvent::HatchingResponse(_identity) => {
1752                                        // Hatching response is handled via ResponseDone
1753                                        // since it comes through as streaming chunks.
1754                                        // This event is currently unused but defined for
1755                                        // potential future direct gateway hatching support.
1756                                    }
1757                                }
1758                            }
1759                        }
1760                    }
1761
1762                    // Update spinner and elapsed timer
1763                    spinner_tick.set(spinner_tick.get().wrapping_add(1));
1764                    
1765                    // Animate hatching sequence (advance every 8 ticks ≈ 2 seconds)
1766                    if show_hatching.get() && !hatching_pending.get() {
1767                        let tick = hatching_tick.get().wrapping_add(1);
1768                        hatching_tick.set(tick);
1769                        if tick % 8 == 0 {
1770                            let mut state = hatching_state.read().clone();
1771                            let should_connect = state.advance();
1772                            hatching_state.set(state);
1773                            if should_connect {
1774                                // Send hatching request to gateway
1775                                hatching_pending.set(true);
1776                                if let Ok(guard) = tx_for_ticker.lock() {
1777                                    if let Some(ref tx) = *guard {
1778                                        let _ = tx.send(UserInput::HatchingRequest);
1779                                    }
1780                                }
1781                            }
1782                        }
1783                    }
1784                    
1785                    if let Some(start) = stream_start.get() {
1786                        let d = start.elapsed();
1787                        let secs = d.as_secs();
1788                        elapsed.set(if secs >= 60 {
1789                            format!("{}m {:02}s", secs / 60, secs % 60)
1790                        } else {
1791                            format!("{}.{}s", secs, d.subsec_millis() / 100)
1792                        });
1793                    }
1794                }
1795            }
1796        });
1797
1798        // ── Keyboard handling ───────────────────────────────────────────
1799        let tx_for_keys = Arc::clone(&user_tx);
1800        hooks.use_terminal_events({
1801            move |event| match event {
1802                TerminalEvent::Key(KeyEvent { code, kind, modifiers, .. })
1803                    if kind != KeyEventKind::Release =>
1804                {
1805                    // ── Auth dialog has focus when visible ───────────
1806                    if show_auth_dialog.get() {
1807                        match code {
1808                            KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
1809                                should_quit.set(true);
1810                                if let Ok(guard) = tx_for_keys.lock() {
1811                                    if let Some(ref tx) = *guard {
1812                                        let _ = tx.send(UserInput::Quit);
1813                                    }
1814                                }
1815                            }
1816                            KeyCode::Esc => {
1817                                // Cancel auth dialog
1818                                show_auth_dialog.set(false);
1819                                auth_code.set(String::new());
1820                                auth_error.set(String::new());
1821                                let mut m = messages.read().clone();
1822                                m.push(DisplayMessage::info("Authentication cancelled."));
1823                                messages.set(m);
1824                                gw_status.set(rustyclaw_core::types::GatewayStatus::Disconnected);
1825                            }
1826                            KeyCode::Char(c) if c.is_ascii_digit() => {
1827                                let mut code_val = auth_code.read().clone();
1828                                if code_val.len() < 6 {
1829                                    code_val.push(c);
1830                                    auth_code.set(code_val);
1831                                }
1832                            }
1833                            KeyCode::Backspace => {
1834                                let mut code_val = auth_code.read().clone();
1835                                code_val.pop();
1836                                auth_code.set(code_val);
1837                            }
1838                            KeyCode::Enter => {
1839                                let code_val = auth_code.read().clone();
1840                                if code_val.len() == 6 {
1841                                    // Submit the TOTP code — keep dialog open
1842                                    // until Authenticated/Error arrives
1843                                    auth_code.set(String::new());
1844                                    auth_error.set("Verifying…".to_string());
1845                                    if let Ok(guard) = tx_for_keys.lock() {
1846                                        if let Some(ref tx) = *guard {
1847                                            let _ = tx.send(UserInput::AuthResponse(code_val));
1848                                        }
1849                                    }
1850                                }
1851                                // If < 6 digits, ignore Enter
1852                            }
1853                            _ => {}
1854                        }
1855                        return;
1856                    }
1857
1858                    // ── Tool approval dialog ────────────────────────
1859                    if show_tool_approval.get() {
1860                        match code {
1861                            KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
1862                                should_quit.set(true);
1863                                if let Ok(guard) = tx_for_keys.lock() {
1864                                    if let Some(ref tx) = *guard {
1865                                        let _ = tx.send(UserInput::Quit);
1866                                    }
1867                                }
1868                            }
1869                            KeyCode::Left | KeyCode::Right | KeyCode::Tab => {
1870                                // Toggle between Allow / Deny
1871                                tool_approval_selected.set(!tool_approval_selected.get());
1872                            }
1873                            KeyCode::Char('y') | KeyCode::Char('Y') => {
1874                                // Quick-approve
1875                                let id = tool_approval_id.read().clone();
1876                                show_tool_approval.set(false);
1877                                let mut m = messages.read().clone();
1878                                m.push(DisplayMessage::success(format!(
1879                                    "✓ Approved: {}", &*tool_approval_name.read()
1880                                )));
1881                                messages.set(m);
1882                                if let Ok(guard) = tx_for_keys.lock() {
1883                                    if let Some(ref tx) = *guard {
1884                                        let _ = tx.send(UserInput::ToolApprovalResponse {
1885                                            id,
1886                                            approved: true,
1887                                        });
1888                                    }
1889                                }
1890                            }
1891                            KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
1892                                // Deny
1893                                let id = tool_approval_id.read().clone();
1894                                show_tool_approval.set(false);
1895                                let mut m = messages.read().clone();
1896                                m.push(DisplayMessage::warning(format!(
1897                                    "✗ Denied: {}", &*tool_approval_name.read()
1898                                )));
1899                                messages.set(m);
1900                                if let Ok(guard) = tx_for_keys.lock() {
1901                                    if let Some(ref tx) = *guard {
1902                                        let _ = tx.send(UserInput::ToolApprovalResponse {
1903                                            id,
1904                                            approved: false,
1905                                        });
1906                                    }
1907                                }
1908                            }
1909                            KeyCode::Enter => {
1910                                let id = tool_approval_id.read().clone();
1911                                let approved = tool_approval_selected.get();
1912                                show_tool_approval.set(false);
1913                                let mut m = messages.read().clone();
1914                                if approved {
1915                                    m.push(DisplayMessage::success(format!(
1916                                        "✓ Approved: {}", &*tool_approval_name.read()
1917                                    )));
1918                                } else {
1919                                    m.push(DisplayMessage::warning(format!(
1920                                        "✗ Denied: {}", &*tool_approval_name.read()
1921                                    )));
1922                                }
1923                                messages.set(m);
1924                                if let Ok(guard) = tx_for_keys.lock() {
1925                                    if let Some(ref tx) = *guard {
1926                                        let _ = tx.send(UserInput::ToolApprovalResponse {
1927                                            id,
1928                                            approved,
1929                                        });
1930                                    }
1931                                }
1932                            }
1933                            _ => {}
1934                        }
1935                        return;
1936                    }
1937
1938                    // ── Vault unlock dialog ─────────────────────────
1939                    if show_vault_unlock.get() {
1940                        match code {
1941                            KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
1942                                should_quit.set(true);
1943                                if let Ok(guard) = tx_for_keys.lock() {
1944                                    if let Some(ref tx) = *guard {
1945                                        let _ = tx.send(UserInput::Quit);
1946                                    }
1947                                }
1948                            }
1949                            KeyCode::Esc => {
1950                                show_vault_unlock.set(false);
1951                                vault_password.set(String::new());
1952                                vault_error.set(String::new());
1953                                let mut m = messages.read().clone();
1954                                m.push(DisplayMessage::info("Vault unlock cancelled."));
1955                                messages.set(m);
1956                            }
1957                            KeyCode::Char(c) => {
1958                                let mut pw = vault_password.read().clone();
1959                                pw.push(c);
1960                                vault_password.set(pw);
1961                            }
1962                            KeyCode::Backspace => {
1963                                let mut pw = vault_password.read().clone();
1964                                pw.pop();
1965                                vault_password.set(pw);
1966                            }
1967                            KeyCode::Enter => {
1968                                let pw = vault_password.read().clone();
1969                                if !pw.is_empty() {
1970                                    vault_password.set(String::new());
1971                                    vault_error.set("Unlocking…".to_string());
1972                                    if let Ok(guard) = tx_for_keys.lock() {
1973                                        if let Some(ref tx) = *guard {
1974                                            let _ = tx.send(UserInput::VaultUnlock(pw));
1975                                        }
1976                                    }
1977                                }
1978                            }
1979                            _ => {}
1980                        }
1981                        return;
1982                    }
1983
1984                    // ── Hatching dialog ─────────────────────────────
1985                    if show_hatching.get() {
1986                        match code {
1987                            KeyCode::Enter => {
1988                                // If awakened, close the dialog
1989                                let state = hatching_state.read().clone();
1990                                if matches!(
1991                                    state,
1992                                    crate::components::hatching_dialog::HatchState::Awakened { .. }
1993                                ) {
1994                                    show_hatching.set(false);
1995                                    let mut m = messages.read().clone();
1996                                    m.push(DisplayMessage::success("Identity established! Welcome to RustyClaw."));
1997                                    messages.set(m);
1998                                }
1999                            }
2000                            KeyCode::Esc => {
2001                                // Allow skipping hatching
2002                                show_hatching.set(false);
2003                                let mut m = messages.read().clone();
2004                                m.push(DisplayMessage::info("Hatching skipped. You can customize SOUL.md manually."));
2005                                messages.set(m);
2006                            }
2007                            _ => {}
2008                        }
2009                        return;
2010                    }
2011
2012                    // ── User prompt dialog ──────────────────────────
2013                    if show_user_prompt.get() {
2014                        let prompt_type = user_prompt_type.read().clone();
2015                        match code {
2016                            KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
2017                                should_quit.set(true);
2018                                if let Ok(guard) = tx_for_keys.lock() {
2019                                    if let Some(ref tx) = *guard {
2020                                        let _ = tx.send(UserInput::Quit);
2021                                    }
2022                                }
2023                            }
2024                            KeyCode::Esc => {
2025                                let id = user_prompt_id.read().clone();
2026                                show_user_prompt.set(false);
2027                                user_prompt_input.set(String::new());
2028                                user_prompt_type.set(None);
2029                                let mut m = messages.read().clone();
2030                                m.push(DisplayMessage::info("Prompt dismissed."));
2031                                messages.set(m);
2032                                if let Ok(guard) = tx_for_keys.lock() {
2033                                    if let Some(ref tx) = *guard {
2034                                        let _ = tx.send(UserInput::UserPromptResponse {
2035                                            id,
2036                                            dismissed: true,
2037                                            value: rustyclaw_core::user_prompt_types::PromptResponseValue::Text(String::new()),
2038                                        });
2039                                    }
2040                                }
2041                            }
2042                            // Navigation for Select/MultiSelect
2043                            KeyCode::Up | KeyCode::Char('k') => {
2044                                if let Some(ref pt) = prompt_type {
2045                                    match pt {
2046                                        rustyclaw_core::user_prompt_types::PromptType::Select { options: _, .. } |
2047                                        rustyclaw_core::user_prompt_types::PromptType::MultiSelect { options: _, .. } => {
2048                                            let current = user_prompt_selected.get();
2049                                            if current > 0 {
2050                                                user_prompt_selected.set(current - 1);
2051                                            }
2052                                        }
2053                                        _ => {}
2054                                    }
2055                                }
2056                            }
2057                            KeyCode::Down | KeyCode::Char('j') => {
2058                                if let Some(ref pt) = prompt_type {
2059                                    match pt {
2060                                        rustyclaw_core::user_prompt_types::PromptType::Select { options, .. } |
2061                                        rustyclaw_core::user_prompt_types::PromptType::MultiSelect { options, .. } => {
2062                                            let current = user_prompt_selected.get();
2063                                            if current + 1 < options.len() {
2064                                                user_prompt_selected.set(current + 1);
2065                                            }
2066                                        }
2067                                        _ => {}
2068                                    }
2069                                }
2070                            }
2071                            // Left/Right for Confirm
2072                            KeyCode::Left | KeyCode::Right => {
2073                                if let Some(rustyclaw_core::user_prompt_types::PromptType::Confirm { .. }) = prompt_type {
2074                                    let current = user_prompt_selected.get();
2075                                    user_prompt_selected.set(if current == 0 { 1 } else { 0 });
2076                                }
2077                            }
2078                            // Y/N shortcuts for Confirm
2079                            KeyCode::Char('y') | KeyCode::Char('Y') => {
2080                                if let Some(rustyclaw_core::user_prompt_types::PromptType::Confirm { .. }) = prompt_type {
2081                                    user_prompt_selected.set(0); // Yes
2082                                } else {
2083                                    // Normal text input
2084                                    let mut input = user_prompt_input.read().clone();
2085                                    input.push(if code == KeyCode::Char('Y') { 'Y' } else { 'y' });
2086                                    user_prompt_input.set(input);
2087                                }
2088                            }
2089                            KeyCode::Char('n') | KeyCode::Char('N') => {
2090                                if let Some(rustyclaw_core::user_prompt_types::PromptType::Confirm { .. }) = prompt_type {
2091                                    user_prompt_selected.set(1); // No
2092                                } else {
2093                                    // Normal text input
2094                                    let mut input = user_prompt_input.read().clone();
2095                                    input.push(if code == KeyCode::Char('N') { 'N' } else { 'n' });
2096                                    user_prompt_input.set(input);
2097                                }
2098                            }
2099                            KeyCode::Char(c) => {
2100                                // Only for TextInput types
2101                                if matches!(prompt_type, None | Some(rustyclaw_core::user_prompt_types::PromptType::TextInput { .. }) | Some(rustyclaw_core::user_prompt_types::PromptType::Form { .. })) {
2102                                    let mut input = user_prompt_input.read().clone();
2103                                    input.push(c);
2104                                    user_prompt_input.set(input);
2105                                }
2106                            }
2107                            KeyCode::Backspace => {
2108                                let mut input = user_prompt_input.read().clone();
2109                                input.pop();
2110                                user_prompt_input.set(input);
2111                            }
2112                            KeyCode::Enter => {
2113                                let id = user_prompt_id.read().clone();
2114                                let input = user_prompt_input.read().clone();
2115                                let selected = user_prompt_selected.get();
2116                                show_user_prompt.set(false);
2117                                user_prompt_input.set(String::new());
2118                                user_prompt_type.set(None);
2119
2120                                // Build response based on prompt type
2121                                let (value, display) = match &prompt_type {
2122                                    Some(rustyclaw_core::user_prompt_types::PromptType::Select { options, .. }) => {
2123                                        let label = options.get(selected).map(|o| o.label.clone()).unwrap_or_default();
2124                                        (rustyclaw_core::user_prompt_types::PromptResponseValue::Selected(vec![label.clone()]), format!("→ {}", label))
2125                                    }
2126                                    Some(rustyclaw_core::user_prompt_types::PromptType::Confirm { .. }) => {
2127                                        let yes = selected == 0;
2128                                        (rustyclaw_core::user_prompt_types::PromptResponseValue::Confirm(yes), format!("→ {}", if yes { "Yes" } else { "No" }))
2129                                    }
2130                                    Some(rustyclaw_core::user_prompt_types::PromptType::MultiSelect { options, .. }) => {
2131                                        // TODO: track multiple selections properly
2132                                        let label = options.get(selected).map(|o| o.label.clone()).unwrap_or_default();
2133                                        (rustyclaw_core::user_prompt_types::PromptResponseValue::Selected(vec![label.clone()]), format!("→ {}", label))
2134                                    }
2135                                    _ => {
2136                                        (rustyclaw_core::user_prompt_types::PromptResponseValue::Text(input.clone()), format!("→ {}", input))
2137                                    }
2138                                };
2139
2140                                let mut m = messages.read().clone();
2141                                m.push(DisplayMessage::user(display));
2142                                messages.set(m);
2143                                if let Ok(guard) = tx_for_keys.lock() {
2144                                    if let Some(ref tx) = *guard {
2145                                        let _ = tx.send(UserInput::UserPromptResponse {
2146                                            id,
2147                                            dismissed: false,
2148                                            value,
2149                                        });
2150                                    }
2151                                }
2152                            }
2153                            _ => {}
2154                        }
2155                        return;
2156                    }
2157
2158                    // ── Normal mode keyboard ────────────────────────
2159                    // Info dialogs: Esc to close, Up/Down to navigate, Enter to act
2160                    if show_skills_dialog.get() {
2161                        const VISIBLE_ROWS: usize = 20;
2162                        match code {
2163                            KeyCode::Esc => {
2164                                show_skills_dialog.set(false);
2165                            }
2166                            KeyCode::Up => {
2167                                let cur = skills_selected.get().unwrap_or(0);
2168                                let len = skills_dialog_data.read().len();
2169                                if len > 0 {
2170                                    let next = if cur == 0 { len - 1 } else { cur - 1 };
2171                                    skills_selected.set(Some(next));
2172                                    // Adjust scroll offset
2173                                    let so = skills_scroll_offset.get();
2174                                    if next < so {
2175                                        skills_scroll_offset.set(next);
2176                                    } else if next >= so + VISIBLE_ROWS {
2177                                        skills_scroll_offset.set(next.saturating_sub(VISIBLE_ROWS - 1));
2178                                    }
2179                                }
2180                            }
2181                            KeyCode::Down => {
2182                                let cur = skills_selected.get().unwrap_or(0);
2183                                let len = skills_dialog_data.read().len();
2184                                if len > 0 {
2185                                    let next = (cur + 1) % len;
2186                                    skills_selected.set(Some(next));
2187                                    // Adjust scroll offset
2188                                    let so = skills_scroll_offset.get();
2189                                    if next < so {
2190                                        skills_scroll_offset.set(next);
2191                                    } else if next >= so + VISIBLE_ROWS {
2192                                        skills_scroll_offset.set(next.saturating_sub(VISIBLE_ROWS - 1));
2193                                    }
2194                                }
2195                            }
2196                            KeyCode::Enter => {
2197                                let idx = skills_selected.get().unwrap_or(0);
2198                                let data = skills_dialog_data.read();
2199                                if let Some(skill) = data.get(idx) {
2200                                    let name = skill.name.clone();
2201                                    drop(data);
2202                                    if let Ok(guard) = tx_for_keys.lock() {
2203                                        if let Some(ref tx) = *guard {
2204                                            let _ = tx.send(UserInput::ToggleSkill { name });
2205                                        }
2206                                    }
2207                                }
2208                            }
2209                            _ => {}
2210                        }
2211                        return;
2212                    }
2213                    if show_tool_perms_dialog.get() {
2214                        const VISIBLE_ROWS: usize = 20;
2215                        match code {
2216                            KeyCode::Esc => {
2217                                show_tool_perms_dialog.set(false);
2218                            }
2219                            KeyCode::Up => {
2220                                let cur = tool_perms_selected.get().unwrap_or(0);
2221                                let len = tool_perms_dialog_data.read().len();
2222                                if len > 0 {
2223                                    let next = if cur == 0 { len - 1 } else { cur - 1 };
2224                                    tool_perms_selected.set(Some(next));
2225                                    let so = tool_perms_scroll_offset.get();
2226                                    if next < so {
2227                                        tool_perms_scroll_offset.set(next);
2228                                    } else if next >= so + VISIBLE_ROWS {
2229                                        tool_perms_scroll_offset.set(next.saturating_sub(VISIBLE_ROWS - 1));
2230                                    }
2231                                }
2232                            }
2233                            KeyCode::Down => {
2234                                let cur = tool_perms_selected.get().unwrap_or(0);
2235                                let len = tool_perms_dialog_data.read().len();
2236                                if len > 0 {
2237                                    let next = (cur + 1) % len;
2238                                    tool_perms_selected.set(Some(next));
2239                                    let so = tool_perms_scroll_offset.get();
2240                                    if next < so {
2241                                        tool_perms_scroll_offset.set(next);
2242                                    } else if next >= so + VISIBLE_ROWS {
2243                                        tool_perms_scroll_offset.set(next.saturating_sub(VISIBLE_ROWS - 1));
2244                                    }
2245                                }
2246                            }
2247                            KeyCode::Enter => {
2248                                let idx = tool_perms_selected.get().unwrap_or(0);
2249                                let data = tool_perms_dialog_data.read();
2250                                if let Some(tool) = data.get(idx) {
2251                                    let name = tool.name.clone();
2252                                    drop(data);
2253                                    if let Ok(guard) = tx_for_keys.lock() {
2254                                        if let Some(ref tx) = *guard {
2255                                            let _ = tx.send(UserInput::CycleToolPermission { name });
2256                                        }
2257                                    }
2258                                }
2259                            }
2260                            _ => {}
2261                        }
2262                        return;
2263                    }
2264                    if show_secrets_dialog.get() {
2265                        const VISIBLE_ROWS: usize = 20;
2266                        // Add-secret inline input mode
2267                        let add_step = secrets_add_step.get();
2268                        if add_step > 0 {
2269                            match code {
2270                                KeyCode::Esc => {
2271                                    secrets_add_step.set(0);
2272                                    secrets_add_name.set(String::new());
2273                                    secrets_add_value.set(String::new());
2274                                }
2275                                KeyCode::Enter => {
2276                                    if add_step == 1 {
2277                                        // Name entered, move to value
2278                                        if !secrets_add_name.read().trim().is_empty() {
2279                                            secrets_add_step.set(2);
2280                                        }
2281                                    } else {
2282                                        // Value entered, submit
2283                                        let name = secrets_add_name.read().trim().to_string();
2284                                        let value = secrets_add_value.read().clone();
2285                                        if !name.is_empty() && !value.is_empty() {
2286                                            if let Ok(guard) = tx_for_keys.lock() {
2287                                                if let Some(ref tx) = *guard {
2288                                                    let _ = tx.send(UserInput::AddSecret { name, value });
2289                                                }
2290                                            }
2291                                        }
2292                                        secrets_add_step.set(0);
2293                                        secrets_add_name.set(String::new());
2294                                        secrets_add_value.set(String::new());
2295                                    }
2296                                }
2297                                KeyCode::Backspace => {
2298                                    if add_step == 1 {
2299                                        let mut s = secrets_add_name.read().clone();
2300                                        s.pop();
2301                                        secrets_add_name.set(s);
2302                                    } else {
2303                                        let mut s = secrets_add_value.read().clone();
2304                                        s.pop();
2305                                        secrets_add_value.set(s);
2306                                    }
2307                                }
2308                                KeyCode::Char(c) => {
2309                                    if add_step == 1 {
2310                                        let mut s = secrets_add_name.read().clone();
2311                                        s.push(c);
2312                                        secrets_add_name.set(s);
2313                                    } else {
2314                                        let mut s = secrets_add_value.read().clone();
2315                                        s.push(c);
2316                                        secrets_add_value.set(s);
2317                                    }
2318                                }
2319                                _ => {}
2320                            }
2321                            return;
2322                        }
2323                        // Normal secrets dialog navigation
2324                        match code {
2325                            KeyCode::Esc => {
2326                                show_secrets_dialog.set(false);
2327                            }
2328                            KeyCode::Up => {
2329                                let cur = secrets_selected.get().unwrap_or(0);
2330                                let len = secrets_dialog_data.read().len();
2331                                if len > 0 {
2332                                    let next = if cur == 0 { len - 1 } else { cur - 1 };
2333                                    secrets_selected.set(Some(next));
2334                                    let so = secrets_scroll_offset.get();
2335                                    if next < so {
2336                                        secrets_scroll_offset.set(next);
2337                                    } else if next >= so + VISIBLE_ROWS {
2338                                        secrets_scroll_offset.set(next.saturating_sub(VISIBLE_ROWS - 1));
2339                                    }
2340                                }
2341                            }
2342                            KeyCode::Down => {
2343                                let cur = secrets_selected.get().unwrap_or(0);
2344                                let len = secrets_dialog_data.read().len();
2345                                if len > 0 {
2346                                    let next = (cur + 1) % len;
2347                                    secrets_selected.set(Some(next));
2348                                    let so = secrets_scroll_offset.get();
2349                                    if next < so {
2350                                        secrets_scroll_offset.set(next);
2351                                    } else if next >= so + VISIBLE_ROWS {
2352                                        secrets_scroll_offset.set(next.saturating_sub(VISIBLE_ROWS - 1));
2353                                    }
2354                                }
2355                            }
2356                            KeyCode::Enter => {
2357                                // Cycle permission policy
2358                                let idx = secrets_selected.get().unwrap_or(0);
2359                                let data = secrets_dialog_data.read();
2360                                if let Some(secret) = data.get(idx) {
2361                                    let name = secret.name.clone();
2362                                    let policy = secret.policy.clone();
2363                                    drop(data);
2364                                    if let Ok(guard) = tx_for_keys.lock() {
2365                                        if let Some(ref tx) = *guard {
2366                                            let _ = tx.send(UserInput::CycleSecretPolicy { name, current_policy: policy });
2367                                        }
2368                                    }
2369                                }
2370                            }
2371                            KeyCode::Char('d') | KeyCode::Delete => {
2372                                // Delete selected secret
2373                                let idx = secrets_selected.get().unwrap_or(0);
2374                                let data = secrets_dialog_data.read();
2375                                if let Some(secret) = data.get(idx) {
2376                                    let name = secret.name.clone();
2377                                    drop(data);
2378                                    if let Ok(guard) = tx_for_keys.lock() {
2379                                        if let Some(ref tx) = *guard {
2380                                            let _ = tx.send(UserInput::DeleteSecret { name });
2381                                        }
2382                                    }
2383                                }
2384                            }
2385                            KeyCode::Char('a') => {
2386                                // Start add-secret inline input
2387                                secrets_add_step.set(1);
2388                                secrets_add_name.set(String::new());
2389                                secrets_add_value.set(String::new());
2390                            }
2391                            _ => {}
2392                        }
2393                        return;
2394                    }
2395
2396                    // Command menu intercepts when visible
2397                    let menu_open = !command_completions.read().is_empty();
2398
2399                    match code {
2400                        KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
2401                            should_quit.set(true);
2402                            if let Ok(guard) = tx_for_keys.lock() {
2403                                if let Some(ref tx) = *guard {
2404                                    let _ = tx.send(UserInput::Quit);
2405                                }
2406                            }
2407                        }
2408                        KeyCode::Tab if menu_open => {
2409                            // Cycle forward through completions
2410                            let completions = command_completions.read().clone();
2411                            let new_idx = match command_selected.get() {
2412                                Some(i) => (i + 1) % completions.len(),
2413                                None => 0,
2414                            };
2415                            command_selected.set(Some(new_idx));
2416                            // Apply the selected completion into the input
2417                            if let Some(cmd) = completions.get(new_idx) {
2418                                input_value.set(format!("/{}", cmd));
2419                            }
2420                        }
2421                        KeyCode::BackTab if menu_open => {
2422                            // Cycle backward through completions
2423                            let completions = command_completions.read().clone();
2424                            let new_idx = match command_selected.get() {
2425                                Some(0) | None => completions.len().saturating_sub(1),
2426                                Some(i) => i - 1,
2427                            };
2428                            command_selected.set(Some(new_idx));
2429                            if let Some(cmd) = completions.get(new_idx) {
2430                                input_value.set(format!("/{}", cmd));
2431                            }
2432                        }
2433                        KeyCode::Up if menu_open => {
2434                            // Navigate up through completions
2435                            let completions = command_completions.read().clone();
2436                            let new_idx = match command_selected.get() {
2437                                Some(0) | None => completions.len().saturating_sub(1),
2438                                Some(i) => i - 1,
2439                            };
2440                            command_selected.set(Some(new_idx));
2441                            if let Some(cmd) = completions.get(new_idx) {
2442                                input_value.set(format!("/{}", cmd));
2443                            }
2444                        }
2445                        KeyCode::Down if menu_open => {
2446                            // Navigate down through completions
2447                            let completions = command_completions.read().clone();
2448                            let new_idx = match command_selected.get() {
2449                                Some(i) => (i + 1) % completions.len(),
2450                                None => 0,
2451                            };
2452                            command_selected.set(Some(new_idx));
2453                            if let Some(cmd) = completions.get(new_idx) {
2454                                input_value.set(format!("/{}", cmd));
2455                            }
2456                        }
2457                        KeyCode::Esc if menu_open => {
2458                            // Close the command menu
2459                            command_completions.set(Vec::new());
2460                            command_selected.set(None);
2461                        }
2462                        KeyCode::Enter if sidebar_focused.get() => {
2463                            let thread_list = threads.read().clone();
2464                            if let Some(thread) = thread_list.get(sidebar_selected.get()) {
2465                                // Send thread switch request
2466                                if let Ok(guard) = tx_for_keys.lock() {
2467                                    if let Some(ref tx) = *guard {
2468                                        let _ = tx.send(UserInput::ThreadSwitch(thread.id));
2469                                    }
2470                                }
2471                            }
2472                            // Return focus to input after selection
2473                            sidebar_focused.set(false);
2474                        }
2475                        KeyCode::Enter => {
2476                            let val = input_value.to_string();
2477                            if !val.is_empty() {
2478                                input_value.set(String::new());
2479                                // Close command menu
2480                                command_completions.set(Vec::new());
2481                                command_selected.set(None);
2482                                // Snap to bottom so user sees their message + response
2483                                scroll_offset.set(0);
2484                                if let Ok(guard) = tx_for_keys.lock() {
2485                                    if let Some(ref tx) = *guard {
2486                                        if val.starts_with('/') {
2487                                            let _ = tx.send(UserInput::Command(
2488                                                val.trim_start_matches('/').to_string(),
2489                                            ));
2490                                        } else {
2491                                            let mut m = messages.read().clone();
2492                                            m.push(DisplayMessage::user(&val));
2493                                            messages.set(m);
2494                                            // Start the spinner immediately so the user
2495                                            // sees feedback while waiting for the model.
2496                                            streaming.set(true);
2497                                            stream_start.set(Some(Instant::now()));
2498                                            let _ = tx.send(UserInput::Chat(val));
2499                                        }
2500                                    }
2501                                }
2502                            }
2503                        }
2504                        // Tab toggles sidebar focus when command menu is not open
2505                        KeyCode::Tab if !menu_open => {
2506                            sidebar_focused.set(!sidebar_focused.get());
2507                        }
2508                        // Sidebar navigation when focused
2509                        KeyCode::Up if sidebar_focused.get() => {
2510                            let thread_count = threads.read().len();
2511                            if thread_count > 0 {
2512                                let current = sidebar_selected.get();
2513                                sidebar_selected.set(current.saturating_sub(1));
2514                            }
2515                        }
2516                        KeyCode::Down if sidebar_focused.get() => {
2517                            let thread_count = threads.read().len();
2518                            if thread_count > 0 {
2519                                let current = sidebar_selected.get();
2520                                sidebar_selected.set((current + 1).min(thread_count - 1));
2521                            }
2522                        }
2523                        KeyCode::Esc if sidebar_focused.get() => {
2524                            // Escape returns focus to input
2525                            sidebar_focused.set(false);
2526                        }
2527                        KeyCode::Up => {
2528                            scroll_offset.set(scroll_offset.get() + 1);
2529                        }
2530                        KeyCode::Down => {
2531                            scroll_offset.set((scroll_offset.get() - 1).max(0));
2532                        }
2533                        _ => {}
2534                    }
2535                }
2536                _ => {}
2537            }
2538        });
2539
2540        if should_quit.get() {
2541            system.exit();
2542        }
2543
2544        // Auto-scroll to bottom when streaming
2545        if streaming.get() {
2546            scroll_offset.set(0);
2547        }
2548
2549        // Gateway display
2550        let status = gw_status.get();
2551        let gw_icon = theme::gateway_icon(&status).to_string();
2552        let gw_label = status.label().to_string();
2553        let gw_color = Some(theme::gateway_color(&status));
2554
2555        // Clone props into owned values so closures below don't borrow `props`.
2556        let prop_soul_name = props.soul_name.clone();
2557        let prop_soul_name_for_hatching = props.soul_name.clone();
2558        let prop_model_label = props.model_label.clone();
2559        let prop_provider_id = props.provider_id.clone();
2560        let prop_hint = props.hint.clone();
2561
2562        element! {
2563            Root(
2564                width: width,
2565                height: height,
2566                soul_name: prop_soul_name,
2567                model_label: dynamic_model_label.read().clone().unwrap_or_else(|| prop_model_label.clone()),
2568                gateway_icon: gw_icon,
2569                gateway_label: gw_label,
2570                gateway_color: gw_color,
2571                messages: messages.read().clone(),
2572                scroll_offset: scroll_offset.get(),
2573                command_completions: command_completions.read().clone(),
2574                command_selected: command_selected.get(),
2575                input_value: input_value.to_string(),
2576                input_has_focus: !show_auth_dialog.get()
2577                    && !show_tool_approval.get()
2578                    && !show_vault_unlock.get()
2579                    && !show_user_prompt.get()
2580                    && !show_secrets_dialog.get()
2581                    && !show_skills_dialog.get()
2582                    && !show_tool_perms_dialog.get()
2583                    && !show_hatching.get()
2584                    && !sidebar_focused.get(),
2585                on_change: move |new_val: String| {
2586                    input_value.set(new_val.clone());
2587                    // Update slash-command completions
2588                    if let Some(partial) = new_val.strip_prefix('/') {
2589                        let current_pid = dynamic_provider_id.read().clone()
2590                            .unwrap_or_else(|| prop_provider_id.clone());
2591                        let names = rustyclaw_core::commands::command_names_for_provider(&current_pid);
2592                        let filtered: Vec<String> = names
2593                            .into_iter()
2594                            .filter(|c: &String| c.starts_with(partial))
2595                            .collect();
2596                        if filtered.is_empty() {
2597                            command_completions.set(Vec::new());
2598                            command_selected.set(None);
2599                        } else {
2600                            command_completions.set(filtered);
2601                            command_selected.set(None);
2602                        }
2603                    } else {
2604                        command_completions.set(Vec::new());
2605                        command_selected.set(None);
2606                    }
2607                },
2608                on_submit: move |_val: String| {
2609                    // Submit handled by Enter key above
2610                },
2611                task_text: if streaming.get() { "Streaming…".to_string() } else { "Idle".to_string() },
2612                streaming: streaming.get(),
2613                elapsed: elapsed.to_string(),
2614                threads: threads.read().clone(),
2615                sidebar_focused: sidebar_focused.get(),
2616                sidebar_selected: sidebar_selected.get(),
2617                hint: prop_hint.clone(),
2618                spinner_tick: spinner_tick.get(),
2619                show_auth_dialog: show_auth_dialog.get(),
2620                auth_code: auth_code.read().clone(),
2621                auth_error: auth_error.read().clone(),
2622                show_tool_approval: show_tool_approval.get(),
2623                tool_approval_name: tool_approval_name.read().clone(),
2624                tool_approval_args: tool_approval_args.read().clone(),
2625                tool_approval_selected: tool_approval_selected.get(),
2626                show_vault_unlock: show_vault_unlock.get(),
2627                vault_password_len: vault_password.read().len(),
2628                vault_error: vault_error.read().clone(),
2629                show_user_prompt: show_user_prompt.get(),
2630                user_prompt_title: user_prompt_title.read().clone(),
2631                user_prompt_desc: user_prompt_desc.read().clone(),
2632                user_prompt_input: user_prompt_input.read().clone(),
2633                user_prompt_type: user_prompt_type.read().clone(),
2634                user_prompt_selected: user_prompt_selected.get(),
2635                show_secrets_dialog: show_secrets_dialog.get(),
2636                secrets_data: secrets_dialog_data.read().clone(),
2637                secrets_agent_access: secrets_agent_access.get(),
2638                secrets_has_totp: secrets_has_totp.get(),
2639                secrets_selected: secrets_selected.get(),
2640                secrets_scroll_offset: secrets_scroll_offset.get(),
2641                secrets_add_step: secrets_add_step.get(),
2642                secrets_add_name: secrets_add_name.read().clone(),
2643                secrets_add_value: secrets_add_value.read().clone(),
2644                show_skills_dialog: show_skills_dialog.get(),
2645                skills_data: skills_dialog_data.read().clone(),
2646                skills_selected: skills_selected.get(),
2647                skills_scroll_offset: skills_scroll_offset.get(),
2648                show_tool_perms_dialog: show_tool_perms_dialog.get(),
2649                tool_perms_data: tool_perms_dialog_data.read().clone(),
2650                tool_perms_selected: tool_perms_selected.get(),
2651                tool_perms_scroll_offset: tool_perms_scroll_offset.get(),
2652                show_hatching: show_hatching.get(),
2653                hatching_state: hatching_state.read().clone(),
2654                hatching_agent_name: prop_soul_name_for_hatching,
2655            )
2656        }
2657    }
2658}
2659
2660// Re-export the component so element!() can find it
2661use tui_component::TuiRoot;