The first Ctrl+C clears the draft; the second did nothing and left the user in comment mode. Both the approval and the casual preview paths now cancel.
888 lines
36 KiB
Rust
888 lines
36 KiB
Rust
//! Plan surfaces: plan chip/preview, plan approval + feedback, and casual
|
|
//! plan commenting.
|
|
use super::AgentView;
|
|
#[cfg(test)]
|
|
use super::{ActivePane, InputMode, test_fixtures};
|
|
#[cfg(test)]
|
|
use crate::actions::ActionRegistry;
|
|
use crate::app::actions::Action;
|
|
use crate::app::app_view::InputOutcome;
|
|
use crate::views::file_search::line_viewer::LineViewerState;
|
|
use crate::views::list_pane::ListItem;
|
|
use crate::views::plan_approval_view::{PlanApprovalFocus, PlanComment, PlanReviewSource};
|
|
use crate::views::prompt_widget::{EnterOutcome, PromptEvent};
|
|
#[cfg(test)]
|
|
use crossterm::event::KeyModifiers;
|
|
use crossterm::event::{KeyCode, KeyEvent};
|
|
impl AgentView {
|
|
fn plan_file_path(&self) -> Option<std::path::PathBuf> {
|
|
let session_id = self.session.session_id.as_ref()?;
|
|
let cwd_str = self.session.cwd.to_string_lossy().into_owned();
|
|
let encoded_cwd = urlencoding::encode(&cwd_str);
|
|
Some(
|
|
kigi_shell::util::kigi_home::kigi_home()
|
|
.join("sessions")
|
|
.join(encoded_cwd.as_ref())
|
|
.join(session_id.0.as_ref())
|
|
.join("plan.md"),
|
|
)
|
|
}
|
|
pub(super) fn is_plan_viewer(&self) -> bool {
|
|
self.line_viewer.as_ref().is_some_and(|v| {
|
|
v.kind == crate::views::file_search::line_viewer::LineViewerKind::PlanPreview
|
|
})
|
|
}
|
|
/// True while the user composes a comment in the *casual* plan preview
|
|
/// (the modal opened with no `plan_approval_view`). Mirrors the
|
|
/// `pav.focus == Commenting` check of the plan-approval path so the
|
|
/// prompt/footer behave identically across both modes.
|
|
pub(super) fn is_casual_commenting(&self) -> bool {
|
|
self.plan_approval_view.is_none()
|
|
&& self.is_plan_viewer()
|
|
&& self.casual_commenting_range.is_some()
|
|
}
|
|
/// Whether the prompt "auto" (LLM classifier mode) flag should render:
|
|
/// only when the session is in auto mode and neither yolo (always-approve
|
|
/// wins) nor plan is active.
|
|
pub(super) fn auto_flag_visible(&self, effective_plan: bool) -> bool {
|
|
self.session.is_auto() && !self.session.is_yolo() && !effective_plan
|
|
}
|
|
fn plan_preview_available(&self) -> bool {
|
|
self.plan_body_for_preview().is_some()
|
|
}
|
|
/// Visible while plan mode is active, or always when `show_plan_chip` is
|
|
/// set in `pager.toml`; hidden by default once the user exits plan mode.
|
|
pub(super) fn should_show_plan_chip(
|
|
&self,
|
|
appearance: &crate::appearance::AppearanceConfig,
|
|
) -> bool {
|
|
(self.plan_mode_active || appearance.show_plan_chip) && self.plan_preview_available()
|
|
}
|
|
fn inline_plan_content(&self) -> Option<&str> {
|
|
self.plan_approval_view
|
|
.as_ref()
|
|
.filter(|p| p.source == PlanReviewSource::Inline)
|
|
.and_then(|p| p.plan_content.as_deref())
|
|
.filter(|s| !s.trim().is_empty())
|
|
}
|
|
/// Prefers content carried on the approval request (inline plan-creation or
|
|
/// the shell-read file body), then falls back to the on-disk plan file.
|
|
/// Request body first keeps file-backed previews working when path
|
|
/// resolution fails or the file disappears between intercept and open.
|
|
fn plan_body_for_preview(&self) -> Option<String> {
|
|
if let Some(content) = self
|
|
.plan_approval_view
|
|
.as_ref()
|
|
.and_then(|p| p.plan_content.as_deref())
|
|
.filter(|s| !s.trim().is_empty())
|
|
{
|
|
return Some(content.to_owned());
|
|
}
|
|
if let Some(content) = self
|
|
.latest_inline_plan_content
|
|
.as_deref()
|
|
.filter(|s| !s.trim().is_empty())
|
|
{
|
|
return Some(content.to_owned());
|
|
}
|
|
self.plan_file_path()
|
|
.and_then(|p| std::fs::read_to_string(p).ok())
|
|
.filter(|s| !s.trim().is_empty())
|
|
}
|
|
/// Open the plan preview when content exists, or when plan approval is
|
|
/// parked with an empty body (so the decision surface always pops).
|
|
pub(crate) fn show_plan_preview_if_available(&mut self) {
|
|
if self.plan_preview_available() || self.plan_approval_view.is_some() {
|
|
self.show_plan_preview();
|
|
}
|
|
}
|
|
/// When plan approval is parked without a body, opens a placeholder
|
|
/// preview so the user always sees a decision surface (a/s/q) instead of
|
|
/// a dead "Waiting on plan approval" line with a no-op Tab:plan.
|
|
pub fn show_plan_preview(&mut self) {
|
|
let body = self.plan_body_for_preview();
|
|
let approval_empty = self
|
|
.plan_approval_view
|
|
.as_ref()
|
|
.is_some_and(|p| !p.has_plan);
|
|
let Some(mut viewer) = (if let Some(content) = body {
|
|
LineViewerState::open_markdown_content("plan.md", content, None)
|
|
} else if approval_empty {
|
|
LineViewerState::open_markdown_content(
|
|
"plan.md",
|
|
crate::views::plan_approval_view::EMPTY_PLAN_PLACEHOLDER.to_owned(),
|
|
None,
|
|
)
|
|
} else if let Some(plan_path) = self.plan_file_path() {
|
|
LineViewerState::open_markdown(&plan_path, None)
|
|
} else {
|
|
None
|
|
}) else {
|
|
self.show_toast("No plan written yet.");
|
|
return;
|
|
};
|
|
viewer.kind = crate::views::file_search::line_viewer::LineViewerKind::PlanPreview;
|
|
viewer.title_override = Some(if approval_empty {
|
|
"plan.md (empty)".to_string()
|
|
} else {
|
|
"plan.md".to_string()
|
|
});
|
|
viewer.fullscreen = true;
|
|
{
|
|
let plan = viewer.plan_mut();
|
|
plan.show_action_buttons = self.plan_approval_view.is_none();
|
|
plan.feedback_active = self.plan_approval_view.is_some();
|
|
}
|
|
if let Some(ref pav) = self.plan_approval_view
|
|
&& !pav.comments.is_empty()
|
|
{
|
|
viewer.rebuild_with_comments(&pav.comments);
|
|
} else if !self.plan_comments.is_empty() {
|
|
viewer.rebuild_with_comments(&self.plan_comments);
|
|
}
|
|
self.line_viewer = Some(viewer);
|
|
}
|
|
/// Drive the agent into casual-commenting state (line viewer open in
|
|
/// plan-preview mode + `casual_commenting_range` armed) so the paste
|
|
/// plan-feedback arm is reachable from a unit test without the real
|
|
/// keystroke pipeline.
|
|
#[cfg(test)]
|
|
pub(crate) fn enter_casual_commenting_for_test(&mut self) {
|
|
let mut viewer =
|
|
crate::views::file_search::line_viewer::LineViewerState::open_markdown_content(
|
|
"test.md",
|
|
"hello\n".to_owned(),
|
|
None,
|
|
)
|
|
.expect("fixture must open the line viewer");
|
|
viewer.kind = crate::views::file_search::line_viewer::LineViewerKind::PlanPreview;
|
|
self.line_viewer = Some(viewer);
|
|
self.casual_commenting_range = Some(0..1);
|
|
}
|
|
pub(crate) fn approve_plan(&mut self) -> InputOutcome {
|
|
let Some(mut pav) = self.plan_approval_view.take() else {
|
|
return InputOutcome::Changed;
|
|
};
|
|
let review_comments = if !pav.comments.is_empty() {
|
|
let formatted = pav.format_feedback(None);
|
|
if formatted.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(format!(
|
|
"The user approved the plan with the following review comments:\n\n{}",
|
|
formatted
|
|
))
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
pav.send_approved();
|
|
self.latest_inline_plan_content = None;
|
|
self.plan_next_comment_id = pav.next_comment_id;
|
|
self.prompt.restore(pav.stashed_prompt);
|
|
self.line_viewer = None;
|
|
self.casual_commenting_range = None;
|
|
self.casual_editing_comment_id = None;
|
|
{}
|
|
if let Some(text) = review_comments {
|
|
return InputOutcome::Action(Action::Interject {
|
|
text,
|
|
images: vec![],
|
|
});
|
|
}
|
|
InputOutcome::Changed
|
|
}
|
|
pub(crate) fn abandon_plan(&mut self) -> InputOutcome {
|
|
let Some(mut pav) = self.plan_approval_view.take() else {
|
|
return InputOutcome::Changed;
|
|
};
|
|
pav.send_abandoned();
|
|
self.plan_mode_pending = Some(false);
|
|
self.latest_inline_plan_content = None;
|
|
self.plan_next_comment_id = pav.next_comment_id;
|
|
self.prompt.restore(pav.stashed_prompt);
|
|
self.line_viewer = None;
|
|
self.casual_commenting_range = None;
|
|
self.casual_editing_comment_id = None;
|
|
{}
|
|
InputOutcome::Changed
|
|
}
|
|
fn send_plan_feedback(&mut self, feedback: Option<String>) -> InputOutcome {
|
|
let Some(mut pav) = self.plan_approval_view.take() else {
|
|
return InputOutcome::Changed;
|
|
};
|
|
let formatted = pav.format_feedback(feedback.as_deref());
|
|
let to_send = if formatted.trim().is_empty() {
|
|
feedback
|
|
} else {
|
|
Some(formatted)
|
|
};
|
|
if crate::app::minimal_mode_active()
|
|
&& let Some(msg) = to_send.as_deref().map(str::trim).filter(|s| !s.is_empty())
|
|
{
|
|
self.scrollback
|
|
.push_block(crate::scrollback::RenderBlock::user_prompt(msg.to_string()));
|
|
}
|
|
pav.send_cancelled(to_send);
|
|
if pav.source == PlanReviewSource::Inline {
|
|
self.latest_inline_plan_content = None;
|
|
}
|
|
self.plan_next_comment_id = pav.next_comment_id;
|
|
self.prompt.restore(pav.stashed_prompt);
|
|
self.line_viewer = None;
|
|
self.prompt.textarea.cancel_undo_group();
|
|
self.show_toast("Plan revision sent.");
|
|
{}
|
|
InputOutcome::Changed
|
|
}
|
|
pub(crate) fn reopen_plan_approval(&mut self) {
|
|
if let Some(ref mut pav) = self.plan_approval_view {
|
|
pav.stashed_prompt = self.prompt.stash();
|
|
pav.focus = PlanApprovalFocus::Preview;
|
|
}
|
|
self.prompt.set_text("");
|
|
self.show_plan_preview_if_available();
|
|
if self.line_viewer.is_none() {
|
|
if let Some(ref mut pav) = self.plan_approval_view {
|
|
pav.focus = PlanApprovalFocus::Prompt;
|
|
}
|
|
} else if let Some(ref mut viewer) = self.line_viewer {
|
|
viewer.plan_mut().feedback_active = true;
|
|
}
|
|
}
|
|
/// Called whenever focus leaves the prompt without an explicit save or
|
|
/// cancel (e.g. Tab back to Preview, click into the modal).
|
|
fn discard_in_progress_comment(&mut self) {
|
|
if let Some(ref mut pav) = self.plan_approval_view {
|
|
pav.commenting_range = None;
|
|
pav.editing_comment_id = None;
|
|
pav.stashed_feedback_prompt = None;
|
|
}
|
|
self.prompt.set_text("");
|
|
}
|
|
fn cancel_plan_comment(&mut self) -> InputOutcome {
|
|
let stashed = if let Some(ref mut pav) = self.plan_approval_view {
|
|
pav.focus = PlanApprovalFocus::Preview;
|
|
pav.editing_comment_id = None;
|
|
pav.commenting_range = None;
|
|
pav.stashed_feedback_prompt.take()
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(stashed) = stashed {
|
|
self.prompt.restore(stashed);
|
|
} else {
|
|
self.prompt.set_text("");
|
|
}
|
|
InputOutcome::Changed
|
|
}
|
|
/// Ctrl+C on an empty draft; the first one clears it.
|
|
fn ctrl_c_cancels_empty_draft(&self, key: &KeyEvent) -> bool {
|
|
crate::key!('c', CONTROL).matches(key) && self.prompt.text().is_empty()
|
|
}
|
|
pub(super) fn handle_plan_feedback_key(&mut self, key: &KeyEvent) -> InputOutcome {
|
|
let is_commenting = self
|
|
.plan_approval_view
|
|
.as_ref()
|
|
.is_some_and(|pav| pav.focus == PlanApprovalFocus::Commenting);
|
|
if key.code == KeyCode::Tab && key.modifiers.is_empty() {
|
|
let focus = self.plan_approval_view.as_ref().map(|p| p.focus);
|
|
match focus {
|
|
Some(PlanApprovalFocus::Prompt) | Some(PlanApprovalFocus::Commenting) => {
|
|
if self.line_viewer.is_none() {
|
|
self.show_plan_preview_if_available();
|
|
}
|
|
if let Some(ref mut pav) = self.plan_approval_view {
|
|
pav.focus = PlanApprovalFocus::Preview;
|
|
}
|
|
if let Some(ref mut viewer) = self.line_viewer {
|
|
viewer.plan_mut().feedback_active = true;
|
|
}
|
|
}
|
|
Some(PlanApprovalFocus::Preview) => {
|
|
if let Some(ref mut pav) = self.plan_approval_view {
|
|
pav.focus = PlanApprovalFocus::Prompt;
|
|
}
|
|
}
|
|
None => {}
|
|
}
|
|
if is_commenting {
|
|
self.discard_in_progress_comment();
|
|
}
|
|
return InputOutcome::Changed;
|
|
}
|
|
if key.code == KeyCode::Esc {
|
|
if self.prompt.file_search_visible() {
|
|
self.prompt.file_search.clear_context();
|
|
return InputOutcome::Changed;
|
|
}
|
|
if is_commenting {
|
|
return self.cancel_plan_comment();
|
|
}
|
|
if let Some(ref mut pav) = self.plan_approval_view {
|
|
pav.focus = PlanApprovalFocus::Preview;
|
|
}
|
|
return InputOutcome::Changed;
|
|
}
|
|
if is_commenting && self.ctrl_c_cancels_empty_draft(key) {
|
|
return self.cancel_plan_comment();
|
|
}
|
|
match self.prompt.route_enter(key) {
|
|
EnterOutcome::NewlineInserted => return InputOutcome::Changed,
|
|
EnterOutcome::Submit => {
|
|
if is_commenting {
|
|
return self.save_plan_comment();
|
|
}
|
|
let text = self.prompt.text().to_string();
|
|
let has_comments = self
|
|
.plan_approval_view
|
|
.as_ref()
|
|
.is_some_and(|pav| !pav.comments.is_empty());
|
|
let prompt_focused = self
|
|
.plan_approval_view
|
|
.as_ref()
|
|
.is_some_and(|pav| pav.focus == PlanApprovalFocus::Prompt);
|
|
if prompt_focused {
|
|
if text.trim().is_empty() && !has_comments {
|
|
return self.approve_plan();
|
|
}
|
|
let freeform = if text.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(text)
|
|
};
|
|
return self.send_plan_feedback(freeform);
|
|
}
|
|
return InputOutcome::Changed;
|
|
}
|
|
EnterOutcome::PassThrough => {}
|
|
}
|
|
match self.prompt.handle_key(key) {
|
|
PromptEvent::Edited => {
|
|
if let Some(req) = self.prompt.pending_viewer_request.take() {
|
|
self.open_line_viewer(&req.path, req.initial_range);
|
|
}
|
|
InputOutcome::Changed
|
|
}
|
|
PromptEvent::Ignored => InputOutcome::Changed,
|
|
}
|
|
}
|
|
pub(super) fn enter_plan_commenting(&mut self) -> InputOutcome {
|
|
let viewer = match self.line_viewer.as_mut() {
|
|
Some(v) => v,
|
|
None => return InputOutcome::Changed,
|
|
};
|
|
if let Some(vi) = viewer.list_state.selected_index() {
|
|
let pi = viewer.list_state.to_physical(vi);
|
|
if let Some(comment_id) = viewer.lines.get(pi).and_then(|item| item.comment_id())
|
|
&& let Some(pav) = self.plan_approval_view.as_mut()
|
|
&& let Some(comment) = pav.comments.iter().find(|c| c.id == comment_id)
|
|
{
|
|
let comment_text = comment.text.clone();
|
|
let comment_range = comment.line_range.clone();
|
|
pav.stashed_feedback_prompt = Some(self.prompt.stash());
|
|
pav.editing_comment_id = Some(comment_id);
|
|
pav.commenting_range = Some(comment_range);
|
|
pav.focus = PlanApprovalFocus::Commenting;
|
|
self.prompt.set_text(&comment_text);
|
|
return InputOutcome::Changed;
|
|
}
|
|
}
|
|
let range = viewer.selected_line_range();
|
|
let Some(range) = range else {
|
|
return InputOutcome::Changed;
|
|
};
|
|
if viewer.list_state.visual_mode {
|
|
let start_vi = viewer.list_state.multi_range().map(|r| r.start);
|
|
if let Some(start_vi) = start_vi {
|
|
let start_pi = viewer.list_state.to_physical(start_vi);
|
|
let start_id = viewer.lines.get(start_pi).map(|l| l.stable_id());
|
|
viewer.list_state.exit_visual_mode();
|
|
if let Some(id) = start_id {
|
|
viewer.list_state.select_by_id(id);
|
|
}
|
|
} else {
|
|
viewer.list_state.exit_visual_mode();
|
|
}
|
|
}
|
|
if let Some(ref mut pav) = self.plan_approval_view {
|
|
pav.stashed_feedback_prompt = Some(self.prompt.stash());
|
|
pav.commenting_range = Some(range);
|
|
pav.editing_comment_id = None;
|
|
pav.focus = PlanApprovalFocus::Commenting;
|
|
}
|
|
self.prompt.set_text("");
|
|
InputOutcome::Changed
|
|
}
|
|
fn save_plan_comment(&mut self) -> InputOutcome {
|
|
let text = self.prompt.text().to_string();
|
|
if text.trim().is_empty() {
|
|
return InputOutcome::Changed;
|
|
}
|
|
let pav = match self.plan_approval_view.as_mut() {
|
|
Some(pav) => pav,
|
|
None => return InputOutcome::Changed,
|
|
};
|
|
let range = match pav.commenting_range.take() {
|
|
Some(r) => r,
|
|
None => return InputOutcome::Changed,
|
|
};
|
|
if let Some(edit_id) = pav.editing_comment_id.take() {
|
|
if let Some(comment) = pav.comments.iter_mut().find(|c| c.id == edit_id) {
|
|
comment.text = text;
|
|
comment.line_range = range;
|
|
}
|
|
} else {
|
|
let id = pav.next_comment_id;
|
|
pav.next_comment_id += 1;
|
|
pav.comments.push(PlanComment {
|
|
id,
|
|
line_range: range,
|
|
text,
|
|
});
|
|
}
|
|
pav.focus = PlanApprovalFocus::Preview;
|
|
let comments = pav.comments.clone();
|
|
if let Some(ref mut viewer) = self.line_viewer {
|
|
viewer.rebuild_with_comments(&comments);
|
|
}
|
|
if let Some(stashed) = pav.stashed_feedback_prompt.take() {
|
|
self.prompt.restore(stashed);
|
|
} else {
|
|
self.prompt.set_text("");
|
|
}
|
|
InputOutcome::Changed
|
|
}
|
|
pub(super) fn delete_plan_comment_at_cursor(&mut self) -> InputOutcome {
|
|
let viewer = match self.line_viewer.as_ref() {
|
|
Some(v) => v,
|
|
None => return InputOutcome::Changed,
|
|
};
|
|
let vi = match viewer.list_state.selected_index() {
|
|
Some(vi) => vi,
|
|
None => return InputOutcome::Changed,
|
|
};
|
|
let pi = viewer.list_state.to_physical(vi);
|
|
let comment_id = match viewer.lines.get(pi).and_then(|item| item.comment_id()) {
|
|
Some(id) => id,
|
|
None => return InputOutcome::Changed,
|
|
};
|
|
if let Some(ref mut pav) = self.plan_approval_view {
|
|
pav.comments.retain(|c| c.id != comment_id);
|
|
let comments = pav.comments.clone();
|
|
if let Some(ref mut viewer) = self.line_viewer {
|
|
viewer.rebuild_with_comments(&comments);
|
|
}
|
|
}
|
|
InputOutcome::Changed
|
|
}
|
|
/// If the cursor is on a comment line, edit that comment; if on a source
|
|
/// line, capture the line range and start a new comment.
|
|
pub(super) fn enter_casual_plan_commenting(&mut self) -> InputOutcome {
|
|
let viewer = match self.line_viewer.as_mut() {
|
|
Some(v) => v,
|
|
None => return InputOutcome::Changed,
|
|
};
|
|
if let Some(vi) = viewer.list_state.selected_index() {
|
|
let pi = viewer.list_state.to_physical(vi);
|
|
if let Some(comment_id) = viewer.lines.get(pi).and_then(|item| item.comment_id())
|
|
&& let Some(comment) = self.plan_comments.iter().find(|c| c.id == comment_id)
|
|
{
|
|
let comment_text = comment.text.clone();
|
|
let comment_range = comment.line_range.clone();
|
|
if self.casual_stashed_prompt.is_none() {
|
|
self.casual_stashed_prompt = Some(self.prompt.stash());
|
|
}
|
|
self.casual_editing_comment_id = Some(comment_id);
|
|
self.casual_commenting_range = Some(comment_range);
|
|
self.prompt.set_text(&comment_text);
|
|
return InputOutcome::Changed;
|
|
}
|
|
}
|
|
let range = viewer.selected_line_range();
|
|
let Some(range) = range else {
|
|
return InputOutcome::Changed;
|
|
};
|
|
if viewer.list_state.visual_mode {
|
|
let start_vi = viewer.list_state.multi_range().map(|r| r.start);
|
|
if let Some(start_vi) = start_vi {
|
|
let start_pi = viewer.list_state.to_physical(start_vi);
|
|
let start_id = viewer.lines.get(start_pi).map(|l| l.stable_id());
|
|
viewer.list_state.exit_visual_mode();
|
|
if let Some(id) = start_id {
|
|
viewer.list_state.select_by_id(id);
|
|
}
|
|
} else {
|
|
viewer.list_state.exit_visual_mode();
|
|
}
|
|
}
|
|
if self.casual_stashed_prompt.is_none() {
|
|
self.casual_stashed_prompt = Some(self.prompt.stash());
|
|
}
|
|
self.casual_commenting_range = Some(range);
|
|
self.casual_editing_comment_id = None;
|
|
self.prompt.set_text("");
|
|
InputOutcome::Changed
|
|
}
|
|
pub(super) fn save_casual_plan_comment(&mut self) -> InputOutcome {
|
|
let text = self.prompt.text().to_owned();
|
|
if text.trim().is_empty() {
|
|
return self.cancel_casual_plan_commenting();
|
|
}
|
|
let range = match self.casual_commenting_range.take() {
|
|
Some(r) => r,
|
|
None => return self.cancel_casual_plan_commenting(),
|
|
};
|
|
if let Some(edit_id) = self.casual_editing_comment_id.take() {
|
|
if let Some(comment) = self.plan_comments.iter_mut().find(|c| c.id == edit_id) {
|
|
comment.text = text;
|
|
comment.line_range = range;
|
|
}
|
|
} else {
|
|
let id = self.plan_next_comment_id;
|
|
self.plan_next_comment_id += 1;
|
|
self.plan_comments.push(PlanComment {
|
|
id,
|
|
line_range: range,
|
|
text,
|
|
});
|
|
}
|
|
if let Some(stashed) = self.casual_stashed_prompt.take() {
|
|
self.prompt.restore(stashed);
|
|
} else {
|
|
self.prompt.set_text("");
|
|
}
|
|
let comments = self.plan_comments.clone();
|
|
if let Some(ref mut viewer) = self.line_viewer {
|
|
viewer.rebuild_with_comments(&comments);
|
|
}
|
|
InputOutcome::Changed
|
|
}
|
|
pub(super) fn cancel_casual_plan_commenting(&mut self) -> InputOutcome {
|
|
self.casual_commenting_range = None;
|
|
self.casual_editing_comment_id = None;
|
|
if let Some(stashed) = self.casual_stashed_prompt.take() {
|
|
self.prompt.restore(stashed);
|
|
} else {
|
|
self.prompt.set_text("");
|
|
}
|
|
InputOutcome::Changed
|
|
}
|
|
/// Mirrors `handle_plan_feedback_key` (the plan-approval Commenting focus)
|
|
/// so the casual-commenting UX is identical: Enter saves, Esc cancels, Tab
|
|
/// cancels back to the modal, everything else routes to the prompt textarea.
|
|
pub(super) fn handle_casual_plan_feedback_key(&mut self, key: &KeyEvent) -> InputOutcome {
|
|
if key.code == KeyCode::Esc {
|
|
if self.prompt.file_search_visible() {
|
|
self.prompt.file_search.clear_context();
|
|
return InputOutcome::Changed;
|
|
}
|
|
return self.cancel_casual_plan_commenting();
|
|
}
|
|
if self.ctrl_c_cancels_empty_draft(key) {
|
|
return self.cancel_casual_plan_commenting();
|
|
}
|
|
match self.prompt.route_enter(key) {
|
|
EnterOutcome::NewlineInserted => return InputOutcome::Changed,
|
|
EnterOutcome::Submit => return self.save_casual_plan_comment(),
|
|
EnterOutcome::PassThrough => {}
|
|
}
|
|
if key.code == KeyCode::Tab && key.modifiers.is_empty() {
|
|
return self.cancel_casual_plan_commenting();
|
|
}
|
|
match self.prompt.handle_key(key) {
|
|
PromptEvent::Edited => {
|
|
if let Some(req) = self.prompt.pending_viewer_request.take() {
|
|
self.open_line_viewer(&req.path, req.initial_range);
|
|
}
|
|
InputOutcome::Changed
|
|
}
|
|
PromptEvent::Ignored => InputOutcome::Changed,
|
|
}
|
|
}
|
|
pub(super) fn delete_casual_plan_comment_at_cursor(&mut self) -> InputOutcome {
|
|
let viewer = match self.line_viewer.as_ref() {
|
|
Some(v) => v,
|
|
None => return InputOutcome::Unchanged,
|
|
};
|
|
let vi = match viewer.list_state.selected_index() {
|
|
Some(vi) => vi,
|
|
None => return InputOutcome::Unchanged,
|
|
};
|
|
let pi = viewer.list_state.to_physical(vi);
|
|
let comment_id = match viewer.lines.get(pi).and_then(|item| item.comment_id()) {
|
|
Some(id) => id,
|
|
None => return InputOutcome::Unchanged,
|
|
};
|
|
self.plan_comments.retain(|c| c.id != comment_id);
|
|
let comments = self.plan_comments.clone();
|
|
if let Some(ref mut viewer) = self.line_viewer {
|
|
viewer.rebuild_with_comments(&comments);
|
|
}
|
|
InputOutcome::Changed
|
|
}
|
|
pub(super) fn send_casual_plan_comments(&mut self) -> InputOutcome {
|
|
if self.plan_comments.is_empty() {
|
|
self.show_toast("No comments to send.");
|
|
return InputOutcome::Changed;
|
|
}
|
|
let plan_content = self.inline_plan_content().map(str::to_owned).or_else(|| {
|
|
let path = self.plan_file_path()?;
|
|
std::fs::read_to_string(path).ok()
|
|
});
|
|
let body = crate::views::plan_approval_view::format_plan_comments(
|
|
&self.plan_comments,
|
|
plan_content.as_deref(),
|
|
);
|
|
let text = format!("Plan feedback:\n\n{body}");
|
|
self.plan_comments.clear();
|
|
self.plan_next_comment_id = 0;
|
|
self.cancel_line_viewer();
|
|
self.show_toast("Plan feedback sent.");
|
|
InputOutcome::Action(Action::SendPrompt(text))
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
mod prompt_flag_tests {
|
|
use super::test_fixtures::make_agent;
|
|
#[test]
|
|
fn auto_flag_visible_precedence() {
|
|
let mut agent = make_agent();
|
|
assert!(!agent.auto_flag_visible(false));
|
|
agent.session.auto_mode = true;
|
|
assert!(agent.auto_flag_visible(false));
|
|
assert!(!agent.auto_flag_visible(true));
|
|
agent.session.yolo_mode = true;
|
|
assert!(!agent.auto_flag_visible(false));
|
|
agent.session.yolo_mode = false;
|
|
assert!(agent.auto_flag_visible(false));
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
mod plan_chip_tests {
|
|
use super::*;
|
|
use crate::acp::model_state::ModelState;
|
|
use crate::app::agent::{AgentId, AgentSession, AgentState};
|
|
use crate::appearance::AppearanceConfig;
|
|
use crate::scrollback::state::ScrollbackState;
|
|
fn make_agent() -> AgentView {
|
|
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
|
AgentView::new(
|
|
AgentSession {
|
|
id: AgentId(0),
|
|
acp_tx: tx,
|
|
session_id: None,
|
|
models: ModelState::default(),
|
|
state: AgentState::Idle,
|
|
tracker: crate::acp::tracker::AcpUpdateTracker::new(),
|
|
cwd: std::path::PathBuf::from("/tmp"),
|
|
is_worktree: false,
|
|
forked_from: None,
|
|
pending_prompts: std::collections::VecDeque::new(),
|
|
next_queue_id: 0,
|
|
yolo_mode: false,
|
|
auto_mode: false,
|
|
prompt_history: Vec::new(),
|
|
prompt_history_loading: false,
|
|
loading_replay: false,
|
|
restore_degree: None,
|
|
rate_limited: false,
|
|
model_incompatible: false,
|
|
available_commands: Vec::new(),
|
|
available_commands_generation: 0,
|
|
available_tools: None,
|
|
model_switch_pending: false,
|
|
user_model_preference: None,
|
|
deferred_model_switch: None,
|
|
bg_tasks: std::collections::BTreeMap::new(),
|
|
bg_tool_call_to_task: std::collections::HashMap::new(),
|
|
scheduled_tasks: std::collections::HashMap::new(),
|
|
in_flight_prompt: None,
|
|
current_prompt_id: None,
|
|
created_via_new: false,
|
|
},
|
|
ScrollbackState::new(),
|
|
)
|
|
}
|
|
#[test]
|
|
fn plan_chip_hidden_after_exit_by_default() {
|
|
let mut agent = make_agent();
|
|
agent.plan_mode_active = false;
|
|
let appearance = AppearanceConfig::default();
|
|
assert!(!appearance.show_plan_chip);
|
|
assert!(!agent.should_show_plan_chip(&appearance));
|
|
}
|
|
#[test]
|
|
fn plan_chip_visible_while_plan_mode_active() {
|
|
let mut agent = make_agent();
|
|
agent.plan_mode_active = true;
|
|
let appearance = AppearanceConfig::default();
|
|
assert!(!agent.should_show_plan_chip(&appearance));
|
|
}
|
|
#[test]
|
|
fn plan_chip_visible_when_config_overrides() {
|
|
let mut agent = make_agent();
|
|
agent.plan_mode_active = false;
|
|
let appearance = AppearanceConfig {
|
|
show_plan_chip: true,
|
|
..Default::default()
|
|
};
|
|
assert!(!agent.should_show_plan_chip(&appearance));
|
|
}
|
|
#[test]
|
|
fn set_input_mode_vim_empty_prompt_switches_to_scrollback_and_j_selects_next() {
|
|
crate::appearance::cache::set_simple_mode(true);
|
|
let mut agent = make_agent();
|
|
agent.vim_mode = true;
|
|
agent.set_active_pane(ActivePane::Prompt, true);
|
|
agent.set_input_mode(InputMode::Vim);
|
|
assert_eq!(agent.active_pane, ActivePane::Scrollback);
|
|
assert!(!agent.is_simple_mode());
|
|
let registry = ActionRegistry::defaults();
|
|
let j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
|
|
let outcome = agent.handle_scrollback_key(&j, ®istry);
|
|
assert!(matches!(outcome, InputOutcome::Action(Action::SelectNext)));
|
|
}
|
|
#[test]
|
|
fn set_input_mode_vim_nonempty_prompt_keeps_pane() {
|
|
let mut agent = make_agent();
|
|
agent.set_active_pane(ActivePane::Prompt, true);
|
|
agent.prompt.set_text("draft");
|
|
agent.set_input_mode(InputMode::Vim);
|
|
assert_eq!(agent.active_pane, ActivePane::Prompt);
|
|
}
|
|
#[test]
|
|
fn set_input_mode_simple_from_scrollback_leaves_pane_unchanged() {
|
|
let mut agent = make_agent();
|
|
agent.vim_mode = true;
|
|
agent.set_active_pane(ActivePane::Scrollback, true);
|
|
agent.set_input_mode(InputMode::Simple);
|
|
assert_eq!(agent.active_pane, ActivePane::Scrollback);
|
|
assert!(agent.is_simple_mode());
|
|
let registry = ActionRegistry::defaults();
|
|
let x = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
|
|
let outcome = agent.handle_scrollback_key(&x, ®istry);
|
|
assert_eq!(agent.active_pane, ActivePane::Scrollback);
|
|
assert!(matches!(outcome, InputOutcome::Unchanged));
|
|
}
|
|
#[test]
|
|
fn new_agent_respects_persisted_simple_mode_for_mode_and_pane() {
|
|
crate::appearance::cache::set_simple_mode(true);
|
|
let a1 = make_agent();
|
|
assert!(a1.is_simple_mode());
|
|
assert_eq!(a1.active_pane, ActivePane::Prompt);
|
|
crate::appearance::cache::set_simple_mode(false);
|
|
let a2 = make_agent();
|
|
assert!(!a2.is_simple_mode());
|
|
assert_eq!(a2.active_pane, ActivePane::Scrollback);
|
|
}
|
|
#[test]
|
|
fn set_input_mode_reconciles_pane_orthogonal_to_active_modal_field() {
|
|
let mut agent = make_agent();
|
|
agent.set_active_pane(ActivePane::Prompt, true);
|
|
agent.active_modal = None;
|
|
agent.set_input_mode(InputMode::Vim);
|
|
assert_eq!(agent.active_pane, ActivePane::Scrollback);
|
|
assert!(agent.active_modal.is_none());
|
|
}
|
|
#[test]
|
|
fn scrollback_j_with_vim_mode_off_forwards_to_prompt() {
|
|
crate::appearance::cache::set_vim_mode(false);
|
|
let mut agent = make_agent();
|
|
agent.vim_mode = false;
|
|
agent.set_active_pane(ActivePane::Scrollback, true);
|
|
let registry = ActionRegistry::defaults();
|
|
let j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
|
|
let outcome = agent.handle_scrollback_key(&j, ®istry);
|
|
assert!(
|
|
matches!(
|
|
outcome,
|
|
InputOutcome::ActionThenForward(Action::FocusPrompt)
|
|
),
|
|
"vim-off: bare 'j' in scrollback must forward to prompt; got {outcome:?}"
|
|
);
|
|
}
|
|
#[test]
|
|
fn scrollback_j_with_vim_mode_on_selects_next() {
|
|
crate::appearance::cache::set_vim_mode(true);
|
|
let mut agent = make_agent();
|
|
agent.vim_mode = true;
|
|
agent.set_active_pane(ActivePane::Scrollback, true);
|
|
let registry = ActionRegistry::defaults();
|
|
let j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
|
|
let outcome = agent.handle_scrollback_key(&j, ®istry);
|
|
assert!(
|
|
matches!(outcome, InputOutcome::Action(Action::SelectNext)),
|
|
"vim-on: bare 'j' in scrollback must dispatch SelectNext; got {outcome:?}"
|
|
);
|
|
}
|
|
#[test]
|
|
fn scrollback_arrow_down_works_in_both_modes() {
|
|
let registry = ActionRegistry::defaults();
|
|
let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
|
|
let mut a_off = make_agent();
|
|
a_off.vim_mode = false;
|
|
a_off.set_active_pane(ActivePane::Scrollback, true);
|
|
assert!(matches!(
|
|
a_off.handle_scrollback_key(&down, ®istry),
|
|
InputOutcome::Action(Action::SelectNext)
|
|
));
|
|
let mut a_on = make_agent();
|
|
a_on.vim_mode = true;
|
|
a_on.set_active_pane(ActivePane::Scrollback, true);
|
|
assert!(matches!(
|
|
a_on.handle_scrollback_key(&down, ®istry),
|
|
InputOutcome::Action(Action::SelectNext)
|
|
));
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
mod plan_comment_cancel_tests {
|
|
use super::test_fixtures::make_agent;
|
|
use super::*;
|
|
fn ctrl_c() -> KeyEvent {
|
|
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)
|
|
}
|
|
fn stash_of(text: &str) -> crate::views::prompt_widget::StashedPrompt {
|
|
let mut stash = crate::views::prompt_widget::StashedPrompt::default();
|
|
stash.text = text.to_owned();
|
|
stash
|
|
}
|
|
#[test]
|
|
fn second_ctrl_c_cancels_a_plan_approval_comment() {
|
|
let mut agent = make_agent();
|
|
let mut pav =
|
|
crate::app::agent_view::paste::paste_key_tests::make_plan_approval_view_state();
|
|
pav.focus = PlanApprovalFocus::Commenting;
|
|
pav.commenting_range = Some(0..1);
|
|
pav.stashed_feedback_prompt = Some(stash_of("freeform notes"));
|
|
agent.plan_approval_view = Some(pav);
|
|
agent.prompt.set_text("half a comment");
|
|
let _ = agent.handle_plan_feedback_key(&ctrl_c());
|
|
assert_eq!(agent.prompt.text(), "");
|
|
assert_eq!(
|
|
agent.plan_approval_view.as_ref().map(|p| p.focus),
|
|
Some(PlanApprovalFocus::Commenting),
|
|
"the first Ctrl+C only clears the draft"
|
|
);
|
|
let _ = agent.handle_plan_feedback_key(&ctrl_c());
|
|
let pav = agent.plan_approval_view.as_ref().unwrap();
|
|
assert_eq!(pav.focus, PlanApprovalFocus::Preview);
|
|
assert!(pav.commenting_range.is_none());
|
|
assert_eq!(agent.prompt.text(), "freeform notes");
|
|
}
|
|
#[test]
|
|
fn second_ctrl_c_cancels_a_casual_plan_comment() {
|
|
let mut agent = make_agent();
|
|
agent.casual_commenting_range = Some(0..1);
|
|
agent.casual_stashed_prompt = Some(stash_of("earlier draft"));
|
|
agent.prompt.set_text("half a comment");
|
|
let _ = agent.handle_casual_plan_feedback_key(&ctrl_c());
|
|
assert_eq!(agent.prompt.text(), "");
|
|
assert!(agent.casual_commenting_range.is_some());
|
|
let _ = agent.handle_casual_plan_feedback_key(&ctrl_c());
|
|
assert!(agent.casual_commenting_range.is_none());
|
|
assert_eq!(agent.prompt.text(), "earlier draft");
|
|
}
|
|
}
|