diff --git a/nori-rs/acp/docs.md b/nori-rs/acp/docs.md index b48cd5f48..04faad9a5 100644 --- a/nori-rs/acp/docs.md +++ b/nori-rs/acp/docs.md @@ -163,6 +163,10 @@ Three config enums control notification behavior, all stored in the `[tui]` sect The `AcpBackendConfig` struct carries both `os_notifications` and `notify_after_idle` so the backend can configure the `UserNotifier` and the idle timer respectively. Terminal notifications flow separately through `codex-core`'s `Config::tui_notifications` bool to the TUI's `ChatWidget::notify()` method. +**TUI Display Configuration** (`config/types/mod.rs`): + +The `[tui]` section also owns display-only preferences consumed by `@/nori-rs/tui/`. `custom_working_messages` defaults to `true`; setting it to `false` disables the rotating whimsical status header list and lets the TUI use a plain "Working" label while a task starts. This value is resolved onto `NoriConfig` in `loader.rs`, mirrored through `codex-core`'s config, and can be changed from the `/config` menu. + **Hotkey Configuration** (`config/types/mod.rs`): diff --git a/nori-rs/acp/src/config/loader.rs b/nori-rs/acp/src/config/loader.rs index a6f07ae9e..075f681d6 100644 --- a/nori-rs/acp/src/config/loader.rs +++ b/nori-rs/acp/src/config/loader.rs @@ -168,6 +168,7 @@ impl NoriConfig { skillset_per_session, file_manager: toml.tui.file_manager, pinned_plan_drawer: toml.tui.pinned_plan_drawer.unwrap_or(false), + custom_working_messages: toml.tui.custom_working_messages.unwrap_or(true), auto_worktree, footer_segment_config: super::types::FooterSegmentConfig::from_toml( &toml.tui.footer_segments, diff --git a/nori-rs/acp/src/config/mod.rs b/nori-rs/acp/src/config/mod.rs index 5173601db..5e95340e5 100644 --- a/nori-rs/acp/src/config/mod.rs +++ b/nori-rs/acp/src/config/mod.rs @@ -125,6 +125,7 @@ animations = false terminal_notifications = "disabled" os_notifications = "disabled" vertical_footer = true +custom_working_messages = false "#; let config: NoriConfigToml = toml::from_str(toml_str).unwrap(); @@ -141,6 +142,7 @@ vertical_footer = true ); assert_eq!(config.tui.os_notifications, Some(OsNotifications::Disabled)); assert_eq!(config.tui.vertical_footer, Some(true)); + assert_eq!(config.tui.custom_working_messages, Some(false)); } #[test] @@ -156,6 +158,7 @@ model = "gemini" [tui] animations = false vertical_footer = true +custom_working_messages = false "#, ) .unwrap(); @@ -170,6 +173,7 @@ vertical_footer = true ); // default assert_eq!(config.os_notifications, OsNotifications::Enabled); // default assert!(config.vertical_footer); + assert!(!config.custom_working_messages); } #[test] diff --git a/nori-rs/acp/src/config/types/mod.rs b/nori-rs/acp/src/config/types/mod.rs index 63ad75028..a0543d817 100644 --- a/nori-rs/acp/src/config/types/mod.rs +++ b/nori-rs/acp/src/config/types/mod.rs @@ -1385,6 +1385,9 @@ pub struct TuiConfigToml { /// Pin plan updates to a drawer in the viewport instead of history cells. pub pinned_plan_drawer: Option, + + /// Show rotating custom messages while the agent is working. + pub custom_working_messages: Option, } /// Resolved TUI configuration @@ -1561,6 +1564,9 @@ pub struct NoriConfig { /// Pin plan updates to a drawer in the viewport instead of history cells. pub pinned_plan_drawer: bool, + /// Show rotating custom messages while the agent is working. + pub custom_working_messages: bool, + /// Footer segment visibility configuration. pub footer_segment_config: FooterSegmentConfig, @@ -1653,6 +1659,7 @@ impl Default for NoriConfig { skillset_per_session: false, file_manager: None, pinned_plan_drawer: false, + custom_working_messages: true, footer_segment_config: FooterSegmentConfig::default(), nori_home: PathBuf::from(".nori/cli"), cwd: std::env::current_dir().unwrap_or_default(), diff --git a/nori-rs/core/docs.md b/nori-rs/core/docs.md index d5aa4bb9b..dce0b9356 100644 --- a/nori-rs/core/docs.md +++ b/nori-rs/core/docs.md @@ -115,7 +115,7 @@ Notification modes: 1. **Native notifications** (`use_native: true`): Uses `notify-rust` for desktop notifications. All calls to `send_native()` are non-blocking -- they spawn a background thread to call `notif.show()`, because some platforms (notably macOS) block synchronously on that call. On X11 Linux, the spawned thread also handles click-to-focus via `wmctrl` or `xdotool`. The `use_native` flag is controlled by `OsNotifications` in the ACP config layer (`@/nori-rs/acp/src/config/types.rs`). 2. **External script** (`notify_command` configured): Invokes user-specified command with JSON payload. -Core's `Config::tui_notifications` is a simple `bool` that controls whether the TUI sends OSC 9 terminal escape sequence notifications. It derives its value from the ACP config's `TerminalNotifications` enum during config loading. +Core's `Config::tui_notifications` is a simple `bool` that controls whether the TUI sends OSC 9 terminal escape sequence notifications. It derives its value from the ACP config's `TerminalNotifications` enum during config loading. Core also carries TUI display booleans such as `animations` and `custom_working_messages`; the latter mirrors `[tui].custom_working_messages` from Nori config so the TUI can choose between rotating custom working headers and the plain `Working` label without re-reading config. ### Things to Know diff --git a/nori-rs/core/src/config/mod.rs b/nori-rs/core/src/config/mod.rs index d36233977..d0f5b50a3 100644 --- a/nori-rs/core/src/config/mod.rs +++ b/nori-rs/core/src/config/mod.rs @@ -154,6 +154,9 @@ pub struct Config { /// Enable ASCII animations and shimmer effects in the TUI. pub animations: bool, + /// Show rotating custom messages while the agent is working. + pub custom_working_messages: bool, + /// The directory that should be treated as the current working directory /// for the session. All relative paths inside the business-logic layer are /// resolved against this path. @@ -1261,6 +1264,11 @@ impl Config { .map(|t| t.terminal_notifications) .unwrap_or(true), animations: cfg.tui.as_ref().map(|t| t.animations).unwrap_or(true), + custom_working_messages: cfg + .tui + .as_ref() + .map(|t| t.custom_working_messages) + .unwrap_or(true), otel: { let t: OtelConfigToml = cfg.otel.unwrap_or_default(); let log_user_prompt = t.log_user_prompt.unwrap_or(false); diff --git a/nori-rs/core/src/config/tests/part1.rs b/nori-rs/core/src/config/tests/part1.rs index 4564a59b4..100cb253d 100644 --- a/nori-rs/core/src/config/tests/part1.rs +++ b/nori-rs/core/src/config/tests/part1.rs @@ -46,6 +46,33 @@ fn tui_config_missing_terminal_notifications_field_defaults_to_true() { assert!(tui.terminal_notifications); } +#[test] +fn tui_config_custom_working_messages_defaults_to_true() { + let cfg = r#" +[tui] +"#; + + let parsed = toml::from_str::(cfg) + .expect("TUI config without custom_working_messages should succeed"); + let tui = parsed.tui.expect("config should include tui section"); + + assert!(tui.custom_working_messages); +} + +#[test] +fn tui_config_custom_working_messages_can_be_disabled() { + let cfg = r#" +[tui] +custom_working_messages = false +"#; + + let parsed = toml::from_str::(cfg) + .expect("TUI config with custom_working_messages disabled should succeed"); + let tui = parsed.tui.expect("config should include tui section"); + + assert!(!tui.custom_working_messages); +} + #[test] fn test_sandbox_config_parsing() { let sandbox_full_access = r#" diff --git a/nori-rs/core/src/config/tests/part3.rs b/nori-rs/core/src/config/tests/part3.rs index 12c11e967..ba75e5fc9 100644 --- a/nori-rs/core/src/config/tests/part3.rs +++ b/nori-rs/core/src/config/tests/part3.rs @@ -547,6 +547,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { disable_paste_burst: false, tui_notifications: true, animations: true, + custom_working_messages: true, otel: OtelConfig::default(), acp_allow_http_fallback: false, }, @@ -620,6 +621,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { disable_paste_burst: false, tui_notifications: true, animations: true, + custom_working_messages: true, otel: OtelConfig::default(), acp_allow_http_fallback: false, }; diff --git a/nori-rs/core/src/config/tests/part4.rs b/nori-rs/core/src/config/tests/part4.rs index de8cdd157..adb0cccab 100644 --- a/nori-rs/core/src/config/tests/part4.rs +++ b/nori-rs/core/src/config/tests/part4.rs @@ -66,6 +66,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { disable_paste_burst: false, tui_notifications: true, animations: true, + custom_working_messages: true, otel: OtelConfig::default(), acp_allow_http_fallback: false, }; @@ -140,6 +141,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { disable_paste_burst: false, tui_notifications: true, animations: true, + custom_working_messages: true, otel: OtelConfig::default(), acp_allow_http_fallback: false, }; diff --git a/nori-rs/core/src/config/types.rs b/nori-rs/core/src/config/types.rs index a852f8034..7fd51cd84 100644 --- a/nori-rs/core/src/config/types.rs +++ b/nori-rs/core/src/config/types.rs @@ -370,6 +370,11 @@ pub struct Tui { /// Defaults to `true`. #[serde(default = "default_true")] pub animations: bool, + + /// Show rotating custom messages while the agent is working. + /// Defaults to `true`. + #[serde(default = "default_true")] + pub custom_working_messages: bool, } const fn default_true() -> bool { diff --git a/nori-rs/tui/docs.md b/nori-rs/tui/docs.md index c411e1010..aa94a6abf 100644 --- a/nori-rs/tui/docs.md +++ b/nori-rs/tui/docs.md @@ -262,7 +262,7 @@ During background system info collection on unix, `check_worktree_cleanup()` run | `/agent` | Switch between available ACP agents (dynamically shows current agent name) | | `/model` | Choose model (dynamically shows current agent/model name) | | `/approvals` | Choose what Nori can do without approval (dynamically shows current approval mode) | -| `/config` | Toggle TUI settings (pinned plan drawer, vertical footer, terminal notifications, OS notifications, vim mode with enter behavior sub-picker, auto worktree, per session skillsets, notify after idle, hotkeys, script timeout, loop count, footer segments, file manager) | +| `/config` | Toggle TUI settings (pinned plan drawer, custom working messages, vertical footer, terminal notifications, OS notifications, vim mode with enter behavior sub-picker, auto worktree, per session skillsets, notify after idle, hotkeys, script timeout, loop count, footer segments, file manager) | | `/browse` | Open a terminal file manager to browse and edit files | | `/new` | Start a new chat during a conversation | | `/resume` | Resume a previous ACP session | @@ -811,7 +811,7 @@ When the user selects an agent (or resumes a session), the TUI shows a "Connecti **Status Indicator Whimsical Messages (`status_indicator_widget.rs`):** -When the agent begins processing a task, the `StatusIndicatorWidget` displays an animated header with a randomly selected tongue-in-cheek message (e.g., "Thinking really hard", "Hallucinating responsibly") drawn from the `WHIMSICAL_STATUS_MESSAGES` pool via `random_status_message()`. A new random message is selected each time `on_task_started()` fires in `chatwidget/event_handlers.rs`. During streaming, reasoning chunk headers (extracted from bold markdown text) dynamically replace this initial message via `update_status_header()`. +When the agent begins processing a task, the `StatusIndicatorWidget` displays an animated header. By default it chooses a randomly selected tongue-in-cheek message (e.g., "Thinking really hard", "Hallucinating responsibly") drawn from the `WHIMSICAL_STATUS_MESSAGES` pool via `initial_status_message(true)`. Users can opt out with `[tui].custom_working_messages = false` or the `/config` toggle, which makes the initial header the plain `Working` label instead. During streaming, reasoning chunk headers (extracted from bold markdown text) dynamically replace this initial message via `update_status_header()`. **Terminal Title Management (`terminal_title.rs`, `chatwidget/helpers.rs`):** diff --git a/nori-rs/tui/src/app/config_persistence.rs b/nori-rs/tui/src/app/config_persistence.rs index 46ef3ba51..85becdea7 100644 --- a/nori-rs/tui/src/app/config_persistence.rs +++ b/nori-rs/tui/src/app/config_persistence.rs @@ -191,6 +191,27 @@ impl App { .add_info_message(format!("Pinned plan drawer {status}."), None); } + #[cfg(feature = "nori-config")] + pub(super) async fn persist_custom_working_messages_setting(&mut self, enabled: bool) { + self.config.custom_working_messages = enabled; + self.chat_widget.set_custom_working_messages(enabled); + + if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) + .set_path(&["tui", "custom_working_messages"], toml_value(enabled)) + .apply() + .await + { + tracing::error!(error = %err, "failed to persist custom_working_messages setting"); + self.chat_widget.add_error_message(format!( + "Failed to save custom_working_messages setting: {err}" + )); + return; + } + let status = if enabled { "enabled" } else { "disabled" }; + self.chat_widget + .add_info_message(format!("Custom working messages {status}."), None); + } + #[cfg(feature = "nori-config")] pub(super) async fn persist_skillset_per_session_setting(&mut self, enabled: bool) { let builder = ConfigEditsBuilder::new(&self.config.codex_home) diff --git a/nori-rs/tui/src/app/event_handling.rs b/nori-rs/tui/src/app/event_handling.rs index 936e5d07b..e26b8a8b6 100644 --- a/nori-rs/tui/src/app/event_handling.rs +++ b/nori-rs/tui/src/app/event_handling.rs @@ -797,6 +797,10 @@ impl App { self.persist_pinned_plan_drawer_setting(enabled).await; } #[cfg(feature = "nori-config")] + AppEvent::SetConfigCustomWorkingMessages(enabled) => { + self.persist_custom_working_messages_setting(enabled).await; + } + #[cfg(feature = "nori-config")] AppEvent::OpenSkillsetPerSessionWorktreeChoice => { self.chat_widget.open_skillset_worktree_choice_picker(); } diff --git a/nori-rs/tui/src/app_event.rs b/nori-rs/tui/src/app_event.rs index a006c6ce5..fc6d28f59 100644 --- a/nori-rs/tui/src/app_event.rs +++ b/nori-rs/tui/src/app_event.rs @@ -320,6 +320,10 @@ pub(crate) enum AppEvent { #[cfg(feature = "nori-config")] SetConfigPinnedPlanDrawer(bool), + /// Set the TUI custom working messages config setting. + #[cfg(feature = "nori-config")] + SetConfigCustomWorkingMessages(bool), + /// Open the worktree choice modal when enabling per-session skillsets. #[cfg(feature = "nori-config")] OpenSkillsetPerSessionWorktreeChoice, diff --git a/nori-rs/tui/src/bottom_pane/mod.rs b/nori-rs/tui/src/bottom_pane/mod.rs index 575361769..e64511eb3 100644 --- a/nori-rs/tui/src/bottom_pane/mod.rs +++ b/nori-rs/tui/src/bottom_pane/mod.rs @@ -68,6 +68,7 @@ pub(crate) struct BottomPane { ctrl_c_quit_hint: bool, esc_backtrack_hint: bool, animations_enabled: bool, + custom_working_messages: bool, /// Inline status indicator shown above the composer while a task is running. status: Option, @@ -90,6 +91,7 @@ pub(crate) struct BottomPaneParams { pub(crate) placeholder_text: String, pub(crate) disable_paste_burst: bool, pub(crate) animations_enabled: bool, + pub(crate) custom_working_messages: bool, pub(crate) vertical_footer: bool, pub(crate) footer_segment_config: nori_acp::config::FooterSegmentConfig, pub(crate) agent_display_name: String, @@ -106,6 +108,7 @@ impl BottomPane { placeholder_text, disable_paste_burst, animations_enabled, + custom_working_messages, vertical_footer, footer_segment_config, agent_display_name, @@ -146,6 +149,7 @@ impl BottomPane { queued_user_messages: QueuedUserMessages::new(), esc_backtrack_hint: false, animations_enabled, + custom_working_messages, context_window_percent: None, agent_display_name, agent_slug, @@ -350,6 +354,7 @@ impl BottomPane { self.app_event_tx.clone(), self.frame_requester.clone(), self.animations_enabled, + self.custom_working_messages, )); } if let Some(status) = self.status.as_mut() { @@ -376,6 +381,7 @@ impl BottomPane { self.app_event_tx.clone(), self.frame_requester.clone(), self.animations_enabled, + self.custom_working_messages, )); self.request_redraw(); } @@ -422,6 +428,16 @@ impl BottomPane { self.composer.set_vertical_footer(vertical_footer); } + pub(crate) fn set_custom_working_messages(&mut self, enabled: bool) { + self.custom_working_messages = enabled; + if let Some(status) = self.status.as_mut() { + status.update_header(crate::status_indicator_widget::initial_status_message( + enabled, + )); + self.request_redraw(); + } + } + /// Update the hotkey configuration used by the textarea for editing bindings. pub(crate) fn set_hotkey_config(&mut self, config: nori_acp::config::HotkeyConfig) { self.composer.set_hotkey_config(config); @@ -799,6 +815,7 @@ mod tests { placeholder_text: "Ask Nori to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + custom_working_messages: true, vertical_footer: false, footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), agent_display_name: String::new(), @@ -824,6 +841,7 @@ mod tests { placeholder_text: "Ask Nori to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + custom_working_messages: true, vertical_footer: false, footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), agent_display_name: String::new(), @@ -857,6 +875,7 @@ mod tests { placeholder_text: "Ask Nori to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + custom_working_messages: true, vertical_footer: false, footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), agent_display_name: String::new(), @@ -931,6 +950,7 @@ mod tests { placeholder_text: "Ask Nori to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + custom_working_messages: true, vertical_footer: false, footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), agent_display_name: String::new(), @@ -963,6 +983,7 @@ mod tests { placeholder_text: "Ask Nori to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + custom_working_messages: true, vertical_footer: false, footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), agent_display_name: String::new(), @@ -998,6 +1019,7 @@ mod tests { placeholder_text: "Ask Nori to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + custom_working_messages: true, vertical_footer: false, footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), agent_display_name: String::new(), @@ -1029,6 +1051,7 @@ mod tests { placeholder_text: "Ask Nori to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + custom_working_messages: true, vertical_footer: false, footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), agent_display_name: String::new(), @@ -1060,6 +1083,7 @@ mod tests { placeholder_text: "Ask Nori to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + custom_working_messages: true, vertical_footer: false, footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), agent_display_name: String::new(), diff --git a/nori-rs/tui/src/chatwidget/constructors.rs b/nori-rs/tui/src/chatwidget/constructors.rs index d22046e13..fcb2d13fd 100644 --- a/nori-rs/tui/src/chatwidget/constructors.rs +++ b/nori-rs/tui/src/chatwidget/constructors.rs @@ -45,6 +45,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, + custom_working_messages: config.custom_working_messages, vertical_footer, footer_segment_config, agent_display_name: crate::nori::agent_picker::get_agent_info(&config.model) @@ -75,7 +76,9 @@ impl ChatWidget { interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), - current_status_header: crate::status_indicator_widget::random_status_message(), + current_status_header: crate::status_indicator_widget::initial_status_message( + config.custom_working_messages, + ), retry_status_header: None, conversation_id: None, show_welcome_banner: true, @@ -155,6 +158,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, + custom_working_messages: config.custom_working_messages, vertical_footer, footer_segment_config, agent_display_name: crate::nori::agent_picker::get_agent_info(&config.model) @@ -185,7 +189,9 @@ impl ChatWidget { interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), - current_status_header: crate::status_indicator_widget::random_status_message(), + current_status_header: crate::status_indicator_widget::initial_status_message( + config.custom_working_messages, + ), retry_status_header: None, conversation_id: None, show_welcome_banner: false, diff --git a/nori-rs/tui/src/chatwidget/helpers.rs b/nori-rs/tui/src/chatwidget/helpers.rs index f34017068..314ab9e61 100644 --- a/nori-rs/tui/src/chatwidget/helpers.rs +++ b/nori-rs/tui/src/chatwidget/helpers.rs @@ -18,6 +18,11 @@ impl ChatWidget { self.bottom_pane.set_vertical_footer(enabled); } + pub(crate) fn set_custom_working_messages(&mut self, enabled: bool) { + self.config.custom_working_messages = enabled; + self.bottom_pane.set_custom_working_messages(enabled); + } + /// Set the plan drawer mode. The latest plan state is always retained so /// that switching to a visible mode shows the most recent plan immediately. pub(crate) fn set_plan_drawer_mode(&mut self, mode: PlanDrawerMode) { diff --git a/nori-rs/tui/src/chatwidget/tests/mod.rs b/nori-rs/tui/src/chatwidget/tests/mod.rs index 200a3f886..dc22f8812 100644 --- a/nori-rs/tui/src/chatwidget/tests/mod.rs +++ b/nori-rs/tui/src/chatwidget/tests/mod.rs @@ -258,6 +258,7 @@ pub(crate) fn make_chatwidget_manual() -> ( placeholder_text: "Ask Nori to do anything".to_string(), disable_paste_burst: false, animations_enabled: cfg.animations, + custom_working_messages: cfg.custom_working_messages, vertical_footer: false, footer_segment_config: nori_acp::config::FooterSegmentConfig::default(), agent_display_name: String::new(), diff --git a/nori-rs/tui/src/nori/config_picker.rs b/nori-rs/tui/src/nori/config_picker.rs index 7d584f576..a9bd4629b 100644 --- a/nori-rs/tui/src/nori/config_picker.rs +++ b/nori-rs/tui/src/nori/config_picker.rs @@ -35,6 +35,7 @@ pub fn config_picker_params( config.terminal_notifications == TerminalNotifications::Enabled; let os_notifications_enabled = config.os_notifications == OsNotifications::Enabled; let pinned_plan_drawer_enabled = config.pinned_plan_drawer; + let custom_working_messages_enabled = config.custom_working_messages; let items: Vec = vec![ build_toggle_item( @@ -49,6 +50,18 @@ pub fn config_picker_params( } }, ), + build_toggle_item( + "Custom Working Messages", + "Rotate playful status messages while the agent is working", + custom_working_messages_enabled, + { + let tx = app_event_tx.clone(); + let new_value = !custom_working_messages_enabled; + move || { + tx.send(AppEvent::SetConfigCustomWorkingMessages(new_value)); + } + }, + ), build_toggle_item( "Vertical Footer", "Stack footer segments vertically instead of horizontally", @@ -664,6 +677,7 @@ mod tests { skillset_per_session: false, file_manager: None, pinned_plan_drawer: false, + custom_working_messages: true, } } @@ -675,7 +689,7 @@ mod tests { let params = config_picker_params(&config, tx); - assert_eq!(params.items.len(), 13); + assert_eq!(params.items.len(), 14); assert!(params.title.is_some()); assert!(params.title.unwrap().contains("Configuration")); } @@ -688,8 +702,8 @@ mod tests { let params = config_picker_params(&config, tx); - // Vertical Footer is at index 1 (Pinned Plan Drawer is at index 0) - assert!(params.items[1].name.contains("(on)")); + // Vertical Footer follows Pinned Plan Drawer and Custom Working Messages. + assert!(params.items[2].name.contains("(on)")); } #[test] @@ -700,8 +714,8 @@ mod tests { let params = config_picker_params(&config, tx); - // Vertical Footer is at index 1 (Pinned Plan Drawer is at index 0) - assert!(params.items[1].name.contains("(off)")); + // Vertical Footer follows Pinned Plan Drawer and Custom Working Messages. + assert!(params.items[2].name.contains("(off)")); } #[test] @@ -712,23 +726,44 @@ mod tests { let params = config_picker_params(&config, tx); - assert_eq!(params.items.len(), 13); + assert_eq!(params.items.len(), 14); // The 1st item should be Pinned Plan Drawer assert!(params.items[0].name.contains("Pinned Plan Drawer")); - // The 5th item should be Vim Mode - assert!(params.items[4].name.contains("Vim Mode")); - // The 6th item should be Auto Worktree - assert!(params.items[5].name.contains("Auto Worktree")); - // The 7th item should be Per Session Skillsets - assert!(params.items[6].name.contains("Per Session Skillsets")); - // The 8th item should be Notify After Idle - assert!(params.items[7].name.contains("Notify After Idle")); - // The 9th item should be Hotkeys - assert!(params.items[8].name.contains("Hotkeys")); - // The 10th item should be Script Timeout - assert!(params.items[9].name.contains("Script Timeout")); - // The 11th item should be Loop Count - assert!(params.items[10].name.contains("Loop Count")); + assert!(params.items[1].name.contains("Custom Working Messages")); + assert!(params.items[5].name.contains("Vim Mode")); + assert!(params.items[6].name.contains("Auto Worktree")); + assert!(params.items[7].name.contains("Per Session Skillsets")); + assert!(params.items[8].name.contains("Notify After Idle")); + assert!(params.items[9].name.contains("Hotkeys")); + assert!(params.items[10].name.contains("Script Timeout")); + assert!(params.items[11].name.contains("Loop Count")); + } + + #[test] + fn config_picker_custom_working_messages_action_sends_correct_event() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let config = make_test_config(false); + + let params = config_picker_params(&config, tx.clone()); + + let item = params + .items + .iter() + .find(|item| item.name.contains("Custom Working Messages")) + .expect("config picker should include Custom Working Messages"); + assert!(item.name.contains("(on)")); + for action in &item.actions { + action(&tx); + } + + let event = rx.try_recv().expect("should receive event"); + match event { + AppEvent::SetConfigCustomWorkingMessages(value) => { + assert!(!value, "enabled setting should toggle off"); + } + _ => panic!("expected SetConfigCustomWorkingMessages event, got: {event:?}"), + } } #[test] @@ -740,7 +775,7 @@ mod tests { let params = config_picker_params(&config, tx); // Default config has FiveSeconds, so should show "5 seconds" - let idle_item = ¶ms.items[7]; + let idle_item = ¶ms.items[8]; assert!( idle_item.name.contains("5 seconds"), "Expected '5 seconds' in name, got: {}", @@ -756,8 +791,7 @@ mod tests { let params = config_picker_params(&config, tx.clone()); - // Trigger the notify after idle action (8th item, index 7) - let idle_item = ¶ms.items[7]; + let idle_item = ¶ms.items[8]; for action in &idle_item.actions { action(&tx); } @@ -777,8 +811,7 @@ mod tests { let params = config_picker_params(&config, tx.clone()); - // Trigger the vertical footer toggle action (second item) - let vertical_footer_item = ¶ms.items[1]; + let vertical_footer_item = ¶ms.items[2]; assert!(vertical_footer_item.name.contains("Vertical Footer")); for action in &vertical_footer_item.actions { action(&tx); @@ -803,8 +836,7 @@ mod tests { let params = config_picker_params(&config, tx.clone()); - // Trigger the hotkeys action (9th item, index 8) - let hotkeys_item = ¶ms.items[8]; + let hotkeys_item = ¶ms.items[9]; assert!(hotkeys_item.name.contains("Hotkeys")); for action in &hotkeys_item.actions { action(&tx); @@ -885,8 +917,7 @@ mod tests { let params = config_picker_params(&config, tx); - // Should now have 13 items (includes pinned plan drawer, vim mode, auto worktree, per session skillsets, script timeout, and loop count) - assert_eq!(params.items.len(), 13); + assert_eq!(params.items.len(), 14); // Find the vim mode item let vim_mode_item = params .items @@ -954,7 +985,7 @@ mod tests { let params = config_picker_params(&config, tx); // Default config has 30s timeout - let timeout_item = ¶ms.items[9]; + let timeout_item = ¶ms.items[10]; assert!( timeout_item.name.contains("30s"), "Expected '30s' in name, got: {}", @@ -970,8 +1001,7 @@ mod tests { let params = config_picker_params(&config, tx.clone()); - // Trigger the script timeout action (10th item, index 9) - let timeout_item = ¶ms.items[9]; + let timeout_item = ¶ms.items[10]; assert!(timeout_item.name.contains("Script Timeout")); for action in &timeout_item.actions { action(&tx); diff --git a/nori-rs/tui/src/status_indicator_widget.rs b/nori-rs/tui/src/status_indicator_widget.rs index 25c82ac4b..313fd525d 100644 --- a/nori-rs/tui/src/status_indicator_widget.rs +++ b/nori-rs/tui/src/status_indicator_widget.rs @@ -57,11 +57,21 @@ pub(crate) const WHIMSICAL_STATUS_MESSAGES: &[&str] = &[ "Awaiting further instructions from the void", ]; +pub(crate) const DEFAULT_STATUS_MESSAGE: &str = "Working"; + pub(crate) fn random_status_message() -> String { let idx = rand::rng().random_range(0..WHIMSICAL_STATUS_MESSAGES.len()); WHIMSICAL_STATUS_MESSAGES[idx].to_string() } +pub(crate) fn initial_status_message(custom_working_messages: bool) -> String { + if custom_working_messages { + random_status_message() + } else { + DEFAULT_STATUS_MESSAGE.to_string() + } +} + pub(crate) struct StatusIndicatorWidget { /// Animated header text (randomly selected whimsical message by default). header: String, @@ -97,9 +107,10 @@ impl StatusIndicatorWidget { app_event_tx: AppEventSender, frame_requester: FrameRequester, animations_enabled: bool, + custom_working_messages: bool, ) -> Self { Self { - header: random_status_message(), + header: initial_status_message(custom_working_messages), show_interrupt_hint: true, elapsed_running: Duration::ZERO, last_resume_at: Instant::now(), @@ -269,7 +280,8 @@ mod tests { fn new_widget_gets_whimsical_default_header() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + let w = + StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true, true); assert!( WHIMSICAL_STATUS_MESSAGES.contains(&w.header()), "default header {:?} should be a whimsical message", @@ -277,11 +289,22 @@ mod tests { ); } + #[test] + fn new_widget_uses_plain_default_header_when_custom_working_messages_disabled() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let w = + StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true, false); + + assert_eq!(w.header(), "Working"); + } + #[test] fn renders_with_default_header() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + let mut w = + StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true, true); w.update_header("Thinking really hard".to_string()); // Render into a fixed-size test terminal and snapshot the backend. @@ -296,7 +319,8 @@ mod tests { fn renders_truncated() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + let mut w = + StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true, true); w.update_header("Thinking really hard".to_string()); // Render into a fixed-size test terminal and snapshot the backend. @@ -312,7 +336,7 @@ mod tests { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut widget = - StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true, true); let baseline = Instant::now(); widget.last_resume_at = baseline;