Merge branch 'agentydragon-03-live-config-reload' into agentydragon

# Conflicts:
#	agentydragon/tasks/03-live-config-reload.md
This commit is contained in:
Rai (Michael Pokorny)
2025-06-24 22:33:39 -07:00
10 changed files with 351 additions and 10 deletions

View File

@@ -1,8 +1,8 @@
+++
id = "03"
title = "Live Config Reload and Prompt on Changes"
status = "Not started"
dependencies = "" # No prerequisites
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 `<Enter>`/`<Esc>` 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.
- `<Enter>` applies the new config (re-parses and updates runtime state); `<Esc>` 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.

167
codex-rs/Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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 {

View File

@@ -44,4 +44,10 @@ pub(crate) enum AppEvent {
MountRemove {
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,
}

View File

@@ -0,0 +1,60 @@
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::{Block, Borders, BorderType, Paragraph};
use ratatui::prelude::Widget;
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
}
}

View File

@@ -18,6 +18,7 @@ mod chat_composer;
mod chat_composer_history;
mod command_popup;
mod status_indicator_view;
mod config_reload_view;
pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::InputResult;
@@ -25,6 +26,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 +168,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 +280,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());

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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();