Files
Kigi-CLI/crates/codegen/kigi-pager-minimal/src/plan.rs
T
ZacharyZhang-NY a02b555e66 docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
2026-07-23 16:55:39 -04:00

263 lines
9.4 KiB
Rust

//! Minimal-mode plan-approval host (design PR10).
//!
//! The full TUI renders plan approval as a fullscreen line-viewer plus a live
//! feedback prompt. Minimal takes a simpler route: the **whole plan is committed
//! into native scrollback** as a normal conversation block (see
//! [`maybe_commit_plan`]), so it reads and scrolls exactly like the rest of the
//! transcript. The prompt-anchored live region then holds only the decision
//! controls — approve / revise / keep planning — plus the feedback input when
//! revising. Nothing of the plan body is drawn under the prompt.
//!
//! Input routing is `unchanged`: while `line_viewer.is_some()` the agent's input
//! handler already routes keys to `handle_line_viewer_key` (Preview focus:
//! `a` approve / `s`/`Tab` revise / `q` keep planning) and `handle_plan_feedback_key`
//! (Prompt focus: type feedback, `Enter` send, `Esc` back). Minimal keeps the
//! line viewer open (so those keys fire) but renders this compact controls strip
//! in place of the never-drawn fullscreen viewer.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use kigi_tui::app::agent_view::AgentView;
use kigi_tui::app::app_view::{ActiveView, AppView};
use kigi_tui::minimal_api;
use kigi_tui::scrollback::block::RenderBlock;
use kigi_tui::theme::Theme;
use kigi_tui::views::plan_approval_view::PlanApprovalFocus;
use kigi_tui::views::prompt_widget::PromptStyle;
/// The active plan-approval focus, defaulting to `Preview`.
fn focus(agent: &AgentView) -> PlanApprovalFocus {
minimal_api::plan_approval_view(agent)
.map(|p| p.focus)
.unwrap_or(PlanApprovalFocus::Preview)
}
/// Scrollback notice when exit_plan_mode parks with no plan body.
///
/// Kept short and plain (no markdown chrome) so native scrollback reads cleanly
/// under minimal mode's chromeless commit path.
const EMPTY_PLAN_SCROLLBACK: &str = "\
No plan written yet.
Approve to leave plan mode and start implementing, request changes to send the \
agent back to planning, or quit to abandon.";
/// Controls-strip header for the parked plan-approval surface.
fn plan_header(has_plan: bool) -> &'static str {
if has_plan {
"Plan ready for review"
} else {
"No plan written yet"
}
}
/// Body committed into native scrollback for a parked plan approval.
fn plan_scrollback_body(plan_content: Option<&str>) -> String {
plan_content
.filter(|s| !s.trim().is_empty())
.map(str::to_owned)
.unwrap_or_else(|| EMPTY_PLAN_SCROLLBACK.to_owned())
}
/// Commit the active plan into native scrollback, once per plan (and once per
/// revision).
///
/// Minimal has no separate plan pane: the terminal's scrollback *is* the
/// history, so the plan is pushed as an ordinary finalized agent-message block
/// and printed into native scrollback by the normal commit pass — leaving only
/// the decision controls under the prompt. De-duplicated by the plan's
/// `tool_call_id`; a revised plan arrives as a fresh ExitPlanMode with a new id
/// and is committed as its own block. Empty / whitespace-only plans still commit
/// a short notice so the user sees *why* approval is parked (otherwise only the
/// controls strip appears and the session looks stuck).
///
/// NOTE (draw-path state mutation + replay durability): this pushes into
/// `ScrollbackState` from the render path — a deliberate exception, since the
/// plan block must enter the normal commit pipeline. The pushed block is
/// client-render state, not a server event: a resumed session will not replay
/// it, so post-reload `/transcript` shows the plan only through whatever the
/// agent itself messaged. Accepted for v1 (the live session — the mode's whole
/// surface — is consistent).
///
/// Call once per frame from [`crate::draw`], before the commit pass.
pub fn maybe_commit_plan(app: &mut AppView) {
let ActiveView::Agent(id) = &app.active_view else {
return;
};
let id = *id;
// Extract the plan (owned) under a short immutable borrow so the mutable
// scrollback push and the `minimal_state` read/write below don't overlap it.
let plan = app.agents.get(&id).and_then(|agent| {
minimal_api::plan_approval_view(agent).map(|pav| {
let content = plan_scrollback_body(pav.plan_content.as_deref());
(pav.tool_call_id.clone(), content)
})
});
let Some((tool_call_id, content)) = plan else {
return;
};
if minimal_api::minimal_committed_plan_id(app) == Some(tool_call_id.as_str()) {
// already emitted this plan
return;
}
// Mark the plan as emitted only when the block was actually pushed: the
// agent borrow can't fail here (the plan was just extracted from it), but
// if it ever did, stamping the id anyway would treat the plan as committed
// while nothing ever reaches native scrollback.
if let Some(agent) = app.agents.get_mut(&id) {
agent
.scrollback
.push_block(RenderBlock::agent_message(content));
minimal_api::set_minimal_committed_plan_id(app, Some(tool_call_id));
}
}
/// Desired controls-strip height: header + controls + optional feedback input.
pub fn height(agent: &AgentView) -> u16 {
let input = if focus(agent) == PlanApprovalFocus::Prompt {
1
} else {
0
};
// header (1) + controls (1) + input (0/1)
2u16.saturating_add(input)
}
/// Render the compact plan-approval controls strip into `area`. The plan itself
/// lives in native scrollback ([`maybe_commit_plan`]); this only draws the
/// header, the decision hint, and — when revising — the feedback input. Returns
/// the text cursor when the feedback input is focused, else `None`.
pub fn render(
buf: &mut Buffer,
area: Rect,
agent: &mut AgentView,
theme: &Theme,
) -> Option<(u16, u16)> {
if area.height == 0 || area.width < 4 {
return None;
}
let foc = focus(agent);
let input_h: u16 = if foc == PlanApprovalFocus::Prompt {
1
} else {
0
};
// header (1) · controls (1) · input (0/1)
let controls_y = (area.y + area.height).saturating_sub(1 + input_h);
// header
let has_plan = minimal_api::plan_approval_view(agent)
.map(|p| p.has_plan)
.unwrap_or(false);
let header_style = Style::default()
.fg(theme.accent_user)
.bg(Color::Reset)
.add_modifier(Modifier::BOLD);
buf.set_style(
Rect { height: 1, ..area },
Style::default().bg(Color::Reset),
);
buf.set_span(
area.x,
area.y,
&Span::styled(plan_header(has_plan), header_style),
area.width,
);
// controls hint
let has_content = minimal_api::plan_approval_view(agent)
.map(|p| !p.comments.is_empty())
.unwrap_or(false)
|| !agent.prompt.text().trim().is_empty();
// Tab reopens the preview (including the empty-plan placeholder).
let hint = match foc {
PlanApprovalFocus::Prompt if has_content => {
"enter request changes \u{00b7} tab plan \u{00b7} esc back"
}
PlanApprovalFocus::Prompt => "enter approve \u{00b7} tab plan \u{00b7} esc back",
PlanApprovalFocus::Commenting => "enter save comment \u{00b7} esc cancel",
PlanApprovalFocus::Preview => "a approve \u{00b7} s revise \u{00b7} q keep planning",
};
let hint_style = theme.dim().bg(Color::Reset);
let controls_rect = Rect {
x: area.x,
y: controls_y,
width: area.width,
height: 1,
};
buf.set_style(controls_rect, hint_style);
buf.set_span(
area.x,
controls_y,
&Span::styled(hint, hint_style),
area.width,
);
// feedback input (revise mode)
if input_h > 0 {
let row = Rect {
x: area.x,
y: (area.y + area.height).saturating_sub(1),
width: area.width,
height: 1,
};
let style = input_style(theme);
buf.set_style(row, Style::default().bg(theme.bg_visual));
return agent.prompt.draw(buf, row, None, &style, None).cursor_pos;
}
None
}
/// Chromeless prompt style for the feedback editor (the modal supplies framing).
fn input_style(theme: &Theme) -> PromptStyle {
PromptStyle {
focused: true,
show_prefix: false,
vpad_top: 0,
compact: false,
chrome: false,
chrome_pad_left: 0,
chrome_pad_right: 0,
bg_override: Some(theme.bg_visual),
accent_color_override: None,
border_color_override: None,
prefix_override: None,
placeholder_override: None,
show_accent_line: false,
show_borders: false,
title: None,
image_preview: true,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_plan_header_is_explicit() {
assert_eq!(plan_header(true), "Plan ready for review");
assert_eq!(plan_header(false), "No plan written yet");
}
#[test]
fn empty_plan_scrollback_uses_notice_not_silence() {
let body = plan_scrollback_body(None);
assert!(body.contains("No plan written yet"));
assert!(body.contains("Approve"));
let whitespace = plan_scrollback_body(Some(" \n\t "));
assert_eq!(whitespace, body, "whitespace-only counts as empty");
let real = plan_scrollback_body(Some("# Plan\n- do it"));
assert_eq!(real, "# Plan\n- do it");
}
}