merge local main into code-branch-20250926-220945 before merge

This commit is contained in:
James Peter
2025-09-27 09:43:56 +10:00
11 changed files with 781 additions and 97 deletions
+107 -55
View File
@@ -45,18 +45,53 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
skip_due_to_active: ${{ steps.active_guard.outputs.skip_due_to_active }}
action: ${{ steps.check.outputs.action }}
upstream_only: ${{ steps.check.outputs.upstream_only }}
merge_only: ${{ steps.check.outputs.merge_only }}
upstream_in_merge: ${{ steps.check.outputs.upstream_in_merge }}
steps:
- name: Guard concurrent upstream-merge run
id: active_guard
uses: actions/github-script@v7
with:
github-token: ${{ secrets.CODE_GH_PAT || github.token }}
script: |
const { owner, repo } = context.repo;
const workflowId = 'upstream-merge.yml';
const resp = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: workflowId,
per_page: 20
});
const blocking = resp.data.workflow_runs.filter(run =>
run.status === 'in_progress' && run.id !== context.runId
);
const skip = blocking.length > 0;
core.setOutput('skip_due_to_active', skip ? 'true' : 'false');
if (skip) {
const latest = blocking[0];
await core.summary
.addHeading('Upstream Merge: Active Run Guard')
.addRaw(`- blocking_runs: ${blocking.length}`, true)
.addRaw(`- blocking_run: #${latest.run_number} (${latest.status})`, true)
.addLink('Blocking run logs', latest.html_url)
.write();
core.notice(`Another Upstream Merge run (#${latest.run_number}) is still ${latest.status}; skipping remaining steps.`);
core.info('Exiting early because another upstream-merge run is still active.');
}
- name: Check out repository (full history)
if: steps.active_guard.outputs.skip_due_to_active != 'true'
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: "Quick precheck: compute upstream/merge deltas"
if: steps.active_guard.outputs.skip_due_to_active != 'true'
id: check
shell: bash
env:
@@ -109,7 +144,7 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
- name: Close stale upstream-merge PR on skip when zero diff (opt-in)
if: steps.check.outputs.skip == 'true' && steps.check.outputs.mirror_on_skip == 'true' && env.CLOSE_ZERO_DIFF_ON_SKIP == 'true'
if: steps.active_guard.outputs.skip_due_to_active != 'true' && steps.check.outputs.skip == 'true' && steps.check.outputs.mirror_on_skip == 'true' && env.CLOSE_ZERO_DIFF_ON_SKIP == 'true'
uses: actions/github-script@v7
env:
MERGE_BRANCH: ${{ env.MERGE_BRANCH }}
@@ -141,7 +176,7 @@ jobs:
merge:
needs: [precheck]
if: needs.precheck.outputs.action == 'merge'
if: needs.precheck.outputs.skip_due_to_active != 'true' && needs.precheck.outputs.action == 'merge'
runs-on: ubuntu-latest
timeout-minutes: 45
@@ -644,38 +679,37 @@ jobs:
cat .github/auto/VERIFY.json || true
if jq -e '.build_fast=="ok" and .api_check=="ok"' .github/auto/VERIFY.json >/dev/null 2>&1; then echo "failed=false" >> "$GITHUB_OUTPUT"; else echo "failed=true" >> "$GITHUB_OUTPUT"; fi
- name: Upload artifact - VERIFY logs
if: steps.check_upstream.outputs.skip != 'true'
uses: actions/upload-artifact@v4
with:
name: VERIFY
if-no-files-found: warn
path: |
.github/auto/VERIFY.json
.github/auto/VERIFY_build-fast.log
.github/auto/VERIFY_api-check.log
- name: Remediate verification failures with Code agent
- name: Remediate verification failures with Code agent (loop)
id: remediation_loop
if: steps.check_upstream.outputs.skip != 'true' && steps.verify.outputs.failed == 'true'
env:
GH_TOKEN: ${{ secrets.CODE_GH_PAT || github.token }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch || 'main' }}
MERGE_BRANCH: ${{ env.MERGE_BRANCH }}
MAX_REMEDIATION_ATTEMPTS: ${{ env.MAX_REMEDIATION_ATTEMPTS || '3' }}
run: |
set -euo pipefail
SAFE_PATH="$PATH"; SAFE_HOME="$HOME"
{
echo 'Context';
echo "- MERGE_BRANCH=${MERGE_BRANCH}";
echo "- DEFAULT_BRANCH=${DEFAULT_BRANCH}";
echo '';
echo 'Previous step (VERIFY) failed. Here are the logs:';
echo '---8<---';
tail -n 200 "$GITHUB_WORKSPACE/.github/auto/VERIFY_build-fast.log" 2>/dev/null || true
tail -n 200 "$GITHUB_WORKSPACE/.github/auto/VERIFY_api-check.log" 2>/dev/null || true
echo '---8<---';
echo '';
cat << 'PROMPT'
: > "$GITHUB_WORKSPACE/.github/auto/AGENT_REMEDIATION_STDOUT.txt"
MAX_ATTEMPTS=${MAX_REMEDIATION_ATTEMPTS:-3}
attempt=1
while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do
echo "::notice::Remediation attempt $attempt of $MAX_ATTEMPTS"
{
echo 'Context';
echo "- MERGE_BRANCH=${MERGE_BRANCH}";
echo "- DEFAULT_BRANCH=${DEFAULT_BRANCH}";
echo "- ATTEMPT=${attempt}/${MAX_ATTEMPTS}";
echo '';
echo 'Previous verify logs (tail):';
echo '---8<---';
tail -n 200 "$GITHUB_WORKSPACE/.github/auto/VERIFY_build-fast.log" 2>/dev/null || true
tail -n 200 "$GITHUB_WORKSPACE/.github/auto/VERIFY_api-check.log" 2>/dev/null || true
tail -n 200 "$GITHUB_WORKSPACE/.github/auto/VERIFY_guards.log" 2>/dev/null || true
echo '---8<---';
echo '';
cat << 'PROMPT'
You are acting as a maintainer to remediate compile-only API test failures after an upstream merge.
Tasks (minimal and surgical):
@@ -693,35 +727,53 @@ jobs:
- No history rewriting; only commit on ${MERGE_BRANCH}.
- Treat warnings as failures during the build-fast step.
PROMPT
} | env -i PATH="$SAFE_PATH" HOME="$SAFE_HOME" \
OPENAI_API_KEY="x" \
OPENAI_BASE_URL="http://127.0.0.1:5055/v1" \
OPENAI_API_BASE="http://127.0.0.1:5055/v1" \
GH_TOKEN="$GH_TOKEN" \
RUSTC_WRAPPER="sccache" SCCACHE_GHA_ENABLED="true" SCCACHE_IDLE_TIMEOUT="1800" SCCACHE_CACHE_SIZE="5G" \
npm_config_cache="$SAFE_HOME/.npm" \
npx -y @just-every/code@latest \
exec \
-s workspace-write \
-c sandbox_workspace_write.allow_git_writes=true \
-c sandbox_workspace_write.network_access=true \
-c shell_environment_policy.r#set.CARGO_HOME="${RUNNER_TEMP}/cargo-home" \
-c shell_environment_policy.r#set.CARGO_TARGET_DIR="${GITHUB_WORKSPACE}/codex-rs/target" \
-c shell_environment_policy.r#set.RUSTC_WRAPPER="sccache" \
-c shell_environment_policy.r#set.SCCACHE_GHA_ENABLED="true" \
-c shell_environment_policy.r#set.SCCACHE_IDLE_TIMEOUT="1800" \
-c shell_environment_policy.r#set.SCCACHE_CACHE_SIZE="5G" \
-c shell_environment_policy.r#set.KEEP_ENV="1" \
--cd "$GITHUB_WORKSPACE" \
--skip-git-repo-check \
- | tee "$GITHUB_WORKSPACE/.github/auto/AGENT_REMEDIATION_STDOUT.txt"
} | env -i PATH="$SAFE_PATH" HOME="$SAFE_HOME" \
OPENAI_API_KEY="x" \
OPENAI_BASE_URL="http://127.0.0.1:5055/v1" \
OPENAI_API_BASE="http://127.0.0.1:5055/v1" \
GH_TOKEN="$GH_TOKEN" \
RUSTC_WRAPPER="sccache" SCCACHE_GHA_ENABLED="true" SCCACHE_IDLE_TIMEOUT="1800" SCCACHE_CACHE_SIZE="5G" \
npm_config_cache="$SAFE_HOME/.npm" \
npx -y @just-every/code@latest \
exec \
-s workspace-write \
-c sandbox_workspace_write.allow_git_writes=true \
-c sandbox_workspace_write.network_access=true \
-c shell_environment_policy.r#set.CARGO_HOME="${RUNNER_TEMP}/cargo-home" \
-c shell_environment_policy.r#set.CARGO_TARGET_DIR="${GITHUB_WORKSPACE}/codex-rs/target" \
-c shell_environment_policy.r#set.RUSTC_WRAPPER="sccache" \
-c shell_environment_policy.r#set.SCCACHE_GHA_ENABLED="true" \
-c shell_environment_policy.r#set.SCCACHE_IDLE_TIMEOUT="1800" \
-c shell_environment_policy.r#set.SCCACHE_CACHE_SIZE="5G" \
-c shell_environment_policy.r#set.KEEP_ENV="1" \
--cd "$GITHUB_WORKSPACE" \
--skip-git-repo-check \
- | tee -a "$GITHUB_WORKSPACE/.github/auto/AGENT_REMEDIATION_STDOUT.txt"
- name: Re-run verify after remediation
if: steps.check_upstream.outputs.skip != 'true' && steps.verify.outputs.failed == 'true'
working-directory: .
run: |
set -euo pipefail
bash scripts/upstream-merge/verify.sh
if bash scripts/upstream-merge/verify.sh; then
echo "remediated=true" >> "$GITHUB_OUTPUT"
exit 0
fi
attempt=$((attempt + 1))
if [ "$attempt" -gt "$MAX_ATTEMPTS" ]; then
echo "::error::Verification still failing after ${MAX_ATTEMPTS} remediation attempts" >&2
exit 1
fi
done
- name: Upload artifact - VERIFY logs
if: steps.check_upstream.outputs.skip != 'true' && always()
uses: actions/upload-artifact@v4
with:
name: VERIFY
if-no-files-found: warn
path: |
.github/auto/VERIFY.json
.github/auto/VERIFY_build-fast.log
.github/auto/VERIFY_api-check.log
.github/auto/VERIFY_guards.log
.github/auto/AGENT_REMEDIATION_STDOUT.txt
- name: Enforce policy removals on merge branch (images + caches + purge_globs)
if: steps.check_upstream.outputs.skip != 'true'
@@ -997,7 +1049,7 @@ jobs:
pr:
name: Open/Update PR (no merge work)
needs: [precheck]
if: needs.precheck.outputs.action == 'pr_only'
if: needs.precheck.outputs.skip_due_to_active != 'true' && needs.precheck.outputs.action == 'pr_only'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
+36
View File
@@ -855,6 +855,42 @@ pub fn set_tui_alternate_screen(codex_home: &Path, enabled: bool) -> anyhow::Res
Ok(())
}
/// Persist the TUI notifications preference into `CODEX_HOME/config.toml` at `[tui].notifications`.
pub fn set_tui_notifications(
codex_home: &Path,
notifications: crate::config_types::Notifications,
) -> anyhow::Result<()> {
use crate::config_types::Notifications;
let config_path = codex_home.join(CONFIG_TOML_FILE);
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
let mut doc = match std::fs::read_to_string(&read_path) {
Ok(contents) => contents.parse::<DocumentMut>()?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
Err(e) => return Err(e.into()),
};
match notifications {
Notifications::Enabled(value) => {
doc["tui"]["notifications"] = toml_edit::value(value);
}
Notifications::Custom(values) => {
let mut array = TomlArray::default();
for value in values {
array.push(value);
}
doc["tui"]["notifications"] = TomlItem::Value(array.into());
}
}
std::fs::create_dir_all(codex_home)?;
let tmp_file = NamedTempFile::new_in(codex_home)?;
std::fs::write(tmp_file.path(), doc.to_string())?;
tmp_file.persist(config_path)?;
Ok(())
}
/// Persist the GitHub workflow check preference under `[github].check_workflows_on_push`.
pub fn set_github_check_on_push(codex_home: &Path, enabled: bool) -> anyhow::Result<()> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
+78
View File
@@ -15,6 +15,7 @@ use crate::tui;
use crate::tui::TerminalInfo;
use codex_core::config::add_project_allowed_command;
use codex_core::config::Config;
use codex_core::config_types::Notifications;
use codex_core::protocol::Event;
use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
@@ -418,6 +419,66 @@ impl App<'_> {
);
}
fn sanitize_notification_text(input: &str) -> String {
let mut sanitized = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'\u{00}'..='\u{08}' | '\u{0B}' | '\u{0C}' | '\u{0E}'..='\u{1F}' | '\u{7F}' => {}
'\n' | '\r' | '\t' => {
if !sanitized.ends_with(' ') {
sanitized.push(' ');
}
}
_ => sanitized.push(ch),
}
}
sanitized
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn format_notification_message(title: &str, body: Option<&str>) -> Option<String> {
let title = Self::sanitize_notification_text(title);
let body = body.map(Self::sanitize_notification_text);
let mut message = match body {
Some(ref b) if !b.is_empty() => {
if title.is_empty() {
b.clone()
} else {
format!("{}: {}", title, b)
}
}
_ => title.clone(),
};
if message.is_empty() {
return None;
}
const MAX_LEN: usize = 160;
if message.chars().count() > MAX_LEN {
let mut truncated = String::new();
for ch in message.chars() {
if truncated.chars().count() >= MAX_LEN.saturating_sub(3) {
break;
}
truncated.push(ch);
}
truncated.push_str("...");
message = truncated;
}
Some(message)
}
fn emit_osc9_notification(message: &str) {
let payload = format!("\u{1b}]9;{}\u{7}", message);
let mut stdout = std::io::stdout();
let _ = stdout.write_all(payload.as_bytes());
let _ = stdout.flush();
}
/// Schedule a redraw immediately and open a short debounce window to coalesce
/// subsequent requests. Crucially, even if a timer is already armed (e.g., an
@@ -1705,6 +1766,11 @@ impl App<'_> {
widget.handle_update_command();
}
}
SlashCommand::Notifications => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_notifications_command(command_args);
}
}
SlashCommand::Agents => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_agents_command(command_args);
@@ -1884,6 +1950,13 @@ impl App<'_> {
widget.set_github_watcher(enabled);
}
}
AppEvent::UpdateTuiNotifications(enabled) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_tui_notifications(enabled);
}
self.config.tui.notifications = Notifications::Enabled(enabled);
self.config.tui_notifications = Notifications::Enabled(enabled);
}
AppEvent::UpdateValidationTool { name, enable } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.toggle_validation_tool(&name, enable);
@@ -1898,6 +1971,11 @@ impl App<'_> {
self.terminal_title_override = title;
self.apply_terminal_title();
}
AppEvent::EmitTuiNotification { title, body } => {
if let Some(message) = Self::format_notification_message(&title, body.as_deref()) {
Self::emit_osc9_notification(&message);
}
}
AppEvent::UpdateMcpServer { name, enable } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.toggle_mcp_server(&name, enable);
+5
View File
@@ -87,6 +87,9 @@ pub(crate) enum AppEvent {
/// Update the terminal title override. `None` restores the default title.
SetTerminalTitle { title: Option<String> },
/// Emit a best-effort OSC 9 notification from the terminal.
EmitTuiNotification { title: String, body: Option<String> },
/// Schedule a one-shot animation frame roughly after the given duration.
/// Multiple requests are coalesced by the central frame scheduler.
ScheduleFrameIn(Duration),
@@ -149,6 +152,8 @@ pub(crate) enum AppEvent {
/// Update GitHub workflow monitoring toggle
UpdateGithubWatcher(bool),
/// Update the TUI notifications toggle
UpdateTuiNotifications(bool),
/// Enable/disable a specific validation tool
UpdateValidationTool { name: String, enable: bool },
/// Enable/disable an entire validation group
+1
View File
@@ -41,6 +41,7 @@ impl AppEventSender {
| AppEvent::Redraw
| AppEvent::ExitRequest
| AppEvent::SetTerminalTitle { .. }
| AppEvent::EmitTuiNotification { .. }
);
let tx = if is_high { &self.high_tx } else { &self.bulk_tx };
+8
View File
@@ -46,6 +46,7 @@ mod verbosity_selection_view;
pub(crate) mod validation_settings_view;
mod update_settings_view;
mod undo_restore_view;
mod notifications_settings_view;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CancellationEvent {
@@ -63,6 +64,7 @@ pub(crate) use login_accounts_view::{
};
pub(crate) use update_settings_view::{UpdateSettingsView, UpdateSharedState};
pub(crate) use notifications_settings_view::{NotificationsMode, NotificationsSettingsView};
use codex_core::protocol::Op;
use approval_modal_view::ApprovalModalView;
@@ -161,6 +163,12 @@ impl BottomPane<'_> {
self.request_redraw();
}
pub fn show_notifications_settings(&mut self, view: NotificationsSettingsView) {
self.active_view = Some(Box::new(view));
self.status_view_active = false;
self.request_redraw();
}
/// Show per-agent editor
pub fn show_agent_editor(
&mut self,
@@ -0,0 +1,229 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use super::bottom_pane_view::BottomPaneView;
use super::BottomPane;
#[derive(Clone)]
pub(crate) enum NotificationsMode {
Toggle { enabled: bool },
Custom { entries: Vec<String> },
}
pub(crate) struct NotificationsSettingsView {
mode: NotificationsMode,
app_event_tx: AppEventSender,
selected_row: usize,
is_complete: bool,
}
impl NotificationsSettingsView {
pub fn new(mode: NotificationsMode, app_event_tx: AppEventSender) -> Self {
Self {
mode,
app_event_tx,
selected_row: 0,
is_complete: false,
}
}
fn toggle(&mut self) {
match &mut self.mode {
NotificationsMode::Toggle { enabled } => {
*enabled = !*enabled;
self.app_event_tx
.send(AppEvent::UpdateTuiNotifications(*enabled));
}
NotificationsMode::Custom { entries } => {
let filters = if entries.is_empty() {
"(none)".to_string()
} else {
entries.join(", ")
};
self.app_event_tx.send_background_event(format!(
"TUI notifications are filtered in config: [{}]",
filters
));
self.app_event_tx.send_background_event(
"Edit ~/.code/config.toml [tui].notifications to change filters.".to_string(),
);
}
}
}
fn status_line(&self) -> Line<'static> {
match &self.mode {
NotificationsMode::Toggle { enabled } => {
let status = if *enabled { "Enabled" } else { "Disabled" };
let color = if *enabled {
crate::colors::success()
} else {
crate::colors::warning()
};
Line::from(vec![
Span::styled("Status: ", Style::default().fg(crate::colors::text_dim())),
Span::styled(status, Style::default().fg(color).add_modifier(Modifier::BOLD)),
])
}
NotificationsMode::Custom { entries } => {
let filters = if entries.is_empty() {
"<none>".to_string()
} else {
entries.join(", ")
};
Line::from(vec![
Span::styled("Status: ", Style::default().fg(crate::colors::text_dim())),
Span::styled("Custom filter", Style::default().fg(crate::colors::info()).add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(filters, Style::default().fg(crate::colors::dim())),
])
}
}
}
fn toggle_line(&self) -> Line<'static> {
match &self.mode {
NotificationsMode::Toggle { enabled } => {
let label = if *enabled { "Enabled" } else { "Disabled" };
Line::from(vec![
Span::styled("Notifications: ", Style::default().fg(crate::colors::text_dim())),
Span::raw(label),
])
}
NotificationsMode::Custom { .. } => {
Line::from(vec![
Span::styled(
"Notifications are managed by your config file.",
Style::default().fg(crate::colors::text()),
),
])
}
}
}
}
impl<'a> BottomPaneView<'a> for NotificationsSettingsView {
fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, key_event: KeyEvent) {
match key_event {
KeyEvent { code: KeyCode::Up, modifiers: KeyModifiers::NONE, .. } => {
if self.selected_row > 0 {
self.selected_row -= 1;
}
}
KeyEvent { code: KeyCode::Down, modifiers: KeyModifiers::NONE, .. } => {
if self.selected_row < 1 {
self.selected_row += 1;
}
}
KeyEvent { code: KeyCode::Left | KeyCode::Right, modifiers: KeyModifiers::NONE, .. } => {
if self.selected_row == 0 {
self.toggle();
}
}
KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, .. } => {
if self.selected_row == 0 {
self.toggle();
} else {
self.is_complete = true;
}
}
KeyEvent { code: KeyCode::Char(' '), modifiers: KeyModifiers::NONE, .. } => {
if self.selected_row == 0 {
self.toggle();
}
}
KeyEvent { code: KeyCode::Esc, .. } => {
self.is_complete = true;
}
_ => {}
}
}
fn is_complete(&self) -> bool {
self.is_complete
}
fn desired_height(&self, _width: u16) -> u16 {
9
}
fn render(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::border()))
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()))
.title(" Notifications ")
.title_alignment(Alignment::Center);
let inner = block.inner(area);
block.render(area, buf);
let mut lines = Vec::new();
lines.push(self.status_line());
lines.push(Line::from(""));
let mut toggle_line = self.toggle_line();
if self.selected_row == 0 {
toggle_line = toggle_line
.style(
Style::default()
.bg(crate::colors::selection())
.add_modifier(Modifier::BOLD),
);
}
lines.push(toggle_line);
lines.push(Line::from(""));
let mut close_line = Line::from(vec![
Span::raw(if self.selected_row == 1 { "> " } else { " " }),
Span::raw("Close"),
]);
if self.selected_row == 1 {
close_line = close_line
.style(
Style::default()
.bg(crate::colors::selection())
.add_modifier(Modifier::BOLD),
);
}
lines.push(close_line);
lines.push(Line::from(""));
let footer = match &self.mode {
NotificationsMode::Toggle { .. } => Line::from(vec![
Span::styled("Up/Down", Style::default().fg(crate::colors::light_blue())),
Span::raw(" Navigate "),
Span::styled("Left/Right or Space", Style::default().fg(crate::colors::success())),
Span::raw(" Toggle "),
Span::styled("Enter", Style::default().fg(crate::colors::success())),
Span::raw(" Toggle or Close "),
Span::styled("Esc", Style::default().fg(crate::colors::error())),
Span::raw(" Cancel"),
]),
NotificationsMode::Custom { .. } => Line::from(vec![
Span::styled("Edit ", Style::default().fg(crate::colors::text_dim())),
Span::styled("[tui].notifications", Style::default().fg(crate::colors::info())),
Span::styled(" in ~/.code/config.toml to adjust filters.", Style::default().fg(crate::colors::text_dim())),
]),
};
lines.push(footer);
let paragraph = Paragraph::new(lines)
.alignment(Alignment::Left)
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()));
paragraph.render(
Rect {
x: inner.x.saturating_add(1),
y: inner.y,
width: inner.width.saturating_sub(2),
height: inner.height,
},
buf,
);
}
}
+34 -8
View File
@@ -295,11 +295,21 @@ impl TextArea {
modifiers: KeyModifiers::CONTROL,
..
} => self.delete_backward(1),
KeyEvent {
code: KeyCode::Delete,
modifiers: KeyModifiers::ALT,
..
} => self.delete_forward_word(),
KeyEvent { code: KeyCode::Delete, modifiers, .. }
if modifiers.contains(KeyModifiers::SUPER)
&& !modifiers.intersects(KeyModifiers::ALT | KeyModifiers::CONTROL) =>
{
self.delete_entire_line();
}
// Option+Fn+Delete (Forward Delete on macOS) arrives as ALT+Delete. Keep
// it deleting the word ahead of the cursor so we do not regress the
// default macOS terminal behavior. Option+Backspace already covers the
// "delete previous word" shortcut exposed in Warp.
KeyEvent { code: KeyCode::Delete, modifiers, .. }
if modifiers.contains(KeyModifiers::ALT) =>
{
self.delete_forward_word();
}
KeyEvent {
code: KeyCode::Delete,
..
@@ -335,7 +345,7 @@ impl TextArea {
// macOS-like shortcuts (when terminals report the Command key as SUPER):
// Cmd+Left -> move to beginning of line
// Cmd+Right -> move to end of line
// Cmd+Backspace -> delete entire current line contents
// Cmd+Backspace / Cmd+Delete -> delete entire current line contents
KeyEvent { code: KeyCode::Left, modifiers, .. }
if modifiers.contains(KeyModifiers::SUPER)
&& !modifiers.intersects(KeyModifiers::ALT | KeyModifiers::CONTROL) =>
@@ -349,7 +359,8 @@ impl TextArea {
self.move_cursor_to_end_of_line(false);
}
KeyEvent { code: KeyCode::Backspace, modifiers, .. }
if modifiers.contains(KeyModifiers::SUPER) =>
if modifiers.contains(KeyModifiers::SUPER)
&& !modifiers.contains(KeyModifiers::ALT) =>
{
self.delete_entire_line();
}
@@ -1272,7 +1283,7 @@ mod tests {
}
#[test]
fn delete_forward_word_with_without_alt_modifier() {
fn option_fn_delete_deletes_next_word() {
let mut t = ta_with("hello world");
t.set_cursor(0);
t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT));
@@ -1286,6 +1297,21 @@ mod tests {
assert_eq!(t.cursor(), 0);
}
#[test]
fn super_delete_and_backspace_clear_current_line() {
let mut t = ta_with("example text");
t.set_cursor(4);
t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::SUPER));
assert_eq!(t.text(), "");
assert_eq!(t.cursor(), 0);
let mut t = ta_with("example text");
t.set_cursor(4);
t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::SUPER));
assert_eq!(t.text(), "");
assert_eq!(t.cursor(), 0);
}
#[test]
fn alt_enter_inserts_newline() {
let mut t = TextArea::new();
+280 -34
View File
@@ -24,6 +24,7 @@ use codex_core::ConversationManager;
use codex_core::config::Config;
use codex_core::git_info::CommitLogEntry;
use codex_core::config_types::AgentConfig;
use codex_core::config_types::Notifications;
use codex_core::config_types::ReasoningEffort;
use codex_core::config_types::TextVerbosity;
use codex_core::plan_tool::{PlanItemArg, StepStatus, UpdatePlanArgs};
@@ -410,6 +411,11 @@ pub(crate) struct GhostState {
snapshots: Vec<GhostSnapshot>,
disabled: bool,
disabled_reason: Option<GhostSnapshotsDisabledReason>,
queue: VecDeque<(u64, GhostSnapshotRequest)>,
active: Option<(u64, GhostSnapshotRequest)>,
next_id: u64,
pending_dispatches: VecDeque<PendingSnapshotDispatch>,
queued_user_messages: VecDeque<UserMessage>,
}
struct UndoSnapshotPreview {
@@ -609,6 +615,7 @@ pub(crate) struct ChatWidget<'a> {
synthetic_system_req: Option<u64>,
// Map of system notice ids to their history index for in-place replacement
system_cell_by_id: std::collections::HashMap<String, usize>,
replay_history_depth: usize,
}
struct PendingJumpBack {
@@ -704,6 +711,7 @@ enum GhostSnapshotJobHandle {
Skipped,
}
#[derive(Clone)]
enum PendingSnapshotDispatch {
Direct { job_id: u64, batch: Vec<UserMessage> },
Queued { job_id: u64, message: UserMessage },
@@ -2795,6 +2803,7 @@ impl ChatWidget<'_> {
synthetic_system_req: None,
system_cell_by_id: HashMap::new(),
standard_terminal_mode: !config.tui.alternate_screen,
replay_history_depth: 0,
};
if let Ok(Some(active_id)) = auth_accounts::get_active_account_id(&config.codex_home) {
if let Ok(records) = account_usage::list_rate_limit_snapshots(&config.codex_home) {
@@ -3030,6 +3039,7 @@ impl ChatWidget<'_> {
pending_agent_notes: Vec::new(),
synthetic_system_req: None,
system_cell_by_id: HashMap::new(),
replay_history_depth: 0,
};
if let Ok(Some(active_id)) = auth_accounts::get_active_account_id(&config.codex_home) {
if let Ok(records) = account_usage::list_rate_limit_snapshots(&config.codex_home) {
@@ -3473,7 +3483,7 @@ impl ChatWidget<'_> {
let codex_home = self.config.codex_home.clone();
let accounts = auth_accounts::list_accounts(&codex_home).unwrap_or_default();
let mut account_map: HashMap<String, StoredAccount> = accounts
let account_map: HashMap<String, StoredAccount> = accounts
.into_iter()
.map(|account| (account.id.clone(), account))
.collect();
@@ -3485,19 +3495,22 @@ impl ChatWidget<'_> {
let usage_records = account_usage::list_rate_limit_snapshots(&codex_home).unwrap_or_default();
let mut snapshot_map: HashMap<String, StoredRateLimitSnapshot> = usage_records
.into_iter()
.filter(|record| account_map.contains_key(&record.account_id))
.map(|record| (record.account_id.clone(), record))
.collect();
let mut summary_ids: HashSet<String> = account_map.keys().cloned().collect();
summary_ids.extend(snapshot_map.keys().cloned());
if let Some(id) = active_id.as_ref() {
summary_ids.insert(id.clone());
let mut usage_summary_map: HashMap<String, StoredUsageSummary> = HashMap::new();
for id in account_map.keys() {
if let Ok(Some(summary)) = account_usage::load_account_usage(&codex_home, id) {
usage_summary_map.insert(id.clone(), summary);
}
}
let mut usage_summary_map: HashMap<String, StoredUsageSummary> = HashMap::new();
for id in summary_ids {
if let Ok(Some(summary)) = account_usage::load_account_usage(&codex_home, &id) {
usage_summary_map.insert(id, summary);
if let Some(active_id) = active_id.as_ref() {
if !usage_summary_map.contains_key(active_id) {
if let Ok(Some(summary)) = account_usage::load_account_usage(&codex_home, active_id) {
usage_summary_map.insert(active_id.clone(), summary);
}
}
}
@@ -3523,25 +3536,21 @@ impl ChatWidget<'_> {
let extra = Self::usage_history_lines(summary_ref);
let view = build_limits_view(&snapshot, current_reset, DEFAULT_GRID_CONFIG);
tabs.push(LimitsTab::view(title, header, view, extra));
if let Some(id) = active_id.as_ref() {
seen_ids.insert(id.clone());
account_map.remove(id);
snapshot_map.remove(id);
usage_summary_map.remove(id);
if let Some(active_id) = active_id.as_ref() {
if account_map.contains_key(active_id) {
seen_ids.insert(active_id.clone());
snapshot_map.remove(active_id);
usage_summary_map.remove(active_id);
}
}
}
let mut remaining_ids: Vec<String> = Vec::new();
for id in account_map.keys() {
if seen_ids.insert(id.clone()) {
remaining_ids.push(id.clone());
}
}
for id in snapshot_map.keys() {
if seen_ids.insert(id.clone()) {
remaining_ids.push(id.clone());
}
}
let mut remaining_ids: Vec<String> = account_map
.keys()
.filter(|id| !seen_ids.contains(*id))
.cloned()
.collect();
remaining_ids.sort_by(|a, b| {
let a_label = account_map
.get(a)
@@ -3551,9 +3560,14 @@ impl ChatWidget<'_> {
.get(b)
.map(Self::account_label)
.unwrap_or_else(|| b.clone());
a_label
.to_ascii_lowercase()
.cmp(&b_label.to_ascii_lowercase())
let a_cmp = a_label.to_ascii_lowercase();
let b_cmp = b_label.to_ascii_lowercase();
let order = a_cmp.cmp(&b_cmp);
if order == std::cmp::Ordering::Equal {
a_label.cmp(&b_label)
} else {
order
}
});
for id in remaining_ids {
@@ -5258,6 +5272,11 @@ impl ChatWidget<'_> {
snapshots: self.ghost_snapshots.clone(),
disabled: self.ghost_snapshots_disabled,
disabled_reason: self.ghost_snapshots_disabled_reason.clone(),
queue: self.ghost_snapshot_queue.clone(),
active: self.active_ghost_snapshot.clone(),
next_id: self.next_ghost_snapshot_id,
pending_dispatches: self.pending_snapshot_dispatches.clone(),
queued_user_messages: self.queued_user_messages.clone(),
}
}
@@ -5269,6 +5288,13 @@ impl ChatWidget<'_> {
}
self.ghost_snapshots_disabled = state.disabled;
self.ghost_snapshots_disabled_reason = state.disabled_reason;
self.ghost_snapshot_queue = state.queue;
self.active_ghost_snapshot = state.active;
self.next_ghost_snapshot_id = state.next_id;
self.pending_snapshot_dispatches = state.pending_dispatches;
self.queued_user_messages = state.queued_user_messages;
self.refresh_queued_user_messages();
self.spawn_next_ghost_snapshot();
}
fn snapshot_preview(&self, index: usize) -> Option<UndoSnapshotPreview> {
@@ -5864,6 +5890,7 @@ impl ChatWidget<'_> {
}
EventMsg::ReplayHistory(ev) => {
let codex_core::protocol::ReplayHistoryEvent { items, events } = ev;
self.replay_history_depth = self.replay_history_depth.saturating_add(1);
let mut max_req = self.last_seen_request_index;
if events.is_empty() {
for item in &items {
@@ -5896,6 +5923,7 @@ impl ChatWidget<'_> {
self.current_request_index = self.last_seen_request_index;
}
self.request_redraw();
self.replay_history_depth = self.replay_history_depth.saturating_sub(1);
}
EventMsg::WebSearchComplete(ev) => {
tools::web_search_complete(self, ev.call_id, ev.query)
@@ -6072,9 +6100,7 @@ impl ChatWidget<'_> {
self.mark_needs_redraw();
}
EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: _,
}) => {
EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => {
// Finalize any active streams
if self.stream.is_write_cycle_active() {
// Finalize both streams via streaming facade
@@ -6155,6 +6181,7 @@ impl ChatWidget<'_> {
self.stream_state.current_kind = None;
// Final re-check for idle state
self.maybe_hide_spinner();
self.emit_turn_complete_notification(last_agent_message);
self.mark_needs_redraw();
}
EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent {
@@ -7684,6 +7711,79 @@ impl ChatWidget<'_> {
);
}
pub(crate) fn handle_notifications_command(&mut self, args: String) {
use crate::bottom_pane::{NotificationsMode, NotificationsSettingsView};
let trimmed = args.trim();
if trimmed.is_empty() {
let mode = match &self.config.tui.notifications {
Notifications::Enabled(enabled) => NotificationsMode::Toggle { enabled: *enabled },
Notifications::Custom(entries) => NotificationsMode::Custom { entries: entries.clone() },
};
let view = NotificationsSettingsView::new(mode, self.app_event_tx.clone());
self.bottom_pane.show_notifications_settings(view);
return;
}
let keyword = trimmed.split_whitespace().next().unwrap_or("").to_ascii_lowercase();
match keyword.as_str() {
"status" => {
match &self.config.tui.notifications {
Notifications::Enabled(true) => {
self.push_background_tail("🔔 TUI notifications are enabled.".to_string());
}
Notifications::Enabled(false) => {
self.push_background_tail("🔕 TUI notifications are disabled.".to_string());
}
Notifications::Custom(entries) => {
let filters = if entries.is_empty() {
"<none>".to_string()
} else {
entries.join(", ")
};
self.push_background_tail(format!(
"🔔 TUI notifications use custom filters: [{}]",
filters
));
}
}
}
"on" | "off" => {
let enable = keyword == "on";
match &self.config.tui.notifications {
Notifications::Enabled(current) => {
if *current == enable {
self.push_background_tail(format!(
"TUI notifications already {}.",
if enable { "enabled" } else { "disabled" }
));
} else {
self.app_event_tx
.send(AppEvent::UpdateTuiNotifications(enable));
}
}
Notifications::Custom(entries) => {
let filters = if entries.is_empty() {
"<none>".to_string()
} else {
entries.join(", ")
};
self.push_background_tail(format!(
"TUI notifications use custom filters ([{}]); edit ~/.code/config.toml to change them.",
filters
));
}
}
}
_ => {
self.push_background_tail(
"Usage: /notifications [status|on|off]".to_string(),
);
}
}
}
pub(crate) fn add_prompts_output(&mut self) {
self.history_push(history_cell::new_prompts_output());
}
@@ -14390,8 +14490,8 @@ impl ChatWidget<'_> {
let mut items: Vec<SelectionItem> = Vec::new();
items.push(SelectionItem {
name: "Review current workspace changes".to_string(),
description: Some("Include staged, unstaged, and untracked files".to_string()),
name: "Review /branch changes".to_string(),
description: Some("Compare your worktree branch against its merge target".to_string()),
is_current: false,
actions: vec![Box::new(|tx: &crate::app_event_sender::AppEventSender| {
tx.send(crate::app_event::AppEvent::RunReviewCommand(String::new()));
@@ -14610,7 +14710,7 @@ impl ChatWidget<'_> {
let prompt = if let Some(current) = current_trimmed.as_ref() {
format!(
"Review the code changes between the current branch '{current}' and '{branch_trimmed}'. Identify bugs, regressions, risky patterns, and missing tests before merging."
"Review the code changes between the current branch '{current}' and '{branch_trimmed}'. Identify the intent of the changes in '{current}' and ensure no obvious gaps remain. Find all geniune bugs or regressions which need to be addressed before merging. Return ALL issues which need to be addressed, not just the first one you find."
)
} else {
format!(
@@ -14683,6 +14783,62 @@ impl ChatWidget<'_> {
let trimmed = args.trim();
if trimmed.is_empty() {
if Self::is_branch_worktree_path(&self.config.cwd) {
if let Some(git_root) =
codex_core::git_info::resolve_root_git_project_for_trust(&self.config.cwd)
{
let worktree_cwd = self.config.cwd.clone();
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let default_branch = codex_core::git_worktree::detect_default_branch(&git_root)
.await
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty());
let current_branch = codex_core::git_info::current_branch_name(&worktree_cwd)
.await
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty());
if let (Some(base_branch), Some(current_branch)) =
(default_branch, current_branch)
{
if base_branch != current_branch {
let prompt = format!(
"Review the code changes between the current branch '{current_branch}' and '{base_branch}'. Identify the intent of the changes in '{current_branch}' and ensure no obvious gaps remain. Find all geniune bugs or regressions which need to be addressed before merging. Return ALL issues which need to be addressed, not just the first one you find."
);
let hint = format!("against {base_branch}");
let preparation_label =
Some(format!("Preparing code review against {base_branch}"));
let metadata = Some(ReviewContextMetadata {
scope: Some("branch_diff".to_string()),
base_branch: Some(base_branch.clone()),
current_branch: Some(current_branch.clone()),
..Default::default()
});
tx.send(crate::app_event::AppEvent::RunReviewWithScope {
prompt,
hint,
preparation_label,
metadata,
});
return;
}
}
tx.send(crate::app_event::AppEvent::RunReviewWithScope {
prompt: "Review the current workspace changes and highlight bugs, regressions, risky patterns, and missing tests before merge.".to_string(),
hint: "current workspace changes".to_string(),
preparation_label: Some("Preparing code review request...".to_string()),
metadata: Some(ReviewContextMetadata {
scope: Some("workspace".to_string()),
..Default::default()
}),
});
});
return;
}
}
let metadata = ReviewContextMetadata {
scope: Some("workspace".to_string()),
..Default::default()
@@ -19630,6 +19786,96 @@ impl ChatWidget<'_> {
}
}
pub(crate) fn set_tui_notifications(&mut self, enabled: bool) {
let new_state = Notifications::Enabled(enabled);
self.config.tui.notifications = new_state.clone();
self.config.tui_notifications = new_state.clone();
match find_codex_home() {
Ok(home) => {
match codex_core::config::set_tui_notifications(&home, new_state) {
Ok(()) => {
let msg = format!(
"✅ {} TUI notifications",
if enabled { "Enabled" } else { "Disabled" }
);
self.push_background_tail(msg);
}
Err(err) => {
let msg = format!(
"⚠️ Failed to persist TUI notifications setting: {}",
err
);
self.history_push(history_cell::new_error_event(msg));
}
}
}
Err(_) => {
let msg = format!(
"✅ {} TUI notifications (not persisted: CODE_HOME/CODEX_HOME not found)",
if enabled { "Enabled" } else { "Disabled" }
);
self.push_background_tail(msg);
}
}
}
fn emit_turn_complete_notification(&self, last_agent_message: Option<String>) {
if !self.should_emit_tui_notification("agent-turn-complete") {
return;
}
let snippet = last_agent_message
.as_deref()
.map(Self::notification_snippet)
.filter(|text| !text.is_empty());
self.app_event_tx.send(AppEvent::EmitTuiNotification {
title: "Code".to_string(),
body: snippet,
});
}
fn should_emit_tui_notification(&self, event: &str) -> bool {
if self.replay_history_depth > 0 {
return false;
}
self.tui_notification_filter_allows(event)
}
fn tui_notification_filter_allows(&self, event: &str) -> bool {
match &self.config.tui.notifications {
Notifications::Enabled(enabled) => *enabled,
Notifications::Custom(entries) => entries
.iter()
.any(|entry| entry.eq_ignore_ascii_case(event)),
}
}
fn notification_snippet(input: &str) -> String {
let collapsed = input
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
const LIMIT: usize = 120;
if collapsed.chars().count() <= LIMIT {
return collapsed;
}
let mut truncated = String::new();
let mut count = 0usize;
for ch in collapsed.chars() {
if count >= LIMIT.saturating_sub(3) {
break;
}
truncated.push(ch);
count += 1;
}
truncated.push_str("...");
truncated
}
pub(crate) fn toggle_mcp_server(&mut self, name: &str, enable: bool) {
match codex_core::config::find_codex_home() {
Ok(home) => match codex_core::config::set_mcp_server_enabled(&home, name, enable) {
+2
View File
@@ -64,6 +64,7 @@ pub enum SlashCommand {
Limits,
#[strum(serialize = "update", serialize = "upgrade")]
Update,
Notifications,
Theme,
Model,
Reasoning,
@@ -113,6 +114,7 @@ impl SlashCommand {
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::Limits => "visualize weekly and hourly rate limits",
SlashCommand::Update => "check for updates and optionally upgrade",
SlashCommand::Notifications => "toggle TUI notifications (status/on/off)",
SlashCommand::Theme => "switch between color themes",
SlashCommand::Prompts => "show example prompts",
SlashCommand::Model => "choose model & reasoning effort",
+1
View File
@@ -49,6 +49,7 @@ Notes
- `/limits`: visualize current hourly and weekly rate-limit usage.
- `/update`: check the installed version, detect available upgrades, and run the
appropriate installer when possible.
- `/notifications`: inspect and toggle TUI notifications.
- `/mcp`: manage MCP servers (status/on/off/add).
- `/validation [status|on|off|<tool> (on|off)]`: inspect or toggle validation
harness settings.