mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
Refine request_user_input TUI interactions and option UX (#10025)
## Summary Overhaul the ask‑user‑questions TUI to support “Other/None” answers, better notes handling, improved option selection UX, and a safer submission flow with confirmation for unanswered questions. Multiple choice (number keys for quick selection, up/down or jk for cycling through options): <img width="856" height="169" alt="Screenshot 2026-01-27 at 7 22 29 PM" src="https://github.com/user-attachments/assets/cabd1b0e-25e0-4859-bd8f-9941192ca274" /> Tab to add notes: <img width="856" height="197" alt="Screenshot 2026-01-27 at 7 22 45 PM" src="https://github.com/user-attachments/assets/a807db5e-e966-412c-af91-6edc60062f35" /> Freeform (also note enter tooltip is highlighted on last question to indicate questions UI will be exited upon submission): <img width="854" height="112" alt="Screenshot 2026-01-27 at 7 23 13 PM" src="https://github.com/user-attachments/assets/2e7b88bf-062b-4b9f-a9da-c9d8c8a59643" /> Confirmation dialogue (submitting with unanswered questions): <img width="854" height="126" alt="Screenshot 2026-01-27 at 7 23 29 PM" src="https://github.com/user-attachments/assets/93965c8f-54ac-45bc-a660-9625bcd101f8" /> ## Key Changes - **Options UI refresh** - Render options as numbered entries; allow number keys to select & submit. - Remove “Option X/Y” header and allow the question UI height to expand naturally. - Keep spacing between question, options, and notes even when notes are visible. - Hide the title line and render the question prompt in cyan **only when uncommitted**. - **“Other / None of the above” support** - Wire `isOther` to add “None of the above”. - Add guidance text: “Optionally, add details in notes (tab).” - **Notes composer UX** - Remove “Notes” heading; place composer directly under the selected option. - Preserve pending paste placeholders across question navigation and after submission. - Ctrl+C clears notes **only when the notes composer has focus**. - Ctrl+C now triggers an immediate redraw so the clear is visible. - **Committed vs uncommitted state** - Introduce a unified `answer_committed` flag per question. - Editing notes (including adding text or pastes) marks the answer uncommitted. - Changing the option highlight (j/k, up/down) marks the answer uncommitted. - Clearing options (Backspace/Delete) also clears pending notes. - Question prompt turns cyan only when the answer is uncommitted. - **Submission safety & confirmation** - Only submit notes/freeform text once explicitly committed. - Last-question submit with unanswered questions shows a confirmation dialog. - Confirmation options: 1. **Proceed** (default) 2. **Go back** - Description reflects count: “Submit with N unanswered question(s).” - Esc/Backspace in confirmation returns to first unanswered question. - Ctrl+C in confirmation interrupts and exits the overlay. - **Footer hints** - Cyan highlight restored for “enter to submit answer” / “enter to submit all”. ## Codex author `codex fork 019c00ed-323a-7000-bdb5-9f9c5a635bd9`
This commit is contained in:
committed by
GitHub
parent
74bd6d7178
commit
96386755b6
@@ -21,6 +21,12 @@ pub(crate) trait BottomPaneView: Renderable {
|
||||
CancellationEvent::NotHandled
|
||||
}
|
||||
|
||||
/// Return true if Esc should be routed through `handle_key_event` instead
|
||||
/// of the `on_ctrl_c` cancellation path.
|
||||
fn prefer_esc_to_handle_key_event(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Optional paste handler. Return true if the view modified its state and
|
||||
/// needs a redraw.
|
||||
fn handle_paste(&mut self, _pasted: String) -> bool {
|
||||
|
||||
@@ -653,6 +653,18 @@ impl ChatComposer {
|
||||
text
|
||||
}
|
||||
|
||||
pub(crate) fn pending_pastes(&self) -> Vec<(String, String)> {
|
||||
self.pending_pastes.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_pending_pastes(&mut self, pending_pastes: Vec<(String, String)>) {
|
||||
let text = self.textarea.text().to_string();
|
||||
self.pending_pastes = pending_pastes
|
||||
.into_iter()
|
||||
.filter(|(placeholder, _)| text.contains(placeholder))
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Override the footer hint items displayed beneath the composer. Passing
|
||||
/// `None` restores the default shortcut footer.
|
||||
pub(crate) fn set_footer_hint_override(&mut self, items: Option<Vec<(String, String)>>) {
|
||||
@@ -1431,7 +1443,7 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
/// Expand large-paste placeholders using element ranges and rebuild other element spans.
|
||||
fn expand_pending_pastes(
|
||||
pub(crate) fn expand_pending_pastes(
|
||||
text: &str,
|
||||
mut elements: Vec<TextElement>,
|
||||
pending_pastes: &[(String, String)],
|
||||
|
||||
@@ -269,7 +269,10 @@ impl BottomPane {
|
||||
let (ctrl_c_completed, view_complete, view_in_paste_burst) = {
|
||||
let last_index = self.view_stack.len() - 1;
|
||||
let view = &mut self.view_stack[last_index];
|
||||
let prefer_esc =
|
||||
key_event.code == KeyCode::Esc && view.prefer_esc_to_handle_key_event();
|
||||
let ctrl_c_completed = key_event.code == KeyCode::Esc
|
||||
&& !prefer_esc
|
||||
&& matches!(view.on_ctrl_c(), CancellationEvent::Handled)
|
||||
&& view.is_complete();
|
||||
if ctrl_c_completed {
|
||||
@@ -338,6 +341,7 @@ impl BottomPane {
|
||||
self.on_active_view_complete();
|
||||
}
|
||||
self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')));
|
||||
self.request_redraw();
|
||||
}
|
||||
event
|
||||
} else if self.composer_is_empty() {
|
||||
@@ -346,6 +350,7 @@ impl BottomPane {
|
||||
self.view_stack.pop();
|
||||
self.clear_composer_for_ctrl_c();
|
||||
self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')));
|
||||
self.request_redraw();
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
}
|
||||
@@ -815,7 +820,9 @@ mod tests {
|
||||
use insta::assert_snapshot;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use std::cell::Cell;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
fn snapshot_buffer(buf: &Buffer) -> String {
|
||||
@@ -1242,4 +1249,63 @@ mod tests {
|
||||
"expected Esc to send Op::Interrupt while a task is running"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_routes_to_handle_key_event_when_requested() {
|
||||
#[derive(Default)]
|
||||
struct EscRoutingView {
|
||||
on_ctrl_c_calls: Rc<Cell<usize>>,
|
||||
handle_calls: Rc<Cell<usize>>,
|
||||
}
|
||||
|
||||
impl Renderable for EscRoutingView {
|
||||
fn render(&self, _area: Rect, _buf: &mut Buffer) {}
|
||||
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for EscRoutingView {
|
||||
fn handle_key_event(&mut self, _key_event: KeyEvent) {
|
||||
self.handle_calls
|
||||
.set(self.handle_calls.get().saturating_add(1));
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
self.on_ctrl_c_calls
|
||||
.set(self.on_ctrl_c_calls.get().saturating_add(1));
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
|
||||
fn prefer_esc_to_handle_key_event(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
animations_enabled: true,
|
||||
skills: Some(Vec::new()),
|
||||
});
|
||||
|
||||
let on_ctrl_c_calls = Rc::new(Cell::new(0));
|
||||
let handle_calls = Rc::new(Cell::new(0));
|
||||
pane.push_view(Box::new(EscRoutingView {
|
||||
on_ctrl_c_calls: Rc::clone(&on_ctrl_c_calls),
|
||||
handle_calls: Rc::clone(&handle_calls),
|
||||
}));
|
||||
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(on_ctrl_c_calls.get(), 0);
|
||||
assert_eq!(handle_calls.get(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
use super::DESIRED_SPACERS_WHEN_NOTES_HIDDEN;
|
||||
use super::DESIRED_SPACERS_BETWEEN_SECTIONS;
|
||||
use super::RequestUserInputOverlay;
|
||||
|
||||
pub(super) struct LayoutSections {
|
||||
pub(super) progress_area: Rect,
|
||||
pub(super) header_area: Rect,
|
||||
pub(super) question_area: Rect,
|
||||
// Wrapped question text lines to render in the question area.
|
||||
pub(super) question_lines: Vec<String>,
|
||||
pub(super) options_area: Rect,
|
||||
pub(super) notes_title_area: Rect,
|
||||
pub(super) notes_area: Rect,
|
||||
// Number of footer rows (status + hints).
|
||||
pub(super) footer_lines: u16,
|
||||
@@ -26,16 +24,7 @@ impl RequestUserInputOverlay {
|
||||
let mut question_lines = self.wrapped_question_lines(area.width);
|
||||
let question_height = question_lines.len() as u16;
|
||||
|
||||
let (
|
||||
question_height,
|
||||
progress_height,
|
||||
spacer_after_question,
|
||||
options_height,
|
||||
spacer_after_options,
|
||||
notes_title_height,
|
||||
notes_height,
|
||||
footer_lines,
|
||||
) = if has_options {
|
||||
let layout = if has_options {
|
||||
self.layout_with_options(
|
||||
OptionsLayoutArgs {
|
||||
available_height: area.height,
|
||||
@@ -57,98 +46,52 @@ impl RequestUserInputOverlay {
|
||||
)
|
||||
};
|
||||
|
||||
let (progress_area, header_area, question_area, options_area, notes_title_area, notes_area) =
|
||||
self.build_layout_areas(
|
||||
area,
|
||||
LayoutHeights {
|
||||
progress_height,
|
||||
question_height,
|
||||
spacer_after_question,
|
||||
options_height,
|
||||
spacer_after_options,
|
||||
notes_title_height,
|
||||
notes_height,
|
||||
},
|
||||
);
|
||||
let (progress_area, question_area, options_area, notes_area) =
|
||||
self.build_layout_areas(area, layout);
|
||||
|
||||
LayoutSections {
|
||||
progress_area,
|
||||
header_area,
|
||||
question_area,
|
||||
question_lines,
|
||||
options_area,
|
||||
notes_title_area,
|
||||
notes_area,
|
||||
footer_lines,
|
||||
footer_lines: layout.footer_lines,
|
||||
}
|
||||
}
|
||||
|
||||
/// Layout calculation when options are present.
|
||||
///
|
||||
/// Handles both tight layout (when space is constrained) and normal layout
|
||||
/// (when there's sufficient space for all elements).
|
||||
///
|
||||
/// Returns: (question_height, progress_height, spacer_after_question, options_height, spacer_after_options, notes_title_height, notes_height, footer_lines)
|
||||
fn layout_with_options(
|
||||
&self,
|
||||
args: OptionsLayoutArgs,
|
||||
question_lines: &mut Vec<String>,
|
||||
) -> (u16, u16, u16, u16, u16, u16, u16, u16) {
|
||||
) -> LayoutPlan {
|
||||
let OptionsLayoutArgs {
|
||||
available_height,
|
||||
width,
|
||||
question_height,
|
||||
mut question_height,
|
||||
notes_pref_height,
|
||||
footer_pref,
|
||||
notes_visible,
|
||||
} = args;
|
||||
let options_heights = OptionsHeights {
|
||||
preferred: self.options_preferred_height(width),
|
||||
full: self.options_required_height(width),
|
||||
};
|
||||
let min_options_height = 1u16;
|
||||
let required = 1u16
|
||||
.saturating_add(question_height)
|
||||
.saturating_add(options_heights.preferred);
|
||||
|
||||
if required > available_height {
|
||||
self.layout_with_options_tight(
|
||||
let min_options_height = available_height.min(1);
|
||||
let max_question_height = available_height.saturating_sub(min_options_height);
|
||||
if question_height > max_question_height {
|
||||
question_height = max_question_height;
|
||||
question_lines.truncate(question_height as usize);
|
||||
}
|
||||
self.layout_with_options_normal(
|
||||
OptionsNormalArgs {
|
||||
available_height,
|
||||
question_height,
|
||||
min_options_height,
|
||||
question_lines,
|
||||
)
|
||||
} else {
|
||||
self.layout_with_options_normal(
|
||||
OptionsNormalArgs {
|
||||
available_height,
|
||||
question_height,
|
||||
notes_pref_height,
|
||||
footer_pref,
|
||||
notes_visible,
|
||||
},
|
||||
options_heights,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tight layout for options case: allocate header + question + options first
|
||||
/// and drop everything else when space is constrained.
|
||||
fn layout_with_options_tight(
|
||||
&self,
|
||||
available_height: u16,
|
||||
question_height: u16,
|
||||
min_options_height: u16,
|
||||
question_lines: &mut Vec<String>,
|
||||
) -> (u16, u16, u16, u16, u16, u16, u16, u16) {
|
||||
let max_question_height =
|
||||
available_height.saturating_sub(1u16.saturating_add(min_options_height));
|
||||
let adjusted_question_height = question_height.min(max_question_height);
|
||||
question_lines.truncate(adjusted_question_height as usize);
|
||||
let options_height =
|
||||
available_height.saturating_sub(1u16.saturating_add(adjusted_question_height));
|
||||
|
||||
(adjusted_question_height, 0, 0, options_height, 0, 0, 0, 0)
|
||||
notes_pref_height,
|
||||
footer_pref,
|
||||
notes_visible,
|
||||
},
|
||||
OptionsHeights {
|
||||
preferred: self.options_preferred_height(width),
|
||||
full: self.options_required_height(width),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Normal layout for options case: allocate footer + progress first, and
|
||||
@@ -157,7 +100,7 @@ impl RequestUserInputOverlay {
|
||||
&self,
|
||||
args: OptionsNormalArgs,
|
||||
options: OptionsHeights,
|
||||
) -> (u16, u16, u16, u16, u16, u16, u16, u16) {
|
||||
) -> LayoutPlan {
|
||||
let OptionsNormalArgs {
|
||||
available_height,
|
||||
question_height,
|
||||
@@ -165,19 +108,23 @@ impl RequestUserInputOverlay {
|
||||
footer_pref,
|
||||
notes_visible,
|
||||
} = args;
|
||||
let min_options_height = 1u16;
|
||||
let mut options_height = options.preferred.max(min_options_height);
|
||||
let used = 1u16
|
||||
.saturating_add(question_height)
|
||||
.saturating_add(options_height);
|
||||
let max_options_height = available_height.saturating_sub(question_height);
|
||||
let min_options_height = max_options_height.min(1);
|
||||
let mut options_height = options
|
||||
.preferred
|
||||
.min(max_options_height)
|
||||
.max(min_options_height);
|
||||
let used = question_height.saturating_add(options_height);
|
||||
let mut remaining = available_height.saturating_sub(used);
|
||||
|
||||
// When notes are hidden, prefer to reserve room for progress, footer,
|
||||
// and spacers by shrinking the options window if needed.
|
||||
let desired_spacers = if notes_visible {
|
||||
0
|
||||
// Notes already separate options from the footer, so only keep a
|
||||
// single spacer between the question and options.
|
||||
1
|
||||
} else {
|
||||
DESIRED_SPACERS_WHEN_NOTES_HIDDEN
|
||||
DESIRED_SPACERS_BETWEEN_SECTIONS
|
||||
};
|
||||
let required_extra = footer_pref
|
||||
.saturating_add(1) // progress line
|
||||
@@ -211,45 +158,41 @@ impl RequestUserInputOverlay {
|
||||
}
|
||||
let grow_by = remaining.min(options.full.saturating_sub(options_height));
|
||||
options_height = options_height.saturating_add(grow_by);
|
||||
return (
|
||||
return LayoutPlan {
|
||||
question_height,
|
||||
progress_height,
|
||||
spacer_after_question,
|
||||
options_height,
|
||||
spacer_after_options,
|
||||
0,
|
||||
0,
|
||||
notes_height: 0,
|
||||
footer_lines,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
let footer_lines = footer_pref.min(remaining);
|
||||
remaining = remaining.saturating_sub(footer_lines);
|
||||
|
||||
// Prefer notes next, then labels, with any leftover rows expanding notes.
|
||||
let spacer_after_question = 0;
|
||||
// Prefer spacers before notes, then notes.
|
||||
let mut spacer_after_question = 0;
|
||||
if remaining > 0 {
|
||||
spacer_after_question = 1;
|
||||
remaining = remaining.saturating_sub(1);
|
||||
}
|
||||
let spacer_after_options = 0;
|
||||
let mut notes_height = notes_pref_height.min(remaining);
|
||||
remaining = remaining.saturating_sub(notes_height);
|
||||
|
||||
let mut notes_title_height = 0;
|
||||
if remaining > 0 {
|
||||
notes_title_height = 1;
|
||||
remaining = remaining.saturating_sub(1);
|
||||
}
|
||||
|
||||
notes_height = notes_height.saturating_add(remaining);
|
||||
|
||||
(
|
||||
LayoutPlan {
|
||||
question_height,
|
||||
progress_height,
|
||||
spacer_after_question,
|
||||
options_height,
|
||||
spacer_after_options,
|
||||
notes_title_height,
|
||||
notes_height,
|
||||
footer_lines,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Layout calculation when no options are present.
|
||||
@@ -257,7 +200,6 @@ impl RequestUserInputOverlay {
|
||||
/// Handles both tight layout (when space is constrained) and normal layout
|
||||
/// (when there's sufficient space for all elements).
|
||||
///
|
||||
/// Returns: (question_height, progress_height, spacer_after_question, options_height, spacer_after_options, notes_title_height, notes_height, footer_lines)
|
||||
fn layout_without_options(
|
||||
&self,
|
||||
available_height: u16,
|
||||
@@ -265,8 +207,8 @@ impl RequestUserInputOverlay {
|
||||
notes_pref_height: u16,
|
||||
footer_pref: u16,
|
||||
question_lines: &mut Vec<String>,
|
||||
) -> (u16, u16, u16, u16, u16, u16, u16, u16) {
|
||||
let required = 1u16.saturating_add(question_height);
|
||||
) -> LayoutPlan {
|
||||
let required = question_height;
|
||||
if required > available_height {
|
||||
self.layout_without_options_tight(available_height, question_height, question_lines)
|
||||
} else {
|
||||
@@ -285,12 +227,20 @@ impl RequestUserInputOverlay {
|
||||
available_height: u16,
|
||||
question_height: u16,
|
||||
question_lines: &mut Vec<String>,
|
||||
) -> (u16, u16, u16, u16, u16, u16, u16, u16) {
|
||||
let max_question_height = available_height.saturating_sub(1);
|
||||
) -> LayoutPlan {
|
||||
let max_question_height = available_height;
|
||||
let adjusted_question_height = question_height.min(max_question_height);
|
||||
question_lines.truncate(adjusted_question_height as usize);
|
||||
|
||||
(adjusted_question_height, 0, 0, 0, 0, 0, 0, 0)
|
||||
LayoutPlan {
|
||||
question_height: adjusted_question_height,
|
||||
progress_height: 0,
|
||||
spacer_after_question: 0,
|
||||
options_height: 0,
|
||||
spacer_after_options: 0,
|
||||
notes_height: 0,
|
||||
footer_lines: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Normal layout for no-options case: allocate space for notes, footer, and progress.
|
||||
@@ -300,8 +250,8 @@ impl RequestUserInputOverlay {
|
||||
question_height: u16,
|
||||
notes_pref_height: u16,
|
||||
footer_pref: u16,
|
||||
) -> (u16, u16, u16, u16, u16, u16, u16, u16) {
|
||||
let required = 1u16.saturating_add(question_height);
|
||||
) -> LayoutPlan {
|
||||
let required = question_height;
|
||||
let mut remaining = available_height.saturating_sub(required);
|
||||
let mut notes_height = notes_pref_height.min(remaining);
|
||||
remaining = remaining.saturating_sub(notes_height);
|
||||
@@ -317,29 +267,26 @@ impl RequestUserInputOverlay {
|
||||
|
||||
notes_height = notes_height.saturating_add(remaining);
|
||||
|
||||
(
|
||||
LayoutPlan {
|
||||
question_height,
|
||||
progress_height,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
spacer_after_question: 0,
|
||||
options_height: 0,
|
||||
spacer_after_options: 0,
|
||||
notes_height,
|
||||
footer_lines,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the final layout areas from computed heights.
|
||||
fn build_layout_areas(
|
||||
&self,
|
||||
area: Rect,
|
||||
heights: LayoutHeights,
|
||||
heights: LayoutPlan,
|
||||
) -> (
|
||||
Rect, // progress_area
|
||||
Rect, // header_area
|
||||
Rect, // question_area
|
||||
Rect, // options_area
|
||||
Rect, // notes_title_area
|
||||
Rect, // notes_area
|
||||
) {
|
||||
let mut cursor_y = area.y;
|
||||
@@ -350,14 +297,6 @@ impl RequestUserInputOverlay {
|
||||
height: heights.progress_height,
|
||||
};
|
||||
cursor_y = cursor_y.saturating_add(heights.progress_height);
|
||||
let header_height = area.height.saturating_sub(heights.progress_height).min(1);
|
||||
let header_area = Rect {
|
||||
x: area.x,
|
||||
y: cursor_y,
|
||||
width: area.width,
|
||||
height: header_height,
|
||||
};
|
||||
cursor_y = cursor_y.saturating_add(header_height);
|
||||
let question_area = Rect {
|
||||
x: area.x,
|
||||
y: cursor_y,
|
||||
@@ -376,13 +315,6 @@ impl RequestUserInputOverlay {
|
||||
cursor_y = cursor_y.saturating_add(heights.options_height);
|
||||
cursor_y = cursor_y.saturating_add(heights.spacer_after_options);
|
||||
|
||||
let notes_title_area = Rect {
|
||||
x: area.x,
|
||||
y: cursor_y,
|
||||
width: area.width,
|
||||
height: heights.notes_title_height,
|
||||
};
|
||||
cursor_y = cursor_y.saturating_add(heights.notes_title_height);
|
||||
let notes_area = Rect {
|
||||
x: area.x,
|
||||
y: cursor_y,
|
||||
@@ -390,26 +322,19 @@ impl RequestUserInputOverlay {
|
||||
height: heights.notes_height,
|
||||
};
|
||||
|
||||
(
|
||||
progress_area,
|
||||
header_area,
|
||||
question_area,
|
||||
options_area,
|
||||
notes_title_area,
|
||||
notes_area,
|
||||
)
|
||||
(progress_area, question_area, options_area, notes_area)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct LayoutHeights {
|
||||
struct LayoutPlan {
|
||||
progress_height: u16,
|
||||
question_height: u16,
|
||||
spacer_after_question: u16,
|
||||
options_height: u16,
|
||||
spacer_after_options: u16,
|
||||
notes_title_height: u16,
|
||||
notes_height: u16,
|
||||
footer_lines: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,21 +5,64 @@ use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use std::borrow::Cow;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
||||
use crate::bottom_pane::scroll_state::ScrollState;
|
||||
use crate::bottom_pane::selection_popup_common::measure_rows_height;
|
||||
use crate::bottom_pane::selection_popup_common::menu_surface_inset;
|
||||
use crate::bottom_pane::selection_popup_common::menu_surface_padding_height;
|
||||
use crate::bottom_pane::selection_popup_common::render_menu_surface;
|
||||
use crate::bottom_pane::selection_popup_common::render_rows;
|
||||
use crate::bottom_pane::selection_popup_common::wrap_styled_line;
|
||||
use crate::render::renderable::Renderable;
|
||||
|
||||
use super::DESIRED_SPACERS_WHEN_NOTES_HIDDEN;
|
||||
use super::DESIRED_SPACERS_BETWEEN_SECTIONS;
|
||||
use super::RequestUserInputOverlay;
|
||||
use super::TIP_SEPARATOR;
|
||||
|
||||
const MIN_OVERLAY_HEIGHT: usize = 8;
|
||||
const PROGRESS_ROW_HEIGHT: usize = 1;
|
||||
const SPACER_ROWS_WITH_NOTES: usize = 1;
|
||||
const SPACER_ROWS_NO_OPTIONS: usize = 0;
|
||||
|
||||
struct UnansweredConfirmationData {
|
||||
title_line: Line<'static>,
|
||||
subtitle_line: Line<'static>,
|
||||
hint_line: Line<'static>,
|
||||
rows: Vec<crate::bottom_pane::selection_popup_common::GenericDisplayRow>,
|
||||
state: ScrollState,
|
||||
}
|
||||
|
||||
struct UnansweredConfirmationLayout {
|
||||
header_lines: Vec<Line<'static>>,
|
||||
hint_lines: Vec<Line<'static>>,
|
||||
rows: Vec<crate::bottom_pane::selection_popup_common::GenericDisplayRow>,
|
||||
state: ScrollState,
|
||||
}
|
||||
|
||||
fn line_to_owned(line: Line<'_>) -> Line<'static> {
|
||||
Line {
|
||||
style: line.style,
|
||||
alignment: line.alignment,
|
||||
spans: line
|
||||
.spans
|
||||
.into_iter()
|
||||
.map(|span| Span {
|
||||
style: span.style,
|
||||
content: Cow::Owned(span.content.into_owned()),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for RequestUserInputOverlay {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
if self.confirm_unanswered_active() {
|
||||
return self.unanswered_confirmation_height(width);
|
||||
}
|
||||
let outer = Rect::new(0, 0, width, u16::MAX);
|
||||
let inner = menu_surface_inset(outer);
|
||||
let inner_width = inner.width.max(1);
|
||||
@@ -36,26 +79,29 @@ impl Renderable for RequestUserInputOverlay {
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let spacer_rows = if has_options && !notes_visible {
|
||||
DESIRED_SPACERS_WHEN_NOTES_HIDDEN as usize
|
||||
// When notes are visible, the composer already separates options from the footer.
|
||||
// Without notes, we keep extra spacing so the footer hints don't crowd the options.
|
||||
let spacer_rows = if has_options {
|
||||
if notes_visible {
|
||||
SPACER_ROWS_WITH_NOTES
|
||||
} else {
|
||||
DESIRED_SPACERS_BETWEEN_SECTIONS as usize
|
||||
}
|
||||
} else {
|
||||
0
|
||||
SPACER_ROWS_NO_OPTIONS
|
||||
};
|
||||
let footer_height = self.footer_required_height(inner_width) as usize;
|
||||
|
||||
// Tight minimum height: progress + header + question + (optional) titles/options
|
||||
// Tight minimum height: progress + question + (optional) titles/options
|
||||
// + notes composer + footer + menu padding.
|
||||
let mut height = question_height
|
||||
.saturating_add(options_height)
|
||||
.saturating_add(spacer_rows)
|
||||
.saturating_add(notes_height)
|
||||
.saturating_add(footer_height)
|
||||
.saturating_add(2); // progress + header
|
||||
if has_options && notes_visible {
|
||||
height = height.saturating_add(1); // notes title
|
||||
}
|
||||
.saturating_add(PROGRESS_ROW_HEIGHT); // progress
|
||||
height = height.saturating_add(menu_surface_padding_height() as usize);
|
||||
height.max(8) as u16
|
||||
height.max(MIN_OVERLAY_HEIGHT) as u16
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
@@ -68,11 +114,145 @@ impl Renderable for RequestUserInputOverlay {
|
||||
}
|
||||
|
||||
impl RequestUserInputOverlay {
|
||||
fn unanswered_confirmation_data(&self) -> UnansweredConfirmationData {
|
||||
let unanswered = self.unanswered_question_count();
|
||||
let subtitle = format!(
|
||||
"{unanswered} unanswered question{}",
|
||||
if unanswered == 1 { "" } else { "s" }
|
||||
);
|
||||
UnansweredConfirmationData {
|
||||
title_line: Line::from(super::UNANSWERED_CONFIRM_TITLE.bold()),
|
||||
subtitle_line: Line::from(subtitle.dim()),
|
||||
hint_line: standard_popup_hint_line(),
|
||||
rows: self.unanswered_confirmation_rows(),
|
||||
state: self.confirm_unanswered.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn unanswered_confirmation_layout(&self, width: u16) -> UnansweredConfirmationLayout {
|
||||
let data = self.unanswered_confirmation_data();
|
||||
let content_width = width.max(1);
|
||||
let mut header_lines = wrap_styled_line(&data.title_line, content_width);
|
||||
let mut subtitle_lines = wrap_styled_line(&data.subtitle_line, content_width);
|
||||
header_lines.append(&mut subtitle_lines);
|
||||
let header_lines = header_lines.into_iter().map(line_to_owned).collect();
|
||||
let hint_lines = wrap_styled_line(&data.hint_line, content_width)
|
||||
.into_iter()
|
||||
.map(line_to_owned)
|
||||
.collect();
|
||||
UnansweredConfirmationLayout {
|
||||
header_lines,
|
||||
hint_lines,
|
||||
rows: data.rows,
|
||||
state: data.state,
|
||||
}
|
||||
}
|
||||
|
||||
fn unanswered_confirmation_height(&self, width: u16) -> u16 {
|
||||
let outer = Rect::new(0, 0, width, u16::MAX);
|
||||
let inner = menu_surface_inset(outer);
|
||||
let inner_width = inner.width.max(1);
|
||||
let layout = self.unanswered_confirmation_layout(inner_width);
|
||||
let rows_height = measure_rows_height(
|
||||
&layout.rows,
|
||||
&layout.state,
|
||||
layout.rows.len().max(1),
|
||||
inner_width.max(1),
|
||||
);
|
||||
let height = layout.header_lines.len() as u16
|
||||
+ 1
|
||||
+ rows_height
|
||||
+ 1
|
||||
+ layout.hint_lines.len() as u16
|
||||
+ menu_surface_padding_height();
|
||||
height.max(MIN_OVERLAY_HEIGHT as u16)
|
||||
}
|
||||
|
||||
fn render_unanswered_confirmation(&self, area: Rect, buf: &mut Buffer) {
|
||||
let content_area = render_menu_surface(area, buf);
|
||||
if content_area.width == 0 || content_area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let width = content_area.width.max(1);
|
||||
let layout = self.unanswered_confirmation_layout(width);
|
||||
|
||||
let mut cursor_y = content_area.y;
|
||||
for line in layout.header_lines {
|
||||
if cursor_y >= content_area.y + content_area.height {
|
||||
return;
|
||||
}
|
||||
Paragraph::new(line).render(
|
||||
Rect {
|
||||
x: content_area.x,
|
||||
y: cursor_y,
|
||||
width: content_area.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
cursor_y = cursor_y.saturating_add(1);
|
||||
}
|
||||
|
||||
if cursor_y < content_area.y + content_area.height {
|
||||
cursor_y = cursor_y.saturating_add(1);
|
||||
}
|
||||
|
||||
let remaining = content_area
|
||||
.height
|
||||
.saturating_sub(cursor_y.saturating_sub(content_area.y));
|
||||
if remaining == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let hint_height = layout.hint_lines.len() as u16;
|
||||
let spacer_before_hint = u16::from(remaining > hint_height);
|
||||
let rows_height = remaining.saturating_sub(hint_height + spacer_before_hint);
|
||||
|
||||
let rows_area = Rect {
|
||||
x: content_area.x,
|
||||
y: cursor_y,
|
||||
width: content_area.width,
|
||||
height: rows_height,
|
||||
};
|
||||
render_rows(
|
||||
rows_area,
|
||||
buf,
|
||||
&layout.rows,
|
||||
&layout.state,
|
||||
layout.rows.len().max(1),
|
||||
"No choices",
|
||||
);
|
||||
|
||||
cursor_y = cursor_y.saturating_add(rows_height);
|
||||
if spacer_before_hint > 0 {
|
||||
cursor_y = cursor_y.saturating_add(1);
|
||||
}
|
||||
for (offset, line) in layout.hint_lines.into_iter().enumerate() {
|
||||
let y = cursor_y.saturating_add(offset as u16);
|
||||
if y >= content_area.y + content_area.height {
|
||||
break;
|
||||
}
|
||||
Paragraph::new(line).render(
|
||||
Rect {
|
||||
x: content_area.x,
|
||||
y,
|
||||
width: content_area.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the full request-user-input overlay.
|
||||
pub(super) fn render_ui(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
if self.confirm_unanswered_active() {
|
||||
self.render_unanswered_confirmation(area, buf);
|
||||
return;
|
||||
}
|
||||
// Paint the same menu surface used by other bottom-pane overlays and
|
||||
// then render the overlay content inside its inset area.
|
||||
let content_area = render_menu_surface(area, buf);
|
||||
@@ -98,28 +278,22 @@ impl RequestUserInputOverlay {
|
||||
};
|
||||
Paragraph::new(progress_line).render(sections.progress_area, buf);
|
||||
|
||||
// Question title and wrapped prompt text.
|
||||
let question_header = self.current_question().map(|q| q.header.clone());
|
||||
let answered = self.current_question_answered();
|
||||
let header_line = if let Some(header) = question_header {
|
||||
if answered {
|
||||
Line::from(header.bold())
|
||||
} else {
|
||||
Line::from(header.cyan().bold())
|
||||
}
|
||||
} else {
|
||||
Line::from("No questions".dim())
|
||||
};
|
||||
Paragraph::new(header_line).render(sections.header_area, buf);
|
||||
|
||||
// Question prompt text.
|
||||
let question_y = sections.question_area.y;
|
||||
let answered =
|
||||
self.is_question_answered(self.current_index(), &self.composer.current_text());
|
||||
for (offset, line) in sections.question_lines.iter().enumerate() {
|
||||
if question_y.saturating_add(offset as u16)
|
||||
>= sections.question_area.y + sections.question_area.height
|
||||
{
|
||||
break;
|
||||
}
|
||||
Paragraph::new(Line::from(line.clone())).render(
|
||||
let question_line = if answered {
|
||||
Line::from(line.clone())
|
||||
} else {
|
||||
Line::from(line.clone()).cyan()
|
||||
};
|
||||
Paragraph::new(question_line).render(
|
||||
Rect {
|
||||
x: sections.question_area.x,
|
||||
y: question_y.saturating_add(offset as u16),
|
||||
@@ -134,48 +308,25 @@ impl RequestUserInputOverlay {
|
||||
let option_rows = self.option_rows();
|
||||
|
||||
if self.has_options() {
|
||||
let mut options_ui_state = self
|
||||
let mut options_state = self
|
||||
.current_answer()
|
||||
.map(|answer| answer.options_ui_state)
|
||||
.map(|answer| answer.options_state)
|
||||
.unwrap_or_default();
|
||||
if sections.options_area.height > 0 {
|
||||
// Ensure the selected option is visible in the scroll window.
|
||||
options_ui_state
|
||||
options_state
|
||||
.ensure_visible(option_rows.len(), sections.options_area.height as usize);
|
||||
render_rows(
|
||||
sections.options_area,
|
||||
buf,
|
||||
&option_rows,
|
||||
&options_ui_state,
|
||||
&options_state,
|
||||
option_rows.len().max(1),
|
||||
"No options",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if notes_visible && sections.notes_title_area.height > 0 {
|
||||
let notes_label = if self.has_options()
|
||||
&& self
|
||||
.current_answer()
|
||||
.is_some_and(|answer| answer.committed_option_idx.is_some())
|
||||
{
|
||||
if let Some(label) = self.current_option_label() {
|
||||
format!("Notes for {label}")
|
||||
} else {
|
||||
"Notes".to_string()
|
||||
}
|
||||
} else {
|
||||
"Notes".to_string()
|
||||
};
|
||||
let notes_active = self.focus_is_notes();
|
||||
let notes_title = if notes_active {
|
||||
notes_label.as_str().cyan().bold()
|
||||
} else {
|
||||
notes_label.as_str().dim()
|
||||
};
|
||||
Paragraph::new(Line::from(notes_title)).render(sections.notes_title_area, buf);
|
||||
}
|
||||
|
||||
if notes_visible && sections.notes_area.height > 0 {
|
||||
self.render_notes_input(sections.notes_area, buf);
|
||||
}
|
||||
@@ -193,7 +344,17 @@ impl RequestUserInputOverlay {
|
||||
if footer_area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let tip_lines = self.footer_tip_lines(footer_area.width);
|
||||
let options_hidden = self.has_options()
|
||||
&& sections.options_area.height > 0
|
||||
&& self.options_required_height(content_area.width) > sections.options_area.height;
|
||||
let option_tip = if options_hidden {
|
||||
let selected = self.selected_option_index().unwrap_or(0).saturating_add(1);
|
||||
let total = self.options_len();
|
||||
Some(super::FooterTip::new(format!("option {selected}/{total}")))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let tip_lines = self.footer_tip_lines_with_prefix(footer_area.width, option_tip);
|
||||
for (row_idx, tips) in tip_lines
|
||||
.into_iter()
|
||||
.take(footer_area.height as usize)
|
||||
@@ -224,6 +385,9 @@ impl RequestUserInputOverlay {
|
||||
|
||||
/// Return the cursor position when editing notes, if visible.
|
||||
pub(super) fn cursor_pos_impl(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
if self.confirm_unanswered_active() {
|
||||
return None;
|
||||
}
|
||||
let has_options = self.has_options();
|
||||
let notes_visible = self.notes_ui_visible();
|
||||
|
||||
|
||||
@@ -3,14 +3,12 @@ source: tui/src/bottom_pane/request_user_input/mod.rs
|
||||
expression: "render_snapshot(&overlay, area)"
|
||||
---
|
||||
|
||||
Question 1/2 (1 unanswered)
|
||||
Pick one
|
||||
Question 1/2 (2 unanswered)
|
||||
Choose an option.
|
||||
|
||||
( ) Option 1 First choice.
|
||||
(x) Option 2 Second choice.
|
||||
( ) Option 3 Third choice.
|
||||
1. Option 1 First choice.
|
||||
› 2. Option 2 Second choice.
|
||||
3. Option 3 Third choice.
|
||||
|
||||
Option 2 of 3 | ↑/↓ scroll | Tab: add notes
|
||||
Enter: submit answer | Ctrl+n next
|
||||
Esc: interrupt
|
||||
tab to add notes | enter to submit answer
|
||||
ctrl + n next question | esc to interrupt
|
||||
|
||||
@@ -4,10 +4,10 @@ expression: "render_snapshot(&overlay, area)"
|
||||
---
|
||||
|
||||
Question 1/1 (1 unanswered)
|
||||
Goal
|
||||
Share details.
|
||||
|
||||
› Type your answer (optional)
|
||||
|
||||
|
||||
Enter: submit answer | Esc: interrupt
|
||||
|
||||
enter to submit answer | esc to interrupt
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/request_user_input/mod.rs
|
||||
expression: "render_snapshot(&overlay, area)"
|
||||
---
|
||||
|
||||
Question 1/1 (1 unanswered)
|
||||
What would you like to do next?
|
||||
|
||||
2. Run tests Pick a crate and run its tests.
|
||||
3. Review a diff Summarize or review current changes.
|
||||
› 4. Refactor Tighten structure and remove dead code.
|
||||
|
||||
option 4/5 | tab to add notes | enter to submit answer | esc to interrupt
|
||||
@@ -4,11 +4,10 @@ expression: "render_snapshot(&overlay, area)"
|
||||
---
|
||||
|
||||
Question 1/2 (2 unanswered)
|
||||
Area
|
||||
Choose an option.
|
||||
|
||||
( ) Option 1 First choice.
|
||||
( ) Option 2 Second choice.
|
||||
( ) Option 3 Third choice.
|
||||
› 1. Option 1 First choice.
|
||||
2. Option 2 Second choice.
|
||||
3. Option 3 Third choice.
|
||||
|
||||
Option 1 of 3 | ↑/↓ scroll | Tab: add notes | Enter: submit answer | Ctrl+n next | Esc: interrupt
|
||||
tab to add notes | enter to submit answer | ctrl + n next question | esc to interrupt
|
||||
|
||||
@@ -4,7 +4,6 @@ expression: "render_snapshot(&overlay, area)"
|
||||
---
|
||||
|
||||
Question 2/2 (2 unanswered)
|
||||
Goal
|
||||
Share details.
|
||||
|
||||
› Type your answer (optional)
|
||||
@@ -12,4 +11,5 @@ expression: "render_snapshot(&overlay, area)"
|
||||
|
||||
|
||||
|
||||
Enter: submit all answers | Ctrl+n next | Esc: interrupt
|
||||
|
||||
enter to submit all | ctrl + n first question | esc to interrupt
|
||||
|
||||
@@ -4,11 +4,10 @@ expression: "render_snapshot(&overlay, area)"
|
||||
---
|
||||
|
||||
Question 1/1 (1 unanswered)
|
||||
Area
|
||||
Choose an option.
|
||||
|
||||
( ) Option 1 First choice.
|
||||
( ) Option 2 Second choice.
|
||||
( ) Option 3 Third choice.
|
||||
› 1. Option 1 First choice.
|
||||
2. Option 2 Second choice.
|
||||
3. Option 3 Third choice.
|
||||
|
||||
Option 1 of 3 | ↑/↓ scroll | Tab: add notes | Enter: submit answer | Esc: interrupt
|
||||
tab to add notes | enter to submit answer | esc to interrupt
|
||||
|
||||
@@ -3,17 +3,17 @@ source: tui/src/bottom_pane/request_user_input/mod.rs
|
||||
expression: "render_snapshot(&overlay, area)"
|
||||
---
|
||||
|
||||
Question 1/1
|
||||
Area
|
||||
Question 1/1 (1 unanswered)
|
||||
Choose an option.
|
||||
(x) Option 1 First choice.
|
||||
( ) Option 2 Second choice.
|
||||
( ) Option 3 Third choice.
|
||||
Notes for Option 1
|
||||
|
||||
› 1. Option 1 First choice.
|
||||
2. Option 2 Second choice.
|
||||
3. Option 3 Third choice.
|
||||
|
||||
› Add notes
|
||||
|
||||
|
||||
|
||||
|
||||
Option 1 of 3 | ↑/↓ scroll | Tab: clear notes | Enter: submit answer | Esc: interrupt
|
||||
|
||||
tab to clear notes | enter to submit answer | esc to interrupt
|
||||
|
||||
@@ -3,13 +3,13 @@ source: tui/src/bottom_pane/request_user_input/mod.rs
|
||||
expression: "render_snapshot(&overlay, area)"
|
||||
---
|
||||
|
||||
Question 1/1
|
||||
Next Step
|
||||
Question 1/1 (1 unanswered)
|
||||
What would you like to do next?
|
||||
|
||||
( ) Discuss a code change (Recommended) Walk through a plan and edit code together.
|
||||
( ) Run tests Pick a crate and run its tests.
|
||||
( ) Review a diff Summarize or review current changes.
|
||||
(x) Refactor Tighten structure and remove dead code.
|
||||
1. Discuss a code change (Recommended) Walk through a plan and edit code together.
|
||||
2. Run tests Pick a crate and run its tests.
|
||||
3. Review a diff Summarize or review current changes.
|
||||
› 4. Refactor Tighten structure and remove dead code.
|
||||
5. Ship it Finalize and open a PR.
|
||||
|
||||
Option 4 of 5 | ↑/↓ scroll | Tab: add notes | Enter: submit answer | Esc: interrupt
|
||||
tab to add notes | enter to submit answer | esc to interrupt
|
||||
|
||||
@@ -4,10 +4,10 @@ expression: "render_snapshot(&overlay, area)"
|
||||
---
|
||||
|
||||
Question 1/1 (1 unanswered)
|
||||
Area
|
||||
Choose an option.
|
||||
|
||||
( ) Option 1 First choice.
|
||||
( ) Option 2 Second choice.
|
||||
› 1. Option 1 First choice.
|
||||
2. Option 2 Second choice.
|
||||
3. Option 3 Third choice.
|
||||
|
||||
Option 1 of 3 | ↑/↓ scroll | Tab: add notes | Enter: submit answer | Esc: interrupt
|
||||
tab to add notes | enter to submit answer | esc to interrupt
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/request_user_input/mod.rs
|
||||
expression: "render_snapshot(&overlay, area)"
|
||||
---
|
||||
|
||||
Submit with unanswered questions?
|
||||
2 unanswered questions
|
||||
|
||||
› 1. Proceed Submit with 2 unanswered questions.
|
||||
2. Go back Return to the first unanswered question.
|
||||
|
||||
|
||||
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -3,12 +3,11 @@ source: tui/src/bottom_pane/request_user_input/mod.rs
|
||||
expression: "render_snapshot(&overlay, area)"
|
||||
---
|
||||
|
||||
Question 1/1
|
||||
Next Step
|
||||
Question 1/1 (1 unanswered)
|
||||
Choose the next step for this task.
|
||||
|
||||
(x) Discuss a code change Walk through a plan, then implement it together with careful checks.
|
||||
( ) Run targeted tests Pick the most relevant crate and validate the current behavior first.
|
||||
( ) Review the diff Summarize the changes and highlight the most important risks and gaps.
|
||||
› 1. Discuss a code change Walk through a plan, then implement it together with careful checks.
|
||||
2. Run targeted tests Pick the most relevant crate and validate the current behavior first.
|
||||
3. Review the diff Summarize the changes and highlight the most important risks and gaps.
|
||||
|
||||
Option 1 of 3 | ↑/↓ scroll | Tab: add notes | Enter: submit answer | Esc: interrupt
|
||||
tab to add notes | enter to submit answer | esc to interrupt
|
||||
|
||||
Reference in New Issue
Block a user