From a34ec85bf0155dc45df6f160a27202d09669ac58 Mon Sep 17 00:00:00 2001 From: allen-munsch-bot Date: Mon, 24 Aug 2026 16:03:10 -0500 Subject: [PATCH] vigil: interactive TUI wiring (panels, /panel vigils, /vigil commands) Wire the vigil runtime (slice 2) into the interactive TUI loop behind the opt-in vigil feature. - run_interactive gains vigil wake/observance/ctl/hook receivers and drains plugin hook dispatch requests each iteration; idle poll slows from 20Hz to 1Hz while a vigil is active. - Vigil-wake select! arm observes a reap and launches the agent turn; post-turn dispatch fires on-vigil-observance via VigilBits and releases the in-flight flag. - decide_post_done_action gains a VigilSleep outcome so an active vigil suppresses loop/followup auto-restart (cfg-gated, no behavior change when vigil is off). - Left panel: PanelMode::Vigil, VigilStatusRow, VigilLeftPanel widget, Scene.left_panel_mode + vigil_data, /panel vigils, /vigil subcommands (add/start/stop/status/rest/pause/resume/remove), and live status polling through VigilCtl::StatusReq. Feature-OFF builds stay warning-clean (windows-default/no-plugin clippy and build matrix verified locally). --- src/main.rs | 12 +- src/plugin/mod.rs | 7 ++ src/plugin/mod_tests.rs | 56 +++++++++- src/ui/mod.rs | 161 ++++++++++++++++++++++++++- src/ui/panel_data.rs | 49 ++++++++ src/ui/renderer.rs | 25 +++++ src/ui/run_handlers/done.rs | 59 ++++++++++ src/ui/slash/cmd/mod.rs | 1 + src/ui/slash/cmd/panel.rs | 8 +- src/ui/slash/cmd/vigil_cmd/add.rs | 132 ++++++++++++++++++++++ src/ui/slash/cmd/vigil_cmd/mod.rs | 61 ++++++++++ src/ui/slash/cmd/vigil_cmd/pause.rs | 25 +++++ src/ui/slash/cmd/vigil_cmd/remove.rs | 43 +++++++ src/ui/slash/cmd/vigil_cmd/rest.rs | 49 ++++++++ src/ui/slash/cmd/vigil_cmd/resume.rs | 25 +++++ src/ui/slash/cmd/vigil_cmd/start.rs | 29 +++++ src/ui/slash/cmd/vigil_cmd/status.rs | 58 ++++++++++ src/ui/slash/cmd/vigil_cmd/stop.rs | 29 +++++ src/ui/slash/completion.rs | 2 +- src/ui/slash/mod.rs | 15 +++ src/ui/tui/panels.rs | 125 +++++++++++++++++++++ src/ui/tui/scene.rs | 50 ++++++++- 22 files changed, 1005 insertions(+), 16 deletions(-) create mode 100644 src/ui/slash/cmd/vigil_cmd/add.rs create mode 100644 src/ui/slash/cmd/vigil_cmd/mod.rs create mode 100644 src/ui/slash/cmd/vigil_cmd/pause.rs create mode 100644 src/ui/slash/cmd/vigil_cmd/remove.rs create mode 100644 src/ui/slash/cmd/vigil_cmd/rest.rs create mode 100644 src/ui/slash/cmd/vigil_cmd/resume.rs create mode 100644 src/ui/slash/cmd/vigil_cmd/start.rs create mode 100644 src/ui/slash/cmd/vigil_cmd/status.rs create mode 100644 src/ui/slash/cmd/vigil_cmd/stop.rs diff --git a/src/main.rs b/src/main.rs index 6ffdc85c1..9caf8036e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2059,9 +2059,9 @@ async fn main() -> anyhow::Result<()> { #[cfg(feature = "vigil")] let ( mut _vigil_keeper, - _vigil_wake_rx, + vigil_wake_rx, mut vigil_observance_rx, - _vigil_ctl_tx, + vigil_ctl_tx, mut vigil_hook_rx, ) = { if !cli.vigil_mode && !cli.vigil_once { @@ -2288,6 +2288,14 @@ async fn main() -> anyhow::Result<()> { dialog_rx, subagent_chat_rx, sysload, + #[cfg(feature = "vigil")] + vigil_wake_rx, + #[cfg(feature = "vigil")] + vigil_observance_rx, + #[cfg(feature = "vigil")] + vigil_ctl_tx, + #[cfg(feature = "vigil")] + vigil_hook_rx, ) .await?; diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 41db88479..4e5cc57ff 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -140,16 +140,23 @@ pub enum PostDoneAction { LoopIter, LoopStop, Idle, + #[cfg(feature = "vigil")] + VigilSleep, } pub fn decide_post_done_action( followup: Option, loop_active: bool, loop_should_stop: bool, + #[cfg(feature = "vigil")] vigil_active: bool, ) -> PostDoneAction { if let Some(text) = followup { return PostDoneAction::Followup(text); } + #[cfg(feature = "vigil")] + if vigil_active { + return PostDoneAction::VigilSleep; + } if !loop_active { return PostDoneAction::Idle; } diff --git a/src/plugin/mod_tests.rs b/src/plugin/mod_tests.rs index b50716ee3..cdd3d9524 100644 --- a/src/plugin/mod_tests.rs +++ b/src/plugin/mod_tests.rs @@ -135,29 +135,75 @@ fn test_post_done_action() { // Plugin followup must take precedence over the loop iteration // so we never silently drop a queued prompt. let followup = Some("retry".to_string()); + #[cfg(feature = "vigil")] + let vigil_off = false; assert_eq!( - decide_post_done_action(followup.clone(), true, false), + decide_post_done_action( + followup.clone(), + true, + false, + #[cfg(feature = "vigil")] + vigil_off + ), PostDoneAction::Followup("retry".into()) ); assert_eq!( - decide_post_done_action(followup.clone(), false, false), + decide_post_done_action( + followup.clone(), + false, + false, + #[cfg(feature = "vigil")] + vigil_off + ), PostDoneAction::Followup("retry".into()) ); // Loop iteration only when no followup. assert_eq!( - decide_post_done_action(None, true, false), + decide_post_done_action( + None, + true, + false, + #[cfg(feature = "vigil")] + vigil_off + ), PostDoneAction::LoopIter ); // Loop stop only when no followup and should_stop. assert_eq!( - decide_post_done_action(None, true, true), + decide_post_done_action( + None, + true, + true, + #[cfg(feature = "vigil")] + vigil_off + ), PostDoneAction::LoopStop ); // Idle: nothing to do. assert_eq!( - decide_post_done_action(None, false, false), + decide_post_done_action( + None, + false, + false, + #[cfg(feature = "vigil")] + vigil_off + ), PostDoneAction::Idle ); + + #[cfg(feature = "vigil")] + { + // VigilSleep: vigil active outranks loop. + assert_eq!( + decide_post_done_action(None, true, false, true), + PostDoneAction::VigilSleep + ); + // Followup still beats vigil. + assert_eq!( + decide_post_done_action(followup.clone(), false, false, true), + PostDoneAction::Followup("retry".into()) + ); + } } #[test] diff --git a/src/ui/mod.rs b/src/ui/mod.rs index c47d59cec..016abffc6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -93,6 +93,8 @@ use crate::ui::events::{render_session, sanitize_output}; use crate::ui::input::InputEditor; use crate::ui::keymap::{KeyAction, Keymaps}; use crate::ui::panel_render::{build_left_panel_info, build_panel_data}; +#[cfg(feature = "vigil")] +use crate::ui::renderer::VigilStatusRow; use crate::ui::renderer::{LineEntry, Renderer}; use crate::ui::search_rewind::{ allow_always_downgrade_reason, is_placeholder_pattern, open_rewind_picker, rewind_session, @@ -281,6 +283,16 @@ pub async fn run_interactive( // ui-redesign: shared system-load snapshot. Polled in the // background; read at panel paint time. Cheap clone (Arc bump). sysload: crate::ui::sysload::SharedSysLoad, + #[cfg(feature = "vigil")] mut vigil_wake_rx: Option>, + #[cfg(feature = "vigil")] mut vigil_observance_rx: Option< + tokio::sync::mpsc::Receiver, + >, + #[cfg(feature = "vigil")] vigil_ctl_tx: Option< + tokio::sync::mpsc::Sender, + >, + #[cfg(feature = "vigil")] mut vigil_hook_rx: Option< + tokio::sync::mpsc::Receiver, + >, ) -> anyhow::Result<()> { let _guard = TerminalGuard::new(cfg.keyboard_enhancement.unwrap_or(true))?; @@ -538,6 +550,13 @@ pub async fn run_interactive( #[cfg(feature = "loop")] let mut loop_state: Option = None; + #[cfg(feature = "vigil")] + let mut vigil_state: Option = + Some(crate::extras::vigil::VigilState { + active: vigil_wake_rx.is_some(), + pending_observance: None, + }); + // Snapshot plugin-registered shortcuts (P9c). Seeded at UI // startup; refreshed at the top of each event loop iteration // (M2) so a plugin that registers a shortcut from a hook — @@ -1652,6 +1671,36 @@ pub async fn run_interactive( gitstat.snapshot(), )); } + #[cfg(feature = "vigil")] + { + if let Some(ref vigil_ctl) = vigil_ctl_tx { + let (tx, rx) = tokio::sync::oneshot::channel(); + let _ = vigil_ctl + .send(crate::extras::vigil::types::VigilCtl::StatusReq { respond_to: tx }) + .await; + if let Ok(statuses) = rx.await { + let rows: Vec = statuses + .into_iter() + .map(|s| VigilStatusRow { + name: s.name, + trigger: s.trigger.as_str().to_string(), + interval_secs: s.reap_interval_secs, + running: s.running, + paused: s.paused, + last_event_count: s.last_event_count, + last_event_age: s.last_event_at.and_then(|ts| { + chrono::DateTime::parse_from_rfc3339(&ts).ok().map(|dt| { + let elapsed = + chrono::Utc::now().signed_duration_since(dt.to_utc()); + crate::ui::panel_data::format_duration_short(elapsed) + }) + }), + }) + .collect(); + renderer.set_vigil_status(rows); + } + } + } } // H-R1: loop-top PM acquisitions use `try_lock` so a @@ -1775,6 +1824,28 @@ pub async fn run_interactive( } } + // Drain vigil plugin hook dispatch requests (on-vigil-event, + // on-vigil-reap) every iteration rather than only after a + // successful observance wake. Rite failures, paused vigils, and + // commands-mode dispatches never wake the loop, so their hooks + // would otherwise sit undelivered. + #[cfg(feature = "vigil")] + if let Some(ref mut hook_rx) = vigil_hook_rx { + while let Ok(req) = hook_rx.try_recv() { + #[cfg(feature = "plugin")] + if let Some(pm) = plugin_manager { + let pm = pm.clone(); + let hook = req.hook_name; + let ctx = req.context; + tokio::task::spawn_blocking(move || { + pm.lock_ignore_poison().dispatch_tool_hook(&hook, &ctx) + }) + .await + .ok(); + } + } + } + // #387: single paint per event. Render the model (the previous // event's mutations + this iteration's loop-top updates) exactly // once, THEN block on the next event. Because every handler returns @@ -1786,6 +1857,22 @@ pub async fn run_interactive( // mount-timer select! arm can move it into its async block. let mount_deadline = ui.shell_mount_deadline; + // When vigil is active, slow the idle poll from 20Hz to 1Hz so the + // CPU isn't constantly waking during a quiet observance window. + #[cfg(feature = "vigil")] + let idle_sleep_ms: u64 = if vigil_state.as_ref().is_some_and(|vs| vs.active) { + 1000 + } else { + 50 + }; + #[cfg(not(feature = "vigil"))] + let idle_sleep_ms: u64 = 50; + + // When vigil is compiled out, declare a dummy wake receiver so + // the vigil select! arm is syntactically present but inert. + #[cfg(not(feature = "vigil"))] + let mut vigil_wake_rx: Option> = None; + tokio::select! { // #387: poll arms in order so USER INPUT takes priority — when a // keystroke and an agent event are both ready, the keystroke is @@ -2959,7 +3046,7 @@ pub async fn run_interactive( // /help) have no UserMessage event, so we keep the echo. write_user_lines(&mut renderer, &text)?; renderer.write_line("", Color::White)?; - let result = handle_slash(&expanded, &mut agent, &mut client, &mut renderer, session, cli, cfg, context, &mut ui.show_reasoning, &mut ui.is_running, &mut input, &permission, &ask_tx, &question_tx, &plan_tx, &mut ui.todo_tools_enabled, &bg_store, &sandbox, #[cfg(unix)] &user_tx, #[cfg(feature = "loop")] &mut loop_state, #[cfg(feature = "mcp")] mcp_manager.as_ref(), #[cfg(feature = "semantic")] semantic_manager, #[cfg(feature = "lsp")] lsp_manager.as_ref(), &mut ui.plan_phase).await; + let result = handle_slash(&expanded, &mut agent, &mut client, &mut renderer, session, cli, cfg, context, &mut ui.show_reasoning, &mut ui.is_running, &mut input, &permission, &ask_tx, &question_tx, &plan_tx, &mut ui.todo_tools_enabled, &bg_store, &sandbox, #[cfg(unix)] &user_tx, #[cfg(feature = "loop")] &mut loop_state, #[cfg(feature = "vigil")] &mut vigil_state, #[cfg(feature = "vigil")] &vigil_ctl_tx, #[cfg(feature = "mcp")] mcp_manager.as_ref(), #[cfg(feature = "semantic")] semantic_manager, #[cfg(feature = "lsp")] lsp_manager.as_ref(), &mut ui.plan_phase).await; match result { Ok(SlashOutcome::DeferCompress { instructions }) => { let instructions = instructions.as_deref().and_then(|s| { @@ -3606,6 +3693,10 @@ pub async fn run_interactive( state: &mut loop_state, label: &mut ui.loop_label, }; + #[cfg(feature = "vigil")] + let vigil_bits = run_handlers::done::VigilBits { + state: &mut vigil_state, + }; run_handlers::handle_done( &mut ctx, response, @@ -3626,6 +3717,8 @@ pub async fn run_interactive( &mut ui.done_phase, #[cfg(feature = "loop")] loop_bits, + #[cfg(feature = "vigil")] + vigil_bits, ).await?; } AgentEvent::Usage { @@ -4098,6 +4191,10 @@ pub async fn run_interactive( state: &mut loop_state, label: &mut ui.loop_label, }; + #[cfg(feature = "vigil")] + let vigil_bits = run_handlers::done::VigilBits { + state: &mut vigil_state, + }; run_handlers::done::finish_done( &mut ctx, result.response, @@ -4116,6 +4213,8 @@ pub async fn run_interactive( plugin_manager, #[cfg(feature = "loop")] loop_bits, + #[cfg(feature = "vigil")] + vigil_bits, ) .await?; } @@ -5266,9 +5365,67 @@ pub async fn run_interactive( // active path already re-asserts. _ = tokio::time::sleep(tokio::time::Duration::from_secs(1)), if !ui.is_running => { renderer.reassert_terminal_modes(); + }, + // Vigil wake — triggered by the reaper after a + // successful observance. The vigils may be disabled + // at compile time; the `if vigil_wake_rx.is_some()` + // guard ensures the arm is inert when vigil is off. + _ = async { + match &mut vigil_wake_rx { + Some(rx) => rx.recv().await, + None => std::future::pending::>().await, + } + }, if vigil_wake_rx.is_some() => { + #[cfg(feature = "vigil")] + { + if !ui.is_running + && let Some(ref mut rx) = vigil_observance_rx + && let Ok(obs) = rx.try_recv() + { + // Store observance metadata so the post-turn + // handler can dispatch on-vigil-observance + // with :response and :exit after the agent turn. + if let Some(ref mut vs) = vigil_state { + vs.pending_observance = Some( + crate::extras::vigil::PendingObservance { + vigil_name: obs.vigil_name.clone(), + event_count: obs.event_count, + running: obs.running.clone(), + }, + ); + } + let prompt = if obs.prompt.is_empty() { + format!("[vigil] {} - {} event(s)", obs.vigil_name, obs.event_count) + } else { + obs.prompt.clone() + }; + ui.last_user_prompt.clone_from(&prompt); + let history = crate::agent::runner::convert_history(session); + session.add_message(MessageRole::User, &prompt); + begin_snapshot_turn(session); + let runner = agent.clone().spawn_runner( + crate::provider::Prompt::text( + crate::agent::tools::background::prepend_pending_notifications( + &prompt, + bg_store.as_ref(), + ), + ), + history, + Some(ui.interjection_queue.clone()), + Some(session.assets_dir()), + ); + runner.install_into( + &mut ui.agent_rx, + &mut ui.agent_abort, + &mut ui.agent_interject, + &mut ui.agent_cancel, + &mut ui.is_running, + ); + } + } } else => { - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + tokio::time::sleep(tokio::time::Duration::from_millis(idle_sleep_ms)).await; } } } diff --git a/src/ui/panel_data.rs b/src/ui/panel_data.rs index 716eba8d4..f4a661350 100644 --- a/src/ui/panel_data.rs +++ b/src/ui/panel_data.rs @@ -86,6 +86,21 @@ pub struct LeftPanelInfo { pub git: Option, } +/// A single row in a vigil-status panel display. +#[cfg(feature = "vigil")] +#[derive(Debug, Clone, Default)] +pub struct VigilStatusRow { + pub name: String, + pub trigger: String, + pub interval_secs: u64, + pub running: bool, + pub paused: bool, + /// Number of events in the most recent reap window. + pub last_event_count: usize, + /// Human-readable age of the most recent reap (e.g. "3s", "12m"). + pub last_event_age: Option, +} + /// Build a compact, glanceable label for a tool call shown in the /// left-panel `[ACTIVITY]` ticker — ` `. The /// target is the basename for path tools, the command head for `bash`, @@ -128,8 +143,23 @@ pub fn tool_call_label(name: &str, args: &serde_json::Value) -> String { } } +/// Human-readable short duration like "3s", "12m", "2h". +#[cfg(feature = "vigil")] +pub fn format_duration_short(d: chrono::TimeDelta) -> String { + let secs = d.num_seconds(); + if secs < 60 { + format!("{}s", secs.max(0)) + } else if secs < 3600 { + format!("{}m", secs / 60) + } else { + format!("{}h", secs / 3600) + } +} + #[cfg(test)] mod tests { + #[cfg(feature = "vigil")] + use super::format_duration_short; use super::tool_call_label; use serde_json::json; @@ -169,4 +199,23 @@ mod tests { ); assert_eq!(tool_call_label("read", &json!({})), "read"); } + + #[cfg(feature = "vigil")] + #[test] + fn duration_short_formats_seconds_minutes_hours() { + let d = |secs: i64| chrono::TimeDelta::try_seconds(secs).unwrap(); + assert_eq!(format_duration_short(d(0)), "0s"); + assert_eq!(format_duration_short(d(45)), "45s"); + assert_eq!(format_duration_short(d(60)), "1m"); + assert_eq!(format_duration_short(d(3599)), "59m"); + assert_eq!(format_duration_short(d(3600)), "1h"); + assert_eq!(format_duration_short(d(7200)), "2h"); + } + + #[cfg(feature = "vigil")] + #[test] + fn duration_short_clamps_negative_to_zero() { + let d = chrono::TimeDelta::try_seconds(-10).unwrap(); + assert_eq!(format_duration_short(d), "0s"); + } } diff --git a/src/ui/renderer.rs b/src/ui/renderer.rs index 663d5a92f..11a2fd2d1 100644 --- a/src/ui/renderer.rs +++ b/src/ui/renderer.rs @@ -371,6 +371,8 @@ pub enum PanelMode { /// Show debug panel instead of system info (gated on ≥100 cols). /// Only meaningful when a DAP session is active. Debug, + /// Show vigil status in the left panel instead of vitals. + Vigil, } /// Which side panels a `/display` spec (or the `display` config value) @@ -425,6 +427,8 @@ pub fn parse_display_spec(spec: &str) -> Result { } // Re-exported from submodules so existing imports don't break. +#[cfg(feature = "vigil")] +pub use crate::ui::panel_data::VigilStatusRow; pub use crate::ui::panel_data::{LeftPanelInfo, PanelData, SubagentStatusRow}; /// Normalized selection range — `start <= end` in row-major order. /// Coordinates are `(buffer_line_idx, char_offset_in_line)`. Used by @@ -590,6 +594,9 @@ pub struct Renderer { /// ui-redesign: idle-state info for the left panel. Painted when /// `subagent_status` is empty so the gutter never looks dead. left_panel_info: LeftPanelInfo, + /// Live vigil status rows for the left panel when in Vigil mode. + #[cfg(feature = "vigil")] + vigil_status: Vec, /// DAP debug panel snapshot — updated each UI tick when a /// DAP session is active and panel mode is Debug. #[cfg(feature = "dap")] @@ -746,6 +753,8 @@ impl Renderer { panel_data: PanelData::default(), subagent_status: Vec::new(), left_panel_info: LeftPanelInfo::default(), + #[cfg(feature = "vigil")] + vigil_status: Vec::new(), #[cfg(feature = "dap")] debug_panel_data: None, alert_overlay: None, @@ -912,6 +921,7 @@ impl Renderer { selection_start, selection_end, right_panel_mode, + left_panel_mode, .. } = self; @@ -1112,6 +1122,9 @@ impl Renderer { input_bg: crate::ui::theme::input_bg(), picker: picker_overlay.as_ref(), right_panel_mode: *right_panel_mode, + left_panel_mode: *left_panel_mode, + #[cfg(feature = "vigil")] + vigil_data: &self.vigil_status, tooltip, #[cfg(feature = "dap")] debug_panel_data: self.debug_panel_data.as_ref(), @@ -1390,6 +1403,17 @@ impl Renderer { self.right_panel_mode = mode; } + /// Set only the left panel mode (used by `/panel vigils`). + pub fn set_left_panel_mode(&mut self, mode: PanelMode) { + self.left_panel_mode = mode; + } + + /// Replace the vigil status snapshot in the left panel. + #[cfg(feature = "vigil")] + pub fn set_vigil_status(&mut self, rows: Vec) { + self.vigil_status = rows; + } + /// Apply a parsed `/display` selection (or the `display` config /// value): each listed side panel is forced on, each omitted one /// forced off — an explicit user choice, so `On`/`Off` rather than @@ -1611,6 +1635,7 @@ impl Renderer { PanelMode::On => self.content_indent() >= 15, PanelMode::Auto => cols >= PANEL_AUTO_MIN_COLS && self.content_indent() >= 15, PanelMode::Debug => cols >= PANEL_AUTO_MIN_COLS && self.content_indent() >= 15, + PanelMode::Vigil => cols >= PANEL_AUTO_MIN_COLS && self.content_indent() >= 15, } } diff --git a/src/ui/run_handlers/done.rs b/src/ui/run_handlers/done.rs index 0f18bdc40..8119c36c6 100644 --- a/src/ui/run_handlers/done.rs +++ b/src/ui/run_handlers/done.rs @@ -42,6 +42,12 @@ pub(crate) struct LoopBits<'a> { pub label: &'a mut Option, } +/// Optional vigil-feature state passed through to `handle_done`. +#[cfg(feature = "vigil")] +pub(crate) struct VigilBits<'a> { + pub state: &'a mut Option, +} + /// Outcome of [`prepare_next_model_client`]: tells the caller whether to go on /// and rebuild the agent, and if so whether the provider changed. #[cfg(feature = "plugin")] @@ -204,8 +210,37 @@ pub(crate) async fn handle_done( // arm applies the model swap and runs finish_done once it resolves. #[cfg(feature = "plugin")] done_phase: &mut Option, #[cfg(feature = "loop")] loop_bits: LoopBits<'_>, + #[cfg(feature = "vigil")] vigil_bits: VigilBits<'_>, ) -> anyhow::Result<()> { *was_reasoning = false; + // Dispatch on-vigil-observance hook now that we have the agent's response. + // The vigil select! arm stored pending observance metadata in VigilState; + // we fire the hook here so it sees :response and :exit. + #[cfg(feature = "vigil")] + if let Some(vs) = vigil_bits.state + && let Some(pending) = vs.pending_observance.take() + { + #[cfg(feature = "plugin")] + if let Some(pm) = plugin_manager { + let ctx = crate::extras::vigil::observance_context( + &pending.vigil_name, + pending.event_count, + &response, + ); + let pm = pm.clone(); + tokio::task::spawn_blocking(move || { + pm.lock_ignore_poison() + .dispatch_tool_hook("on-vigil-observance", &ctx) + }) + .await + .ok(); + } + // Release the in-flight flag so the vigil can fire again on its next + // reap window. The reaper skips a vigil while this flag is set. + pending + .running + .store(false, std::sync::atomic::Ordering::SeqCst); + } // A successful turn must not leave a chamber // half-painted. If anything slipped through // — show_details=false skipping the body, an @@ -294,6 +329,8 @@ pub(crate) async fn handle_done( plugin_manager, #[cfg(feature = "loop")] loop_bits, + #[cfg(feature = "vigil")] + vigil_bits, ) .await } @@ -326,6 +363,7 @@ pub(crate) async fn finish_done( #[cfg_attr(not(feature = "experimental-graph-search"), allow(unused_variables))] plugin_manager: Option<&std::sync::Arc>>, #[cfg(feature = "loop")] loop_bits: LoopBits<'_>, + #[cfg(feature = "vigil")] vigil_bits: VigilBits<'_>, ) -> anyhow::Result<()> { let bg_store = deps.bg_store; @@ -420,6 +458,21 @@ pub(crate) async fn finish_done( #[cfg(not(feature = "loop"))] let (loop_active, loop_should_stop) = (false, false); + #[cfg(feature = "vigil")] + let vigil_active = vigil_bits + .state + .as_ref() + .map(|vs| vs.active) + .unwrap_or(false); + + #[cfg(feature = "vigil")] + let action = crate::plugin::decide_post_done_action( + followup_for_decision, + loop_active, + loop_should_stop, + vigil_active, + ); + #[cfg(not(feature = "vigil"))] let action = crate::plugin::decide_post_done_action( followup_for_decision, loop_active, @@ -496,6 +549,12 @@ pub(crate) async fn finish_done( } } crate::plugin::PostDoneAction::Idle => {} + #[cfg(feature = "vigil")] + crate::plugin::PostDoneAction::VigilSleep => { + // Vigil mode: don't auto-follow-up or loop; just sleep. + // The select! loop's vigil-wake arm will observe and + // launch the next agent turn. + } } // Phased `/plan` reviewer loop (P3e-b). If this `Done` closed a plan-driven diff --git a/src/ui/slash/cmd/mod.rs b/src/ui/slash/cmd/mod.rs index aeef7f95f..11d47f921 100644 --- a/src/ui/slash/cmd/mod.rs +++ b/src/ui/slash/cmd/mod.rs @@ -43,6 +43,7 @@ pub(crate) mod tasks; pub(crate) mod toggle; pub(crate) mod tree; pub(crate) mod undo; +pub(crate) mod vigil_cmd; #[cfg(feature = "git-worktree")] pub(crate) mod worktree; #[cfg(feature = "git-worktree")] diff --git a/src/ui/slash/cmd/panel.rs b/src/ui/slash/cmd/panel.rs index 14bddec92..2a3c6fdf0 100644 --- a/src/ui/slash/cmd/panel.rs +++ b/src/ui/slash/cmd/panel.rs @@ -11,9 +11,13 @@ pub(crate) async fn cmd_panel(ctx: &mut SlashCtx<'_>, parts: &[&str]) -> anyhow: "off" => Some(PanelMode::Off), "auto" => Some(PanelMode::Auto), "debug" => Some(PanelMode::Debug), + "vigils" => Some(PanelMode::Vigil), other => { ctx.renderer.write_line( - &format!("unknown /panel mode '{}' (use on|off|auto|debug)", other), + &format!( + "unknown /panel mode '{}' (use on|off|auto|debug|vigils)", + other + ), c_error(), )?; return Ok(()); @@ -22,6 +26,8 @@ pub(crate) async fn cmd_panel(ctx: &mut SlashCtx<'_>, parts: &[&str]) -> anyhow: if let Some(mode) = new_mode { if mode == PanelMode::Debug { ctx.renderer.set_right_panel_mode(mode); + } else if mode == PanelMode::Vigil { + ctx.renderer.set_left_panel_mode(mode); } else { ctx.renderer.set_panel_mode(mode); } diff --git a/src/ui/slash/cmd/vigil_cmd/add.rs b/src/ui/slash/cmd/vigil_cmd/add.rs new file mode 100644 index 000000000..474d8d7e9 --- /dev/null +++ b/src/ui/slash/cmd/vigil_cmd/add.rs @@ -0,0 +1,132 @@ +//! /vigil add toll|watcher|harbinger [key=value ...] — add a new vigil. + +use crate::extras::dirge_paths::ProjectPaths; +use crate::extras::vigil_db::VigilStore; +use crate::ui::slash::{SlashCtx, c_agent, c_error}; + +pub(crate) async fn cmd_vigil_add( + ctx: &mut SlashCtx<'_>, + parts: &[&str], + _text: &str, +) -> anyhow::Result<()> { + let trigger = parts.get(2).copied().unwrap_or(""); + let name = parts.get(3).copied().unwrap_or(""); + if trigger.is_empty() || name.is_empty() { + ctx.renderer.write_line( + "usage: /vigil add toll|watcher|harbinger [key=value ...]", + c_error(), + )?; + return Ok(()); + } + + let entry = match build_entry(trigger, name, &parts[4..]) { + Ok(e) => e, + Err(msg) => { + ctx.renderer.write_line(&msg, c_error())?; + return Ok(()); + } + }; + + let json = match serde_json::to_string(&entry) { + Ok(j) => j, + Err(e) => { + ctx.renderer + .write_line(&format!("failed to serialize: {e}"), c_error())?; + return Ok(()); + } + }; + + let paths = ProjectPaths::new( + &std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), + ); + match VigilStore::open(&paths) { + Ok(store) => { + if let Err(e) = store.upsert(name, &json) { + ctx.renderer + .write_line(&format!("failed to save: {e}"), c_error())?; + return Ok(()); + } + } + Err(e) => { + ctx.renderer + .write_line(&format!("cannot open vigil store: {e}"), c_error())?; + return Ok(()); + } + } + + ctx.renderer.write_line( + &format!("vigil '{name}' (trigger: {trigger}) added"), + c_agent(), + )?; + Ok(()) +} + +fn build_entry( + trigger: &str, + name: &str, + args: &[&str], +) -> Result { + use crate::config::{SocketMode, VigilEntry, VigilRite, VigilTrigger}; + + let parsed: std::collections::HashMap = args + .iter() + .filter_map(|a| a.split_once('=')) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + let trigger = match trigger { + "toll" => { + let secs = parsed + .get("interval_secs") + .and_then(|v| v.parse().ok()) + .unwrap_or(30); + VigilTrigger::Toll { + interval_secs: secs, + } + } + "watcher" => { + let path = parsed + .get("path") + .cloned() + .unwrap_or_else(|| ".".to_string()); + VigilTrigger::Watcher { path } + } + "harbinger" => { + let address = parsed + .get("address") + .cloned() + .unwrap_or_else(|| "127.0.0.1:9000".to_string()); + let protocol = parsed.get("protocol").cloned().unwrap_or_default(); + VigilTrigger::Harbinger { + address, + protocol, + socket_mode: SocketMode::Commands, + commands: std::collections::HashMap::new(), + } + } + other => { + return Err(format!( + "unknown trigger '{other}'. use: toll, watcher, or harbinger" + )); + } + }; + + let reap_interval_secs = parsed + .get("reap_interval_secs") + .and_then(|v| v.parse().ok()) + .unwrap_or(30); + + let prompt = parsed.get("prompt").cloned().unwrap_or_default(); + + Ok(VigilEntry { + name: name.to_string(), + trigger, + reap_interval_secs, + prompt, + procession: None, + rite: Some(VigilRite { + cmd: None, + git_dirty: false, + }), + }) +} diff --git a/src/ui/slash/cmd/vigil_cmd/mod.rs b/src/ui/slash/cmd/vigil_cmd/mod.rs new file mode 100644 index 000000000..ba46de27f --- /dev/null +++ b/src/ui/slash/cmd/vigil_cmd/mod.rs @@ -0,0 +1,61 @@ +//! /vigil command dispatch. + +#[cfg(feature = "vigil")] +pub(crate) mod add; +#[cfg(feature = "vigil")] +pub(crate) mod pause; +#[cfg(feature = "vigil")] +pub(crate) mod remove; +#[cfg(feature = "vigil")] +pub(crate) mod rest; +#[cfg(feature = "vigil")] +pub(crate) mod resume; +#[cfg(feature = "vigil")] +pub(crate) mod start; +#[cfg(feature = "vigil")] +pub(crate) mod status; +#[cfg(feature = "vigil")] +pub(crate) mod stop; + +use crate::ui::slash::SlashCtx; +#[cfg(feature = "vigil")] +use crate::ui::slash::c_error; + +#[cfg(not(feature = "vigil"))] +use crate::ui::slash::c_agent; + +pub(crate) async fn cmd_vigil( + ctx: &mut SlashCtx<'_>, + #[allow(unused_variables)] parts: &[&str], + #[allow(unused_variables)] text: &str, +) -> anyhow::Result<()> { + #[cfg(feature = "vigil")] + { + let sub = parts.get(1).copied().unwrap_or("status"); + match sub { + "add" => add::cmd_vigil_add(ctx, parts, text).await, + "start" => start::cmd_vigil_start(ctx, parts).await, + "stop" => stop::cmd_vigil_stop(ctx, parts).await, + "status" => status::cmd_vigil_status(ctx).await, + "rest" => rest::cmd_vigil_rest(ctx, parts).await, + "pause" => pause::cmd_vigil_pause(ctx, parts).await, + "resume" => resume::cmd_vigil_resume(ctx, parts).await, + "remove" => remove::cmd_vigil_remove(ctx, parts).await, + _ => { + ctx.renderer.write_line( + "usage: /vigil [add|start|stop|status|rest|pause|resume|remove]", + c_error(), + )?; + Ok(()) + } + } + } + #[cfg(not(feature = "vigil"))] + { + ctx.renderer.write_line( + "/vigil requires the 'vigil' feature: cargo build --features vigil", + c_agent(), + )?; + Ok(()) + } +} diff --git a/src/ui/slash/cmd/vigil_cmd/pause.rs b/src/ui/slash/cmd/vigil_cmd/pause.rs new file mode 100644 index 000000000..13999d64f --- /dev/null +++ b/src/ui/slash/cmd/vigil_cmd/pause.rs @@ -0,0 +1,25 @@ +//! /vigil pause — pause a vigil (keep config, don't fire). + +use crate::ui::slash::{SlashCtx, c_agent, c_error}; + +pub(crate) async fn cmd_vigil_pause(ctx: &mut SlashCtx<'_>, parts: &[&str]) -> anyhow::Result<()> { + let name = parts.get(2).copied().unwrap_or(""); + if name.is_empty() { + ctx.renderer + .write_line("usage: /vigil pause ", c_error())?; + return Ok(()); + } + if let Some(tx) = ctx.vigil_ctl_tx { + let _ = tx + .send(crate::extras::vigil::types::VigilCtl::Pause { + name: name.to_string(), + }) + .await; + ctx.renderer + .write_line(&format!("vigil '{}' paused", name), c_agent())?; + } else { + ctx.renderer + .write_line("vigil keeper not running", c_error())?; + } + Ok(()) +} diff --git a/src/ui/slash/cmd/vigil_cmd/remove.rs b/src/ui/slash/cmd/vigil_cmd/remove.rs new file mode 100644 index 000000000..10148916c --- /dev/null +++ b/src/ui/slash/cmd/vigil_cmd/remove.rs @@ -0,0 +1,43 @@ +//! /vigil remove — remove a vigil definition. + +use crate::extras::dirge_paths::ProjectPaths; +use crate::extras::vigil_db::VigilStore; +use crate::ui::slash::{SlashCtx, c_agent, c_error}; + +pub(crate) async fn cmd_vigil_remove(ctx: &mut SlashCtx<'_>, parts: &[&str]) -> anyhow::Result<()> { + let name = parts.get(2).copied().unwrap_or(""); + if name.is_empty() { + ctx.renderer + .write_line("usage: /vigil remove ", c_error())?; + return Ok(()); + } + + let paths = ProjectPaths::new( + &std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), + ); + let db_path = paths.session_db_path(); + if db_path.exists() { + match VigilStore::open_at(&db_path) { + Ok(store) => { + if let Err(e) = store.remove(name) { + ctx.renderer + .write_line(&format!("vigil '{name}' not found: {e}"), c_error())?; + return Ok(()); + } + } + Err(e) => { + ctx.renderer + .write_line(&format!("cannot open vigil store: {e}"), c_error())?; + return Ok(()); + } + } + } else { + ctx.renderer + .write_line(&format!("vigil '{name}' not found"), c_error())?; + return Ok(()); + } + + ctx.renderer + .write_line(&format!("vigil '{name}' removed"), c_agent())?; + Ok(()) +} diff --git a/src/ui/slash/cmd/vigil_cmd/rest.rs b/src/ui/slash/cmd/vigil_cmd/rest.rs new file mode 100644 index 000000000..2c5dfb895 --- /dev/null +++ b/src/ui/slash/cmd/vigil_cmd/rest.rs @@ -0,0 +1,49 @@ +//! /vigil rest — put a vigil into resting state (sleep until next trigger). + +use crate::extras::dirge_paths::ProjectPaths; +use crate::extras::vigil_db::{VigilStatus, VigilStore}; +use crate::ui::slash::{SlashCtx, c_agent, c_error}; + +pub(crate) async fn cmd_vigil_rest(ctx: &mut SlashCtx<'_>, parts: &[&str]) -> anyhow::Result<()> { + let name = parts.get(2).copied().unwrap_or(""); + if name.is_empty() { + ctx.renderer + .write_line("usage: /vigil rest ", c_error())?; + return Ok(()); + } + + let paths = ProjectPaths::new( + &std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), + ); + let db_path = paths.session_db_path(); + if db_path.exists() { + match VigilStore::open_at(&db_path) { + Ok(store) => { + if let Err(e) = store.set_status(name, VigilStatus::Resting) { + ctx.renderer + .write_line(&format!("vigil '{name}' not found: {e}"), c_error())?; + return Ok(()); + } + } + Err(e) => { + ctx.renderer + .write_line(&format!("cannot open vigil store: {e}"), c_error())?; + return Ok(()); + } + } + } + + if let Some(ctl_tx) = ctx.vigil_ctl_tx { + let _ = ctl_tx + .send(crate::extras::vigil::types::VigilCtl::Pause { + name: name.to_string(), + }) + .await; + } + + ctx.renderer.write_line( + &format!("vigil '{name}' resting (will sleep until next trigger)"), + c_agent(), + )?; + Ok(()) +} diff --git a/src/ui/slash/cmd/vigil_cmd/resume.rs b/src/ui/slash/cmd/vigil_cmd/resume.rs new file mode 100644 index 000000000..7e35423cc --- /dev/null +++ b/src/ui/slash/cmd/vigil_cmd/resume.rs @@ -0,0 +1,25 @@ +//! /vigil resume — resume a paused vigil. + +use crate::ui::slash::{SlashCtx, c_agent, c_error}; + +pub(crate) async fn cmd_vigil_resume(ctx: &mut SlashCtx<'_>, parts: &[&str]) -> anyhow::Result<()> { + let name = parts.get(2).copied().unwrap_or(""); + if name.is_empty() { + ctx.renderer + .write_line("usage: /vigil resume ", c_error())?; + return Ok(()); + } + if let Some(tx) = ctx.vigil_ctl_tx { + let _ = tx + .send(crate::extras::vigil::types::VigilCtl::Resume { + name: name.to_string(), + }) + .await; + ctx.renderer + .write_line(&format!("vigil '{}' resumed", name), c_agent())?; + } else { + ctx.renderer + .write_line("vigil keeper not running", c_error())?; + } + Ok(()) +} diff --git a/src/ui/slash/cmd/vigil_cmd/start.rs b/src/ui/slash/cmd/vigil_cmd/start.rs new file mode 100644 index 000000000..30b0efcbf --- /dev/null +++ b/src/ui/slash/cmd/vigil_cmd/start.rs @@ -0,0 +1,29 @@ +//! /vigil start — (re)start one vigil or all vigils. + +use crate::ui::slash::{SlashCtx, c_agent, c_error}; + +pub(crate) async fn cmd_vigil_start(ctx: &mut SlashCtx<'_>, parts: &[&str]) -> anyhow::Result<()> { + let name = parts.get(2).copied().unwrap_or(""); + + let Some(ctl_tx) = ctx.vigil_ctl_tx else { + ctx.renderer + .write_line("vigil keeper not running", c_error())?; + return Ok(()); + }; + + if name.is_empty() { + let _ = ctl_tx + .send(crate::extras::vigil::types::VigilCtl::ResumeAll) + .await; + ctx.renderer.write_line("resumed all vigils", c_agent())?; + } else { + let _ = ctl_tx + .send(crate::extras::vigil::types::VigilCtl::Resume { + name: name.to_string(), + }) + .await; + ctx.renderer + .write_line(&format!("vigil '{name}' started"), c_agent())?; + } + Ok(()) +} diff --git a/src/ui/slash/cmd/vigil_cmd/status.rs b/src/ui/slash/cmd/vigil_cmd/status.rs new file mode 100644 index 000000000..d90cdee9d --- /dev/null +++ b/src/ui/slash/cmd/vigil_cmd/status.rs @@ -0,0 +1,58 @@ +//! /vigil status — show all vigils and their state. + +use crate::ui::slash::{SlashCtx, c_agent, c_error}; + +use tokio::sync::oneshot; + +pub(crate) async fn cmd_vigil_status(ctx: &mut SlashCtx<'_>) -> anyhow::Result<()> { + let Some(ctl_tx) = ctx.vigil_ctl_tx else { + ctx.renderer + .write_line("vigil keeper not running", c_error())?; + return Ok(()); + }; + + let (tx, rx) = oneshot::channel(); + let _ = ctl_tx + .send(crate::extras::vigil::types::VigilCtl::StatusReq { respond_to: tx }) + .await; + + let statuses = match rx.await { + Ok(s) => s, + Err(_) => { + ctx.renderer + .write_line("vigil keeper did not respond", c_error())?; + return Ok(()); + } + }; + + if statuses.is_empty() { + ctx.renderer.write_line("no vigils configured", c_agent())?; + return Ok(()); + } + + for info in &statuses { + let trigger = info.trigger.as_str(); + let state = if info.paused { "paused" } else { "active" }; + ctx.renderer.write_line( + &format!( + " {} trigger={} reap={}s {}", + info.name, trigger, info.reap_interval_secs, state + ), + c_agent(), + )?; + } + + let active = statuses.iter().filter(|i| !i.paused).count(); + let paused = statuses.iter().filter(|i| i.paused).count(); + ctx.renderer.write_line( + &format!( + "{} vigil(s): {} active, {} paused", + statuses.len(), + active, + paused, + ), + c_agent(), + )?; + + Ok(()) +} diff --git a/src/ui/slash/cmd/vigil_cmd/stop.rs b/src/ui/slash/cmd/vigil_cmd/stop.rs new file mode 100644 index 000000000..e3fa88161 --- /dev/null +++ b/src/ui/slash/cmd/vigil_cmd/stop.rs @@ -0,0 +1,29 @@ +//! /vigil stop — stop one vigil or all vigils. + +use crate::ui::slash::{SlashCtx, c_agent, c_error}; + +pub(crate) async fn cmd_vigil_stop(ctx: &mut SlashCtx<'_>, parts: &[&str]) -> anyhow::Result<()> { + let name = parts.get(2).copied().unwrap_or(""); + + let Some(ctl_tx) = ctx.vigil_ctl_tx else { + ctx.renderer + .write_line("vigil keeper not running", c_error())?; + return Ok(()); + }; + + if name.is_empty() { + let _ = ctl_tx + .send(crate::extras::vigil::types::VigilCtl::PauseAll) + .await; + ctx.renderer.write_line("stopped all vigils", c_agent())?; + } else { + let _ = ctl_tx + .send(crate::extras::vigil::types::VigilCtl::Pause { + name: name.to_string(), + }) + .await; + ctx.renderer + .write_line(&format!("vigil '{name}' stopped"), c_agent())?; + } + Ok(()) +} diff --git a/src/ui/slash/completion.rs b/src/ui/slash/completion.rs index 7e984fe02..977fbe6cf 100644 --- a/src/ui/slash/completion.rs +++ b/src/ui/slash/completion.rs @@ -179,7 +179,7 @@ static SUBCOMMAND_ENTRIES: &[(&str, &[&str])] = &[ "help", ], ), - ("/panel", &["on", "off", "auto", "debug"]), + ("/panel", &["on", "off", "auto", "debug", "vigils"]), ("/plugins", &["load"]), ("/display", &[]), // dynamic: pane spec ("/kill", &[]), // dynamic: subagent ID diff --git a/src/ui/slash/mod.rs b/src/ui/slash/mod.rs index d8739d683..e599a87fa 100644 --- a/src/ui/slash/mod.rs +++ b/src/ui/slash/mod.rs @@ -109,6 +109,11 @@ pub(super) struct SlashCtx<'a> { pub user_tx: &'a tokio::sync::mpsc::UnboundedSender, #[cfg(feature = "loop")] pub loop_state: &'a mut Option, + #[cfg(feature = "vigil")] + #[allow(dead_code)] + pub vigil_state: &'a mut Option, + #[cfg(feature = "vigil")] + pub vigil_ctl_tx: &'a Option>, #[cfg(feature = "mcp")] pub mcp_manager: Option<&'a McpClientManager>, #[cfg(feature = "semantic")] @@ -636,6 +641,10 @@ pub async fn handle_slash( sandbox: &Sandbox, #[cfg(unix)] user_tx: &tokio::sync::mpsc::UnboundedSender, #[cfg(feature = "loop")] loop_state: &mut Option, + #[cfg(feature = "vigil")] vigil_state: &mut Option, + #[cfg(feature = "vigil")] vigil_ctl_tx: &Option< + tokio::sync::mpsc::Sender, + >, #[cfg(feature = "mcp")] mcp_manager: Option<&McpClientManager>, #[cfg(feature = "semantic")] semantic_manager: Option<&SemanticManager>, // C8 (audit fix): every prior agent-rebuild path (/model, @@ -669,6 +678,10 @@ pub async fn handle_slash( user_tx, #[cfg(feature = "loop")] loop_state, + #[cfg(feature = "vigil")] + vigil_state, + #[cfg(feature = "vigil")] + vigil_ctl_tx, #[cfg(feature = "mcp")] mcp_manager, #[cfg(feature = "semantic")] @@ -717,6 +730,7 @@ pub async fn handle_slash( "/fork" => cmd::fork::cmd_fork(&mut ctx, &parts).await?, "/clone" => cmd::clone::cmd_clone(&mut ctx, &parts).await?, "/panel" => cmd::panel::cmd_panel(&mut ctx, &parts).await?, + "/vigil" => cmd::vigil_cmd::cmd_vigil(&mut ctx, &parts, text).await?, "/display" => cmd::panel::cmd_display(&mut ctx, &parts).await?, "/btw" => return cmd::btw::cmd_btw(&mut ctx, &parts).await, "/learn" => return cmd::learn::cmd_learn(&mut ctx, &parts).await, @@ -926,6 +940,7 @@ fn slash_commands() -> Vec<(&'static str, &'static str)> { // (dirge-3p8j). The gated entry made it un-completable / "unknown" in // no-loop builds even though the arm handled it. cmds.push(("/loop", "start, stop, or show a background prompt loop")); + cmds.push(("/vigil", "manage vigil heartbeat/watch triggers")); #[cfg(feature = "dap")] cmds.push(( "/debug", diff --git a/src/ui/tui/panels.rs b/src/ui/tui/panels.rs index cbd375404..f4b12f8b6 100644 --- a/src/ui/tui/panels.rs +++ b/src/ui/tui/panels.rs @@ -16,6 +16,8 @@ use ratatui::layout::Rect; use ratatui::style::{Color as RColor, Style}; use ratatui::widgets::Widget; +#[cfg(feature = "vigil")] +use crate::ui::renderer::VigilStatusRow; use crate::ui::renderer::{LeftPanelInfo, PanelData, SubagentStatusRow}; use super::chat::crossterm_to_ratatui; @@ -165,6 +167,129 @@ impl<'a> Widget for LeftPanel<'a> { } } +/// Left panel widget that displays vigil status rows. +#[cfg(feature = "vigil")] +pub struct VigilLeftPanel<'a> { + data: &'a [VigilStatusRow], + style: Style, +} + +#[cfg(feature = "vigil")] +impl<'a> VigilLeftPanel<'a> { + pub fn new(data: &'a [VigilStatusRow]) -> Self { + Self { + data, + style: Style::default().fg(RColor::Green), + } + } + + pub fn border_style(mut self, style: Style) -> Self { + self.style = style; + self + } +} + +#[cfg(feature = "vigil")] +impl<'a> Widget for VigilLeftPanel<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + paint_vigil_card(buf, area, self.data, self.style); + } +} + +#[cfg(feature = "vigil")] +fn paint_vigil_card(buf: &mut Buffer, area: Rect, data: &[VigilStatusRow], style: Style) { + let dim = RColor::DarkGray; + let green = RColor::Green; + let yellow = RColor::Yellow; + let panel_w = area.width as usize; + let box_w = area.width.saturating_sub(1); + let bs = style; + + let mut dy = LEFT_PANEL_TOP_PAD; + + // DIRGE banner + let banner = "D I R G E"; + if dy < area.height { + let bw = banner.chars().count(); + let bpad = panel_w.saturating_sub(bw) / 2; + buf.set_stringn( + area.x + bpad as u16, + area.y + dy, + banner, + panel_w.saturating_sub(bpad), + style, + ); + } + dy += 2; + + // VIGILS sub-panel + if data.is_empty() { + let h = 4; + if area.y + dy + h <= area.y + area.height { + let sp = SubPanel::new("VIGILS") + .line("· (none)", dim) + .border_style(bs); + sp.render(Rect::new(area.x, area.y + dy, box_w, h), buf); + } + } else { + // Each row: " ● name trigger XXs" + let rows = data.len().min( + (area.y + area.height) + .saturating_sub(area.y + dy) + .saturating_sub(2) as usize, + ); + let h = 2 + rows as u16; + if area.y + dy + h <= area.y + area.height { + let mut sp = SubPanel::new("VIGILS").border_style(bs); + let inner_w = box_w as usize; + for row in data.iter().take(rows) { + let glyph = if row.paused { + ("○", dim) + } else if row.running { + ("●", green) + } else { + ("◐", yellow) + }; + let interval = if row.interval_secs >= 60 { + format!("{}m", row.interval_secs / 60) + } else { + format!("{}s", row.interval_secs) + }; + // Event ticker: "⚡N" when events were recently reaped. + let ev_tick = if row.last_event_count > 0 { + let age = row.last_event_age.as_deref().unwrap_or(""); + format!("⚡{} {}", row.last_event_count, age) + } else { + String::new() + }; + // Layout: " ● name trigger ⚡3 5s 10s" + let rhs = if ev_tick.is_empty() { + format!("{} {}", row.trigger, interval) + } else { + format!("{} {} {}", row.trigger, ev_tick, interval) + }; + let name_limit = inner_w.saturating_sub(6 + rhs.len() + 2); + let name = if row.name.chars().count() > name_limit && name_limit > 3 { + let truncated: String = row + .name + .chars() + .take(name_limit.saturating_sub(1)) + .collect(); + format!("{truncated}…") + } else { + row.name.clone() + }; + let line = format!(" {} {} {}", glyph.0, name, rhs); + sp = sp.line(line, glyph.1); + } + sp.render(Rect::new(area.x, area.y + dy, box_w, h), buf); + } + } +} + /// One row of top padding so the left-panel content doesn't sit /// flush against the unified top frame. Matches the right panel's /// symmetric padding for visual balance. diff --git a/src/ui/tui/scene.rs b/src/ui/tui/scene.rs index 0d8af5b0c..9cd8d4cf2 100644 --- a/src/ui/tui/scene.rs +++ b/src/ui/tui/scene.rs @@ -19,7 +19,11 @@ use super::frame::{ChatBotFrame, TopFrame}; use super::layout::{ LEFT_PANEL_MIN_W, Layout, MAX_INPUT_ROWS, RIGHT_PANEL_MIN_W, overlay_max_rows, }; +#[cfg(feature = "vigil")] +use super::panels::VigilLeftPanel; use super::panels::{LeftPanel, RightPanel}; +#[cfg(feature = "vigil")] +use crate::ui::renderer::VigilStatusRow; use crate::ui::renderer::{ LeftPanelInfo, LineEntry, PanelData, PanelMode, SelectionRange, SubagentStatusRow, }; @@ -50,6 +54,13 @@ pub struct Scene<'a> { /// Current right-panel mode — determines whether to show the debug panel /// or the normal system-info panel on the right side. pub right_panel_mode: PanelMode, + /// Current left-panel mode — determines whether to show vigil status + /// or the normal idle card on the left side. + #[cfg_attr(not(feature = "vigil"), allow(dead_code))] + pub left_panel_mode: PanelMode, + /// Vigil status rows for the left panel when left_panel_mode is Vigil. + #[cfg(feature = "vigil")] + pub vigil_data: &'a [VigilStatusRow], /// dirge-b11: how many entries to skip from the *top* of the /// MODIFIED list (most-recent-first). Carried in Scene so the /// renderer can paint the scrolled view; persisted across @@ -117,12 +128,29 @@ pub fn render_frame(scene: &Scene, f: &mut Frame<'_>) { // Top frame (full width, across left panel + chat + right panel). f.render_widget(TopFrame::new(&layout).style(frame_style), area); - // Left panel — idle card or subagent list. Skip on narrow terminals. + // Left panel — idle card, subagent list, or vigil status. Skip on narrow terminals. if scene.show_left_panel && layout.left_panel.width >= LEFT_PANEL_MIN_W { - f.render_widget( - LeftPanel::new(scene.left_info, scene.subagents).border_style(frame_style), - layout.left_panel, - ); + #[cfg(feature = "vigil")] + { + if scene.left_panel_mode == PanelMode::Vigil { + f.render_widget( + VigilLeftPanel::new(scene.vigil_data).border_style(frame_style), + layout.left_panel, + ); + } else { + f.render_widget( + LeftPanel::new(scene.left_info, scene.subagents).border_style(frame_style), + layout.left_panel, + ); + } + } + #[cfg(not(feature = "vigil"))] + { + f.render_widget( + LeftPanel::new(scene.left_info, scene.subagents).border_style(frame_style), + layout.left_panel, + ); + } } // Chat region (content + │ verticals). @@ -365,6 +393,9 @@ pub fn empty_scene<'a>( #[cfg(feature = "dap")] debug_panel_data: None, right_panel_mode: PanelMode::Auto, + left_panel_mode: PanelMode::Auto, + #[cfg(feature = "vigil")] + vigil_data: &[], modified_offset: 0, left_info, subagents, @@ -619,6 +650,9 @@ mod tests { #[cfg(feature = "dap")] debug_panel_data: None, right_panel_mode: PanelMode::Auto, + left_panel_mode: PanelMode::Auto, + #[cfg(feature = "vigil")] + vigil_data: &[], modified_offset: 0, left_info: &info, subagents: &subs, @@ -987,6 +1021,9 @@ mod tests { #[cfg(feature = "dap")] debug_panel_data: None, right_panel_mode: PanelMode::Auto, + left_panel_mode: PanelMode::Auto, + #[cfg(feature = "vigil")] + vigil_data: &[], modified_offset: 0, left_info: &info, subagents: &subs, @@ -1021,6 +1058,9 @@ mod tests { #[cfg(feature = "dap")] debug_panel_data: None, right_panel_mode: PanelMode::Auto, + left_panel_mode: PanelMode::Auto, + #[cfg(feature = "vigil")] + vigil_data: &[], modified_offset: 0, left_info: &info, subagents: &subs,