Show remote connection details in /status (#24420)

## Summary

Fixes #24411.

`/status` currently has no way to show when the TUI is talking to Codex
through a remote transport. That makes embedded local sessions, local
daemon sessions, and true remote sessions look the same, and it hides
the remote server version when debugging connection-specific behavior.

This PR adds a single `Remote` row for non-embedded connections only.
The row shows the sanitized connection address and a dimmed version
parenthetical, preserving the existing status output for embedded local
sessions.

<img width="791" height="144" alt="image"
src="https://github.com/user-attachments/assets/529d7940-1c45-4586-8b06-f20a1f04b771"
/>


## Verification

- Manually validated when connecting remotely (either implicitly to
local daemon or explicitly)
This commit is contained in:
Eric Traut
2026-05-25 09:42:42 -07:00
committed by GitHub
Unverified
parent caebff3d66
commit 913270a689
13 changed files with 180 additions and 20 deletions
+4 -1
View File
@@ -1122,7 +1122,9 @@ mod tests {
websocket,
JSONRPCMessage::Response(JSONRPCResponse {
id: request.id,
result: serde_json::json!({}),
result: serde_json::json!({
"userAgent": "codex_cli_rs/9.8.7-test (Test OS; x86_64) rust",
}),
}),
)
.await;
@@ -1457,6 +1459,7 @@ mod tests {
.await
.expect("remote client should connect");
assert_eq!(client.server_version(), Some("9.8.7-test"));
let response: GetAccountResponse = client
.request_typed(ClientRequest::GetAccount {
request_id: RequestId::Integer(1),
+20 -3
View File
@@ -150,6 +150,7 @@ pub struct RemoteAppServerClient {
command_tx: mpsc::Sender<RemoteClientCommand>,
event_rx: mpsc::UnboundedReceiver<AppServerEvent>,
pending_events: VecDeque<AppServerEvent>,
server_version: Option<String>,
worker_handle: tokio::task::JoinHandle<()>,
}
@@ -180,6 +181,10 @@ impl RemoteAppServerClient {
}
}
pub fn server_version(&self) -> Option<&str> {
self.server_version.as_deref()
}
async fn connect_with_stream<S>(
channel_capacity: usize,
endpoint: String,
@@ -190,7 +195,7 @@ impl RemoteAppServerClient {
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
let mut stream = stream;
let pending_events = initialize_remote_connection(
let (pending_events, server_version) = initialize_remote_connection(
&mut stream,
&endpoint,
initialize_params,
@@ -466,6 +471,7 @@ impl RemoteAppServerClient {
command_tx,
event_rx,
pending_events: pending_events.into(),
server_version,
worker_handle,
})
}
@@ -606,6 +612,7 @@ impl RemoteAppServerClient {
command_tx,
event_rx,
pending_events: _pending_events,
server_version: _server_version,
worker_handle,
} = self;
let mut worker_handle = worker_handle;
@@ -793,12 +800,13 @@ async fn initialize_remote_connection<S>(
endpoint: &str,
params: InitializeParams,
initialize_timeout: Duration,
) -> IoResult<Vec<AppServerEvent>>
) -> IoResult<(Vec<AppServerEvent>, Option<String>)>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let initialize_request_id = RequestId::String("initialize".to_string());
let mut pending_events = Vec::new();
let mut server_version = None;
write_jsonrpc_message(
stream,
JSONRPCMessage::Request(jsonrpc_request_from_client_request(
@@ -822,6 +830,14 @@ where
})?;
match message {
JSONRPCMessage::Response(response) if response.id == initialize_request_id => {
server_version = response
.result
.get("userAgent")
.and_then(serde_json::Value::as_str)
.and_then(|user_agent| {
let (_, rest) = user_agent.split_once('/')?;
rest.split_whitespace().next().map(str::to_string)
});
break Ok(());
}
JSONRPCMessage::Error(error) if error.id == initialize_request_id => {
@@ -913,7 +929,7 @@ where
)
.await?;
Ok(pending_events)
Ok((pending_events, server_version))
}
fn app_server_event_from_notification(notification: JSONRPCNotification) -> Option<AppServerEvent> {
@@ -1007,6 +1023,7 @@ mod tests {
command_tx,
event_rx,
pending_events: VecDeque::new(),
server_version: None,
worker_handle,
};
+5
View File
@@ -754,6 +754,10 @@ impl App {
let bootstrap_ms = bootstrap_started_at.elapsed().as_millis();
let mut model = bootstrap.default_model;
let available_models = bootstrap.available_models;
let remote_connection = crate::status::remote_connection::remote_connection_status_value(
&app_server_target,
app_server.server_version(),
);
let exit_info = handle_model_migration_prompt_if_needed(
tui,
&mut config,
@@ -942,6 +946,7 @@ impl App {
(ChatWidget::new_with_app_event(init), Some(forked))
}
};
chat_widget.remote_connection = remote_connection;
let thread_and_widget_ms = thread_and_widget_started_at.elapsed().as_millis();
if let Some(message) = external_agent_config_migration_message {
chat_widget.add_info_message(message, /*hint*/ None);
@@ -268,6 +268,7 @@ impl App {
if chat_widget.last_terminal_title.is_none() {
chat_widget.last_terminal_title = previous_terminal_title;
}
chat_widget.remote_connection = self.chat_widget.remote_connection.clone();
for (thread_id, entry) in self.agent_navigation.ordered_threads() {
chat_widget.set_collab_agent_metadata(
thread_id,
+7
View File
@@ -227,6 +227,13 @@ impl AppServerSession {
matches!(self.thread_params_mode, ThreadParamsMode::Remote)
}
pub(crate) fn server_version(&self) -> Option<&str> {
let AppServerClient::Remote(client) = &self.client else {
return None;
};
client.server_version()
}
pub(crate) async fn bootstrap(&mut self, config: &Config) -> Result<AppServerBootstrap> {
let account = self.read_account().await?;
let model_request_id = self.next_request_id();
+2
View File
@@ -315,6 +315,7 @@ use crate::render::renderable::RenderableExt;
use crate::render::renderable::RenderableItem;
use crate::slash_command::SlashCommand;
use crate::status::RateLimitSnapshotDisplay;
use crate::status::remote_connection::RemoteConnectionStatus;
use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES;
use crate::status_indicator_widget::StatusDetailsCapitalization;
use crate::text_formatting::truncate_text;
@@ -535,6 +536,7 @@ pub(crate) struct ChatWidget {
initial_user_message: Option<UserMessage>,
status_account_display: Option<StatusAccountDisplay>,
runtime_model_provider_base_url: Option<String>,
pub(crate) remote_connection: Option<RemoteConnectionStatus>,
token_info: Option<TokenUsageInfo>,
rate_limit_snapshots_by_limit_id: BTreeMap<String, RateLimitSnapshotDisplay>,
refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>,
@@ -119,6 +119,7 @@ impl ChatWidget {
initial_user_message,
status_account_display,
runtime_model_provider_base_url,
remote_connection: None,
token_info: None,
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
refreshing_status_outputs: Vec::new(),
@@ -225,6 +225,7 @@ impl ChatWidget {
let (cell, handle) = crate::status::new_status_output_with_rate_limits_handle(
&self.config,
self.runtime_model_provider_base_url.as_deref(),
self.remote_connection.as_ref(),
self.status_account_display.as_ref(),
token_info,
total_usage,
+26 -1
View File
@@ -45,8 +45,10 @@ use super::rate_limits::compose_rate_limit_data;
use super::rate_limits::compose_rate_limit_data_many;
use super::rate_limits::format_status_limit_summary;
use super::rate_limits::render_status_limit_progress_bar;
use super::remote_connection::RemoteConnectionStatus;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_lines;
use crate::wrapping::word_wrap_lines;
use std::sync::Arc;
use std::sync::RwLock;
@@ -106,6 +108,7 @@ struct StatusHistoryCell {
agents_summary: Arc<RwLock<String>>,
collaboration_mode: Option<String>,
model_provider: Option<String>,
remote_connection: Option<RemoteConnectionStatus>,
show_chatgpt_usage_link: bool,
account: Option<StatusAccountDisplay>,
thread_name: Option<String>,
@@ -172,6 +175,7 @@ pub(crate) fn new_status_output_with_rate_limits(
new_status_output_with_rate_limits_handle(
config,
/*runtime_model_provider_base_url*/ None,
/*remote_connection*/ None,
account_display,
token_info,
total_usage,
@@ -194,6 +198,7 @@ pub(crate) fn new_status_output_with_rate_limits(
pub(crate) fn new_status_output_with_rate_limits_handle(
config: &Config,
runtime_model_provider_base_url: Option<&str>,
remote_connection: Option<&RemoteConnectionStatus>,
account_display: Option<&StatusAccountDisplay>,
token_info: Option<&TokenUsageInfo>,
total_usage: &TokenUsage,
@@ -213,6 +218,7 @@ pub(crate) fn new_status_output_with_rate_limits_handle(
let (card, handle) = StatusHistoryCell::new(
config,
runtime_model_provider_base_url,
remote_connection,
account_display,
token_info,
total_usage,
@@ -240,6 +246,7 @@ impl StatusHistoryCell {
fn new(
config: &Config,
runtime_model_provider_base_url: Option<&str>,
remote_connection: Option<&RemoteConnectionStatus>,
account_display: Option<&StatusAccountDisplay>,
token_info: Option<&TokenUsageInfo>,
total_usage: &TokenUsage,
@@ -349,6 +356,7 @@ impl StatusHistoryCell {
permissions,
collaboration_mode: collaboration_mode.map(ToString::to_string),
model_provider,
remote_connection: remote_connection.cloned(),
show_chatgpt_usage_link,
account,
thread_name,
@@ -689,7 +697,6 @@ impl HistoryCell for StatusHistoryCell {
Span::from(" ").dim(),
Span::from(format!("(v{CODEX_CLI_VERSION})")).dim(),
]));
lines.push(Line::from(Vec::<Span<'static>>::new()));
let available_inner_width = usize::from(width.saturating_sub(4));
if available_inner_width == 0 {
@@ -768,12 +775,30 @@ impl HistoryCell for StatusHistoryCell {
[note_first_line, note_second_line],
RtOptions::new(available_inner_width),
);
lines.push(Line::from(Vec::<Span<'static>>::new()));
// The ChatGPT usage page only applies to providers backed by OpenAI auth;
// providers like Bedrock manage limits and billing elsewhere.
if self.show_chatgpt_usage_link {
lines.extend(note_lines);
lines.push(Line::from(Vec::<Span<'static>>::new()));
}
if let Some(remote_connection) = self.remote_connection.as_ref() {
let wrapped_remote = word_wrap_lines(
[Line::from(vec![
Span::from(remote_connection.address.clone()),
Span::from(" (").dim(),
Span::from(remote_connection.version.clone()).dim(),
Span::from(")").dim(),
])],
RtOptions::new(value_width.max(1)),
);
let mut wrapped_remote = wrapped_remote.into_iter();
if let Some(first) = wrapped_remote.next() {
lines.push(formatter.line("Remote", first.spans));
lines.extend(wrapped_remote.map(|line| formatter.continuation(line.spans)));
}
lines.push(Line::from(Vec::<Span<'static>>::new()));
}
let mut model_spans = vec![Span::from(self.model_name.clone())];
if !self.model_details.is_empty() {
+1
View File
@@ -11,6 +11,7 @@ mod card;
mod format;
mod helpers;
mod rate_limits;
pub(crate) mod remote_connection;
pub(crate) use account::StatusAccountDisplay;
pub(crate) use card::StatusHistoryHandle;
@@ -0,0 +1,86 @@
use crate::AppServerTarget;
use crate::RemoteAppServerEndpoint;
use url::Url;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct RemoteConnectionStatus {
pub(crate) address: String,
pub(crate) version: String,
}
pub(crate) fn remote_connection_status_value(
app_server_target: &AppServerTarget,
server_version: Option<&str>,
) -> Option<RemoteConnectionStatus> {
let endpoint = match app_server_target {
AppServerTarget::Embedded => return None,
AppServerTarget::LocalDaemon { endpoint } | AppServerTarget::Remote { endpoint } => {
endpoint
}
};
let address = match endpoint {
RemoteAppServerEndpoint::WebSocket { websocket_url, .. } => {
sanitized_websocket_display_address(websocket_url)
.unwrap_or_else(|| "<invalid websocket URL>".to_string())
}
RemoteAppServerEndpoint::UnixSocket { socket_path } => {
format!("unix://{}", socket_path.display())
}
};
let version = server_version
.map(|version| format!("v{version}"))
.unwrap_or_else(|| "unknown".to_string());
Some(RemoteConnectionStatus { address, version })
}
fn sanitized_websocket_display_address(raw: &str) -> Option<String> {
let mut url = Url::parse(raw).ok()?;
let _ = url.set_username("");
let _ = url.set_password(None);
url.set_query(None);
url.set_fragment(None);
Some(url.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use codex_utils_absolute_path::AbsolutePathBuf;
#[test]
fn remote_connection_status_value_formats_display_value() -> color_eyre::Result<()> {
assert_eq!(
remote_connection_status_value(&AppServerTarget::Embedded, Some("1.2.3")),
None
);
let websocket_target = AppServerTarget::Remote {
endpoint: RemoteAppServerEndpoint::WebSocket {
websocket_url: "ws://user:secret@127.0.0.1:4500/?token=abc#frag".to_string(),
auth_token: Some("abc".to_string()),
},
};
assert_eq!(
remote_connection_status_value(&websocket_target, Some("1.2.3")),
Some(RemoteConnectionStatus {
address: "ws://127.0.0.1:4500/".to_string(),
version: "v1.2.3".to_string(),
})
);
let socket_path = AbsolutePathBuf::relative_to_current_dir("codex.sock")?;
let daemon_target = AppServerTarget::LocalDaemon {
endpoint: RemoteAppServerEndpoint::UnixSocket {
socket_path: socket_path.clone(),
},
};
assert_eq!(
remote_connection_status_value(&daemon_target, /*server_version*/ None),
Some(RemoteConnectionStatus {
address: format!("unix://{}", socket_path.display()),
version: "unknown".to_string(),
})
);
Ok(())
}
}
@@ -4,18 +4,21 @@ expression: sanitized
---
/status
╭─────────────────────────────────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │
│ information on rate limits and credits │
│ │
Model: gpt-5.1-codex-max (reasoning medium, summaries auto)
Directory: [[workspace]]
Permissions: Custom (workspace with network access, on-request)
Agents.md: <none>
Token usage: 750 total (500 input + 250 output)
Context window: 100% left (750 used / 272K)
Limits: data not available yet
╰─────────────────────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────────────────────
│ >_ OpenAI Codex (v0.0.0)
│ Visit https://chatgpt.com/codex/settings/usage for up-to-date
│ information on rate limits and credits
Remote: unix:///tmp/codex-home/app-server-control/app-server-
control.sock (v0.133.0)
Model: gpt-5.1-codex-max (reasoning medium, summaries auto)
Directory: [[workspace]]
Permissions: Custom (workspace with network access, on-request)
Agents.md: <none>
│ Token usage: 750 total (500 input + 250 output) │
│ Context window: 100% left (750 used / 272K) │
│ Limits: data not available yet │
╰──────────────────────────────────────────────────────────────────────────╯
+8
View File
@@ -7,6 +7,7 @@ use crate::legacy_core::config::Config;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::PermissionProfileSnapshot;
use crate::status::StatusAccountDisplay;
use crate::status::remote_connection::RemoteConnectionStatus;
use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
use crate::token_usage::TokenUsage;
@@ -604,6 +605,7 @@ async fn status_model_provider_uses_bedrock_runtime_base_url_and_gates_usage_lin
let (composite, _handle) = new_status_output_with_rate_limits_handle(
&config,
Some(runtime_base_url),
/*remote_connection*/ None,
test_status_account_display().as_ref(),
/*token_info*/ None,
&usage,
@@ -644,6 +646,7 @@ async fn status_model_provider_uses_bedrock_runtime_base_url_and_gates_usage_lin
let (composite, _handle) = new_status_output_with_rate_limits_handle(
&config,
/*runtime_model_provider_base_url*/ None,
/*remote_connection*/ None,
test_status_account_display().as_ref(),
/*token_info*/ None,
&usage,
@@ -1321,12 +1324,17 @@ async fn status_snapshot_uses_default_reasoning_when_config_empty() {
.with_ymd_and_hms(2024, 2, 3, 4, 5, 6)
.single()
.expect("timestamp");
let remote_connection = RemoteConnectionStatus {
address: "unix:///tmp/codex-home/app-server-control/app-server-control.sock".to_string(),
version: "v0.133.0".to_string(),
};
let model_slug = crate::legacy_core::test_support::get_model_offline(config.model.as_deref());
let token_info = token_info_for(&model_slug, &config, &usage);
let (composite, _) = new_status_output_with_rate_limits_handle(
&config,
/*runtime_model_provider_base_url*/ None,
Some(&remote_connection),
account_display.as_ref(),
Some(&token_info),
&usage,