mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
## Why Terminal probes and other bootstrap work can leave keys or partial control sequences buffered before an interactive startup screen is visible. Those inputs must not accidentally select or confirm an action, while typeahead intended for the composer should survive terminal initialization. ## What changed - Replay user input consumed by Unix startup probes through Crossterm while filtering completed terminal color replies, and avoid consuming the Windows console input queue when detecting default colors. - Drain decoded and unread input after rendering actionable startup screens, including onboarding trust, migration, update, provider, resume, and hooks prompts. Keep incomplete control sequences quarantined and fail closed when an input boundary cannot be resolved. - Restore terminal modes if initialization exits early and bound terminal probe reads by time and byte limits. ## Testing Add parser, boundary, onboarding, and PTY coverage for preserved typeahead, split escape sequences, bracketed paste, delayed input, and fresh input after an interactive screen becomes ready. GitOrigin-RevId: 321198996cdd88fe9b43c4a762e750bff450482c
426 lines
15 KiB
Rust
426 lines
15 KiB
Rust
use color_eyre::eyre::Result;
|
|
use crossterm::event::KeyEventKind;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::Stylize;
|
|
use ratatui::text::Line;
|
|
use ratatui::widgets::Clear;
|
|
use ratatui::widgets::Paragraph;
|
|
use ratatui::widgets::WidgetRef;
|
|
use ratatui::widgets::Wrap;
|
|
use tokio::sync::mpsc::unbounded_channel;
|
|
use tokio_stream::StreamExt;
|
|
|
|
use crate::app_event::AppEvent;
|
|
use crate::app_event_sender::AppEventSender;
|
|
use crate::app_server_session::AppServerSession;
|
|
use crate::bottom_pane::BottomPaneView;
|
|
use crate::bottom_pane::ListSelectionView;
|
|
use crate::bottom_pane::SelectionItem;
|
|
use crate::bottom_pane::SelectionViewParams;
|
|
use crate::bottom_pane::popup_consts::standard_popup_hint_line_for_keymap;
|
|
use crate::config_update::format_config_error;
|
|
use crate::hooks_rpc::HookTrustUpdate;
|
|
use crate::hooks_rpc::fetch_hooks_list;
|
|
use crate::hooks_rpc::hook_needs_review;
|
|
use crate::hooks_rpc::hooks_list_entry_for_cwd;
|
|
use crate::hooks_rpc::write_hook_trusts;
|
|
use crate::keymap::RuntimeKeymap;
|
|
use crate::legacy_core::config::Config;
|
|
use crate::render::renderable::ColumnRenderable;
|
|
use crate::render::renderable::Renderable;
|
|
use crate::tui::Tui;
|
|
use crate::tui::TuiEvent;
|
|
use codex_app_server_client::AppServerRequestHandle;
|
|
use codex_app_server_protocol::HooksListEntry;
|
|
use std::path::PathBuf;
|
|
|
|
pub(crate) enum StartupHooksReviewOutcome {
|
|
Continue,
|
|
OpenHooksBrowser(HooksListEntry),
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum StartupHooksReviewSelection {
|
|
ReviewHooks,
|
|
TrustAllAndContinue,
|
|
ContinueWithoutTrusting,
|
|
}
|
|
|
|
pub(crate) async fn load_startup_hooks_review_entry(
|
|
request_handle: AppServerRequestHandle,
|
|
cwd: PathBuf,
|
|
) -> HooksListEntry {
|
|
let response = match fetch_hooks_list(request_handle, cwd.clone()).await {
|
|
Ok(response) => response,
|
|
Err(err) => {
|
|
tracing::warn!("failed to load startup hook review state: {err:#}");
|
|
return HooksListEntry {
|
|
cwd,
|
|
hooks: Vec::new(),
|
|
warnings: Vec::new(),
|
|
errors: Vec::new(),
|
|
};
|
|
}
|
|
};
|
|
hooks_list_entry_for_cwd(response, &cwd)
|
|
}
|
|
|
|
pub(crate) async fn maybe_run_startup_hooks_review(
|
|
app_server: &mut AppServerSession,
|
|
tui: &mut Tui,
|
|
config: &Config,
|
|
bypass_hook_trust: bool,
|
|
entry: HooksListEntry,
|
|
) -> Result<StartupHooksReviewOutcome> {
|
|
if !review_is_needed(bypass_hook_trust, &entry) {
|
|
return Ok(StartupHooksReviewOutcome::Continue);
|
|
}
|
|
|
|
run_startup_hooks_review_app(app_server, tui, config, entry).await
|
|
}
|
|
|
|
async fn run_startup_hooks_review_app(
|
|
app_server: &mut AppServerSession,
|
|
tui: &mut Tui,
|
|
config: &Config,
|
|
entry: HooksListEntry,
|
|
) -> Result<StartupHooksReviewOutcome> {
|
|
let keymap = RuntimeKeymap::from_config(&config.tui_keymap)
|
|
.map_err(|err| color_eyre::eyre::eyre!(err))?;
|
|
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
|
let app_event_tx = AppEventSender::new(tx_raw);
|
|
let mut trust_all_error = None;
|
|
let mut view = selection_view(
|
|
&entry,
|
|
trust_all_error.as_deref(),
|
|
/*trusting_all*/ false,
|
|
app_event_tx.clone(),
|
|
&keymap,
|
|
);
|
|
let mut chord_matcher = crate::keymap::KeyChordMatcher::default();
|
|
draw_view(tui, &view)?;
|
|
|
|
tui.discard_pending_input_before_interactive_screen()?;
|
|
let tui_events = tui.event_stream();
|
|
tokio::pin!(tui_events);
|
|
|
|
loop {
|
|
let Some(event) = tui_events.next().await else {
|
|
return Ok(StartupHooksReviewOutcome::Continue);
|
|
};
|
|
tui.screen_size_for_event(&event)?;
|
|
match event {
|
|
TuiEvent::Key(key_event) => {
|
|
let key_event = match chord_matcher.advance(
|
|
key_event,
|
|
&keymap.chords,
|
|
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List),
|
|
tokio::time::Instant::now(),
|
|
) {
|
|
crate::keymap::KeyChordMatch::PassThrough => key_event,
|
|
crate::keymap::KeyChordMatch::Completed(dispatch_event) => dispatch_event,
|
|
crate::keymap::KeyChordMatch::Pending(_)
|
|
| crate::keymap::KeyChordMatch::Cancelled
|
|
| crate::keymap::KeyChordMatch::Ignored => continue,
|
|
};
|
|
if matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
|
|
view.handle_key_event(key_event);
|
|
}
|
|
let Some(selection) = selected_choice(&mut view) else {
|
|
draw_view(tui, &view)?;
|
|
continue;
|
|
};
|
|
match selection {
|
|
StartupHooksReviewSelection::ReviewHooks => {
|
|
return Ok(StartupHooksReviewOutcome::OpenHooksBrowser(entry));
|
|
}
|
|
StartupHooksReviewSelection::ContinueWithoutTrusting => {
|
|
return Ok(StartupHooksReviewOutcome::Continue);
|
|
}
|
|
StartupHooksReviewSelection::TrustAllAndContinue => {
|
|
view = selection_view(
|
|
&entry,
|
|
trust_all_error.as_deref(),
|
|
/*trusting_all*/ true,
|
|
app_event_tx.clone(),
|
|
&keymap,
|
|
);
|
|
draw_view(tui, &view)?;
|
|
let result = write_hook_trusts(
|
|
app_server.request_handle(),
|
|
entry
|
|
.hooks
|
|
.iter()
|
|
.filter(|hook| hook_needs_review(hook))
|
|
.map(|hook| HookTrustUpdate {
|
|
key: hook.key.clone(),
|
|
current_hash: hook.current_hash.clone(),
|
|
})
|
|
.collect(),
|
|
)
|
|
.await
|
|
.map(|_| ())
|
|
.map_err(|err| {
|
|
format!("Failed to trust hooks: {}", format_config_error(&err))
|
|
});
|
|
match result {
|
|
Ok(()) => return Ok(StartupHooksReviewOutcome::Continue),
|
|
Err(err) => {
|
|
trust_all_error = Some(err);
|
|
view = selection_view(
|
|
&entry,
|
|
trust_all_error.as_deref(),
|
|
/*trusting_all*/ false,
|
|
app_event_tx.clone(),
|
|
&keymap,
|
|
);
|
|
draw_view(tui, &view)?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
TuiEvent::Paste(_) => {}
|
|
TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) => draw_view(tui, &view)?,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn selected_choice(view: &mut ListSelectionView) -> Option<StartupHooksReviewSelection> {
|
|
if !view.is_complete() {
|
|
return None;
|
|
}
|
|
match view.take_last_selected_index() {
|
|
Some(0) => Some(StartupHooksReviewSelection::ReviewHooks),
|
|
Some(1) => Some(StartupHooksReviewSelection::TrustAllAndContinue),
|
|
Some(2) | None => Some(StartupHooksReviewSelection::ContinueWithoutTrusting),
|
|
Some(_) => None,
|
|
}
|
|
}
|
|
|
|
fn selection_view(
|
|
entry: &HooksListEntry,
|
|
trust_all_error: Option<&str>,
|
|
trusting_all: bool,
|
|
app_event_tx: AppEventSender,
|
|
keymap: &RuntimeKeymap,
|
|
) -> ListSelectionView {
|
|
ListSelectionView::new(
|
|
selection_view_params(entry, trust_all_error, trusting_all, keymap),
|
|
app_event_tx,
|
|
keymap.list.clone(),
|
|
)
|
|
}
|
|
|
|
#[allow(clippy::disallowed_methods)]
|
|
fn selection_view_params(
|
|
entry: &HooksListEntry,
|
|
trust_all_error: Option<&str>,
|
|
trusting_all: bool,
|
|
keymap: &RuntimeKeymap,
|
|
) -> SelectionViewParams {
|
|
let count = review_needed_count(entry);
|
|
let count_line = match count {
|
|
1 => "1 hook is new or changed.".to_string(),
|
|
count => format!("{count} hooks are new or changed."),
|
|
};
|
|
let mut header = ColumnRenderable::new();
|
|
header.push(Line::from("Hooks need review".bold()));
|
|
header.push(Line::from(count_line).yellow());
|
|
header.push(Line::from(
|
|
"Hooks can run outside the sandbox after you trust them.".dim(),
|
|
));
|
|
if let Some(error) = trust_all_error {
|
|
header.push(Paragraph::new(Line::from(error.to_string()).red()).wrap(Wrap { trim: false }));
|
|
} else if trusting_all {
|
|
header.push(Line::from("Trusting hooks...".dim()));
|
|
}
|
|
|
|
SelectionViewParams {
|
|
footer_hint: Some(standard_popup_hint_line_for_keymap(&keymap.list)),
|
|
items: vec![
|
|
selection_item("Review hooks", trusting_all),
|
|
selection_item("Trust all and continue", trusting_all),
|
|
selection_item("Continue without trusting (hooks won't run)", trusting_all),
|
|
],
|
|
header: Box::new(header),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn review_needed_count(entry: &HooksListEntry) -> usize {
|
|
entry
|
|
.hooks
|
|
.iter()
|
|
.filter(|hook| hook_needs_review(hook))
|
|
.count()
|
|
}
|
|
|
|
fn review_is_needed(bypass_hook_trust: bool, entry: &HooksListEntry) -> bool {
|
|
!bypass_hook_trust && review_needed_count(entry) > 0
|
|
}
|
|
|
|
fn selection_item(name: &str, is_disabled: bool) -> SelectionItem {
|
|
SelectionItem {
|
|
name: name.to_string(),
|
|
dismiss_on_select: true,
|
|
is_disabled,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn draw_view(tui: &mut Tui, view: &ListSelectionView) -> Result<()> {
|
|
tui.draw(u16::MAX, |frame| {
|
|
let area = frame.area();
|
|
frame.render_widget_ref(&Clear, area);
|
|
let view_area = Rect::new(
|
|
area.x,
|
|
area.y,
|
|
area.width,
|
|
view.desired_height(area.width).min(area.height),
|
|
);
|
|
frame.render_widget_ref(&StandaloneSelectionView { view }, view_area);
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
struct StandaloneSelectionView<'a> {
|
|
view: &'a ListSelectionView,
|
|
}
|
|
|
|
impl WidgetRef for &StandaloneSelectionView<'_> {
|
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
|
self.view.render(area, buf);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::review_is_needed;
|
|
use super::selection_view;
|
|
use crate::app_event::AppEvent;
|
|
use crate::app_event_sender::AppEventSender;
|
|
use crate::keymap::RuntimeKeymap;
|
|
use crate::render::renderable::Renderable;
|
|
use crate::test_support::PathBufExt;
|
|
use crate::test_support::test_path_buf;
|
|
use codex_app_server_protocol::HookEventName;
|
|
use codex_app_server_protocol::HookExecutionMode;
|
|
use codex_app_server_protocol::HookHandlerType;
|
|
use codex_app_server_protocol::HookMetadata;
|
|
use codex_app_server_protocol::HookSource;
|
|
use codex_app_server_protocol::HookTrustStatus;
|
|
use codex_app_server_protocol::HooksListEntry;
|
|
use insta::assert_snapshot;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
use tokio::sync::mpsc::unbounded_channel;
|
|
|
|
fn hook(key: &str, trust_status: HookTrustStatus) -> HookMetadata {
|
|
HookMetadata {
|
|
key: key.to_string(),
|
|
event_name: HookEventName::PreToolUse,
|
|
handler_type: HookHandlerType::Command,
|
|
execution_mode: HookExecutionMode::Sync,
|
|
is_managed: false,
|
|
matcher: Some("Bash".to_string()),
|
|
command: Some("/tmp/hook.sh".to_string()),
|
|
timeout_sec: 30,
|
|
status_message: None,
|
|
additional_context_limit: None,
|
|
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
|
source: HookSource::User,
|
|
plugin_id: None,
|
|
display_order: 0,
|
|
enabled: false,
|
|
current_hash: format!("sha256:{key}"),
|
|
trust_status,
|
|
}
|
|
}
|
|
|
|
fn entry() -> HooksListEntry {
|
|
HooksListEntry {
|
|
cwd: test_path_buf("/tmp"),
|
|
hooks: vec![
|
|
hook("path:new", HookTrustStatus::Untrusted),
|
|
hook("path:changed", HookTrustStatus::Modified),
|
|
],
|
|
warnings: Vec::new(),
|
|
errors: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn render_lines(view: &crate::bottom_pane::ListSelectionView, width: u16) -> String {
|
|
let height = view.desired_height(width);
|
|
let area = Rect::new(0, 0, width, height);
|
|
let mut buf = Buffer::empty(area);
|
|
view.render(area, &mut buf);
|
|
|
|
(0..area.height)
|
|
.map(|row| {
|
|
let rendered = (0..area.width)
|
|
.map(|col| {
|
|
let symbol = buf[(area.x + col, area.y + row)].symbol();
|
|
if symbol.is_empty() {
|
|
" ".to_string()
|
|
} else {
|
|
symbol.to_string()
|
|
}
|
|
})
|
|
.collect::<String>();
|
|
rendered.trim_end().to_string()
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
#[test]
|
|
fn bypass_hook_trust_suppresses_startup_review() {
|
|
assert!(!review_is_needed(/*bypass_hook_trust*/ true, &entry()));
|
|
}
|
|
|
|
#[test]
|
|
fn untrusted_hooks_need_review_without_bypass() {
|
|
assert!(review_is_needed(/*bypass_hook_trust*/ false, &entry()));
|
|
}
|
|
|
|
#[test]
|
|
fn renders_prompt() {
|
|
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
|
let keymap = RuntimeKeymap::defaults();
|
|
let view = selection_view(
|
|
&entry(),
|
|
/*trust_all_error*/ None,
|
|
/*trusting_all*/ false,
|
|
AppEventSender::new(tx_raw),
|
|
&keymap,
|
|
);
|
|
|
|
assert_snapshot!(
|
|
"startup_hooks_review_prompt",
|
|
render_lines(&view, /*width*/ 80)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn renders_prompt_with_trust_error() {
|
|
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
|
let keymap = RuntimeKeymap::defaults();
|
|
let view = selection_view(
|
|
&entry(),
|
|
Some(
|
|
"Failed to trust hooks: config/batchWrite failed in TUI: Invalid configuration: features.fast_mode=true is not supported; allowed set [fast_mode=false]",
|
|
),
|
|
/*trusting_all*/ false,
|
|
AppEventSender::new(tx_raw),
|
|
&keymap,
|
|
);
|
|
|
|
assert_snapshot!(
|
|
"startup_hooks_review_prompt_with_trust_error",
|
|
render_lines(&view, /*width*/ 62)
|
|
);
|
|
}
|
|
}
|