mirror of
https://github.com/openai/codex.git
synced 2026-11-05 16:29:31 +00:00
Move TUI on top of app server (parallel code) (#14717)
This PR replicates the `tui` code directory and creates a temporary parallel `tui_app_server` directory. It also implements a new feature flag `tui_app_server` to select between the two tui implementations. Once the new app-server-based TUI is stabilized, we'll delete the old `tui` directory and feature flag.
This commit is contained in:
1 parent
c04a0a7454
commit
db89b73a9c
1109 files changed
+134253
-17
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,272 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use codex_login::ServerOptions;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::Widget;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use crate::shimmer::shimmer_spans;
|
||||
use crate::tui::FrameRequester;
|
||||
|
||||
use super::AuthModeWidget;
|
||||
use super::ContinueWithDeviceCodeState;
|
||||
use super::SignInState;
|
||||
use super::mark_url_hyperlink;
|
||||
|
||||
pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget, opts: ServerOptions) {
|
||||
let _ = opts;
|
||||
let _ = widget;
|
||||
}
|
||||
|
||||
pub(super) fn render_device_code_login(
|
||||
widget: &AuthModeWidget,
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
state: &ContinueWithDeviceCodeState,
|
||||
) {
|
||||
let banner = if state.device_code.is_some() {
|
||||
"Finish signing in via your browser"
|
||||
} else {
|
||||
"Preparing device code login"
|
||||
};
|
||||
|
||||
let mut spans = vec![" ".into()];
|
||||
if widget.animations_enabled {
|
||||
// Schedule a follow-up frame to keep the shimmer animation going.
|
||||
widget
|
||||
.request_frame
|
||||
.schedule_frame_in(std::time::Duration::from_millis(100));
|
||||
spans.extend(shimmer_spans(banner));
|
||||
} else {
|
||||
spans.push(banner.into());
|
||||
}
|
||||
|
||||
let mut lines = vec![spans.into(), "".into()];
|
||||
|
||||
// Capture the verification URL for OSC 8 hyperlink marking after render.
|
||||
let verification_url = if let Some(device_code) = &state.device_code {
|
||||
lines.push(" 1. Open this link in your browser and sign in".into());
|
||||
lines.push("".into());
|
||||
lines.push(Line::from(vec![
|
||||
" ".into(),
|
||||
device_code.verification_url.as_str().cyan().underlined(),
|
||||
]));
|
||||
lines.push("".into());
|
||||
lines.push(
|
||||
" 2. Enter this one-time code after you are signed in (expires in 15 minutes)".into(),
|
||||
);
|
||||
lines.push("".into());
|
||||
lines.push(Line::from(vec![
|
||||
" ".into(),
|
||||
device_code.user_code.as_str().cyan().bold(),
|
||||
]));
|
||||
lines.push("".into());
|
||||
lines.push(
|
||||
" Device codes are a common phishing target. Never share this code."
|
||||
.dim()
|
||||
.into(),
|
||||
);
|
||||
lines.push("".into());
|
||||
Some(device_code.verification_url.clone())
|
||||
} else {
|
||||
lines.push(" Requesting a one-time code...".dim().into());
|
||||
lines.push("".into());
|
||||
None
|
||||
};
|
||||
|
||||
lines.push(" Press Esc to cancel".dim().into());
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
|
||||
// Wrap cyan+underlined URL cells with OSC 8 so the terminal treats
|
||||
// the entire region as a single clickable hyperlink.
|
||||
if let Some(url) = &verification_url {
|
||||
mark_url_hyperlink(buf, area, url);
|
||||
}
|
||||
}
|
||||
|
||||
fn device_code_attempt_matches(state: &SignInState, cancel: &Arc<Notify>) -> bool {
|
||||
matches!(
|
||||
state,
|
||||
SignInState::ChatGptDeviceCode(state)
|
||||
if state
|
||||
.cancel
|
||||
.as_ref()
|
||||
.is_some_and(|existing| Arc::ptr_eq(existing, cancel))
|
||||
)
|
||||
}
|
||||
|
||||
fn begin_device_code_attempt(
|
||||
sign_in_state: &Arc<RwLock<SignInState>>,
|
||||
request_frame: &FrameRequester,
|
||||
) -> Arc<Notify> {
|
||||
let cancel = Arc::new(Notify::new());
|
||||
*sign_in_state.write().unwrap() = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState {
|
||||
device_code: None,
|
||||
cancel: Some(cancel.clone()),
|
||||
});
|
||||
request_frame.schedule_frame();
|
||||
cancel
|
||||
}
|
||||
|
||||
fn set_device_code_state_for_active_attempt(
|
||||
sign_in_state: &Arc<RwLock<SignInState>>,
|
||||
request_frame: &FrameRequester,
|
||||
cancel: &Arc<Notify>,
|
||||
next_state: SignInState,
|
||||
) -> bool {
|
||||
let mut guard = sign_in_state.write().unwrap();
|
||||
if !device_code_attempt_matches(&guard, cancel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
*guard = next_state;
|
||||
drop(guard);
|
||||
request_frame.schedule_frame();
|
||||
true
|
||||
}
|
||||
|
||||
fn set_device_code_success_message_for_active_attempt(
|
||||
sign_in_state: &Arc<RwLock<SignInState>>,
|
||||
request_frame: &FrameRequester,
|
||||
cancel: &Arc<Notify>,
|
||||
) -> bool {
|
||||
let mut guard = sign_in_state.write().unwrap();
|
||||
if !device_code_attempt_matches(&guard, cancel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
*guard = SignInState::ChatGptSuccessMessage;
|
||||
drop(guard);
|
||||
request_frame.schedule_frame();
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn device_code_sign_in_state(cancel: Arc<Notify>) -> Arc<RwLock<SignInState>> {
|
||||
Arc::new(RwLock::new(SignInState::ChatGptDeviceCode(
|
||||
ContinueWithDeviceCodeState {
|
||||
device_code: None,
|
||||
cancel: Some(cancel),
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_code_attempt_matches_only_for_matching_cancel() {
|
||||
let cancel = Arc::new(Notify::new());
|
||||
let state = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState {
|
||||
device_code: None,
|
||||
cancel: Some(cancel.clone()),
|
||||
});
|
||||
|
||||
assert_eq!(device_code_attempt_matches(&state, &cancel), true);
|
||||
assert_eq!(
|
||||
device_code_attempt_matches(&state, &Arc::new(Notify::new())),
|
||||
false
|
||||
);
|
||||
assert_eq!(
|
||||
device_code_attempt_matches(&SignInState::PickMode, &cancel),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn begin_device_code_attempt_sets_state() {
|
||||
let sign_in_state = Arc::new(RwLock::new(SignInState::PickMode));
|
||||
let request_frame = FrameRequester::test_dummy();
|
||||
|
||||
let cancel = begin_device_code_attempt(&sign_in_state, &request_frame);
|
||||
let guard = sign_in_state.read().unwrap();
|
||||
|
||||
let state: &SignInState = &guard;
|
||||
assert_eq!(device_code_attempt_matches(state, &cancel), true);
|
||||
assert!(matches!(
|
||||
state,
|
||||
SignInState::ChatGptDeviceCode(state) if state.device_code.is_none()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_device_code_state_for_active_attempt_updates_only_when_active() {
|
||||
let request_frame = FrameRequester::test_dummy();
|
||||
let cancel = Arc::new(Notify::new());
|
||||
let sign_in_state = device_code_sign_in_state(cancel.clone());
|
||||
|
||||
assert_eq!(
|
||||
set_device_code_state_for_active_attempt(
|
||||
&sign_in_state,
|
||||
&request_frame,
|
||||
&cancel,
|
||||
SignInState::PickMode,
|
||||
),
|
||||
true
|
||||
);
|
||||
assert!(matches!(
|
||||
&*sign_in_state.read().unwrap(),
|
||||
SignInState::PickMode
|
||||
));
|
||||
|
||||
let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new()));
|
||||
assert_eq!(
|
||||
set_device_code_state_for_active_attempt(
|
||||
&sign_in_state,
|
||||
&request_frame,
|
||||
&cancel,
|
||||
SignInState::PickMode,
|
||||
),
|
||||
false
|
||||
);
|
||||
assert!(matches!(
|
||||
&*sign_in_state.read().unwrap(),
|
||||
SignInState::ChatGptDeviceCode(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_device_code_success_message_for_active_attempt_updates_only_when_active() {
|
||||
let request_frame = FrameRequester::test_dummy();
|
||||
let cancel = Arc::new(Notify::new());
|
||||
let sign_in_state = device_code_sign_in_state(cancel.clone());
|
||||
assert_eq!(
|
||||
set_device_code_success_message_for_active_attempt(
|
||||
&sign_in_state,
|
||||
&request_frame,
|
||||
&cancel,
|
||||
),
|
||||
true
|
||||
);
|
||||
assert!(matches!(
|
||||
&*sign_in_state.read().unwrap(),
|
||||
SignInState::ChatGptSuccessMessage
|
||||
));
|
||||
|
||||
let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new()));
|
||||
assert_eq!(
|
||||
set_device_code_success_message_for_active_attempt(
|
||||
&sign_in_state,
|
||||
&request_frame,
|
||||
&cancel,
|
||||
),
|
||||
false
|
||||
);
|
||||
assert!(matches!(
|
||||
&*sign_in_state.read().unwrap(),
|
||||
SignInState::ChatGptDeviceCode(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod auth;
|
||||
pub mod onboarding_screen;
|
||||
mod trust_directory;
|
||||
pub use trust_directory::TrustDirectorySelection;
|
||||
mod welcome;
|
||||
@@ -0,0 +1,521 @@
|
||||
use codex_app_server_client::AppServerEvent;
|
||||
use codex_app_server_client::AppServerRequestHandle;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_core::config::Config;
|
||||
#[cfg(target_os = "windows")]
|
||||
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
|
||||
#[cfg(target_os = "windows")]
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::Widget;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use codex_protocol::config_types::ForcedLoginMethod;
|
||||
|
||||
use crate::LoginStatus;
|
||||
use crate::app_server_session::AppServerSession;
|
||||
use crate::onboarding::auth::AuthModeWidget;
|
||||
use crate::onboarding::auth::SignInOption;
|
||||
use crate::onboarding::auth::SignInState;
|
||||
use crate::onboarding::trust_directory::TrustDirectorySelection;
|
||||
use crate::onboarding::trust_directory::TrustDirectoryWidget;
|
||||
use crate::onboarding::welcome::WelcomeWidget;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::tui::Tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use color_eyre::eyre::Result;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum Step {
|
||||
Welcome(WelcomeWidget),
|
||||
Auth(AuthModeWidget),
|
||||
TrustDirectory(TrustDirectoryWidget),
|
||||
}
|
||||
|
||||
pub(crate) trait KeyboardHandler {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent);
|
||||
fn handle_paste(&mut self, _pasted: String) {}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum StepState {
|
||||
Hidden,
|
||||
InProgress,
|
||||
Complete,
|
||||
}
|
||||
|
||||
pub(crate) trait StepStateProvider {
|
||||
fn get_step_state(&self) -> StepState;
|
||||
}
|
||||
|
||||
pub(crate) struct OnboardingScreen {
|
||||
request_frame: FrameRequester,
|
||||
steps: Vec<Step>,
|
||||
is_done: bool,
|
||||
should_exit: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct OnboardingScreenArgs {
|
||||
pub show_trust_screen: bool,
|
||||
pub show_login_screen: bool,
|
||||
pub login_status: LoginStatus,
|
||||
pub app_server_request_handle: Option<AppServerRequestHandle>,
|
||||
pub config: Config,
|
||||
}
|
||||
|
||||
pub(crate) struct OnboardingResult {
|
||||
pub directory_trust_decision: Option<TrustDirectorySelection>,
|
||||
pub should_exit: bool,
|
||||
}
|
||||
|
||||
impl OnboardingScreen {
|
||||
pub(crate) fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self {
|
||||
let OnboardingScreenArgs {
|
||||
show_trust_screen,
|
||||
show_login_screen,
|
||||
login_status,
|
||||
app_server_request_handle,
|
||||
config,
|
||||
} = args;
|
||||
let cwd = config.cwd.clone();
|
||||
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
|
||||
let forced_login_method = config.forced_login_method;
|
||||
let codex_home = config.codex_home.clone();
|
||||
let cli_auth_credentials_store_mode = config.cli_auth_credentials_store_mode;
|
||||
let mut steps: Vec<Step> = Vec::new();
|
||||
steps.push(Step::Welcome(WelcomeWidget::new(
|
||||
!matches!(login_status, LoginStatus::NotAuthenticated),
|
||||
tui.frame_requester(),
|
||||
config.animations,
|
||||
)));
|
||||
if show_login_screen {
|
||||
let highlighted_mode = match forced_login_method {
|
||||
Some(ForcedLoginMethod::Api) => SignInOption::ApiKey,
|
||||
_ => SignInOption::ChatGpt,
|
||||
};
|
||||
if let Some(app_server_request_handle) = app_server_request_handle {
|
||||
steps.push(Step::Auth(AuthModeWidget {
|
||||
request_frame: tui.frame_requester(),
|
||||
highlighted_mode,
|
||||
error: Arc::new(RwLock::new(None)),
|
||||
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
|
||||
codex_home: codex_home.clone(),
|
||||
cli_auth_credentials_store_mode,
|
||||
login_status,
|
||||
app_server_request_handle,
|
||||
forced_chatgpt_workspace_id,
|
||||
forced_login_method,
|
||||
animations_enabled: config.animations,
|
||||
}));
|
||||
} else {
|
||||
tracing::warn!("skipping onboarding login step without app-server request handle");
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
let show_windows_create_sandbox_hint =
|
||||
WindowsSandboxLevel::from_config(&config) == WindowsSandboxLevel::Disabled;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let show_windows_create_sandbox_hint = false;
|
||||
let highlighted = TrustDirectorySelection::Trust;
|
||||
if show_trust_screen {
|
||||
steps.push(Step::TrustDirectory(TrustDirectoryWidget {
|
||||
cwd,
|
||||
codex_home,
|
||||
show_windows_create_sandbox_hint,
|
||||
should_quit: false,
|
||||
selection: None,
|
||||
highlighted,
|
||||
error: None,
|
||||
}))
|
||||
}
|
||||
// TODO: add git warning.
|
||||
Self {
|
||||
request_frame: tui.frame_requester(),
|
||||
steps,
|
||||
is_done: false,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_steps_mut(&mut self) -> Vec<&mut Step> {
|
||||
let mut out: Vec<&mut Step> = Vec::new();
|
||||
for step in self.steps.iter_mut() {
|
||||
match step.get_step_state() {
|
||||
StepState::Hidden => continue,
|
||||
StepState::Complete => out.push(step),
|
||||
StepState::InProgress => {
|
||||
out.push(step);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn current_steps(&self) -> Vec<&Step> {
|
||||
let mut out: Vec<&Step> = Vec::new();
|
||||
for step in self.steps.iter() {
|
||||
match step.get_step_state() {
|
||||
StepState::Hidden => continue,
|
||||
StepState::Complete => out.push(step),
|
||||
StepState::InProgress => {
|
||||
out.push(step);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn is_auth_in_progress(&self) -> bool {
|
||||
self.steps.iter().any(|step| {
|
||||
matches!(step, Step::Auth(_)) && matches!(step.get_step_state(), StepState::InProgress)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn is_done(&self) -> bool {
|
||||
self.is_done
|
||||
|| !self
|
||||
.steps
|
||||
.iter()
|
||||
.any(|step| matches!(step.get_step_state(), StepState::InProgress))
|
||||
}
|
||||
|
||||
pub fn directory_trust_decision(&self) -> Option<TrustDirectorySelection> {
|
||||
self.steps
|
||||
.iter()
|
||||
.find_map(|step| {
|
||||
if let Step::TrustDirectory(TrustDirectoryWidget { selection, .. }) = step {
|
||||
Some(*selection)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub fn should_exit(&self) -> bool {
|
||||
self.should_exit
|
||||
}
|
||||
|
||||
fn auth_widget_mut(&mut self) -> Option<&mut AuthModeWidget> {
|
||||
self.steps.iter_mut().find_map(|step| match step {
|
||||
Step::Auth(widget) => Some(widget),
|
||||
Step::Welcome(_) | Step::TrustDirectory(_) => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_app_server_notification(&mut self, notification: ServerNotification) {
|
||||
match notification {
|
||||
ServerNotification::AccountLoginCompleted(notification) => {
|
||||
if let Some(widget) = self.auth_widget_mut() {
|
||||
widget.on_account_login_completed(notification);
|
||||
}
|
||||
}
|
||||
ServerNotification::AccountUpdated(notification) => {
|
||||
if let Some(widget) = self.auth_widget_mut() {
|
||||
widget.on_account_updated(notification);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_api_key_entry_active(&self) -> bool {
|
||||
self.steps.iter().any(|step| {
|
||||
if let Step::Auth(widget) = step {
|
||||
return widget
|
||||
.sign_in_state
|
||||
.read()
|
||||
.is_ok_and(|g| matches!(&*g, SignInState::ApiKeyEntry(_)));
|
||||
}
|
||||
false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyboardHandler for OnboardingScreen {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
if !matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
|
||||
return;
|
||||
}
|
||||
let is_api_key_entry_active = self.is_api_key_entry_active();
|
||||
let should_quit = match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => true,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('q'),
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => !is_api_key_entry_active,
|
||||
_ => false,
|
||||
};
|
||||
if should_quit {
|
||||
if self.is_auth_in_progress() {
|
||||
// If the user cancels the auth menu, exit the app rather than
|
||||
// leave the user at a prompt in an unauthed state.
|
||||
self.should_exit = true;
|
||||
}
|
||||
self.is_done = true;
|
||||
} else {
|
||||
if let Some(Step::Welcome(widget)) = self
|
||||
.steps
|
||||
.iter_mut()
|
||||
.find(|step| matches!(step, Step::Welcome(_)))
|
||||
{
|
||||
widget.handle_key_event(key_event);
|
||||
}
|
||||
if let Some(active_step) = self.current_steps_mut().into_iter().last() {
|
||||
active_step.handle_key_event(key_event);
|
||||
}
|
||||
if self.steps.iter().any(|step| {
|
||||
if let Step::TrustDirectory(widget) = step {
|
||||
widget.should_quit()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}) {
|
||||
self.should_exit = true;
|
||||
self.is_done = true;
|
||||
}
|
||||
}
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
|
||||
fn handle_paste(&mut self, pasted: String) {
|
||||
if pasted.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(active_step) = self.current_steps_mut().into_iter().last() {
|
||||
active_step.handle_paste(pasted);
|
||||
}
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &OnboardingScreen {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
Clear.render(area, buf);
|
||||
// Render steps top-to-bottom, measuring each step's height dynamically.
|
||||
let mut y = area.y;
|
||||
let bottom = area.y.saturating_add(area.height);
|
||||
let width = area.width;
|
||||
|
||||
// Helper to scan a temporary buffer and return number of used rows.
|
||||
fn used_rows(tmp: &Buffer, width: u16, height: u16) -> u16 {
|
||||
if width == 0 || height == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut last_non_empty: Option<u16> = None;
|
||||
for yy in 0..height {
|
||||
let mut any = false;
|
||||
for xx in 0..width {
|
||||
let cell = &tmp[(xx, yy)];
|
||||
let has_symbol = !cell.symbol().trim().is_empty();
|
||||
let has_style = cell.fg != Color::Reset
|
||||
|| cell.bg != Color::Reset
|
||||
|| !cell.modifier.is_empty();
|
||||
if has_symbol || has_style {
|
||||
any = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if any {
|
||||
last_non_empty = Some(yy);
|
||||
}
|
||||
}
|
||||
last_non_empty.map(|v| v + 2).unwrap_or(0)
|
||||
}
|
||||
|
||||
let mut i = 0usize;
|
||||
let current_steps = self.current_steps();
|
||||
|
||||
while i < current_steps.len() && y < bottom {
|
||||
let step = ¤t_steps[i];
|
||||
let max_h = bottom.saturating_sub(y);
|
||||
if max_h == 0 || width == 0 {
|
||||
break;
|
||||
}
|
||||
let scratch_area = Rect::new(0, 0, width, max_h);
|
||||
let mut scratch = Buffer::empty(scratch_area);
|
||||
if let Step::Welcome(widget) = step {
|
||||
widget.update_layout_area(scratch_area);
|
||||
}
|
||||
step.render_ref(scratch_area, &mut scratch);
|
||||
let h = used_rows(&scratch, width, max_h).min(max_h);
|
||||
if h > 0 {
|
||||
let target = Rect {
|
||||
x: area.x,
|
||||
y,
|
||||
width,
|
||||
height: h,
|
||||
};
|
||||
Clear.render(target, buf);
|
||||
step.render_ref(target, buf);
|
||||
y = y.saturating_add(h);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyboardHandler for Step {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
match self {
|
||||
Step::Welcome(widget) => widget.handle_key_event(key_event),
|
||||
Step::Auth(widget) => widget.handle_key_event(key_event),
|
||||
Step::TrustDirectory(widget) => widget.handle_key_event(key_event),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_paste(&mut self, pasted: String) {
|
||||
match self {
|
||||
Step::Welcome(_) => {}
|
||||
Step::Auth(widget) => widget.handle_paste(pasted),
|
||||
Step::TrustDirectory(widget) => widget.handle_paste(pasted),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StepStateProvider for Step {
|
||||
fn get_step_state(&self) -> StepState {
|
||||
match self {
|
||||
Step::Welcome(w) => w.get_step_state(),
|
||||
Step::Auth(w) => w.get_step_state(),
|
||||
Step::TrustDirectory(w) => w.get_step_state(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for Step {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
match self {
|
||||
Step::Welcome(widget) => {
|
||||
widget.render_ref(area, buf);
|
||||
}
|
||||
Step::Auth(widget) => {
|
||||
widget.render_ref(area, buf);
|
||||
}
|
||||
Step::TrustDirectory(widget) => {
|
||||
widget.render_ref(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run_onboarding_app(
|
||||
args: OnboardingScreenArgs,
|
||||
mut app_server: Option<AppServerSession>,
|
||||
tui: &mut Tui,
|
||||
) -> Result<OnboardingResult> {
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
let mut onboarding_screen = OnboardingScreen::new(tui, args);
|
||||
// One-time guard to fully clear the screen after ChatGPT login success message is shown
|
||||
let mut did_full_clear_after_success = false;
|
||||
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&onboarding_screen, frame.area());
|
||||
})?;
|
||||
|
||||
let tui_events = tui.event_stream();
|
||||
tokio::pin!(tui_events);
|
||||
|
||||
while !onboarding_screen.is_done() {
|
||||
tokio::select! {
|
||||
event = tui_events.next() => {
|
||||
if let Some(event) = event {
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => {
|
||||
onboarding_screen.handle_key_event(key_event);
|
||||
}
|
||||
TuiEvent::Paste(text) => {
|
||||
onboarding_screen.handle_paste(text);
|
||||
}
|
||||
TuiEvent::Draw => {
|
||||
if !did_full_clear_after_success
|
||||
&& onboarding_screen.steps.iter().any(|step| {
|
||||
if let Step::Auth(w) = step {
|
||||
w.sign_in_state.read().is_ok_and(|g| {
|
||||
matches!(&*g, super::auth::SignInState::ChatGptSuccessMessage)
|
||||
})
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
{
|
||||
// Reset any lingering SGR (underline/color) before clearing
|
||||
let _ = ratatui::crossterm::execute!(
|
||||
std::io::stdout(),
|
||||
ratatui::crossterm::style::SetAttribute(
|
||||
ratatui::crossterm::style::Attribute::Reset
|
||||
),
|
||||
ratatui::crossterm::style::SetAttribute(
|
||||
ratatui::crossterm::style::Attribute::NoUnderline
|
||||
),
|
||||
ratatui::crossterm::style::SetForegroundColor(
|
||||
ratatui::crossterm::style::Color::Reset
|
||||
),
|
||||
ratatui::crossterm::style::SetBackgroundColor(
|
||||
ratatui::crossterm::style::Color::Reset
|
||||
)
|
||||
);
|
||||
let _ = tui.terminal.clear();
|
||||
did_full_clear_after_success = true;
|
||||
}
|
||||
let _ = tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&onboarding_screen, frame.area());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
event = async {
|
||||
match app_server.as_mut() {
|
||||
Some(app_server) => app_server.next_event().await,
|
||||
None => None,
|
||||
}
|
||||
}, if app_server.is_some() => {
|
||||
if let Some(event) = event {
|
||||
match event {
|
||||
AppServerEvent::ServerNotification(notification) => {
|
||||
onboarding_screen.handle_app_server_notification(notification);
|
||||
}
|
||||
AppServerEvent::Disconnected { message } => {
|
||||
return Err(color_eyre::eyre::eyre!(message));
|
||||
}
|
||||
AppServerEvent::Lagged { .. }
|
||||
| AppServerEvent::LegacyNotification(_)
|
||||
| AppServerEvent::ServerRequest(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(app_server) = app_server {
|
||||
app_server.shutdown().await.ok();
|
||||
}
|
||||
Ok(OnboardingResult {
|
||||
directory_trust_decision: onboarding_screen.directory_trust_decision(),
|
||||
should_exit: onboarding_screen.should_exit(),
|
||||
})
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/onboarding/trust_directory.rs
|
||||
assertion_line: 218
|
||||
expression: terminal.backend()
|
||||
---
|
||||
> You are in /workspace/project
|
||||
|
||||
Do you trust the contents of this directory? Working with untrusted
|
||||
contents comes with higher risk of prompt injection.
|
||||
|
||||
› 1. Yes, continue
|
||||
2. No, quit
|
||||
|
||||
Press enter to continue
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui_app_server/src/onboarding/trust_directory.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
> You are in /workspace/project
|
||||
|
||||
Do you trust the contents of this directory? Working with untrusted
|
||||
contents comes with higher risk of prompt injection.
|
||||
|
||||
› 1. Yes, continue
|
||||
2. No, quit
|
||||
|
||||
Press enter to continue
|
||||
@@ -0,0 +1,220 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_core::config::set_project_trust_level;
|
||||
use codex_core::git_info::resolve_root_git_project_for_trust;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
use crate::key_hint;
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||
use crate::render::Insets;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::render::renderable::RenderableExt as _;
|
||||
use crate::selection_list::selection_option_row;
|
||||
|
||||
use super::onboarding_screen::StepState;
|
||||
pub(crate) struct TrustDirectoryWidget {
|
||||
pub codex_home: PathBuf,
|
||||
pub cwd: PathBuf,
|
||||
pub show_windows_create_sandbox_hint: bool,
|
||||
pub should_quit: bool,
|
||||
pub selection: Option<TrustDirectorySelection>,
|
||||
pub highlighted: TrustDirectorySelection,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TrustDirectorySelection {
|
||||
Trust,
|
||||
Quit,
|
||||
}
|
||||
|
||||
impl WidgetRef for &TrustDirectoryWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let mut column = ColumnRenderable::new();
|
||||
|
||||
column.push(Line::from(vec![
|
||||
"> ".into(),
|
||||
"You are in ".bold(),
|
||||
self.cwd.to_string_lossy().to_string().into(),
|
||||
]));
|
||||
column.push("");
|
||||
|
||||
column.push(
|
||||
Paragraph::new(
|
||||
"Do you trust the contents of this directory? Working with untrusted contents comes with higher risk of prompt injection.".to_string(),
|
||||
)
|
||||
.wrap(Wrap { trim: true })
|
||||
.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||
);
|
||||
column.push("");
|
||||
|
||||
let options: Vec<(&str, TrustDirectorySelection)> = vec![
|
||||
("Yes, continue", TrustDirectorySelection::Trust),
|
||||
("No, quit", TrustDirectorySelection::Quit),
|
||||
];
|
||||
|
||||
for (idx, (text, selection)) in options.iter().enumerate() {
|
||||
column.push(selection_option_row(
|
||||
idx,
|
||||
text.to_string(),
|
||||
self.highlighted == *selection,
|
||||
));
|
||||
}
|
||||
|
||||
column.push("");
|
||||
|
||||
if let Some(error) = &self.error {
|
||||
column.push(
|
||||
Paragraph::new(error.to_string())
|
||||
.red()
|
||||
.wrap(Wrap { trim: true })
|
||||
.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||
);
|
||||
column.push("");
|
||||
}
|
||||
|
||||
column.push(
|
||||
Line::from(vec![
|
||||
"Press ".dim(),
|
||||
key_hint::plain(KeyCode::Enter).into(),
|
||||
if self.show_windows_create_sandbox_hint {
|
||||
" to continue and create a sandbox...".dim()
|
||||
} else {
|
||||
" to continue".dim()
|
||||
},
|
||||
])
|
||||
.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||
);
|
||||
|
||||
column.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyboardHandler for TrustDirectoryWidget {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
if key_event.kind == KeyEventKind::Release {
|
||||
return;
|
||||
}
|
||||
|
||||
match key_event.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.highlighted = TrustDirectorySelection::Trust;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.highlighted = TrustDirectorySelection::Quit;
|
||||
}
|
||||
KeyCode::Char('1') | KeyCode::Char('y') => self.handle_trust(),
|
||||
KeyCode::Char('2') | KeyCode::Char('n') => self.handle_quit(),
|
||||
KeyCode::Enter => match self.highlighted {
|
||||
TrustDirectorySelection::Trust => self.handle_trust(),
|
||||
TrustDirectorySelection::Quit => self.handle_quit(),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StepStateProvider for TrustDirectoryWidget {
|
||||
fn get_step_state(&self) -> StepState {
|
||||
if self.selection.is_some() || self.should_quit {
|
||||
StepState::Complete
|
||||
} else {
|
||||
StepState::InProgress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TrustDirectoryWidget {
|
||||
fn handle_trust(&mut self) {
|
||||
let target =
|
||||
resolve_root_git_project_for_trust(&self.cwd).unwrap_or_else(|| self.cwd.clone());
|
||||
if let Err(e) = set_project_trust_level(&self.codex_home, &target, TrustLevel::Trusted) {
|
||||
tracing::error!("Failed to set project trusted: {e:?}");
|
||||
self.error = Some(format!("Failed to set trust for {}: {e}", target.display()));
|
||||
}
|
||||
|
||||
self.selection = Some(TrustDirectorySelection::Trust);
|
||||
}
|
||||
|
||||
fn handle_quit(&mut self) {
|
||||
self.highlighted = TrustDirectorySelection::Quit;
|
||||
self.should_quit = true;
|
||||
}
|
||||
|
||||
pub fn should_quit(&self) -> bool {
|
||||
self.should_quit
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::test_backend::VT100Backend;
|
||||
|
||||
use super::*;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::Terminal;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn release_event_does_not_change_selection() {
|
||||
let codex_home = TempDir::new().expect("temp home");
|
||||
let mut widget = TrustDirectoryWidget {
|
||||
codex_home: codex_home.path().to_path_buf(),
|
||||
cwd: PathBuf::from("."),
|
||||
show_windows_create_sandbox_hint: false,
|
||||
should_quit: false,
|
||||
selection: None,
|
||||
highlighted: TrustDirectorySelection::Quit,
|
||||
error: None,
|
||||
};
|
||||
|
||||
let release = KeyEvent {
|
||||
kind: KeyEventKind::Release,
|
||||
..KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
|
||||
};
|
||||
widget.handle_key_event(release);
|
||||
assert_eq!(widget.selection, None);
|
||||
|
||||
let press = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||||
widget.handle_key_event(press);
|
||||
assert!(widget.should_quit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_snapshot_for_git_repo() {
|
||||
let codex_home = TempDir::new().expect("temp home");
|
||||
let widget = TrustDirectoryWidget {
|
||||
codex_home: codex_home.path().to_path_buf(),
|
||||
cwd: PathBuf::from("/workspace/project"),
|
||||
show_windows_create_sandbox_hint: false,
|
||||
should_quit: false,
|
||||
selection: None,
|
||||
highlighted: TrustDirectorySelection::Trust,
|
||||
error: None,
|
||||
};
|
||||
|
||||
let mut terminal = Terminal::new(VT100Backend::new(70, 14)).expect("terminal");
|
||||
terminal
|
||||
.draw(|f| (&widget).render_ref(f.area(), f.buffer_mut()))
|
||||
.expect("draw");
|
||||
|
||||
insta::assert_snapshot!(terminal.backend());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::Widget;
|
||||
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 std::cell::Cell;
|
||||
|
||||
use crate::ascii_animation::AsciiAnimation;
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||
use crate::tui::FrameRequester;
|
||||
|
||||
use super::onboarding_screen::StepState;
|
||||
|
||||
const MIN_ANIMATION_HEIGHT: u16 = 37;
|
||||
const MIN_ANIMATION_WIDTH: u16 = 60;
|
||||
|
||||
pub(crate) struct WelcomeWidget {
|
||||
pub is_logged_in: bool,
|
||||
animation: AsciiAnimation,
|
||||
animations_enabled: bool,
|
||||
layout_area: Cell<Option<Rect>>,
|
||||
}
|
||||
|
||||
impl KeyboardHandler for WelcomeWidget {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
if !self.animations_enabled {
|
||||
return;
|
||||
}
|
||||
if key_event.kind == KeyEventKind::Press
|
||||
&& key_event.code == KeyCode::Char('.')
|
||||
&& key_event.modifiers.contains(KeyModifiers::CONTROL)
|
||||
{
|
||||
tracing::warn!("Welcome background to press '.'");
|
||||
let _ = self.animation.pick_random_variant();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WelcomeWidget {
|
||||
pub(crate) fn new(
|
||||
is_logged_in: bool,
|
||||
request_frame: FrameRequester,
|
||||
animations_enabled: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
is_logged_in,
|
||||
animation: AsciiAnimation::new(request_frame),
|
||||
animations_enabled,
|
||||
layout_area: Cell::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn update_layout_area(&self, area: Rect) {
|
||||
self.layout_area.set(Some(area));
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &WelcomeWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
Clear.render(area, buf);
|
||||
if self.animations_enabled {
|
||||
self.animation.schedule_next_frame();
|
||||
}
|
||||
|
||||
let layout_area = self.layout_area.get().unwrap_or(area);
|
||||
// Skip the animation entirely when the viewport is too small so we don't clip frames.
|
||||
let show_animation = self.animations_enabled
|
||||
&& layout_area.height >= MIN_ANIMATION_HEIGHT
|
||||
&& layout_area.width >= MIN_ANIMATION_WIDTH;
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
if show_animation {
|
||||
let frame = self.animation.current_frame();
|
||||
lines.extend(frame.lines().map(Into::into));
|
||||
lines.push("".into());
|
||||
}
|
||||
lines.push(Line::from(vec![
|
||||
" ".into(),
|
||||
"Welcome to ".into(),
|
||||
"Codex".bold(),
|
||||
", OpenAI's command-line coding agent".into(),
|
||||
]));
|
||||
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
impl StepStateProvider for WelcomeWidget {
|
||||
fn get_step_state(&self) -> StepState {
|
||||
match self.is_logged_in {
|
||||
true => StepState::Hidden,
|
||||
false => StepState::Complete,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
static VARIANT_A: [&str; 1] = ["frame-a"];
|
||||
static VARIANT_B: [&str; 1] = ["frame-b"];
|
||||
static VARIANTS: [&[&str]; 2] = [&VARIANT_A, &VARIANT_B];
|
||||
|
||||
fn row_containing(buf: &Buffer, needle: &str) -> Option<u16> {
|
||||
(0..buf.area.height).find(|&y| {
|
||||
let mut row = String::new();
|
||||
for x in 0..buf.area.width {
|
||||
row.push_str(buf[(x, y)].symbol());
|
||||
}
|
||||
row.contains(needle)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn welcome_renders_animation_on_first_draw() {
|
||||
let widget = WelcomeWidget::new(false, FrameRequester::test_dummy(), true);
|
||||
let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let frame_lines = widget.animation.current_frame().lines().count() as u16;
|
||||
(&widget).render(area, &mut buf);
|
||||
|
||||
let welcome_row = row_containing(&buf, "Welcome");
|
||||
assert_eq!(welcome_row, Some(frame_lines + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn welcome_skips_animation_below_height_breakpoint() {
|
||||
let widget = WelcomeWidget::new(false, FrameRequester::test_dummy(), true);
|
||||
let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT - 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&widget).render(area, &mut buf);
|
||||
|
||||
let welcome_row = row_containing(&buf, "Welcome");
|
||||
assert_eq!(welcome_row, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_dot_changes_animation_variant() {
|
||||
let mut widget = WelcomeWidget {
|
||||
is_logged_in: false,
|
||||
animation: AsciiAnimation::with_variants(FrameRequester::test_dummy(), &VARIANTS, 0),
|
||||
animations_enabled: true,
|
||||
layout_area: Cell::new(None),
|
||||
};
|
||||
|
||||
let before = widget.animation.current_frame();
|
||||
widget.handle_key_event(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::CONTROL));
|
||||
let after = widget.animation.current_frame();
|
||||
|
||||
assert_ne!(
|
||||
before, after,
|
||||
"expected ctrl+. to switch welcome animation variant"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user