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(¤t_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;