Codex Handbook
tui/src/ide_context/prompt.rs 403 lines
//! Prompt rendering for IDE context injected into TUI user turns.use codex_app_server_protocol::ByteRange;use codex_app_server_protocol::TextElement;use codex_app_server_protocol::UserInput;use super::IdeContext;const MAX_ACTIVE_SELECTION_CHARS: usize = 40_000;const MAX_OPEN_TABS: usize = 100;const MAX_OPEN_TABS_CHARS: usize = 20_000;// Match the desktop app and IDE extension delimiter exactly. IDE context is serialized into the// raw prompt before this marker, then transcript rendering strips back to the request after the last// marker. Keeping the same marker and stripping semantics lets threads created with IDE context in// one surface replay cleanly in the others.const PROMPT_REQUEST_BEGIN: &str = "## My request for Codex:";pub(crate) fn apply_ide_context_to_user_input(    context: &IdeContext,    items: &mut Vec<UserInput>,) -> bool {    let Some(context_text) = render_prompt_context(context) else {        return false;    };    let prefix = format!("{context_text}\n{PROMPT_REQUEST_BEGIN}\n");    if let Some(text_index) = items        .iter()        .position(|item| matches!(item, UserInput::Text { .. }))    {        // Prefix the existing text item in place so image and text items keep        // the same relative order they had in the user's original submission.        let item = std::mem::replace(            &mut items[text_index],            UserInput::Text {                text: String::new(),                text_elements: Vec::new(),            },        );        let UserInput::Text {            text,            text_elements,        } = item        else {            unreachable!("position matched a text item");        };        items[text_index] = prefixed_text_input(prefix, text, text_elements);    } else {        items.insert(            0,            UserInput::Text {                text: prefix,                text_elements: Vec::new(),            },        );    }    true}pub(crate) fn has_prompt_context(context: &IdeContext) -> bool {    render_prompt_context(context).is_some()}pub(crate) fn extract_prompt_request_with_offset(message: &str) -> (&str, usize) {    let Some((before_request, request)) = message.rsplit_once(PROMPT_REQUEST_BEGIN) else {        return (message, 0);    };    let request_start = before_request.len() + PROMPT_REQUEST_BEGIN.len();    let trimmed_request = request.trim();    let leading_trimmed_len = request.len() - request.trim_start().len();    (trimmed_request, request_start + leading_trimmed_len)}fn prefixed_text_input(prefix: String, text: String, text_elements: Vec<TextElement>) -> UserInput {    let prefix_len = prefix.len();    UserInput::Text {        text: format!("{prefix}{text}"),        text_elements: text_elements            .into_iter()            .map(|element| {                let range = element.byte_range.clone();                TextElement::new(                    ByteRange {                        start: range.start + prefix_len,                        end: range.end + prefix_len,                    },                    element.placeholder().map(str::to_string),                )            })            .collect(),    }}fn render_prompt_context(context: &IdeContext) -> Option<String> {    let mut ide_context_section = String::new();    if let Some(active_file) = &context.active_file {        ide_context_section.push_str(&format!(            "\n## Active file: {}\n",            active_file.descriptor.path        ));    }    if let Some(active_file) = &context.active_file {        let selected_ranges = if active_file.selections.is_empty() {            std::slice::from_ref(&active_file.selection)        } else {            active_file.selections.as_slice()        }        .iter()        .filter(|range| range.start != range.end)        .collect::<Vec<_>>();        if !selected_ranges.is_empty()            && (active_file.active_selection_content.is_empty() || selected_ranges.len() > 1)        {            if selected_ranges.len() == 1 {                ide_context_section.push_str("\n## Active selection range:\n");            } else {                ide_context_section.push_str("\n## Active selection ranges:\n");            }            for range in selected_ranges {                // Render ranges as 1-based positions for the prompt.                let start_line = range.start.line + 1;                let start_column = range.start.character + 1;                let end_line = range.end.line + 1;                let end_column = range.end.character + 1;                ide_context_section.push_str(&format!(                    "- {}: line {start_line}, column {start_column} to line {end_line}, column {end_column}\n",                    active_file.descriptor.path                ));            }        }    }    if let Some(active_file) = &context.active_file        && !active_file.active_selection_content.is_empty()    {        ide_context_section.push_str("\n## Active selection of the file:\n");        let selection = active_file.active_selection_content.as_str();        if let Some((truncate_at, _)) = selection.char_indices().nth(MAX_ACTIVE_SELECTION_CHARS) {            ide_context_section.push_str(&selection[..truncate_at]);            ide_context_section.push_str(&format!(                "\n[Selection truncated to {MAX_ACTIVE_SELECTION_CHARS} characters.]\n"            ));        } else {            ide_context_section.push_str(selection);        }    }    if !context.open_tabs.is_empty() {        ide_context_section.push_str("\n## Open tabs:\n");        let mut rendered_tabs = 0;        let mut rendered_tab_chars = 0;        for tab in &context.open_tabs {            if rendered_tabs >= MAX_OPEN_TABS {                break;            }            let tab_line = format!("- {}: {}\n", tab.label, tab.path);            if rendered_tab_chars + tab_line.len() > MAX_OPEN_TABS_CHARS {                break;            }            ide_context_section.push_str(&tab_line);            rendered_tabs += 1;            rendered_tab_chars += tab_line.len();        }        let omitted_tabs = context.open_tabs.len() - rendered_tabs;        if omitted_tabs > 0 {            ide_context_section.push_str(&format!("[{omitted_tabs} open tabs omitted.]\n"));        }    }    if ide_context_section.is_empty() {        None    } else {        Some(format!(            "# Context from my IDE setup:\n{ide_context_section}"        ))    }}#[cfg(test)]mod tests {    use super::super::ActiveFile;    use super::super::FileDescriptor;    use super::super::IdeContext;    use super::super::Position;    use super::super::Range;    use super::*;    use pretty_assertions::assert_eq;    use std::path::PathBuf;    fn descriptor(label: &str, path: &str) -> FileDescriptor {        FileDescriptor {            label: label.to_string(),            path: path.to_string(),        }    }    #[test]    fn render_prompt_context_matches_app_format() {        let context = IdeContext {            active_file: Some(ActiveFile {                descriptor: descriptor("lib.rs", "src/lib.rs"),                selection: Range {                    start: Position {                        line: 4,                        character: 0,                    },                    end: Position {                        line: 6,                        character: 1,                    },                },                active_selection_content: "fn selected() {}".to_string(),                selections: Vec::new(),            }),            open_tabs: vec![                descriptor("lib.rs", "src/lib.rs"),                descriptor("main.rs", "src/main.rs"),            ],        };        assert_eq!(            render_prompt_context(&context),            Some(                "# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## Active selection of the file:\nfn selected() {}\n## Open tabs:\n- lib.rs: src/lib.rs\n- main.rs: src/main.rs\n"                    .to_string()            )        );    }    #[test]    fn render_prompt_context_omits_empty_context() {        let context = IdeContext {            active_file: None,            open_tabs: Vec::new(),        };        assert_eq!(render_prompt_context(&context), None);    }    #[test]    fn apply_ide_context_uses_desktop_prompt_request_delimiter() {        let context = IdeContext {            active_file: Some(ActiveFile {                descriptor: descriptor("lib.rs", "src/lib.rs"),                selection: Range {                    start: Position {                        line: 0,                        character: 0,                    },                    end: Position {                        line: 0,                        character: 0,                    },                },                active_selection_content: String::new(),                selections: Vec::new(),            }),            open_tabs: Vec::new(),        };        let text = "Ask $figma".to_string();        let mut items = vec![            UserInput::LocalImage {                path: PathBuf::from("/tmp/screenshot.png"),                detail: None,            },            UserInput::Text {                text,                text_elements: vec![TextElement::new(                    ByteRange { start: 4, end: 10 },                    Some("$figma".to_string()),                )],            },        ];        assert!(apply_ide_context_to_user_input(&context, &mut items));        let expected_prefix = "# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## My request for Codex:\n";        let prefix_len = expected_prefix.len();        assert_eq!(            items,            vec![                UserInput::LocalImage {                    path: PathBuf::from("/tmp/screenshot.png"),                    detail: None,                },                UserInput::Text {                    text: format!("{expected_prefix}Ask $figma"),                    text_elements: vec![TextElement::new(                        ByteRange {                            start: prefix_len + 4,                            end: prefix_len + 10,                        },                        Some("$figma".to_string()),                    )],                },            ]        );    }    #[test]    fn extract_prompt_request_returns_text_after_last_delimiter() {        let message =            "# Context\n## My request for Codex:\nFirst\n## My request for Codex:\n  Second\n";        assert_eq!(            extract_prompt_request_with_offset(message),            ("Second", message.find("Second").expect("request offset"))        );    }    #[test]    fn render_prompt_context_includes_selection_ranges_without_content() {        let first_range = Range {            start: Position {                line: 1,                character: 2,            },            end: Position {                line: 1,                character: 5,            },        };        let second_range = Range {            start: Position {                line: 3,                character: 0,            },            end: Position {                line: 4,                character: 1,            },        };        let context = IdeContext {            active_file: Some(ActiveFile {                descriptor: descriptor("lib.rs", "src/lib.rs"),                selection: first_range.clone(),                active_selection_content: String::new(),                selections: vec![first_range, second_range],            }),            open_tabs: Vec::new(),        };        assert_eq!(            render_prompt_context(&context),            Some(                "# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## Active selection ranges:\n- src/lib.rs: line 2, column 3 to line 2, column 6\n- src/lib.rs: line 4, column 1 to line 5, column 2\n"                    .to_string()            )        );    }    #[test]    fn render_prompt_context_truncates_large_selection() {        let context = IdeContext {            active_file: Some(ActiveFile {                descriptor: descriptor("large.txt", "large.txt"),                selection: Range {                    start: Position {                        line: 0,                        character: 0,                    },                    end: Position {                        line: 0,                        character: 1,                    },                },                active_selection_content: format!("{}tail", "a".repeat(MAX_ACTIVE_SELECTION_CHARS)),                selections: Vec::new(),            }),            open_tabs: Vec::new(),        };        let rendered = render_prompt_context(&context).expect("rendered IDE context");        assert!(rendered.contains(&format!(            "[Selection truncated to {MAX_ACTIVE_SELECTION_CHARS} characters.]"        )));        assert!(!rendered.contains("tail"));    }    #[test]    fn render_prompt_context_omits_excess_open_tabs() {        let open_tabs = (0..MAX_OPEN_TABS + 2)            .map(|index| descriptor(&format!("file-{index}.rs"), &format!("src/file-{index}.rs")))            .collect::<Vec<_>>();        let context = IdeContext {            active_file: None,            open_tabs,        };        let rendered = render_prompt_context(&context).expect("rendered IDE context");        assert!(rendered.contains("- file-99.rs: src/file-99.rs\n"));        assert!(!rendered.contains("- file-100.rs: src/file-100.rs\n"));        assert!(rendered.contains("[2 open tabs omitted.]\n"));    }}