Compare commits

..

1 Commits

Author SHA1 Message Date
arun
4948a9d273 Hide app-only MCP tools from model 2026-05-26 19:01:52 +00:00
16 changed files with 152 additions and 446 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -3790,6 +3790,7 @@ dependencies = [
"codex-app-server-client",
"codex-app-server-protocol",
"codex-arg0",
"codex-chatgpt",
"codex-cli",
"codex-cloud-requirements",
"codex-config",

View File

@@ -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" }

View File

@@ -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) {

View File

@@ -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,
));

View File

@@ -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;

View File

@@ -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 }

View File

@@ -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;

View File

@@ -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

View File

@@ -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(),

View File

@@ -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()]
);
}
}

View File

@@ -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");

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,10 +0,0 @@
---
source: tui/src/insert_history.rs
expression: "rows.join(\"\\n\")"
---
raw-start-aaaaaaaaaa
aaaaaaaaaaaaaa-tail-
must-remain

View File

@@ -1,10 +0,0 @@
---
source: tui/src/insert_history.rs
expression: "rows.join(\"\\n\")"
---
aaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaa
tail-must-remain

View File

@@ -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();