Skip to main content

orcs_app/app/
mod.rs

1//! ORCS Application.
2//!
3//! High-level application wrapper that integrates:
4//!
5//! - [`OrcsEngine`] - Core execution engine
6//! - [`EchoWithHilComponent`] - Echo with HIL integration (via ChannelRunner)
7//! - [`InputParser`] - stdin command parsing
8//! - [`ConsoleRenderer`] - User output display
9//!
10//! # Input Flow
11//!
12//! ```text
13//! stdin → handle_input() → InputCommand
14//!                            │
15//!          ┌─────────────────┴─────────────────┐
16//!          │                                   │
17//!          ▼                                   ▼
18//!    App-local                             IOInput
19//!    (Quit/Pause/Resume/Help)          (All other input)
20//!          │                                   │
21//!          │                                   ▼
22//!          │                            ClientRunner
23//!          │                       io_bridge.recv_input()
24//!          │                                   │
25//!          │                     ┌─────────────┴─────────────┐
26//!          │                     │                           │
27//!          │                     ▼                           ▼
28//!          │              Signal (y/n/veto)           User message
29//!          │                     │                   handle_user_message()
30//!          │                     │                           │
31//!          │                     ▼                           ▼
32//!          │            Signal broadcast          Component.on_request()
33//!          │                     │                           │
34//!          └─────────────────────┴───────────────────────────┘
35//!                                │
36//!                                ▼
37//!                    IOOutput → handle_io_output()
38//! ```
39//!
40//! # Architecture
41//!
42//! The EchoWithHilComponent runs inside ClientRunner (parallel tokio task).
43//! App manages UI state (pending_approval) locally, updated via IOOutput.
44//!
45//! ## Input Routing
46//!
47//! | Input Type | Route | Handler |
48//! |------------|-------|---------|
49//! | Quit/Pause/Resume/Help | App-local | LoopControl |
50//! | Approve/Reject/Veto | IOInput → ClientRunner | Signal broadcast |
51//! | User message (Unknown) | IOInput → ClientRunner | Component.on_request() |
52//!
53//! # Example
54//!
55//! ```ignore
56//! use orcs_app::{OrcsApp, NoOpResolver};
57//!
58//! #[tokio::main]
59//! async fn main() -> anyhow::Result<()> {
60//!     let mut app = OrcsApp::builder(NoOpResolver).build().await?;
61//!     app.run_interactive().await?;
62//!     Ok(())
63//! }
64//! ```
65
66mod builder;
67
68pub use builder::OrcsAppBuilder;
69
70use crate::AppError;
71use crate::SharedPrinterSlot;
72use orcs_event::Signal;
73use orcs_runtime::auth::{DefaultGrantStore, GrantPolicy};
74use orcs_runtime::io::{ConsoleRenderer, InputParser};
75use orcs_runtime::session::SessionStore;
76use orcs_runtime::{
77    ConfigResolver, IOInput, IOInputHandle, IOOutput, IOOutputHandle, InputCommand, InputContext,
78    LocalFileStore, OrcsConfig, OrcsEngine, SessionAsset,
79};
80use orcs_types::{ChannelId, Principal, PrincipalId};
81use rustyline::highlight::{CmdKind, Highlighter};
82use rustyline::history::DefaultHistory;
83use rustyline::{
84    Completer, Editor, ExternalPrinter as RustylineExternalPrinter, Helper, Hinter, Validator,
85};
86use std::collections::HashMap;
87use std::path::PathBuf;
88use std::sync::Arc;
89
90/// Pending approval state.
91///
92/// Tracks the last approval request for "y" / "n" without explicit ID.
93#[derive(Debug, Clone)]
94struct PendingApproval {
95    /// Approval ID (UUID).
96    id: String,
97}
98
99/// Control flow for the main input loop.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101enum LoopControl {
102    /// Continue processing input.
103    Continue,
104    /// Exit the loop normally (quit command).
105    Exit,
106    /// Pause and save session before exit.
107    Pause,
108}
109
110/// Event sent from the dedicated readline OS thread to the async main loop.
111#[derive(Debug)]
112enum ReadlineEvent {
113    /// User entered a line of text.
114    Line(String),
115    /// EOF (Ctrl+D on empty line).
116    Eof,
117}
118
119/// Rustyline helper that forces full line refresh on every character insert.
120///
121/// The primary IME fix is in the patched rustyline (`[patch.crates-io]`),
122/// which drains BufReader before the raw `select()` syscall so that
123/// multi-character IME bursts are processed without stalling.
124///
125/// This helper provides an additional safeguard: by returning `true` from
126/// [`Highlighter::highlight_char`] for [`CmdKind::Other`], we bypass
127/// rustyline's fast-path optimization (raw UTF-8 byte write) and force a
128/// full `refresh()` after every character insertion.  This ensures the
129/// terminal repaints the line even if the fast-path write doesn't trigger
130/// a visible update for IME-composed text on macOS.
131#[derive(Completer, Helper, Hinter, Validator)]
132struct OrcsHelper;
133
134impl Highlighter for OrcsHelper {
135    fn highlight_char(&self, _line: &str, _pos: usize, kind: CmdKind) -> bool {
136        // Force full line refresh on character insertion (CmdKind::Other).
137        // This fixes IME composition display on macOS where the fast-path
138        // write_and_flush doesn't trigger terminal repaint after IME commit.
139        kind == CmdKind::Other
140    }
141}
142
143/// ORCS Application.
144///
145/// Integrates the engine with HIL, input handling, and output display.
146pub struct OrcsApp {
147    /// Loaded configuration (merged from all sources).
148    pub(super) config: OrcsConfig,
149    /// Configuration resolver (retained for hot-reload).
150    pub(super) resolver: Box<dyn ConfigResolver>,
151    pub(super) engine: OrcsEngine,
152    /// Principal for signal creation.
153    pub(super) principal: Principal,
154    /// Console renderer for output display.
155    pub(super) renderer: ConsoleRenderer,
156    /// Handle for sending input to ClientRunner via IOPort.
157    ///
158    /// Primary input channel for View → ClientRunner communication.
159    /// All user input flows through this channel.
160    pub(super) io_input: IOInputHandle,
161    /// Handle for receiving IO output from ClientRunner.
162    pub(super) io_output: IOOutputHandle,
163    /// Pending approval (for "y" / "n" without explicit ID).
164    pending_approval: Option<PendingApproval>,
165    /// Session store for persistence.
166    pub(super) store: LocalFileStore,
167    /// Current session asset.
168    pub(super) session: SessionAsset,
169    /// Whether this session was resumed from storage.
170    pub(super) is_resumed: bool,
171    /// Number of components restored (if resumed).
172    pub(super) restored_count: usize,
173    /// Shared grant store for elevated components.
174    ///
175    /// Retained so that `save_session` can snapshot grants and
176    /// `resume` can restore them. Concrete type allows direct
177    /// `restore_grants` call without downcasting.
178    pub(super) shared_grants: Arc<DefaultGrantStore>,
179    /// Component name → ChannelId routing table.
180    ///
181    /// Used by `@component message` syntax for targeted delivery.
182    pub(super) component_routes: HashMap<String, ChannelId>,
183    /// Shared printer slot for terminal-safe output while rustyline is active.
184    ///
185    /// Shared with the tracing `MakeWriter` so that log output also routes
186    /// through ExternalPrinter during interactive mode.
187    printer_slot: SharedPrinterSlot,
188}
189
190impl OrcsApp {
191    /// Creates a new builder for OrcsApp.
192    ///
193    /// The resolver is required and controls how configuration is loaded.
194    /// The application retains the resolver for hot-reload support.
195    #[must_use]
196    pub fn builder(resolver: impl ConfigResolver + 'static) -> OrcsAppBuilder {
197        OrcsAppBuilder::new(resolver)
198    }
199
200    /// Returns a reference to the engine.
201    #[must_use]
202    pub fn engine(&self) -> &OrcsEngine {
203        &self.engine
204    }
205
206    /// Returns a mutable reference to the engine.
207    pub fn engine_mut(&mut self) -> &mut OrcsEngine {
208        &mut self.engine
209    }
210
211    /// Returns a reference to the loaded configuration.
212    #[must_use]
213    pub fn config(&self) -> &OrcsConfig {
214        &self.config
215    }
216
217    /// Re-resolves configuration from all sources (hot-reload).
218    ///
219    /// Calls the retained `ConfigResolver::resolve()` to get a fresh
220    /// config reflecting any file/env changes since last resolution.
221    ///
222    /// # Errors
223    ///
224    /// Returns [`AppError::Config`] if resolution fails.
225    pub fn reload_config(&mut self) -> Result<(), AppError> {
226        self.config = self
227            .resolver
228            .resolve()
229            .map_err(|e| AppError::Config(e.to_string()))?;
230        Ok(())
231    }
232
233    /// Returns the console renderer.
234    #[must_use]
235    pub fn renderer(&self) -> &ConsoleRenderer {
236        &self.renderer
237    }
238
239    /// Returns the pending approval ID.
240    #[must_use]
241    pub fn pending_approval_id(&self) -> Option<&str> {
242        self.pending_approval.as_ref().map(|p| p.id.as_str())
243    }
244
245    /// Returns the current session ID.
246    #[must_use]
247    pub fn session_id(&self) -> &str {
248        &self.session.id
249    }
250
251    /// Returns whether this session was resumed from storage.
252    #[must_use]
253    pub fn is_resumed(&self) -> bool {
254        self.is_resumed
255    }
256
257    /// Returns the number of components restored (if resumed).
258    #[must_use]
259    pub fn restored_count(&self) -> usize {
260        self.restored_count
261    }
262
263    /// Saves the current session to storage.
264    ///
265    /// Transfers component snapshots from the engine into the session
266    /// asset, then persists to the store.
267    ///
268    /// # Errors
269    ///
270    /// Returns [`AppError::Storage`] if saving fails.
271    pub async fn save_session(&mut self) -> Result<(), AppError> {
272        // Save component snapshots
273        for (fqn, snapshot) in self.engine.collected_snapshots() {
274            self.session
275                .component_snapshots
276                .insert(fqn.clone(), snapshot.clone());
277        }
278        // Save grant state
279        match self.shared_grants.list_grants() {
280            Ok(grants) => {
281                tracing::debug!("Saving {} grant(s) to session", grants.len());
282                self.session.save_grants(grants);
283            }
284            Err(e) => {
285                tracing::error!("Failed to list grants for session save: {e}");
286            }
287        }
288        self.session.touch();
289        self.store.save(&self.session).await?;
290        Ok(())
291    }
292
293    /// Loads a session from storage.
294    ///
295    /// # Arguments
296    ///
297    /// * `session_id` - The session ID to load
298    ///
299    /// # Errors
300    ///
301    /// Returns [`AppError::Storage`] if loading fails.
302    pub async fn load_session(&mut self, session_id: &str) -> Result<(), AppError> {
303        self.session = self.store.load(session_id).await?;
304        Ok(())
305    }
306
307    /// Returns the history file path from config, or `~/.orcs/history` as default.
308    fn history_path(&self) -> PathBuf {
309        self.config.paths.history_file_or_default()
310    }
311
312    /// Spawns a dedicated OS thread running rustyline for line editing.
313    ///
314    /// Returns:
315    /// - `UnboundedReceiver<ReadlineEvent>` for the async loop to consume
316    /// - `Option<Box<dyn ExternalPrinter>>` for terminal-safe output during readline
317    ///
318    /// The thread saves history to disk after each entered line.
319    fn spawn_readline_thread(
320        history_path: PathBuf,
321    ) -> (
322        tokio::sync::mpsc::UnboundedReceiver<ReadlineEvent>,
323        Option<Box<dyn RustylineExternalPrinter + Send>>,
324    ) {
325        let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel();
326        let (printer_tx, printer_rx) =
327            std::sync::mpsc::sync_channel::<Option<Box<dyn RustylineExternalPrinter + Send>>>(1);
328
329        if let Err(e) = std::thread::Builder::new()
330            .name("orcs-readline".into())
331            .spawn(move || {
332                let config = rustyline::Config::builder().auto_add_history(true).build();
333
334                let mut rl: Editor<OrcsHelper, DefaultHistory> = match Editor::with_config(config) {
335                    Ok(editor) => editor,
336                    Err(e) => {
337                        tracing::error!("Failed to create readline editor: {e}");
338                        let _ = printer_tx.send(None);
339                        return;
340                    }
341                };
342                rl.set_helper(Some(OrcsHelper));
343
344                // Load history (ignore errors for first run)
345                if let Err(e) = rl.load_history(&history_path) {
346                    tracing::debug!("History load: {e} (expected on first run)");
347                }
348
349                // Create ExternalPrinter for async-side output during readline
350                let printer = rl
351                    .create_external_printer()
352                    .ok()
353                    .map(|p| Box::new(p) as Box<dyn RustylineExternalPrinter + Send>);
354                let _ = printer_tx.send(printer);
355
356                // Readline loop
357                loop {
358                    match rl.readline("orcs> ") {
359                        Ok(line) => {
360                            // Persist history after each line (resilient to process kill)
361                            let _ = rl.save_history(&history_path);
362
363                            if event_tx.send(ReadlineEvent::Line(line)).is_err() {
364                                break; // Receiver dropped → shutdown
365                            }
366                        }
367                        Err(rustyline::error::ReadlineError::Interrupted) => {
368                            // Ctrl+C → clear current line, continue
369                            continue;
370                        }
371                        Err(rustyline::error::ReadlineError::Eof) => {
372                            let _ = event_tx.send(ReadlineEvent::Eof);
373                            break;
374                        }
375                        Err(e) => {
376                            tracing::error!("Readline error: {e}");
377                            let _ = event_tx.send(ReadlineEvent::Eof);
378                            break;
379                        }
380                    }
381                }
382
383                // Final history save
384                let _ = rl.save_history(&history_path);
385            })
386        {
387            tracing::error!("failed to spawn readline thread: {e}");
388        }
389
390        // Block until the readline thread sends the printer (or fails)
391        let printer = printer_rx.recv().ok().flatten();
392
393        (event_rx, printer)
394    }
395
396    /// Renders output through ExternalPrinter (if active) or ConsoleRenderer.
397    ///
398    /// During `run_interactive()`, all output must go through ExternalPrinter
399    /// to avoid corrupting rustyline's terminal state (raw mode, prompt redraw).
400    fn render_safe(&self, output: &IOOutput) {
401        if let Some(formatted) = Self::format_io_output(output, self.renderer.is_verbose()) {
402            match self.printer_slot.try_print(formatted) {
403                crate::PrintResult::Sent | crate::PrintResult::Dropped => return,
404                crate::PrintResult::NoPrinter => {}
405            }
406        }
407        // Fallback: direct console output (no printer installed)
408        self.renderer.render_output(output);
409    }
410
411    /// Formats an IOOutput into a terminal-ready string for ExternalPrinter.
412    ///
413    /// Returns `None` for messages that should be suppressed (e.g. Debug when not verbose).
414    fn format_io_output(output: &IOOutput, verbose: bool) -> Option<String> {
415        match output {
416            IOOutput::Print { text, style } => match style {
417                orcs_runtime::OutputStyle::Normal | orcs_runtime::OutputStyle::Info => {
418                    Some(format!("{text}\n"))
419                }
420                orcs_runtime::OutputStyle::Warn => Some(format!("[WARN] {text}\n")),
421                orcs_runtime::OutputStyle::Error => Some(format!("[ERROR] {text}\n")),
422                orcs_runtime::OutputStyle::Success => Some(format!("\x1B[32m{text}\x1B[0m\n")),
423                orcs_runtime::OutputStyle::Debug => {
424                    if verbose {
425                        Some(format!("[DEBUG] {text}\n"))
426                    } else {
427                        None
428                    }
429                }
430            },
431            IOOutput::Prompt { message } => Some(format!("{message} ")),
432            IOOutput::ShowApprovalRequest {
433                id,
434                operation,
435                description,
436            } => Some(format!(
437                "\n  [{operation}] {id} - {description}\n  Enter 'y' to approve, 'n' to reject:\n"
438            )),
439            IOOutput::ShowApproved { approval_id } => {
440                Some(format!("  \u{2713} Approved: {approval_id}\n"))
441            }
442            IOOutput::ShowRejected {
443                approval_id,
444                reason,
445            } => {
446                if let Some(reason) = reason {
447                    Some(format!("  \u{2717} Rejected: {approval_id} ({reason})\n"))
448                } else {
449                    Some(format!("  \u{2717} Rejected: {approval_id}\n"))
450                }
451            }
452            IOOutput::ShowProcessing {
453                component,
454                operation,
455            } => Some(format!("  [{component}] Processing ({operation})...\n")),
456            IOOutput::Clear => Some("\x1B[2J\x1B[1;1H".to_string()),
457        }
458    }
459
460    /// Runs the application in non-interactive command mode.
461    ///
462    /// Sends a single command, collects output until idle, and exits.
463    /// Unlike interactive mode, raw text is sent directly without
464    /// interactive command parsing (`q`/`y`/`n` are sent as-is).
465    ///
466    /// # Output routing
467    ///
468    /// - `Normal`/`Info`/`Success`/`Debug` → stdout
469    /// - `Warn`/`Error` → stderr
470    ///
471    /// # Returns
472    ///
473    /// Exit code: 0 for success, 1 if errors occurred.
474    ///
475    /// # Errors
476    ///
477    /// Returns [`AppError::Io`] on communication errors.
478    pub async fn run_command(&mut self, command: &str) -> Result<i32, AppError> {
479        self.engine.start();
480
481        let trimmed = command.trim();
482        if trimmed.is_empty() {
483            self.engine.stop();
484            self.engine.shutdown().await;
485            return Ok(0);
486        }
487
488        // Route: @component messages use targeted injection,
489        // everything else goes through IOInput as user message.
490        if trimmed.starts_with('@') {
491            let cmd = InputParser.parse(trimmed);
492            match cmd {
493                InputCommand::ComponentMessage {
494                    ref target,
495                    ref message,
496                } => {
497                    self.handle_component_message(target, message);
498                }
499                _ => {
500                    // Bare "@" or invalid target
501                    eprintln!("Invalid component target: {trimmed}");
502                    self.engine.stop();
503                    self.engine.shutdown().await;
504                    return Ok(1);
505                }
506            }
507        } else {
508            // Send raw text directly (no interactive command parsing).
509            let io_input = IOInput::line(command);
510            if let Err(e) = self.io_input.try_send(io_input) {
511                eprintln!("Failed to send command: {e:?}");
512                self.engine.stop();
513                self.engine.shutdown().await;
514                return Ok(1);
515            }
516        }
517
518        // Collect output with idle-timeout detection.
519        //   - Before first output: wait up to MAX_WAIT (LLM may be slow to start).
520        //   - After first output:  wait up to IDLE for more (stream complete).
521        const IDLE: tokio::time::Duration = tokio::time::Duration::from_secs(3);
522        const MAX_WAIT: tokio::time::Duration = tokio::time::Duration::from_secs(120);
523
524        let start = tokio::time::Instant::now();
525        let mut received_output = false;
526        let mut exit_code: i32 = 0;
527
528        loop {
529            if start.elapsed() > MAX_WAIT {
530                tracing::warn!(
531                    "Command mode: max timeout reached ({}s)",
532                    MAX_WAIT.as_secs()
533                );
534                break;
535            }
536
537            let timeout = if received_output { IDLE } else { MAX_WAIT };
538
539            tokio::select! {
540                Some(io_out) = self.io_output.recv() => {
541                    match &io_out {
542                        IOOutput::Print { text, style } => {
543                            received_output = true;
544                            if style.is_warning_or_error() {
545                                exit_code = 1;
546                                eprintln!("{text}");
547                            } else {
548                                println!("{text}");
549                            }
550                        }
551                        IOOutput::Prompt { .. } => {
552                            // System ready for next input → command complete.
553                            break;
554                        }
555                        IOOutput::ShowApprovalRequest { .. } => {
556                            // Non-interactive mode cannot handle HIL approval.
557                            eprintln!("Command requires interactive approval. Use interactive mode.");
558                            exit_code = 1;
559                            break;
560                        }
561                        _ => {}
562                    }
563                }
564                () = tokio::time::sleep(timeout) => {
565                    tracing::debug!(
566                        "Command mode: timeout (received_output={received_output})",
567                    );
568                    break;
569                }
570            }
571        }
572
573        self.engine.stop();
574        self.engine.shutdown().await;
575        Ok(exit_code)
576    }
577
578    /// Runs the application in interactive mode.
579    ///
580    /// Uses a dedicated OS thread for rustyline (prompt, history, line editing)
581    /// and communicates with the async main loop via channels.
582    ///
583    /// All terminal output during the loop goes through `ExternalPrinter`
584    /// to avoid corrupting rustyline's raw-mode terminal state.
585    ///
586    /// # Errors
587    ///
588    /// Returns [`AppError::Io`] on stdin read errors.
589    pub async fn run_interactive(&mut self) -> Result<(), AppError> {
590        let io_id = self.engine.io_channel();
591        tracing::info!("Starting interactive mode (IO channel: {})", io_id);
592
593        self.engine.start();
594
595        // Startup messages (before readline, ext_printer is None → ConsoleRenderer)
596        self.renderer.render_output(&IOOutput::info(
597            "Interactive mode started. Type 'q' to quit, 'help' for commands.",
598        ));
599        if self.is_resumed {
600            self.renderer.render_output(&IOOutput::info(format!(
601                "Resumed session: {} ({} component(s) restored)",
602                self.session.id, self.restored_count
603            )));
604        } else {
605            self.renderer
606                .render_output(&IOOutput::info(format!("Session ID: {}", self.session.id)));
607        }
608
609        // Spawn readline thread and install ExternalPrinter into shared slot
610        // (tracing MakeWriter also reads this slot)
611        let (mut readline_rx, printer) = Self::spawn_readline_thread(self.history_path());
612        if let Some(p) = printer {
613            self.printer_slot.set(p);
614        }
615
616        let mut should_save = false;
617
618        // Yield to let pending component init messages (Ready, etc.) be delivered.
619        // Without this, piped stdin can race ahead of async IOOutput.
620        tokio::task::yield_now().await;
621
622        loop {
623            if !self.engine.is_running() {
624                break;
625            }
626
627            // biased: always drain IOOutput before processing stdin.
628            // This ensures component messages (Ready, approval requests, etc.)
629            // are displayed before the next input line is handled.
630            tokio::select! {
631                biased;
632
633                Some(io_out) = self.io_output.recv() => {
634                    self.handle_io_output(io_out);
635                }
636                event = readline_rx.recv() => {
637                    match event {
638                        Some(ReadlineEvent::Line(line)) => {
639                            match self.handle_input(&line) {
640                                LoopControl::Continue => {}
641                                LoopControl::Exit => break,
642                                LoopControl::Pause => {
643                                    should_save = true;
644                                    break;
645                                }
646                            }
647                        }
648                        Some(ReadlineEvent::Eof) | None => {
649                            tracing::debug!("readline: EOF or channel closed");
650                            break;
651                        }
652                    }
653                }
654            }
655        }
656
657        // Clear ExternalPrinter before shutdown output
658        // (tracing falls back to stderr, ConsoleRenderer writes directly)
659        self.printer_slot.clear();
660
661        self.engine.stop();
662        self.engine.shutdown().await;
663
664        if should_save {
665            self.renderer
666                .render_output(&IOOutput::info("Saving session..."));
667            self.save_session().await?;
668            self.renderer.render_output(&IOOutput::info(format!(
669                "Session saved: {}",
670                self.session.id
671            )));
672            self.renderer.render_output(&IOOutput::info(format!(
673                "Resume with: orcs --resume {}",
674                self.session.id
675            )));
676        }
677
678        self.renderer
679            .render_output(&IOOutput::info("Shutting down..."));
680        Ok(())
681    }
682
683    /// Handles a single line of input.
684    ///
685    /// # Input Routing
686    ///
687    /// - **Control signals** (Approve/Reject/Veto): → IOInput経由でClientRunner
688    /// - **App-local commands** (Quit/Pause/Resume/Help): → App内で処理
689    /// - **Component messages** (`@name msg`): → Engine.inject_event → 特定Channel
690    /// - **User messages** (Unknown): → IOInput経由でClientRunner → 全Component
691    ///
692    /// Returns control flow instruction for the main loop.
693    fn handle_input(&mut self, line: &str) -> LoopControl {
694        let cmd = InputParser.parse(line);
695
696        match &cmd {
697            InputCommand::Quit => {
698                tracing::info!("Quit requested");
699                return LoopControl::Exit;
700            }
701            InputCommand::Veto => {
702                tracing::warn!("Veto signal sent");
703                // Send via IOInput (ClientRunner will convert to Signal)
704                self.send_io_input(line);
705                return LoopControl::Exit;
706            }
707            InputCommand::Approve { approval_id } => {
708                self.handle_approve_via_io(line, approval_id.as_deref());
709            }
710            InputCommand::Reject {
711                approval_id,
712                reason: _,
713            } => {
714                self.handle_reject_via_io(line, approval_id.as_deref());
715            }
716            InputCommand::Pause => {
717                tracing::info!("Pause requested");
718                return LoopControl::Pause;
719            }
720            InputCommand::Resume => {
721                // Resume is handled at startup via --resume flag, not during interactive mode
722                self.render_safe(&IOOutput::info(
723                    "Resume is handled at startup. Use: orcs --resume",
724                ));
725            }
726            InputCommand::Steer { message } => {
727                let io_id = self.engine.io_channel();
728                self.engine.signal(Signal::steer(
729                    io_id,
730                    message.as_str(),
731                    Principal::User(PrincipalId::new()),
732                ));
733                self.render_safe(&IOOutput::info(format!("Steer: {message}")));
734            }
735            InputCommand::Empty => {
736                // Blank line - ignore
737            }
738            InputCommand::ComponentMessage { target, message } => {
739                self.handle_component_message(target, message);
740            }
741            InputCommand::Unknown { input } => {
742                if input == "help" {
743                    self.show_help();
744                } else if !input.is_empty() {
745                    // Send user message to ClientRunner via IOInput
746                    self.handle_echo_input(input.as_str());
747                }
748            }
749        }
750
751        LoopControl::Continue
752    }
753
754    /// Sends raw input to ClientRunner via IOInput.
755    ///
756    /// Used for control signals that ClientRunner should convert to Signal.
757    fn send_io_input(&self, line: &str) {
758        let context = self.build_input_context();
759        let io_input = IOInput::line_with_context(line, context);
760
761        if let Err(e) = self.io_input.try_send(io_input) {
762            tracing::warn!("Failed to send IOInput: {:?}", e);
763        }
764    }
765
766    /// Builds InputContext with current pending approval ID.
767    fn build_input_context(&self) -> InputContext {
768        match &self.pending_approval {
769            Some(pending) => InputContext::with_approval_id(&pending.id),
770            None => InputContext::empty(),
771        }
772    }
773
774    /// Handles user message input by sending to ClientRunner via IOInput.
775    ///
776    /// The message is sent to ClientRunner, which forwards it to the Component.
777    /// If the Component requires HIL approval, it will send back an approval
778    /// request via IOOutput, which App will receive and track.
779    fn handle_echo_input(&mut self, input: &str) {
780        // Send user message via IOInput (no context needed for messages)
781        let io_input = IOInput::line(input);
782
783        if let Err(e) = self.io_input.try_send(io_input) {
784            self.render_safe(&IOOutput::error(format!("Failed to send message: {e:?}")));
785        }
786    }
787
788    /// Routes a message to a specific component via `@component message`.
789    ///
790    /// Looks up the component name in `component_routes` and injects
791    /// a UserInput event directly to the target channel.
792    fn handle_component_message(&mut self, target: &str, message: &str) {
793        let channel_id = match self.component_routes.get(target) {
794            Some(id) => *id,
795            None => {
796                let available: Vec<&str> =
797                    self.component_routes.keys().map(|k| k.as_str()).collect();
798                self.render_safe(&IOOutput::error(format!(
799                    "Unknown component: @{}. Available: {}",
800                    target,
801                    available.join(", ")
802                )));
803                return;
804            }
805        };
806
807        let event = orcs_runtime::Event {
808            category: orcs_event::EventCategory::UserInput,
809            operation: "input".to_string(),
810            source: orcs_types::ComponentId::builtin("app"),
811            payload: serde_json::json!({ "message": message }),
812        };
813
814        match self.engine.inject_event(channel_id, event) {
815            Ok(()) => {
816                tracing::debug!("Routed @{} to channel {}", target, channel_id);
817            }
818            Err(e) => {
819                self.render_safe(&IOOutput::error(format!(
820                    "Failed to route to @{}: {}",
821                    target, e
822                )));
823            }
824        }
825    }
826
827    /// Handles an approve command by broadcasting Signal::Approve.
828    ///
829    /// Broadcasts the signal to all channels via the Engine's signal bus.
830    /// The shell's ChannelRunner receives it and dispatches to on_signal.
831    fn handle_approve_via_io(&mut self, _line: &str, explicit_id: Option<&str>) {
832        let approval_id = explicit_id
833            .map(String::from)
834            .or_else(|| self.pending_approval.as_ref().map(|p| p.id.clone()));
835
836        if let Some(id) = approval_id {
837            // Broadcast signal to all channels (shell receives via signal_rx)
838            let signal = Signal::approve(&id, self.principal.clone());
839            self.engine.signal(signal);
840            tracing::info!(approval_id = %id, "Approved");
841            // Approval feedback is displayed via ClientRunner → IOOutput::ShowApproved
842
843            // Clear pending approval
844            if self.pending_approval.as_ref().map(|p| p.id.as_str()) == Some(id.as_str()) {
845                self.pending_approval = None;
846            }
847        } else {
848            self.render_safe(&IOOutput::warn("No pending approval. Use: y <id>"));
849        }
850    }
851
852    /// Handles a reject command by broadcasting Signal::Reject.
853    ///
854    /// Broadcasts the signal to all channels via the Engine's signal bus.
855    fn handle_reject_via_io(&mut self, _line: &str, explicit_id: Option<&str>) {
856        let approval_id = explicit_id
857            .map(String::from)
858            .or_else(|| self.pending_approval.as_ref().map(|p| p.id.clone()));
859
860        if let Some(id) = approval_id {
861            // Broadcast signal to all channels
862            let signal = Signal::reject(&id, None, self.principal.clone());
863            self.engine.signal(signal);
864            tracing::info!(approval_id = %id, "Rejected");
865            // Rejection feedback is displayed via ClientRunner → IOOutput::ShowRejected
866
867            // Clear pending approval
868            if self.pending_approval.as_ref().map(|p| p.id.as_str()) == Some(id.as_str()) {
869                self.pending_approval = None;
870            }
871        } else {
872            self.render_safe(&IOOutput::warn("No pending approval. Use: n <id> [reason]"));
873        }
874    }
875
876    /// Shows help text as a single block (avoids per-line terminal redraws).
877    fn show_help(&self) {
878        self.render_safe(&IOOutput::info(
879            "Commands:\n\
880             \x20 y [id]          - Approve pending request\n\
881             \x20 n [id] [reason] - Reject pending request\n\
882             \x20 p / pause       - Pause execution\n\
883             \x20 r / resume      - Resume execution\n\
884             \x20 s <message>     - Steer with instruction\n\
885             \x20 q / quit        - Quit application\n\
886             \x20 veto / stop     - Emergency stop",
887        ));
888    }
889
890    /// Handles IO output from ClientRunner.
891    ///
892    /// Updates pending_approval when an approval request is received.
893    /// Uses ExternalPrinter during interactive mode.
894    fn handle_io_output(&mut self, io_out: IOOutput) {
895        // Store as pending approval for "y" / "n" without ID
896        if let IOOutput::ShowApprovalRequest { ref id, .. } = io_out {
897            self.pending_approval = Some(PendingApproval { id: id.clone() });
898        }
899
900        self.render_safe(&io_out);
901    }
902}
903
904#[cfg(test)]
905mod tests {
906    use super::*;
907    use orcs_runtime::sandbox::ProjectSandbox;
908    use orcs_runtime::NoOpResolver;
909    use orcs_runtime::WorkDir;
910    use std::path::PathBuf;
911
912    fn test_policy() -> Arc<dyn orcs_runtime::sandbox::SandboxPolicy> {
913        Arc::new(ProjectSandbox::new(".").expect("test sandbox"))
914    }
915
916    // --- OrcsHelper (IME refresh fix) ---
917
918    #[test]
919    fn orcs_helper_forces_refresh_on_char_insert() {
920        let helper = OrcsHelper;
921        assert!(
922            helper.highlight_char("", 0, CmdKind::Other),
923            "CmdKind::Other (edit_insert) should force full line refresh"
924        );
925    }
926
927    #[test]
928    fn orcs_helper_skips_refresh_on_cursor_move() {
929        let helper = OrcsHelper;
930        assert!(
931            !helper.highlight_char("abc", 1, CmdKind::MoveCursor),
932            "CmdKind::MoveCursor should not trigger refresh"
933        );
934    }
935
936    #[test]
937    fn orcs_helper_skips_refresh_on_forced_refresh() {
938        let helper = OrcsHelper;
939        assert!(
940            !helper.highlight_char("abc", 0, CmdKind::ForcedRefresh),
941            "CmdKind::ForcedRefresh should not trigger extra refresh from helper"
942        );
943    }
944
945    #[tokio::test]
946    async fn builder_default() {
947        let app = OrcsApp::builder(NoOpResolver)
948            .with_sandbox(test_policy())
949            .build()
950            .await
951            .expect("builder should succeed with NoOpResolver");
952        assert!(app.pending_approval_id().is_none());
953    }
954
955    #[tokio::test]
956    async fn builder_with_custom_resolver() {
957        struct VerboseResolver;
958        impl ConfigResolver for VerboseResolver {
959            fn resolve(&self) -> Result<OrcsConfig, orcs_runtime::ConfigError> {
960                let mut config = OrcsConfig::default();
961                config.ui.verbose = true;
962                Ok(config)
963            }
964        }
965
966        let app = OrcsApp::builder(VerboseResolver)
967            .with_sandbox(test_policy())
968            .build()
969            .await
970            .expect("builder should succeed with VerboseResolver");
971        assert!(app.config().ui.verbose);
972    }
973
974    #[tokio::test]
975    async fn app_engine_access() {
976        let app = OrcsApp::builder(NoOpResolver)
977            .with_sandbox(test_policy())
978            .build()
979            .await
980            .expect("builder should succeed");
981        assert!(!app.engine().is_running());
982    }
983
984    #[tokio::test]
985    async fn app_engine_parallel_access() {
986        let app = OrcsApp::builder(NoOpResolver)
987            .with_sandbox(test_policy())
988            .build()
989            .await
990            .expect("builder should succeed");
991        let io = app.engine().io_channel();
992        let world = app.engine().world_read();
993        let w = world.read().await;
994        assert!(w.get(&io).is_some());
995    }
996
997    #[tokio::test]
998    async fn app_session_id() {
999        let app = OrcsApp::builder(NoOpResolver)
1000            .with_sandbox(test_policy())
1001            .build()
1002            .await
1003            .expect("builder should succeed");
1004        assert!(!app.session_id().is_empty());
1005    }
1006
1007    #[tokio::test]
1008    async fn app_new_session_not_resumed() {
1009        let app = OrcsApp::builder(NoOpResolver)
1010            .with_sandbox(test_policy())
1011            .build()
1012            .await
1013            .expect("builder should succeed");
1014        assert!(!app.is_resumed());
1015        assert_eq!(app.restored_count(), 0);
1016    }
1017
1018    #[tokio::test]
1019    async fn app_save_and_load_session() {
1020        let wd = WorkDir::temporary().expect("should create temp WorkDir for session test");
1021        let temp_dir = wd.path().to_path_buf();
1022
1023        struct SessionResolver {
1024            session_dir: PathBuf,
1025        }
1026        impl ConfigResolver for SessionResolver {
1027            fn resolve(&self) -> Result<OrcsConfig, orcs_runtime::ConfigError> {
1028                let mut config = OrcsConfig::default();
1029                config.paths.session_dir = Some(self.session_dir.clone());
1030                Ok(config)
1031            }
1032        }
1033
1034        let session_id = {
1035            let mut app = OrcsApp::builder(SessionResolver {
1036                session_dir: temp_dir.clone(),
1037            })
1038            .with_sandbox(test_policy())
1039            .build()
1040            .await
1041            .expect("builder should succeed with SessionResolver");
1042
1043            let id = app.session_id().to_string();
1044            app.save_session()
1045                .await
1046                .expect("save session should succeed");
1047            id
1048        };
1049
1050        let app = OrcsApp::builder(SessionResolver {
1051            session_dir: temp_dir.clone(),
1052        })
1053        .with_sandbox(test_policy())
1054        .resume(&session_id)
1055        .build()
1056        .await
1057        .expect("resume should succeed for saved session");
1058
1059        assert!(app.is_resumed());
1060        assert_eq!(app.session_id(), session_id);
1061    }
1062
1063    #[tokio::test]
1064    async fn app_resume_nonexistent_session_fails() {
1065        let wd = WorkDir::temporary().expect("should create temp WorkDir for resume test");
1066        let temp_dir = wd.path().to_path_buf();
1067
1068        struct SessionResolver {
1069            session_dir: PathBuf,
1070        }
1071        impl ConfigResolver for SessionResolver {
1072            fn resolve(&self) -> Result<OrcsConfig, orcs_runtime::ConfigError> {
1073                let mut config = OrcsConfig::default();
1074                config.paths.session_dir = Some(self.session_dir.clone());
1075                Ok(config)
1076            }
1077        }
1078
1079        let result = OrcsApp::builder(SessionResolver {
1080            session_dir: temp_dir.clone(),
1081        })
1082        .with_sandbox(test_policy())
1083        .resume("nonexistent-session-id")
1084        .build()
1085        .await;
1086
1087        assert!(result.is_err());
1088    }
1089
1090    #[tokio::test]
1091    async fn app_reload_config() {
1092        use std::sync::atomic::{AtomicBool, Ordering};
1093
1094        struct ToggleResolver {
1095            debug: Arc<AtomicBool>,
1096        }
1097        impl ConfigResolver for ToggleResolver {
1098            fn resolve(&self) -> Result<OrcsConfig, orcs_runtime::ConfigError> {
1099                Ok(OrcsConfig {
1100                    debug: self.debug.load(Ordering::Relaxed),
1101                    ..OrcsConfig::default()
1102                })
1103            }
1104        }
1105
1106        let flag = Arc::new(AtomicBool::new(false));
1107        let mut app = OrcsApp::builder(ToggleResolver {
1108            debug: Arc::clone(&flag),
1109        })
1110        .with_sandbox(test_policy())
1111        .build()
1112        .await
1113        .expect("builder should succeed with ToggleResolver");
1114
1115        assert!(!app.config().debug);
1116
1117        flag.store(true, Ordering::Relaxed);
1118        app.reload_config().expect("reload config should succeed");
1119
1120        assert!(app.config().debug);
1121    }
1122}