mirror of
https://github.com/openai/codex.git
synced 2026-06-03 11:52:03 +00:00
Compare commits
1 Commits
btraut/fix
...
codex/app-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4948a9d273 |
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -3790,6 +3790,7 @@ dependencies = [
|
||||
"codex-app-server-client",
|
||||
"codex-app-server-protocol",
|
||||
"codex-arg0",
|
||||
"codex-chatgpt",
|
||||
"codex-cli",
|
||||
"codex-cloud-requirements",
|
||||
"codex-config",
|
||||
|
||||
@@ -515,10 +515,6 @@ inherits = "test"
|
||||
opt-level = 0
|
||||
|
||||
[patch.crates-io]
|
||||
# TODO(sqlite-wal-reset): Use a pinned external sqlx-sqlite 0.8.6 patch that
|
||||
# permits libsqlite3-sys 0.37.0 (bundled SQLite 3.51.3+) once its owning
|
||||
# repository is selected. Pooled WAL-mode state DBs remain exposed while
|
||||
# bundled SQLite 3.46.0 is shipped.
|
||||
# Uncomment to debug local changes.
|
||||
# ratatui = { path = "../../ratatui" }
|
||||
crossterm = { git = "https://github.com/nornagon/crossterm", rev = "87db8bfa6dc99427fd3b071681b07fc31c6ce995" }
|
||||
|
||||
@@ -59,6 +59,22 @@ impl ToolInfo {
|
||||
pub fn canonical_tool_name(&self) -> ToolName {
|
||||
ToolName::namespaced(self.callable_namespace.clone(), self.callable_name.clone())
|
||||
}
|
||||
|
||||
/// Returns whether MCP Apps metadata allows this tool to be presented to the model.
|
||||
pub fn is_model_visible(&self) -> bool {
|
||||
self.tool
|
||||
.meta
|
||||
.as_deref()
|
||||
.and_then(|meta| meta.get(META_UI))
|
||||
.and_then(JsonValue::as_object)
|
||||
.and_then(|ui| ui.get(META_UI_VISIBILITY))
|
||||
.and_then(JsonValue::as_array)
|
||||
.is_none_or(|visibility| {
|
||||
visibility
|
||||
.iter()
|
||||
.any(|value| value.as_str() == Some(META_UI_VISIBILITY_MODEL))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn declared_openai_file_input_param_names(
|
||||
@@ -261,6 +277,9 @@ const MCP_TOOL_NAME_DELIMITER: &str = "__";
|
||||
const MAX_TOOL_NAME_LENGTH: usize = 64;
|
||||
const CALLABLE_NAME_HASH_LEN: usize = 12;
|
||||
const META_OPENAI_FILE_PARAMS: &str = "openai/fileParams";
|
||||
const META_UI: &str = "ui";
|
||||
const META_UI_VISIBILITY: &str = "visibility";
|
||||
const META_UI_VISIBILITY_MODEL: &str = "model";
|
||||
|
||||
fn callable_namespace_with_prefix(namespace: &str, prefix_mcp_tool_names: bool) -> String {
|
||||
if !prefix_mcp_tool_names || namespace.starts_with(LEGACY_MCP_TOOL_NAME_PREFIX) {
|
||||
|
||||
@@ -20,10 +20,15 @@ pub(crate) fn build_mcp_tool_exposure(
|
||||
config: &Config,
|
||||
search_tool_enabled: bool,
|
||||
) -> McpToolExposure {
|
||||
let mut deferred_tools = filter_non_codex_apps_mcp_tools_only(all_mcp_tools);
|
||||
let model_visible_tools = all_mcp_tools
|
||||
.iter()
|
||||
.filter(|tool| tool.is_model_visible())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let mut deferred_tools = filter_non_codex_apps_mcp_tools_only(&model_visible_tools);
|
||||
if let Some(connectors) = connectors {
|
||||
deferred_tools.extend(filter_codex_apps_mcp_tools(
|
||||
all_mcp_tools,
|
||||
&model_visible_tools,
|
||||
connectors,
|
||||
config,
|
||||
));
|
||||
|
||||
@@ -7,6 +7,7 @@ use codex_mcp::ToolInfo;
|
||||
use codex_tools::ToolName;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::model::JsonObject;
|
||||
use rmcp::model::Meta;
|
||||
use rmcp::model::Tool;
|
||||
|
||||
use super::*;
|
||||
@@ -79,6 +80,20 @@ fn numbered_mcp_tools(count: usize) -> Vec<ToolInfo> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn with_ui_visibility(mut tool: ToolInfo, visibility: &[&str]) -> ToolInfo {
|
||||
tool.tool.meta = Some(Meta(
|
||||
serde_json::json!({
|
||||
"ui": {
|
||||
"visibility": visibility,
|
||||
},
|
||||
})
|
||||
.as_object()
|
||||
.expect("object")
|
||||
.clone(),
|
||||
));
|
||||
tool
|
||||
}
|
||||
|
||||
fn tool_names(tools: &[ToolInfo]) -> HashSet<ToolName> {
|
||||
tools
|
||||
.iter()
|
||||
@@ -86,6 +101,52 @@ fn tool_names(tools: &[ToolInfo]) -> HashSet<ToolName> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hides_app_only_tools_from_model_exposure() {
|
||||
let config = test_config().await;
|
||||
let public_tool = make_mcp_tool(
|
||||
"rmcp",
|
||||
"public_tool",
|
||||
"mcp__rmcp",
|
||||
"public_tool",
|
||||
/*connector_id*/ None,
|
||||
/*connector_name*/ None,
|
||||
);
|
||||
let app_only_tool = with_ui_visibility(
|
||||
make_mcp_tool(
|
||||
"rmcp",
|
||||
"app_only_tool",
|
||||
"mcp__rmcp",
|
||||
"app_only_tool",
|
||||
/*connector_id*/ None,
|
||||
/*connector_name*/ None,
|
||||
),
|
||||
&["app"],
|
||||
);
|
||||
let model_only_tool = with_ui_visibility(
|
||||
make_mcp_tool(
|
||||
"rmcp",
|
||||
"model_only_tool",
|
||||
"mcp__rmcp",
|
||||
"model_only_tool",
|
||||
/*connector_id*/ None,
|
||||
/*connector_name*/ None,
|
||||
),
|
||||
&["model"],
|
||||
);
|
||||
let mcp_tools = vec![public_tool.clone(), app_only_tool, model_only_tool.clone()];
|
||||
|
||||
let exposure = build_mcp_tool_exposure(
|
||||
&mcp_tools, /*connectors*/ None, &config, /*search_tool_enabled*/ true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
tool_names(&exposure.direct_tools),
|
||||
tool_names(&[public_tool, model_only_tool])
|
||||
);
|
||||
assert!(exposure.deferred_tools.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn directly_exposes_small_effective_tool_sets() {
|
||||
let config = test_config().await;
|
||||
|
||||
@@ -31,6 +31,7 @@ codex-app-server-client = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-arg0 = { workspace = true }
|
||||
codex-install-context = { workspace = true }
|
||||
codex-chatgpt = { workspace = true }
|
||||
codex-cloud-requirements = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
codex-connectors = { workspace = true }
|
||||
|
||||
@@ -124,6 +124,7 @@ use codex_app_server_protocol::TurnCompletedNotification;
|
||||
use codex_app_server_protocol::TurnPlanStepStatus;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_chatgpt::connectors as chatgpt_connectors;
|
||||
use codex_config::ConfigLayerStackOrdering;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
use codex_config::types::Notifications;
|
||||
@@ -361,7 +362,6 @@ mod slash_dispatch;
|
||||
use self::skills::collect_tool_mentions;
|
||||
use self::skills::find_app_mentions;
|
||||
use self::skills::find_skill_mentions_with_tool_mentions;
|
||||
use self::skills::is_app_mentionable;
|
||||
mod plugins;
|
||||
use self::plugins::PluginInstallAuthFlowState;
|
||||
use self::plugins::PluginListFetchState;
|
||||
|
||||
@@ -303,6 +303,13 @@ impl ChatWidget {
|
||||
|
||||
match result {
|
||||
Ok(mut snapshot) => {
|
||||
if !is_final {
|
||||
snapshot.connectors = chatgpt_connectors::merge_connectors_with_accessible(
|
||||
Vec::new(),
|
||||
snapshot.connectors,
|
||||
/*all_connectors_loaded*/ false,
|
||||
);
|
||||
}
|
||||
if let ConnectorsCacheState::Ready(existing_snapshot) = &self.connectors.cache {
|
||||
let enabled_by_id: HashMap<&str, bool> = existing_snapshot
|
||||
.connectors
|
||||
|
||||
@@ -263,14 +263,10 @@ impl ChatWidget {
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if selected_app_ids.contains(app_id) {
|
||||
if !selected_app_ids.insert(app_id.to_string()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(app) = apps
|
||||
.iter()
|
||||
.find(|app| app.id == app_id && is_app_mentionable(app))
|
||||
{
|
||||
selected_app_ids.insert(app_id.to_string());
|
||||
if let Some(app) = apps.iter().find(|app| app.id == app_id && app.is_enabled) {
|
||||
items.push(UserInput::Mention {
|
||||
name: app.name.clone(),
|
||||
path: binding.path.clone(),
|
||||
|
||||
@@ -315,12 +315,12 @@ pub(crate) fn find_app_mentions(
|
||||
}
|
||||
|
||||
let mut slug_counts: HashMap<String, usize> = HashMap::new();
|
||||
for app in apps.iter().filter(|app| is_app_mentionable(app)) {
|
||||
for app in apps.iter().filter(|app| app.is_enabled) {
|
||||
let slug = codex_connectors::metadata::connector_mention_slug(app);
|
||||
*slug_counts.entry(slug).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
for app in apps.iter().filter(|app| is_app_mentionable(app)) {
|
||||
for app in apps.iter().filter(|app| app.is_enabled) {
|
||||
let slug = codex_connectors::metadata::connector_mention_slug(app);
|
||||
let slug_count = slug_counts.get(&slug).copied().unwrap_or(0);
|
||||
if mentions.names.contains(&slug)
|
||||
@@ -333,15 +333,11 @@ pub(crate) fn find_app_mentions(
|
||||
}
|
||||
|
||||
apps.iter()
|
||||
.filter(|app| is_app_mentionable(app) && selected_ids.contains(&app.id))
|
||||
.filter(|app| app.is_enabled && selected_ids.contains(&app.id))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn is_app_mentionable(app: &AppInfo) -> bool {
|
||||
app.is_accessible && app.is_enabled
|
||||
}
|
||||
|
||||
pub(crate) struct ToolMentions {
|
||||
names: HashSet<String>,
|
||||
linked_paths: HashMap<String, String>,
|
||||
@@ -501,74 +497,3 @@ fn app_id_from_path(path: &str) -> Option<&str> {
|
||||
path.strip_prefix("app://")
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn app(id: &str, name: &str) -> AppInfo {
|
||||
AppInfo {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_app_mentions_requires_accessible_enabled_apps_for_slugs() {
|
||||
let apps = vec![
|
||||
app("google_drive", "Google Drive"),
|
||||
AppInfo {
|
||||
is_accessible: false,
|
||||
..app("arabica_uae", "% Arabica UAE")
|
||||
},
|
||||
AppInfo {
|
||||
is_enabled: false,
|
||||
..app("linear", "Linear")
|
||||
},
|
||||
];
|
||||
let mentions = collect_tool_mentions("$google-drive $arabica-uae $linear", &HashMap::new());
|
||||
|
||||
assert_eq!(
|
||||
find_app_mentions(&mentions, &apps, &HashSet::new()),
|
||||
vec![apps[0].clone()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_app_mentions_requires_accessible_enabled_apps_for_bound_paths() {
|
||||
let apps = vec![
|
||||
app("google_drive", "Google Drive"),
|
||||
AppInfo {
|
||||
is_accessible: false,
|
||||
..app("arabica_uae", "% Arabica UAE")
|
||||
},
|
||||
AppInfo {
|
||||
is_enabled: false,
|
||||
..app("linear", "Linear")
|
||||
},
|
||||
];
|
||||
let mention_paths = HashMap::from([
|
||||
("google-drive".to_string(), "app://google_drive".to_string()),
|
||||
("arabica-uae".to_string(), "app://arabica_uae".to_string()),
|
||||
("linear".to_string(), "app://linear".to_string()),
|
||||
]);
|
||||
let mentions = collect_tool_mentions("$google-drive $arabica-uae $linear", &mention_paths);
|
||||
|
||||
assert_eq!(
|
||||
find_app_mentions(&mentions, &apps, &HashSet::new()),
|
||||
vec![apps[0].clone()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use super::*;
|
||||
use crate::app_event::ConnectorsSnapshot;
|
||||
use codex_protocol::models::ManagedFileSystemPermissions;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
@@ -1173,61 +1172,6 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submit_user_message_ignores_inaccessible_app_mentions_from_bindings() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.config
|
||||
.features
|
||||
.enable(Feature::Apps)
|
||||
.expect("test config should allow feature update");
|
||||
|
||||
chat.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
connectors: vec![AppInfo {
|
||||
id: "arabica_uae".to_string(),
|
||||
name: "% Arabica UAE".to_string(),
|
||||
description: Some("Directory-only app".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some("https://example.test/arabica".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}],
|
||||
}),
|
||||
/*is_final*/ false,
|
||||
);
|
||||
|
||||
chat.submit_user_message(UserMessage {
|
||||
text: "$arabica-uae".to_string(),
|
||||
local_images: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
mention_bindings: vec![MentionBinding {
|
||||
mention: "arabica-uae".to_string(),
|
||||
path: "app://arabica_uae".to_string(),
|
||||
}],
|
||||
});
|
||||
|
||||
let items = match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => items,
|
||||
other => panic!("expected Op::UserTurn, got {other:?}"),
|
||||
};
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![UserInput::Text {
|
||||
text: "$arabica-uae".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_message_display_from_inputs_matches_flattened_user_message_shape() {
|
||||
let local_image = PathBuf::from("/tmp/local.png");
|
||||
|
||||
@@ -1399,77 +1399,6 @@ async fn apps_popup_stays_loading_until_final_snapshot_updates() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_notification_update_excludes_inaccessible_apps_from_mentions() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.config
|
||||
.features
|
||||
.enable(Feature::Apps)
|
||||
.expect("test config should allow feature update");
|
||||
chat.bottom_pane.set_connectors_enabled(/*enabled*/ true);
|
||||
chat.bottom_pane
|
||||
.set_composer_text("$".to_string(), Vec::new(), Vec::new());
|
||||
|
||||
chat.on_connectors_loaded(
|
||||
Ok(ConnectorsSnapshot {
|
||||
connectors: vec![
|
||||
AppInfo {
|
||||
id: "google_drive".to_string(),
|
||||
name: "Google Drive".to_string(),
|
||||
description: Some("Connected files".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some("https://example.test/google-drive".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "arabica_uae".to_string(),
|
||||
name: "% Arabica UAE".to_string(),
|
||||
description: Some("Directory-only app".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some("https://example.test/arabica".to_string()),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
/*is_final*/ false,
|
||||
);
|
||||
|
||||
assert_matches!(
|
||||
&chat.connectors.partial_snapshot,
|
||||
Some(snapshot)
|
||||
if snapshot
|
||||
.connectors
|
||||
.iter()
|
||||
.find(|connector| connector.id == "arabica_uae")
|
||||
.is_some_and(|connector| !connector.is_accessible)
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert!(
|
||||
popup.contains("Google Drive"),
|
||||
"expected accessible apps to appear in the mention popup, got:\n{popup}"
|
||||
);
|
||||
assert!(
|
||||
!popup.contains("% Arabica UAE"),
|
||||
"did not expect an inaccessible directory app in the mention popup, got:\n{popup}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_refresh_failure_keeps_existing_full_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -40,17 +40,6 @@ pub enum HistoryLineWrapPolicy {
|
||||
Terminal,
|
||||
}
|
||||
|
||||
/// Selects the terminal escape strategy used when writing history above the viewport.
|
||||
///
|
||||
/// Raw lines intentionally remain unbroken so terminal selection copies their source faithfully.
|
||||
/// Zellij does not constrain soft-wrapped continuation rows to Codex's scroll region, so its raw
|
||||
/// path appends history through the terminal and reserves blank rows for the next viewport draw.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum InsertHistoryMode {
|
||||
Standard,
|
||||
ZellijRaw,
|
||||
}
|
||||
|
||||
/// Insert `lines` above the viewport using the terminal's backend writer
|
||||
/// (avoids direct stdout references).
|
||||
pub fn insert_history_lines<B>(
|
||||
@@ -68,23 +57,6 @@ pub fn insert_history_lines_with_wrap_policy<B>(
|
||||
lines: Vec<Line>,
|
||||
wrap_policy: HistoryLineWrapPolicy,
|
||||
) -> io::Result<()>
|
||||
where
|
||||
B: Backend + Write,
|
||||
{
|
||||
insert_history_lines_with_mode_and_wrap_policy(
|
||||
terminal,
|
||||
lines,
|
||||
InsertHistoryMode::Standard,
|
||||
wrap_policy,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn insert_history_lines_with_mode_and_wrap_policy<B>(
|
||||
terminal: &mut crate::custom_terminal::Terminal<B>,
|
||||
lines: Vec<Line>,
|
||||
mode: InsertHistoryMode,
|
||||
wrap_policy: HistoryLineWrapPolicy,
|
||||
) -> io::Result<()>
|
||||
where
|
||||
B: Backend + Write,
|
||||
{
|
||||
@@ -93,6 +65,7 @@ where
|
||||
let mut area = terminal.viewport_area;
|
||||
let mut should_update_area = false;
|
||||
let last_cursor_pos = terminal.last_known_cursor_pos;
|
||||
let writer = terminal.backend_mut();
|
||||
|
||||
// Pre-wrap lines for terminal scrollback. Three paths:
|
||||
//
|
||||
@@ -129,92 +102,60 @@ where
|
||||
wrapped.extend(line_wrapped);
|
||||
}
|
||||
let wrapped_lines = wrapped_rows as u16;
|
||||
match mode {
|
||||
InsertHistoryMode::ZellijRaw => {
|
||||
// The existing viewport is immediately replaced in the same draw pass. Clear it
|
||||
// before terminal scrolling can move composer contents into scrollback.
|
||||
terminal.clear_after_position(area.as_position())?;
|
||||
let writer = terminal.backend_mut();
|
||||
queue!(writer, MoveTo(/*x*/ 0, area.top()))?;
|
||||
for (index, line) in wrapped.iter().enumerate() {
|
||||
if index > 0 {
|
||||
queue!(writer, Print("\r\n"))?;
|
||||
}
|
||||
write_history_line(writer, line, wrap_width)?;
|
||||
}
|
||||
let cursor_top = if area.bottom() < screen_size.height {
|
||||
// If the viewport is not at the bottom of the screen, scroll it down to make room.
|
||||
// Don't scroll it past the bottom of the screen.
|
||||
let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom());
|
||||
|
||||
// Writing raw source text through the terminal preserves its soft-wrap metadata.
|
||||
// Advance through empty rows for the viewport so history ends immediately above the
|
||||
// composer even when a replay batch is taller than the visible history region.
|
||||
for _ in 0..area.height {
|
||||
queue!(writer, Print("\r\n"), Clear(ClearType::UntilNewLine))?;
|
||||
}
|
||||
queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y))?;
|
||||
|
||||
let viewport_top = area
|
||||
.top()
|
||||
.saturating_add(wrapped_lines)
|
||||
.min(screen_size.height.saturating_sub(area.height));
|
||||
if area.y != viewport_top {
|
||||
area.y = viewport_top;
|
||||
should_update_area = true;
|
||||
}
|
||||
let top_1based = area.top() + 1;
|
||||
queue!(writer, SetScrollRegion(top_1based..screen_size.height))?;
|
||||
queue!(writer, MoveTo(/*x*/ 0, area.top()))?;
|
||||
for _ in 0..scroll_amount {
|
||||
queue!(writer, Print("\x1bM"))?;
|
||||
}
|
||||
InsertHistoryMode::Standard => {
|
||||
let writer = terminal.backend_mut();
|
||||
let cursor_top = if area.bottom() < screen_size.height {
|
||||
// If the viewport is not at the bottom of the screen, scroll it down to make room.
|
||||
// Don't scroll it past the bottom of the screen.
|
||||
let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom());
|
||||
queue!(writer, ResetScrollRegion)?;
|
||||
|
||||
let top_1based = area.top() + 1;
|
||||
queue!(writer, SetScrollRegion(top_1based..screen_size.height))?;
|
||||
queue!(writer, MoveTo(/*x*/ 0, area.top()))?;
|
||||
for _ in 0..scroll_amount {
|
||||
queue!(writer, Print("\x1bM"))?;
|
||||
}
|
||||
queue!(writer, ResetScrollRegion)?;
|
||||
let cursor_top = area.top().saturating_sub(1);
|
||||
area.y += scroll_amount;
|
||||
should_update_area = true;
|
||||
cursor_top
|
||||
} else {
|
||||
area.top().saturating_sub(1)
|
||||
};
|
||||
|
||||
let cursor_top = area.top().saturating_sub(1);
|
||||
area.y += scroll_amount;
|
||||
should_update_area = true;
|
||||
cursor_top
|
||||
} else {
|
||||
area.top().saturating_sub(1)
|
||||
};
|
||||
// Limit the scroll region to the lines from the top of the screen to the
|
||||
// top of the viewport. With this in place, when we add lines inside this
|
||||
// area, only the lines in this area will be scrolled. We place the cursor
|
||||
// at the end of the scroll region, and add lines starting there.
|
||||
//
|
||||
// ┌─Screen───────────────────────┐
|
||||
// │┌╌Scroll region╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐│
|
||||
// │┆ ┆│
|
||||
// │┆ ┆│
|
||||
// │┆ ┆│
|
||||
// │█╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘│
|
||||
// │╭─Viewport───────────────────╮│
|
||||
// ││ ││
|
||||
// │╰────────────────────────────╯│
|
||||
// └──────────────────────────────┘
|
||||
queue!(writer, SetScrollRegion(1..area.top()))?;
|
||||
|
||||
// Limit the scroll region to the lines from the top of the screen to the
|
||||
// top of the viewport. With this in place, when we add lines inside this
|
||||
// area, only the lines in this area will be scrolled. We place the cursor
|
||||
// at the end of the scroll region, and add lines starting there.
|
||||
//
|
||||
// ┌─Screen───────────────────────┐
|
||||
// │┌╌Scroll region╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐│
|
||||
// │┆ ┆│
|
||||
// │┆ ┆│
|
||||
// │┆ ┆│
|
||||
// │█╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘│
|
||||
// │╭─Viewport───────────────────╮│
|
||||
// ││ ││
|
||||
// │╰────────────────────────────╯│
|
||||
// └──────────────────────────────┘
|
||||
queue!(writer, SetScrollRegion(1..area.top()))?;
|
||||
// NB: we are using MoveTo instead of set_cursor_position here to avoid messing with the
|
||||
// terminal's last_known_cursor_position, which hopefully will still be accurate after we
|
||||
// fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :)
|
||||
queue!(writer, MoveTo(/*x*/ 0, cursor_top))?;
|
||||
|
||||
// NB: we are using MoveTo instead of set_cursor_position here to avoid messing with the
|
||||
// terminal's last_known_cursor_position, which hopefully will still be accurate after we
|
||||
// fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :)
|
||||
queue!(writer, MoveTo(/*x*/ 0, cursor_top))?;
|
||||
|
||||
for line in &wrapped {
|
||||
queue!(writer, Print("\r\n"))?;
|
||||
write_history_line(writer, line, wrap_width)?;
|
||||
}
|
||||
|
||||
queue!(writer, ResetScrollRegion)?;
|
||||
queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y))?;
|
||||
}
|
||||
for line in &wrapped {
|
||||
queue!(writer, Print("\r\n"))?;
|
||||
write_history_line(writer, line, wrap_width)?;
|
||||
}
|
||||
|
||||
queue!(writer, ResetScrollRegion)?;
|
||||
|
||||
// Restore the cursor position to where it was before we started.
|
||||
queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y))?;
|
||||
|
||||
let _ = writer;
|
||||
if should_update_area {
|
||||
terminal.set_viewport_area(area);
|
||||
}
|
||||
@@ -855,85 +796,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vt100_zellij_raw_insert_keeps_soft_wrapped_tail_above_viewport() {
|
||||
let width: u16 = 20;
|
||||
let height: u16 = 8;
|
||||
let backend = VT100Backend::new(width, height);
|
||||
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
|
||||
let viewport = Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ height - 2,
|
||||
/*width*/ width,
|
||||
/*height*/ 2,
|
||||
);
|
||||
term.set_viewport_area(viewport);
|
||||
|
||||
let line = Line::from("raw-start-aaaaaaaaaaaaaaaaaaaaaaaa-tail-must-remain");
|
||||
insert_history_lines_with_mode_and_wrap_policy(
|
||||
&mut term,
|
||||
vec![line],
|
||||
InsertHistoryMode::ZellijRaw,
|
||||
HistoryLineWrapPolicy::Terminal,
|
||||
)
|
||||
.expect("insert Zellij raw history");
|
||||
|
||||
let rows: Vec<String> = term.backend().vt100().screen().rows(0, width).collect();
|
||||
insta::assert_snapshot!("zellij_raw_terminal_wrap_above_viewport", rows.join("\n"));
|
||||
let history_rows = rows[..usize::from(term.viewport_area.y)]
|
||||
.iter()
|
||||
.map(|row| row.trim_end())
|
||||
.collect::<String>();
|
||||
let viewport_rows = rows[usize::from(term.viewport_area.y)..].join("\n");
|
||||
assert!(
|
||||
history_rows.contains("tail-must-remain"),
|
||||
"expected wrapped raw tail above the viewport, rows: {rows:?}"
|
||||
);
|
||||
assert!(
|
||||
!viewport_rows.contains("tail-must-remain"),
|
||||
"raw tail must not be written through the viewport, rows: {rows:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vt100_zellij_raw_replay_keeps_overflowing_soft_wrapped_tail_above_viewport() {
|
||||
let width: u16 = 20;
|
||||
let height: u16 = 8;
|
||||
let backend = VT100Backend::new(width, height);
|
||||
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
|
||||
term.set_viewport_area(Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ width, /*height*/ 2,
|
||||
));
|
||||
|
||||
let line = Line::from(format!("raw-start-{}tail-must-remain", "a".repeat(130)));
|
||||
insert_history_lines_with_mode_and_wrap_policy(
|
||||
&mut term,
|
||||
vec![line],
|
||||
InsertHistoryMode::ZellijRaw,
|
||||
HistoryLineWrapPolicy::Terminal,
|
||||
)
|
||||
.expect("replay Zellij raw history");
|
||||
|
||||
let rows: Vec<String> = term.backend().vt100().screen().rows(0, width).collect();
|
||||
insta::assert_snapshot!(
|
||||
"zellij_raw_terminal_wrap_overflow_above_viewport",
|
||||
rows.join("\n")
|
||||
);
|
||||
let history_rows = rows[..usize::from(term.viewport_area.y)]
|
||||
.iter()
|
||||
.map(|row| row.trim_end())
|
||||
.collect::<String>();
|
||||
let viewport_rows = rows[usize::from(term.viewport_area.y)..].join("\n");
|
||||
assert!(
|
||||
history_rows.contains("tail-must-remain"),
|
||||
"expected overflowing raw tail above the viewport, rows: {rows:?}"
|
||||
);
|
||||
assert!(
|
||||
!viewport_rows.contains("tail-must-remain"),
|
||||
"overflowing raw tail must not be written through the viewport, rows: {rows:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vt100_unwrapped_url_like_clears_continuation_rows() {
|
||||
let width: u16 = 20;
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
source: tui/src/insert_history.rs
|
||||
expression: "rows.join(\"\\n\")"
|
||||
---
|
||||
|
||||
|
||||
|
||||
raw-start-aaaaaaaaaa
|
||||
aaaaaaaaaaaaaa-tail-
|
||||
must-remain
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
source: tui/src/insert_history.rs
|
||||
expression: "rows.join(\"\\n\")"
|
||||
---
|
||||
aaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaa
|
||||
aaaaaaaaaaaaaaaaaaaa
|
||||
tail-must-remain
|
||||
@@ -41,7 +41,6 @@ pub use self::frame_requester::FrameRequester;
|
||||
use crate::custom_terminal;
|
||||
use crate::custom_terminal::Terminal as CustomTerminal;
|
||||
use crate::insert_history::HistoryLineWrapPolicy;
|
||||
use crate::insert_history::InsertHistoryMode;
|
||||
use crate::notifications::DesktopNotificationBackend;
|
||||
use crate::notifications::detect_backend;
|
||||
use crate::tui::event_stream::EventBroker;
|
||||
@@ -501,8 +500,6 @@ pub struct Tui {
|
||||
enhanced_keys_supported: bool,
|
||||
notification_backend: Option<DesktopNotificationBackend>,
|
||||
notification_condition: NotificationCondition,
|
||||
// Raw terminal-wrapped history needs a non-scroll-region insertion path in Zellij.
|
||||
is_zellij: bool,
|
||||
// When false, enter_alt_screen() becomes a no-op.
|
||||
alt_screen_enabled: bool,
|
||||
// Keeps unmanaged process stderr writes out of the inline viewport.
|
||||
@@ -538,7 +535,6 @@ impl Tui {
|
||||
// Cache this to avoid contention with the event reader.
|
||||
supports_color::on_cached(supports_color::Stream::Stdout);
|
||||
let _ = crate::terminal_palette::default_colors();
|
||||
let is_zellij = codex_terminal_detection::terminal_info().is_zellij();
|
||||
|
||||
Self {
|
||||
frame_requester,
|
||||
@@ -556,7 +552,6 @@ impl Tui {
|
||||
enhanced_keys_supported,
|
||||
notification_backend: Some(detect_backend(NotificationMethod::default())),
|
||||
notification_condition: NotificationCondition::default(),
|
||||
is_zellij,
|
||||
alt_screen_enabled: true,
|
||||
_stderr_guard: stderr_guard,
|
||||
}
|
||||
@@ -802,22 +797,15 @@ impl Tui {
|
||||
fn flush_pending_history_lines(
|
||||
terminal: &mut Terminal,
|
||||
pending_history_lines: &mut Vec<PendingHistoryLines>,
|
||||
is_zellij: bool,
|
||||
) -> Result<()> {
|
||||
if pending_history_lines.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for batch in pending_history_lines.iter() {
|
||||
let mode = if is_zellij && batch.wrap_policy == HistoryLineWrapPolicy::Terminal {
|
||||
InsertHistoryMode::ZellijRaw
|
||||
} else {
|
||||
InsertHistoryMode::Standard
|
||||
};
|
||||
crate::insert_history::insert_history_lines_with_mode_and_wrap_policy(
|
||||
crate::insert_history::insert_history_lines_with_wrap_policy(
|
||||
terminal,
|
||||
batch.lines.clone(),
|
||||
mode,
|
||||
batch.wrap_policy,
|
||||
)?;
|
||||
}
|
||||
@@ -874,11 +862,7 @@ impl Tui {
|
||||
terminal.set_viewport_area(area);
|
||||
}
|
||||
|
||||
Self::flush_pending_history_lines(
|
||||
terminal,
|
||||
&mut self.pending_history_lines,
|
||||
self.is_zellij,
|
||||
)?;
|
||||
Self::flush_pending_history_lines(terminal, &mut self.pending_history_lines)?;
|
||||
|
||||
// Update the y position for suspending so Ctrl-Z can place the cursor correctly.
|
||||
#[cfg(unix)]
|
||||
@@ -984,11 +968,7 @@ impl Tui {
|
||||
let terminal = &mut self.terminal;
|
||||
let needs_full_repaint =
|
||||
Self::update_inline_viewport_for_resize_reflow(terminal, height)?;
|
||||
Self::flush_pending_history_lines(
|
||||
terminal,
|
||||
&mut self.pending_history_lines,
|
||||
self.is_zellij,
|
||||
)?;
|
||||
Self::flush_pending_history_lines(terminal, &mut self.pending_history_lines)?;
|
||||
|
||||
if needs_full_repaint {
|
||||
terminal.invalidate_viewport();
|
||||
|
||||
Reference in New Issue
Block a user