diff --git a/codex-rs/tui/src/assistant_directives.rs b/codex-rs/tui/src/assistant_directives.rs new file mode 100644 index 0000000000..f88306448a --- /dev/null +++ b/codex-rs/tui/src/assistant_directives.rs @@ -0,0 +1,119 @@ +//! Parse structured annotations embedded in assistant-authored Markdown. +//! +//! For example, `::git-create-pr{cwd="/repo" isDraft=true}` becomes the name +//! `git-create-pr` and the attributes `cwd` = `/repo` and `isDraft` = `true`. +//! A review annotation such as `::code-comment{body="Keep \"x}\" literal."}` +//! can contain quotes and braces within its quoted value. Consumers choose +//! the quote-escaping rules and interpret the attributes; this module also +//! retains the exact directive source, excluding any trailing Markdown. + +use std::borrow::Cow; +use std::collections::BTreeMap; + +/// An assistant annotation and its complete, unmodified source representation. +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct AssistantDirective<'source> { + pub(crate) name: &'source str, + pub(crate) attributes: BTreeMap<&'source str, Cow<'source, str>>, + pub(crate) raw: &'source str, +} + +/// Git receipts preserve backslashes; review comments allow escaped quote delimiters. +#[derive(Clone, Copy, Eq, PartialEq)] +pub(crate) enum QuoteEscaping { + Literal, + Backslash, +} + +/// Parse one inline, leaf, or container marker from the beginning of `source`. +pub(crate) fn parse_assistant_directive( + source: &str, + escaping: QuoteEscaping, +) -> Option> { + // `::git-create-pr{...}` starts with a one-to-three-colon marker and a name; + // require `{` immediately after the name so `::git-create-pr prose` is not parsed. + let rest = source.trim_start_matches(':'); + if !(1..=3).contains(&(source.len() - rest.len())) { + return None; + } + let name_len = rest.bytes().take_while(|byte| is_name_byte(*byte)).count(); + let (name, suffix) = rest.split_at(name_len); + let mut rest = suffix.strip_prefix('{')?; + if !name.as_bytes().first().is_some_and(u8::is_ascii_alphabetic) { + return None; + } + + let mut attributes = BTreeMap::new(); + loop { + rest = rest.trim_start_matches([' ', '\t']); + if let Some(suffix) = rest.strip_prefix('}') { + // In `::git-push{cwd="/repo"} done`, retain the directive but not ` done`. + return Some(AssistantDirective { + name, + attributes, + raw: &source[..source.len() - suffix.len()], + }); + } + // For `cwd = "/repo" isDraft=true}`, split off `cwd` and leave the next + // attribute for the next iteration after consuming this value. + let key_len = rest.bytes().take_while(|byte| is_name_byte(*byte)).count(); + let (key, suffix) = rest.split_at(key_len); + // Reject malformed keys and duplicates before scanning a potentially long value. + if key.is_empty() || attributes.contains_key(key) { + return None; + } + let value = suffix.trim_start_matches([' ', '\t']).strip_prefix('=')?; + rest = value.trim_start_matches([' ', '\t']); + let value = if let Some(delimiter @ (b'"' | b'\'')) = rest.as_bytes().first().copied() { + // In `body="Keep \"x}\" literal."`, only the matching unescaped quote + // ends the value, not the embedded `}`. Single-quoted values work too. + let quoted = &rest[1..]; + let mut characters = quoted.char_indices().peekable(); + let end = loop { + let (index, character) = characters.next()?; + match character { + c if c == char::from(delimiter) => break index, + '\n' | '\r' => return None, + '\\' if escaping == QuoteEscaping::Backslash + && characters + .peek() + .is_some_and(|(_, next)| *next == char::from(delimiter)) => + { + // Backslash mode consumes `\"` as a literal quote. Literal + // mode instead lets the quote close `cwd="/repo\"`. + characters.next(); + } + _ => {} + } + }; + let value = "ed[..end]; + rest = "ed[end + 1..]; + let escaped = format!("\\{}", char::from(delimiter)); + if escaping == QuoteEscaping::Backslash && value.contains(&escaped) { + Cow::Owned(value.replace(&escaped, &char::from(delimiter).to_string())) + } else { + Cow::Borrowed(value) + } + } else { + // Unquoted `isDraft=true` ends at whitespace or `}`, not at a quote. + let end = rest + .find([' ', '\t', '}', '\n', '\r']) + .unwrap_or(rest.len()); + if end == 0 { + return None; + } + let value = &rest[..end]; + rest = &rest[end..]; + Cow::Borrowed(value) + }; + attributes.insert(key, value); + } +} + +fn is_name_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') +} + +#[cfg(test)] +#[path = "assistant_directives_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/assistant_directives_tests.rs b/codex-rs/tui/src/assistant_directives_tests.rs new file mode 100644 index 0000000000..8592f1e6c9 --- /dev/null +++ b/codex-rs/tui/src/assistant_directives_tests.rs @@ -0,0 +1,86 @@ +//! Shared directive grammar and caller-specific quote compatibility. + +use super::AssistantDirective; +use super::QuoteEscaping; +use super::parse_assistant_directive; +use pretty_assertions::assert_eq; +use std::borrow::Cow; +use std::collections::BTreeMap; + +#[test] +fn parses_git_receipts_with_unquoted_attributes() { + let raw = r#"::git-create-pr{cwd="/repo\" branch="feature/report" isDraft=true}"#; + + assert_eq!( + parse_assistant_directive(raw, QuoteEscaping::Literal), + Some(AssistantDirective { + name: "git-create-pr", + attributes: BTreeMap::from([ + ("branch", Cow::Borrowed("feature/report")), + ("cwd", Cow::Borrowed(r"/repo\")), + ("isDraft", Cow::Borrowed("true")), + ]), + raw, + }), + ); +} + +#[test] +fn parses_triple_colon_comments_with_escaped_quotes_and_closing_braces() { + let raw = r#":::code-comment{title="Fix class" body="Keep \"px-${size}\" literal." file="C:\Users\me\app.ts" start=10 priority=2}"#; + let source = format!("{raw} remaining text"); + + assert_eq!( + parse_assistant_directive(&source, QuoteEscaping::Backslash), + Some(AssistantDirective { + name: "code-comment", + attributes: BTreeMap::from([ + ( + "body", + Cow::Owned(r#"Keep "px-${size}" literal."#.to_string()) + ), + ("file", Cow::Borrowed(r"C:\Users\me\app.ts")), + ("priority", Cow::Borrowed("2")), + ("start", Cow::Borrowed("10")), + ("title", Cow::Borrowed("Fix class")), + ]), + raw, + }), + ); +} + +#[test] +fn preserves_artifact_metadata_and_single_quoted_values() { + let raw = ":artifact{path='Quarterly Report.xlsx' label='team\\'s report' sheet='Revenue' range='A1:D8'}"; + + assert_eq!( + parse_assistant_directive(raw, QuoteEscaping::Backslash), + Some(AssistantDirective { + name: "artifact", + attributes: BTreeMap::from([ + ("label", Cow::Owned("team's report".to_string())), + ("path", Cow::Borrowed("Quarterly Report.xlsx")), + ("range", Cow::Borrowed("A1:D8")), + ("sheet", Cow::Borrowed("Revenue")), + ]), + raw, + }), + ); +} + +#[test] +fn rejects_ambiguous_or_incomplete_directives() { + for source in [ + r#"::git-push{cwd="/repo" cwd="/other"}"#, + r#"::git-push{cwd="/repo" branch="feature""#, + "::::code-comment{title=comment}", + ":artifact{path=/tmp/a path=:artifact{path=/tmp/b}}", + ":artifact{invalid:key=:artifact{path=/tmp/a}}", + ":artifact{path=/tmp/a\nnext=value}", + ] { + assert_eq!( + parse_assistant_directive(source, QuoteEscaping::Backslash), + None + ); + } +} diff --git a/codex-rs/tui/src/git_action_directives.rs b/codex-rs/tui/src/git_action_directives.rs index 98747ca9b1..7477d7053b 100644 --- a/codex-rs/tui/src/git_action_directives.rs +++ b/codex-rs/tui/src/git_action_directives.rs @@ -1,6 +1,8 @@ //! Codex App directives embedded in assistant markdown. -use std::collections::HashMap; +use crate::assistant_directives::AssistantDirective; +use crate::assistant_directives::QuoteEscaping; +use crate::assistant_directives::parse_assistant_directive; use std::collections::HashSet; use std::path::Path; @@ -58,11 +60,8 @@ pub(crate) fn parse_assistant_markdown(markdown: &str, cwd: &Path) -> ParsedAssi let mut visible_lines = Vec::new(); for line in markdown.lines() { - if let Some(rewritten) = rewrite_code_comment_line(line, cwd) { - visible_lines.push(rewritten.trim_end().to_string()); - continue; - } - let (visible_line, line_actions) = strip_line_directives(line); + let (visible_line, line_actions) = + rewrite_code_comment_line(line, cwd).unwrap_or_else(|| strip_line_directives(line)); for action in line_actions { if seen.insert(action.clone()) { git_actions.push(action); @@ -84,32 +83,29 @@ pub(crate) fn parse_assistant_markdown(markdown: &str, cwd: &Path) -> ParsedAssi } } -fn rewrite_code_comment_line(line: &str, cwd: &Path) -> Option { +fn rewrite_code_comment_line(line: &str, cwd: &Path) -> Option<(String, Vec)> { let content = line.trim_start_matches([' ', '\t']); let indent = &line[..line.len() - content.len()]; - let marker_length = content.bytes().take_while(|byte| *byte == b':').count(); - if !(1..=3).contains(&marker_length) { + let directive = parse_assistant_directive(content, QuoteEscaping::Backslash)?; + if directive.name != "code-comment" { return None; } - - let directive = content[marker_length..].strip_prefix("code-comment{")?; - let (attributes, suffix) = directive.rsplit_once('}')?; - let attributes = parse_code_comment_attributes(attributes)?; - let title = attributes.get("title")?; - let body = attributes.get("body")?; - let file = attributes.get("file")?; + let suffix = &content[directive.raw.len()..]; + let title = directive.attributes.get("title")?; + let body = directive.attributes.get("body")?; + let file = directive.attributes.get("file")?; let title = title.trim(); let body = body.trim(); let file = file.trim(); (!title.is_empty() && !body.is_empty() && !file.is_empty()).then_some(())?; - let start = directive_integer(&attributes, "start").unwrap_or(1).max(1); - let end = directive_integer(&attributes, "end") + let start = directive_integer(&directive, "start").unwrap_or(1).max(1); + let end = directive_integer(&directive, "end") .unwrap_or(start) .max(start); let title = if title_has_priority(title) { title.to_string() - } else if let Some(priority @ 0..=3) = directive_integer(&attributes, "priority") { + } else if let Some(priority @ 0..=3) = directive_integer(&directive, "priority") { format!("[P{priority}] {title}") } else { title.to_string() @@ -126,8 +122,10 @@ fn rewrite_code_comment_line(line: &str, cwd: &Path) -> Option { format!("{file}:{start}-{end}") }; - Some(format!( - "{indent}- {title} — {location}\n{indent} {body}{suffix}" + let (suffix, actions) = strip_line_directives(suffix); + Some(( + format!("{indent}- {title} — {location}\n{indent} {body}{suffix}"), + actions, )) } @@ -138,29 +136,28 @@ pub(crate) fn strip_line_directives(line: &str) -> (String, Vec, name: &str) -> Option { - attributes +fn directive_integer(directive: &AssistantDirective<'_>, name: &str) -> Option { + directive + .attributes .get(name)? .trim() .trim_start_matches(['P', 'p']) @@ -177,95 +174,32 @@ fn title_has_priority(title: &str) -> bool { && bytes[3] == b']' } -fn parse_code_comment_attributes(input: &str) -> Option> { - let mut attributes = HashMap::new(); - let mut rest = input.trim(); - while !rest.is_empty() { - let equals = rest.find('=')?; - let name = rest[..equals].trim(); - if name.is_empty() { - return None; - } - rest = rest[equals + 1..].trim_start(); - let (value, next) = if let Some(quoted) = rest.strip_prefix('"') { - parse_quoted_value(quoted)? - } else { - let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); - (rest[..end].to_string(), &rest[end..]) - }; - attributes.insert(name.to_string(), value); - rest = next.trim_start(); - } - Some(attributes) -} - -fn parse_git_action(name: &str, attributes: &str) -> Option { - let attrs = parse_attributes(attributes)?; - let cwd = attrs.get("cwd")?.clone(); - match name { +fn parse_git_action(directive: &AssistantDirective<'_>) -> Option { + let cwd = directive.attributes.get("cwd")?.to_string(); + match directive.name { "git-stage" => Some(GitActionDirective::Stage { cwd }), "git-commit" => Some(GitActionDirective::Commit { cwd }), "git-create-branch" => Some(GitActionDirective::CreateBranch { cwd, - branch: attrs.get("branch")?.clone(), + branch: directive.attributes.get("branch")?.to_string(), }), "git-push" => Some(GitActionDirective::Push { cwd, - branch: attrs.get("branch")?.clone(), + branch: directive.attributes.get("branch")?.to_string(), }), "git-create-pr" => Some(GitActionDirective::CreatePr { cwd, - branch: attrs.get("branch")?.clone(), - url: attrs.get("url").cloned(), - is_draft: attrs.get("isDraft").is_some_and(|value| value == "true"), + branch: directive.attributes.get("branch")?.to_string(), + url: directive.attributes.get("url").map(ToString::to_string), + is_draft: directive + .attributes + .get("isDraft") + .is_some_and(|value| value == "true"), }), _ => None, } } -fn parse_attributes(input: &str) -> Option> { - let mut attrs = std::collections::HashMap::new(); - let mut rest = input.trim(); - while !rest.is_empty() { - let eq = rest.find('=')?; - let key = rest[..eq].trim(); - if key.is_empty() { - return None; - } - rest = rest[eq + 1..].trim_start(); - let (value, next) = if let Some(quoted) = rest.strip_prefix('"') { - let end = quoted.find('"')?; - (quoted[..end].to_string(), "ed[end + 1..]) - } else { - let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); - (rest[..end].to_string(), &rest[end..]) - }; - attrs.insert(key.to_string(), value); - rest = next.trim_start(); - } - Some(attrs) -} - -fn parse_quoted_value(input: &str) -> Option<(String, &str)> { - let mut value = String::new(); - let mut characters = input.char_indices().peekable(); - - while let Some((index, character)) = characters.next() { - if character == '"' { - return Some((value, &input[index + 1..])); - } - match character { - '\\' if characters.peek().is_some_and(|(_, next)| *next == '"') => { - value.push('"'); - characters.next(); - } - _ => value.push(character), - } - } - - None -} - #[cfg(test)] mod tests { use super::*; @@ -274,7 +208,7 @@ mod tests { #[test] fn strips_and_parses_git_action_directives() { let parsed = parse_assistant_markdown( - "Done\n\n::git-stage{cwd=\"/repo\"} ::git-push{cwd=\"/repo\" branch=\"feat/x\"} ::git-stage{cwd=\"C:\\repo\\\"}", + "Done\n\n::git-stage{cwd=\"/repo\"} ::git-push{cwd=\"/repo\" branch=\"feat/x\"} ::git-stage{cwd=\"C:\\repo\\\"} ::git-create-branch{cwd=\"/tmp/repo\\\" branch=\"feat\"}", Path::new("/repo"), ); @@ -292,6 +226,10 @@ mod tests { GitActionDirective::Stage { cwd: "C:\\repo\\".to_string(), }, + GitActionDirective::CreateBranch { + cwd: "/tmp/repo\\".to_string(), + branch: "feat".to_string(), + }, ] ); } @@ -304,25 +242,49 @@ mod tests { assert!(parsed.git_actions.is_empty()); } + #[test] + fn parses_unquoted_git_flags_and_quoted_closing_braces() { + let cwd = Path::new("/repo"); + let parsed = parse_assistant_markdown( + r#"Done ::git-create-pr{cwd="C:\repo" branch="feature/{rollout}" isDraft=true}"#, + cwd, + ); + insta::assert_snapshot!(parsed.visible_markdown, @"Done"); + assert_eq!( + parsed.git_actions, + vec![GitActionDirective::CreatePr { + cwd: r"C:\repo".to_string(), + branch: "feature/{rollout}".to_string(), + url: None, + is_draft: true, + }], + ); + } + #[test] fn renders_code_comment_directives_as_markdown() { let parsed = parse_assistant_markdown( concat!( "Found two issues.\n\n", - r#"::code-comment{title="Fix body= parsing" body="Keep role=\"tab\", ::git-stage{cwd=/tmp}, file=, and \n literal." file="/repo/src/app.ts" start=10 end=12 priority="P2"}"#, + r#"::code-comment{title="Fix body= parsing" body="C:\temp says \"foo\" here; keep role=\"tab\", ::git-stage{cwd=/tmp}, file=, and \n literal." file="/repo/src/app.ts" start=10 end=12 priority="P2"} ::git-stage{cwd="/repo"}"#, "\n\n", - r#":::code-comment{title="[P1] Clamp the range" body="The line range should match the App." file="codex/src/range.ts" start=8 end=2 priority=3}"#, + r#":::code-comment{title='[P1] Clamp the range' body='The line range should match the App.' file='codex/src/range.ts' start=8 end=2 priority=3}"#, ), Path::new("/repo"), ); insta::assert_snapshot!("code_comment_directive_fallback", parsed.visible_markdown); - assert!(parsed.git_actions.is_empty()); + assert_eq!( + parsed.git_actions, + vec![GitActionDirective::Stage { + cwd: "/repo".to_string() + }], + ); } #[test] fn preserves_non_directive_and_malformed_code_comment_text() { - let markdown = "Mention `::code-comment{title=\"Example\"}` inline.\n::code-comment{title=\"Missing body\" file=\"/repo/src/app.ts\"}"; + let markdown = "Mention `::code-comment{title=\"Example\"}` and `::git-push}` inline.\n::code-comment{title=\"Missing body\" file=\"/repo/src/app.ts\"}"; let parsed = parse_assistant_markdown(markdown, Path::new("/repo")); assert_eq!(parsed.visible_markdown, markdown); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 3aa701b5cc..918a81bc05 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -123,6 +123,7 @@ mod experimental_features; mod permission_discovery; mod pets; pub use custom_terminal::Terminal; +mod assistant_directives; mod auto_review_denials; mod cwd_prompt; mod debug_config; diff --git a/codex-rs/tui/src/markdown_render/local_links.rs b/codex-rs/tui/src/markdown_render/local_links.rs index 1266ace096..738605c9f7 100644 --- a/codex-rs/tui/src/markdown_render/local_links.rs +++ b/codex-rs/tui/src/markdown_render/local_links.rs @@ -7,6 +7,7 @@ //! canonical target to avoid duplicate file references. //! +use codex_utils_path_uri::PathUri; use codex_utils_string::normalize_markdown_hash_location_suffix; use regex_lite::Regex; use std::path::Path; @@ -85,21 +86,7 @@ fn comparable_local_link_path(text: &str) -> Option { } else { std::borrow::Cow::Borrowed(text) }; - let (mut path, _) = parse_local_link_target(&text)?; - if text.starts_with("file://") { - let url = Url::parse(&text).ok()?; - // The display parser's fallback preserves URL escapes on platforms that cannot convert - // this URL to a native path. Decode that fallback exactly once for comparison as well. - if url.to_file_path().is_err() { - path = urlencoding::decode(&path) - .unwrap_or(std::borrow::Cow::Borrowed(&path)) - .into_owned(); - } - // Unix URL conversion retains the slash before a Windows drive; ignore it here only. - if matches!(path.as_bytes(), [b'/', drive, b':', b'/', ..] if drive.is_ascii_alphabetic()) { - path.remove(0); - } - } + let (path, _) = parse_local_link_target(&text)?; Some(normalize_local_link_path_text(&path).to_lowercase()) } @@ -127,7 +114,9 @@ pub(super) fn render_local_link_target(dest_url: &str, cwd: Option<&Path>) -> Op fn parse_local_link_target(dest_url: &str) -> Option<(String, Option)> { if dest_url.starts_with("file://") { let url = Url::parse(dest_url).ok()?; - let path_text = file_url_to_local_path_text(&url)?; + // Infer the path's OS from the URI, not the host running the TUI. + let path = PathUri::parse(&url[..url::Position::AfterPath]).ok()?; + let path_text = normalize_local_link_path_text(&path.inferred_native_path_string()); let location_suffix = url .fragment() .and_then(normalize_hash_location_suffix_fragment); @@ -175,41 +164,13 @@ fn normalize_hash_location_suffix_fragment(fragment: &str) -> Option { /// /// The suffix must occur at the end of the input; embedded colons elsewhere in the path are left /// alone. This is what keeps Windows drive letters like `C:/...` from being misread as locations. -fn extract_colon_location_suffix(path_text: &str) -> Option { +pub(super) fn extract_colon_location_suffix(path_text: &str) -> Option { COLON_LOCATION_SUFFIX_RE .find(path_text) .filter(|matched| matched.end() == path_text.len()) .map(|matched| matched.as_str().to_string()) } -/// Convert a `file://` URL into the normalized local-path text used for transcript rendering. -/// -/// This prefers `Url::to_file_path()` for standard file URLs. When that rejects Windows-oriented -/// encodings, we reconstruct a display path from the host/path parts so UNC paths and drive-letter -/// URLs still render sensibly. -fn file_url_to_local_path_text(url: &Url) -> Option { - if let Ok(path) = url.to_file_path() { - return Some(normalize_local_link_path_text(&path.to_string_lossy())); - } - - // Fall back to string reconstruction for cases `to_file_path()` rejects, especially UNC-style - // hosts and Windows drive paths encoded in URL form. - let mut path_text = url.path().to_string(); - if let Some(host) = url.host_str() - && !host.is_empty() - && host != "localhost" - { - path_text = format!("//{host}{path_text}"); - } else if matches!( - path_text.as_bytes(), - [b'/', drive, b':', b'/', ..] if drive.is_ascii_alphabetic() - ) { - path_text.remove(0); - } - - Some(normalize_local_link_path_text(&path_text)) -} - /// Normalize local-path text into the transcript display form. /// /// Display normalization is intentionally lexical: it does not touch the filesystem, resolve diff --git a/codex-rs/tui/src/markdown_render_tests.rs b/codex-rs/tui/src/markdown_render_tests.rs index 54daa081ff..b2e7ec8f3f 100644 --- a/codex-rs/tui/src/markdown_render_tests.rs +++ b/codex-rs/tui/src/markdown_render_tests.rs @@ -983,6 +983,27 @@ fn file_link_keeps_absolute_paths_outside_cwd() { assert_eq!(text, expected); } +#[test] +fn file_links_preserve_foreign_windows_paths_and_anchors_snapshot() { + let rendered = [ + ("file:///C:/repo/Quarterly%20Report.xlsx", "C:/repo"), + ( + "file://server/share/Quarterly%20Report.xlsx", + "//server/share", + ), + ] + .map(|(destination, cwd)| { + render_markdown_text_for_cwd(&format!("[report]({destination}#L4C2)"), Path::new(cwd)) + .to_string() + }) + .join("\n"); + + insta::assert_snapshot!(rendered, @r" + report (Quarterly Report.xlsx:4:2) + report (Quarterly Report.xlsx:4:2) + "); +} + #[test] fn file_link_appends_hash_anchor_when_label_lacks_it() { let text = render_markdown_text_for_cwd( diff --git a/codex-rs/tui/src/snapshots/codex_tui__git_action_directives__tests__code_comment_directive_fallback.snap b/codex-rs/tui/src/snapshots/codex_tui__git_action_directives__tests__code_comment_directive_fallback.snap index 630a3a542b..8c62bb3b28 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__git_action_directives__tests__code_comment_directive_fallback.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__git_action_directives__tests__code_comment_directive_fallback.snap @@ -5,7 +5,7 @@ expression: parsed.visible_markdown Found two issues. - [P2] Fix body= parsing — src/app.ts:10-12 - Keep role="tab", ::git-stage{cwd=/tmp}, file=, and \n literal. + C:\temp says "foo" here; keep role="tab", ::git-stage{cwd=/tmp}, file=, and \n literal. - [P1] Clamp the range — codex/src/range.ts:8 The line range should match the App.