mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
Compare commits
4 Commits
response-a
...
nornagon/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3093612c8 | ||
|
|
def2e81fdf | ||
|
|
b313f39e48 | ||
|
|
5d8b9770e6 |
2
codex-rs/Cargo.lock
generated
2
codex-rs/Cargo.lock
generated
@@ -975,6 +975,7 @@ dependencies = [
|
||||
"supports-color",
|
||||
"tempfile",
|
||||
"textwrap 0.16.2",
|
||||
"tiny_http",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
@@ -984,6 +985,7 @@ dependencies = [
|
||||
"unicode-width 0.1.14",
|
||||
"url",
|
||||
"vt100",
|
||||
"webbrowser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -40,21 +40,17 @@ codex-login = { workspace = true }
|
||||
codex-ollama = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
color-eyre = { workspace = true }
|
||||
crossterm = { workspace = true, features = [
|
||||
"bracketed-paste",
|
||||
"event-stream",
|
||||
] }
|
||||
dirs = { workspace = true }
|
||||
crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] }
|
||||
diffy = { workspace = true }
|
||||
image = { workspace = true, features = [
|
||||
"jpeg",
|
||||
"png",
|
||||
] }
|
||||
dirs = { workspace = true }
|
||||
image = { workspace = true, features = ["jpeg", "png"] }
|
||||
itertools = { workspace = true }
|
||||
lazy_static = { workspace = true }
|
||||
mcp-types = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
path-clean = { workspace = true }
|
||||
pathdiff = { workspace = true }
|
||||
pulldown-cmark = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
ratatui = { workspace = true, features = [
|
||||
"scrolling-regions",
|
||||
@@ -70,6 +66,7 @@ strum_macros = { workspace = true }
|
||||
supports-color = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
textwrap = { workspace = true }
|
||||
tiny_http = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"io-std",
|
||||
"macros",
|
||||
@@ -81,11 +78,11 @@ tokio-stream = { workspace = true }
|
||||
tracing = { workspace = true, features = ["log"] }
|
||||
tracing-appender = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
pulldown-cmark = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
unicode-width = { workspace = true }
|
||||
url = { workspace = true }
|
||||
pathdiff = { workspace = true }
|
||||
webbrowser = { workspace = true }
|
||||
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = { workspace = true }
|
||||
|
||||
@@ -298,6 +298,18 @@ impl App {
|
||||
));
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::ReviewServerStarted { url, handle } => {
|
||||
self.chat_widget.on_review_server_started(url, handle);
|
||||
}
|
||||
AppEvent::ReviewServerFailed { message } => {
|
||||
self.chat_widget.on_review_server_failed(message);
|
||||
}
|
||||
AppEvent::ReviewSubmitted { composer_text } => {
|
||||
self.chat_widget.on_review_submitted(composer_text);
|
||||
}
|
||||
AppEvent::ReviewCancelled => {
|
||||
self.chat_widget.on_review_cancelled();
|
||||
}
|
||||
AppEvent::StartFileSearch(query) => {
|
||||
if !query.is_empty() {
|
||||
self.file_search.on_user_query(query);
|
||||
|
||||
@@ -41,6 +41,25 @@ pub(crate) enum AppEvent {
|
||||
/// Result of computing a `/diff` command.
|
||||
DiffResult(String),
|
||||
|
||||
/// The `/review` server started successfully.
|
||||
ReviewServerStarted {
|
||||
url: String,
|
||||
handle: crate::web_review::ReviewServerHandle,
|
||||
},
|
||||
|
||||
/// The `/review` server failed to start.
|
||||
ReviewServerFailed {
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// Review comments submitted from the web UI.
|
||||
ReviewSubmitted {
|
||||
composer_text: String,
|
||||
},
|
||||
|
||||
/// Review cancelled from the web UI.
|
||||
ReviewCancelled,
|
||||
|
||||
InsertHistoryCell(Box<dyn HistoryCell>),
|
||||
|
||||
StartCommitAnimation,
|
||||
|
||||
@@ -41,6 +41,14 @@ impl ApprovalModalView {
|
||||
}
|
||||
|
||||
impl BottomPaneView for ApprovalModalView {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
self.current.handle_key_event(key_event);
|
||||
self.maybe_advance();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::any::Any;
|
||||
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
@@ -6,7 +8,11 @@ use ratatui::layout::Rect;
|
||||
use super::CancellationEvent;
|
||||
|
||||
/// Trait implemented by every view that can be shown in the bottom pane.
|
||||
pub(crate) trait BottomPaneView {
|
||||
pub(crate) trait BottomPaneView: Any {
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any;
|
||||
|
||||
/// Handle a key event while the view is active. A redraw is always
|
||||
/// scheduled after this call.
|
||||
fn handle_key_event(&mut self, _key_event: KeyEvent) {}
|
||||
|
||||
@@ -1893,6 +1893,39 @@ mod tests {
|
||||
assert_eq!(composer.textarea.cursor(), composer.textarea.text().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_opine_dispatches_command() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
type_chars_humanlike(&mut composer, &['/', 'o', 'p', 'i', 'n', 'e']);
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
match result {
|
||||
InputResult::Command(cmd) => {
|
||||
assert_eq!(cmd.command(), "opine");
|
||||
}
|
||||
InputResult::Submitted(text) => {
|
||||
panic!("expected command dispatch, but composer submitted literal text: {text}")
|
||||
}
|
||||
InputResult::None => panic!("expected Command result for '/opine'"),
|
||||
}
|
||||
assert!(composer.textarea.is_empty(), "composer should be cleared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_mention_dispatches_command_and_inserts_at() {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::slash_command::SlashCommand;
|
||||
use crate::slash_command::built_in_slash_commands;
|
||||
use codex_common::fuzzy_match::fuzzy_match;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// A selectable item in the popup: either a built-in command or a user prompt.
|
||||
@@ -131,16 +132,15 @@ impl CommandPopup {
|
||||
}
|
||||
// When filtering, sort by ascending score and then by name for stability.
|
||||
out.sort_by(|a, b| {
|
||||
a.2.cmp(&b.2).then_with(|| {
|
||||
let an = match a.0 {
|
||||
CommandItem::Builtin(c) => c.command(),
|
||||
CommandItem::UserPrompt(i) => &self.prompts[i].name,
|
||||
};
|
||||
let bn = match b.0 {
|
||||
CommandItem::Builtin(c) => c.command(),
|
||||
CommandItem::UserPrompt(i) => &self.prompts[i].name,
|
||||
};
|
||||
an.cmp(bn)
|
||||
a.2.cmp(&b.2).then_with(|| match (&a.0, &b.0) {
|
||||
(CommandItem::Builtin(a_cmd), CommandItem::Builtin(b_cmd)) => {
|
||||
self.builtin_index(*a_cmd).cmp(&self.builtin_index(*b_cmd))
|
||||
}
|
||||
(CommandItem::Builtin(_), CommandItem::UserPrompt(_)) => Ordering::Less,
|
||||
(CommandItem::UserPrompt(_), CommandItem::Builtin(_)) => Ordering::Greater,
|
||||
(CommandItem::UserPrompt(a_idx), CommandItem::UserPrompt(b_idx)) => {
|
||||
self.prompts[*a_idx].name.cmp(&self.prompts[*b_idx].name)
|
||||
}
|
||||
})
|
||||
});
|
||||
out
|
||||
@@ -176,6 +176,13 @@ impl CommandPopup {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn builtin_index(&self, command: SlashCommand) -> usize {
|
||||
self.builtins
|
||||
.iter()
|
||||
.position(|(_, cmd)| *cmd == command)
|
||||
.unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
/// Move the selection cursor one step up.
|
||||
pub(crate) fn move_up(&mut self) {
|
||||
let len = self.filtered_items().len();
|
||||
|
||||
@@ -55,6 +55,14 @@ impl CustomPromptView {
|
||||
}
|
||||
|
||||
impl BottomPaneView for CustomPromptView {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
|
||||
@@ -217,6 +217,14 @@ impl ListSelectionView {
|
||||
}
|
||||
|
||||
impl BottomPaneView for ListSelectionView {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
|
||||
@@ -27,6 +27,7 @@ mod list_selection_view;
|
||||
pub(crate) use list_selection_view::SelectionViewParams;
|
||||
mod paste_burst;
|
||||
pub mod popup_consts;
|
||||
mod review_modal_view;
|
||||
mod scroll_state;
|
||||
mod selection_popup_common;
|
||||
mod textarea;
|
||||
@@ -45,6 +46,8 @@ use crate::status_indicator_widget::StatusIndicatorWidget;
|
||||
use approval_modal_view::ApprovalModalView;
|
||||
pub(crate) use list_selection_view::SelectionAction;
|
||||
pub(crate) use list_selection_view::SelectionItem;
|
||||
pub(crate) use review_modal_view::ReviewModalState;
|
||||
use review_modal_view::ReviewModalView;
|
||||
|
||||
/// Pane displayed in the lower half of the chat UI.
|
||||
pub(crate) struct BottomPane {
|
||||
@@ -341,6 +344,30 @@ impl BottomPane {
|
||||
self.push_view(Box::new(view));
|
||||
}
|
||||
|
||||
pub(crate) fn show_review_modal(&mut self, state: ReviewModalState) {
|
||||
if let Some(view) = self.view_stack.last_mut()
|
||||
&& let Some(review_view) = view.as_any_mut().downcast_mut::<ReviewModalView>()
|
||||
{
|
||||
review_view.set_state(state);
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
let view = ReviewModalView::new(state, self.app_event_tx.clone());
|
||||
self.pause_status_timer_for_modal();
|
||||
self.push_view(Box::new(view));
|
||||
}
|
||||
|
||||
pub(crate) fn hide_review_modal(&mut self) {
|
||||
if let Some(view) = self.active_view().as_ref()
|
||||
&& view.as_any().is::<ReviewModalView>()
|
||||
{
|
||||
self.view_stack.pop();
|
||||
self.on_active_view_complete();
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the queued messages shown under the status header.
|
||||
pub(crate) fn set_queued_user_messages(&mut self, queued: Vec<String>) {
|
||||
self.queued_user_messages = queued.clone();
|
||||
|
||||
111
codex-rs/tui/src/bottom_pane/review_modal_view.rs
Normal file
111
codex-rs/tui/src/bottom_pane/review_modal_view.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::key_hint;
|
||||
use crate::render::line_utils::prefix_lines;
|
||||
|
||||
use super::BottomPane;
|
||||
use super::BottomPaneView;
|
||||
use super::CancellationEvent;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ReviewModalState {
|
||||
Launching,
|
||||
Active { url: String },
|
||||
Cancelling,
|
||||
}
|
||||
|
||||
pub(crate) struct ReviewModalView {
|
||||
state: ReviewModalState,
|
||||
cancel_requested: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
}
|
||||
|
||||
impl ReviewModalView {
|
||||
pub(crate) fn new(state: ReviewModalState, app_event_tx: AppEventSender) -> Self {
|
||||
Self {
|
||||
state,
|
||||
cancel_requested: false,
|
||||
app_event_tx,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_state(&mut self, state: ReviewModalState) {
|
||||
if !matches!(state, ReviewModalState::Cancelling) {
|
||||
self.cancel_requested = false;
|
||||
}
|
||||
self.state = state;
|
||||
}
|
||||
|
||||
fn lines(&self) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
let mut status_lines = vec![vec!["Web review in progress".bold()].into()];
|
||||
match &self.state {
|
||||
ReviewModalState::Launching => {
|
||||
status_lines.push(vec!["Starting web review server...".into()].into());
|
||||
}
|
||||
ReviewModalState::Active { url } => {
|
||||
status_lines.push(vec!["Open this URL in your browser:".into()].into());
|
||||
status_lines.push(vec![url.clone().cyan().underlined()].into());
|
||||
}
|
||||
ReviewModalState::Cancelling => {
|
||||
status_lines.push(vec!["Cancelling web review...".into()].into());
|
||||
}
|
||||
}
|
||||
let prefix = "▌ ".dim();
|
||||
lines.extend(prefix_lines(status_lines, prefix.clone(), prefix.clone()));
|
||||
lines.push("".into());
|
||||
match &self.state {
|
||||
ReviewModalState::Cancelling => {
|
||||
lines.extend(prefix_lines(
|
||||
vec![vec!["Waiting for the review to stop...".into()].into()],
|
||||
prefix.clone(),
|
||||
prefix,
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
lines.push(vec![" ".into(), key_hint::ctrl('C'), " cancel review".dim()].into());
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for ReviewModalView {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, _key_event: KeyEvent) {}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
let paragraph = Paragraph::new(self.lines()).wrap(Wrap { trim: false });
|
||||
paragraph.line_count(width) as u16
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let paragraph = Paragraph::new(self.lines()).wrap(Wrap { trim: false });
|
||||
paragraph.render(area, buf);
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
if !self.cancel_requested {
|
||||
self.app_event_tx.send(AppEvent::ReviewCancelled);
|
||||
self.cancel_requested = true;
|
||||
self.state = ReviewModalState::Cancelling;
|
||||
}
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
assertion_line: 1791
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"▌ /mo "
|
||||
"▌ "
|
||||
"▌ /model choose what model and reasoning effort to use "
|
||||
"▌ /mention start a mention "
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::VecDeque;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -43,6 +44,7 @@ use codex_core::protocol::WebSearchBeginEvent;
|
||||
use codex_core::protocol::WebSearchEndEvent;
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use crossterm::Command;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
@@ -63,6 +65,7 @@ use crate::bottom_pane::BottomPane;
|
||||
use crate::bottom_pane::BottomPaneParams;
|
||||
use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::bottom_pane::ReviewModalState;
|
||||
use crate::bottom_pane::SelectionAction;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use crate::bottom_pane::SelectionViewParams;
|
||||
@@ -70,6 +73,7 @@ use crate::bottom_pane::custom_prompt_view::CustomPromptView;
|
||||
use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE;
|
||||
use crate::clipboard_paste::paste_image_to_temp_png;
|
||||
use crate::diff_render::display_path_for;
|
||||
use crate::get_git_diff::DiffFormat;
|
||||
use crate::get_git_diff::get_git_diff;
|
||||
use crate::history_cell;
|
||||
use crate::history_cell::AgentMessageCell;
|
||||
@@ -81,6 +85,8 @@ use crate::markdown::append_markdown;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::text_formatting::truncate_text;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::web_review::ReviewServerHandle;
|
||||
use crate::web_review::{self};
|
||||
// streaming internals are provided by crate::streaming and crate::markdown_stream
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
mod interrupts;
|
||||
@@ -217,6 +223,10 @@ pub(crate) struct ChatWidget {
|
||||
// List of ghost commits corresponding to each turn.
|
||||
ghost_snapshots: Vec<GhostCommit>,
|
||||
ghost_snapshots_disabled: bool,
|
||||
|
||||
// Review server handle
|
||||
review_server: Option<ReviewServerHandle>,
|
||||
review_server_launching: bool,
|
||||
}
|
||||
|
||||
struct UserMessage {
|
||||
@@ -811,6 +821,8 @@ impl ChatWidget {
|
||||
is_review_mode: false,
|
||||
ghost_snapshots: Vec::new(),
|
||||
ghost_snapshots_disabled: true,
|
||||
review_server: None,
|
||||
review_server_launching: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -872,6 +884,8 @@ impl ChatWidget {
|
||||
is_review_mode: false,
|
||||
ghost_snapshots: Vec::new(),
|
||||
ghost_snapshots_disabled: true,
|
||||
review_server: None,
|
||||
review_server_launching: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1011,7 +1025,7 @@ impl ChatWidget {
|
||||
self.add_diff_in_progress();
|
||||
let tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let text = match get_git_diff().await {
|
||||
let text = match get_git_diff(DiffFormat::Ansi).await {
|
||||
Ok((is_git_repo, diff_text)) => {
|
||||
if is_git_repo {
|
||||
diff_text
|
||||
@@ -1024,6 +1038,32 @@ impl ChatWidget {
|
||||
tx.send(AppEvent::DiffResult(text));
|
||||
});
|
||||
}
|
||||
SlashCommand::Opine => {
|
||||
if self.review_server_launching || self.review_server.is_some() {
|
||||
self.add_info_message(
|
||||
"A web review session is already running.".to_string(),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.review_server_launching = true;
|
||||
self.bottom_pane
|
||||
.show_review_modal(ReviewModalState::Launching);
|
||||
let tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
match web_review::start_review_server(tx.clone()).await {
|
||||
Ok(review) => {
|
||||
let web_review::ReviewServer { handle, url } = review;
|
||||
tx.send(AppEvent::ReviewServerStarted { url, handle });
|
||||
}
|
||||
Err(err) => {
|
||||
tx.send(AppEvent::ReviewServerFailed {
|
||||
message: err.message(),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
SlashCommand::Mention => {
|
||||
self.insert_str("@");
|
||||
}
|
||||
@@ -1439,6 +1479,50 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn on_review_server_started(&mut self, url: String, handle: ReviewServerHandle) {
|
||||
self.review_server_launching = false;
|
||||
self.clear_review_server();
|
||||
self.review_server = Some(handle);
|
||||
self.bottom_pane
|
||||
.show_review_modal(ReviewModalState::Active { url: url.clone() });
|
||||
|
||||
let can_open_browser = std::env::var_os("CODEX_SANDBOX").is_none_or(|v| v != "seatbelt")
|
||||
&& std::env::var_os("CODEX_SANDBOX_NETWORK_DISABLED").is_none_or(|v| v != "1");
|
||||
let open_result = if can_open_browser {
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| webbrowser::open(&url)))
|
||||
} else {
|
||||
Ok(Ok(()))
|
||||
};
|
||||
match open_result {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(err)) => {
|
||||
self.add_error_message(format!("Failed to launch browser automatically: {err}"));
|
||||
}
|
||||
Err(_) => {
|
||||
self.add_error_message("Failed to launch browser automatically.".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_review_server_failed(&mut self, message: String) {
|
||||
self.review_server_launching = false;
|
||||
self.clear_review_server();
|
||||
self.add_error_message(message);
|
||||
}
|
||||
|
||||
pub(crate) fn on_review_submitted(&mut self, composer_text: String) {
|
||||
self.review_server_launching = false;
|
||||
self.clear_review_server();
|
||||
self.bottom_pane.set_composer_text(composer_text);
|
||||
steal_terminal_focus();
|
||||
}
|
||||
|
||||
pub(crate) fn on_review_cancelled(&mut self) {
|
||||
self.review_server_launching = false;
|
||||
self.clear_review_server();
|
||||
steal_terminal_focus();
|
||||
}
|
||||
|
||||
pub(crate) fn add_status_output(&mut self) {
|
||||
let default_usage;
|
||||
let usage_ref = if let Some(ti) = &self.token_info {
|
||||
@@ -1640,6 +1724,13 @@ impl ChatWidget {
|
||||
self.bottom_pane.set_composer_text(text);
|
||||
}
|
||||
|
||||
fn clear_review_server(&mut self) {
|
||||
if let Some(mut handle) = self.review_server.take() {
|
||||
handle.shutdown();
|
||||
}
|
||||
self.bottom_pane.hide_review_modal();
|
||||
}
|
||||
|
||||
pub(crate) fn show_esc_backtrack_hint(&mut self) {
|
||||
self.bottom_pane.show_esc_backtrack_hint();
|
||||
}
|
||||
@@ -1993,6 +2084,32 @@ fn extract_first_bold(s: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn steal_terminal_focus() {
|
||||
let _ = ratatui::crossterm::execute!(io::stdout(), StealFocus);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct StealFocus;
|
||||
|
||||
// OSC 1337 is iTerm2-specific; other terminals ignore this focus request.
|
||||
impl Command for StealFocus {
|
||||
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
|
||||
write!(f, "\x1b]1337;StealFocus\x07")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
Err(std::io::Error::other(
|
||||
"tried to execute StealFocus using WinAPI; use ANSI instead",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_ansi_code_supported(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn show_review_commit_picker_with_entries(
|
||||
chat: &mut ChatWidget,
|
||||
@@ -2038,3 +2155,9 @@ pub(crate) fn show_review_commit_picker_with_entries(
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests;
|
||||
|
||||
impl Drop for ChatWidget {
|
||||
fn drop(&mut self) {
|
||||
self.clear_review_server();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +337,8 @@ fn make_chatwidget_manual() -> (
|
||||
is_review_mode: false,
|
||||
ghost_snapshots: Vec::new(),
|
||||
ghost_snapshots_disabled: false,
|
||||
review_server: None,
|
||||
review_server_launching: false,
|
||||
};
|
||||
(widget, rx, op_rx)
|
||||
}
|
||||
|
||||
@@ -8,21 +8,42 @@
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use tokio::process::Command;
|
||||
|
||||
/// The format to use when emitting diff output.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum DiffFormat {
|
||||
/// Emit ANSI-colored diffs (used by the `/diff` overlay).
|
||||
Ansi,
|
||||
/// Emit plain-text diffs (used by the web review API).
|
||||
Plain,
|
||||
}
|
||||
|
||||
impl DiffFormat {
|
||||
fn color_arg(self) -> &'static str {
|
||||
match self {
|
||||
DiffFormat::Ansi => "always",
|
||||
DiffFormat::Plain => "never",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return value of [`get_git_diff`].
|
||||
///
|
||||
/// * `bool` – Whether the current working directory is inside a Git repo.
|
||||
/// * `String` – The concatenated diff (may be empty).
|
||||
pub(crate) async fn get_git_diff() -> io::Result<(bool, String)> {
|
||||
pub(crate) async fn get_git_diff(format: DiffFormat) -> io::Result<(bool, String)> {
|
||||
// First check if we are inside a Git repository.
|
||||
if !inside_git_repo().await? {
|
||||
return Ok((false, String::new()));
|
||||
}
|
||||
|
||||
// Run tracked diff and untracked file listing in parallel.
|
||||
let color_flag: Arc<str> = format!("--color={}", format.color_arg()).into();
|
||||
let diff_args = ["diff", color_flag.as_ref()];
|
||||
let (tracked_diff_res, untracked_output_res) = tokio::join!(
|
||||
run_git_capture_diff(&["diff", "--color"]),
|
||||
run_git_capture_diff(&diff_args),
|
||||
run_git_capture_stdout(&["ls-files", "--others", "--exclude-standard"]),
|
||||
);
|
||||
let tracked_diff = tracked_diff_res?;
|
||||
@@ -43,9 +64,17 @@ pub(crate) async fn get_git_diff() -> io::Result<(bool, String)> {
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
let null_path = null_path.clone();
|
||||
let color = color_flag.clone();
|
||||
let file = file.to_string();
|
||||
join_set.spawn(async move {
|
||||
let args = ["diff", "--color", "--no-index", "--", &null_path, &file];
|
||||
let args = [
|
||||
"diff",
|
||||
color.as_ref(),
|
||||
"--no-index",
|
||||
"--",
|
||||
&null_path,
|
||||
&file,
|
||||
];
|
||||
run_git_capture_diff(&args).await
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ mod tui;
|
||||
mod ui_consts;
|
||||
mod user_approval_widget;
|
||||
mod version;
|
||||
mod web_review;
|
||||
mod wrapping;
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::key_hint;
|
||||
use crate::render::line_utils::push_owned_lines;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
@@ -11,9 +12,6 @@ use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Styled;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
@@ -61,14 +59,13 @@ const PAGER_KEY_HINTS: &[(&str, &str)] = &[
|
||||
|
||||
// Render a single line of key hints from (key, description) pairs.
|
||||
fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&str, &str)]) {
|
||||
let key_hint_style = Style::default().fg(Color::Cyan);
|
||||
let mut spans: Vec<Span<'static>> = vec![" ".into()];
|
||||
let mut first = true;
|
||||
for (key, desc) in pairs {
|
||||
if !first {
|
||||
spans.push(" ".into());
|
||||
}
|
||||
spans.push(Span::from(key.to_string()).set_style(key_hint_style));
|
||||
spans.push(key_hint::plain(*key));
|
||||
spans.push(" ".into());
|
||||
spans.push(Span::from(desc.to_string()));
|
||||
first = false;
|
||||
|
||||
@@ -20,6 +20,7 @@ pub enum SlashCommand {
|
||||
Compact,
|
||||
Undo,
|
||||
Diff,
|
||||
Opine,
|
||||
Mention,
|
||||
Status,
|
||||
Mcp,
|
||||
@@ -40,7 +41,8 @@ impl SlashCommand {
|
||||
SlashCommand::Undo => "restore the workspace to the last Codex snapshot",
|
||||
SlashCommand::Quit => "exit Codex",
|
||||
SlashCommand::Diff => "show git diff (including untracked files)",
|
||||
SlashCommand::Mention => "mention a file",
|
||||
SlashCommand::Opine => "open the web review interface",
|
||||
SlashCommand::Mention => "start a mention",
|
||||
SlashCommand::Status => "show current session configuration and token usage",
|
||||
SlashCommand::Model => "choose what model and reasoning effort to use",
|
||||
SlashCommand::Approvals => "choose what Codex can do without approval",
|
||||
@@ -69,6 +71,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Review
|
||||
| SlashCommand::Logout => false,
|
||||
SlashCommand::Diff
|
||||
| SlashCommand::Opine
|
||||
| SlashCommand::Mention
|
||||
| SlashCommand::Status
|
||||
| SlashCommand::Mcp
|
||||
|
||||
2388
codex-rs/tui/src/web_review.html
Normal file
2388
codex-rs/tui/src/web_review.html
Normal file
File diff suppressed because it is too large
Load Diff
710
codex-rs/tui/src/web_review.rs
Normal file
710
codex-rs/tui/src/web_review.rs
Normal file
@@ -0,0 +1,710 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::thread;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::get_git_diff::DiffFormat;
|
||||
use crate::get_git_diff::get_git_diff;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use tiny_http::Header;
|
||||
use tiny_http::Method;
|
||||
use tiny_http::Request;
|
||||
use tiny_http::Response;
|
||||
use tiny_http::Server;
|
||||
use tokio::process::Command;
|
||||
|
||||
fn collect_post_files(diff: &str) -> BTreeMap<String, String> {
|
||||
let mut paths = BTreeMap::new();
|
||||
for line in diff.lines() {
|
||||
if let Some(rest) = line.strip_prefix("+++ ") {
|
||||
if rest == "/dev/null" {
|
||||
continue;
|
||||
}
|
||||
let trimmed = rest.strip_prefix("b/").unwrap_or(rest);
|
||||
paths.entry(trimmed.to_string()).or_insert(());
|
||||
}
|
||||
}
|
||||
|
||||
let mut contents = BTreeMap::new();
|
||||
for (path, ()) in paths {
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => {
|
||||
contents.insert(path, text);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!("failed to read file for review context {path:?}: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
contents
|
||||
}
|
||||
|
||||
pub(crate) struct ReviewServer {
|
||||
pub(crate) handle: ReviewServerHandle,
|
||||
pub(crate) url: String,
|
||||
}
|
||||
|
||||
pub(crate) struct ReviewServerHandle {
|
||||
port: u16,
|
||||
server: Arc<Server>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
join_handle: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ReviewServerHandle {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ReviewServerHandle")
|
||||
.field("port", &self.port)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ReviewServer {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ReviewServer")
|
||||
.field("url", &self.url)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl ReviewServerHandle {
|
||||
pub(crate) fn shutdown(&mut self) {
|
||||
let _ = self.shutdown.swap(true, Ordering::SeqCst);
|
||||
self.server.unblock();
|
||||
if let Some(handle) = self.join_handle.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ReviewServerHandle {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum ReviewStartError {
|
||||
NotInGitRepo,
|
||||
Diff(std::io::Error),
|
||||
Server(std::io::Error),
|
||||
}
|
||||
|
||||
impl ReviewStartError {
|
||||
pub(crate) fn message(&self) -> String {
|
||||
match self {
|
||||
Self::NotInGitRepo => "`/review` — not inside a git repository".to_string(),
|
||||
Self::Diff(err) => format!("Failed to compute diff: {err}"),
|
||||
Self::Server(err) => format!("Failed to start review server: {err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ReviewSharedState {
|
||||
diff: Arc<str>,
|
||||
repo_path: Option<String>,
|
||||
post_files: Arc<BTreeMap<String, String>>, // map from path -> post-change contents
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum ReviewCommentSide {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub(crate) struct ReviewComment {
|
||||
pub(crate) path: String,
|
||||
pub(crate) line_start: u32,
|
||||
pub(crate) line_end: u32,
|
||||
pub(crate) side: ReviewCommentSide,
|
||||
pub(crate) text: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SubmitPayload {
|
||||
#[serde(default)]
|
||||
summary: Option<String>,
|
||||
#[serde(default)]
|
||||
comments: Vec<ReviewComment>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DiffResponse<'a> {
|
||||
repo_path: Option<&'a str>,
|
||||
diff: &'a str,
|
||||
post_files: &'a BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
const REVIEW_APP_HTML: &str = include_str!("web_review.html");
|
||||
|
||||
pub(crate) async fn start_review_server(
|
||||
app_event_tx: AppEventSender,
|
||||
) -> Result<ReviewServer, ReviewStartError> {
|
||||
let (is_repo, diff) = get_git_diff(DiffFormat::Plain)
|
||||
.await
|
||||
.map_err(ReviewStartError::Diff)?;
|
||||
if !is_repo {
|
||||
return Err(ReviewStartError::NotInGitRepo);
|
||||
}
|
||||
|
||||
let server = Server::http(("127.0.0.1", 0))
|
||||
.map_err(|err| ReviewStartError::Server(io::Error::other(err)))?;
|
||||
let port = server
|
||||
.server_addr()
|
||||
.to_ip()
|
||||
.map(|addr| addr.port())
|
||||
.ok_or_else(|| {
|
||||
ReviewStartError::Server(io::Error::other("failed to determine server port"))
|
||||
})?;
|
||||
let repo_path = match git_repo_root().await {
|
||||
Ok(Some(path)) => Some(path),
|
||||
Ok(None) => None,
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to determine git repo root: {err}");
|
||||
None
|
||||
}
|
||||
};
|
||||
let server = Arc::new(server);
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let post_files = collect_post_files(&diff);
|
||||
let shared = Arc::new(ReviewSharedState {
|
||||
diff: diff.into(),
|
||||
repo_path,
|
||||
post_files: Arc::new(post_files),
|
||||
});
|
||||
|
||||
let server_clone = server.clone();
|
||||
let shutdown_clone = shutdown.clone();
|
||||
let shared_clone = shared;
|
||||
let join_handle = thread::spawn(move || {
|
||||
run_server_loop(server_clone, shared_clone, shutdown_clone, app_event_tx)
|
||||
});
|
||||
|
||||
let handle = ReviewServerHandle {
|
||||
port,
|
||||
server,
|
||||
shutdown,
|
||||
join_handle: Some(join_handle),
|
||||
};
|
||||
|
||||
let url = format!("http://127.0.0.1:{port}/");
|
||||
Ok(ReviewServer { handle, url })
|
||||
}
|
||||
|
||||
fn run_server_loop(
|
||||
server: Arc<Server>,
|
||||
data: Arc<ReviewSharedState>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
app_event_tx: AppEventSender,
|
||||
) {
|
||||
while !shutdown.load(Ordering::SeqCst) {
|
||||
match server.recv() {
|
||||
Ok(request) => {
|
||||
if handle_request(request, &data, &app_event_tx) {
|
||||
shutdown.store(true, Ordering::SeqCst);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
if shutdown.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_request(
|
||||
mut request: Request,
|
||||
data: &ReviewSharedState,
|
||||
app_event_tx: &AppEventSender,
|
||||
) -> bool {
|
||||
let path = request.url().split('?').next().unwrap_or("/");
|
||||
match (request.method(), path) {
|
||||
(&Method::Get, "/") => {
|
||||
respond_html(request, REVIEW_APP_HTML);
|
||||
false
|
||||
}
|
||||
(&Method::Get, "/favicon.ico") => {
|
||||
let _ = request.respond(Response::empty(204));
|
||||
false
|
||||
}
|
||||
(&Method::Get, "/api/diff") => {
|
||||
let payload = DiffResponse {
|
||||
repo_path: data.repo_path.as_deref(),
|
||||
diff: &data.diff,
|
||||
post_files: &data.post_files,
|
||||
};
|
||||
if let Ok(body) = serde_json::to_string(&payload) {
|
||||
respond_json(request, body);
|
||||
} else {
|
||||
respond_json(
|
||||
request,
|
||||
"{\"error\":\"failed to serialize diff\"}".to_string(),
|
||||
);
|
||||
}
|
||||
false
|
||||
}
|
||||
(&Method::Post, "/api/submit") => {
|
||||
let mut body = String::new();
|
||||
{
|
||||
let reader = request.as_reader();
|
||||
if reader.read_to_string(&mut body).is_err() {
|
||||
respond_status(request, 400, "Invalid body");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
match serde_json::from_str::<SubmitPayload>(&body) {
|
||||
Ok(payload) => {
|
||||
let summary = payload.summary.unwrap_or_default();
|
||||
let message = format_review_message(&summary, &payload.comments, &data.diff);
|
||||
app_event_tx.send(AppEvent::ReviewSubmitted {
|
||||
composer_text: message,
|
||||
});
|
||||
respond_status(request, 200, "OK");
|
||||
true
|
||||
}
|
||||
Err(err) => {
|
||||
respond_status(request, 400, &format!("Invalid payload: {err}"));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
(&Method::Post, "/api/cancel") => {
|
||||
app_event_tx.send(AppEvent::ReviewCancelled);
|
||||
respond_status(request, 200, "OK");
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
respond_status(request, 404, "Not found");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn respond_html(request: Request, body: &str) {
|
||||
let mut response = Response::from_string(body.to_string());
|
||||
if let Ok(header) = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) {
|
||||
response.add_header(header);
|
||||
}
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
|
||||
fn respond_json(request: Request, body: String) {
|
||||
let mut response = Response::from_string(body);
|
||||
if let Ok(header) = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]) {
|
||||
response.add_header(header);
|
||||
}
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
|
||||
fn respond_status(request: Request, status: u16, message: &str) {
|
||||
let status = tiny_http::StatusCode(status);
|
||||
let mut response = Response::from_string(message.to_string()).with_status_code(status);
|
||||
if let Ok(header) = Header::from_bytes(&b"Content-Type"[..], &b"text/plain; charset=utf-8"[..])
|
||||
{
|
||||
response.add_header(header);
|
||||
}
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
|
||||
async fn git_repo_root() -> io::Result<Option<String>> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--show-toplevel"])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if path.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(path))
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_review_message(
|
||||
summary: &str,
|
||||
comments: &[ReviewComment],
|
||||
diff: &str,
|
||||
) -> String {
|
||||
let mut output = String::new();
|
||||
let trimmed_summary = summary.trim();
|
||||
let has_summary = !trimmed_summary.is_empty();
|
||||
let has_comments = !comments.is_empty();
|
||||
|
||||
if has_comments {
|
||||
output.push_str("Address the following review comments.\n\n");
|
||||
}
|
||||
|
||||
if has_summary {
|
||||
output.push_str("Review summary:\n");
|
||||
output.push_str(trimmed_summary);
|
||||
output.push('\n');
|
||||
if has_comments {
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if !has_comments {
|
||||
return output;
|
||||
}
|
||||
|
||||
let diff_index = DiffIndex::from_diff(diff);
|
||||
for (idx, comment) in comments.iter().enumerate() {
|
||||
let (start, end) = normalize_range(comment.line_start, comment.line_end);
|
||||
let range_label = if start == end {
|
||||
format!("L{start}")
|
||||
} else {
|
||||
format!("L{start}-{end}")
|
||||
};
|
||||
output.push_str(&format!("## {} {}\n", comment.path, range_label));
|
||||
let snippet = diff_index
|
||||
.snippet_for(comment)
|
||||
.unwrap_or_else(|| "[code snippet unavailable]".to_string());
|
||||
output.push_str("```\n");
|
||||
output.push_str(&snippet);
|
||||
output.push_str("\n```\n\n");
|
||||
output.push_str(comment.text.trim());
|
||||
output.push('\n');
|
||||
if idx + 1 < comments.len() {
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
fn normalize_range(a: u32, b: u32) -> (u32, u32) {
|
||||
if a <= b { (a, b) } else { (b, a) }
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DiffLine {
|
||||
prefix: char,
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl DiffLine {
|
||||
fn new(prefix: char, text: &str) -> Self {
|
||||
Self {
|
||||
prefix,
|
||||
text: text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self) -> String {
|
||||
format!("{}{}", self.prefix, self.text)
|
||||
}
|
||||
}
|
||||
|
||||
struct FileDiffLines {
|
||||
left: BTreeMap<u32, DiffLine>,
|
||||
right: BTreeMap<u32, DiffLine>,
|
||||
}
|
||||
|
||||
impl FileDiffLines {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
left: BTreeMap::new(),
|
||||
right: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DiffIndex {
|
||||
files: HashMap<String, FileDiffLines>,
|
||||
}
|
||||
|
||||
impl DiffIndex {
|
||||
fn from_diff(diff: &str) -> Self {
|
||||
let mut files: HashMap<String, FileDiffLines> = HashMap::new();
|
||||
let mut current_old: Option<String> = None;
|
||||
let mut current_new: Option<String> = None;
|
||||
let mut old_line: u32 = 0;
|
||||
let mut new_line: u32 = 0;
|
||||
|
||||
for line in diff.lines() {
|
||||
if line.starts_with("diff --git ") {
|
||||
current_old = None;
|
||||
current_new = None;
|
||||
old_line = 0;
|
||||
new_line = 0;
|
||||
continue;
|
||||
}
|
||||
if let Some(path) = parse_file_path_line(line, "--- ") {
|
||||
current_old = path;
|
||||
ensure_entry(&mut files, current_old.as_deref());
|
||||
continue;
|
||||
}
|
||||
if let Some(path) = parse_file_path_line(line, "+++ ") {
|
||||
current_new = path;
|
||||
ensure_entry(&mut files, current_new.as_deref());
|
||||
continue;
|
||||
}
|
||||
if let Some((old_start, new_start)) = parse_hunk_header(line) {
|
||||
old_line = old_start;
|
||||
new_line = new_start;
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.starts_with('+') && !line.starts_with("+++") {
|
||||
let text = line.get(1..).unwrap_or("");
|
||||
insert_line(
|
||||
&mut files,
|
||||
current_new.as_ref(),
|
||||
ReviewCommentSide::Right,
|
||||
new_line,
|
||||
'+',
|
||||
text,
|
||||
);
|
||||
if current_new != current_old {
|
||||
insert_line(
|
||||
&mut files,
|
||||
current_old.as_ref(),
|
||||
ReviewCommentSide::Right,
|
||||
new_line,
|
||||
'+',
|
||||
text,
|
||||
);
|
||||
}
|
||||
new_line = new_line.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.starts_with('-') && !line.starts_with("---") {
|
||||
let text = line.get(1..).unwrap_or("");
|
||||
insert_line(
|
||||
&mut files,
|
||||
current_old.as_ref(),
|
||||
ReviewCommentSide::Left,
|
||||
old_line,
|
||||
'-',
|
||||
text,
|
||||
);
|
||||
if current_new != current_old {
|
||||
insert_line(
|
||||
&mut files,
|
||||
current_new.as_ref(),
|
||||
ReviewCommentSide::Left,
|
||||
old_line,
|
||||
'-',
|
||||
text,
|
||||
);
|
||||
}
|
||||
old_line = old_line.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.starts_with(' ') {
|
||||
let text = line.get(1..).unwrap_or("");
|
||||
insert_line(
|
||||
&mut files,
|
||||
current_old.as_ref(),
|
||||
ReviewCommentSide::Left,
|
||||
old_line,
|
||||
' ',
|
||||
text,
|
||||
);
|
||||
insert_line(
|
||||
&mut files,
|
||||
current_new.as_ref(),
|
||||
ReviewCommentSide::Right,
|
||||
new_line,
|
||||
' ',
|
||||
text,
|
||||
);
|
||||
if current_new != current_old {
|
||||
insert_line(
|
||||
&mut files,
|
||||
current_new.as_ref(),
|
||||
ReviewCommentSide::Left,
|
||||
old_line,
|
||||
' ',
|
||||
text,
|
||||
);
|
||||
insert_line(
|
||||
&mut files,
|
||||
current_old.as_ref(),
|
||||
ReviewCommentSide::Right,
|
||||
new_line,
|
||||
' ',
|
||||
text,
|
||||
);
|
||||
}
|
||||
old_line = old_line.saturating_add(1);
|
||||
new_line = new_line.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
Self { files }
|
||||
}
|
||||
|
||||
fn snippet_for(&self, comment: &ReviewComment) -> Option<String> {
|
||||
let file = self.files.get(&comment.path)?;
|
||||
let (start, end) = normalize_range(comment.line_start, comment.line_end);
|
||||
let lines = match comment.side {
|
||||
ReviewCommentSide::Left => &file.left,
|
||||
ReviewCommentSide::Right => &file.right,
|
||||
};
|
||||
let collected: Vec<_> = lines
|
||||
.range(start..=end)
|
||||
.map(|(_, line)| line.render())
|
||||
.collect();
|
||||
if collected.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(collected.join("\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_line(
|
||||
files: &mut HashMap<String, FileDiffLines>,
|
||||
path: Option<&String>,
|
||||
side: ReviewCommentSide,
|
||||
line_no: u32,
|
||||
prefix: char,
|
||||
text: &str,
|
||||
) {
|
||||
if let Some(path) = path {
|
||||
if let Some(file) = files.get_mut(path) {
|
||||
let target = match side {
|
||||
ReviewCommentSide::Left => &mut file.left,
|
||||
ReviewCommentSide::Right => &mut file.right,
|
||||
};
|
||||
target.insert(line_no, DiffLine::new(prefix, text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_entry(map: &mut HashMap<String, FileDiffLines>, path: Option<&str>) {
|
||||
if let Some(path) = path {
|
||||
map.entry(path.to_string())
|
||||
.or_insert_with(FileDiffLines::new);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_file_path_line(line: &str, prefix: &str) -> Option<Option<String>> {
|
||||
if !line.starts_with(prefix) {
|
||||
return None;
|
||||
}
|
||||
let path_part = line[prefix.len()..].trim();
|
||||
let mut segment = path_part.split('\t').next().unwrap_or("");
|
||||
if segment.starts_with('"') && segment.ends_with('"') && segment.len() >= 2 {
|
||||
segment = &segment[1..segment.len() - 1];
|
||||
}
|
||||
let normalized = segment.trim();
|
||||
if normalized == "/dev/null" || normalized.is_empty() {
|
||||
return Some(None);
|
||||
}
|
||||
let normalized = normalized
|
||||
.strip_prefix("a/")
|
||||
.or_else(|| normalized.strip_prefix("b/"))
|
||||
.unwrap_or(normalized);
|
||||
Some(Some(normalized.to_string()))
|
||||
}
|
||||
|
||||
fn parse_hunk_header(line: &str) -> Option<(u32, u32)> {
|
||||
if !line.starts_with("@@") {
|
||||
return None;
|
||||
}
|
||||
let mut parts = line.split_whitespace();
|
||||
let _ = parts.next()?; // @@
|
||||
let old_part = parts.next()?;
|
||||
let new_part = parts.next()?;
|
||||
let old_start = parse_range(old_part, '-');
|
||||
let new_start = parse_range(new_part, '+');
|
||||
match (old_start, new_start) {
|
||||
(Some(o), Some(n)) => Some((o, n)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_range(part: &str, marker: char) -> Option<u32> {
|
||||
part.strip_prefix(marker)?
|
||||
.split(',')
|
||||
.next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
const SAMPLE_DIFF: &str = "diff --git a/src/lib.rs b/src/lib.rs\nindex 1111111..2222222 100644\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -12,1 +12,1 @@ fn sample() {\n-old_call();\n+new_call();\n }\n@@ -34,2 +33,0 @@ fn old_section() {\n-old_line_one();\n-old_line_two();\n }\n";
|
||||
|
||||
#[test]
|
||||
fn formats_review_message_with_comments() {
|
||||
let comments = vec![
|
||||
ReviewComment {
|
||||
path: "src/lib.rs".to_string(),
|
||||
line_start: 12,
|
||||
line_end: 12,
|
||||
side: ReviewCommentSide::Right,
|
||||
text: "Looks good".to_string(),
|
||||
},
|
||||
ReviewComment {
|
||||
path: "src/lib.rs".to_string(),
|
||||
line_start: 34,
|
||||
line_end: 35,
|
||||
side: ReviewCommentSide::Left,
|
||||
text: "Consider renaming".to_string(),
|
||||
},
|
||||
];
|
||||
let formatted = format_review_message("Summary text", &comments, SAMPLE_DIFF);
|
||||
let expected = "Address the following review comments.\n\nReview summary:\nSummary text\n\n## src/lib.rs L12\n```\n+new_call();\n```\n\nLooks good\n\n## src/lib.rs L34-35\n```\n-old_line_one();\n-old_line_two();\n```\n\nConsider renaming\n";
|
||||
assert_eq!(formatted, expected);
|
||||
}
|
||||
|
||||
const MULTI_DIFF: &str = "diff --git a/a.rs b/a.rs\nindex 3333333..4444444 100644\n--- a/a.rs\n+++ b/a.rs\n@@ -1,3 +1,3 @@\n-line one\n+line one updated\n line two\n line three\ndiff --git a/b.rs b/b.rs\nindex 5555555..6666666 100644\n--- a/b.rs\n+++ b/b.rs\n@@ -2,2 +2,2 @@\n-line alpha\n+line alpha new\n line beta\n";
|
||||
|
||||
#[test]
|
||||
fn preserves_comment_order() {
|
||||
let comments = vec![
|
||||
ReviewComment {
|
||||
path: "a.rs".to_string(),
|
||||
line_start: 1,
|
||||
line_end: 1,
|
||||
side: ReviewCommentSide::Right,
|
||||
text: "First".to_string(),
|
||||
},
|
||||
ReviewComment {
|
||||
path: "b.rs".to_string(),
|
||||
line_start: 2,
|
||||
line_end: 2,
|
||||
side: ReviewCommentSide::Left,
|
||||
text: "Second".to_string(),
|
||||
},
|
||||
];
|
||||
let formatted = format_review_message("", &comments, MULTI_DIFF);
|
||||
let expected_headings: Vec<_> = formatted
|
||||
.lines()
|
||||
.filter(|line| line.starts_with("## "))
|
||||
.collect();
|
||||
assert_eq!(expected_headings, vec!["## a.rs L1", "## b.rs L2"]);
|
||||
assert!(!formatted.contains("Review summary"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_review_message_without_comments() {
|
||||
let formatted = format_review_message("Only summary", &[], SAMPLE_DIFF);
|
||||
assert_eq!(formatted, "Review summary:\nOnly summary\n");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user