From 2cbf67bfc926de5e0c1d3c892041c4d17d8a77c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 27 Jan 2026 13:57:13 +0000 Subject: [PATCH 1/3] feat(tui): plumb parent field from Nix error messages Add `parent: Option` to InternalLog::Msg to forward the parent activity ID from Nix's structured log format. Add `message_with_parent()` to the activity stack so the nix log bridge can pass an explicit parent instead of relying on the task-local stack. This enables the TUI to associate error messages with the specific build that produced them. Co-Authored-By: Claude Opus 4.5 --- devenv-activity/src/lib.rs | 2 +- devenv-activity/src/stack.rs | 22 ++++++++++++++++++++++ devenv-core/src/eval_op.rs | 1 + devenv-core/src/internal_log.rs | 21 +++++++++++++++++++++ devenv-core/src/nix_log_bridge.rs | 15 ++++++++++----- devenv-nix-backend/src/logger.rs | 1 + 6 files changed, 56 insertions(+), 6 deletions(-) diff --git a/devenv-activity/src/lib.rs b/devenv-activity/src/lib.rs index 3ac8d9c692..070a68c723 100644 --- a/devenv-activity/src/lib.rs +++ b/devenv-activity/src/lib.rs @@ -67,7 +67,7 @@ pub use handle::{ActivityHandle, init}; pub use serde_valuable::SerdeValue; pub use stack::{ current_activity_id, current_activity_level, log_to_evaluate, message, message_with_details, - op_to_evaluate, set_expected, + message_with_parent, op_to_evaluate, set_expected, }; // Trait diff --git a/devenv-activity/src/stack.rs b/devenv-activity/src/stack.rs index f355bf6d4a..c04fd5701a 100644 --- a/devenv-activity/src/stack.rs +++ b/devenv-activity/src/stack.rs @@ -115,6 +115,28 @@ pub fn message_with_details( })); } +/// Emit a standalone message with optional details and an explicit parent activity ID. +/// +/// Use this when you know the parent activity ID from an external source (e.g., Nix's +/// internal log format) rather than relying on the task-local activity stack. +pub fn message_with_parent( + level: ActivityLevel, + text: impl Into, + details: Option, + parent: Option, +) { + // Prefer the explicit parent if provided, otherwise fall back to the stack + let parent = parent.or_else(current_activity_id); + send_activity_event(ActivityEvent::Message(Message { + id: next_id(), + level, + text: text.into(), + details, + parent, + timestamp: Timestamp::now(), + })); +} + /// Emit a SetExpected event to announce aggregate expected counts. /// This is used by Nix to announce how many items/bytes are expected /// before individual activities start. diff --git a/devenv-core/src/eval_op.rs b/devenv-core/src/eval_op.rs index 1073afe3a9..9e20d31106 100644 --- a/devenv-core/src/eval_op.rs +++ b/devenv-core/src/eval_op.rs @@ -150,6 +150,7 @@ mod tests { msg: msg.to_string(), raw_msg: None, level: Verbosity::Warn, + parent: None, } } diff --git a/devenv-core/src/internal_log.rs b/devenv-core/src/internal_log.rs index a9a6e9e952..103c121d23 100644 --- a/devenv-core/src/internal_log.rs +++ b/devenv-core/src/internal_log.rs @@ -14,6 +14,9 @@ pub enum InternalLog { msg: String, // Raw message when logging ErrorInfo raw_msg: Option, + // Parent activity ID (from Nix's internal log format) + #[serde(default)] + parent: Option, }, Start { id: u64, @@ -215,6 +218,22 @@ mod test { level: Verbosity::Warn, msg: "hello".to_string(), raw_msg: None, + parent: None, + } + ); + } + + #[test] + fn test_parse_log_msg_with_parent() { + let line = r#"@nix {"action":"msg","level":0,"msg":"error: something","parent":42}"#; + let log = InternalLog::parse(line).unwrap().unwrap(); + assert_eq!( + log, + InternalLog::Msg { + level: Verbosity::Error, + msg: "error: something".to_string(), + raw_msg: None, + parent: Some(42), } ); } @@ -284,6 +303,7 @@ mod test { level: Verbosity::Error, msg: "\u{1b}[31;1merror:\u{1b}[0m\nsomething went wrong".to_string(), raw_msg: None, + parent: None, }; assert!(log.is_nix_error()); } @@ -296,6 +316,7 @@ mod test { level: Verbosity::Error, msg: "not an error".to_string(), raw_msg: None, + parent: None, }; assert!(!log.is_nix_error()); } diff --git a/devenv-core/src/nix_log_bridge.rs b/devenv-core/src/nix_log_bridge.rs index fc4fd3d3f3..4cda1a2551 100644 --- a/devenv-core/src/nix_log_bridge.rs +++ b/devenv-core/src/nix_log_bridge.rs @@ -22,8 +22,8 @@ //! This guard-based API ensures eval scopes are always properly closed. use devenv_activity::{ - Activity, ActivityLevel, ExpectedCategory, FetchKind, message, message_with_details, - op_to_evaluate, set_expected, + Activity, ActivityLevel, ExpectedCategory, FetchKind, message_with_parent, op_to_evaluate, + set_expected, }; use regex::Regex; use std::collections::HashMap; @@ -188,7 +188,12 @@ impl NixLogBridge { activity_info.activity.phase(&phase); } } - InternalLog::Msg { level, ref msg, .. } => { + InternalLog::Msg { + level, + ref msg, + ref parent, + .. + } => { // Extract any input operation from the log for caching if let Some(op) = EvalOp::from_internal_log(&log) { // Notify all active observers @@ -213,7 +218,7 @@ impl NixLogBridge { // or is_builtin_trace() checks. if log.is_nix_error() || log.is_builtin_trace() { let (summary, details) = parse_nix_error(msg); - message_with_details(ActivityLevel::Error, summary, details); + message_with_parent(ActivityLevel::Error, summary, details, *parent); error!("{msg}"); } else { let activity_level = match level { @@ -227,7 +232,7 @@ impl NixLogBridge { Verbosity::Debug => ActivityLevel::Debug, Verbosity::Vomit => ActivityLevel::Trace, }; - message(activity_level, msg); + message_with_parent(activity_level, msg, None, *parent); } } } diff --git a/devenv-nix-backend/src/logger.rs b/devenv-nix-backend/src/logger.rs index 39da59cc5f..82e9862682 100644 --- a/devenv-nix-backend/src/logger.rs +++ b/devenv-nix-backend/src/logger.rs @@ -172,6 +172,7 @@ fn create_log_callback( msg: msg.to_string(), raw_msg: None, level: verbosity, + parent: None, }; bridge.process_internal_log(log); } From 859d858decff77da7f37766a82d3dc60090418d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 27 Jan 2026 13:57:48 +0000 Subject: [PATCH 2/3] feat(tui): improve error handling in activity model - Track activities that received error messages and override their success status on completion, so failed builds are visually marked. - Extract derivation paths from Nix error messages to associate errors with the specific build activity. - Filter Error/Warn messages from the activity tree (they still appear in the message log and final error summary). - Keep failed activities always visible in get_children(), regardless of linger duration limits. - Add ViewMode::ErrorPaused variant for the upcoming --tui-open-on-error flag. - Add has_errors() method to check if any errors occurred during session. - Update test snapshots for filtered error/warning message activities. Co-Authored-By: Claude Opus 4.5 --- devenv-tui/src/model.rs | 107 ++++++++++++++++-- ...tui_tests__error_message_with_details.snap | 8 +- .../tui_tests__error_message_with_parent.snap | 3 +- ..._tests__error_message_without_details.snap | 3 +- ...ui_tests__warning_message_with_parent.snap | 3 +- 5 files changed, 101 insertions(+), 23 deletions(-) diff --git a/devenv-tui/src/model.rs b/devenv-tui/src/model.rs index 5bd0f1c60b..e2f8308028 100644 --- a/devenv-tui/src/model.rs +++ b/devenv-tui/src/model.rs @@ -3,7 +3,7 @@ use devenv_activity::{ ActivityEvent, ActivityLevel, ActivityOutcome, Build, Command, EvalOp, Evaluate, ExpectedCategory, Fetch, FetchKind, Message, Operation, SetExpected, Task, }; -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -42,6 +42,8 @@ pub struct ActivityModel { expected_builds: Option, /// Expected download count announced by Nix (via SetExpected events) expected_downloads: Option, + /// Activity IDs that have received error messages (used to override success on completion) + activities_with_errors: HashSet, } impl Default for ActivityModel { @@ -281,6 +283,8 @@ pub enum ViewMode { /// Expanded log view for a specific activity (fullscreen, uses alternate screen) /// Note: scroll_offset is managed as component-local state for immediate responsiveness ExpandedLogs { activity_id: u64 }, + /// TUI stays open after errors for interactive review (--tui-open-on-error) + ErrorPaused, } /// Controls rendering behavior independent of view content. @@ -314,6 +318,7 @@ impl ActivityModel { config, expected_builds: None, expected_downloads: None, + activities_with_errors: HashSet::new(), } } @@ -678,10 +683,14 @@ impl ActivityModel { // First, get the activity info we need let (variant, success, cached, duration) = { if let Some(activity) = self.activities.get(&id) { - let success = matches!( + let mut success = matches!( outcome, ActivityOutcome::Success | ActivityOutcome::Cached | ActivityOutcome::Skipped ); + // Override success if this activity received error messages + if success && self.activities_with_errors.contains(&id) { + success = false; + } let cached = matches!(outcome, ActivityOutcome::Cached); let duration = activity.start_time.elapsed(); (activity.variant.clone(), success, cached, duration) @@ -857,8 +866,28 @@ impl ActivityModel { fn handle_message(&mut self, msg: Message) { self.add_log_message(msg.clone()); - // Only create activity for messages with a parent - if msg.parent.is_some() { + // Track activities that received error messages + if msg.level == ActivityLevel::Error { + if let Some(parent_id) = msg.parent { + self.activities_with_errors.insert(parent_id); + } + + // Also try to find a matching build activity by derivation path + // Nix error messages like "cannot build '/nix/store/...drv'" reference the derivation + if let Some(drv_path) = extract_derivation_path_from_error(&msg.text) + && let Some(build_id) = self.find_build_by_derivation(&drv_path) + { + self.activities_with_errors.insert(build_id); + } + } + + // Only create activity for messages with a parent. + // Skip Error/Warn messages from the activity tree - they are already in message_log + // and will be shown in the final error summary. + if msg.parent.is_some() + && msg.level != ActivityLevel::Error + && msg.level != ActivityLevel::Warn + { let id = msg.id; let level = msg.level; let has_details = msg.details.is_some(); @@ -1112,6 +1141,32 @@ impl ActivityModel { .collect() } + /// Check if any errors occurred during the session. + pub fn has_errors(&self) -> bool { + !self.activities_with_errors.is_empty() + || self + .message_log + .iter() + .any(|m| m.level == ActivityLevel::Error) + || self + .activities + .values() + .any(|a| matches!(a.state, NixActivityState::Completed { success: false, .. })) + } + + /// Find a build activity by its derivation path. + fn find_build_by_derivation(&self, drv_path: &str) -> Option { + self.activities.iter().find_map(|(&id, activity)| { + if matches!(activity.variant, ActivityVariant::Build(_)) + && activity.detail.as_deref() == Some(drv_path) + { + Some(id) + } else { + None + } + }) + } + pub fn get_total_duration(&self) -> Option { let earliest_start = self.activities.values().map(|a| a.start_time).min()?; Some(Instant::now().duration_since(earliest_start)) @@ -1169,10 +1224,18 @@ impl ActivityModel { return (all_children, total_count, 0); } - // Partition into active (including queued) and completed - let (active, completed): (Vec<_>, Vec<_>) = all_children - .into_iter() - .partition(|a| matches!(a.state, NixActivityState::Queued | NixActivityState::Active)); + // Partition into active (including queued), failed completed, and successful completed + let mut active: Vec<&Activity> = Vec::new(); + let mut failed: Vec<&Activity> = Vec::new(); + let mut completed: Vec<&Activity> = Vec::new(); + + for a in all_children { + match &a.state { + NixActivityState::Queued | NixActivityState::Active => active.push(a), + NixActivityState::Completed { success: false, .. } => failed.push(a), + NixActivityState::Completed { .. } => completed.push(a), + } + } // Sort completed by completion time (most recent first) let mut completed_with_time: Vec<_> = completed @@ -1192,10 +1255,15 @@ impl ActivityModel { now.duration_since(*completed_at) < limit.linger_duration }); - // Build result: prioritize active, then lingering, then older + // Build result: failed items always visible, then active, then lingering, then older let mut result: Vec<&Activity> = Vec::new(); - // Add all active items first (they always show) + // Failed items always stay visible regardless of limits + for a in &failed { + result.push(a); + } + + // Add all active items (they always show) for a in &active { if result.len() >= limit.max_lines { break; @@ -1373,6 +1441,25 @@ pub struct ActivitySummary { pub failed_tasks: usize, } +/// Extract a derivation path from a Nix error message. +/// +/// Nix error messages often reference derivations, e.g.: +/// - "error: cannot build derivation '/nix/store/...-foo.drv': ..." +/// - "error: builder for '/nix/store/...-foo.drv' failed ..." +fn extract_derivation_path_from_error(text: &str) -> Option { + // Look for '/nix/store/...drv' in the text + let start = text.find("'/nix/store/")?; + let path_start = start + 1; // skip the opening quote + let rest = &text[path_start..]; + let end = rest.find('\'')?; + let path = &rest[..end]; + if path.ends_with(".drv") { + Some(path.to_string()) + } else { + None + } +} + /// Format an EvalOp as a display string for logging. fn format_eval_op(op: &EvalOp) -> String { match op { diff --git a/devenv-tui/tests/snapshots/tui_tests__error_message_with_details.snap b/devenv-tui/tests/snapshots/tui_tests__error_message_with_details.snap index e779ec407c..8e3dea573d 100644 --- a/devenv-tui/tests/snapshots/tui_tests__error_message_with_details.snap +++ b/devenv-tui/tests/snapshots/tui_tests__error_message_with_details.snap @@ -3,11 +3,5 @@ source: devenv-tui/tests/tui_tests.rs expression: output --- ⠋ Building shell - error: - … while evaluating - at devenv.nix:10:5 - error: undefined variable 'pkgs' - ✗ error: undefined variable 'pkgs' - - ↑↓ nav + ↑↓ nav diff --git a/devenv-tui/tests/snapshots/tui_tests__error_message_with_parent.snap b/devenv-tui/tests/snapshots/tui_tests__error_message_with_parent.snap index fe22ebe157..8e3dea573d 100644 --- a/devenv-tui/tests/snapshots/tui_tests__error_message_with_parent.snap +++ b/devenv-tui/tests/snapshots/tui_tests__error_message_with_parent.snap @@ -3,6 +3,5 @@ source: devenv-tui/tests/tui_tests.rs expression: output --- ⠋ Building shell - ✗ error: undefined variable 'pkgs' - ↑↓ nav + ↑↓ nav diff --git a/devenv-tui/tests/snapshots/tui_tests__error_message_without_details.snap b/devenv-tui/tests/snapshots/tui_tests__error_message_without_details.snap index 1504b63a0d..8e3dea573d 100644 --- a/devenv-tui/tests/snapshots/tui_tests__error_message_without_details.snap +++ b/devenv-tui/tests/snapshots/tui_tests__error_message_without_details.snap @@ -3,6 +3,5 @@ source: devenv-tui/tests/tui_tests.rs expression: output --- ⠋ Building shell - ✗ error: simple error - ↑↓ nav + ↑↓ nav diff --git a/devenv-tui/tests/snapshots/tui_tests__warning_message_with_parent.snap b/devenv-tui/tests/snapshots/tui_tests__warning_message_with_parent.snap index 4396255f71..8e3dea573d 100644 --- a/devenv-tui/tests/snapshots/tui_tests__warning_message_with_parent.snap +++ b/devenv-tui/tests/snapshots/tui_tests__warning_message_with_parent.snap @@ -3,6 +3,5 @@ source: devenv-tui/tests/tui_tests.rs expression: output --- ⠋ Building shell - • warning: deprecated option 'services.foo' used - ↑↓ nav + ↑↓ nav From f3fd25664bbff4b59ae60c1260a9fc2ebfbd2360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 27 Jan 2026 13:58:14 +0000 Subject: [PATCH 3/3] feat(tui): add --tui-open-on-error flag for interactive error review Add opt-in --tui-open-on-error flag (also DEVENV_TUI_OPEN_ON_ERROR env) that keeps the TUI open when errors occur, allowing users to navigate and review errors interactively. Users can press q/Enter/Esc to exit. When errors occur without the flag (the default), the TUI exits normally and prints a tip suggesting the flag for future use. Co-Authored-By: Claude Opus 4.5 --- devenv-core/src/cli.rs | 9 ++++ devenv-tui/src/app.rs | 98 +++++++++++++++++++++++++++++++++++++++++- devenv-tui/src/view.rs | 24 ++++++++++- devenv/src/main.rs | 4 ++ 4 files changed, 133 insertions(+), 2 deletions(-) diff --git a/devenv-core/src/cli.rs b/devenv-core/src/cli.rs index f03e369386..78f5ff7c1e 100644 --- a/devenv-core/src/cli.rs +++ b/devenv-core/src/cli.rs @@ -115,6 +115,14 @@ pub struct GlobalOptions { )] pub no_tui: bool, + #[arg( + long, + global = true, + env = "DEVENV_TUI_OPEN_ON_ERROR", + help = "Keep the TUI open on error for interactive review." + )] + pub tui_open_on_error: bool, + #[arg( long, global = true, @@ -284,6 +292,7 @@ impl Default for GlobalOptions { quiet: false, tui: true, no_tui: false, + tui_open_on_error: false, log_format: None, trace_format: TraceFormat::default(), trace_output: None, diff --git a/devenv-tui/src/app.rs b/devenv-tui/src/app.rs index cc8610e560..40c89ed7d9 100644 --- a/devenv-tui/src/app.rs +++ b/devenv-tui/src/app.rs @@ -34,6 +34,8 @@ pub struct TuiConfig { pub max_fps: u64, /// Minimum activity level to display (activities below this level are filtered out) pub filter_level: ActivityLevel, + /// Whether to keep the TUI open when errors occur (opt-in) + pub open_on_error: bool, } impl Default for TuiConfig { @@ -45,6 +47,7 @@ impl Default for TuiConfig { log_viewport_collapsed: 10, max_fps: 30, filter_level: ActivityLevel::Info, + open_on_error: false, } } } @@ -100,6 +103,12 @@ impl TuiApp { self } + /// Set whether to keep the TUI open on error for interactive review. + pub fn open_on_error(mut self, open: bool) -> Self { + self.config.open_on_error = open; + self + } + /// Run the TUI application until the backend completes. /// /// The `backend_done` receiver signals when the backend has fully completed @@ -207,6 +216,37 @@ impl TuiApp { } } + // If open_on_error is set and there are errors, enter ErrorPaused mode + // This keeps the TUI open for interactive review until the user exits + let error_paused = if config.open_on_error && shutdown.last_signal().is_none() { + let has_errors = activity_model + .read() + .map(|m| m.has_errors()) + .unwrap_or(false); + if has_errors { + if let Ok(mut ui) = ui_state.write() { + ui.view_mode = ViewMode::ErrorPaused; + } + // Create a new shutdown for the error-paused loop + let pause_shutdown = Arc::new(Shutdown::new()); + // Run the TUI in ErrorPaused mode until user presses q/Enter/Esc + let _ = run_view( + activity_model.clone(), + ui_state.clone(), + notify.clone(), + pause_shutdown, + config.clone(), + &mut pre_expand_height, + ) + .await; + true + } else { + false + } + } else { + false + }; + // Final render pass to ensure all drained events are displayed // This replaces the old is_done() check that triggered shutdown AFTER rendering // @@ -300,6 +340,17 @@ impl TuiApp { } let _ = execute!(stderr, ResetColor); } + + // Print tip about --tui-open-on-error if not already using it + // and we didn't just come from error-paused mode + if !config.open_on_error && !error_paused { + eprintln!(); + let _ = execute!(stderr, SetForegroundColor(Color::AnsiValue(245))); // Dim gray + eprintln!( + "Tip: use --tui-open-on-error to review errors interactively" + ); + let _ = execute!(stderr, ResetColor); + } } } } @@ -370,12 +421,23 @@ fn MainView(mut hooks: Hooks) -> impl Into> { && key_event.kind != KeyEventKind::Release { debug!("Key event: {:?}", key_event); + + // Check if we're in ErrorPaused mode + let is_error_paused = ui_state + .read() + .map(|ui| matches!(ui.view_mode, ViewMode::ErrorPaused)) + .unwrap_or(false); + match key_event.code { KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => { // Set signal so Nix backend knows to interrupt operations shutdown.set_last_signal(Signal::SIGINT); shutdown.shutdown(); } + // In ErrorPaused mode: q, Enter, Esc all exit + KeyCode::Char('q') | KeyCode::Enter if is_error_paused => { + should_exit.set(true); + } KeyCode::Char('e') if key_event.modifiers.contains(KeyModifiers::CONTROL) => { if let Ok(mut ui) = ui_state.write() && let Some(activity_id) = ui.selected_activity @@ -403,7 +465,9 @@ fn MainView(mut hooks: Hooks) -> impl Into> { } } KeyCode::Esc => { - if let Ok(mut ui) = ui_state.write() { + if is_error_paused { + should_exit.set(true); + } else if let Ok(mut ui) = ui_state.write() { ui.selected_activity = None; } } @@ -481,6 +545,38 @@ async fn run_view( .ignore_ctrl_c() .await } + ViewMode::ErrorPaused => { + // Same as Main view but stays open for error review + if *pre_expand_height > 0 { + let mut stderr = io::stderr(); + let _ = execute!( + stderr, + cursor::MoveToPreviousLine(*pre_expand_height), + terminal::Clear(terminal::ClearType::FromCursorDown) + ); + *pre_expand_height = 0; + } + + let mut element = element! { + ContextProvider(value: Context::owned(config.clone())) { + ContextProvider(value: Context::owned(shutdown.clone())) { + ContextProvider(value: Context::owned(notify.clone())) { + ContextProvider(value: Context::owned(activity_model.clone())) { + ContextProvider(value: Context::owned(ui_state.clone())) { + MainView + } + } + } + } + } + }; + + element + .render_loop() + .output(Output::Stderr) + .ignore_ctrl_c() + .await + } ViewMode::ExpandedLogs { activity_id } => { // Calculate height before switching to expanded view // Use a block to ensure guards are dropped before await diff --git a/devenv-tui/src/view.rs b/devenv-tui/src/view.rs index 7fca67c746..ce50dbadf7 100644 --- a/devenv-tui/src/view.rs +++ b/devenv-tui/src/view.rs @@ -109,6 +109,8 @@ pub fn view( // Show summary (nav bar) only in normal render context let show_summary = render_context == RenderContext::Normal; + let is_paused = matches!(ui_state.view_mode, crate::model::ViewMode::ErrorPaused); + let summary_view = element! { ContextProvider(value: Context::owned(SummaryViewContext { summary: summary.clone(), @@ -116,6 +118,7 @@ pub fn view( showing_logs: selected_logs.is_some(), can_go_up, can_go_down, + is_paused, })) { SummaryView } @@ -671,6 +674,7 @@ struct SummaryViewContext { showing_logs: bool, can_go_up: bool, can_go_down: bool, + is_paused: bool, } /// Summary view component that adapts to terminal width @@ -684,6 +688,7 @@ fn SummaryView(hooks: Hooks) -> impl Into> { showing_logs, can_go_up, can_go_down, + is_paused, } = &*ctx; build_summary_view_impl( @@ -692,6 +697,7 @@ fn SummaryView(hooks: Hooks) -> impl Into> { *showing_logs, *can_go_up, *can_go_down, + *is_paused, terminal_width, ) } @@ -703,6 +709,7 @@ fn build_summary_view_impl( showing_logs: bool, can_go_up: bool, can_go_down: bool, + is_paused: bool, terminal_width: u16, ) -> AnyElement<'static> { let mut children = vec![]; @@ -882,7 +889,22 @@ fn build_summary_view_impl( COLOR_HIERARCHY }; - if has_selection { + if is_paused { + // Error paused mode: show exit instructions + help_children.push(element!(Text(content: "↑", color: up_arrow_color)).into_any()); + help_children.push(element!(Text(content: "↓", color: down_arrow_color)).into_any()); + if !use_symbols { + help_children.push(element!(Text(content: " navigate • ")).into_any()); + } else { + help_children.push(element!(Text(content: " • ")).into_any()); + } + help_children.push(element!(Text(content: "q", color: COLOR_INTERACTIVE)).into_any()); + help_children.push(element!(Text(content: "/")).into_any()); + help_children.push(element!(Text(content: "Enter", color: COLOR_INTERACTIVE)).into_any()); + help_children.push(element!(Text(content: "/")).into_any()); + help_children.push(element!(Text(content: "Esc", color: COLOR_INTERACTIVE)).into_any()); + help_children.push(element!(Text(content: " exit")).into_any()); + } else if has_selection { // Show full navigation when something is selected help_children.push(element!(Text(content: "↑", color: up_arrow_color)).into_any()); help_children.push(element!(Text(content: "↓", color: down_arrow_color)).into_any()); diff --git a/devenv/src/main.rs b/devenv/src/main.rs index 95f8983128..e76abce975 100644 --- a/devenv/src/main.rs +++ b/devenv/src/main.rs @@ -167,6 +167,9 @@ async fn run_with_tui(cli: Cli) -> Result<()> { // Channel to signal TUI when backend is fully done (including cleanup) let (backend_done_tx, backend_done_rx) = tokio::sync::oneshot::channel(); + // Extract TUI config before moving cli into devenv thread + let tui_open_on_error = cli.global_options.tui_open_on_error; + // Devenv on background thread (own runtime with GC-registered workers) let shutdown_clone = shutdown.clone(); let devenv_thread = std::thread::spawn(move || { @@ -190,6 +193,7 @@ async fn run_with_tui(cli: Cli) -> Result<()> { // Runs until backend signals completion, then drains remaining events let _ = devenv_tui::TuiApp::new(activity_rx, shutdown) .filter_level(filter_level) + .open_on_error(tui_open_on_error) .run(backend_done_rx) .await;