mirror of
https://github.com/openai/codex.git
synced 2026-10-25 18:28:38 +00:00
Unify the TUI on the app-server implementation
This commit is contained in:
@@ -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.
|
||||
|
||||
Generated
-97
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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`).
|
||||
///
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/request_user_input/mod.rs
|
||||
assertion_line: 2600
|
||||
expression: "render_snapshot(&overlay, area)"
|
||||
---
|
||||
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/request_user_input/mod.rs
|
||||
assertion_line: 2744
|
||||
expression: "render_snapshot(&overlay, area)"
|
||||
---
|
||||
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/request_user_input/mod.rs
|
||||
assertion_line: 2770
|
||||
expression: "render_snapshot(&overlay, area)"
|
||||
---
|
||||
|
||||
|
||||
-1
@@ -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
-1
@@ -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?
|
||||
|
||||
+1
@@ -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
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/approval_overlay.rs
|
||||
assertion_line: 821
|
||||
expression: "format!(\"{buf:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
assertion_line: 2385
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
assertion_line: 1207
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" Robie [explorer] 100% context left "
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
assertion_line: 535
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" / for commands ! for shell commands "
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
assertion_line: 1210
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" Status line content · Robie [explorer] "
|
||||
|
||||
-1
@@ -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
@@ -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
@@ -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
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/list_selection_view.rs
|
||||
assertion_line: 828
|
||||
expression: "render_lines_with_width(&view, 40)"
|
||||
---
|
||||
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/skills_toggle_view.rs
|
||||
assertion_line: 439
|
||||
expression: "render_lines(&view, 72)"
|
||||
---
|
||||
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/status_line_setup.rs
|
||||
assertion_line: 365
|
||||
expression: "render_lines(&view, 72)"
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1995
-842
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
@@ -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
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 7368
|
||||
expression: popup
|
||||
---
|
||||
Update Model Permissions
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 7365
|
||||
expression: popup
|
||||
---
|
||||
Update Model Permissions
|
||||
|
||||
+36
-1
@@ -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
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 9494
|
||||
expression: combined
|
||||
---
|
||||
|
||||
|
||||
-1
@@ -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
|
||||
|
||||
-1
@@ -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
-1
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob1
|
||||
expression: active_blob(&chat)
|
||||
---
|
||||
• Exploring
|
||||
└ List ls -la
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob2
|
||||
expression: active_blob(&chat)
|
||||
---
|
||||
• Explored
|
||||
└ List ls -la
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob3
|
||||
expression: active_blob(&chat)
|
||||
---
|
||||
• Exploring
|
||||
└ List ls -la
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob4
|
||||
expression: active_blob(&chat)
|
||||
---
|
||||
• Explored
|
||||
└ List ls -la
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob5
|
||||
expression: active_blob(&chat)
|
||||
---
|
||||
• Explored
|
||||
└ List ls -la
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob6
|
||||
expression: active_blob(&chat)
|
||||
---
|
||||
• Explored
|
||||
└ List ls -la
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 9237
|
||||
expression: term.backend().vt100().screen().contents()
|
||||
---
|
||||
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 9085
|
||||
expression: term.backend().vt100().screen().contents()
|
||||
---
|
||||
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 9336
|
||||
expression: rendered
|
||||
---
|
||||
• Reviewing 2 approval requests (0s • esc to interrupt)
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 5889
|
||||
expression: combined
|
||||
---
|
||||
• Generated Image:
|
||||
|
||||
+1
-1
@@ -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
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1989
|
||||
expression: popup
|
||||
---
|
||||
Select Model and Effort
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 6001
|
||||
expression: popup
|
||||
---
|
||||
Enable subagents?
|
||||
|
||||
-1
@@ -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
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 7289
|
||||
expression: popup
|
||||
---
|
||||
Plugins
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 7527
|
||||
expression: popup
|
||||
---
|
||||
Plugins
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 7060
|
||||
expression: header
|
||||
---
|
||||
› Ask Codex to do anything
|
||||
|
||||
+1
-2
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+1216
-619
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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!(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/onboarding/trust_directory.rs
|
||||
assertion_line: 218
|
||||
expression: terminal.backend()
|
||||
---
|
||||
> You are in /workspace/project
|
||||
|
||||
+475
-114
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
-7
@@ -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
Reference in New Issue
Block a user