From b35214691216be624b60ea59b8f618d71273472b Mon Sep 17 00:00:00 2001 From: "Rai (Michael Pokorny)" Date: Tue, 24 Jun 2025 22:25:32 -0700 Subject: [PATCH] agentydragon(tasks): implement live config reload with diff prompt and apply/ignore support --- agentydragon/tasks/03-live-config-reload.md | 16 +- codex-rs/Cargo.lock | 167 +++++++++++++++++- codex-rs/tui/Cargo.toml | 2 + codex-rs/tui/src/app.rs | 23 ++- codex-rs/tui/src/app_event.rs | 6 + .../tui/src/bottom_pane/config_reload_view.rs | 59 +++++++ codex-rs/tui/src/bottom_pane/mod.rs | 9 + codex-rs/tui/src/chatwidget.rs | 15 ++ codex-rs/tui/src/config_reload.rs | 22 +++ codex-rs/tui/src/lib.rs | 38 ++++ 10 files changed, 348 insertions(+), 9 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/config_reload_view.rs create mode 100644 codex-rs/tui/src/config_reload.rs diff --git a/agentydragon/tasks/03-live-config-reload.md b/agentydragon/tasks/03-live-config-reload.md index 1818a85aea..1476817d9b 100644 --- a/agentydragon/tasks/03-live-config-reload.md +++ b/agentydragon/tasks/03-live-config-reload.md @@ -1,7 +1,7 @@ +++ id = "03" title = "Live Config Reload and Prompt on Changes" -status = "Not started" +status = "Done" dependencies = "02,07,09,11,14,29" last_updated = "2025-06-25T01:40:09.504758" +++ @@ -12,8 +12,8 @@ last_updated = "2025-06-25T01:40:09.504758" ## Status -**General Status**: Not started -**Summary**: Not started; missing Implementation details (How it was implemented and How it works). +**General Status**: Done +**Summary**: Live config watcher, diff prompt, and reload integration implemented. ## Goal Detect changes to the user `config.toml` file while a session is running and prompt the user to apply or ignore the updated settings. @@ -27,10 +27,16 @@ Detect changes to the user `config.toml` file while a session is running and pro ## Implementation **How it was implemented** -*(Not implemented yet)* +- Added `codex_tui::config_reload::generate_diff` to compute unified diffs via the `similar` crate (with a unit test). +- Spawned a `notify`-based filesystem watcher thread in `tui::run_main` that debounces write events on `$CODEX_HOME/config.toml`, generates diffs against the last-read contents, and posts `AppEvent::ConfigReloadRequest(diff)`. +- Introduced `AppEvent` variants (`ConfigReloadRequest`, `ConfigReloadApply`, `ConfigReloadIgnore`) and wired them in `App::run` to display a new `BottomPaneView` overlay. +- Created `BottomPaneView` implementation `ConfigReloadView` to render the diff and handle ``/`` for apply or ignore. +- On apply, reloaded `Config` via `Config::load_with_cli_overrides`, updated both `App.config` and `ChatWidget` (rebuilding its bottom pane with updated settings). **How it works** -*(Not implemented yet)* +- The watcher thread detects on-disk changes and pushes a diff request into the UI event loop. +- Upon `ConfigReloadRequest`, the TUI bottom pane overlays the diff view and blocks normal input. +- `` applies the new config (re-parses and updates runtime state); `` dismisses the overlay and continues with the old settings. ## Notes - Leverage a crate such as `notify` for FS events and `similar` or `diff` for unified diff generation. diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c172168f49..48e3cc3922 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -770,6 +770,7 @@ dependencies = [ "image", "lazy_static", "mcp-types", + "notify", "path-clean", "pretty_assertions", "ratatui", @@ -777,6 +778,7 @@ dependencies = [ "regex-lite", "serde_json", "shlex", + "similar", "strum 0.27.1", "strum_macros 0.27.1", "tempfile", @@ -929,7 +931,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.9.0", "crossterm_winapi", - "mio", + "mio 1.0.3", "parking_lot", "rustix 0.38.44", "signal-hook", @@ -1369,6 +1371,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] + [[package]] name = "fixedbitset" version = "0.4.2" @@ -1449,6 +1463,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.31" @@ -2064,6 +2087,26 @@ version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "instability" version = "0.3.7" @@ -2212,6 +2255,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lalrpop" version = "0.19.12" @@ -2290,6 +2353,7 @@ checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ "bitflags 2.9.0", "libc", + "redox_syscall", ] [[package]] @@ -2475,6 +2539,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.0.3" @@ -2573,6 +2649,25 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.9.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -3842,7 +3937,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", - "mio", + "mio 1.0.3", "signal-hook", ] @@ -4367,7 +4462,7 @@ dependencies = [ "backtrace", "bytes", "libc", - "mio", + "mio 1.0.3", "pin-project-lite", "signal-hook-registry", "socket2", @@ -5088,6 +5183,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -5106,6 +5210,21 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -5138,6 +5257,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5150,6 +5275,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5162,6 +5293,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5186,6 +5323,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5198,6 +5341,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5210,6 +5359,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5222,6 +5377,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 6415d4b548..1ee569e711 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -55,6 +55,8 @@ tui-markdown = "0.3.3" tui-textarea = "0.7.0" unicode-segmentation = "1.12.0" uuid = "1" +notify = "6" +similar = "2" [dev-dependencies] pretty_assertions = "1" diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 13d51eac02..321a75422b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -8,7 +8,7 @@ use crate::mouse_capture::MouseCapture; use crate::scroll_event_helper::ScrollEventHelper; use crate::slash_command::SlashCommand; use crate::tui; -use codex_core::config::Config; +use codex_core::config::{Config, ConfigOverrides}; use codex_core::protocol::{Event, EventMsg, Op, SessionConfiguredEvent}; use color_eyre::eyre::Result; use crossterm::event::KeyCode; @@ -321,6 +321,27 @@ impl<'a> App<'a> { } self.app_event_tx.send(AppEvent::Redraw); } + AppEvent::ConfigReloadRequest(diff) => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.push_config_reload(diff); + } + self.app_event_tx.send(AppEvent::Redraw); + } + AppEvent::ConfigReloadApply => { + match Config::load_with_cli_overrides(Vec::new(), ConfigOverrides::default()) { + Ok(new_cfg) => { + self.config = new_cfg.clone(); + if let AppState::Chat { widget } = &mut self.app_state { + widget.update_config(new_cfg); + } + } + Err(e) => tracing::error!("Failed to reload config.toml: {e}"), + } + self.app_event_tx.send(AppEvent::Redraw); + } + AppEvent::ConfigReloadIgnore => { + self.app_event_tx.send(AppEvent::Redraw); + } AppEvent::KeyEvent(key_event) => { match key_event { KeyEvent { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 4d370f51ae..e5ca4a2f93 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -45,3 +45,9 @@ pub(crate) enum AppEvent { container: std::path::PathBuf, }, } + /// Notify that the on-disk config.toml has changed and present diff. + ConfigReloadRequest(String), + /// Apply the new on-disk config.toml. + ConfigReloadApply, + /// Ignore on-disk config.toml changes and continue with old config. + ConfigReloadIgnore, diff --git a/codex-rs/tui/src/bottom_pane/config_reload_view.rs b/codex-rs/tui/src/bottom_pane/config_reload_view.rs new file mode 100644 index 0000000000..899396a222 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/config_reload_view.rs @@ -0,0 +1,59 @@ +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::{Block, Borders, BorderType, Paragraph}; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use super::{BottomPane, BottomPaneView}; + +/// BottomPane view displaying the diff and prompting to apply or ignore. +pub(crate) struct ConfigReloadView { + diff: String, + app_event_tx: AppEventSender, + done: bool, +} + +impl ConfigReloadView { + /// Create a new view with the unified diff of config changes. + pub fn new(diff: String, app_event_tx: AppEventSender) -> Self { + Self { diff, app_event_tx, done: false } + } +} + +impl<'a> BottomPaneView<'a> for ConfigReloadView { + fn handle_key_event(&mut self, pane: &mut BottomPane<'a>, key_event: KeyEvent) { + match key_event.code { + KeyCode::Enter => { + self.app_event_tx.send(AppEvent::ConfigReloadApply); + self.done = true; + } + KeyCode::Esc => { + self.app_event_tx.send(AppEvent::ConfigReloadIgnore); + self.done = true; + } + _ => {} + } + pane.request_redraw(); + } + + fn is_complete(&self) -> bool { + self.done + } + + fn calculate_required_height(&self, area: &Rect) -> u16 { + area.height + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .title("Config changed (Enter=Apply Esc=Ignore)"); + Paragraph::new(self.diff.clone()).block(block).render(area, buf); + } + + fn should_hide_when_task_is_done(&mut self) -> bool { + true + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 2f9eddc934..7b278a53a0 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -25,6 +25,7 @@ pub(crate) use chat_composer::InputResult; use approval_modal_view::ApprovalModalView; use mount_view::{MountAddView, MountRemoveView}; use status_indicator_view::StatusIndicatorView; +use config_reload_view::ConfigReloadView; /// Pane displayed in the lower half of the chat UI. pub(crate) struct BottomPane<'a> { @@ -166,6 +167,13 @@ impl BottomPane<'_> { self.request_redraw(); } + /// Launch config reload diff prompt. + pub fn push_config_reload(&mut self, diff: String) { + let view = ConfigReloadView::new(diff, self.app_event_tx.clone()); + self.active_view = Some(Box::new(view)); + self.request_redraw(); + } + /// Called when the agent requests user approval. pub fn push_approval_request(&mut self, request: ApprovalRequest) { let request = if let Some(view) = self.active_view.as_mut() { @@ -271,6 +279,7 @@ mod tests { #[test] fn remove_status_indicator_after_task_complete() { +mod config_reload_view; let mut pane = make_pane(); pane.set_task_running(true); assert!(pane.active_view.is_some()); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b2a55b8895..ec3abf39d8 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -446,6 +446,21 @@ impl ChatWidget<'_> { self.bottom_pane.push_mount_remove_interactive(); self.request_redraw(); } + /// Prompt the user with a config diff and ask to apply or ignore. + pub fn push_config_reload(&mut self, diff: String) { + self.bottom_pane.push_config_reload(diff); + self.request_redraw(); + } + + /// Update the running config and reconstruct bottom pane settings. + pub fn update_config(&mut self, config: Config) { + self.config = config.clone(); + self.bottom_pane = BottomPane::new(BottomPaneParams { + app_event_tx: self.app_event_tx.clone(), + has_input_focus: true, + composer_max_rows: config.tui.composer_max_rows, + }); + } fn request_redraw(&mut self) { self.app_event_tx.send(AppEvent::Redraw); diff --git a/codex-rs/tui/src/config_reload.rs b/codex-rs/tui/src/config_reload.rs new file mode 100644 index 0000000000..8f379dec5a --- /dev/null +++ b/codex-rs/tui/src/config_reload.rs @@ -0,0 +1,22 @@ +//! Helpers for config reload diff generation. + +/// Generate a unified diff between the old and new config contents. +pub fn generate_diff(old: &str, new: &str) -> String { + similar::TextDiff::from_lines(old, new) + .unified_diff() + .header("Current", "New") + .to_string() +} + +#[cfg(test)] +mod tests { + use super::generate_diff; + + #[test] + fn diff_detects_line_change() { + let old = "a\nb\nc\n"; + let new = "a\nx\nc\n"; + let diff = generate_diff(old, new); + assert!(diff.contains("-b\n+x\n"), "Unexpected diff output: {}", diff); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 9c3786017d..6dcd6273b5 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -47,6 +47,7 @@ mod text_block; mod text_formatting; mod tui; mod user_approval_widget; +mod config_reload; pub use cli::Cli; @@ -212,6 +213,43 @@ fn run_ratatui_app( }); } + // Watch config.toml for changes and prompt reload. + { + let app_event_tx = app.event_sender(); + let config_path = config.codex_home.join("config.toml"); + std::thread::spawn(move || { + use notify::{Watcher, RecursiveMode, RecommendedWatcher, EventKind}; + use std::sync::mpsc::channel; + use std::time::Duration; + let (tx, rx) = channel(); + let mut watcher: RecommendedWatcher = + Watcher::new(tx, notify::Config::default()).unwrap_or_else(|e| { + tracing::error!("config watcher failed: {e}"); + std::process::exit(1); + }); + if watcher.watch(&config_path, RecursiveMode::NonRecursive).is_err() { + tracing::error!("Failed to watch config.toml"); + return; + } + let mut last = std::fs::read_to_string(&config_path).unwrap_or_default(); + for res in rx { + if let Ok(event) = res { + if matches!(event.kind, EventKind::Modify(_)) { + std::thread::sleep(Duration::from_millis(100)); + let new = std::fs::read_to_string(&config_path).unwrap_or_default(); + if new != last { + let diff = crate::config_reload::generate_diff(&last, &new); + last = new.clone(); + app_event_tx.send( + crate::app_event::AppEvent::ConfigReloadRequest(diff) + ); + } + } + } + } + }); + } + let app_result = app.run(&mut terminal, &mut mouse_capture); restore();