We continue the separation between `codex app-server` and `codex mcp-server`. In particular, we introduce a new crate, `codex-app-server-protocol`, and migrate `codex-rs/protocol/src/mcp_protocol.rs` into it, renaming it `codex-rs/app-server-protocol/src/protocol.rs`. Because `ConversationId` was defined in `mcp_protocol.rs`, we move it into its own file, `codex-rs/protocol/src/conversation_id.rs`, and because it is referenced in a ton of places, we have to touch a lot of files as part of this PR. We also decide to get away from proper JSON-RPC 2.0 semantics, so we also introduce `codex-rs/app-server-protocol/src/jsonrpc_lite.rs`, which is basically the same `JSONRPCMessage` type defined in `mcp-types` except with all of the `"jsonrpc": "2.0"` removed. Getting rid of `"jsonrpc": "2.0"` makes our serialization logic considerably simpler, as we can lean heavier on serde to serialize directly into the wire format that we use now.
391 lines
13 KiB
Rust
391 lines
13 KiB
Rust
use codex_core::AuthManager;
|
|
use codex_core::config::Config;
|
|
use codex_core::git_info::get_git_repo_root;
|
|
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_app_server_protocol::AuthMode;
|
|
|
|
use crate::LoginStatus;
|
|
use crate::onboarding::auth::AuthModeWidget;
|
|
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) {}
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
pub(crate) struct OnboardingScreenArgs {
|
|
pub show_trust_screen: bool,
|
|
pub show_login_screen: bool,
|
|
pub login_status: LoginStatus,
|
|
pub auth_manager: Arc<AuthManager>,
|
|
pub config: Config,
|
|
}
|
|
|
|
impl OnboardingScreen {
|
|
pub(crate) fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self {
|
|
let OnboardingScreenArgs {
|
|
show_trust_screen,
|
|
show_login_screen,
|
|
login_status,
|
|
auth_manager,
|
|
config,
|
|
} = args;
|
|
let cwd = config.cwd.clone();
|
|
let codex_home = config.codex_home;
|
|
let mut steps: Vec<Step> = vec![Step::Welcome(WelcomeWidget::new(
|
|
!matches!(login_status, LoginStatus::NotAuthenticated),
|
|
tui.frame_requester(),
|
|
))];
|
|
if show_login_screen {
|
|
steps.push(Step::Auth(AuthModeWidget {
|
|
request_frame: tui.frame_requester(),
|
|
highlighted_mode: AuthMode::ChatGPT,
|
|
error: None,
|
|
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
|
|
codex_home: codex_home.clone(),
|
|
login_status,
|
|
auth_manager,
|
|
}))
|
|
}
|
|
let is_git_repo = get_git_repo_root(&cwd).is_some();
|
|
let highlighted = if is_git_repo {
|
|
TrustDirectorySelection::Trust
|
|
} else {
|
|
// Default to not trusting the directory if it's not a git repo.
|
|
TrustDirectorySelection::DontTrust
|
|
};
|
|
if show_trust_screen {
|
|
steps.push(Step::TrustDirectory(TrustDirectoryWidget {
|
|
cwd,
|
|
codex_home,
|
|
is_git_repo,
|
|
selection: None,
|
|
highlighted,
|
|
error: None,
|
|
}))
|
|
}
|
|
// TODO: add git warning.
|
|
Self {
|
|
request_frame: tui.frame_requester(),
|
|
steps,
|
|
is_done: 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
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
impl KeyboardHandler for OnboardingScreen {
|
|
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
|
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,
|
|
..
|
|
}
|
|
| KeyEvent {
|
|
code: KeyCode::Char('q'),
|
|
kind: KeyEventKind::Press,
|
|
..
|
|
} => {
|
|
self.is_done = true;
|
|
}
|
|
_ => {
|
|
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);
|
|
}
|
|
}
|
|
};
|
|
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);
|
|
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,
|
|
tui: &mut Tui,
|
|
) -> Result<Option<crate::onboarding::TrustDirectorySelection>> {
|
|
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() {
|
|
if let Some(event) = tui_events.next().await {
|
|
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());
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(onboarding_screen.directory_trust_decision())
|
|
}
|