Skip to content

Commit e146713

Browse files
authored
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).
1 parent 7ed354d commit e146713

22 files changed

Lines changed: 1005 additions & 16 deletions

File tree

src/main.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2059,9 +2059,9 @@ async fn main() -> anyhow::Result<()> {
20592059
#[cfg(feature = "vigil")]
20602060
let (
20612061
mut _vigil_keeper,
2062-
_vigil_wake_rx,
2062+
vigil_wake_rx,
20632063
mut vigil_observance_rx,
2064-
_vigil_ctl_tx,
2064+
vigil_ctl_tx,
20652065
mut vigil_hook_rx,
20662066
) = {
20672067
if !cli.vigil_mode && !cli.vigil_once {
@@ -2288,6 +2288,14 @@ async fn main() -> anyhow::Result<()> {
22882288
dialog_rx,
22892289
subagent_chat_rx,
22902290
sysload,
2291+
#[cfg(feature = "vigil")]
2292+
vigil_wake_rx,
2293+
#[cfg(feature = "vigil")]
2294+
vigil_observance_rx,
2295+
#[cfg(feature = "vigil")]
2296+
vigil_ctl_tx,
2297+
#[cfg(feature = "vigil")]
2298+
vigil_hook_rx,
22912299
)
22922300
.await?;
22932301

src/plugin/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,16 +140,23 @@ pub enum PostDoneAction {
140140
LoopIter,
141141
LoopStop,
142142
Idle,
143+
#[cfg(feature = "vigil")]
144+
VigilSleep,
143145
}
144146

145147
pub fn decide_post_done_action(
146148
followup: Option<String>,
147149
loop_active: bool,
148150
loop_should_stop: bool,
151+
#[cfg(feature = "vigil")] vigil_active: bool,
149152
) -> PostDoneAction {
150153
if let Some(text) = followup {
151154
return PostDoneAction::Followup(text);
152155
}
156+
#[cfg(feature = "vigil")]
157+
if vigil_active {
158+
return PostDoneAction::VigilSleep;
159+
}
153160
if !loop_active {
154161
return PostDoneAction::Idle;
155162
}

src/plugin/mod_tests.rs

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -135,29 +135,75 @@ fn test_post_done_action() {
135135
// Plugin followup must take precedence over the loop iteration
136136
// so we never silently drop a queued prompt.
137137
let followup = Some("retry".to_string());
138+
#[cfg(feature = "vigil")]
139+
let vigil_off = false;
138140
assert_eq!(
139-
decide_post_done_action(followup.clone(), true, false),
141+
decide_post_done_action(
142+
followup.clone(),
143+
true,
144+
false,
145+
#[cfg(feature = "vigil")]
146+
vigil_off
147+
),
140148
PostDoneAction::Followup("retry".into())
141149
);
142150
assert_eq!(
143-
decide_post_done_action(followup.clone(), false, false),
151+
decide_post_done_action(
152+
followup.clone(),
153+
false,
154+
false,
155+
#[cfg(feature = "vigil")]
156+
vigil_off
157+
),
144158
PostDoneAction::Followup("retry".into())
145159
);
146160
// Loop iteration only when no followup.
147161
assert_eq!(
148-
decide_post_done_action(None, true, false),
162+
decide_post_done_action(
163+
None,
164+
true,
165+
false,
166+
#[cfg(feature = "vigil")]
167+
vigil_off
168+
),
149169
PostDoneAction::LoopIter
150170
);
151171
// Loop stop only when no followup and should_stop.
152172
assert_eq!(
153-
decide_post_done_action(None, true, true),
173+
decide_post_done_action(
174+
None,
175+
true,
176+
true,
177+
#[cfg(feature = "vigil")]
178+
vigil_off
179+
),
154180
PostDoneAction::LoopStop
155181
);
156182
// Idle: nothing to do.
157183
assert_eq!(
158-
decide_post_done_action(None, false, false),
184+
decide_post_done_action(
185+
None,
186+
false,
187+
false,
188+
#[cfg(feature = "vigil")]
189+
vigil_off
190+
),
159191
PostDoneAction::Idle
160192
);
193+
194+
#[cfg(feature = "vigil")]
195+
{
196+
// VigilSleep: vigil active outranks loop.
197+
assert_eq!(
198+
decide_post_done_action(None, true, false, true),
199+
PostDoneAction::VigilSleep
200+
);
201+
// Followup still beats vigil.
202+
assert_eq!(
203+
decide_post_done_action(followup.clone(), false, false, true),
204+
PostDoneAction::Followup("retry".into())
205+
);
206+
}
161207
}
162208

163209
#[test]

src/ui/mod.rs

Lines changed: 159 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ use crate::ui::events::{render_session, sanitize_output};
9393
use crate::ui::input::InputEditor;
9494
use crate::ui::keymap::{KeyAction, Keymaps};
9595
use crate::ui::panel_render::{build_left_panel_info, build_panel_data};
96+
#[cfg(feature = "vigil")]
97+
use crate::ui::renderer::VigilStatusRow;
9698
use crate::ui::renderer::{LineEntry, Renderer};
9799
use crate::ui::search_rewind::{
98100
allow_always_downgrade_reason, is_placeholder_pattern, open_rewind_picker, rewind_session,
@@ -281,6 +283,16 @@ pub async fn run_interactive(
281283
// ui-redesign: shared system-load snapshot. Polled in the
282284
// background; read at panel paint time. Cheap clone (Arc bump).
283285
sysload: crate::ui::sysload::SharedSysLoad,
286+
#[cfg(feature = "vigil")] mut vigil_wake_rx: Option<tokio::sync::mpsc::UnboundedReceiver<()>>,
287+
#[cfg(feature = "vigil")] mut vigil_observance_rx: Option<
288+
tokio::sync::mpsc::Receiver<crate::extras::vigil::reaper::Observance>,
289+
>,
290+
#[cfg(feature = "vigil")] vigil_ctl_tx: Option<
291+
tokio::sync::mpsc::Sender<crate::extras::vigil::types::VigilCtl>,
292+
>,
293+
#[cfg(feature = "vigil")] mut vigil_hook_rx: Option<
294+
tokio::sync::mpsc::Receiver<crate::extras::vigil::types::HookDispatchRequest>,
295+
>,
284296
) -> anyhow::Result<()> {
285297
let _guard = TerminalGuard::new(cfg.keyboard_enhancement.unwrap_or(true))?;
286298

@@ -538,6 +550,13 @@ pub async fn run_interactive(
538550
#[cfg(feature = "loop")]
539551
let mut loop_state: Option<crate::extras::r#loop::LoopState> = None;
540552

553+
#[cfg(feature = "vigil")]
554+
let mut vigil_state: Option<crate::extras::vigil::VigilState> =
555+
Some(crate::extras::vigil::VigilState {
556+
active: vigil_wake_rx.is_some(),
557+
pending_observance: None,
558+
});
559+
541560
// Snapshot plugin-registered shortcuts (P9c). Seeded at UI
542561
// startup; refreshed at the top of each event loop iteration
543562
// (M2) so a plugin that registers a shortcut from a hook —
@@ -1652,6 +1671,36 @@ pub async fn run_interactive(
16521671
gitstat.snapshot(),
16531672
));
16541673
}
1674+
#[cfg(feature = "vigil")]
1675+
{
1676+
if let Some(ref vigil_ctl) = vigil_ctl_tx {
1677+
let (tx, rx) = tokio::sync::oneshot::channel();
1678+
let _ = vigil_ctl
1679+
.send(crate::extras::vigil::types::VigilCtl::StatusReq { respond_to: tx })
1680+
.await;
1681+
if let Ok(statuses) = rx.await {
1682+
let rows: Vec<VigilStatusRow> = statuses
1683+
.into_iter()
1684+
.map(|s| VigilStatusRow {
1685+
name: s.name,
1686+
trigger: s.trigger.as_str().to_string(),
1687+
interval_secs: s.reap_interval_secs,
1688+
running: s.running,
1689+
paused: s.paused,
1690+
last_event_count: s.last_event_count,
1691+
last_event_age: s.last_event_at.and_then(|ts| {
1692+
chrono::DateTime::parse_from_rfc3339(&ts).ok().map(|dt| {
1693+
let elapsed =
1694+
chrono::Utc::now().signed_duration_since(dt.to_utc());
1695+
crate::ui::panel_data::format_duration_short(elapsed)
1696+
})
1697+
}),
1698+
})
1699+
.collect();
1700+
renderer.set_vigil_status(rows);
1701+
}
1702+
}
1703+
}
16551704
}
16561705

16571706
// H-R1: loop-top PM acquisitions use `try_lock` so a
@@ -1775,6 +1824,28 @@ pub async fn run_interactive(
17751824
}
17761825
}
17771826

1827+
// Drain vigil plugin hook dispatch requests (on-vigil-event,
1828+
// on-vigil-reap) every iteration rather than only after a
1829+
// successful observance wake. Rite failures, paused vigils, and
1830+
// commands-mode dispatches never wake the loop, so their hooks
1831+
// would otherwise sit undelivered.
1832+
#[cfg(feature = "vigil")]
1833+
if let Some(ref mut hook_rx) = vigil_hook_rx {
1834+
while let Ok(req) = hook_rx.try_recv() {
1835+
#[cfg(feature = "plugin")]
1836+
if let Some(pm) = plugin_manager {
1837+
let pm = pm.clone();
1838+
let hook = req.hook_name;
1839+
let ctx = req.context;
1840+
tokio::task::spawn_blocking(move || {
1841+
pm.lock_ignore_poison().dispatch_tool_hook(&hook, &ctx)
1842+
})
1843+
.await
1844+
.ok();
1845+
}
1846+
}
1847+
}
1848+
17781849
// #387: single paint per event. Render the model (the previous
17791850
// event's mutations + this iteration's loop-top updates) exactly
17801851
// once, THEN block on the next event. Because every handler returns
@@ -1786,6 +1857,22 @@ pub async fn run_interactive(
17861857
// mount-timer select! arm can move it into its async block.
17871858
let mount_deadline = ui.shell_mount_deadline;
17881859

1860+
// When vigil is active, slow the idle poll from 20Hz to 1Hz so the
1861+
// CPU isn't constantly waking during a quiet observance window.
1862+
#[cfg(feature = "vigil")]
1863+
let idle_sleep_ms: u64 = if vigil_state.as_ref().is_some_and(|vs| vs.active) {
1864+
1000
1865+
} else {
1866+
50
1867+
};
1868+
#[cfg(not(feature = "vigil"))]
1869+
let idle_sleep_ms: u64 = 50;
1870+
1871+
// When vigil is compiled out, declare a dummy wake receiver so
1872+
// the vigil select! arm is syntactically present but inert.
1873+
#[cfg(not(feature = "vigil"))]
1874+
let mut vigil_wake_rx: Option<tokio::sync::mpsc::UnboundedReceiver<()>> = None;
1875+
17891876
tokio::select! {
17901877
// #387: poll arms in order so USER INPUT takes priority — when a
17911878
// keystroke and an agent event are both ready, the keystroke is
@@ -2959,7 +3046,7 @@ pub async fn run_interactive(
29593046
// /help) have no UserMessage event, so we keep the echo.
29603047
write_user_lines(&mut renderer, &text)?;
29613048
renderer.write_line("", Color::White)?;
2962-
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;
3049+
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;
29633050
match result {
29643051
Ok(SlashOutcome::DeferCompress { instructions }) => {
29653052
let instructions = instructions.as_deref().and_then(|s| {
@@ -3606,6 +3693,10 @@ pub async fn run_interactive(
36063693
state: &mut loop_state,
36073694
label: &mut ui.loop_label,
36083695
};
3696+
#[cfg(feature = "vigil")]
3697+
let vigil_bits = run_handlers::done::VigilBits {
3698+
state: &mut vigil_state,
3699+
};
36093700
run_handlers::handle_done(
36103701
&mut ctx,
36113702
response,
@@ -3626,6 +3717,8 @@ pub async fn run_interactive(
36263717
&mut ui.done_phase,
36273718
#[cfg(feature = "loop")]
36283719
loop_bits,
3720+
#[cfg(feature = "vigil")]
3721+
vigil_bits,
36293722
).await?;
36303723
}
36313724
AgentEvent::Usage {
@@ -4098,6 +4191,10 @@ pub async fn run_interactive(
40984191
state: &mut loop_state,
40994192
label: &mut ui.loop_label,
41004193
};
4194+
#[cfg(feature = "vigil")]
4195+
let vigil_bits = run_handlers::done::VigilBits {
4196+
state: &mut vigil_state,
4197+
};
41014198
run_handlers::done::finish_done(
41024199
&mut ctx,
41034200
result.response,
@@ -4116,6 +4213,8 @@ pub async fn run_interactive(
41164213
plugin_manager,
41174214
#[cfg(feature = "loop")]
41184215
loop_bits,
4216+
#[cfg(feature = "vigil")]
4217+
vigil_bits,
41194218
)
41204219
.await?;
41214220
}
@@ -5266,9 +5365,67 @@ pub async fn run_interactive(
52665365
// active path already re-asserts.
52675366
_ = tokio::time::sleep(tokio::time::Duration::from_secs(1)), if !ui.is_running => {
52685367
renderer.reassert_terminal_modes();
5368+
},
5369+
// Vigil wake — triggered by the reaper after a
5370+
// successful observance. The vigils may be disabled
5371+
// at compile time; the `if vigil_wake_rx.is_some()`
5372+
// guard ensures the arm is inert when vigil is off.
5373+
_ = async {
5374+
match &mut vigil_wake_rx {
5375+
Some(rx) => rx.recv().await,
5376+
None => std::future::pending::<Option<()>>().await,
5377+
}
5378+
}, if vigil_wake_rx.is_some() => {
5379+
#[cfg(feature = "vigil")]
5380+
{
5381+
if !ui.is_running
5382+
&& let Some(ref mut rx) = vigil_observance_rx
5383+
&& let Ok(obs) = rx.try_recv()
5384+
{
5385+
// Store observance metadata so the post-turn
5386+
// handler can dispatch on-vigil-observance
5387+
// with :response and :exit after the agent turn.
5388+
if let Some(ref mut vs) = vigil_state {
5389+
vs.pending_observance = Some(
5390+
crate::extras::vigil::PendingObservance {
5391+
vigil_name: obs.vigil_name.clone(),
5392+
event_count: obs.event_count,
5393+
running: obs.running.clone(),
5394+
},
5395+
);
5396+
}
5397+
let prompt = if obs.prompt.is_empty() {
5398+
format!("[vigil] {} - {} event(s)", obs.vigil_name, obs.event_count)
5399+
} else {
5400+
obs.prompt.clone()
5401+
};
5402+
ui.last_user_prompt.clone_from(&prompt);
5403+
let history = crate::agent::runner::convert_history(session);
5404+
session.add_message(MessageRole::User, &prompt);
5405+
begin_snapshot_turn(session);
5406+
let runner = agent.clone().spawn_runner(
5407+
crate::provider::Prompt::text(
5408+
crate::agent::tools::background::prepend_pending_notifications(
5409+
&prompt,
5410+
bg_store.as_ref(),
5411+
),
5412+
),
5413+
history,
5414+
Some(ui.interjection_queue.clone()),
5415+
Some(session.assets_dir()),
5416+
);
5417+
runner.install_into(
5418+
&mut ui.agent_rx,
5419+
&mut ui.agent_abort,
5420+
&mut ui.agent_interject,
5421+
&mut ui.agent_cancel,
5422+
&mut ui.is_running,
5423+
);
5424+
}
5425+
}
52695426
}
52705427
else => {
5271-
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
5428+
tokio::time::sleep(tokio::time::Duration::from_millis(idle_sleep_ms)).await;
52725429
}
52735430
}
52745431
}

0 commit comments

Comments
 (0)