mirror of
https://github.com/openai/codex.git
synced 2026-09-16 12:13:30 +00:00
chore: introduce AppEventSender to help fix clippy warnings and update to Rust 1.87
This commit is contained in:
4
.github/workflows/rust-ci.yml
vendored
4
.github/workflows/rust-ci.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@1.86
|
||||
- uses: dtolnay/rust-toolchain@1.87
|
||||
with:
|
||||
components: rustfmt
|
||||
- name: cargo fmt
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@1.86
|
||||
- uses: dtolnay/rust-toolchain@1.87
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
components: clippy
|
||||
|
||||
2
.github/workflows/rust-release.yml
vendored
2
.github/workflows/rust-release.yml
vendored
@@ -74,7 +74,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@1.86
|
||||
- uses: dtolnay/rust-toolchain@1.87
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::chatwidget::ChatWidget;
|
||||
use crate::git_warning_screen::GitWarningOutcome;
|
||||
use crate::git_warning_screen::GitWarningScreen;
|
||||
@@ -14,7 +15,6 @@ use crossterm::event::KeyEvent;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
/// Top‑level application state – which full‑screen view is currently active.
|
||||
@@ -26,7 +26,7 @@ enum AppState {
|
||||
}
|
||||
|
||||
pub(crate) struct App<'a> {
|
||||
app_event_tx: Sender<AppEvent>,
|
||||
app_event_tx: AppEventSender,
|
||||
app_event_rx: Receiver<AppEvent>,
|
||||
chat_widget: ChatWidget<'a>,
|
||||
app_state: AppState,
|
||||
@@ -40,6 +40,7 @@ impl App<'_> {
|
||||
initial_images: Vec<std::path::PathBuf>,
|
||||
) -> Self {
|
||||
let (app_event_tx, app_event_rx) = channel();
|
||||
let app_event_tx = AppEventSender::new(app_event_tx);
|
||||
let scroll_event_helper = ScrollEventHelper::new(app_event_tx.clone());
|
||||
|
||||
// Spawn a dedicated thread for reading the crossterm event loop and
|
||||
@@ -50,14 +51,10 @@ impl App<'_> {
|
||||
while let Ok(event) = crossterm::event::read() {
|
||||
match event {
|
||||
crossterm::event::Event::Key(key_event) => {
|
||||
if let Err(e) = app_event_tx.send(AppEvent::KeyEvent(key_event)) {
|
||||
tracing::error!("failed to send key event: {e}");
|
||||
}
|
||||
app_event_tx.send(AppEvent::KeyEvent(key_event));
|
||||
}
|
||||
crossterm::event::Event::Resize(_, _) => {
|
||||
if let Err(e) = app_event_tx.send(AppEvent::Redraw) {
|
||||
tracing::error!("failed to send resize event: {e}");
|
||||
}
|
||||
app_event_tx.send(AppEvent::Redraw);
|
||||
}
|
||||
crossterm::event::Event::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::ScrollUp,
|
||||
@@ -85,10 +82,7 @@ impl App<'_> {
|
||||
}
|
||||
_ => KeyEvent::new(KeyCode::Char(ch), KeyModifiers::empty()),
|
||||
};
|
||||
if let Err(e) = app_event_tx.send(AppEvent::KeyEvent(key_event)) {
|
||||
tracing::error!("failed to send pasted key event: {e}");
|
||||
break;
|
||||
}
|
||||
app_event_tx.send(AppEvent::KeyEvent(key_event));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
@@ -124,14 +118,14 @@ impl App<'_> {
|
||||
|
||||
/// Clone of the internal event sender so external tasks (e.g. log bridge)
|
||||
/// can inject `AppEvent`s.
|
||||
pub fn event_sender(&self) -> Sender<AppEvent> {
|
||||
pub fn event_sender(&self) -> AppEventSender {
|
||||
self.app_event_tx.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> {
|
||||
// Insert an event to trigger the first render.
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
app_event_tx.send(AppEvent::Redraw)?;
|
||||
app_event_tx.send(AppEvent::Redraw);
|
||||
|
||||
while let Ok(event) = self.app_event_rx.recv() {
|
||||
match event {
|
||||
@@ -152,7 +146,7 @@ impl App<'_> {
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest)?;
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
_ => {
|
||||
self.dispatch_key_event(key_event);
|
||||
@@ -175,12 +169,12 @@ impl App<'_> {
|
||||
}
|
||||
AppEvent::LatestLog(line) => {
|
||||
if matches!(self.app_state, AppState::Chat) {
|
||||
let _ = self.chat_widget.update_latest_log(line);
|
||||
self.chat_widget.update_latest_log(line);
|
||||
}
|
||||
}
|
||||
AppEvent::DispatchCommand(command) => match command {
|
||||
SlashCommand::Clear => {
|
||||
let _ = self.chat_widget.clear_conversation_history();
|
||||
self.chat_widget.clear_conversation_history();
|
||||
}
|
||||
SlashCommand::Quit => {
|
||||
break;
|
||||
@@ -210,17 +204,15 @@ impl App<'_> {
|
||||
fn dispatch_key_event(&mut self, key_event: KeyEvent) {
|
||||
match &mut self.app_state {
|
||||
AppState::Chat => {
|
||||
if let Err(e) = self.chat_widget.handle_key_event(key_event) {
|
||||
tracing::error!("SendError: {e}");
|
||||
}
|
||||
self.chat_widget.handle_key_event(key_event);
|
||||
}
|
||||
AppState::GitWarning { screen } => match screen.handle_key_event(key_event) {
|
||||
GitWarningOutcome::Continue => {
|
||||
self.app_state = AppState::Chat;
|
||||
let _ = self.app_event_tx.send(AppEvent::Redraw);
|
||||
self.app_event_tx.send(AppEvent::Redraw);
|
||||
}
|
||||
GitWarningOutcome::Quit => {
|
||||
let _ = self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
GitWarningOutcome::None => {
|
||||
// do nothing
|
||||
@@ -231,17 +223,13 @@ impl App<'_> {
|
||||
|
||||
fn dispatch_scroll_event(&mut self, scroll_delta: i32) {
|
||||
if matches!(self.app_state, AppState::Chat) {
|
||||
if let Err(e) = self.chat_widget.handle_scroll_delta(scroll_delta) {
|
||||
tracing::error!("SendError: {e}");
|
||||
}
|
||||
self.chat_widget.handle_scroll_delta(scroll_delta);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_codex_event(&mut self, event: Event) {
|
||||
if matches!(self.app_state, AppState::Chat) {
|
||||
if let Err(e) = self.chat_widget.handle_codex_event(event) {
|
||||
tracing::error!("SendError: {e}");
|
||||
}
|
||||
self.chat_widget.handle_codex_event(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
22
codex-rs/tui/src/app_event_sender.rs
Normal file
22
codex-rs/tui/src/app_event_sender.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct AppEventSender {
|
||||
app_event_tx: Sender<AppEvent>,
|
||||
}
|
||||
|
||||
impl AppEventSender {
|
||||
pub(crate) fn new(app_event_tx: Sender<AppEvent>) -> Self {
|
||||
Self { app_event_tx }
|
||||
}
|
||||
|
||||
/// Send an event to the app event channel. If it fails, we swallow the
|
||||
/// error and log it.
|
||||
pub(crate) fn send(&self, event: AppEvent) {
|
||||
if let Err(e) = self.app_event_tx.send(event) {
|
||||
tracing::error!("failed to send event: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
use std::sync::mpsc::SendError;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
use crate::user_approval_widget::UserApprovalWidget;
|
||||
|
||||
@@ -17,11 +14,11 @@ use super::BottomPaneView;
|
||||
pub(crate) struct ApprovalModalView<'a> {
|
||||
current: UserApprovalWidget<'a>,
|
||||
queue: Vec<ApprovalRequest>,
|
||||
app_event_tx: Sender<AppEvent>,
|
||||
app_event_tx: AppEventSender,
|
||||
}
|
||||
|
||||
impl ApprovalModalView<'_> {
|
||||
pub fn new(request: ApprovalRequest, app_event_tx: Sender<AppEvent>) -> Self {
|
||||
pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
|
||||
Self {
|
||||
current: UserApprovalWidget::new(request, app_event_tx.clone()),
|
||||
queue: Vec::new(),
|
||||
@@ -44,14 +41,9 @@ impl ApprovalModalView<'_> {
|
||||
}
|
||||
|
||||
impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
|
||||
fn handle_key_event(
|
||||
&mut self,
|
||||
_pane: &mut BottomPane<'a>,
|
||||
key_event: KeyEvent,
|
||||
) -> Result<(), SendError<AppEvent>> {
|
||||
self.current.handle_key_event(key_event)?;
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, key_event: KeyEvent) {
|
||||
self.current.handle_key_event(key_event);
|
||||
self.maybe_advance();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use std::sync::mpsc::SendError;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
|
||||
use super::BottomPane;
|
||||
|
||||
@@ -18,11 +15,7 @@ pub(crate) enum ConditionalUpdate {
|
||||
pub(crate) trait BottomPaneView<'a> {
|
||||
/// Handle a key event while the view is active. A redraw is always
|
||||
/// scheduled after this call.
|
||||
fn handle_key_event(
|
||||
&mut self,
|
||||
pane: &mut BottomPane<'a>,
|
||||
key_event: KeyEvent,
|
||||
) -> Result<(), SendError<AppEvent>>;
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, _key_event: KeyEvent) {}
|
||||
|
||||
/// Return `true` if the view has finished and should be removed.
|
||||
fn is_complete(&self) -> bool {
|
||||
|
||||
@@ -13,9 +13,8 @@ use tui_textarea::Input;
|
||||
use tui_textarea::Key;
|
||||
use tui_textarea::TextArea;
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
|
||||
use super::command_popup::CommandPopup;
|
||||
|
||||
@@ -33,11 +32,11 @@ pub enum InputResult {
|
||||
pub(crate) struct ChatComposer<'a> {
|
||||
textarea: TextArea<'a>,
|
||||
command_popup: Option<CommandPopup>,
|
||||
app_event_tx: Sender<AppEvent>,
|
||||
app_event_tx: AppEventSender,
|
||||
}
|
||||
|
||||
impl ChatComposer<'_> {
|
||||
pub fn new(has_input_focus: bool, app_event_tx: Sender<AppEvent>) -> Self {
|
||||
pub fn new(has_input_focus: bool, app_event_tx: AppEventSender) -> Self {
|
||||
let mut textarea = TextArea::default();
|
||||
textarea.set_placeholder_text("send a message");
|
||||
textarea.set_cursor_line_style(ratatui::style::Style::default());
|
||||
@@ -113,9 +112,7 @@ impl ChatComposer<'_> {
|
||||
} => {
|
||||
if let Some(cmd) = popup.selected_command() {
|
||||
// Send command to the app layer.
|
||||
if let Err(e) = self.app_event_tx.send(AppEvent::DispatchCommand(*cmd)) {
|
||||
tracing::error!("failed to send DispatchCommand event: {e}");
|
||||
}
|
||||
self.app_event_tx.send(AppEvent::DispatchCommand(*cmd));
|
||||
|
||||
// Clear textarea so no residual text remains.
|
||||
self.textarea.select_all();
|
||||
|
||||
@@ -6,10 +6,9 @@ use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use std::sync::mpsc::SendError;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
|
||||
mod approval_modal_view;
|
||||
@@ -33,13 +32,13 @@ pub(crate) struct BottomPane<'a> {
|
||||
/// If present, this is displayed instead of the `composer`.
|
||||
active_view: Option<Box<dyn BottomPaneView<'a> + 'a>>,
|
||||
|
||||
app_event_tx: Sender<AppEvent>,
|
||||
app_event_tx: AppEventSender,
|
||||
has_input_focus: bool,
|
||||
is_task_running: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct BottomPaneParams {
|
||||
pub(crate) app_event_tx: Sender<AppEvent>,
|
||||
pub(crate) app_event_tx: AppEventSender,
|
||||
pub(crate) has_input_focus: bool,
|
||||
}
|
||||
|
||||
@@ -55,12 +54,9 @@ impl BottomPane<'_> {
|
||||
}
|
||||
|
||||
/// Forward a key event to the active view or the composer.
|
||||
pub fn handle_key_event(
|
||||
&mut self,
|
||||
key_event: KeyEvent,
|
||||
) -> Result<InputResult, SendError<AppEvent>> {
|
||||
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
|
||||
if let Some(mut view) = self.active_view.take() {
|
||||
view.handle_key_event(self, key_event)?;
|
||||
view.handle_key_event(self, key_event);
|
||||
if !view.is_complete() {
|
||||
self.active_view = Some(view);
|
||||
} else if self.is_task_running {
|
||||
@@ -70,31 +66,30 @@ impl BottomPane<'_> {
|
||||
height,
|
||||
)));
|
||||
}
|
||||
self.request_redraw()?;
|
||||
Ok(InputResult::None)
|
||||
self.request_redraw();
|
||||
InputResult::None
|
||||
} else {
|
||||
let (input_result, needs_redraw) = self.composer.handle_key_event(key_event);
|
||||
if needs_redraw {
|
||||
self.request_redraw()?;
|
||||
self.request_redraw();
|
||||
}
|
||||
Ok(input_result)
|
||||
input_result
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the status indicator text (only when the `StatusIndicatorView` is
|
||||
/// active).
|
||||
pub(crate) fn update_status_text(&mut self, text: String) -> Result<(), SendError<AppEvent>> {
|
||||
pub(crate) fn update_status_text(&mut self, text: String) {
|
||||
if let Some(view) = &mut self.active_view {
|
||||
match view.update_status_text(text) {
|
||||
ConditionalUpdate::NeedsRedraw => {
|
||||
self.request_redraw()?;
|
||||
self.request_redraw();
|
||||
}
|
||||
ConditionalUpdate::NoRedraw => {
|
||||
// No redraw needed.
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update the UI to reflect whether this `BottomPane` has input focus.
|
||||
@@ -103,7 +98,7 @@ impl BottomPane<'_> {
|
||||
self.composer.set_input_focus(has_focus);
|
||||
}
|
||||
|
||||
pub fn set_task_running(&mut self, running: bool) -> Result<(), SendError<AppEvent>> {
|
||||
pub fn set_task_running(&mut self, running: bool) {
|
||||
self.is_task_running = running;
|
||||
|
||||
match (running, self.active_view.is_some()) {
|
||||
@@ -114,13 +109,13 @@ impl BottomPane<'_> {
|
||||
self.app_event_tx.clone(),
|
||||
height,
|
||||
)));
|
||||
self.request_redraw()?;
|
||||
self.request_redraw();
|
||||
}
|
||||
(false, true) => {
|
||||
if let Some(mut view) = self.active_view.take() {
|
||||
if view.should_hide_when_task_is_done() {
|
||||
// Leave self.active_view as None.
|
||||
self.request_redraw()?;
|
||||
self.request_redraw();
|
||||
} else {
|
||||
// Preserve the view.
|
||||
self.active_view = Some(view);
|
||||
@@ -131,20 +126,16 @@ impl BottomPane<'_> {
|
||||
// No change.
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Called when the agent requests user approval.
|
||||
pub fn push_approval_request(
|
||||
&mut self,
|
||||
request: ApprovalRequest,
|
||||
) -> Result<(), SendError<AppEvent>> {
|
||||
pub fn push_approval_request(&mut self, request: ApprovalRequest) {
|
||||
let request = if let Some(view) = self.active_view.as_mut() {
|
||||
match view.try_consume_approval_request(request) {
|
||||
Some(request) => request,
|
||||
None => {
|
||||
self.request_redraw()?;
|
||||
return Ok(());
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -166,7 +157,7 @@ impl BottomPane<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn request_redraw(&self) -> Result<(), SendError<AppEvent>> {
|
||||
pub(crate) fn request_redraw(&self) {
|
||||
self.app_event_tx.send(AppEvent::Redraw)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
use std::sync::mpsc::SendError;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::status_indicator_widget::StatusIndicatorWidget;
|
||||
|
||||
use super::BottomPane;
|
||||
use super::BottomPaneView;
|
||||
use super::bottom_pane_view::ConditionalUpdate;
|
||||
|
||||
@@ -18,7 +13,7 @@ pub(crate) struct StatusIndicatorView {
|
||||
}
|
||||
|
||||
impl StatusIndicatorView {
|
||||
pub fn new(app_event_tx: Sender<AppEvent>, height: u16) -> Self {
|
||||
pub fn new(app_event_tx: AppEventSender, height: u16) -> Self {
|
||||
Self {
|
||||
view: StatusIndicatorWidget::new(app_event_tx, height),
|
||||
}
|
||||
@@ -30,14 +25,6 @@ impl StatusIndicatorView {
|
||||
}
|
||||
|
||||
impl<'a> BottomPaneView<'a> for StatusIndicatorView {
|
||||
fn handle_key_event(
|
||||
&mut self,
|
||||
_pane: &mut BottomPane<'a>,
|
||||
_key_event: KeyEvent,
|
||||
) -> Result<(), SendError<AppEvent>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_status_text(&mut self, text: String) -> ConditionalUpdate {
|
||||
self.update_text(text);
|
||||
ConditionalUpdate::NeedsRedraw
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::SendError;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use codex_core::codex_wrapper::init_codex;
|
||||
use codex_core::config::Config;
|
||||
@@ -31,6 +29,7 @@ use tokio::sync::mpsc::UnboundedSender;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::BottomPane;
|
||||
use crate::bottom_pane::BottomPaneParams;
|
||||
use crate::bottom_pane::InputResult;
|
||||
@@ -39,7 +38,7 @@ use crate::history_cell::PatchEventType;
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
|
||||
pub(crate) struct ChatWidget<'a> {
|
||||
app_event_tx: Sender<AppEvent>,
|
||||
app_event_tx: AppEventSender,
|
||||
codex_op_tx: UnboundedSender<Op>,
|
||||
conversation_history: ConversationHistoryWidget,
|
||||
bottom_pane: BottomPane<'a>,
|
||||
@@ -56,7 +55,7 @@ enum InputFocus {
|
||||
impl ChatWidget<'_> {
|
||||
pub(crate) fn new(
|
||||
config: Config,
|
||||
app_event_tx: Sender<AppEvent>,
|
||||
app_event_tx: AppEventSender,
|
||||
initial_prompt: Option<String>,
|
||||
initial_images: Vec<PathBuf>,
|
||||
) -> Self {
|
||||
@@ -77,9 +76,7 @@ impl ChatWidget<'_> {
|
||||
|
||||
// Forward the captured `SessionInitialized` event that was consumed
|
||||
// inside `init_codex()` so it can be rendered in the UI.
|
||||
if let Err(e) = app_event_tx_clone.send(AppEvent::CodexEvent(session_event.clone())) {
|
||||
tracing::error!("failed to send SessionInitialized event: {e}");
|
||||
}
|
||||
app_event_tx_clone.send(AppEvent::CodexEvent(session_event.clone()));
|
||||
let codex = Arc::new(codex);
|
||||
let codex_clone = codex.clone();
|
||||
tokio::spawn(async move {
|
||||
@@ -92,11 +89,7 @@ impl ChatWidget<'_> {
|
||||
});
|
||||
|
||||
while let Ok(event) = codex.next_event().await {
|
||||
app_event_tx_clone
|
||||
.send(AppEvent::CodexEvent(event))
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::error!("failed to send event: {e}");
|
||||
});
|
||||
app_event_tx_clone.send(AppEvent::CodexEvent(event));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -114,16 +107,13 @@ impl ChatWidget<'_> {
|
||||
|
||||
if initial_prompt.is_some() || !initial_images.is_empty() {
|
||||
let text = initial_prompt.unwrap_or_default();
|
||||
let _ = chat_widget.submit_user_message_with_images(text, initial_images);
|
||||
chat_widget.submit_user_message_with_images(text, initial_images);
|
||||
}
|
||||
|
||||
chat_widget
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event(
|
||||
&mut self,
|
||||
key_event: KeyEvent,
|
||||
) -> std::result::Result<(), SendError<AppEvent>> {
|
||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
// Special-case <Tab>: normally toggles focus between history and bottom panes.
|
||||
// However, when the slash-command popup is visible we forward the key
|
||||
// to the bottom pane so it can handle auto-completion.
|
||||
@@ -138,43 +128,31 @@ impl ChatWidget<'_> {
|
||||
.set_input_focus(self.input_focus == InputFocus::HistoryPane);
|
||||
self.bottom_pane
|
||||
.set_input_focus(self.input_focus == InputFocus::BottomPane);
|
||||
self.request_redraw()?;
|
||||
return Ok(());
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
match self.input_focus {
|
||||
InputFocus::HistoryPane => {
|
||||
let needs_redraw = self.conversation_history.handle_key_event(key_event);
|
||||
if needs_redraw {
|
||||
self.request_redraw()?;
|
||||
self.request_redraw();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
InputFocus::BottomPane => {
|
||||
match self.bottom_pane.handle_key_event(key_event)? {
|
||||
InputResult::Submitted(text) => {
|
||||
self.submit_user_message(text)?;
|
||||
}
|
||||
InputResult::None => {}
|
||||
InputFocus::BottomPane => match self.bottom_pane.handle_key_event(key_event) {
|
||||
InputResult::Submitted(text) => {
|
||||
self.submit_user_message(text);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
InputResult::None => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_user_message(
|
||||
&mut self,
|
||||
text: String,
|
||||
) -> std::result::Result<(), SendError<AppEvent>> {
|
||||
fn submit_user_message(&mut self, text: String) {
|
||||
// Forward to codex and update conversation history.
|
||||
self.submit_user_message_with_images(text, vec![])
|
||||
self.submit_user_message_with_images(text, vec![]);
|
||||
}
|
||||
|
||||
fn submit_user_message_with_images(
|
||||
&mut self,
|
||||
text: String,
|
||||
image_paths: Vec<PathBuf>,
|
||||
) -> std::result::Result<(), SendError<AppEvent>> {
|
||||
fn submit_user_message_with_images(&mut self, text: String, image_paths: Vec<PathBuf>) {
|
||||
let mut items: Vec<InputItem> = Vec::new();
|
||||
|
||||
if !text.is_empty() {
|
||||
@@ -186,7 +164,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
if items.is_empty() {
|
||||
return Ok(());
|
||||
return;
|
||||
}
|
||||
|
||||
self.codex_op_tx
|
||||
@@ -200,48 +178,41 @@ impl ChatWidget<'_> {
|
||||
self.conversation_history.add_user_message(text);
|
||||
}
|
||||
self.conversation_history.scroll_to_bottom();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn clear_conversation_history(
|
||||
&mut self,
|
||||
) -> std::result::Result<(), SendError<AppEvent>> {
|
||||
pub(crate) fn clear_conversation_history(&mut self) {
|
||||
self.conversation_history.clear();
|
||||
self.request_redraw()
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn handle_codex_event(
|
||||
&mut self,
|
||||
event: Event,
|
||||
) -> std::result::Result<(), SendError<AppEvent>> {
|
||||
pub(crate) fn handle_codex_event(&mut self, event: Event) {
|
||||
let Event { id, msg } = event;
|
||||
match msg {
|
||||
EventMsg::SessionConfigured(event) => {
|
||||
// Record session information at the top of the conversation.
|
||||
self.conversation_history
|
||||
.add_session_info(&self.config, event);
|
||||
self.request_redraw()?;
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
|
||||
self.conversation_history.add_agent_message(message);
|
||||
self.request_redraw()?;
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::AgentReasoning(AgentReasoningEvent { text }) => {
|
||||
self.conversation_history.add_agent_reasoning(text);
|
||||
self.request_redraw()?;
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::TaskStarted => {
|
||||
self.bottom_pane.set_task_running(true)?;
|
||||
self.request_redraw()?;
|
||||
self.bottom_pane.set_task_running(true);
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::TaskComplete => {
|
||||
self.bottom_pane.set_task_running(false)?;
|
||||
self.request_redraw()?;
|
||||
self.bottom_pane.set_task_running(false);
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::Error(ErrorEvent { message }) => {
|
||||
self.conversation_history.add_error(message);
|
||||
self.bottom_pane.set_task_running(false)?;
|
||||
self.bottom_pane.set_task_running(false);
|
||||
}
|
||||
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
command,
|
||||
@@ -254,7 +225,7 @@ impl ChatWidget<'_> {
|
||||
cwd,
|
||||
reason,
|
||||
};
|
||||
self.bottom_pane.push_approval_request(request)?;
|
||||
self.bottom_pane.push_approval_request(request);
|
||||
}
|
||||
EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
|
||||
changes,
|
||||
@@ -283,8 +254,8 @@ impl ChatWidget<'_> {
|
||||
reason,
|
||||
grant_root,
|
||||
};
|
||||
self.bottom_pane.push_approval_request(request)?;
|
||||
self.request_redraw()?;
|
||||
self.bottom_pane.push_approval_request(request);
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id,
|
||||
@@ -293,7 +264,7 @@ impl ChatWidget<'_> {
|
||||
}) => {
|
||||
self.conversation_history
|
||||
.add_active_exec_command(call_id, command);
|
||||
self.request_redraw()?;
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
||||
call_id: _,
|
||||
@@ -307,7 +278,7 @@ impl ChatWidget<'_> {
|
||||
if !auto_approved {
|
||||
self.conversation_history.scroll_to_bottom();
|
||||
}
|
||||
self.request_redraw()?;
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id,
|
||||
@@ -317,7 +288,7 @@ impl ChatWidget<'_> {
|
||||
}) => {
|
||||
self.conversation_history
|
||||
.record_completed_exec_command(call_id, stdout, stderr, exit_code);
|
||||
self.request_redraw()?;
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
|
||||
call_id,
|
||||
@@ -327,7 +298,7 @@ impl ChatWidget<'_> {
|
||||
}) => {
|
||||
self.conversation_history
|
||||
.add_active_mcp_tool_call(call_id, server, tool, arguments);
|
||||
self.request_redraw()?;
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::McpToolCallEnd(McpToolCallEndEvent {
|
||||
call_id,
|
||||
@@ -336,36 +307,27 @@ impl ChatWidget<'_> {
|
||||
}) => {
|
||||
self.conversation_history
|
||||
.record_completed_mcp_tool_call(call_id, success, result);
|
||||
self.request_redraw()?;
|
||||
self.request_redraw();
|
||||
}
|
||||
event => {
|
||||
self.conversation_history
|
||||
.add_background_event(format!("{event:?}"));
|
||||
self.request_redraw()?;
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update the live log preview while a task is running.
|
||||
pub(crate) fn update_latest_log(
|
||||
&mut self,
|
||||
line: String,
|
||||
) -> std::result::Result<(), SendError<AppEvent>> {
|
||||
pub(crate) fn update_latest_log(&mut self, line: String) {
|
||||
// Forward only if we are currently showing the status indicator.
|
||||
self.bottom_pane.update_status_text(line)?;
|
||||
Ok(())
|
||||
self.bottom_pane.update_status_text(line);
|
||||
}
|
||||
|
||||
fn request_redraw(&mut self) -> std::result::Result<(), SendError<AppEvent>> {
|
||||
self.app_event_tx.send(AppEvent::Redraw)?;
|
||||
Ok(())
|
||||
fn request_redraw(&mut self) {
|
||||
self.app_event_tx.send(AppEvent::Redraw);
|
||||
}
|
||||
|
||||
pub(crate) fn handle_scroll_delta(
|
||||
&mut self,
|
||||
scroll_delta: i32,
|
||||
) -> std::result::Result<(), SendError<AppEvent>> {
|
||||
pub(crate) fn handle_scroll_delta(&mut self, scroll_delta: i32) {
|
||||
// If the user is trying to scroll exactly one line, we let them, but
|
||||
// otherwise we assume they are trying to scroll in larger increments.
|
||||
let magnified_scroll_delta = if scroll_delta == 1 {
|
||||
@@ -375,8 +337,7 @@ impl ChatWidget<'_> {
|
||||
scroll_delta * 2
|
||||
};
|
||||
self.conversation_history.scroll(magnified_scroll_delta);
|
||||
self.request_redraw()?;
|
||||
Ok(())
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Forward an `Op` directly to codex.
|
||||
|
||||
@@ -16,6 +16,7 @@ use tracing_subscriber::prelude::*;
|
||||
|
||||
mod app;
|
||||
mod app_event;
|
||||
mod app_event_sender;
|
||||
mod bottom_pane;
|
||||
mod chatwidget;
|
||||
mod cli;
|
||||
@@ -161,7 +162,7 @@ fn run_ratatui_app(
|
||||
let app_event_tx = app.event_sender();
|
||||
tokio::spawn(async move {
|
||||
while let Some(line) = log_rx.recv().await {
|
||||
let _ = app_event_tx.send(crate::app_event::AppEvent::LatestLog(line));
|
||||
app_event_tx.send(crate::app_event::AppEvent::LatestLog(line));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,16 +2,16 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicI32;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use tokio::runtime::Handle;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
|
||||
pub(crate) struct ScrollEventHelper {
|
||||
app_event_tx: Sender<AppEvent>,
|
||||
app_event_tx: AppEventSender,
|
||||
scroll_delta: Arc<AtomicI32>,
|
||||
timer_scheduled: Arc<AtomicBool>,
|
||||
runtime: Handle,
|
||||
@@ -26,7 +26,7 @@ const DEBOUNCE_WINDOW: Duration = Duration::from_millis(100);
|
||||
/// window. The debounce timer now runs on Tokio so we avoid spinning up a new
|
||||
/// operating-system thread for every burst.
|
||||
impl ScrollEventHelper {
|
||||
pub(crate) fn new(app_event_tx: Sender<AppEvent>) -> Self {
|
||||
pub(crate) fn new(app_event_tx: AppEventSender) -> Self {
|
||||
Self {
|
||||
app_event_tx,
|
||||
scroll_delta: Arc::new(AtomicI32::new(0)),
|
||||
@@ -68,7 +68,7 @@ impl ScrollEventHelper {
|
||||
|
||||
let accumulated = delta.swap(0, Ordering::SeqCst);
|
||||
if accumulated != 0 {
|
||||
let _ = tx.send(AppEvent::Scroll(accumulated));
|
||||
tx.send(AppEvent::Scroll(accumulated));
|
||||
}
|
||||
|
||||
timer_flag.store(false, Ordering::SeqCst);
|
||||
|
||||
@@ -5,7 +5,6 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -26,6 +25,7 @@ use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
|
||||
use codex_ansi_escape::ansi_escape_line;
|
||||
|
||||
@@ -45,12 +45,12 @@ pub(crate) struct StatusIndicatorWidget {
|
||||
// animation thread is still running. The field itself is currently not
|
||||
// accessed anywhere, therefore the leading underscore silences the
|
||||
// `dead_code` warning without affecting behavior.
|
||||
_app_event_tx: Sender<AppEvent>,
|
||||
_app_event_tx: AppEventSender,
|
||||
}
|
||||
|
||||
impl StatusIndicatorWidget {
|
||||
/// Create a new status indicator and start the animation timer.
|
||||
pub(crate) fn new(app_event_tx: Sender<AppEvent>, height: u16) -> Self {
|
||||
pub(crate) fn new(app_event_tx: AppEventSender, height: u16) -> Self {
|
||||
let frame_idx = Arc::new(AtomicUsize::new(0));
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
|
||||
@@ -65,9 +65,7 @@ impl StatusIndicatorWidget {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
counter = counter.wrapping_add(1);
|
||||
frame_idx_clone.store(counter, Ordering::Relaxed);
|
||||
if app_event_tx_clone.send(AppEvent::Redraw).is_err() {
|
||||
break;
|
||||
}
|
||||
app_event_tx_clone.send(AppEvent::Redraw);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
//! driven workflow – a fully‑fledged visual match is not required.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::SendError;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::ReviewDecision;
|
||||
@@ -30,6 +28,7 @@ use tui_input::Input;
|
||||
use tui_input::backend::crossterm::EventHandler;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::exec_command::relativize_to_home;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
|
||||
@@ -48,8 +47,6 @@ pub(crate) enum ApprovalRequest {
|
||||
},
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Options displayed in the *select* mode.
|
||||
struct SelectOption {
|
||||
label: &'static str,
|
||||
@@ -102,7 +99,7 @@ enum Mode {
|
||||
/// A modal prompting the user to approve or deny the pending request.
|
||||
pub(crate) struct UserApprovalWidget<'a> {
|
||||
approval_request: ApprovalRequest,
|
||||
app_event_tx: Sender<AppEvent>,
|
||||
app_event_tx: AppEventSender,
|
||||
confirmation_prompt: Paragraph<'a>,
|
||||
|
||||
/// Currently selected index in *select* mode.
|
||||
@@ -124,7 +121,7 @@ pub(crate) struct UserApprovalWidget<'a> {
|
||||
const BORDER_LINES: u16 = 2;
|
||||
|
||||
impl UserApprovalWidget<'_> {
|
||||
pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: Sender<AppEvent>) -> Self {
|
||||
pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
|
||||
let input = Input::default();
|
||||
let confirmation_prompt = match &approval_request {
|
||||
ApprovalRequest::Exec {
|
||||
@@ -225,15 +222,14 @@ impl UserApprovalWidget<'_> {
|
||||
/// Process a key event originating from crossterm. As the modal fully
|
||||
/// captures input while visible, we don’t need to report whether the event
|
||||
/// was consumed—callers can assume it always is.
|
||||
pub(crate) fn handle_key_event(&mut self, key: KeyEvent) -> Result<(), SendError<AppEvent>> {
|
||||
pub(crate) fn handle_key_event(&mut self, key: KeyEvent) {
|
||||
match self.mode {
|
||||
Mode::Select => self.handle_select_key(key)?,
|
||||
Mode::Input => self.handle_input_key(key)?,
|
||||
Mode::Select => self.handle_select_key(key),
|
||||
Mode::Input => self.handle_input_key(key),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_select_key(&mut self, key_event: KeyEvent) -> Result<(), SendError<AppEvent>> {
|
||||
fn handle_select_key(&mut self, key_event: KeyEvent) {
|
||||
match key_event.code {
|
||||
KeyCode::Up => {
|
||||
if self.selected_option == 0 {
|
||||
@@ -241,77 +237,61 @@ impl UserApprovalWidget<'_> {
|
||||
} else {
|
||||
self.selected_option -= 1;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
KeyCode::Down => {
|
||||
self.selected_option = (self.selected_option + 1) % SELECT_OPTIONS.len();
|
||||
return Ok(());
|
||||
}
|
||||
KeyCode::Char('y') => {
|
||||
self.send_decision(ReviewDecision::Approved)?;
|
||||
return Ok(());
|
||||
self.send_decision(ReviewDecision::Approved);
|
||||
}
|
||||
KeyCode::Char('a') => {
|
||||
self.send_decision(ReviewDecision::ApprovedForSession)?;
|
||||
return Ok(());
|
||||
self.send_decision(ReviewDecision::ApprovedForSession);
|
||||
}
|
||||
KeyCode::Char('n') => {
|
||||
self.send_decision(ReviewDecision::Denied)?;
|
||||
return Ok(());
|
||||
self.send_decision(ReviewDecision::Denied);
|
||||
}
|
||||
KeyCode::Char('e') => {
|
||||
self.mode = Mode::Input;
|
||||
return Ok(());
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let opt = &SELECT_OPTIONS[self.selected_option];
|
||||
if opt.enters_input_mode {
|
||||
self.mode = Mode::Input;
|
||||
} else if let Some(decision) = opt.decision {
|
||||
self.send_decision(decision)?;
|
||||
self.send_decision(decision);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
self.send_decision(ReviewDecision::Abort)?;
|
||||
return Ok(());
|
||||
self.send_decision(ReviewDecision::Abort);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_input_key(&mut self, key_event: KeyEvent) -> Result<(), SendError<AppEvent>> {
|
||||
fn handle_input_key(&mut self, key_event: KeyEvent) {
|
||||
// Handle special keys first.
|
||||
match key_event.code {
|
||||
KeyCode::Enter => {
|
||||
let feedback = self.input.value().to_string();
|
||||
self.send_decision_with_feedback(ReviewDecision::Denied, feedback)?;
|
||||
return Ok(());
|
||||
self.send_decision_with_feedback(ReviewDecision::Denied, feedback);
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
// Cancel input – treat as deny without feedback.
|
||||
self.send_decision(ReviewDecision::Denied)?;
|
||||
return Ok(());
|
||||
self.send_decision(ReviewDecision::Denied);
|
||||
}
|
||||
_ => {
|
||||
// Feed into input widget for normal editing.
|
||||
let ct_event = crossterm::event::Event::Key(key_event);
|
||||
self.input.handle_event(&ct_event);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Feed into input widget for normal editing.
|
||||
let ct_event = crossterm::event::Event::Key(key_event);
|
||||
self.input.handle_event(&ct_event);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_decision(&mut self, decision: ReviewDecision) -> Result<(), SendError<AppEvent>> {
|
||||
fn send_decision(&mut self, decision: ReviewDecision) {
|
||||
self.send_decision_with_feedback(decision, String::new())
|
||||
}
|
||||
|
||||
fn send_decision_with_feedback(
|
||||
&mut self,
|
||||
decision: ReviewDecision,
|
||||
_feedback: String,
|
||||
) -> Result<(), SendError<AppEvent>> {
|
||||
fn send_decision_with_feedback(&mut self, decision: ReviewDecision, _feedback: String) {
|
||||
let op = match &self.approval_request {
|
||||
ApprovalRequest::Exec { id, .. } => Op::ExecApproval {
|
||||
id: id.clone(),
|
||||
@@ -329,9 +309,8 @@ impl UserApprovalWidget<'_> {
|
||||
// redraw after it processes the resulting state change, so we avoid
|
||||
// issuing an extra Redraw here to prevent a transient frame where the
|
||||
// modal is still visible.
|
||||
self.app_event_tx.send(AppEvent::CodexOp(op))?;
|
||||
self.app_event_tx.send(AppEvent::CodexOp(op));
|
||||
self.done = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `true` once the user has made a decision and the widget no
|
||||
@@ -339,8 +318,6 @@ impl UserApprovalWidget<'_> {
|
||||
pub(crate) fn is_complete(&self) -> bool {
|
||||
self.done
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
}
|
||||
|
||||
const PLAIN: Style = Style::new();
|
||||
|
||||
Reference in New Issue
Block a user