mirror of
https://github.com/openai/codex.git
synced 2026-04-23 22:24:57 +00:00
Compare commits
7 Commits
rust-v0.36
...
easong/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45bccd36b0 | ||
|
|
404c126fc3 | ||
|
|
88027552dd | ||
|
|
ca8bd09d56 | ||
|
|
39ed8a7d26 | ||
|
|
2df7f7efe5 | ||
|
|
0560079c41 |
3
.github/workflows/rust-release.yml
vendored
3
.github/workflows/rust-release.yml
vendored
@@ -211,8 +211,7 @@ jobs:
|
||||
files: dist/**
|
||||
# Mark as prerelease only when the version has a suffix after x.y.z
|
||||
# (e.g. -alpha, -beta). Otherwise publish a normal release.
|
||||
# prerelease: ${{ contains(steps.release_name.outputs.name, '-') }}
|
||||
prerelease: true
|
||||
prerelease: ${{ contains(steps.release_name.outputs.name, '-') }}
|
||||
|
||||
- uses: facebook/dotslash-publish-release@v2
|
||||
env:
|
||||
|
||||
27
codex-rs/Cargo.lock
generated
27
codex-rs/Cargo.lock
generated
@@ -3926,18 +3926,28 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.219"
|
||||
version = "1.0.224"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
|
||||
checksum = "6aaeb1e94f53b16384af593c71e20b095e958dab1d26939c1b70645c5cfbcc0b"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.224"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32f39390fa6346e24defbcdd3d9544ba8a19985d0af74df8501fbfe9a64341ab"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.219"
|
||||
version = "1.0.224"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
|
||||
checksum = "87ff78ab5e8561c9a675bfc1785cb07ae721f0ee53329a595cefd8c04c2ac4e0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3957,15 +3967,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.143"
|
||||
version = "1.0.145"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a"
|
||||
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
|
||||
dependencies = [
|
||||
"indexmap 2.10.0",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5281,9 +5292,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wildmatch"
|
||||
version = "2.4.0"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ce1ab1f8c62655ebe1350f589c61e505cf94d385bc6a12899442d9081e71fd"
|
||||
checksum = "39b7d07a236abaef6607536ccfaf19b396dbe3f5110ddb73d39f4562902ed382"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
|
||||
@@ -57,7 +57,7 @@ tree-sitter = "0.25.9"
|
||||
tree-sitter-bash = "0.25.0"
|
||||
uuid = { version = "1", features = ["serde", "v4"] }
|
||||
which = "6"
|
||||
wildmatch = "2.4.0"
|
||||
wildmatch = "2.5.0"
|
||||
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::config_profile::ConfigProfile;
|
||||
use crate::config_types::History;
|
||||
use crate::config_types::McpServerConfig;
|
||||
use crate::config_types::Notifications;
|
||||
use crate::config_types::ReasoningSummaryFormat;
|
||||
use crate::config_types::SandboxWorkspaceWrite;
|
||||
use crate::config_types::ShellEnvironmentPolicy;
|
||||
@@ -117,6 +118,10 @@ pub struct Config {
|
||||
/// If unset the feature is disabled.
|
||||
pub notify: Option<Vec<String>>,
|
||||
|
||||
/// TUI notifications preference. When set, the TUI will send OSC 9 notifications on approvals
|
||||
/// and turn completions when not focused.
|
||||
pub tui_notifications: Notifications,
|
||||
|
||||
/// The directory that should be treated as the current working directory
|
||||
/// for the session. All relative paths inside the business-logic layer are
|
||||
/// resolved against this path.
|
||||
@@ -1043,6 +1048,11 @@ impl Config {
|
||||
include_view_image_tool,
|
||||
active_profile: active_profile_name,
|
||||
disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false),
|
||||
tui_notifications: cfg
|
||||
.tui
|
||||
.as_ref()
|
||||
.map(|t| t.notifications.clone())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
Ok(config)
|
||||
}
|
||||
@@ -1606,6 +1616,7 @@ model_verbosity = "high"
|
||||
include_view_image_tool: true,
|
||||
active_profile: Some("o3".to_string()),
|
||||
disable_paste_burst: false,
|
||||
tui_notifications: Default::default(),
|
||||
},
|
||||
o3_profile_config
|
||||
);
|
||||
@@ -1663,6 +1674,7 @@ model_verbosity = "high"
|
||||
include_view_image_tool: true,
|
||||
active_profile: Some("gpt3".to_string()),
|
||||
disable_paste_burst: false,
|
||||
tui_notifications: Default::default(),
|
||||
};
|
||||
|
||||
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
|
||||
@@ -1735,6 +1747,7 @@ model_verbosity = "high"
|
||||
include_view_image_tool: true,
|
||||
active_profile: Some("zdr".to_string()),
|
||||
disable_paste_burst: false,
|
||||
tui_notifications: Default::default(),
|
||||
};
|
||||
|
||||
assert_eq!(expected_zdr_profile_config, zdr_profile_config);
|
||||
@@ -1793,6 +1806,7 @@ model_verbosity = "high"
|
||||
include_view_image_tool: true,
|
||||
active_profile: Some("gpt5".to_string()),
|
||||
disable_paste_burst: false,
|
||||
tui_notifications: Default::default(),
|
||||
};
|
||||
|
||||
assert_eq!(expected_gpt5_profile_config, gpt5_profile_config);
|
||||
@@ -1896,3 +1910,46 @@ trust_level = "trusted"
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod notifications_tests {
|
||||
use crate::config_types::Notifications;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug, PartialEq)]
|
||||
struct TuiTomlTest {
|
||||
notifications: Notifications,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, PartialEq)]
|
||||
struct RootTomlTest {
|
||||
tui: TuiTomlTest,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tui_notifications_true() {
|
||||
let toml = r#"
|
||||
[tui]
|
||||
notifications = true
|
||||
"#;
|
||||
let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize notifications=true");
|
||||
assert!(matches!(
|
||||
parsed.tui.notifications,
|
||||
Notifications::Enabled(true)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tui_notifications_custom_array() {
|
||||
let toml = r#"
|
||||
[tui]
|
||||
notifications = ["foo"]
|
||||
"#;
|
||||
let parsed: RootTomlTest =
|
||||
toml::from_str(toml).expect("deserialize notifications=[\"foo\"]");
|
||||
assert!(matches!(
|
||||
parsed.tui.notifications,
|
||||
Notifications::Custom(ref v) if v == &vec!["foo".to_string()]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,9 +76,26 @@ pub enum HistoryPersistence {
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Notifications {
|
||||
Enabled(bool),
|
||||
Custom(Vec<String>),
|
||||
}
|
||||
|
||||
impl Default for Notifications {
|
||||
fn default() -> Self {
|
||||
Self::Enabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Collection of settings that are specific to the TUI.
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
pub struct Tui {}
|
||||
pub struct Tui {
|
||||
/// Enable desktop notifications from the TUI when the terminal is unfocused.
|
||||
/// Defaults to `false`.
|
||||
pub notifications: Notifications,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
pub struct SandboxWorkspaceWrite {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
edition = "2024"
|
||||
name = "codex-execpolicy"
|
||||
version = { workspace = true }
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "codex-execpolicy"
|
||||
@@ -15,9 +15,8 @@ path = "src/lib.rs"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
starlark = "0.13.0"
|
||||
allocative = "0.3.3"
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
derive_more = { version = "2", features = ["display"] }
|
||||
env_logger = "0.11.5"
|
||||
@@ -25,9 +24,10 @@ log = "0.4"
|
||||
multimap = "0.10.0"
|
||||
path-absolutize = "3.1.1"
|
||||
regex-lite = "0.1"
|
||||
serde = { version = "1.0.194", features = ["derive"] }
|
||||
serde_json = "1.0.143"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_with = { version = "3", features = ["macros"] }
|
||||
starlark = "0.13.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.13.0"
|
||||
|
||||
@@ -17,5 +17,5 @@ clap = { version = "4", features = ["derive"] }
|
||||
ignore = "0.4.23"
|
||||
nucleo-matcher = "0.3.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1.0.143"
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
@@ -177,6 +177,7 @@ impl App {
|
||||
self.chat_widget.handle_paste(pasted);
|
||||
}
|
||||
TuiEvent::Draw => {
|
||||
self.chat_widget.maybe_post_pending_notification(tui);
|
||||
if self
|
||||
.chat_widget
|
||||
.handle_paste_burst_tick(tui.frame_requester())
|
||||
|
||||
@@ -142,14 +142,16 @@ impl ChatComposer {
|
||||
.desired_height(width.saturating_sub(LIVE_PREFIX_COLS))
|
||||
+ match &self.active_popup {
|
||||
ActivePopup::None => FOOTER_HEIGHT_WITH_HINT,
|
||||
ActivePopup::Command(c) => c.calculate_required_height(),
|
||||
ActivePopup::Command(c) => c.calculate_required_height(width),
|
||||
ActivePopup::File(c) => c.calculate_required_height(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
let popup_constraint = match &self.active_popup {
|
||||
ActivePopup::Command(popup) => Constraint::Max(popup.calculate_required_height()),
|
||||
ActivePopup::Command(popup) => {
|
||||
Constraint::Max(popup.calculate_required_height(area.width))
|
||||
}
|
||||
ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()),
|
||||
ActivePopup::None => Constraint::Max(FOOTER_HEIGHT_WITH_HINT),
|
||||
};
|
||||
@@ -1232,7 +1234,10 @@ impl ChatComposer {
|
||||
impl WidgetRef for ChatComposer {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let (popup_constraint, hint_spacing) = match &self.active_popup {
|
||||
ActivePopup::Command(popup) => (Constraint::Max(popup.calculate_required_height()), 0),
|
||||
ActivePopup::Command(popup) => (
|
||||
Constraint::Max(popup.calculate_required_height(area.width)),
|
||||
0,
|
||||
),
|
||||
ActivePopup::File(popup) => (Constraint::Max(popup.calculate_required_height()), 0),
|
||||
ActivePopup::None => (
|
||||
Constraint::Length(FOOTER_HEIGHT_WITH_HINT),
|
||||
|
||||
@@ -92,10 +92,35 @@ impl CommandPopup {
|
||||
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
|
||||
}
|
||||
|
||||
/// Determine the preferred height of the popup. This is the number of
|
||||
/// rows required to show at most MAX_POPUP_ROWS commands.
|
||||
pub(crate) fn calculate_required_height(&self) -> u16 {
|
||||
self.filtered_items().len().clamp(1, MAX_POPUP_ROWS) as u16
|
||||
/// Determine the preferred height of the popup for a given width.
|
||||
/// Accounts for wrapped descriptions so that long tooltips don't overflow.
|
||||
pub(crate) fn calculate_required_height(&self, width: u16) -> u16 {
|
||||
use super::selection_popup_common::GenericDisplayRow;
|
||||
use super::selection_popup_common::measure_rows_height;
|
||||
let matches = self.filtered();
|
||||
let rows_all: Vec<GenericDisplayRow> = if matches.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|(item, indices, _)| match item {
|
||||
CommandItem::Builtin(cmd) => GenericDisplayRow {
|
||||
name: format!("/{}", cmd.command()),
|
||||
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
|
||||
is_current: false,
|
||||
description: Some(cmd.description().to_string()),
|
||||
},
|
||||
CommandItem::UserPrompt(i) => GenericDisplayRow {
|
||||
name: format!("/{}", self.prompts[i].name),
|
||||
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
|
||||
is_current: false,
|
||||
description: Some("send saved prompt".to_string()),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
measure_rows_height(&rows_all, &self.state, MAX_POPUP_ROWS, width)
|
||||
}
|
||||
|
||||
/// Compute fuzzy-filtered matches over built-in commands and user prompts,
|
||||
|
||||
@@ -17,6 +17,7 @@ use super::bottom_pane_view::BottomPaneView;
|
||||
use super::popup_consts::MAX_POPUP_ROWS;
|
||||
use super::scroll_state::ScrollState;
|
||||
use super::selection_popup_common::GenericDisplayRow;
|
||||
use super::selection_popup_common::measure_rows_height;
|
||||
use super::selection_popup_common::render_rows;
|
||||
|
||||
/// One selectable item in the generic selection list.
|
||||
@@ -135,11 +136,36 @@ impl BottomPaneView for ListSelectionView {
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
let rows = (self.items.len()).clamp(1, MAX_POPUP_ROWS);
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
// Measure wrapped height for up to MAX_POPUP_ROWS items at the given width.
|
||||
// Build the same display rows used by the renderer so wrapping math matches.
|
||||
let rows: Vec<GenericDisplayRow> = self
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, it)| {
|
||||
let is_selected = self.state.selected_idx == Some(i);
|
||||
let prefix = if is_selected { '>' } else { ' ' };
|
||||
let name_with_marker = if it.is_current {
|
||||
format!("{} (current)", it.name)
|
||||
} else {
|
||||
it.name.clone()
|
||||
};
|
||||
let display_name = format!("{} {}. {}", prefix, i + 1, name_with_marker);
|
||||
GenericDisplayRow {
|
||||
name: display_name,
|
||||
match_indices: None,
|
||||
is_current: it.is_current,
|
||||
description: it.description.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width);
|
||||
|
||||
// +1 for the title row, +1 for a spacer line beneath the header,
|
||||
// +1 for optional subtitle, +1 for optional footer
|
||||
let mut height = rows as u16 + 2;
|
||||
// +1 for optional subtitle, +1 for optional footer (2 lines incl. spacing)
|
||||
let mut height = rows_height + 2;
|
||||
if self.subtitle.is_some() {
|
||||
// +1 for subtitle (the spacer is accounted for above)
|
||||
height = height.saturating_add(1);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::Constraint;
|
||||
// Note: Table-based layout previously used Constraint; the manual renderer
|
||||
// below no longer requires it.
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
@@ -10,9 +11,7 @@ use ratatui::text::Span;
|
||||
use ratatui::widgets::Block;
|
||||
use ratatui::widgets::BorderType;
|
||||
use ratatui::widgets::Borders;
|
||||
use ratatui::widgets::Cell;
|
||||
use ratatui::widgets::Row;
|
||||
use ratatui::widgets::Table;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
use super::scroll_state::ScrollState;
|
||||
@@ -27,6 +26,61 @@ pub(crate) struct GenericDisplayRow {
|
||||
|
||||
impl GenericDisplayRow {}
|
||||
|
||||
/// Compute a shared description-column start based on the widest visible name
|
||||
/// plus two spaces of padding. Ensures at least one column is left for the
|
||||
/// description.
|
||||
fn compute_desc_col(
|
||||
rows_all: &[GenericDisplayRow],
|
||||
start_idx: usize,
|
||||
visible_items: usize,
|
||||
content_width: u16,
|
||||
) -> usize {
|
||||
let visible_range = start_idx..(start_idx + visible_items);
|
||||
let max_name_width = rows_all
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| visible_range.contains(i))
|
||||
.map(|(_, r)| Line::from(r.name.clone()).width())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let mut desc_col = max_name_width.saturating_add(2);
|
||||
if (desc_col as u16) >= content_width {
|
||||
desc_col = content_width.saturating_sub(1) as usize;
|
||||
}
|
||||
desc_col
|
||||
}
|
||||
|
||||
/// Build the full display line for a row with the description padded to start
|
||||
/// at `desc_col`. Applies fuzzy-match bolding when indices are present and
|
||||
/// dims the description.
|
||||
fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
|
||||
let mut name_spans: Vec<Span> = Vec::with_capacity(row.name.len());
|
||||
if let Some(idxs) = row.match_indices.as_ref() {
|
||||
let mut idx_iter = idxs.iter().peekable();
|
||||
for (char_idx, ch) in row.name.chars().enumerate() {
|
||||
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
|
||||
idx_iter.next();
|
||||
name_spans.push(ch.to_string().bold());
|
||||
} else {
|
||||
name_spans.push(ch.to_string().into());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
name_spans.push(row.name.clone().into());
|
||||
}
|
||||
|
||||
let this_name_width = Line::from(name_spans.clone()).width();
|
||||
let mut full_spans: Vec<Span> = name_spans;
|
||||
if let Some(desc) = row.description.as_ref() {
|
||||
let gap = desc_col.saturating_sub(this_name_width);
|
||||
if gap > 0 {
|
||||
full_spans.push(" ".repeat(gap).into());
|
||||
}
|
||||
full_spans.push(desc.clone().dim());
|
||||
}
|
||||
Line::from(full_spans)
|
||||
}
|
||||
|
||||
/// Render a list of rows using the provided ScrollState, with shared styling
|
||||
/// and behavior for selection popups.
|
||||
pub(crate) fn render_rows(
|
||||
@@ -38,84 +92,168 @@ pub(crate) fn render_rows(
|
||||
_dim_non_selected: bool,
|
||||
empty_message: &str,
|
||||
) {
|
||||
let mut rows: Vec<Row> = Vec::new();
|
||||
// Always draw a dim left border to match other popups.
|
||||
let block = Block::default()
|
||||
.borders(Borders::LEFT)
|
||||
.border_type(BorderType::QuadrantOutside)
|
||||
.border_style(Style::default().add_modifier(Modifier::DIM));
|
||||
block.render(area, buf);
|
||||
|
||||
// Content renders to the right of the border.
|
||||
let content_area = Rect {
|
||||
x: area.x.saturating_add(1),
|
||||
y: area.y,
|
||||
width: area.width.saturating_sub(1),
|
||||
height: area.height,
|
||||
};
|
||||
|
||||
if rows_all.is_empty() {
|
||||
rows.push(Row::new(vec![Cell::from(Line::from(
|
||||
empty_message.dim().italic(),
|
||||
))]));
|
||||
} else {
|
||||
let max_rows_from_area = area.height as usize;
|
||||
let visible_rows = max_results
|
||||
.min(rows_all.len())
|
||||
.min(max_rows_from_area.max(1));
|
||||
|
||||
// Compute starting index based on scroll state and selection.
|
||||
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
|
||||
if let Some(sel) = state.selected_idx {
|
||||
if sel < start_idx {
|
||||
start_idx = sel;
|
||||
} else if visible_rows > 0 {
|
||||
let bottom = start_idx + visible_rows - 1;
|
||||
if sel > bottom {
|
||||
start_idx = sel + 1 - visible_rows;
|
||||
}
|
||||
}
|
||||
if content_area.height > 0 {
|
||||
let para = Paragraph::new(Line::from(empty_message.dim().italic()));
|
||||
para.render(
|
||||
Rect {
|
||||
x: content_area.x,
|
||||
y: content_area.y,
|
||||
width: content_area.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (i, row) in rows_all
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(start_idx)
|
||||
.take(visible_rows)
|
||||
{
|
||||
let GenericDisplayRow {
|
||||
name,
|
||||
match_indices,
|
||||
is_current: _is_current,
|
||||
description,
|
||||
} = row;
|
||||
// Determine which logical rows (items) are visible given the selection and
|
||||
// the max_results clamp. Scrolling is still item-based for simplicity.
|
||||
let max_rows_from_area = content_area.height as usize;
|
||||
let visible_items = max_results
|
||||
.min(rows_all.len())
|
||||
.min(max_rows_from_area.max(1));
|
||||
|
||||
// Highlight fuzzy indices when present.
|
||||
let mut spans: Vec<Span> = Vec::with_capacity(name.len());
|
||||
if let Some(idxs) = match_indices.as_ref() {
|
||||
let mut idx_iter = idxs.iter().peekable();
|
||||
for (char_idx, ch) in name.chars().enumerate() {
|
||||
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
|
||||
idx_iter.next();
|
||||
spans.push(ch.to_string().bold());
|
||||
} else {
|
||||
spans.push(ch.to_string().into());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
spans.push(name.clone().into());
|
||||
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
|
||||
if let Some(sel) = state.selected_idx {
|
||||
if sel < start_idx {
|
||||
start_idx = sel;
|
||||
} else if visible_items > 0 {
|
||||
let bottom = start_idx + visible_items - 1;
|
||||
if sel > bottom {
|
||||
start_idx = sel + 1 - visible_items;
|
||||
}
|
||||
|
||||
if let Some(desc) = description.as_ref() {
|
||||
spans.push(" ".into());
|
||||
spans.push(desc.clone().dim());
|
||||
}
|
||||
|
||||
let mut cell = Cell::from(Line::from(spans));
|
||||
if Some(i) == state.selected_idx {
|
||||
cell = cell.style(
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
}
|
||||
rows.push(Row::new(vec![cell]));
|
||||
}
|
||||
}
|
||||
|
||||
let table = Table::new(rows, vec![Constraint::Percentage(100)])
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::LEFT)
|
||||
.border_type(BorderType::QuadrantOutside)
|
||||
.border_style(Style::default().add_modifier(Modifier::DIM)),
|
||||
)
|
||||
.widths([Constraint::Percentage(100)]);
|
||||
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_area.width);
|
||||
|
||||
table.render(area, buf);
|
||||
// Render items, wrapping descriptions and aligning wrapped lines under the
|
||||
// shared description column. Stop when we run out of vertical space.
|
||||
let mut cur_y = content_area.y;
|
||||
for (i, row) in rows_all
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(start_idx)
|
||||
.take(visible_items)
|
||||
{
|
||||
if cur_y >= content_area.y + content_area.height {
|
||||
break;
|
||||
}
|
||||
|
||||
let GenericDisplayRow {
|
||||
name,
|
||||
match_indices,
|
||||
is_current: _is_current,
|
||||
description,
|
||||
} = row;
|
||||
|
||||
let full_line = build_full_line(
|
||||
&GenericDisplayRow {
|
||||
name: name.clone(),
|
||||
match_indices: match_indices.clone(),
|
||||
is_current: *_is_current,
|
||||
description: description.clone(),
|
||||
},
|
||||
desc_col,
|
||||
);
|
||||
|
||||
// Wrap with subsequent indent aligned to the description column.
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_line;
|
||||
let options = RtOptions::new(content_area.width as usize)
|
||||
.initial_indent(Line::from(""))
|
||||
.subsequent_indent(Line::from(" ".repeat(desc_col)));
|
||||
let wrapped = word_wrap_line(&full_line, options);
|
||||
|
||||
// Render the wrapped lines.
|
||||
for mut line in wrapped {
|
||||
if cur_y >= content_area.y + content_area.height {
|
||||
break;
|
||||
}
|
||||
if Some(i) == state.selected_idx {
|
||||
// Match previous behavior: cyan + bold for the selected row.
|
||||
line.style = Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
let para = Paragraph::new(line);
|
||||
para.render(
|
||||
Rect {
|
||||
x: content_area.x,
|
||||
y: cur_y,
|
||||
width: content_area.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
cur_y = cur_y.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the number of terminal rows required to render up to `max_results`
|
||||
/// items from `rows_all` given the current scroll/selection state and the
|
||||
/// available `width`. Accounts for description wrapping and alignment so the
|
||||
/// caller can allocate sufficient vertical space.
|
||||
pub(crate) fn measure_rows_height(
|
||||
rows_all: &[GenericDisplayRow],
|
||||
state: &ScrollState,
|
||||
max_results: usize,
|
||||
width: u16,
|
||||
) -> u16 {
|
||||
if rows_all.is_empty() {
|
||||
return 1; // placeholder "no matches" line
|
||||
}
|
||||
|
||||
let content_width = width.saturating_sub(1).max(1);
|
||||
|
||||
let visible_items = max_results.min(rows_all.len());
|
||||
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
|
||||
if let Some(sel) = state.selected_idx {
|
||||
if sel < start_idx {
|
||||
start_idx = sel;
|
||||
} else if visible_items > 0 {
|
||||
let bottom = start_idx + visible_items - 1;
|
||||
if sel > bottom {
|
||||
start_idx = sel + 1 - visible_items;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_width);
|
||||
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_line;
|
||||
let mut total: u16 = 0;
|
||||
for row in rows_all
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(start_idx)
|
||||
.take(visible_items)
|
||||
.map(|(_, r)| r)
|
||||
{
|
||||
let full_line = build_full_line(row, desc_col);
|
||||
let opts = RtOptions::new(content_width as usize)
|
||||
.initial_indent(Line::from(""))
|
||||
.subsequent_indent(Line::from(" ".repeat(desc_col)));
|
||||
total = total.saturating_add(word_wrap_line(&full_line, opts).len() as u16);
|
||||
}
|
||||
total.max(1)
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@ expression: terminal.backend()
|
||||
---
|
||||
"▌ /mo "
|
||||
"▌ "
|
||||
"▌/model choose what model and reasoning effort to use "
|
||||
"▌/model choose what model and reasoning effort to use "
|
||||
"▌/mention mention a file "
|
||||
|
||||
@@ -6,6 +6,6 @@ expression: render_lines(&view)
|
||||
▌ Switch between Codex approval presets
|
||||
▌
|
||||
▌> 1. Read Only (current) Codex can read files
|
||||
▌ 2. Full Access Codex can edit files
|
||||
▌ 2. Full Access Codex can edit files
|
||||
|
||||
Press Enter to confirm or Esc to go back
|
||||
|
||||
@@ -5,6 +5,6 @@ expression: render_lines(&view)
|
||||
▌ Select Approval Mode
|
||||
▌
|
||||
▌> 1. Read Only (current) Codex can read files
|
||||
▌ 2. Full Access Codex can edit files
|
||||
▌ 2. Full Access Codex can edit files
|
||||
|
||||
Press Enter to confirm or Esc to go back
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config_types::Notifications;
|
||||
use codex_core::protocol::AgentMessageDeltaEvent;
|
||||
use codex_core::protocol::AgentMessageEvent;
|
||||
use codex_core::protocol::AgentReasoningDeltaEvent;
|
||||
@@ -59,6 +60,7 @@ use crate::bottom_pane::InputResult;
|
||||
use crate::bottom_pane::SelectionAction;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use crate::clipboard_paste::paste_image_to_temp_png;
|
||||
use crate::diff_render::display_path_for;
|
||||
use crate::get_git_diff::get_git_diff;
|
||||
use crate::history_cell;
|
||||
use crate::history_cell::CommandOutput;
|
||||
@@ -66,6 +68,7 @@ use crate::history_cell::ExecCell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::PatchEventType;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::text_formatting::truncate_text;
|
||||
use crate::tui::FrameRequester;
|
||||
// streaming internals are provided by crate::streaming and crate::markdown_stream
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
@@ -136,6 +139,8 @@ pub(crate) struct ChatWidget {
|
||||
suppress_session_configured_redraw: bool,
|
||||
// User messages queued while a turn is in progress
|
||||
queued_user_messages: VecDeque<UserMessage>,
|
||||
// Pending notification to show when unfocused on next Draw
|
||||
pending_notification: Option<Notification>,
|
||||
}
|
||||
|
||||
struct UserMessage {
|
||||
@@ -265,6 +270,8 @@ impl ChatWidget {
|
||||
|
||||
// If there is a queued user message, send exactly one now to begin the next turn.
|
||||
self.maybe_send_next_queued_input();
|
||||
// Emit a notification when the turn completes (suppressed if focused).
|
||||
self.notify(Notification::AgentTurnComplete);
|
||||
}
|
||||
|
||||
pub(crate) fn set_token_info(&mut self, info: Option<TokenUsageInfo>) {
|
||||
@@ -531,6 +538,9 @@ impl ChatWidget {
|
||||
self.flush_answer_stream_with_separator();
|
||||
// Emit the proposed command into history (like proposed patches)
|
||||
self.add_to_history(history_cell::new_proposed_command(&ev.command));
|
||||
let command = shlex::try_join(ev.command.iter().map(|s| s.as_str()))
|
||||
.unwrap_or_else(|_| ev.command.join(" "));
|
||||
self.notify(Notification::ExecApprovalRequested { command });
|
||||
|
||||
let request = ApprovalRequest::Exec {
|
||||
id,
|
||||
@@ -560,6 +570,10 @@ impl ChatWidget {
|
||||
};
|
||||
self.bottom_pane.push_approval_request(request);
|
||||
self.request_redraw();
|
||||
self.notify(Notification::EditApprovalRequested {
|
||||
cwd: self.config.cwd.clone(),
|
||||
changes: ev.changes.keys().cloned().collect(),
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) {
|
||||
@@ -686,6 +700,7 @@ impl ChatWidget {
|
||||
queued_user_messages: VecDeque::new(),
|
||||
show_welcome_banner: true,
|
||||
suppress_session_configured_redraw: false,
|
||||
pending_notification: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,6 +756,7 @@ impl ChatWidget {
|
||||
queued_user_messages: VecDeque::new(),
|
||||
show_welcome_banner: true,
|
||||
suppress_session_configured_redraw: true,
|
||||
pending_notification: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1137,6 +1153,20 @@ impl ChatWidget {
|
||||
self.frame_requester.schedule_frame();
|
||||
}
|
||||
|
||||
fn notify(&mut self, notification: Notification) {
|
||||
if !notification.allowed_for(&self.config.tui_notifications) {
|
||||
return;
|
||||
}
|
||||
self.pending_notification = Some(notification);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn maybe_post_pending_notification(&mut self, tui: &mut crate::tui::Tui) {
|
||||
if let Some(notif) = self.pending_notification.take() {
|
||||
tui.notify(notif.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark the active exec cell as failed (✗) and flush it into history.
|
||||
fn finalize_active_exec_cell_as_failed(&mut self) {
|
||||
if let Some(cell) = self.active_exec_cell.take() {
|
||||
@@ -1449,6 +1479,49 @@ impl WidgetRef for &ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
enum Notification {
|
||||
AgentTurnComplete,
|
||||
ExecApprovalRequested { command: String },
|
||||
EditApprovalRequested { cwd: PathBuf, changes: Vec<PathBuf> },
|
||||
}
|
||||
|
||||
impl Notification {
|
||||
fn display(&self) -> String {
|
||||
match self {
|
||||
Notification::AgentTurnComplete => "Agent turn complete".to_string(),
|
||||
Notification::ExecApprovalRequested { command } => {
|
||||
format!("Approval requested: {}", truncate_text(command, 30))
|
||||
}
|
||||
Notification::EditApprovalRequested { cwd, changes } => {
|
||||
format!(
|
||||
"Codex wants to edit {}",
|
||||
if changes.len() == 1 {
|
||||
#[allow(clippy::unwrap_used)]
|
||||
display_path_for(changes.first().unwrap(), cwd)
|
||||
} else {
|
||||
format!("{} files", changes.len())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn type_name(&self) -> &str {
|
||||
match self {
|
||||
Notification::AgentTurnComplete => "agent-turn-complete",
|
||||
Notification::ExecApprovalRequested { .. }
|
||||
| Notification::EditApprovalRequested { .. } => "approval-requested",
|
||||
}
|
||||
}
|
||||
|
||||
fn allowed_for(&self, settings: &Notifications) -> bool {
|
||||
match settings {
|
||||
Notifications::Enabled(enabled) => *enabled,
|
||||
Notifications::Custom(allowed) => allowed.iter().any(|a| a == self.type_name()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const EXAMPLE_PROMPTS: [&str; 6] = [
|
||||
"Explain this codebase",
|
||||
"Summarize recent commits",
|
||||
|
||||
@@ -251,6 +251,7 @@ fn make_chatwidget_manual() -> (
|
||||
show_welcome_banner: true,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
suppress_session_configured_redraw: false,
|
||||
pending_notification: None,
|
||||
};
|
||||
(widget, rx, op_rx)
|
||||
}
|
||||
|
||||
@@ -265,7 +265,7 @@ fn render_changes_block(
|
||||
out
|
||||
}
|
||||
|
||||
fn display_path_for(path: &Path, cwd: &Path) -> String {
|
||||
pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String {
|
||||
let path_in_same_repo = match (get_git_repo_root(cwd), get_git_repo_root(path)) {
|
||||
(Some(cwd_repo), Some(path_repo)) => cwd_repo == path_repo,
|
||||
_ => false,
|
||||
|
||||
@@ -17,7 +17,9 @@ use crossterm::SynchronizedUpdate;
|
||||
use crossterm::cursor;
|
||||
use crossterm::cursor::MoveTo;
|
||||
use crossterm::event::DisableBracketedPaste;
|
||||
use crossterm::event::DisableFocusChange;
|
||||
use crossterm::event::EnableBracketedPaste;
|
||||
use crossterm::event::EnableFocusChange;
|
||||
use crossterm::event::Event;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyboardEnhancementFlags;
|
||||
@@ -60,6 +62,8 @@ pub fn set_modes() -> Result<()> {
|
||||
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
|
||||
)
|
||||
);
|
||||
|
||||
let _ = execute!(stdout(), EnableFocusChange);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -111,6 +115,7 @@ pub fn restore() -> Result<()> {
|
||||
// Pop may fail on platforms that didn't support the push; ignore errors.
|
||||
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
|
||||
execute!(stdout(), DisableBracketedPaste)?;
|
||||
let _ = execute!(stdout(), DisableFocusChange);
|
||||
disable_raw_mode()?;
|
||||
let _ = execute!(stdout(), crossterm::cursor::Show);
|
||||
Ok(())
|
||||
@@ -163,6 +168,8 @@ pub struct Tui {
|
||||
suspend_cursor_y: Arc<AtomicU16>, // Bottom line of inline viewport
|
||||
// True when overlay alt-screen UI is active
|
||||
alt_screen_active: Arc<AtomicBool>,
|
||||
// True when terminal/tab is focused; updated internally from crossterm events
|
||||
terminal_focused: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -214,6 +221,16 @@ impl FrameRequester {
|
||||
}
|
||||
|
||||
impl Tui {
|
||||
/// Emit a desktop notification now if the terminal is unfocused.
|
||||
/// Returns true if a notification was posted.
|
||||
pub fn notify(&mut self, message: impl AsRef<str>) -> bool {
|
||||
if !self.terminal_focused.load(Ordering::Relaxed) {
|
||||
let _ = execute!(stdout(), PostNotification(message.as_ref().to_string()));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
pub fn new(terminal: Terminal) -> Self {
|
||||
let (frame_schedule_tx, frame_schedule_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let (draw_tx, _) = tokio::sync::broadcast::channel(1);
|
||||
@@ -270,6 +287,7 @@ impl Tui {
|
||||
#[cfg(unix)]
|
||||
suspend_cursor_y: Arc::new(AtomicU16::new(0)),
|
||||
alt_screen_active: Arc::new(AtomicBool::new(false)),
|
||||
terminal_focused: Arc::new(AtomicBool::new(true)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +307,7 @@ impl Tui {
|
||||
let alt_screen_active = self.alt_screen_active.clone();
|
||||
#[cfg(unix)]
|
||||
let suspend_cursor_y = self.suspend_cursor_y.clone();
|
||||
let terminal_focused = self.terminal_focused.clone();
|
||||
let event_stream = async_stream::stream! {
|
||||
loop {
|
||||
select! {
|
||||
@@ -332,6 +351,12 @@ impl Tui {
|
||||
Event::Paste(pasted) => {
|
||||
yield TuiEvent::Paste(pasted);
|
||||
}
|
||||
Event::FocusGained => {
|
||||
terminal_focused.store(true, Ordering::Relaxed);
|
||||
}
|
||||
Event::FocusLost => {
|
||||
terminal_focused.store(false, Ordering::Relaxed);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -535,3 +560,25 @@ impl Tui {
|
||||
})?
|
||||
}
|
||||
}
|
||||
|
||||
/// Command that emits an OSC 9 desktop notification with a message.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PostNotification(pub String);
|
||||
|
||||
impl Command for PostNotification {
|
||||
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
|
||||
write!(f, "\x1b]9;{}\x07", self.0)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
Err(std::io::Error::other(
|
||||
"tried to execute PostNotification using WinAPI; use ANSI instead",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_ansi_code_supported(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,6 +504,9 @@ To have Codex use this script for notifications, you would configure it via `not
|
||||
notify = ["python3", "/Users/mbolin/.codex/notify.py"]
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Use `notify` for automation and integrations: Codex invokes your external program with a single JSON argument for each event, independent of the TUI. If you only want lightweight desktop notifications while using the TUI, prefer `tui.notifications`, which uses terminal escape codes and requires no external program. You can enable both; `tui.notifications` covers in‑TUI alerts (e.g., approval prompts), while `notify` is best for system‑level hooks or custom notifiers. Currently, `notify` emits only `agent-turn-complete`, whereas `tui.notifications` supports `agent-turn-complete` and `approval-requested` with optional filtering.
|
||||
|
||||
## history
|
||||
|
||||
By default, Codex CLI records messages sent to the model in `$CODEX_HOME/history.jsonl`. Note that on UNIX, the file permissions are set to `o600`, so it should only be readable and writable by the owner.
|
||||
@@ -576,9 +579,21 @@ Options that are specific to the TUI.
|
||||
|
||||
```toml
|
||||
[tui]
|
||||
# More to come here
|
||||
# Send desktop notifications when approvals are required or a turn completes.
|
||||
# Defaults to false.
|
||||
notifications = true
|
||||
|
||||
# You can optionally filter to specific notification types.
|
||||
# Available types are "agent-turn-complete" and "approval-requested".
|
||||
notifications = [ "agent-turn-complete", "approval-requested" ]
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Codex emits desktop notifications using terminal escape codes. Not all terminals support these (notably, macOS Terminal.app and VS Code's terminal do not support custom notifications. iTerm2, Ghostty and WezTerm do support these notifications).
|
||||
|
||||
> [!NOTE]
|
||||
> `tui.notifications` is built‑in and limited to the TUI session. For programmatic or cross‑environment notifications—or to integrate with OS‑specific notifiers—use the top‑level `notify` option to run an external program that receives event JSON. The two settings are independent and can be used together.
|
||||
|
||||
## Config reference
|
||||
|
||||
| Key | Type / Values | Notes |
|
||||
@@ -616,7 +631,8 @@ Options that are specific to the TUI.
|
||||
| `history.persistence` | `save-all` \| `none` | History file persistence (default: `save-all`). |
|
||||
| `history.max_bytes` | number | Currently ignored (not enforced). |
|
||||
| `file_opener` | `vscode` \| `vscode-insiders` \| `windsurf` \| `cursor` \| `none` | URI scheme for clickable citations (default: `vscode`). |
|
||||
| `tui` | table | TUI‑specific options (reserved). |
|
||||
| `tui` | table | TUI‑specific options. |
|
||||
| `tui.notifications` | boolean \| array<string> | Enable desktop notifications in the tui (default: false). |
|
||||
| `hide_agent_reasoning` | boolean | Hide model reasoning events. |
|
||||
| `show_raw_agent_reasoning` | boolean | Show raw reasoning (when available). |
|
||||
| `model_reasoning_effort` | `minimal` \| `low` \| `medium` \| `high` | Responses API reasoning effort. |
|
||||
|
||||
Reference in New Issue
Block a user