Unify the TUI on the app-server implementation

This commit is contained in:
Eric Traut
2026-03-26 15:48:37 -06:00
parent dfb36573cd
commit 53c0ac71e5
1224 changed files with 10006 additions and 144215 deletions
-2
View File
@@ -57,8 +57,6 @@ See `codex-rs/tui/styles.md`.
## TUI code conventions
- When a change lands in `codex-rs/tui` and `codex-rs/tui_app_server` has a parallel implementation of the same behavior, reflect the change in `codex-rs/tui_app_server` too unless there is a documented reason not to.
- Use concise styling helpers from ratatui’s Stylize trait.
- Basic spans: use "text".into()
- Styled spans: use "text".red(), "text".green(), "text".magenta(), "text".dim(), etc.
-97
View File
@@ -1635,7 +1635,6 @@ dependencies = [
"codex-stdio-to-uds",
"codex-terminal-detection",
"codex-tui",
"codex-tui-app-server",
"codex-utils-cargo-bin",
"codex-utils-cli",
"codex-windows-sandbox",
@@ -2629,102 +2628,6 @@ dependencies = [
[[package]]
name = "codex-tui"
version = "0.0.0"
dependencies = [
"anyhow",
"arboard",
"assert_matches",
"base64 0.22.1",
"chrono",
"clap",
"codex-ansi-escape",
"codex-app-server-client",
"codex-app-server-protocol",
"codex-arg0",
"codex-backend-client",
"codex-chatgpt",
"codex-cli",
"codex-client",
"codex-cloud-requirements",
"codex-core",
"codex-exec-server",
"codex-features",
"codex-feedback",
"codex-file-search",
"codex-git-utils",
"codex-login",
"codex-otel",
"codex-protocol",
"codex-shell-command",
"codex-state",
"codex-terminal-detection",
"codex-tui-app-server",
"codex-utils-absolute-path",
"codex-utils-approval-presets",
"codex-utils-cargo-bin",
"codex-utils-cli",
"codex-utils-elapsed",
"codex-utils-fuzzy-match",
"codex-utils-oss",
"codex-utils-pty",
"codex-utils-sandbox-summary",
"codex-utils-sleep-inhibitor",
"codex-utils-string",
"codex-windows-sandbox",
"color-eyre",
"cpal",
"crossterm",
"derive_more 2.1.1",
"diffy",
"dirs",
"dunce",
"hound",
"image",
"insta",
"itertools 0.14.0",
"lazy_static",
"libc",
"pathdiff",
"pretty_assertions",
"pulldown-cmark",
"rand 0.9.2",
"ratatui",
"ratatui-macros",
"regex-lite",
"reqwest",
"rmcp",
"serde",
"serde_json",
"serial_test",
"shlex",
"strum 0.27.2",
"strum_macros 0.28.0",
"supports-color 3.0.2",
"syntect",
"tempfile",
"textwrap 0.16.2",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tokio-util",
"toml 0.9.11+spec-1.1.0",
"tracing",
"tracing-appender",
"tracing-subscriber",
"two-face",
"unicode-segmentation",
"unicode-width 0.2.1",
"url",
"uuid",
"vt100",
"webbrowser",
"which 8.0.0",
"windows-sys 0.52.0",
"winsplit",
]
[[package]]
name = "codex-tui-app-server"
version = "0.0.0"
dependencies = [
"anyhow",
"arboard",
-2
View File
@@ -50,7 +50,6 @@ members = [
"stdio-to-uds",
"otel",
"tui",
"tui_app_server",
"v8-poc",
"utils/absolute-path",
"utils/cargo-bin",
@@ -151,7 +150,6 @@ codex-state = { path = "state" }
codex-stdio-to-uds = { path = "stdio-to-uds" }
codex-terminal-detection = { path = "terminal-detection" }
codex-tui = { path = "tui" }
codex-tui-app-server = { path = "tui_app_server" }
codex-v8-poc = { path = "v8-poc" }
codex-utils-absolute-path = { path = "utils/absolute-path" }
codex-utils-approval-presets = { path = "utils/approval-presets" }
+1 -1
View File
@@ -5,7 +5,7 @@ It owns the remote connection lifecycle, including the initialize/initialized
handshake, JSON-RPC request/response routing, server-request resolution, and
notification streaming. The rest of the crate uses the same `AppServerEvent`
surface for both in-process and remote transports, so callers such as
`tui_app_server` can switch between them without changing their higher-level
`codex_tui` can switch between them without changing their higher-level
session logic.
*/
-1
View File
@@ -41,7 +41,6 @@ codex-state = { workspace = true }
codex-stdio-to-uds = { workspace = true }
codex-terminal-detection = { workspace = true }
codex-tui = { workspace = true }
codex-tui-app-server = { workspace = true }
libc = { workspace = true }
owo-colors = { workspace = true }
regex-lite = { workspace = true }
+14 -89
View File
@@ -1226,10 +1226,9 @@ async fn run_interactive_tui(
}
}
let use_app_server_tui = codex_tui::should_use_app_server_tui(&interactive).await?;
let normalized_remote = remote
.as_deref()
.map(codex_tui_app_server::normalize_remote_addr)
.map(codex_tui::normalize_remote_addr)
.transpose()
.map_err(std::io::Error::other)?;
if remote_auth_token_env.is_some() && normalized_remote.is_none() {
@@ -1237,93 +1236,19 @@ async fn run_interactive_tui(
"`--remote-auth-token-env` requires `--remote`.",
));
}
if normalized_remote.is_some() && !use_app_server_tui {
return Ok(AppExitInfo::fatal(
"`--remote` requires the `tui_app_server` feature flag to be enabled.",
));
}
if use_app_server_tui {
let remote_auth_token = remote_auth_token_env
.as_deref()
.map(read_remote_auth_token_from_env_var)
.transpose()
.map_err(std::io::Error::other)?;
codex_tui_app_server::run_main(
into_app_server_tui_cli(interactive),
arg0_paths,
codex_core::config_loader::LoaderOverrides::default(),
normalized_remote,
remote_auth_token,
)
.await
.map(into_legacy_app_exit_info)
} else {
codex_tui::run_main(
interactive,
arg0_paths,
codex_core::config_loader::LoaderOverrides::default(),
)
.await
}
}
fn into_app_server_tui_cli(cli: TuiCli) -> codex_tui_app_server::Cli {
codex_tui_app_server::Cli {
prompt: cli.prompt,
images: cli.images,
resume_picker: cli.resume_picker,
resume_last: cli.resume_last,
resume_session_id: cli.resume_session_id,
resume_show_all: cli.resume_show_all,
fork_picker: cli.fork_picker,
fork_last: cli.fork_last,
fork_session_id: cli.fork_session_id,
fork_show_all: cli.fork_show_all,
model: cli.model,
oss: cli.oss,
oss_provider: cli.oss_provider,
config_profile: cli.config_profile,
sandbox_mode: cli.sandbox_mode,
approval_policy: cli.approval_policy,
full_auto: cli.full_auto,
dangerously_bypass_approvals_and_sandbox: cli.dangerously_bypass_approvals_and_sandbox,
cwd: cli.cwd,
web_search: cli.web_search,
add_dir: cli.add_dir,
no_alt_screen: cli.no_alt_screen,
config_overrides: cli.config_overrides,
}
}
fn into_legacy_update_action(
action: codex_tui_app_server::update_action::UpdateAction,
) -> UpdateAction {
match action {
codex_tui_app_server::update_action::UpdateAction::NpmGlobalLatest => {
UpdateAction::NpmGlobalLatest
}
codex_tui_app_server::update_action::UpdateAction::BunGlobalLatest => {
UpdateAction::BunGlobalLatest
}
codex_tui_app_server::update_action::UpdateAction::BrewUpgrade => UpdateAction::BrewUpgrade,
}
}
fn into_legacy_exit_reason(reason: codex_tui_app_server::ExitReason) -> ExitReason {
match reason {
codex_tui_app_server::ExitReason::UserRequested => ExitReason::UserRequested,
codex_tui_app_server::ExitReason::Fatal(message) => ExitReason::Fatal(message),
}
}
fn into_legacy_app_exit_info(exit_info: codex_tui_app_server::AppExitInfo) -> AppExitInfo {
AppExitInfo {
token_usage: exit_info.token_usage,
thread_id: exit_info.thread_id,
thread_name: exit_info.thread_name,
update_action: exit_info.update_action.map(into_legacy_update_action),
exit_reason: into_legacy_exit_reason(exit_info.exit_reason),
}
let remote_auth_token = remote_auth_token_env
.as_deref()
.map(read_remote_auth_token_from_env_var)
.transpose()
.map_err(std::io::Error::other)?;
codex_tui::run_main(
interactive,
arg0_paths,
codex_core::config_loader::LoaderOverrides::default(),
normalized_remote,
remote_auth_token,
)
.await
}
fn confirm(prompt: &str) -> std::io::Result<bool> {
+3 -2
View File
@@ -176,7 +176,8 @@ pub enum Feature {
VoiceTranscription,
/// Enable experimental realtime voice conversation mode in the TUI.
RealtimeConversation,
/// Route interactive startup to the app-server-backed TUI implementation.
/// Deprecated no-op flag retained so older configs that still mention the
/// app-server TUI migration toggle continue to parse.
TuiAppServer,
/// Prevent idle system sleep while a turn is actively running.
PreventIdleSleep,
@@ -822,7 +823,7 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::TuiAppServer,
key: "tui_app_server",
stage: Stage::Stable,
stage: Stage::Deprecated,
default_enabled: true,
},
FeatureSpec {
+11 -2
View File
@@ -31,8 +31,11 @@ fn default_enabled_features_are_stable() {
for spec in crate::FEATURES {
if spec.default_enabled {
assert!(
matches!(spec.stage, Stage::Stable | Stage::Removed),
"feature `{}` is enabled by default but is not stable/removed ({:?})",
matches!(
spec.stage,
Stage::Stable | Stage::Deprecated | Stage::Removed
),
"feature `{}` is enabled by default but is not stable/deprecated/removed ({:?})",
spec.key,
spec.stage
);
@@ -52,6 +55,12 @@ fn use_linux_sandbox_bwrap_is_removed_and_disabled_by_default() {
assert_eq!(Feature::UseLinuxSandboxBwrap.default_enabled(), false);
}
#[test]
fn tui_app_server_is_deprecated_compatibility_flag() {
assert_eq!(Feature::TuiAppServer.stage(), Stage::Deprecated);
assert_eq!(Feature::TuiAppServer.default_enabled(), true);
}
#[test]
fn js_repl_is_experimental_and_user_toggleable() {
let spec = Feature::JsRepl.info();
+4 -1
View File
@@ -11,7 +11,10 @@ codex_rust_crate(
"Cargo.toml",
],
allow_empty = True,
),
) + [
"//codex-rs/core:templates/collaboration_mode/default.md",
"//codex-rs/core:templates/collaboration_mode/plan.md",
],
test_data_extra = glob(["src/**/snapshots/**"]) + ["//codex-rs/core:model_availability_nux_fixtures"],
integration_compile_data_extra = ["src/test_backend.rs"],
extra_binaries = [
+5 -3
View File
@@ -3,11 +3,16 @@ name = "codex-tui"
version.workspace = true
edition.workspace = true
license.workspace = true
autobins = false
[[bin]]
name = "codex-tui"
path = "src/main.rs"
[[bin]]
name = "md-events"
path = "src/bin/md-events.rs"
[lib]
name = "codex_tui"
path = "src/lib.rs"
@@ -32,12 +37,10 @@ codex-ansi-escape = { workspace = true }
codex-app-server-client = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-arg0 = { workspace = true }
codex-backend-client = { workspace = true }
codex-chatgpt = { workspace = true }
codex-client = { workspace = true }
codex-cloud-requirements = { workspace = true }
codex-core = { workspace = true }
codex-exec-server = { workspace = true }
codex-features = { workspace = true }
codex-feedback = { workspace = true }
codex-file-search = { workspace = true }
@@ -48,7 +51,6 @@ codex-protocol = { workspace = true }
codex-shell-command = { workspace = true }
codex-state = { workspace = true }
codex-terminal-detection = { workspace = true }
codex-tui-app-server = { workspace = true }
codex-utils-approval-presets = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-cli = { workspace = true }
+3290 -1801
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13 -33
View File
@@ -28,6 +28,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use crate::app::App;
use crate::app_command::AppCommand;
use crate::app_event::AppEvent;
use crate::history_cell::SessionInfoCell;
use crate::history_cell::UserHistoryCell;
@@ -35,10 +36,6 @@ use crate::pager_overlay::Overlay;
use crate::tui;
use crate::tui::TuiEvent;
use codex_protocol::ThreadId;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::user_input::TextElement;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
@@ -215,7 +212,8 @@ impl App {
selection,
thread_id: self.chat_widget.thread_id(),
});
self.chat_widget.submit_op(Op::ThreadRollback { num_turns });
self.chat_widget
.submit_op(AppCommand::thread_rollback(num_turns));
self.chat_widget.set_remote_image_urls(remote_image_urls);
if !prefill.is_empty()
|| !text_elements.is_empty()
@@ -461,37 +459,19 @@ impl App {
tui.frame_requester().schedule_frame();
}
pub(crate) fn handle_backtrack_event(&mut self, event: &EventMsg) {
match event {
EventMsg::ThreadRolledBack(rollback) => {
// `pending_rollback` is set only after this UI sends `Op::ThreadRollback`
// from the backtrack flow. In that case, finish immediately using the
// stored selection (nth user message) so local trim matches the exact
// backtrack target.
//
// When it is `None`, rollback came from replay or another source. We
// queue an AppEvent so rollback trim runs in FIFO order with
// `InsertHistoryCell` events, avoiding races with in-flight transcript
// inserts.
if self.backtrack.pending_rollback.is_some() {
self.finish_pending_backtrack();
} else {
self.app_event_tx.send(AppEvent::ApplyThreadRollback {
num_turns: rollback.num_turns,
});
}
}
EventMsg::Error(ErrorEvent {
codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed),
..
}) => {
// Core rejected the rollback; clear the guard so the user can retry.
self.backtrack.pending_rollback = None;
}
_ => {}
pub(crate) fn handle_backtrack_rollback_succeeded(&mut self, num_turns: u32) {
if self.backtrack.pending_rollback.is_some() {
self.finish_pending_backtrack();
} else {
self.app_event_tx
.send(AppEvent::ApplyThreadRollback { num_turns });
}
}
pub(crate) fn handle_backtrack_rollback_failed(&mut self) {
self.backtrack.pending_rollback = None;
}
/// Apply rollback semantics for `ThreadRolledBack` events where this TUI does not have an
/// in-flight backtrack request (`pending_rollback` is `None`).
///
+19 -7
View File
@@ -10,6 +10,7 @@
use std::path::PathBuf;
use codex_app_server_protocol::McpServerStatus;
use codex_app_server_protocol::PluginInstallResponse;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginReadParams;
@@ -19,7 +20,8 @@ use codex_chatgpt::connectors::AppInfo;
use codex_file_search::FileMatch;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::GetHistoryEntryResponseEvent;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_approval_presets::ApprovalPreset;
@@ -76,7 +78,6 @@ pub(crate) struct ConnectorsSnapshot {
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub(crate) enum AppEvent {
CodexEvent(Event),
/// Open the agent picker for switching active threads.
OpenAgentPicker,
/// Switch the active thread to the selected agent.
@@ -85,13 +86,13 @@ pub(crate) enum AppEvent {
/// Submit an op to the specified thread, regardless of current focus.
SubmitThreadOp {
thread_id: ThreadId,
op: codex_protocol::protocol::Op,
op: Op,
},
/// Forward an event from a non-primary thread into the app-level thread router.
ThreadEvent {
/// Deliver a synthetic history lookup response to a specific thread channel.
ThreadHistoryEntryResponse {
thread_id: ThreadId,
event: Event,
event: GetHistoryEntryResponseEvent,
},
/// Start a new session.
@@ -116,11 +117,12 @@ pub(crate) enum AppEvent {
Exit(ExitMode),
/// Request to exit the application due to a fatal error.
#[allow(dead_code)]
FatalExitRequest(String),
/// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids
/// bubbling channels through layers of widgets.
CodexOp(codex_protocol::protocol::Op),
CodexOp(Op),
/// Kick off an asynchronous file search for the given query (text after
/// the `@`). Previous searches may be cancelled by the app layer so there
@@ -136,6 +138,7 @@ pub(crate) enum AppEvent {
},
/// Result of refreshing rate limits
#[allow(dead_code)]
RateLimitSnapshotFetched(RateLimitSnapshot),
/// Result of prefetching connectors.
@@ -246,6 +249,14 @@ pub(crate) enum AppEvent {
/// Abandon the post-install plugin app-auth flow.
PluginInstallAuthAbandon,
/// Fetch MCP inventory via app-server RPCs and render it into history.
FetchMcpInventory,
/// Result of fetching MCP inventory via app-server RPCs.
McpInventoryLoaded {
result: Result<Vec<McpServerStatus>, String>,
},
InsertHistoryCell(Box<dyn HistoryCell>),
/// Apply rollback semantics to local transcript cells.
@@ -536,6 +547,7 @@ pub(crate) enum AppEvent {
},
/// Dismiss the status-line setup UI without changing config.
StatusLineSetupCancelled,
/// Apply a user-confirmed terminal-title item ordering/selection.
TerminalTitleSetup {
items: Vec<TerminalTitleItem>,
+95
View File
@@ -1,3 +1,14 @@
use std::path::PathBuf;
use crate::app_command::AppCommand;
use codex_protocol::ThreadId;
use codex_protocol::approvals::ElicitationAction;
use codex_protocol::mcp::RequestId as McpRequestId;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::request_permissions::RequestPermissionsResponse;
use codex_protocol::request_user_input::RequestUserInputResponse;
use tokio::sync::mpsc::UnboundedSender;
use crate::app_event::AppEvent;
@@ -25,4 +36,88 @@ impl AppEventSender {
tracing::error!("failed to send event: {e}");
}
}
pub(crate) fn interrupt(&self) {
self.send(AppEvent::CodexOp(AppCommand::interrupt().into_core()));
}
pub(crate) fn compact(&self) {
self.send(AppEvent::CodexOp(AppCommand::compact().into_core()));
}
pub(crate) fn set_thread_name(&self, name: String) {
self.send(AppEvent::CodexOp(
AppCommand::set_thread_name(name).into_core(),
));
}
pub(crate) fn review(&self, review_request: ReviewRequest) {
self.send(AppEvent::CodexOp(
AppCommand::review(review_request).into_core(),
));
}
pub(crate) fn list_skills(&self, cwds: Vec<PathBuf>, force_reload: bool) {
self.send(AppEvent::CodexOp(
AppCommand::list_skills(cwds, force_reload).into_core(),
));
}
#[cfg_attr(
any(target_os = "linux", not(feature = "voice-input")),
allow(dead_code)
)]
pub(crate) fn realtime_conversation_audio(&self, params: ConversationAudioParams) {
self.send(AppEvent::CodexOp(
AppCommand::realtime_conversation_audio(params).into_core(),
));
}
pub(crate) fn user_input_answer(&self, id: String, response: RequestUserInputResponse) {
self.send(AppEvent::CodexOp(
AppCommand::user_input_answer(id, response).into_core(),
));
}
pub(crate) fn exec_approval(&self, thread_id: ThreadId, id: String, decision: ReviewDecision) {
self.send(AppEvent::SubmitThreadOp {
thread_id,
op: AppCommand::exec_approval(id, /*turn_id*/ None, decision).into_core(),
});
}
pub(crate) fn request_permissions_response(
&self,
thread_id: ThreadId,
id: String,
response: RequestPermissionsResponse,
) {
self.send(AppEvent::SubmitThreadOp {
thread_id,
op: AppCommand::request_permissions_response(id, response).into_core(),
});
}
pub(crate) fn patch_approval(&self, thread_id: ThreadId, id: String, decision: ReviewDecision) {
self.send(AppEvent::SubmitThreadOp {
thread_id,
op: AppCommand::patch_approval(id, decision).into_core(),
});
}
pub(crate) fn resolve_elicitation(
&self,
thread_id: ThreadId,
server_name: String,
request_id: McpRequestId,
decision: ElicitationAction,
content: Option<serde_json::Value>,
meta: Option<serde_json::Value>,
) {
self.send(AppEvent::SubmitThreadOp {
thread_id,
op: AppCommand::resolve_elicitation(server_name, request_id, decision, content, meta)
.into_core(),
});
}
}
@@ -1,45 +0,0 @@
use std::future::Future;
use crate::Cli;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_features::Feature;
pub(crate) fn app_server_tui_config_inputs(
cli: &Cli,
) -> std::io::Result<(Vec<(String, toml::Value)>, ConfigOverrides)> {
let mut raw_overrides = cli.config_overrides.raw_overrides.clone();
if cli.web_search {
raw_overrides.push("web_search=\"live\"".to_string());
}
let cli_kv_overrides = codex_utils_cli::CliConfigOverrides { raw_overrides }
.parse_overrides()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?;
let config_overrides = ConfigOverrides {
cwd: cli.cwd.clone(),
config_profile: cli.config_profile.clone(),
..Default::default()
};
Ok((cli_kv_overrides, config_overrides))
}
pub(crate) async fn should_use_app_server_tui_with<F, Fut>(
cli: &Cli,
load_config: F,
) -> std::io::Result<bool>
where
F: FnOnce(Vec<(String, toml::Value)>, ConfigOverrides) -> Fut,
Fut: Future<Output = std::io::Result<Config>>,
{
let (cli_kv_overrides, config_overrides) = app_server_tui_config_inputs(cli)?;
let config = load_config(cli_kv_overrides, config_overrides).await?;
Ok(config.features.enabled(Feature::TuiAppServer))
}
pub async fn should_use_app_server_tui(cli: &Cli) -> std::io::Result<bool> {
should_use_app_server_tui_with(cli, Config::load_with_cli_overrides_and_harness_overrides).await
}
+9 -10
View File
@@ -1,6 +1,7 @@
use codex_protocol::ThreadId;
use codex_protocol::approvals::ElicitationAction;
use codex_protocol::mcp::RequestId as McpRequestId;
#[cfg(test)]
use codex_protocol::protocol::Op;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -150,16 +151,14 @@ impl AppLinkView {
let Some(target) = self.elicitation_target.as_ref() else {
return;
};
self.app_event_tx.send(AppEvent::SubmitThreadOp {
thread_id: target.thread_id,
op: Op::ResolveElicitation {
server_name: target.server_name.clone(),
request_id: target.request_id.clone(),
decision,
content: None,
meta: None,
},
});
self.app_event_tx.resolve_elicitation(
target.thread_id,
target.server_name.clone(),
target.request_id.clone(),
decision,
/*content*/ None,
/*meta*/ None,
);
}
fn decline_tool_suggestion(&mut self) {
@@ -27,6 +27,7 @@ use codex_protocol::protocol::ElicitationAction;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::NetworkApprovalContext;
use codex_protocol::protocol::NetworkPolicyRuleAction;
#[cfg(test)]
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::request_permissions::PermissionGrantScope;
@@ -264,14 +265,8 @@ impl ApprovalOverlay {
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
}
let thread_id = request.thread_id();
self.app_event_tx.send(AppEvent::SubmitThreadOp {
thread_id,
op: Op::ExecApproval {
id: id.to_string(),
turn_id: None,
decision,
},
});
self.app_event_tx
.exec_approval(thread_id, id.to_string(), decision);
}
fn handle_permissions_decision(
@@ -307,16 +302,14 @@ impl ApprovalOverlay {
)));
}
let thread_id = request.thread_id();
self.app_event_tx.send(AppEvent::SubmitThreadOp {
self.app_event_tx.request_permissions_response(
thread_id,
op: Op::RequestPermissionsResponse {
id: call_id.to_string(),
response: codex_protocol::request_permissions::RequestPermissionsResponse {
permissions: granted_permissions,
scope,
},
call_id.to_string(),
codex_protocol::request_permissions::RequestPermissionsResponse {
permissions: granted_permissions,
scope,
},
});
);
}
fn handle_patch_decision(&self, id: &str, decision: ReviewDecision) {
@@ -327,13 +320,8 @@ impl ApprovalOverlay {
else {
return;
};
self.app_event_tx.send(AppEvent::SubmitThreadOp {
thread_id,
op: Op::PatchApproval {
id: id.to_string(),
decision,
},
});
self.app_event_tx
.patch_approval(thread_id, id.to_string(), decision);
}
fn handle_elicitation_decision(
@@ -349,16 +337,14 @@ impl ApprovalOverlay {
else {
return;
};
self.app_event_tx.send(AppEvent::SubmitThreadOp {
self.app_event_tx.resolve_elicitation(
thread_id,
op: Op::ResolveElicitation {
server_name: server_name.to_string(),
request_id: request_id.clone(),
decision,
content: None,
meta: None,
},
});
server_name.to_string(),
request_id.clone(),
decision,
/*content*/ None,
/*meta*/ None,
);
}
fn advance_queue(&mut self) {
+56 -6
View File
@@ -2328,6 +2328,7 @@ impl ChatComposer {
slash_commands::find_builtin_command(name, self.builtin_command_flags())
.is_some();
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
let is_custom_prompt_command = name.starts_with(&prompt_prefix);
let is_known_prompt = name
.strip_prefix(&prompt_prefix)
.map(|prompt_name| {
@@ -2337,12 +2338,25 @@ impl ChatComposer {
})
.unwrap_or(false);
if !is_builtin && !is_known_prompt {
let message = format!(
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
);
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_info_event(message, /*hint*/ None),
)));
let message = if is_custom_prompt_command && self.custom_prompts.is_empty() {
tracing::warn!(
"custom prompt listing/picker is not available in app-server TUI yet"
);
"Not available in app-server TUI yet.".to_string()
} else {
format!(
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
)
};
if is_custom_prompt_command && self.custom_prompts.is_empty() {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(message),
)));
} else {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_info_event(message, /*hint*/ None),
)));
}
self.set_text_content_with_mention_bindings(
original_input.clone(),
original_text_elements,
@@ -3508,6 +3522,7 @@ impl ChatComposer {
}
}
}
#[cfg(test)]
pub(crate) fn set_custom_prompts(&mut self, prompts: Vec<CustomPrompt>) {
self.custom_prompts = prompts.clone();
if let ActivePopup::Command(popup) = &mut self.active_popup {
@@ -8853,6 +8868,41 @@ mod tests {
assert!(found_error, "expected error history cell to be sent");
}
#[test]
fn custom_prompt_command_is_stubbed_when_prompt_listing_is_unavailable() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer
.textarea
.set_text_clearing_elements("/prompts:my-prompt");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(InputResult::None, result);
assert_eq!("/prompts:my-prompt", composer.textarea.text());
let AppEvent::InsertHistoryCell(cell) = rx.try_recv().expect("expected stub history cell")
else {
panic!("expected stub history cell");
};
let message = cell
.display_lines(80)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(message.contains("Not available in app-server TUI yet."));
}
#[test]
fn custom_prompt_missing_required_args_reports_error() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
@@ -278,11 +278,10 @@ impl ChatComposerHistory {
self.last_history_text = Some(entry.text.clone());
return Some(entry);
} else if let Some(log_id) = self.history_log_id {
let op = Op::GetHistoryEntryRequest {
app_event_tx.send(AppEvent::CodexOp(Op::GetHistoryEntryRequest {
offset: global_idx,
log_id,
};
app_event_tx.send(AppEvent::CodexOp(op));
}));
}
None
}
@@ -292,7 +291,6 @@ impl ChatComposerHistory {
mod tests {
use super::*;
use crate::app_event::AppEvent;
use codex_protocol::protocol::Op;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::unbounded_channel;
@@ -338,17 +336,17 @@ mod tests {
assert!(history.should_handle_navigation("", 0));
assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet
// Verify that an AppEvent::CodexOp with the correct GetHistoryEntryRequest was sent.
// Verify that a history lookup request was sent.
let event = rx.try_recv().expect("expected AppEvent to be sent");
let AppEvent::CodexOp(history_request1) = event else {
let AppEvent::CodexOp(op) = event else {
panic!("unexpected event variant");
};
assert_eq!(
Op::GetHistoryEntryRequest {
log_id: 1,
offset: 2
offset: 2,
},
history_request1
op
);
// Inject the async response.
@@ -360,17 +358,17 @@ mod tests {
// Next Up should move to offset 1.
assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet
// Verify second CodexOp event for offset 1.
// Verify second lookup request for offset 1.
let event2 = rx.try_recv().expect("expected second event");
let AppEvent::CodexOp(history_request_2) = event2 else {
let AppEvent::CodexOp(op) = event2 else {
panic!("unexpected event variant");
};
assert_eq!(
Op::GetHistoryEntryRequest {
log_id: 1,
offset: 1
offset: 1,
},
history_request_2
op
);
assert_eq!(
@@ -82,6 +82,7 @@ impl CommandPopup {
}
}
#[cfg(test)]
pub(crate) fn set_prompts(&mut self, mut prompts: Vec<CustomPrompt>) {
let exclude: HashSet<String> = self
.builtins
@@ -5,11 +5,14 @@ use std::path::PathBuf;
use codex_app_server_protocol::McpElicitationEnumSchema;
use codex_app_server_protocol::McpElicitationPrimitiveSchema;
use codex_app_server_protocol::McpElicitationSingleSelectEnumSchema;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_protocol::ThreadId;
use codex_protocol::approvals::ElicitationAction;
use codex_protocol::approvals::ElicitationRequest;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::mcp::RequestId as McpRequestId;
#[cfg(test)]
use codex_protocol::protocol::Op;
use codex_protocol::user_input::TextElement;
use crossterm::event::KeyCode;
@@ -25,7 +28,6 @@ use ratatui::widgets::Widget;
use serde_json::Value;
use unicode_width::UnicodeWidthStr;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::ChatComposer;
@@ -201,6 +203,36 @@ impl FooterTip {
}
impl McpServerElicitationFormRequest {
pub(crate) fn from_app_server_request(
thread_id: ThreadId,
request_id: McpRequestId,
request: McpServerElicitationRequestParams,
) -> Option<Self> {
let McpServerElicitationRequestParams {
server_name,
request,
..
} = request;
let McpServerElicitationRequest::Form {
meta,
message,
requested_schema,
} = request
else {
return None;
};
let requested_schema = serde_json::to_value(requested_schema).ok()?;
Self::from_parts(
thread_id,
server_name,
request_id,
meta,
message,
requested_schema,
)
}
pub(crate) fn from_event(
thread_id: ThreadId,
request: ElicitationRequestEvent,
@@ -214,6 +246,24 @@ impl McpServerElicitationFormRequest {
return None;
};
Self::from_parts(
thread_id,
request.server_name,
request.id,
meta,
message,
requested_schema,
)
}
fn from_parts(
thread_id: ThreadId,
server_name: String,
request_id: McpRequestId,
meta: Option<Value>,
message: String,
requested_schema: Value,
) -> Option<Self> {
let tool_suggestion = parse_tool_suggestion_request(meta.as_ref());
let is_tool_approval = meta
.as_ref()
@@ -313,8 +363,8 @@ impl McpServerElicitationFormRequest {
Some(Self {
thread_id,
server_name: request.server_name,
request_id: request.id,
server_name,
request_id,
message,
approval_display_params,
response_mode,
@@ -1083,16 +1133,14 @@ impl McpServerElicitationOverlay {
}
fn dispatch_cancel(&self) {
self.app_event_tx.send(AppEvent::SubmitThreadOp {
thread_id: self.request.thread_id,
op: Op::ResolveElicitation {
server_name: self.request.server_name.clone(),
request_id: self.request.request_id.clone(),
decision: ElicitationAction::Cancel,
content: None,
meta: None,
},
});
self.app_event_tx.resolve_elicitation(
self.request.thread_id,
self.request.server_name.clone(),
self.request.request_id.clone(),
ElicitationAction::Cancel,
/*content*/ None,
/*meta*/ None,
);
}
fn submit_answers(&mut self) {
@@ -1123,16 +1171,14 @@ impl McpServerElicitationOverlay {
Some(APPROVAL_CANCEL_VALUE) => (ElicitationAction::Cancel, None),
_ => (ElicitationAction::Cancel, None),
};
self.app_event_tx.send(AppEvent::SubmitThreadOp {
thread_id: self.request.thread_id,
op: Op::ResolveElicitation {
server_name: self.request.server_name.clone(),
request_id: self.request.request_id.clone(),
decision,
content: None,
meta,
},
});
self.app_event_tx.resolve_elicitation(
self.request.thread_id,
self.request.server_name.clone(),
self.request.request_id.clone(),
decision,
/*content*/ None,
meta,
);
if let Some(next) = self.queue.pop_front() {
self.request = next;
self.reset_for_request();
@@ -1149,16 +1195,14 @@ impl McpServerElicitationOverlay {
.enumerate()
.filter_map(|(idx, field)| self.field_value(idx).map(|value| (field.id.clone(), value)))
.collect::<serde_json::Map<_, _>>();
self.app_event_tx.send(AppEvent::SubmitThreadOp {
thread_id: self.request.thread_id,
op: Op::ResolveElicitation {
server_name: self.request.server_name.clone(),
request_id: self.request.request_id.clone(),
decision: ElicitationAction::Accept,
content: Some(Value::Object(content)),
meta: None,
},
});
self.app_event_tx.resolve_elicitation(
self.request.thread_id,
self.request.server_name.clone(),
self.request.request_id.clone(),
ElicitationAction::Accept,
Some(Value::Object(content)),
/*meta*/ None,
);
if let Some(next) = self.queue.pop_front() {
self.request = next;
self.reset_for_request();
+1 -8
View File
@@ -47,9 +47,9 @@ mod mcp_server_elicitation;
mod multi_select_picker;
mod request_user_input;
mod status_line_setup;
mod title_setup;
pub(crate) use app_link_view::AppLinkElicitationTarget;
pub(crate) use app_link_view::AppLinkSuggestionType;
mod title_setup;
pub(crate) use app_link_view::AppLinkView;
pub(crate) use app_link_view::AppLinkViewParams;
pub(crate) use approval_overlay::ApprovalOverlay;
@@ -144,7 +144,6 @@ use crate::bottom_pane::prompt_args::parse_slash_name;
pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::ChatComposerConfig;
pub(crate) use chat_composer::InputResult;
use codex_protocol::custom_prompts::CustomPrompt;
use crate::status_indicator_widget::StatusDetailsCapitalization;
use crate::status_indicator_widget::StatusIndicatorWidget;
@@ -866,12 +865,6 @@ impl BottomPane {
}
}
/// Update custom prompts available for the slash popup.
pub(crate) fn set_custom_prompts(&mut self, prompts: Vec<CustomPrompt>) {
self.composer.set_custom_prompts(prompts);
self.request_redraw();
}
pub(crate) fn composer_is_empty(&self) -> bool {
self.composer.is_empty()
}
@@ -119,8 +119,9 @@ mod tests {
assert_snapshot!(
snapshot_rows(&widget, 40).replace(' ', "."),
@r"
..!.Approval.needed.in.Robie.[explorer].
..../agent.to.switch.threads............"
..!.Approval.needed.in.Robie.[explorer].
..../agent.to.switch.threads............
"
);
}
@@ -137,11 +138,12 @@ mod tests {
assert_snapshot!(
snapshot_rows(&widget, 44).replace(' ', "."),
@r"
..!.Approval.needed.in.Main.[default].......
..!.Approval.needed.in.Robie.[explorer].....
..!.Approval.needed.in.Inspector............
............................................
..../agent.to.switch.threads................"
..!.Approval.needed.in.Main.[default].......
..!.Approval.needed.in.Robie.[explorer].....
..!.Approval.needed.in.Inspector............
............................................
..../agent.to.switch.threads................
"
);
}
}
@@ -30,6 +30,7 @@ use crate::bottom_pane::selection_popup_common::measure_rows_height;
use crate::history_cell;
use crate::render::renderable::Renderable;
#[cfg(test)]
use codex_protocol::protocol::Op;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputEvent;
@@ -745,13 +746,12 @@ impl RequestUserInputOverlay {
},
);
}
self.app_event_tx
.send(AppEvent::CodexOp(Op::UserInputAnswer {
id: self.request.turn_id.clone(),
response: RequestUserInputResponse {
answers: answers.clone(),
},
}));
self.app_event_tx.user_input_answer(
self.request.turn_id.clone(),
RequestUserInputResponse {
answers: answers.clone(),
},
);
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::RequestUserInputResultCell {
questions: self.request.questions.clone(),
@@ -1007,7 +1007,7 @@ impl BottomPaneView for RequestUserInputOverlay {
}
// TODO: Emit interrupted request_user_input results (including committed answers)
// once core supports persisting them reliably without follow-up turn issues.
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
self.app_event_tx.interrupt();
self.done = true;
return;
}
@@ -1223,7 +1223,7 @@ impl BottomPaneView for RequestUserInputOverlay {
self.close_unanswered_confirmation();
// TODO: Emit interrupted request_user_input results (including committed answers)
// once core supports persisting them reliably without follow-up turn issues.
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
self.app_event_tx.interrupt();
self.done = true;
return CancellationEvent::Handled;
}
@@ -1234,7 +1234,7 @@ impl BottomPaneView for RequestUserInputOverlay {
// TODO: Emit interrupted request_user_input results (including committed answers)
// once core supports persisting them reliably without follow-up turn issues.
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
self.app_event_tx.interrupt();
self.done = true;
CancellationEvent::Handled
}
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
assertion_line: 2600
expression: "render_snapshot(&overlay, area)"
---
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
assertion_line: 2744
expression: "render_snapshot(&overlay, area)"
---
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
assertion_line: 2770
expression: "render_snapshot(&overlay, area)"
---
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
assertion_line: 2321
expression: "render_snapshot(&overlay, area)"
---
@@ -22,7 +22,6 @@ use crate::render::renderable::Renderable;
use crate::skills_helpers::match_skill;
use crate::skills_helpers::truncate_skill_name;
use crate::style::user_message_style;
use codex_protocol::protocol::Op;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
@@ -187,10 +186,8 @@ impl SkillsToggleView {
}
self.complete = true;
self.app_event_tx.send(AppEvent::ManageSkillsClosed);
self.app_event_tx.send(AppEvent::CodexOp(Op::ListSkills {
cwds: Vec::new(),
force_reload: true,
}));
self.app_event_tx
.list_skills(Vec::new(), /*force_reload*/ true);
}
fn rows_width(total_width: u16) -> u16 {
@@ -1,6 +1,6 @@
---
source: tui/src/bottom_pane/approval_overlay.rs
expression: "render_overlay_lines(&view, 120)"
expression: "normalize_snapshot_paths(render_overlay_lines(&view, 120))"
---
Would you like to run the following command?
@@ -2,6 +2,7 @@
source: tui/src/bottom_pane/approval_overlay.rs
expression: "render_overlay_lines(&view, 80)"
---
Would you like to run the following command?
Thread: Robie [explorer]
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/approval_overlay.rs
assertion_line: 821
expression: "format!(\"{buf:?}\")"
---
Buffer {
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 2385
expression: terminal.backend()
---
" "
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
assertion_line: 1207
expression: terminal.backend()
---
" Robie [explorer] 100% context left "
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
assertion_line: 535
expression: terminal.backend()
---
" / for commands ! for shell commands "
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
assertion_line: 1210
expression: terminal.backend()
---
" Status line content · Robie [explorer] "
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/list_selection_view.rs
assertion_line: 1054
expression: "render_before_after_scroll_snapshot(ColumnWidthMode::AutoAllRows, 96)"
---
before scroll:
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/list_selection_view.rs
assertion_line: 1046
expression: "render_before_after_scroll_snapshot(ColumnWidthMode::AutoVisible, 96)"
---
before scroll:
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/list_selection_view.rs
assertion_line: 1062
expression: "render_before_after_scroll_snapshot(ColumnWidthMode::Fixed, 96)"
---
before scroll:
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/list_selection_view.rs
assertion_line: 828
expression: "render_lines_with_width(&view, 40)"
---
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/skills_toggle_view.rs
assertion_line: 439
expression: "render_lines(&view, 72)"
---
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/status_line_setup.rs
assertion_line: 365
expression: "render_lines(&view, 72)"
---
+22 -1
View File
@@ -25,8 +25,12 @@ use crate::bottom_pane::multi_select_picker::MultiSelectPicker;
use crate::render::renderable::Renderable;
/// Available items that can be displayed in the terminal title.
///
/// Variants serialize to kebab-case identifiers (e.g. `AppName` -> `"app-name"`)
/// via strum. These identifiers are persisted in user config files, so renaming
/// or removing a variant is a breaking config change.
#[derive(EnumIter, EnumString, Display, Debug, Clone, Copy, Eq, PartialEq, Hash)]
#[strum(serialize_all = "kebab_case")]
#[strum(serialize_all = "kebab-case")]
pub(crate) enum TerminalTitleItem {
/// Codex app name.
AppName,
@@ -81,6 +85,11 @@ impl TerminalTitleItem {
}
}
/// Returns the separator to place before this item in a rendered title.
///
/// The spinner gets a plain space on either side so it reads as
/// `my-project <spinner> Working` rather than `my-project | <spinner> | Working`.
/// All other adjacent items are joined with ` | `.
pub(crate) fn separator_from_previous(self, previous: Option<Self>) -> &'static str {
match previous {
None => "",
@@ -295,4 +304,16 @@ mod tests {
let items = parse_terminal_title_items(["project", "not-a-title-item"].into_iter());
assert_eq!(items, None);
}
#[test]
fn parse_terminal_title_items_accepts_kebab_case_variants() {
let items = parse_terminal_title_items(["app-name", "git-branch"].into_iter());
assert_eq!(
items,
Some(vec![
TerminalTitleItem::AppName,
TerminalTitleItem::GitBranch,
])
);
}
}
File diff suppressed because it is too large Load Diff
-149
View File
@@ -1,149 +0,0 @@
use std::sync::Arc;
use codex_core::CodexThread;
use codex_core::NewThread;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::mpsc::unbounded_channel;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
const TUI_NOTIFY_CLIENT: &str = "codex-tui";
async fn initialize_app_server_client_name(thread: &CodexThread) {
if let Err(err) = thread
.set_app_server_client_name(Some(TUI_NOTIFY_CLIENT.to_string()))
.await
{
tracing::error!("failed to set app server client name: {err}");
}
}
/// Spawn the agent bootstrapper and op forwarding loop, returning the
/// `UnboundedSender<Op>` used by the UI to submit operations.
pub(crate) fn spawn_agent(
config: Config,
app_event_tx: AppEventSender,
server: Arc<ThreadManager>,
) -> UnboundedSender<Op> {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
let app_event_tx_clone = app_event_tx;
tokio::spawn(async move {
let NewThread {
thread,
session_configured,
..
} = match server.start_thread(config).await {
Ok(v) => v,
Err(err) => {
let message = format!("Failed to initialize codex: {err}");
tracing::error!("{message}");
app_event_tx_clone.send(AppEvent::CodexEvent(Event {
id: "".to_string(),
msg: EventMsg::Error(err.to_error_event(/*message_prefix*/ None)),
}));
app_event_tx_clone.send(AppEvent::FatalExitRequest(message));
tracing::error!("failed to initialize codex: {err}");
return;
}
};
initialize_app_server_client_name(thread.as_ref()).await;
// Forward the captured `SessionConfigured` event so it can be rendered in the UI.
let ev = codex_protocol::protocol::Event {
// The `id` does not matter for rendering, so we can use a fake value.
id: "".to_string(),
msg: codex_protocol::protocol::EventMsg::SessionConfigured(session_configured),
};
app_event_tx_clone.send(AppEvent::CodexEvent(ev));
let thread_clone = thread.clone();
tokio::spawn(async move {
while let Some(op) = codex_op_rx.recv().await {
let id = thread_clone.submit(op).await;
if let Err(e) = id {
tracing::error!("failed to submit op: {e}");
}
}
});
while let Ok(event) = thread.next_event().await {
let is_shutdown_complete = matches!(event.msg, EventMsg::ShutdownComplete);
app_event_tx_clone.send(AppEvent::CodexEvent(event));
if is_shutdown_complete {
// ShutdownComplete is terminal for a thread; drop this receiver task so
// the Arc<CodexThread> can be released and thread resources can clean up.
break;
}
}
});
codex_op_tx
}
/// Spawn agent loops for an existing thread (e.g., a forked thread).
/// Sends the provided `SessionConfiguredEvent` immediately, then forwards subsequent
/// events and accepts Ops for submission.
pub(crate) fn spawn_agent_from_existing(
thread: std::sync::Arc<CodexThread>,
session_configured: codex_protocol::protocol::SessionConfiguredEvent,
app_event_tx: AppEventSender,
) -> UnboundedSender<Op> {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
let app_event_tx_clone = app_event_tx;
tokio::spawn(async move {
initialize_app_server_client_name(thread.as_ref()).await;
// Forward the captured `SessionConfigured` event so it can be rendered in the UI.
let ev = codex_protocol::protocol::Event {
id: "".to_string(),
msg: codex_protocol::protocol::EventMsg::SessionConfigured(session_configured),
};
app_event_tx_clone.send(AppEvent::CodexEvent(ev));
let thread_clone = thread.clone();
tokio::spawn(async move {
while let Some(op) = codex_op_rx.recv().await {
let id = thread_clone.submit(op).await;
if let Err(e) = id {
tracing::error!("failed to submit op: {e}");
}
}
});
while let Ok(event) = thread.next_event().await {
let is_shutdown_complete = matches!(event.msg, EventMsg::ShutdownComplete);
app_event_tx_clone.send(AppEvent::CodexEvent(event));
if is_shutdown_complete {
// ShutdownComplete is terminal for a thread; drop this receiver task so
// the Arc<CodexThread> can be released and thread resources can clean up.
break;
}
}
});
codex_op_tx
}
/// Spawn an op-forwarding loop for an existing thread without subscribing to events.
pub(crate) fn spawn_op_forwarder(thread: std::sync::Arc<CodexThread>) -> UnboundedSender<Op> {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
tokio::spawn(async move {
initialize_app_server_client_name(thread.as_ref()).await;
while let Some(op) = codex_op_rx.recv().await {
if let Err(e) = thread.submit(op).await {
tracing::error!("failed to submit op: {e}");
}
}
});
codex_op_tx
}
+49 -116
View File
@@ -4,12 +4,8 @@ use codex_protocol::protocol::RealtimeAudioFrame;
use codex_protocol::protocol::RealtimeConversationClosedEvent;
use codex_protocol::protocol::RealtimeConversationRealtimeEvent;
use codex_protocol::protocol::RealtimeConversationStartedEvent;
#[cfg(not(target_os = "linux"))]
use codex_protocol::protocol::RealtimeConversationVersion;
use codex_protocol::protocol::RealtimeEvent;
#[cfg(not(target_os = "linux"))]
use std::sync::atomic::AtomicUsize;
#[cfg(not(target_os = "linux"))]
use std::time::Duration;
const REALTIME_CONVERSATION_PROMPT: &str = "You are in a realtime voice conversation in the Codex TUI. Respond conversationally and concisely.";
@@ -26,8 +22,6 @@ pub(super) enum RealtimeConversationPhase {
#[derive(Default)]
pub(super) struct RealtimeConversationUiState {
pub(super) phase: RealtimeConversationPhase,
#[cfg(not(target_os = "linux"))]
audio_behavior: RealtimeAudioBehavior,
requested_close: bool,
session_id: Option<String>,
warned_audio_only_submission: bool,
@@ -39,40 +33,6 @@ pub(super) struct RealtimeConversationUiState {
capture: Option<crate::voice::VoiceCapture>,
#[cfg(not(target_os = "linux"))]
audio_player: Option<crate::voice::RealtimeAudioPlayer>,
#[cfg(not(target_os = "linux"))]
// Shared queue depth lets capture suppress echoed speaker audio without
// taking the playback queue lock from the input callback.
playback_queued_samples: Arc<AtomicUsize>,
}
#[cfg(not(target_os = "linux"))]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum RealtimeAudioBehavior {
#[default]
Legacy,
PlaybackAware,
}
#[cfg(not(target_os = "linux"))]
impl RealtimeAudioBehavior {
fn from_version(version: RealtimeConversationVersion) -> Self {
match version {
RealtimeConversationVersion::V1 => Self::Legacy,
RealtimeConversationVersion::V2 => Self::PlaybackAware,
}
}
fn input_behavior(
self,
playback_queued_samples: Arc<AtomicUsize>,
) -> crate::voice::RealtimeInputBehavior {
match self {
Self::Legacy => crate::voice::RealtimeInputBehavior::Ungated,
Self::PlaybackAware => crate::voice::RealtimeInputBehavior::PlaybackAware {
playback_queued_samples,
},
}
}
}
impl RealtimeConversationUiState {
@@ -106,23 +66,6 @@ pub(super) struct PendingSteerCompareKey {
}
impl ChatWidget {
pub(super) fn stop_realtime_conversation_from_ui(&mut self) {
self.request_realtime_conversation_close(/*info_message*/ None);
}
#[cfg(not(target_os = "linux"))]
pub(crate) fn stop_realtime_conversation_for_deleted_meter(&mut self, id: &str) -> bool {
if self.realtime_conversation.is_live()
&& self.realtime_conversation.meter_placeholder_id.as_deref() == Some(id)
{
self.realtime_conversation.meter_placeholder_id = None;
self.stop_realtime_conversation_from_ui();
return true;
}
false
}
pub(super) fn rendered_user_message_event_from_parts(
message: String,
text_elements: Vec<TextElement>,
@@ -174,6 +117,7 @@ impl ChatWidget {
}
}
#[cfg(test)]
pub(super) fn pending_steer_compare_key_from_item(
item: &codex_protocol::items::UserMessageItem,
) -> PendingSteerCompareKey {
@@ -220,6 +164,7 @@ impl ChatWidget {
)
}
#[cfg(test)]
pub(super) fn should_render_realtime_user_message_event(
&self,
event: &UserMessageEvent,
@@ -253,23 +198,39 @@ impl ChatWidget {
None
}
fn realtime_footer_hint_items() -> Vec<(String, String)> {
vec![("/realtime".to_string(), "stop live voice".to_string())]
}
pub(super) fn stop_realtime_conversation_from_ui(&mut self) {
self.request_realtime_conversation_close(/*info_message*/ None);
}
#[cfg(not(target_os = "linux"))]
pub(crate) fn stop_realtime_conversation_for_deleted_meter(&mut self, id: &str) -> bool {
if self.realtime_conversation.is_live()
&& self.realtime_conversation.meter_placeholder_id.as_deref() == Some(id)
{
self.realtime_conversation.meter_placeholder_id = None;
self.stop_realtime_conversation_from_ui();
return true;
}
false
}
pub(super) fn start_realtime_conversation(&mut self) {
self.realtime_conversation.phase = RealtimeConversationPhase::Starting;
self.realtime_conversation.requested_close = false;
self.realtime_conversation.session_id = None;
#[cfg(not(target_os = "linux"))]
{
self.realtime_conversation.audio_behavior = RealtimeAudioBehavior::Legacy;
}
self.realtime_conversation.warned_audio_only_submission = false;
self.set_footer_hint_override(Some(vec![(
"/realtime".to_string(),
"stop live voice".to_string(),
)]));
self.submit_op(Op::RealtimeConversationStart(ConversationStartParams {
prompt: REALTIME_CONVERSATION_PROMPT.to_string(),
session_id: None,
}));
self.set_footer_hint_override(Some(Self::realtime_footer_hint_items()));
self.submit_op(AppCommand::realtime_conversation_start(
ConversationStartParams {
prompt: REALTIME_CONVERSATION_PROMPT.to_string(),
session_id: None,
},
));
self.request_redraw();
}
@@ -283,7 +244,7 @@ impl ChatWidget {
self.realtime_conversation.requested_close = true;
self.realtime_conversation.phase = RealtimeConversationPhase::Stopping;
self.submit_op(Op::RealtimeConversationClose);
self.submit_op(AppCommand::realtime_conversation_close());
self.stop_realtime_local_audio();
self.set_footer_hint_override(/*items*/ None);
@@ -300,10 +261,6 @@ impl ChatWidget {
self.realtime_conversation.phase = RealtimeConversationPhase::Inactive;
self.realtime_conversation.requested_close = false;
self.realtime_conversation.session_id = None;
#[cfg(not(target_os = "linux"))]
{
self.realtime_conversation.audio_behavior = RealtimeAudioBehavior::Legacy;
}
self.realtime_conversation.warned_audio_only_submission = false;
}
@@ -327,16 +284,8 @@ impl ChatWidget {
}
self.realtime_conversation.phase = RealtimeConversationPhase::Active;
self.realtime_conversation.session_id = ev.session_id;
#[cfg(not(target_os = "linux"))]
{
self.realtime_conversation.audio_behavior =
RealtimeAudioBehavior::from_version(ev.version);
}
self.realtime_conversation.warned_audio_only_submission = false;
self.set_footer_hint_override(Some(vec![(
"/realtime".to_string(),
"stop live voice".to_string(),
)]));
self.set_footer_hint_override(Some(Self::realtime_footer_hint_items()));
self.start_realtime_local_audio();
self.request_redraw();
}
@@ -349,23 +298,11 @@ impl ChatWidget {
RealtimeEvent::SessionUpdated { session_id, .. } => {
self.realtime_conversation.session_id = Some(session_id);
}
RealtimeEvent::InputAudioSpeechStarted(_) | RealtimeEvent::ResponseCancelled(_) => {
#[cfg(not(target_os = "linux"))]
{
if matches!(
self.realtime_conversation.audio_behavior,
RealtimeAudioBehavior::PlaybackAware
) && let Some(player) = &self.realtime_conversation.audio_player
{
// Once the server detects user speech or the current response is cancelled,
// any buffered assistant audio is stale and should stop gating mic input.
player.clear();
}
}
}
RealtimeEvent::InputAudioSpeechStarted(_) => self.interrupt_realtime_audio_playback(),
RealtimeEvent::InputTranscriptDelta(_) => {}
RealtimeEvent::OutputTranscriptDelta(_) => {}
RealtimeEvent::AudioOut(frame) => self.enqueue_realtime_audio_out(&frame),
RealtimeEvent::ResponseCancelled(_) => self.interrupt_realtime_audio_playback(),
RealtimeEvent::ConversationItemAdded(_item) => {}
RealtimeEvent::ConversationItemDone { .. } => {}
RealtimeEvent::HandoffRequested(_) => {}
@@ -395,11 +332,8 @@ impl ChatWidget {
#[cfg(not(target_os = "linux"))]
{
if self.realtime_conversation.audio_player.is_none() {
self.realtime_conversation.audio_player = crate::voice::RealtimeAudioPlayer::start(
&self.config,
Arc::clone(&self.realtime_conversation.playback_queued_samples),
)
.ok();
self.realtime_conversation.audio_player =
crate::voice::RealtimeAudioPlayer::start(&self.config).ok();
}
if let Some(player) = &self.realtime_conversation.audio_player
&& let Err(err) = player.enqueue_frame(frame)
@@ -413,6 +347,16 @@ impl ChatWidget {
}
}
#[cfg(not(target_os = "linux"))]
fn interrupt_realtime_audio_playback(&mut self) {
if let Some(player) = &self.realtime_conversation.audio_player {
player.clear();
}
}
#[cfg(target_os = "linux")]
fn interrupt_realtime_audio_playback(&mut self) {}
#[cfg(not(target_os = "linux"))]
fn start_realtime_local_audio(&mut self) {
if self.realtime_conversation.capture_stop_flag.is_some() {
@@ -426,11 +370,6 @@ impl ChatWidget {
let capture = match crate::voice::VoiceCapture::start_realtime(
&self.config,
self.app_event_tx.clone(),
self.realtime_conversation
.audio_behavior
.input_behavior(Arc::clone(
&self.realtime_conversation.playback_queued_samples,
)),
) {
Ok(capture) => capture,
Err(err) => {
@@ -451,11 +390,8 @@ impl ChatWidget {
self.realtime_conversation.capture_stop_flag = Some(stop_flag.clone());
self.realtime_conversation.capture = Some(capture);
if self.realtime_conversation.audio_player.is_none() {
self.realtime_conversation.audio_player = crate::voice::RealtimeAudioPlayer::start(
&self.config,
Arc::clone(&self.realtime_conversation.playback_queued_samples),
)
.ok();
self.realtime_conversation.audio_player =
crate::voice::RealtimeAudioPlayer::start(&self.config).ok();
}
std::thread::spawn(move || {
@@ -493,10 +429,7 @@ impl ChatWidget {
}
RealtimeAudioDeviceKind::Speaker => {
self.stop_realtime_speaker();
match crate::voice::RealtimeAudioPlayer::start(
&self.config,
Arc::clone(&self.realtime_conversation.playback_queued_samples),
) {
match crate::voice::RealtimeAudioPlayer::start(&self.config) {
Ok(player) => {
self.realtime_conversation.audio_player = Some(player);
}
@@ -1,5 +1,5 @@
---
source: tui_app_server/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests.rs
expression: combined
---
• Spawned 019cff70-2599-75e2-af72-b91781b41a8e (gpt-5 high)
@@ -1,5 +1,5 @@
---
source: tui_app_server/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests.rs
expression: combined
---
• Waiting for 2 agents
@@ -1,5 +1,5 @@
---
source: tui_app_server/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests.rs
assertion_line: 9974
expression: term.backend().vt100().screen().contents()
---
@@ -2,6 +2,8 @@
source: tui/src/chatwidget/tests.rs
expression: terminal.backend().vt100().screen().contents()
---
Would you like to run the following command?
$ echo hello world
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 7368
expression: popup
---
Update Model Permissions
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 7365
expression: popup
---
Update Model Permissions
@@ -1,7 +1,42 @@
---
source: tui/src/chatwidget/tests.rs
expression: visual
expression: term.backend().vt100().screen().contents()
---
• -- Indented code block (4 spaces)
SELECT *
FROM "users"
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 9494
expression: combined
---
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 495
expression: lines_to_single_string(&aborted_long)
---
✗ You canceled the request to run echo
@@ -3,4 +3,3 @@ source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&aborted_multi)
---
✗ You canceled the request to run echo line1 ...
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob1
expression: active_blob(&chat)
---
• Exploring
└ List ls -la
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob2
expression: active_blob(&chat)
---
• Explored
└ List ls -la
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob3
expression: active_blob(&chat)
---
• Exploring
└ List ls -la
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob4
expression: active_blob(&chat)
---
• Explored
└ List ls -la
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob5
expression: active_blob(&chat)
---
• Explored
└ List ls -la
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob6
expression: active_blob(&chat)
---
• Explored
└ List ls -la
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 9237
expression: term.backend().vt100().screen().contents()
---
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 9085
expression: term.backend().vt100().screen().contents()
---
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 9336
expression: rendered
---
• Reviewing 2 approval requests (0s • esc to interrupt)
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 5889
expression: combined
---
• Generated Image:
@@ -1,5 +1,5 @@
---
source: tui_app_server/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests.rs
expression: blob
---
• You ran python3 -c 'print("Hello, world!")'
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 1989
expression: popup
---
Select Model and Effort
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 6001
expression: popup
---
Enable subagents?
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 7963
expression: "lines_to_single_string(&cells[0])"
---
• Permissions updated to Full Access
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 7289
expression: popup
---
Plugins
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 7527
expression: popup
---
Plugins
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 7060
expression: header
---
› Ask Codex to do anything
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 4430
expression: rendered.clone()
expression: rendered
---
• `/copy` is unavailable before the first Codex output or right after a rollback.
+30 -30
View File
@@ -5,17 +5,23 @@
use super::*;
/// Items shown in the terminal title when the user has not configured a
/// custom selection. Intentionally minimal: spinner + project name.
pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["spinner", "project"];
/// Braille-pattern dot-spinner frames for the terminal title animation.
pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] =
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
/// Time between spinner frame advances in the terminal title.
pub(super) const TERMINAL_TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(100);
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
/// Compact runtime states that can be rendered into the terminal title.
///
/// This is intentionally smaller than the full status-header vocabulary. The
/// title needs short, stable labels, so callers map richer lifecycle events
/// onto one of these buckets before rendering.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(super) enum TerminalTitleStatusKind {
Working,
WaitingForBackgroundTerminal,
@@ -47,12 +53,12 @@ impl StatusSurfaceSelections {
}
}
#[derive(Clone, Debug)]
/// Cached project-root display name keyed by the cwd used for the last lookup.
///
/// Terminal-title refreshes can happen very frequently, so the title path avoids
/// repeatedly walking up the filesystem to rediscover the same project root name
/// while the working directory is unchanged.
#[derive(Clone, Debug)]
pub(super) struct CachedProjectRootName {
pub(super) cwd: PathBuf,
pub(super) root_name: Option<String>,
@@ -270,20 +276,7 @@ impl ChatWidget {
///
/// Unknown ids are deduplicated in insertion order for warning messages.
fn status_line_items_with_invalids(&self) -> (Vec<StatusLineItem>, Vec<String>) {
let mut invalid = Vec::new();
let mut invalid_seen = HashSet::new();
let mut items = Vec::new();
for id in self.configured_status_line_items() {
match id.parse::<StatusLineItem>() {
Ok(item) => items.push(item),
Err(_) => {
if invalid_seen.insert(id.clone()) {
invalid.push(format!(r#""{id}""#));
}
}
}
}
(items, invalid)
parse_items_with_invalids(self.configured_status_line_items())
}
pub(super) fn configured_status_line_items(&self) -> Vec<String> {
@@ -299,20 +292,7 @@ impl ChatWidget {
///
/// Unknown ids are deduplicated in insertion order for warning messages.
fn terminal_title_items_with_invalids(&self) -> (Vec<TerminalTitleItem>, Vec<String>) {
let mut invalid = Vec::new();
let mut invalid_seen = HashSet::new();
let mut items = Vec::new();
for id in self.configured_terminal_title_items() {
match id.parse::<TerminalTitleItem>() {
Ok(item) => items.push(item),
Err(_) => {
if invalid_seen.insert(id.clone()) {
invalid.push(format!(r#""{id}""#));
}
}
}
}
(items, invalid)
parse_items_with_invalids(self.configured_terminal_title_items())
}
/// Returns the configured terminal-title ids, or the default ordering when unset.
@@ -660,3 +640,23 @@ impl ChatWidget {
truncated
}
}
fn parse_items_with_invalids<T>(ids: impl IntoIterator<Item = String>) -> (Vec<T>, Vec<String>)
where
T: std::str::FromStr,
{
let mut invalid = Vec::new();
let mut invalid_seen = HashSet::new();
let mut items = Vec::new();
for id in ids {
match id.parse::<T>() {
Ok(item) => items.push(item),
Err(_) => {
if invalid_seen.insert(id.clone()) {
invalid.push(format!(r#""{id}""#));
}
}
}
}
(items, invalid)
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -32,7 +32,7 @@ pub struct Cli {
#[clap(skip)]
pub resume_show_all: bool,
/// Internal: include non-interactive sessions in resume listings.
/// Internal: include non-interactive sessions when listing resume targets.
#[clap(skip)]
pub resume_include_non_interactive: bool,
+16 -15
View File
@@ -1,21 +1,22 @@
use codex_core::models_manager::manager::ModelsManager;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::ModeKind;
fn filtered_presets(models_manager: &ModelsManager) -> Vec<CollaborationModeMask> {
models_manager
use crate::model_catalog::ModelCatalog;
fn filtered_presets(model_catalog: &ModelCatalog) -> Vec<CollaborationModeMask> {
model_catalog
.list_collaboration_modes()
.into_iter()
.filter(|mask| mask.mode.is_some_and(ModeKind::is_tui_visible))
.collect()
}
pub(crate) fn presets_for_tui(models_manager: &ModelsManager) -> Vec<CollaborationModeMask> {
filtered_presets(models_manager)
pub(crate) fn presets_for_tui(model_catalog: &ModelCatalog) -> Vec<CollaborationModeMask> {
filtered_presets(model_catalog)
}
pub(crate) fn default_mask(models_manager: &ModelsManager) -> Option<CollaborationModeMask> {
let presets = filtered_presets(models_manager);
pub(crate) fn default_mask(model_catalog: &ModelCatalog) -> Option<CollaborationModeMask> {
let presets = filtered_presets(model_catalog);
presets
.iter()
.find(|mask| mask.mode == Some(ModeKind::Default))
@@ -24,23 +25,23 @@ pub(crate) fn default_mask(models_manager: &ModelsManager) -> Option<Collaborati
}
pub(crate) fn mask_for_kind(
models_manager: &ModelsManager,
model_catalog: &ModelCatalog,
kind: ModeKind,
) -> Option<CollaborationModeMask> {
if !kind.is_tui_visible() {
return None;
}
filtered_presets(models_manager)
filtered_presets(model_catalog)
.into_iter()
.find(|mask| mask.mode == Some(kind))
}
/// Cycle to the next collaboration mode preset in list order.
pub(crate) fn next_mask(
models_manager: &ModelsManager,
model_catalog: &ModelCatalog,
current: Option<&CollaborationModeMask>,
) -> Option<CollaborationModeMask> {
let presets = filtered_presets(models_manager);
let presets = filtered_presets(model_catalog);
if presets.is_empty() {
return None;
}
@@ -52,10 +53,10 @@ pub(crate) fn next_mask(
presets.get(next_index).cloned()
}
pub(crate) fn default_mode_mask(models_manager: &ModelsManager) -> Option<CollaborationModeMask> {
mask_for_kind(models_manager, ModeKind::Default)
pub(crate) fn default_mode_mask(model_catalog: &ModelCatalog) -> Option<CollaborationModeMask> {
mask_for_kind(model_catalog, ModeKind::Default)
}
pub(crate) fn plan_mask(models_manager: &ModelsManager) -> Option<CollaborationModeMask> {
mask_for_kind(models_manager, ModeKind::Plan)
pub(crate) fn plan_mask(model_catalog: &ModelCatalog) -> Option<CollaborationModeMask> {
mask_for_kind(model_catalog, ModeKind::Plan)
}
+37
View File
@@ -16,6 +16,22 @@ pub(crate) fn strip_bash_lc_and_escape(command: &[String]) -> String {
escape_command(command)
}
pub(crate) fn split_command_string(command: &str) -> Vec<String> {
let Some(parts) = shlex::split(command) else {
return vec![command.to_string()];
};
match shlex::try_join(parts.iter().map(String::as_str)) {
Ok(round_trip)
if round_trip == command
|| (!command.contains(":\\")
&& shlex::split(&round_trip).as_ref() == Some(&parts)) =>
{
parts
}
_ => vec![command.to_string()],
}
}
/// If `path` is absolute and inside $HOME, return the part *after* the home
/// directory; otherwise, return the path as-is. Note if `path` is the homedir,
/// this will return and empty path.
@@ -67,4 +83,25 @@ mod tests {
let cmdline = strip_bash_lc_and_escape(&args);
assert_eq!(cmdline, "echo hello");
}
#[test]
fn split_command_string_round_trips_shell_wrappers() {
let command =
shlex::try_join(["/bin/zsh", "-lc", r#"python3 -c 'print("Hello, world!")'"#])
.expect("round-trippable command");
assert_eq!(
split_command_string(&command),
vec![
"/bin/zsh".to_string(),
"-lc".to_string(),
r#"python3 -c 'print("Hello, world!")'"#.to_string(),
]
);
}
#[test]
fn split_command_string_preserves_non_roundtrippable_windows_commands() {
let command = r#"C:\Program Files\Git\bin\bash.exe -lc "echo hi""#;
assert_eq!(split_command_string(command), vec![command.to_string()]);
}
}
+293
View File
@@ -39,15 +39,20 @@ use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_line;
use crate::wrapping::adaptive_wrap_lines;
use base64::Engine;
use codex_app_server_protocol::McpServerStatus;
use codex_core::config::Config;
use codex_core::config::types::McpServerTransportConfig;
#[cfg(test)]
use codex_core::mcp::McpManager;
#[cfg(test)]
use codex_core::plugins::PluginsManager;
use codex_core::web_search::web_search_detail;
use codex_otel::RuntimeMetricsSummary;
use codex_protocol::account::PlanType;
use codex_protocol::config_types::ServiceTier;
#[cfg(test)]
use codex_protocol::mcp::Resource;
#[cfg(test)]
use codex_protocol::mcp::ResourceTemplate;
use codex_protocol::models::WebSearchAction;
use codex_protocol::models::local_image_label_text;
@@ -78,6 +83,7 @@ use std::collections::HashMap;
use std::io::Cursor;
use std::path::Path;
use std::path::PathBuf;
#[cfg(test)]
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
@@ -1798,6 +1804,7 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell {
PlainHistoryCell { lines }
}
#[cfg(test)]
/// Render MCP tools grouped by connection using the fully-qualified tool names.
pub(crate) fn new_mcp_tools_output(
config: &Config,
@@ -1965,6 +1972,179 @@ pub(crate) fn new_mcp_tools_output(
PlainHistoryCell { lines }
}
/// Build the `/mcp` history cell from app-server `McpServerStatus` responses.
///
/// The server list comes directly from the app-server status response, sorted
/// alphabetically. Local config is only used to enrich returned servers with
/// transport details such as command, URL, cwd, and environment display.
///
/// This mirrors the layout of [`new_mcp_tools_output`] but sources data from
/// the paginated RPC response rather than the in-process `McpManager`.
pub(crate) fn new_mcp_tools_output_from_statuses(
config: &Config,
statuses: &[McpServerStatus],
) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = vec![
"/mcp".magenta().into(),
"".into(),
vec!["🔌 ".into(), "MCP Tools".bold()].into(),
"".into(),
];
let mut statuses_by_name = HashMap::new();
for status in statuses {
statuses_by_name.insert(status.name.as_str(), status);
}
let mut server_names: Vec<String> = statuses.iter().map(|status| status.name.clone()).collect();
server_names.sort();
let has_any_tools = statuses.iter().any(|status| !status.tools.is_empty());
if !has_any_tools {
lines.push(" • No MCP tools available.".italic().into());
lines.push("".into());
}
for server in server_names {
let cfg = config.mcp_servers.get().get(server.as_str());
let status = statuses_by_name.get(server.as_str()).copied();
let header: Vec<Span<'static>> = vec![" • ".into(), server.clone().into()];
lines.push(header.into());
let auth_status = status
.map(|status| match status.auth_status {
codex_app_server_protocol::McpAuthStatus::Unsupported => McpAuthStatus::Unsupported,
codex_app_server_protocol::McpAuthStatus::NotLoggedIn => McpAuthStatus::NotLoggedIn,
codex_app_server_protocol::McpAuthStatus::BearerToken => McpAuthStatus::BearerToken,
codex_app_server_protocol::McpAuthStatus::OAuth => McpAuthStatus::OAuth,
})
.unwrap_or(McpAuthStatus::Unsupported);
lines.push(vec![" • Auth: ".into(), auth_status.to_string().into()].into());
if let Some(cfg) = cfg {
match &cfg.transport {
McpServerTransportConfig::Stdio {
command,
args,
env,
env_vars,
cwd,
} => {
let args_suffix = if args.is_empty() {
String::new()
} else {
format!(" {}", args.join(" "))
};
let cmd_display = format!("{command}{args_suffix}");
lines.push(vec![" • Command: ".into(), cmd_display.into()].into());
if let Some(cwd) = cwd.as_ref() {
lines.push(
vec![" • Cwd: ".into(), cwd.display().to_string().into()].into(),
);
}
let env_display = format_env_display(env.as_ref(), env_vars.as_slice());
if env_display != "-" {
lines.push(vec![" • Env: ".into(), env_display.into()].into());
}
}
McpServerTransportConfig::StreamableHttp {
url,
http_headers,
env_http_headers,
..
} => {
lines.push(vec![" • URL: ".into(), url.clone().into()].into());
if let Some(headers) = http_headers.as_ref()
&& !headers.is_empty()
{
let mut pairs: Vec<_> = headers.iter().collect();
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
let display = pairs
.into_iter()
.map(|(name, _)| format!("{name}=*****"))
.collect::<Vec<_>>()
.join(", ");
lines.push(vec![" • HTTP headers: ".into(), display.into()].into());
}
if let Some(headers) = env_http_headers.as_ref()
&& !headers.is_empty()
{
let mut pairs: Vec<_> = headers.iter().collect();
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
let display = pairs
.into_iter()
.map(|(name, var)| format!("{name}={var}"))
.collect::<Vec<_>>()
.join(", ");
lines.push(vec![" • Env HTTP headers: ".into(), display.into()].into());
}
}
}
}
let mut names = status
.map(|status| status.tools.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
names.sort();
if names.is_empty() {
lines.push(" • Tools: (none)".into());
} else {
lines.push(vec![" • Tools: ".into(), names.join(", ").into()].into());
}
let server_resources = status
.map(|status| status.resources.clone())
.unwrap_or_default();
if server_resources.is_empty() {
lines.push(" • Resources: (none)".into());
} else {
let mut spans: Vec<Span<'static>> = vec![" • Resources: ".into()];
for (idx, resource) in server_resources.iter().enumerate() {
if idx > 0 {
spans.push(", ".into());
}
let label = resource.title.as_ref().unwrap_or(&resource.name);
spans.push(label.clone().into());
spans.push(" ".into());
spans.push(format!("({})", resource.uri).dim());
}
lines.push(spans.into());
}
let server_templates = status
.map(|status| status.resource_templates.clone())
.unwrap_or_default();
if server_templates.is_empty() {
lines.push(" • Resource templates: (none)".into());
} else {
let mut spans: Vec<Span<'static>> = vec![" • Resource templates: ".into()];
for (idx, template) in server_templates.iter().enumerate() {
if idx > 0 {
spans.push(", ".into());
}
let label = template.title.as_ref().unwrap_or(&template.name);
spans.push(label.clone().into());
spans.push(" ".into());
spans.push(format!("({})", template.uri_template).dim());
}
lines.push(spans.into());
}
lines.push(Line::from(""));
}
PlainHistoryCell { lines }
}
pub(crate) fn new_info_event(message: String, hint: Option<String>) -> PlainHistoryCell {
let mut line = vec!["• ".dim(), message.into()];
if let Some(hint) = hint {
@@ -1983,6 +2163,54 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
PlainHistoryCell { lines }
}
/// A transient history cell that shows an animated spinner while the MCP
/// inventory RPC is in flight.
///
/// Inserted as the `active_cell` by `ChatWidget::add_mcp_output()` and removed
/// once the fetch completes. The app removes committed copies from transcript
/// history, while `ChatWidget::clear_mcp_inventory_loading()` only clears the
/// in-flight `active_cell`.
#[derive(Debug)]
pub(crate) struct McpInventoryLoadingCell {
start_time: Instant,
animations_enabled: bool,
}
impl McpInventoryLoadingCell {
pub(crate) fn new(animations_enabled: bool) -> Self {
Self {
start_time: Instant::now(),
animations_enabled,
}
}
}
impl HistoryCell for McpInventoryLoadingCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
vec![
vec![
spinner(Some(self.start_time), self.animations_enabled),
" ".into(),
"Loading MCP inventory".bold(),
"…".dim(),
]
.into(),
]
}
fn transcript_animation_tick(&self) -> Option<u64> {
if !self.animations_enabled {
return None;
}
Some((self.start_time.elapsed().as_millis() / 50) as u64)
}
}
/// Convenience constructor for [`McpInventoryLoadingCell`].
pub(crate) fn new_mcp_inventory_loading(animations_enabled: bool) -> McpInventoryLoadingCell {
McpInventoryLoadingCell::new(animations_enabled)
}
/// Renders a completed (or interrupted) request_user_input exchange in history.
#[derive(Debug)]
pub(crate) struct RequestUserInputResultCell {
@@ -2544,6 +2772,7 @@ mod tests {
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::types::McpServerConfig;
use codex_core::config::types::McpServerDisabledReason;
use codex_core::config::types::McpServerTransportConfig;
use codex_otel::RuntimeMetricTotals;
use codex_otel::RuntimeMetricsSummary;
@@ -2988,6 +3217,62 @@ mod tests {
insta::assert_snapshot!(rendered);
}
#[tokio::test]
async fn mcp_tools_output_from_statuses_renders_status_only_servers() {
let mut config = test_config().await;
let servers = HashMap::from([(
"plugin_docs".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: vec!["--stdio".to_string()],
env: None,
env_vars: vec![],
cwd: None,
},
enabled: false,
required: false,
disabled_reason: Some(McpServerDisabledReason::Unknown),
startup_timeout_sec: None,
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
},
)]);
config
.mcp_servers
.set(servers)
.expect("test mcp servers should accept any configuration");
let statuses = vec![McpServerStatus {
name: "plugin_docs".to_string(),
tools: HashMap::from([(
"lookup".to_string(),
Tool {
description: None,
name: "lookup".to_string(),
title: None,
input_schema: serde_json::json!({"type": "object", "properties": {}}),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
)]),
resources: Vec::new(),
resource_templates: Vec::new(),
auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported,
}];
let cell = new_mcp_tools_output_from_statuses(&config, &statuses);
let rendered = render_lines(&cell.display_lines(120)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn empty_agent_message_cell_transcript() {
let cell = AgentMessageCell::new(vec![Line::default()], false);
@@ -3215,6 +3500,14 @@ mod tests {
insta::assert_snapshot!(rendered);
}
#[test]
fn mcp_inventory_loading_snapshot() {
let cell = new_mcp_inventory_loading(/*animations_enabled*/ true);
let rendered = render_lines(&cell.display_lines(80)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn completed_mcp_tool_call_success_snapshot() {
let invocation = McpInvocation {
+846 -254
View File
File diff suppressed because it is too large Load Diff
+8 -82
View File
@@ -1,11 +1,8 @@
use clap::Parser;
use codex_arg0::Arg0DispatchPaths;
use codex_arg0::arg0_dispatch_or_else;
use codex_tui::AppExitInfo;
use codex_tui::Cli;
use codex_tui::ExitReason;
use codex_tui::run_main;
use codex_tui::update_action::UpdateAction;
use codex_utils_cli::CliConfigOverrides;
#[derive(Parser, Debug)]
@@ -17,65 +14,6 @@ struct TopCli {
inner: Cli,
}
fn into_app_server_cli(cli: Cli) -> codex_tui_app_server::Cli {
codex_tui_app_server::Cli {
prompt: cli.prompt,
images: cli.images,
resume_picker: cli.resume_picker,
resume_last: cli.resume_last,
resume_session_id: cli.resume_session_id,
resume_show_all: cli.resume_show_all,
fork_picker: cli.fork_picker,
fork_last: cli.fork_last,
fork_session_id: cli.fork_session_id,
fork_show_all: cli.fork_show_all,
model: cli.model,
oss: cli.oss,
oss_provider: cli.oss_provider,
config_profile: cli.config_profile,
sandbox_mode: cli.sandbox_mode,
approval_policy: cli.approval_policy,
full_auto: cli.full_auto,
dangerously_bypass_approvals_and_sandbox: cli.dangerously_bypass_approvals_and_sandbox,
cwd: cli.cwd,
web_search: cli.web_search,
add_dir: cli.add_dir,
no_alt_screen: cli.no_alt_screen,
config_overrides: cli.config_overrides,
}
}
fn into_legacy_update_action(
action: codex_tui_app_server::update_action::UpdateAction,
) -> UpdateAction {
match action {
codex_tui_app_server::update_action::UpdateAction::NpmGlobalLatest => {
UpdateAction::NpmGlobalLatest
}
codex_tui_app_server::update_action::UpdateAction::BunGlobalLatest => {
UpdateAction::BunGlobalLatest
}
codex_tui_app_server::update_action::UpdateAction::BrewUpgrade => UpdateAction::BrewUpgrade,
}
}
fn into_legacy_exit_reason(reason: codex_tui_app_server::ExitReason) -> ExitReason {
match reason {
codex_tui_app_server::ExitReason::UserRequested => ExitReason::UserRequested,
codex_tui_app_server::ExitReason::Fatal(message) => ExitReason::Fatal(message),
}
}
fn into_legacy_exit_info(exit_info: codex_tui_app_server::AppExitInfo) -> AppExitInfo {
AppExitInfo {
token_usage: exit_info.token_usage,
thread_id: exit_info.thread_id,
thread_name: exit_info.thread_name,
update_action: exit_info.update_action.map(into_legacy_update_action),
exit_reason: into_legacy_exit_reason(exit_info.exit_reason),
}
}
fn main() -> anyhow::Result<()> {
arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move {
let top_cli = TopCli::parse();
@@ -84,26 +22,14 @@ fn main() -> anyhow::Result<()> {
.config_overrides
.raw_overrides
.splice(0..0, top_cli.config_overrides.raw_overrides);
let use_app_server_tui = codex_tui::should_use_app_server_tui(&inner).await?;
let exit_info = if use_app_server_tui {
into_legacy_exit_info(
codex_tui_app_server::run_main(
into_app_server_cli(inner),
arg0_paths,
codex_core::config_loader::LoaderOverrides::default(),
/*remote*/ None,
/*remote_auth_token*/ None,
)
.await?,
)
} else {
run_main(
inner,
arg0_paths,
codex_core::config_loader::LoaderOverrides::default(),
)
.await?
};
let exit_info = run_main(
inner,
arg0_paths,
codex_core::config_loader::LoaderOverrides::default(),
/*remote*/ None,
/*remote_auth_token*/ None,
)
.await?;
let token_usage = exit_info.token_usage;
if !token_usage.is_zero() {
println!(
+1
View File
@@ -16,6 +16,7 @@ pub(crate) struct DecodedHistoryText {
pub(crate) mentions: Vec<LinkedMention>,
}
#[allow(dead_code)]
pub(crate) fn encode_history_mentions(text: &str, mentions: &[LinkedMention]) -> String {
if mentions.is_empty() || text.is_empty() {
return text.to_string();
+2 -2
View File
@@ -541,12 +541,12 @@ fn status_summary_line(status: &AgentStatus) -> Line<'static> {
status_summary_spans(status).into()
}
// Allow `.yellow()`
#[allow(clippy::disallowed_methods)]
fn status_summary_spans(status: &AgentStatus) -> Vec<Span<'static>> {
match status {
AgentStatus::PendingInit => vec![Span::from("Pending init").cyan()],
AgentStatus::Running => vec![Span::from("Running").cyan().bold()],
// Allow `.yellow()`
#[allow(clippy::disallowed_methods)]
AgentStatus::Interrupted => vec![Span::from("Interrupted").yellow()],
AgentStatus::Completed(message) => {
let mut spans = vec![Span::from("Completed").green()];
+301 -142
View File
@@ -1,14 +1,16 @@
#![allow(clippy::unwrap_used)]
use codex_core::AuthManager;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_protocol::AccountLoginCompletedNotification;
use codex_app_server_protocol::AccountUpdatedNotification;
use codex_app_server_protocol::AuthMode as AppServerAuthMode;
use codex_app_server_protocol::CancelLoginAccountParams;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::LoginAccountParams;
use codex_app_server_protocol::LoginAccountResponse;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::CLIENT_ID;
use codex_core::auth::login_with_api_key;
use codex_core::auth::read_openai_api_key_from_env;
use codex_login::DeviceCode;
use codex_login::ServerOptions;
use codex_login::ShutdownHandle;
use codex_login::run_login_server;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
@@ -30,9 +32,10 @@ use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use codex_core::auth::AuthMode;
use codex_protocol::config_types::ForcedLoginMethod;
use std::sync::Arc;
use std::sync::RwLock;
use uuid::Uuid;
use crate::LoginStatus;
use crate::onboarding::onboarding_screen::KeyboardHandler;
@@ -75,7 +78,6 @@ pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
}
}
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Notify;
use super::onboarding_screen::StepState;
@@ -86,6 +88,7 @@ mod headless_chatgpt_login;
pub(crate) enum SignInState {
PickMode,
ChatGptContinueInBrowser(ContinueInBrowserState),
#[allow(dead_code)]
ChatGptDeviceCode(ContinueWithDeviceCodeState),
ChatGptSuccessMessage,
ChatGptSuccess,
@@ -101,6 +104,9 @@ pub(crate) enum SignInOption {
}
const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled.";
fn onboarding_request_id() -> codex_app_server_protocol::RequestId {
codex_app_server_protocol::RequestId::String(Uuid::new_v4().to_string())
}
#[derive(Clone, Default)]
pub(crate) struct ApiKeyInputState {
@@ -111,8 +117,8 @@ pub(crate) struct ApiKeyInputState {
#[derive(Clone)]
/// Used to manage the lifecycle of SpawnedLogin and ensure it gets cleaned up.
pub(crate) struct ContinueInBrowserState {
login_id: String,
auth_url: String,
shutdown_flag: Option<ShutdownHandle>,
}
#[derive(Clone)]
@@ -121,14 +127,6 @@ pub(crate) struct ContinueWithDeviceCodeState {
cancel: Option<Arc<Notify>>,
}
impl Drop for ContinueInBrowserState {
fn drop(&mut self) {
if let Some(handle) = &self.shutdown_flag {
handle.shutdown();
}
}
}
impl KeyboardHandler for AuthModeWidget {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if self.handle_api_key_entry_key_event(&key_event) {
@@ -165,23 +163,7 @@ impl KeyboardHandler for AuthModeWidget {
}
KeyCode::Esc => {
tracing::info!("Esc pressed");
let mut sign_in_state = self.sign_in_state.write().unwrap();
match &*sign_in_state {
SignInState::ChatGptContinueInBrowser(_) => {
*sign_in_state = SignInState::PickMode;
drop(sign_in_state);
self.request_frame.schedule_frame();
}
SignInState::ChatGptDeviceCode(state) => {
if let Some(cancel) = &state.cancel {
cancel.notify_one();
}
*sign_in_state = SignInState::PickMode;
drop(sign_in_state);
self.request_frame.schedule_frame();
}
_ => {}
}
self.cancel_active_attempt();
}
_ => {}
}
@@ -193,21 +175,60 @@ impl KeyboardHandler for AuthModeWidget {
}
#[derive(Clone)]
#[allow(dead_code)]
pub(crate) struct AuthModeWidget {
pub request_frame: FrameRequester,
pub highlighted_mode: SignInOption,
pub error: Option<String>,
pub error: Arc<RwLock<Option<String>>>,
pub sign_in_state: Arc<RwLock<SignInState>>,
pub codex_home: PathBuf,
pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
pub login_status: LoginStatus,
pub auth_manager: Arc<AuthManager>,
pub app_server_request_handle: AppServerRequestHandle,
pub forced_chatgpt_workspace_id: Option<String>,
pub forced_login_method: Option<ForcedLoginMethod>,
pub animations_enabled: bool,
}
impl AuthModeWidget {
pub(crate) fn cancel_active_attempt(&self) {
let mut sign_in_state = self.sign_in_state.write().unwrap();
match &*sign_in_state {
SignInState::ChatGptContinueInBrowser(state) => {
let request_handle = self.app_server_request_handle.clone();
let login_id = state.login_id.clone();
tokio::spawn(async move {
let _ = request_handle
.request_typed::<codex_app_server_protocol::CancelLoginAccountResponse>(
ClientRequest::CancelLoginAccount {
request_id: onboarding_request_id(),
params: CancelLoginAccountParams { login_id },
},
)
.await;
});
}
SignInState::ChatGptDeviceCode(state) => {
if let Some(cancel) = &state.cancel {
cancel.notify_one();
}
}
_ => return,
}
*sign_in_state = SignInState::PickMode;
drop(sign_in_state);
self.set_error(/*message*/ None);
self.request_frame.schedule_frame();
}
fn set_error(&self, message: Option<String>) {
*self.error.write().unwrap() = message;
}
fn error_message(&self) -> Option<String> {
self.error.read().unwrap().clone()
}
fn is_api_login_allowed(&self) -> bool {
!matches!(self.forced_login_method, Some(ForcedLoginMethod::Chatgpt))
}
@@ -285,7 +306,7 @@ impl AuthModeWidget {
fn disallow_api_login(&mut self) {
self.highlighted_mode = SignInOption::ChatGpt;
self.error = Some(API_KEY_DISABLED_MESSAGE.to_string());
self.set_error(Some(API_KEY_DISABLED_MESSAGE.to_string()));
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
self.request_frame.schedule_frame();
}
@@ -382,9 +403,9 @@ impl AuthModeWidget {
// But leaving this for a future cleanup.
" Press Enter to continue".dim().into(),
);
if let Some(err) = &self.error {
if let Some(err) = self.error_message() {
lines.push("".into());
lines.push(err.as_str().red().into());
lines.push(err.red().into());
}
Paragraph::new(lines)
@@ -543,9 +564,9 @@ impl AuthModeWidget {
" Press Enter to save".dim().into(),
" Press Esc to go back".dim().into(),
];
if let Some(error) = &self.error {
if let Some(error) = self.error_message() {
footer_lines.push("".into());
footer_lines.push(error.as_str().red().into());
footer_lines.push(error.red().into());
}
Paragraph::new(footer_lines)
.wrap(Wrap { trim: false })
@@ -562,13 +583,13 @@ impl AuthModeWidget {
match key_event.code {
KeyCode::Esc => {
*guard = SignInState::PickMode;
self.error = None;
self.set_error(/*message*/ None);
should_request_frame = true;
}
KeyCode::Enter => {
let trimmed = state.value.trim().to_string();
if trimmed.is_empty() {
self.error = Some("API key cannot be empty".to_string());
self.set_error(Some("API key cannot be empty".to_string()));
should_request_frame = true;
} else {
should_save = Some(trimmed);
@@ -581,7 +602,7 @@ impl AuthModeWidget {
} else {
state.value.pop();
}
self.error = None;
self.set_error(/*message*/ None);
should_request_frame = true;
}
KeyCode::Char(c)
@@ -595,7 +616,7 @@ impl AuthModeWidget {
state.prepopulated_from_env = false;
}
state.value.push(c);
self.error = None;
self.set_error(/*message*/ None);
should_request_frame = true;
}
_ => {}
@@ -628,7 +649,7 @@ impl AuthModeWidget {
} else {
state.value.push_str(trimmed);
}
self.error = None;
self.set_error(/*message*/ None);
} else {
return false;
}
@@ -643,7 +664,7 @@ impl AuthModeWidget {
self.disallow_api_login();
return;
}
self.error = None;
self.set_error(/*message*/ None);
let prefill_from_env = read_openai_api_key_from_env();
let mut guard = self.sign_in_state.write().unwrap();
match &mut *guard {
@@ -673,39 +694,53 @@ impl AuthModeWidget {
self.disallow_api_login();
return;
}
match login_with_api_key(
&self.codex_home,
&api_key,
self.cli_auth_credentials_store_mode,
) {
Ok(()) => {
self.error = None;
self.login_status = LoginStatus::AuthMode(AuthMode::ApiKey);
self.auth_manager.reload();
*self.sign_in_state.write().unwrap() = SignInState::ApiKeyConfigured;
}
Err(err) => {
self.error = Some(format!("Failed to save API key: {err}"));
let mut guard = self.sign_in_state.write().unwrap();
if let SignInState::ApiKeyEntry(existing) = &mut *guard {
if existing.value.is_empty() {
existing.value.push_str(&api_key);
}
existing.prepopulated_from_env = false;
} else {
*guard = SignInState::ApiKeyEntry(ApiKeyInputState {
self.set_error(/*message*/ None);
let request_handle = self.app_server_request_handle.clone();
let sign_in_state = self.sign_in_state.clone();
let error = self.error.clone();
let request_frame = self.request_frame.clone();
tokio::spawn(async move {
match request_handle
.request_typed::<LoginAccountResponse>(ClientRequest::LoginAccount {
request_id: onboarding_request_id(),
params: LoginAccountParams::ApiKey {
api_key: api_key.clone(),
},
})
.await
{
Ok(LoginAccountResponse::ApiKey {}) => {
*error.write().unwrap() = None;
*sign_in_state.write().unwrap() = SignInState::ApiKeyConfigured;
}
Ok(other) => {
*error.write().unwrap() = Some(format!(
"Unexpected account/login/start response: {other:?}"
));
*sign_in_state.write().unwrap() = SignInState::ApiKeyEntry(ApiKeyInputState {
value: api_key,
prepopulated_from_env: false,
});
}
Err(err) => {
*error.write().unwrap() = Some(format!("Failed to save API key: {err}"));
*sign_in_state.write().unwrap() = SignInState::ApiKeyEntry(ApiKeyInputState {
value: api_key,
prepopulated_from_env: false,
});
}
}
}
request_frame.schedule_frame();
});
self.request_frame.schedule_frame();
}
fn handle_existing_chatgpt_login(&mut self) -> bool {
if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::Chatgpt)) {
if matches!(
self.login_status,
LoginStatus::AuthMode(AppServerAuthMode::Chatgpt)
| LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens)
) {
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
self.request_frame.schedule_frame();
true
@@ -722,52 +757,41 @@ impl AuthModeWidget {
return;
}
self.error = None;
let opts = ServerOptions::new(
self.codex_home.clone(),
CLIENT_ID.to_string(),
self.forced_chatgpt_workspace_id.clone(),
self.cli_auth_credentials_store_mode,
);
match run_login_server(opts) {
Ok(child) => {
let sign_in_state = self.sign_in_state.clone();
let request_frame = self.request_frame.clone();
let auth_manager = self.auth_manager.clone();
tokio::spawn(async move {
let auth_url = child.auth_url.clone();
{
*sign_in_state.write().unwrap() =
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
auth_url,
shutdown_flag: Some(child.cancel_handle()),
});
}
request_frame.schedule_frame();
let r = child.block_until_done().await;
match r {
Ok(()) => {
// Force the auth manager to reload the new auth information.
auth_manager.reload();
*sign_in_state.write().unwrap() = SignInState::ChatGptSuccessMessage;
request_frame.schedule_frame();
}
_ => {
*sign_in_state.write().unwrap() = SignInState::PickMode;
// self.error = Some(e.to_string());
request_frame.schedule_frame();
}
}
});
self.set_error(/*message*/ None);
let request_handle = self.app_server_request_handle.clone();
let sign_in_state = self.sign_in_state.clone();
let error = self.error.clone();
let request_frame = self.request_frame.clone();
tokio::spawn(async move {
match request_handle
.request_typed::<LoginAccountResponse>(ClientRequest::LoginAccount {
request_id: onboarding_request_id(),
params: LoginAccountParams::Chatgpt,
})
.await
{
Ok(LoginAccountResponse::Chatgpt { login_id, auth_url }) => {
maybe_open_auth_url_in_browser(&request_handle, &auth_url);
*error.write().unwrap() = None;
*sign_in_state.write().unwrap() =
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
login_id,
auth_url,
});
}
Ok(other) => {
*sign_in_state.write().unwrap() = SignInState::PickMode;
*error.write().unwrap() = Some(format!(
"Unexpected account/login/start response: {other:?}"
));
}
Err(err) => {
*sign_in_state.write().unwrap() = SignInState::PickMode;
*error.write().unwrap() = Some(err.to_string());
}
}
Err(e) => {
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
self.error = Some(e.to_string());
self.request_frame.schedule_frame();
}
}
request_frame.schedule_frame();
});
}
fn start_device_code_login(&mut self) {
@@ -775,14 +799,42 @@ impl AuthModeWidget {
return;
}
self.error = None;
let opts = ServerOptions::new(
self.codex_home.clone(),
CLIENT_ID.to_string(),
self.forced_chatgpt_workspace_id.clone(),
self.cli_auth_credentials_store_mode,
self.set_error(/*message*/ None);
headless_chatgpt_login::start_headless_chatgpt_login(self);
}
pub(crate) fn on_account_login_completed(
&mut self,
notification: AccountLoginCompletedNotification,
) {
let Some(login_id) = notification.login_id else {
return;
};
let guard = self.sign_in_state.read().unwrap();
let is_matching_login = matches!(
&*guard,
SignInState::ChatGptContinueInBrowser(state) if state.login_id == login_id
);
headless_chatgpt_login::start_headless_chatgpt_login(self, opts);
drop(guard);
if !is_matching_login {
return;
}
if notification.success {
self.set_error(/*message*/ None);
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccessMessage;
} else {
self.set_error(notification.error);
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
}
self.request_frame.schedule_frame();
}
pub(crate) fn on_account_updated(&mut self, notification: AccountUpdatedNotification) {
self.login_status = notification
.auth_mode
.map(LoginStatus::AuthMode)
.unwrap_or(LoginStatus::NotAuthenticated);
}
}
@@ -829,30 +881,72 @@ impl WidgetRef for AuthModeWidget {
}
}
pub(super) fn maybe_open_auth_url_in_browser(request_handle: &AppServerRequestHandle, url: &str) {
if !matches!(request_handle, AppServerRequestHandle::InProcess(_)) {
return;
}
if let Err(err) = webbrowser::open(url) {
tracing::warn!("failed to open browser for login URL: {err}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY;
use codex_app_server_client::InProcessAppServerClient;
use codex_app_server_client::InProcessClientStartArgs;
use codex_arg0::Arg0DispatchPaths;
use codex_cloud_requirements::cloud_requirements_loader_for_storage;
use codex_core::config::ConfigBuilder;
use codex_protocol::protocol::SessionSource;
use pretty_assertions::assert_eq;
use std::sync::Arc;
use tempfile::TempDir;
use codex_core::auth::AuthCredentialsStoreMode;
fn widget_forced_chatgpt() -> (AuthModeWidget, TempDir) {
async fn widget_forced_chatgpt() -> (AuthModeWidget, TempDir) {
let codex_home = TempDir::new().unwrap();
let codex_home_path = codex_home.path().to_path_buf();
let config = ConfigBuilder::default()
.codex_home(codex_home_path.clone())
.build()
.await
.unwrap();
let client = InProcessAppServerClient::start(InProcessClientStartArgs {
arg0_paths: Arg0DispatchPaths::default(),
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides: Default::default(),
cloud_requirements: cloud_requirements_loader_for_storage(
codex_home_path.clone(),
false,
AuthCredentialsStoreMode::File,
"https://chatgpt.com/backend-api/".to_string(),
),
feedback: codex_feedback::CodexFeedback::new(),
config_warnings: Vec::new(),
session_source: SessionSource::Cli,
enable_codex_api_key_env: false,
client_name: "test".to_string(),
client_version: "test".to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),
channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
})
.await
.unwrap();
let widget = AuthModeWidget {
request_frame: FrameRequester::test_dummy(),
highlighted_mode: SignInOption::ChatGpt,
error: None,
error: Arc::new(RwLock::new(None)),
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
codex_home: codex_home_path.clone(),
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
login_status: LoginStatus::NotAuthenticated,
auth_manager: AuthManager::shared(
codex_home_path,
false,
AuthCredentialsStoreMode::File,
),
app_server_request_handle: AppServerRequestHandle::InProcess(client.request_handle()),
forced_chatgpt_workspace_id: None,
forced_login_method: Some(ForcedLoginMethod::Chatgpt),
animations_enabled: true,
@@ -860,26 +954,32 @@ mod tests {
(widget, codex_home)
}
#[test]
fn api_key_flow_disabled_when_chatgpt_forced() {
let (mut widget, _tmp) = widget_forced_chatgpt();
#[tokio::test]
async fn api_key_flow_disabled_when_chatgpt_forced() {
let (mut widget, _tmp) = widget_forced_chatgpt().await;
widget.start_api_key_entry();
assert_eq!(widget.error.as_deref(), Some(API_KEY_DISABLED_MESSAGE));
assert_eq!(
widget.error_message().as_deref(),
Some(API_KEY_DISABLED_MESSAGE)
);
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::PickMode
));
}
#[test]
fn saving_api_key_is_blocked_when_chatgpt_forced() {
let (mut widget, _tmp) = widget_forced_chatgpt();
#[tokio::test]
async fn saving_api_key_is_blocked_when_chatgpt_forced() {
let (mut widget, _tmp) = widget_forced_chatgpt().await;
widget.save_api_key("sk-test".to_string());
assert_eq!(widget.error.as_deref(), Some(API_KEY_DISABLED_MESSAGE));
assert_eq!(
widget.error_message().as_deref(),
Some(API_KEY_DISABLED_MESSAGE)
);
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::PickMode
@@ -887,6 +987,64 @@ mod tests {
assert_eq!(widget.login_status, LoginStatus::NotAuthenticated);
}
#[tokio::test]
async fn existing_chatgpt_auth_tokens_login_counts_as_signed_in() {
let (mut widget, _tmp) = widget_forced_chatgpt().await;
widget.login_status = LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens);
let handled = widget.handle_existing_chatgpt_login();
assert_eq!(handled, true);
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::ChatGptSuccess
));
}
#[tokio::test]
async fn cancel_active_attempt_resets_browser_login_state() {
let (widget, _tmp) = widget_forced_chatgpt().await;
*widget.error.write().unwrap() = Some("still logging in".to_string());
*widget.sign_in_state.write().unwrap() =
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
login_id: "login-1".to_string(),
auth_url: "https://auth.example.com".to_string(),
});
widget.cancel_active_attempt();
assert_eq!(widget.error_message(), None);
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::PickMode
));
}
#[tokio::test]
async fn cancel_active_attempt_notifies_device_code_login() {
let (widget, _tmp) = widget_forced_chatgpt().await;
let cancel = Arc::new(Notify::new());
*widget.error.write().unwrap() = Some("still logging in".to_string());
*widget.sign_in_state.write().unwrap() =
SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState {
device_code: None,
cancel: Some(cancel.clone()),
});
widget.cancel_active_attempt();
assert_eq!(widget.error_message(), None);
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::PickMode
));
assert!(
tokio::time::timeout(std::time::Duration::from_millis(50), cancel.notified())
.await
.is_ok()
);
}
/// Collects all buffer cell symbols that contain the OSC 8 open sequence
/// for the given URL. Returns the concatenated "inner" characters.
fn collect_osc8_chars(buf: &Buffer, area: Rect, url: &str) -> String {
@@ -908,12 +1066,13 @@ mod tests {
#[test]
fn continue_in_browser_renders_osc8_hyperlink() {
let (widget, _tmp) = widget_forced_chatgpt();
let runtime = tokio::runtime::Runtime::new().unwrap();
let (widget, _tmp) = runtime.block_on(widget_forced_chatgpt());
let url = "https://auth.example.com/login?state=abc123";
*widget.sign_in_state.write().unwrap() =
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
login_id: "login-1".to_string(),
auth_url: url.to_string(),
shutdown_flag: None,
});
// Render into a narrow buffer so the URL wraps across multiple rows.
@@ -1,8 +1,12 @@
use codex_core::AuthManager;
#![allow(dead_code)]
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::LoginAccountParams;
use codex_app_server_protocol::LoginAccountResponse;
use codex_core::auth::CLIENT_ID;
use codex_login::ServerOptions;
use codex_login::complete_device_code_login;
use codex_login::request_device_code;
use codex_login::run_login_server;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
@@ -10,10 +14,13 @@ 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::local_chatgpt_auth::LocalChatgptAuth;
use crate::local_chatgpt_auth::load_local_chatgpt_auth;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
@@ -22,12 +29,25 @@ use super::ContinueInBrowserState;
use super::ContinueWithDeviceCodeState;
use super::SignInState;
use super::mark_url_hyperlink;
use super::maybe_open_auth_url_in_browser;
use super::onboarding_request_id;
pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget, mut opts: ServerOptions) {
pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget) {
let mut opts = ServerOptions::new(
widget.codex_home.clone(),
CLIENT_ID.to_string(),
widget.forced_chatgpt_workspace_id.clone(),
widget.cli_auth_credentials_store_mode,
);
opts.open_browser = false;
let sign_in_state = widget.sign_in_state.clone();
let request_frame = widget.request_frame.clone();
let auth_manager = widget.auth_manager.clone();
let error = widget.error.clone();
let request_handle = widget.app_server_request_handle.clone();
let codex_home = widget.codex_home.clone();
let cli_auth_credentials_store_mode = widget.cli_auth_credentials_store_mode;
let forced_chatgpt_workspace_id = widget.forced_chatgpt_workspace_id.clone();
let cancel = begin_device_code_attempt(&sign_in_state, &request_frame);
tokio::spawn(async move {
@@ -35,58 +55,23 @@ pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget, mut opts
Ok(device_code) => device_code,
Err(err) => {
if err.kind() == std::io::ErrorKind::NotFound {
let should_fallback = {
let guard = sign_in_state.read().unwrap();
device_code_attempt_matches(&guard, &cancel)
};
if !should_fallback {
return;
}
match run_login_server(opts) {
Ok(child) => {
let auth_url = child.auth_url.clone();
{
*sign_in_state.write().unwrap() =
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
auth_url,
shutdown_flag: Some(child.cancel_handle()),
});
}
request_frame.schedule_frame();
let r = child.block_until_done().await;
match r {
Ok(()) => {
auth_manager.reload();
*sign_in_state.write().unwrap() =
SignInState::ChatGptSuccessMessage;
request_frame.schedule_frame();
}
_ => {
*sign_in_state.write().unwrap() = SignInState::PickMode;
request_frame.schedule_frame();
}
}
}
Err(_) => {
set_device_code_state_for_active_attempt(
&sign_in_state,
&request_frame,
&cancel,
SignInState::PickMode,
);
}
}
fallback_to_browser_login(
request_handle,
sign_in_state,
request_frame,
error,
cancel,
)
.await;
} else {
set_device_code_state_for_active_attempt(
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
SignInState::PickMode,
err.to_string(),
);
}
return;
}
};
@@ -105,22 +90,30 @@ pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget, mut opts
tokio::select! {
_ = cancel.notified() => {}
r = complete_device_code_login(opts, device_code) => {
match r {
result = complete_device_code_login(opts, device_code) => {
match result {
Ok(()) => {
set_device_code_success_message_for_active_attempt(
&sign_in_state,
&request_frame,
&auth_manager,
&cancel,
let local_auth = load_local_chatgpt_auth(
&codex_home,
cli_auth_credentials_store_mode,
forced_chatgpt_workspace_id.as_deref(),
);
handle_chatgpt_auth_tokens_login_result_for_active_attempt(
request_handle,
sign_in_state,
request_frame,
error,
cancel,
local_auth,
).await;
}
Err(_) => {
set_device_code_state_for_active_attempt(
Err(err) => {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
SignInState::PickMode,
err.to_string(),
);
}
}
@@ -241,7 +234,6 @@ fn set_device_code_state_for_active_attempt(
fn set_device_code_success_message_for_active_attempt(
sign_in_state: &Arc<RwLock<SignInState>>,
request_frame: &FrameRequester,
auth_manager: &AuthManager,
cancel: &Arc<Notify>,
) -> bool {
let mut guard = sign_in_state.write().unwrap();
@@ -249,19 +241,171 @@ fn set_device_code_success_message_for_active_attempt(
return false;
}
auth_manager.reload();
*guard = SignInState::ChatGptSuccessMessage;
drop(guard);
request_frame.schedule_frame();
true
}
fn set_device_code_error_for_active_attempt(
sign_in_state: &Arc<RwLock<SignInState>>,
request_frame: &FrameRequester,
error: &Arc<RwLock<Option<String>>>,
cancel: &Arc<Notify>,
message: String,
) -> bool {
if !set_device_code_state_for_active_attempt(
sign_in_state,
request_frame,
cancel,
SignInState::PickMode,
) {
return false;
}
*error.write().unwrap() = Some(message);
request_frame.schedule_frame();
true
}
async fn fallback_to_browser_login(
request_handle: codex_app_server_client::AppServerRequestHandle,
sign_in_state: Arc<RwLock<SignInState>>,
request_frame: FrameRequester,
error: Arc<RwLock<Option<String>>>,
cancel: Arc<Notify>,
) {
let should_fallback = {
let guard = sign_in_state.read().unwrap();
device_code_attempt_matches(&guard, &cancel)
};
if !should_fallback {
return;
}
match request_handle
.request_typed::<LoginAccountResponse>(ClientRequest::LoginAccount {
request_id: onboarding_request_id(),
params: LoginAccountParams::Chatgpt,
})
.await
{
Ok(LoginAccountResponse::Chatgpt { login_id, auth_url }) => {
maybe_open_auth_url_in_browser(&request_handle, &auth_url);
*error.write().unwrap() = None;
let _updated = set_device_code_state_for_active_attempt(
&sign_in_state,
&request_frame,
&cancel,
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
login_id,
auth_url,
}),
);
}
Ok(other) => {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
format!("Unexpected account/login/start response: {other:?}"),
);
}
Err(err) => {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
err.to_string(),
);
}
}
}
async fn handle_chatgpt_auth_tokens_login_result_for_active_attempt(
request_handle: codex_app_server_client::AppServerRequestHandle,
sign_in_state: Arc<RwLock<SignInState>>,
request_frame: FrameRequester,
error: Arc<RwLock<Option<String>>>,
cancel: Arc<Notify>,
local_auth: Result<LocalChatgptAuth, String>,
) {
let local_auth = match local_auth {
Ok(local_auth) => local_auth,
Err(err) => {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
err,
);
return;
}
};
let result = request_handle
.request_typed::<LoginAccountResponse>(ClientRequest::LoginAccount {
request_id: onboarding_request_id(),
params: LoginAccountParams::ChatgptAuthTokens {
access_token: local_auth.access_token,
chatgpt_account_id: local_auth.chatgpt_account_id,
chatgpt_plan_type: local_auth.chatgpt_plan_type,
},
})
.await;
apply_chatgpt_auth_tokens_login_response_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
result.map_err(|err| err.to_string()),
);
}
fn apply_chatgpt_auth_tokens_login_response_for_active_attempt(
sign_in_state: &Arc<RwLock<SignInState>>,
request_frame: &FrameRequester,
error: &Arc<RwLock<Option<String>>>,
cancel: &Arc<Notify>,
result: Result<LoginAccountResponse, String>,
) {
match result {
Ok(LoginAccountResponse::ChatgptAuthTokens {}) => {
*error.write().unwrap() = None;
let _updated = set_device_code_success_message_for_active_attempt(
sign_in_state,
request_frame,
cancel,
);
}
Ok(other) => {
set_device_code_error_for_active_attempt(
sign_in_state,
request_frame,
error,
cancel,
format!("Unexpected account/login/start response: {other:?}"),
);
}
Err(err) => {
set_device_code_error_for_active_attempt(
sign_in_state,
request_frame,
error,
cancel,
err,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_core::auth::AuthCredentialsStoreMode;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn device_code_sign_in_state(cancel: Arc<Notify>) -> Arc<RwLock<SignInState>> {
Arc::new(RwLock::new(SignInState::ChatGptDeviceCode(
@@ -348,18 +492,10 @@ mod tests {
let request_frame = FrameRequester::test_dummy();
let cancel = Arc::new(Notify::new());
let sign_in_state = device_code_sign_in_state(cancel.clone());
let temp_dir = TempDir::new().unwrap();
let auth_manager = AuthManager::shared(
temp_dir.path().to_path_buf(),
false,
AuthCredentialsStoreMode::File,
);
assert_eq!(
set_device_code_success_message_for_active_attempt(
&sign_in_state,
&request_frame,
&auth_manager,
&cancel,
),
true
@@ -374,7 +510,6 @@ mod tests {
set_device_code_success_message_for_active_attempt(
&sign_in_state,
&request_frame,
&auth_manager,
&cancel,
),
false
@@ -384,4 +519,30 @@ mod tests {
SignInState::ChatGptDeviceCode(_)
));
}
#[test]
fn chatgpt_auth_tokens_success_sets_success_message_without_login_id() {
let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new()));
let request_frame = FrameRequester::test_dummy();
let error = Arc::new(RwLock::new(None));
let cancel = match &*sign_in_state.read().unwrap() {
SignInState::ChatGptDeviceCode(state) => {
state.cancel.as_ref().expect("cancel handle").clone()
}
_ => panic!("expected device-code state"),
};
apply_chatgpt_auth_tokens_login_response_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
Ok(LoginAccountResponse::ChatgptAuthTokens {}),
);
assert!(matches!(
&*sign_in_state.read().unwrap(),
SignInState::ChatGptSuccessMessage
));
}
}
+121 -55
View File
@@ -1,4 +1,6 @@
use codex_core::AuthManager;
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;
@@ -17,6 +19,7 @@ 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;
@@ -64,7 +67,7 @@ pub(crate) struct OnboardingScreenArgs {
pub show_trust_screen: bool,
pub show_login_screen: bool,
pub login_status: LoginStatus,
pub auth_manager: Arc<AuthManager>,
pub app_server_request_handle: Option<AppServerRequestHandle>,
pub config: Config,
}
@@ -79,7 +82,7 @@ impl OnboardingScreen {
show_trust_screen,
show_login_screen,
login_status,
auth_manager,
app_server_request_handle,
config,
} = args;
let cwd = config.cwd.to_path_buf();
@@ -98,19 +101,23 @@ impl OnboardingScreen {
Some(ForcedLoginMethod::Api) => SignInOption::ApiKey,
_ => SignInOption::ChatGpt,
};
steps.push(Step::Auth(AuthModeWidget {
request_frame: tui.frame_requester(),
highlighted_mode,
error: None,
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
codex_home: codex_home.clone(),
cli_auth_credentials_store_mode,
login_status,
auth_manager,
forced_chatgpt_workspace_id,
forced_login_method,
animations_enabled: config.animations,
}))
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 =
@@ -199,6 +206,37 @@ impl OnboardingScreen {
self.should_exit
}
fn cancel_auth_if_active(&self) {
for step in &self.steps {
if let Step::Auth(widget) = step {
widget.cancel_active_attempt();
}
}
}
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 {
@@ -240,6 +278,7 @@ impl KeyboardHandler for OnboardingScreen {
};
if should_quit {
if self.is_auth_in_progress() {
self.cancel_auth_if_active();
// 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;
@@ -394,6 +433,7 @@ impl WidgetRef for Step {
pub(crate) async fn run_onboarding_app(
args: OnboardingScreenArgs,
mut app_server: Option<AppServerSession>,
tui: &mut Tui,
) -> Result<OnboardingResult> {
use tokio_stream::StreamExt;
@@ -410,52 +450,78 @@ pub(crate) async fn run_onboarding_app(
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)
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
}
})
} 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;
}
})
{
// 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::ServerRequest(_) => {}
}
let _ = tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&onboarding_screen, frame.area());
});
}
}
}
}
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(),
@@ -1,6 +1,5 @@
---
source: tui/src/onboarding/trust_directory.rs
assertion_line: 218
expression: terminal.backend()
---
> You are in /workspace/project
+475 -114
View File
@@ -4,6 +4,7 @@ use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use crate::app_server_session::AppServerSession;
use crate::diff_render::display_path_for;
use crate::key_hint;
use crate::text_formatting::truncate_text;
@@ -12,6 +13,10 @@ use crate::tui::Tui;
use crate::tui::TuiEvent;
use chrono::DateTime;
use chrono::Utc;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadSortKey as AppServerThreadSortKey;
use codex_app_server_protocol::ThreadSourceKind;
use codex_core::Cursor;
use codex_core::INTERACTIVE_SESSION_SOURCES;
use codex_core::RolloutRecorder;
@@ -35,6 +40,7 @@ use ratatui::text::Span;
use tokio::sync::mpsc;
use tokio_stream::StreamExt;
use tokio_stream::wrappers::UnboundedReceiverStream;
use tracing::warn;
use unicode_width::UnicodeWidthStr;
const PAGE_SIZE: usize = 25;
@@ -42,10 +48,19 @@ const LOAD_NEAR_THRESHOLD: usize = 5;
#[derive(Debug, Clone)]
pub struct SessionTarget {
pub path: PathBuf,
pub path: Option<PathBuf>,
pub thread_id: ThreadId,
}
impl SessionTarget {
pub fn display_label(&self) -> String {
self.path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| format!("thread {}", self.thread_id))
}
}
#[derive(Debug, Clone)]
pub enum SessionSelection {
StartFresh,
@@ -60,21 +75,6 @@ pub enum SessionPickerAction {
Fork,
}
#[derive(Clone, Copy, Debug)]
pub enum SessionSourceFilter {
InteractiveOnly,
IncludeNonInteractive,
}
impl SessionSourceFilter {
fn allowed_sources(self) -> &'static [codex_protocol::protocol::SessionSource] {
match self {
SessionSourceFilter::InteractiveOnly => INTERACTIVE_SESSION_SOURCES.as_slice(),
SessionSourceFilter::IncludeNonInteractive => &[],
}
}
}
impl SessionPickerAction {
fn title(self) -> &'static str {
match self {
@@ -90,7 +90,7 @@ impl SessionPickerAction {
}
}
fn selection(self, path: PathBuf, thread_id: ThreadId) -> SessionSelection {
fn selection(self, path: Option<PathBuf>, thread_id: ThreadId) -> SessionSelection {
let target_session = SessionTarget { path, thread_id };
match self {
SessionPickerAction::Resume => SessionSelection::Resume(target_session),
@@ -101,23 +101,43 @@ impl SessionPickerAction {
#[derive(Clone)]
struct PageLoadRequest {
cursor: Option<Cursor>,
cursor: Option<PageCursor>,
request_token: usize,
search_token: Option<usize>,
default_provider: String,
provider_filter: ProviderFilter,
sort_key: ThreadSortKey,
}
#[derive(Clone)]
enum ProviderFilter {
Any,
MatchDefault(String),
}
type PageLoader = Arc<dyn Fn(PageLoadRequest) + Send + Sync>;
enum BackgroundEvent {
PageLoaded {
request_token: usize,
search_token: Option<usize>,
page: std::io::Result<ThreadsPage>,
page: std::io::Result<PickerPage>,
},
}
#[derive(Clone)]
enum PageCursor {
#[allow(dead_code)]
Rollout(Cursor),
AppServer(String),
}
struct PickerPage {
rows: Vec<Row>,
next_cursor: Option<PageCursor>,
num_scanned_files: usize,
reached_scan_cap: bool,
}
/// Interactive session picker that lists recorded rollout files with simple
/// search and pagination.
///
@@ -131,91 +151,109 @@ enum BackgroundEvent {
/// new sessions appear during pagination.
///
/// Filtering happens in two layers:
/// 1. Provider and source filtering at the backend (interactive sessions for the
/// current model provider by default, optionally including non-interactive
/// sessions).
/// 1. Provider and source filtering at the backend (only interactive CLI sessions
/// for the current model provider).
/// 2. Working-directory filtering at the picker (unless `--all` is passed).
#[allow(dead_code)]
pub async fn run_resume_picker(
tui: &mut Tui,
config: &Config,
show_all: bool,
source_filter: SessionSourceFilter,
) -> Result<SessionSelection> {
run_session_picker(
tui,
config,
show_all,
source_filter,
SessionPickerAction::Resume,
)
.await
run_session_picker(tui, config, show_all, SessionPickerAction::Resume).await
}
pub async fn run_fork_picker(
pub async fn run_resume_picker_with_app_server(
tui: &mut Tui,
config: &Config,
show_all: bool,
include_non_interactive: bool,
app_server: AppServerSession,
) -> Result<SessionSelection> {
run_session_picker(
let (bg_tx, bg_rx) = mpsc::unbounded_channel();
let is_remote = app_server.is_remote();
run_session_picker_with_loader(
tui,
config,
show_all,
SessionSourceFilter::InteractiveOnly,
SessionPickerAction::Fork,
SessionPickerAction::Resume,
is_remote,
spawn_app_server_page_loader(app_server, bg_tx, include_non_interactive),
bg_rx,
)
.await
}
pub async fn run_fork_picker_with_app_server(
tui: &mut Tui,
config: &Config,
show_all: bool,
app_server: AppServerSession,
) -> Result<SessionSelection> {
let (bg_tx, bg_rx) = mpsc::unbounded_channel();
let is_remote = app_server.is_remote();
run_session_picker_with_loader(
tui,
config,
show_all,
SessionPickerAction::Fork,
is_remote,
spawn_app_server_page_loader(app_server, bg_tx, /*include_non_interactive*/ false),
bg_rx,
)
.await
}
#[allow(dead_code)]
async fn run_session_picker(
tui: &mut Tui,
config: &Config,
show_all: bool,
source_filter: SessionSourceFilter,
action: SessionPickerAction,
) -> Result<SessionSelection> {
let alt = AltScreenGuard::enter(tui);
let (bg_tx, bg_rx) = mpsc::unbounded_channel();
run_session_picker_with_loader(
tui,
config,
show_all,
action,
/*is_remote*/ false,
spawn_rollout_page_loader(config, bg_tx),
bg_rx,
)
.await
}
let default_provider = config.model_provider_id.to_string();
async fn run_session_picker_with_loader(
tui: &mut Tui,
config: &Config,
show_all: bool,
action: SessionPickerAction,
is_remote: bool,
page_loader: PageLoader,
bg_rx: mpsc::UnboundedReceiver<BackgroundEvent>,
) -> Result<SessionSelection> {
let alt = AltScreenGuard::enter(tui);
let provider_filter = if is_remote {
ProviderFilter::Any
} else {
ProviderFilter::MatchDefault(config.model_provider_id.to_string())
};
let codex_home = config.codex_home.as_path();
let filter_cwd = if show_all {
let filter_cwd = if show_all || is_remote {
// Remote sessions live in the server's filesystem namespace, so the client
// process cwd is not a meaningful default filter. A real remote cwd filter
// would need an explicit server-side target cwd instead of current_dir().
None
} else {
std::env::current_dir().ok()
};
let config = config.clone();
let loader_tx = bg_tx.clone();
let allowed_sources = source_filter.allowed_sources();
let page_loader: PageLoader = Arc::new(move |request: PageLoadRequest| {
let tx = loader_tx.clone();
let config = config.clone();
tokio::spawn(async move {
let provider_filter = vec![request.default_provider.clone()];
let page = RolloutRecorder::list_threads(
&config,
PAGE_SIZE,
request.cursor.as_ref(),
request.sort_key,
allowed_sources,
Some(provider_filter.as_slice()),
request.default_provider.as_str(),
/*search_term*/ None,
)
.await;
let _ = tx.send(BackgroundEvent::PageLoaded {
request_token: request.request_token,
search_token: request.search_token,
page,
});
});
});
let mut state = PickerState::new(
codex_home.to_path_buf(),
alt.tui.frame_requester(),
page_loader,
default_provider.clone(),
provider_filter,
show_all,
filter_cwd,
action,
@@ -260,6 +298,86 @@ async fn run_session_picker(
Ok(SessionSelection::StartFresh)
}
#[allow(dead_code)]
fn spawn_rollout_page_loader(
config: &Config,
bg_tx: mpsc::UnboundedSender<BackgroundEvent>,
) -> PageLoader {
let config = config.clone();
let loader_tx = bg_tx;
Arc::new(move |request: PageLoadRequest| {
let tx = loader_tx.clone();
let config = config.clone();
tokio::spawn(async move {
let default_provider = match request.provider_filter {
ProviderFilter::Any => None,
ProviderFilter::MatchDefault(default_provider) => Some(default_provider),
};
let cursor = match request.cursor.as_ref() {
Some(PageCursor::Rollout(cursor)) => Some(cursor),
Some(PageCursor::AppServer(_)) => None,
None => None,
};
let page = RolloutRecorder::list_threads(
&config,
PAGE_SIZE,
cursor,
request.sort_key,
INTERACTIVE_SESSION_SOURCES.as_slice(),
default_provider.as_ref().map(std::slice::from_ref),
default_provider.as_deref().unwrap_or_default(),
/*search_term*/ None,
)
.await
.map(picker_page_from_rollout_page);
let _ = tx.send(BackgroundEvent::PageLoaded {
request_token: request.request_token,
search_token: request.search_token,
page,
});
});
})
}
fn spawn_app_server_page_loader(
app_server: AppServerSession,
bg_tx: mpsc::UnboundedSender<BackgroundEvent>,
include_non_interactive: bool,
) -> PageLoader {
let (request_tx, mut request_rx) = mpsc::unbounded_channel::<PageLoadRequest>();
tokio::spawn(async move {
let mut app_server = app_server;
while let Some(request) = request_rx.recv().await {
let cursor = match request.cursor {
Some(PageCursor::AppServer(cursor)) => Some(cursor),
Some(PageCursor::Rollout(_)) => None,
None => None,
};
let page = load_app_server_page(
&mut app_server,
cursor,
request.provider_filter,
request.sort_key,
include_non_interactive,
)
.await;
let _ = bg_tx.send(BackgroundEvent::PageLoaded {
request_token: request.request_token,
search_token: request.search_token,
page,
});
}
if let Err(err) = app_server.shutdown().await {
warn!(%err, "Failed to shut down app-server picker session");
}
});
Arc::new(move |request: PageLoadRequest| {
let _ = request_tx.send(request);
})
}
/// Returns the human-readable column header for the given sort key.
fn sort_key_label(sort_key: ThreadSortKey) -> &'static str {
match sort_key {
@@ -292,7 +410,7 @@ struct PickerState {
pagination: PaginationState,
all_rows: Vec<Row>,
filtered_rows: Vec<Row>,
seen_paths: HashSet<PathBuf>,
seen_rows: HashSet<SeenRowKey>,
selected: usize,
scroll_top: usize,
query: String,
@@ -301,7 +419,7 @@ struct PickerState {
next_search_token: usize,
page_loader: PageLoader,
view_rows: Option<usize>,
default_provider: String,
provider_filter: ProviderFilter,
show_all: bool,
filter_cwd: Option<PathBuf>,
action: SessionPickerAction,
@@ -311,7 +429,7 @@ struct PickerState {
}
struct PaginationState {
next_cursor: Option<Cursor>,
next_cursor: Option<PageCursor>,
num_scanned_files: usize,
reached_scan_cap: bool,
loading: LoadingState,
@@ -346,6 +464,36 @@ impl LoadingState {
}
}
async fn load_app_server_page(
app_server: &mut AppServerSession,
cursor: Option<String>,
provider_filter: ProviderFilter,
sort_key: ThreadSortKey,
include_non_interactive: bool,
) -> std::io::Result<PickerPage> {
let response = app_server
.thread_list(thread_list_params(
cursor,
provider_filter,
sort_key,
include_non_interactive,
))
.await
.map_err(std::io::Error::other)?;
let num_scanned_files = response.data.len();
Ok(PickerPage {
rows: response
.data
.into_iter()
.filter_map(row_from_app_server_thread)
.collect(),
next_cursor: response.next_cursor.map(PageCursor::AppServer),
num_scanned_files,
reached_scan_cap: false,
})
}
impl SearchState {
fn active_token(&self) -> Option<usize> {
match self {
@@ -361,7 +509,7 @@ impl SearchState {
#[derive(Clone)]
struct Row {
path: PathBuf,
path: Option<PathBuf>,
preview: String,
thread_id: Option<ThreadId>,
thread_name: Option<String>,
@@ -371,7 +519,20 @@ struct Row {
git_branch: Option<String>,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
enum SeenRowKey {
Path(PathBuf),
Thread(ThreadId),
}
impl Row {
fn seen_key(&self) -> Option<SeenRowKey> {
if let Some(path) = self.path.clone() {
return Some(SeenRowKey::Path(path));
}
self.thread_id.map(SeenRowKey::Thread)
}
fn display_preview(&self) -> &str {
self.thread_name.as_deref().unwrap_or(&self.preview)
}
@@ -394,7 +555,7 @@ impl PickerState {
codex_home: PathBuf,
requester: FrameRequester,
page_loader: PageLoader,
default_provider: String,
provider_filter: ProviderFilter,
show_all: bool,
filter_cwd: Option<PathBuf>,
action: SessionPickerAction,
@@ -410,7 +571,7 @@ impl PickerState {
},
all_rows: Vec::new(),
filtered_rows: Vec::new(),
seen_paths: HashSet::new(),
seen_rows: HashSet::new(),
selected: 0,
scroll_top: 0,
query: String::new(),
@@ -419,7 +580,7 @@ impl PickerState {
next_search_token: 0,
page_loader,
view_rows: None,
default_provider,
provider_filter,
show_all,
filter_cwd,
action,
@@ -449,21 +610,28 @@ impl PickerState {
let path = row.path.clone();
let thread_id = match row.thread_id {
Some(thread_id) => Some(thread_id),
None => {
crate::resolve_session_thread_id(
path.as_path(),
/*id_str_if_uuid*/ None,
)
.await
}
None => match path.as_ref() {
Some(path) => {
crate::resolve_session_thread_id(
path.as_path(),
/*id_str_if_uuid*/ None,
)
.await
}
None => None,
},
};
if let Some(thread_id) = thread_id {
return Ok(Some(self.action.selection(path, thread_id)));
}
self.inline_error = Some(format!(
"Failed to read session metadata from {}",
path.display()
));
self.inline_error = Some(match path {
Some(path) => {
format!("Failed to read session metadata from {}", path.display())
}
None => {
String::from("Failed to read session metadata from selected session")
}
});
self.request_frame();
}
}
@@ -530,7 +698,7 @@ impl PickerState {
self.reset_pagination();
self.all_rows.clear();
self.filtered_rows.clear();
self.seen_paths.clear();
self.seen_rows.clear();
self.selected = 0;
let search_token = if self.query.is_empty() {
@@ -553,7 +721,7 @@ impl PickerState {
cursor: None,
request_token,
search_token,
default_provider: self.default_provider.clone(),
provider_filter: self.provider_filter.clone(),
sort_key: self.sort_key,
});
}
@@ -590,7 +758,7 @@ impl PickerState {
self.pagination.loading = LoadingState::Idle;
}
fn ingest_page(&mut self, page: ThreadsPage) {
fn ingest_page(&mut self, page: PickerPage) {
if let Some(cursor) = page.next_cursor.clone() {
self.pagination.next_cursor = Some(cursor);
} else {
@@ -604,9 +772,12 @@ impl PickerState {
self.pagination.reached_scan_cap = true;
}
let rows = rows_from_items(page.items);
for row in rows {
if self.seen_paths.insert(row.path.clone()) {
for row in page.rows {
if let Some(seen_key) = row.seen_key() {
if self.seen_rows.insert(seen_key) {
self.all_rows.push(row);
}
} else {
self.all_rows.push(row);
}
}
@@ -620,6 +791,9 @@ impl PickerState {
let Some(thread_id) = row.thread_id else {
continue;
};
if row.thread_name.is_some() {
continue;
}
if self.thread_name_cache.contains_key(&thread_id) {
continue;
}
@@ -823,7 +997,7 @@ impl PickerState {
cursor: Some(cursor),
request_token,
search_token,
default_provider: self.default_provider.clone(),
provider_filter: self.provider_filter.clone(),
sort_key: self.sort_key,
});
}
@@ -854,10 +1028,22 @@ impl PickerState {
}
}
#[cfg_attr(not(test), allow(dead_code))]
fn picker_page_from_rollout_page(page: ThreadsPage) -> PickerPage {
PickerPage {
rows: rows_from_items(page.items),
next_cursor: page.next_cursor.map(PageCursor::Rollout),
num_scanned_files: page.num_scanned_files,
reached_scan_cap: page.reached_scan_cap,
}
}
#[cfg_attr(not(test), allow(dead_code))]
fn rows_from_items(items: Vec<ThreadItem>) -> Vec<Row> {
items.into_iter().map(|item| head_to_row(&item)).collect()
}
#[cfg_attr(not(test), allow(dead_code))]
fn head_to_row(item: &ThreadItem) -> Row {
let created_at = item.created_at.as_deref().and_then(parse_timestamp_str);
let updated_at = item
@@ -875,7 +1061,7 @@ fn head_to_row(item: &ThreadItem) -> Row {
.unwrap_or_else(|| String::from("(no message yet)"));
Row {
path: item.path.clone(),
path: Some(item.path.clone()),
preview,
thread_id: item.thread_id,
thread_name: None,
@@ -886,6 +1072,61 @@ fn head_to_row(item: &ThreadItem) -> Row {
}
}
fn row_from_app_server_thread(thread: Thread) -> Option<Row> {
let thread_id = match ThreadId::from_string(&thread.id) {
Ok(thread_id) => thread_id,
Err(err) => {
warn!(thread_id = thread.id, %err, "Skipping app-server picker row with invalid id");
return None;
}
};
let preview = thread.preview.trim();
Some(Row {
path: thread.path,
preview: if preview.is_empty() {
String::from("(no message yet)")
} else {
preview.to_string()
},
thread_id: Some(thread_id),
thread_name: thread.name,
created_at: chrono::DateTime::from_timestamp(thread.created_at, 0)
.map(|dt| dt.with_timezone(&Utc)),
updated_at: chrono::DateTime::from_timestamp(thread.updated_at, 0)
.map(|dt| dt.with_timezone(&Utc)),
cwd: Some(thread.cwd),
git_branch: thread.git_info.and_then(|git_info| git_info.branch),
})
}
fn thread_list_params(
cursor: Option<String>,
provider_filter: ProviderFilter,
sort_key: ThreadSortKey,
include_non_interactive: bool,
) -> ThreadListParams {
ThreadListParams {
cursor,
limit: Some(PAGE_SIZE as u32),
sort_key: Some(match sort_key {
ThreadSortKey::CreatedAt => AppServerThreadSortKey::CreatedAt,
ThreadSortKey::UpdatedAt => AppServerThreadSortKey::UpdatedAt,
}),
model_providers: match provider_filter {
ProviderFilter::Any => None,
ProviderFilter::MatchDefault(default_provider) => Some(vec![default_provider]),
},
source_kinds: if include_non_interactive {
None
} else {
Some(vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode])
},
archived: Some(false),
cwd: None,
search_term: None,
}
}
fn paths_match(a: &Path, b: &Path) -> bool {
if let (Ok(ca), Ok(cb)) = (
path_utils::normalize_for_path_comparison(a),
@@ -896,6 +1137,7 @@ fn paths_match(a: &Path, b: &Path) -> bool {
a == b
}
#[cfg_attr(not(test), allow(dead_code))]
fn parse_timestamp_str(ts: &str) -> Option<DateTime<Utc>> {
chrono::DateTime::parse_from_rfc3339(ts)
.map(|dt| dt.with_timezone(&Utc))
@@ -1412,13 +1654,13 @@ mod tests {
next_cursor: Option<Cursor>,
num_scanned_files: usize,
reached_scan_cap: bool,
) -> ThreadsPage {
ThreadsPage {
) -> PickerPage {
picker_page_from_rollout_page(ThreadsPage {
items,
next_cursor,
num_scanned_files,
reached_scan_cap,
}
})
}
#[allow(dead_code)]
@@ -1581,7 +1823,7 @@ mod tests {
#[test]
fn row_display_preview_prefers_thread_name() {
let row = Row {
path: PathBuf::from("/tmp/a.jsonl"),
path: Some(PathBuf::from("/tmp/a.jsonl")),
preview: String::from("first message"),
thread_id: None,
thread_name: Some(String::from("My session")),
@@ -1594,6 +1836,56 @@ mod tests {
assert_eq!(row.display_preview(), "My session");
}
#[test]
fn remote_thread_list_params_omit_provider_filter() {
let params = thread_list_params(
Some(String::from("cursor-1")),
ProviderFilter::Any,
ThreadSortKey::UpdatedAt,
false,
);
assert_eq!(params.cursor, Some(String::from("cursor-1")));
assert_eq!(params.model_providers, None);
assert_eq!(
params.source_kinds,
Some(vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode])
);
}
#[test]
fn thread_list_params_include_non_interactive_omits_source_filter() {
let params = thread_list_params(None, ProviderFilter::Any, ThreadSortKey::UpdatedAt, true);
assert_eq!(params.source_kinds, None);
}
#[test]
fn remote_picker_does_not_filter_rows_by_local_cwd() {
let loader: PageLoader = Arc::new(|_| {});
let state = PickerState::new(
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
ProviderFilter::Any,
false,
None,
SessionPickerAction::Resume,
);
let row = Row {
path: None,
preview: String::from("remote session"),
thread_id: Some(ThreadId::new()),
thread_name: None,
created_at: None,
updated_at: None,
cwd: Some(PathBuf::from("/srv/remote-project")),
git_branch: None,
};
assert!(state.row_matches_filter(&row));
}
#[test]
fn resume_table_snapshot() {
use crate::custom_terminal::Terminal;
@@ -1606,7 +1898,7 @@ mod tests {
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
String::from("openai"),
ProviderFilter::MatchDefault(String::from("openai")),
true,
None,
SessionPickerAction::Resume,
@@ -1615,7 +1907,7 @@ mod tests {
let now = Utc::now();
let rows = vec![
Row {
path: PathBuf::from("/tmp/a.jsonl"),
path: Some(PathBuf::from("/tmp/a.jsonl")),
preview: String::from("Fix resume picker timestamps"),
thread_id: None,
thread_name: None,
@@ -1625,7 +1917,7 @@ mod tests {
git_branch: None,
},
Row {
path: PathBuf::from("/tmp/b.jsonl"),
path: Some(PathBuf::from("/tmp/b.jsonl")),
preview: String::from("Investigate lazy pagination cap"),
thread_id: None,
thread_name: None,
@@ -1635,7 +1927,7 @@ mod tests {
git_branch: None,
},
Row {
path: PathBuf::from("/tmp/c.jsonl"),
path: Some(PathBuf::from("/tmp/c.jsonl")),
preview: String::from("Explain the codebase"),
thread_id: None,
thread_name: None,
@@ -1684,7 +1976,7 @@ mod tests {
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
String::from("openai"),
ProviderFilter::MatchDefault(String::from("openai")),
true,
None,
SessionPickerAction::Resume,
@@ -1920,7 +2212,7 @@ mod tests {
tempdir.path().to_path_buf(),
FrameRequester::test_dummy(),
loader,
String::from("openai"),
ProviderFilter::MatchDefault(String::from("openai")),
true,
None,
SessionPickerAction::Resume,
@@ -1929,7 +2221,7 @@ mod tests {
let now = Utc::now();
let rows = vec![
Row {
path: PathBuf::from("/tmp/a.jsonl"),
path: Some(PathBuf::from("/tmp/a.jsonl")),
preview: String::from("First message preview"),
thread_id: Some(id1),
thread_name: None,
@@ -1939,7 +2231,7 @@ mod tests {
git_branch: None,
},
Row {
path: PathBuf::from("/tmp/b.jsonl"),
path: Some(PathBuf::from("/tmp/b.jsonl")),
preview: String::from("Second message preview"),
thread_id: Some(id2),
thread_name: None,
@@ -1987,7 +2279,7 @@ mod tests {
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
String::from("openai"),
ProviderFilter::MatchDefault(String::from("openai")),
true,
None,
SessionPickerAction::Resume,
@@ -2056,7 +2348,7 @@ mod tests {
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
String::from("openai"),
ProviderFilter::MatchDefault(String::from("openai")),
true,
None,
SessionPickerAction::Resume,
@@ -2137,7 +2429,7 @@ mod tests {
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
String::from("openai"),
ProviderFilter::MatchDefault(String::from("openai")),
true,
None,
SessionPickerAction::Resume,
@@ -2167,7 +2459,7 @@ mod tests {
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
String::from("openai"),
ProviderFilter::MatchDefault(String::from("openai")),
true,
None,
SessionPickerAction::Resume,
@@ -2212,14 +2504,14 @@ mod tests {
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
String::from("openai"),
ProviderFilter::MatchDefault(String::from("openai")),
true,
None,
SessionPickerAction::Resume,
);
let row = Row {
path: PathBuf::from("/tmp/missing.jsonl"),
path: Some(PathBuf::from("/tmp/missing.jsonl")),
preview: String::from("missing metadata"),
thread_id: None,
thread_name: None,
@@ -2245,6 +2537,75 @@ mod tests {
);
}
#[tokio::test]
async fn enter_on_pathless_thread_uses_thread_id() {
let loader: PageLoader = Arc::new(|_| {});
let mut state = PickerState::new(
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
true,
None,
SessionPickerAction::Resume,
);
let thread_id = ThreadId::new();
let row = Row {
path: None,
preview: String::from("pathless thread"),
thread_id: Some(thread_id),
thread_name: None,
created_at: None,
updated_at: None,
cwd: None,
git_branch: None,
};
state.all_rows = vec![row.clone()];
state.filtered_rows = vec![row];
let selection = state
.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("enter should not abort the picker");
match selection {
Some(SessionSelection::Resume(SessionTarget {
path: None,
thread_id: selected_thread_id,
})) => assert_eq!(selected_thread_id, thread_id),
other => panic!("unexpected selection: {other:?}"),
}
}
#[test]
fn app_server_row_keeps_pathless_threads() {
let thread_id = ThreadId::new();
let thread = Thread {
id: thread_id.to_string(),
preview: String::from("remote thread"),
ephemeral: false,
model_provider: String::from("openai"),
created_at: 1,
updated_at: 2,
status: codex_app_server_protocol::ThreadStatus::Idle,
path: None,
cwd: PathBuf::from("/tmp"),
cli_version: String::from("0.0.0"),
source: codex_app_server_protocol::SessionSource::Cli,
agent_nickname: None,
agent_role: None,
git_info: None,
name: Some(String::from("Named thread")),
turns: Vec::new(),
};
let row = row_from_app_server_thread(thread).expect("row should be preserved");
assert_eq!(row.path, None);
assert_eq!(row.thread_id, Some(thread_id));
assert_eq!(row.thread_name, Some(String::from("Named thread")));
}
#[tokio::test]
async fn up_at_bottom_does_not_scroll_when_visible() {
let loader: PageLoader = Arc::new(|_| {});
@@ -2252,7 +2613,7 @@ mod tests {
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
String::from("openai"),
ProviderFilter::MatchDefault(String::from("openai")),
true,
None,
SessionPickerAction::Resume,
@@ -2297,7 +2658,7 @@ mod tests {
PathBuf::from("/tmp"),
FrameRequester::test_dummy(),
loader,
String::from("openai"),
ProviderFilter::MatchDefault(String::from("openai")),
true,
None,
SessionPickerAction::Resume,
+2 -5
View File
@@ -6,8 +6,8 @@ use std::sync::LazyLock;
use std::sync::Mutex;
use std::sync::OnceLock;
use crate::app_command::AppCommand;
use codex_core::config::Config;
use codex_protocol::protocol::Op;
use serde::Serialize;
use serde_json::json;
@@ -125,9 +125,6 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
}
match event {
AppEvent::CodexEvent(ev) => {
write_record("to_tui", "codex_event", ev);
}
AppEvent::NewSession => {
let value = json!({
"ts": now_ts(),
@@ -185,7 +182,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
}
}
pub(crate) fn log_outbound_op(op: &Op) {
pub(crate) fn log_outbound_op(op: &AppCommand) {
if !LOGGER.is_enabled() {
return;
}
@@ -1,7 +0,0 @@
---
source: tui/src/app.rs
expression: rendered
---
⚠ Custom prompts are deprecated and will soon be removed.
Detected 1 custom prompt in `$CODEX_HOME/prompts`. Use the `$skill-creator` skill to convert each custom prompt into
a skill.

Some files were not shown because too many files have changed in this diff Show More