mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
## What changed - Group preferences into Appearance, Notifications, Input, Sessions, and Text selection tabs, with search and links to existing theme, status line, terminal title, and keyboard editors. - Persist preference changes to the selected local configuration file, apply supported changes immediately, and show save errors, configuration overrides, and restart requirements in the panel. - Add a delayed notification test and controls for notification timing while preserving custom event filters. - Add `tui.copy_on_select_clear_selection`, disabled by default, to clear transcript selection highlights only after confirmed automatic clipboard delivery. - Keep local configuration editors usable while disconnected and prevent held activation keys or buffered input from triggering actions across editor and reconnect transitions. ## Testing Add regression tests and rendering snapshots for panel navigation, search, dependency states, editor handoffs, offline persistence, notification timers, custom filter preservation, and confirmed-copy deselection. GitOrigin-RevId: a16d218046060fcb6e61705a803fcd9297266e6e
252 lines
7.6 KiB
Rust
252 lines
7.6 KiB
Rust
use codex_config::types::TuiKeymap;
|
|
use crossterm::event::KeyEvent;
|
|
use crossterm::event::KeyEventKind;
|
|
use crossterm::event::KeyModifiers;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::Stylize;
|
|
use ratatui::text::Line;
|
|
use ratatui::widgets::Paragraph;
|
|
use ratatui::widgets::Widget;
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
|
|
use crate::bottom_pane::BottomPaneView;
|
|
use crate::bottom_pane::CancellationEvent;
|
|
use crate::key_hint::KeyBinding;
|
|
use crate::keymap::RuntimeKeymap;
|
|
use crate::render::renderable::Renderable;
|
|
|
|
use super::actions;
|
|
use super::actions::matching_actions_for_key_event;
|
|
use super::key_event_to_config_key_spec;
|
|
|
|
const MISSING_KEY_HINT_DELAY: Duration = Duration::from_secs(3);
|
|
const SHORT_MISSING_KEY_HINT: &str = "Tip: Codex can only inspect keys your terminal sends.";
|
|
const DELAYED_MISSING_KEY_HINT: &str = "Still waiting? If nothing changes when you press a key, your terminal is not sending that key to Codex. Only received keys can be assigned as shortcuts.";
|
|
|
|
struct KeymapDebugReport {
|
|
detected: KeyBinding,
|
|
config_key: Result<String, String>,
|
|
raw_event: String,
|
|
matches: Vec<actions::KeymapDebugActionMatch>,
|
|
}
|
|
|
|
/// Bottom-pane view for inspecting how terminal key events map to keymap actions.
|
|
pub(crate) struct KeymapDebugView {
|
|
runtime_keymap: RuntimeKeymap,
|
|
keymap_config: TuiKeymap,
|
|
opened_at: Instant,
|
|
last_report: Option<KeymapDebugReport>,
|
|
complete: bool,
|
|
}
|
|
|
|
pub(crate) fn build_keymap_debug_view(
|
|
runtime_keymap: &RuntimeKeymap,
|
|
keymap_config: &TuiKeymap,
|
|
) -> KeymapDebugView {
|
|
KeymapDebugView {
|
|
runtime_keymap: runtime_keymap.clone(),
|
|
keymap_config: keymap_config.clone(),
|
|
opened_at: Instant::now(),
|
|
last_report: None,
|
|
complete: false,
|
|
}
|
|
}
|
|
|
|
impl KeymapDebugView {
|
|
fn lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
self.lines_at(width, Instant::now())
|
|
}
|
|
|
|
fn lines_at(&self, width: u16, now: Instant) -> Vec<Line<'static>> {
|
|
let wrap_width = usize::from(width.max(1));
|
|
let mut lines = vec![
|
|
Line::from("Keypress Inspector".bold()),
|
|
Line::from(
|
|
"Press any key to see what Codex receives. Esc is inspected; Ctrl+C closes.".dim(),
|
|
),
|
|
];
|
|
let hint = if self.should_show_delayed_hint(now) {
|
|
DELAYED_MISSING_KEY_HINT
|
|
} else {
|
|
SHORT_MISSING_KEY_HINT
|
|
};
|
|
push_wrapped_dim(&mut lines, hint.to_string(), wrap_width, "", "");
|
|
|
|
let Some(report) = &self.last_report else {
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from("Waiting for a keypress...".cyan()));
|
|
return lines;
|
|
};
|
|
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from(vec![
|
|
"Detected: ".dim(),
|
|
report.detected.display_label().cyan(),
|
|
]));
|
|
match &report.config_key {
|
|
Ok(config_key) => {
|
|
lines.push(Line::from(vec![
|
|
"Config key: ".dim(),
|
|
config_key.clone().cyan(),
|
|
]));
|
|
}
|
|
Err(error) => {
|
|
push_wrapped_dim(
|
|
&mut lines,
|
|
format!("unsupported - {error}"),
|
|
wrap_width,
|
|
"Config key: ",
|
|
" ",
|
|
);
|
|
}
|
|
}
|
|
push_wrapped_dim(
|
|
&mut lines,
|
|
report.raw_event.clone(),
|
|
wrap_width,
|
|
"Raw event: ",
|
|
" ",
|
|
);
|
|
lines.push(Line::from(""));
|
|
lines.push(Line::from("Assigned actions:".dim()));
|
|
if report.matches.is_empty() {
|
|
lines.push(Line::from(" none".dim()));
|
|
} else {
|
|
for matched_action in &report.matches {
|
|
let action = format!(
|
|
"{}.{} ({}) - {} [{}]",
|
|
matched_action.context,
|
|
matched_action.action,
|
|
matched_action.label,
|
|
matched_action.description,
|
|
matched_action.source.label()
|
|
);
|
|
push_wrapped_dim(&mut lines, action, wrap_width, " - ", " ");
|
|
}
|
|
}
|
|
lines
|
|
}
|
|
|
|
fn should_show_delayed_hint(&self, now: Instant) -> bool {
|
|
self.last_report.is_none() && now.duration_since(self.opened_at) >= MISSING_KEY_HINT_DELAY
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn show_delayed_hint_for_test(&mut self) {
|
|
self.opened_at = Instant::now() - MISSING_KEY_HINT_DELAY;
|
|
}
|
|
}
|
|
|
|
impl Renderable for KeymapDebugView {
|
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
|
Paragraph::new(self.lines(area.width)).render(area, buf);
|
|
}
|
|
|
|
fn desired_height(&self, width: u16) -> u16 {
|
|
self.lines(width).len() as u16
|
|
}
|
|
}
|
|
|
|
impl BottomPaneView for KeymapDebugView {
|
|
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
|
|
crate::keymap::KeymapContextSet::raw_key_capture()
|
|
}
|
|
|
|
fn accepts_input_when_disconnected(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
|
if key_event.kind == KeyEventKind::Release {
|
|
return;
|
|
}
|
|
|
|
self.last_report = Some(KeymapDebugReport {
|
|
detected: KeyBinding::from_event(key_event),
|
|
config_key: key_event_to_config_key_spec(key_event),
|
|
raw_event: key_event_debug_summary(key_event),
|
|
matches: matching_actions_for_key_event(
|
|
&self.runtime_keymap,
|
|
&self.keymap_config,
|
|
key_event,
|
|
),
|
|
});
|
|
}
|
|
|
|
fn is_complete(&self) -> bool {
|
|
self.complete
|
|
}
|
|
|
|
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
|
self.complete = true;
|
|
CancellationEvent::Handled
|
|
}
|
|
|
|
fn prefer_esc_to_handle_key_event(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn next_frame_delay(&self) -> Option<Duration> {
|
|
if self.last_report.is_some() {
|
|
return None;
|
|
}
|
|
|
|
self.opened_at
|
|
.checked_add(MISSING_KEY_HINT_DELAY)
|
|
.and_then(|show_at| show_at.checked_duration_since(Instant::now()))
|
|
.filter(|delay| !delay.is_zero())
|
|
}
|
|
}
|
|
|
|
fn push_wrapped_dim(
|
|
lines: &mut Vec<Line<'static>>,
|
|
text: String,
|
|
wrap_width: usize,
|
|
initial_indent: &'static str,
|
|
subsequent_indent: &'static str,
|
|
) {
|
|
let options = textwrap::Options::new(wrap_width)
|
|
.initial_indent(initial_indent)
|
|
.subsequent_indent(subsequent_indent);
|
|
lines.extend(
|
|
textwrap::wrap(&text, options)
|
|
.into_iter()
|
|
.map(|line| Line::from(line.into_owned().dim())),
|
|
);
|
|
}
|
|
|
|
fn key_event_debug_summary(key_event: KeyEvent) -> String {
|
|
format!(
|
|
"code={:?}, modifiers={}, kind={:?}",
|
|
key_event.code,
|
|
key_modifiers_debug_label(key_event.modifiers),
|
|
key_event.kind
|
|
)
|
|
}
|
|
|
|
fn key_modifiers_debug_label(modifiers: KeyModifiers) -> String {
|
|
if modifiers.is_empty() {
|
|
return "none".to_string();
|
|
}
|
|
|
|
let mut parts = Vec::new();
|
|
if modifiers.contains(KeyModifiers::CONTROL) {
|
|
parts.push("ctrl".to_string());
|
|
}
|
|
if modifiers.contains(KeyModifiers::ALT) {
|
|
parts.push("alt".to_string());
|
|
}
|
|
if modifiers.contains(KeyModifiers::SHIFT) {
|
|
parts.push("shift".to_string());
|
|
}
|
|
|
|
let known_modifiers = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT;
|
|
let other_modifiers = modifiers.difference(known_modifiers);
|
|
if !other_modifiers.is_empty() {
|
|
parts.push(format!("{other_modifiers:?}"));
|
|
}
|
|
parts.join("|")
|
|
}
|