|
| 1 | +//! The OpenCode full-screen composer: bordered input rows immediately above its mode footer. |
| 2 | +
|
| 3 | +use super::{Harness, Located, ReceiptState, Screen}; |
| 4 | +use crate::ding::composer::ComposerState; |
| 5 | + |
| 6 | +pub(super) struct OpenCode; |
| 7 | + |
| 8 | +impl Harness for OpenCode { |
| 9 | + fn locate(&self, screen: &Screen<'_>) -> Option<Located> { |
| 10 | + locate_composer(screen.plain).map(|composer| Located { |
| 11 | + row: composer.start, |
| 12 | + }) |
| 13 | + } |
| 14 | + |
| 15 | + fn classify(&self, screen: &Screen<'_>, expected: &str) -> ComposerState { |
| 16 | + let Some(composer) = locate_composer(screen.plain) else { |
| 17 | + return ComposerState::Ambiguous; |
| 18 | + }; |
| 19 | + let exact = equivalent(&composer.input, expected); |
| 20 | + if exact { |
| 21 | + if composer.idle { |
| 22 | + ComposerState::ExactSafe |
| 23 | + } else { |
| 24 | + ComposerState::ExactBlocked |
| 25 | + } |
| 26 | + } else if composer.input.is_empty() && composer.idle { |
| 27 | + ComposerState::EmptySafe |
| 28 | + } else if composer.input.is_empty() { |
| 29 | + ComposerState::Ambiguous |
| 30 | + } else { |
| 31 | + ComposerState::Changed |
| 32 | + } |
| 33 | + } |
| 34 | + |
| 35 | + fn receipt(&self, screen: &Screen<'_>, expected: &str) -> ReceiptState { |
| 36 | + let Some(composer) = locate_composer(screen.plain) else { |
| 37 | + return ReceiptState::Unproven; |
| 38 | + }; |
| 39 | + if equivalent(&composer.input, expected) { |
| 40 | + return if composer.idle { |
| 41 | + ReceiptState::RetainedSafe |
| 42 | + } else { |
| 43 | + ReceiptState::RetainedBlocked |
| 44 | + }; |
| 45 | + } |
| 46 | + if !composer.input.is_empty() { |
| 47 | + return ReceiptState::NotRetained; |
| 48 | + } |
| 49 | + if composer.idle && preceding_submitted_box(&screen.plain[..composer.start_byte], expected) |
| 50 | + { |
| 51 | + ReceiptState::Accepted |
| 52 | + } else if composer.idle { |
| 53 | + ReceiptState::NotRetained |
| 54 | + } else { |
| 55 | + ReceiptState::Unproven |
| 56 | + } |
| 57 | + } |
| 58 | +} |
| 59 | + |
| 60 | +struct Composer { |
| 61 | + start: usize, |
| 62 | + start_byte: usize, |
| 63 | + input: String, |
| 64 | + idle: bool, |
| 65 | +} |
| 66 | + |
| 67 | +fn locate_composer(plain: &str) -> Option<Composer> { |
| 68 | + let lines = plain.split_inclusive('\n').collect::<Vec<_>>(); |
| 69 | + let closure = lines.iter().rposition(|line| { |
| 70 | + let line = line.trim(); |
| 71 | + line.strip_prefix('╹').is_some_and(|rule| { |
| 72 | + rule.chars().filter(|ch| *ch == '▀').count() >= 40 |
| 73 | + && rule.chars().all(|ch| ch == '▀' || ch.is_whitespace()) |
| 74 | + }) |
| 75 | + })?; |
| 76 | + let footer = (0..closure).rev().find(|index| { |
| 77 | + let line = lines[*index].trim(); |
| 78 | + line.starts_with('┃') && line.contains(" · ") |
| 79 | + })?; |
| 80 | + let start = (0..=footer) |
| 81 | + .rev() |
| 82 | + .take_while(|index| lines[*index].trim_start().starts_with('┃')) |
| 83 | + .last()?; |
| 84 | + let input = lines[start..footer] |
| 85 | + .iter() |
| 86 | + .map(|line| line.trim_start().strip_prefix('┃').unwrap_or(line).trim()) |
| 87 | + .filter(|line| !line.is_empty()) |
| 88 | + .collect::<Vec<_>>() |
| 89 | + .join("\n"); |
| 90 | + let start_byte = lines[..start].iter().map(|line| line.len()).sum(); |
| 91 | + let tail = lines[footer..].concat(); |
| 92 | + Some(Composer { |
| 93 | + start, |
| 94 | + start_byte, |
| 95 | + input, |
| 96 | + // Return is only safe when the pinned OpenCode idle shortcut chrome is |
| 97 | + // positively present. Unknown modal/active states fail closed even if |
| 98 | + // they happen not to render one of the known interrupt hints. |
| 99 | + idle: tail.contains("ctrl+p commands"), |
| 100 | + }) |
| 101 | +} |
| 102 | + |
| 103 | +fn equivalent(observed: &str, expected: &str) -> bool { |
| 104 | + if observed.contains('\n') || expected.contains('\n') { |
| 105 | + return observed == expected; |
| 106 | + } |
| 107 | + let normalized = |value: &str| value.split_whitespace().collect::<Vec<_>>().join(" "); |
| 108 | + normalized(observed) == normalized(expected) |
| 109 | +} |
| 110 | + |
| 111 | +fn preceding_submitted_box(before_composer: &str, expected: &str) -> bool { |
| 112 | + let mut lines = before_composer.lines().rev(); |
| 113 | + let first = lines.find(|line| !line.trim().is_empty()); |
| 114 | + let Some(first) = first else { |
| 115 | + return false; |
| 116 | + }; |
| 117 | + let Some(content) = first.trim_start().strip_prefix('┃') else { |
| 118 | + return false; |
| 119 | + }; |
| 120 | + let mut box_lines = vec![content.trim()]; |
| 121 | + for line in lines { |
| 122 | + let Some(content) = line.trim_start().strip_prefix('┃') else { |
| 123 | + break; |
| 124 | + }; |
| 125 | + box_lines.push(content.trim()); |
| 126 | + } |
| 127 | + box_lines.reverse(); |
| 128 | + equivalent(&box_lines.join("\n"), expected) |
| 129 | +} |
| 130 | + |
| 131 | +#[cfg(test)] |
| 132 | +mod tests { |
| 133 | + use super::*; |
| 134 | + |
| 135 | + const EXPECTED: &str = "[DING] id=abc from=dev3.cos subject=liveness"; |
| 136 | + |
| 137 | + fn screen(composer: &str, history: &str, status: &str) -> String { |
| 138 | + format!( |
| 139 | + "{history}\n ┃ {composer}\n ┃\n ┃\n ┃ Build auto · MiMo V2.5 Free OpenCode Zen\n ╹{}\n /work 39K {status}\n", |
| 140 | + "▀".repeat(60) |
| 141 | + ) |
| 142 | + } |
| 143 | + |
| 144 | + #[test] |
| 145 | + fn recognizes_idle_empty_and_exact_composers() { |
| 146 | + let empty = screen("", "answer", "ctrl+p commands"); |
| 147 | + let exact = screen(EXPECTED, "answer", "ctrl+p commands"); |
| 148 | + assert_eq!( |
| 149 | + OpenCode.classify( |
| 150 | + &Screen { |
| 151 | + raw: &empty, |
| 152 | + plain: &empty |
| 153 | + }, |
| 154 | + EXPECTED |
| 155 | + ), |
| 156 | + ComposerState::EmptySafe |
| 157 | + ); |
| 158 | + assert_eq!( |
| 159 | + OpenCode.classify( |
| 160 | + &Screen { |
| 161 | + raw: &exact, |
| 162 | + plain: &exact |
| 163 | + }, |
| 164 | + EXPECTED |
| 165 | + ), |
| 166 | + ComposerState::ExactSafe |
| 167 | + ); |
| 168 | + } |
| 169 | + |
| 170 | + #[test] |
| 171 | + fn active_turn_blocks_submission() { |
| 172 | + let active = screen(EXPECTED, "answer", "esc interrupt"); |
| 173 | + assert_eq!( |
| 174 | + OpenCode.classify( |
| 175 | + &Screen { |
| 176 | + raw: &active, |
| 177 | + plain: &active |
| 178 | + }, |
| 179 | + EXPECTED |
| 180 | + ), |
| 181 | + ComposerState::ExactBlocked |
| 182 | + ); |
| 183 | + } |
| 184 | + |
| 185 | + #[test] |
| 186 | + fn unfamiliar_or_modal_chrome_never_enables_return() { |
| 187 | + for status in ["select an option", "unknown footer"] { |
| 188 | + let exact = screen(EXPECTED, "answer", status); |
| 189 | + let empty = screen("", "answer", status); |
| 190 | + assert_eq!( |
| 191 | + OpenCode.classify( |
| 192 | + &Screen { |
| 193 | + raw: &exact, |
| 194 | + plain: &exact |
| 195 | + }, |
| 196 | + EXPECTED |
| 197 | + ), |
| 198 | + ComposerState::ExactBlocked |
| 199 | + ); |
| 200 | + assert_eq!( |
| 201 | + OpenCode.classify( |
| 202 | + &Screen { |
| 203 | + raw: &empty, |
| 204 | + plain: &empty |
| 205 | + }, |
| 206 | + EXPECTED |
| 207 | + ), |
| 208 | + ComposerState::Ambiguous |
| 209 | + ); |
| 210 | + } |
| 211 | + } |
| 212 | + |
| 213 | + #[test] |
| 214 | + fn human_modified_composer_is_never_exact() { |
| 215 | + for draft in [ |
| 216 | + format!("human prefix {EXPECTED}"), |
| 217 | + format!("{EXPECTED} human suffix"), |
| 218 | + EXPECTED.replace(" from=", "from="), |
| 219 | + ] { |
| 220 | + let modified = screen(&draft, "answer", "ctrl+p commands"); |
| 221 | + assert_eq!( |
| 222 | + OpenCode.classify( |
| 223 | + &Screen { |
| 224 | + raw: &modified, |
| 225 | + plain: &modified |
| 226 | + }, |
| 227 | + EXPECTED |
| 228 | + ), |
| 229 | + ComposerState::Changed |
| 230 | + ); |
| 231 | + } |
| 232 | + } |
| 233 | + |
| 234 | + #[test] |
| 235 | + fn provider_name_does_not_identify_the_opencode_composer() { |
| 236 | + let anthropic = screen(EXPECTED, "answer", "ctrl+p commands") |
| 237 | + .replace("MiMo V2.5 Free OpenCode Zen", "Claude Opus 4.1 Anthropic"); |
| 238 | + assert_eq!( |
| 239 | + OpenCode.classify( |
| 240 | + &Screen { |
| 241 | + raw: &anthropic, |
| 242 | + plain: &anthropic |
| 243 | + }, |
| 244 | + EXPECTED |
| 245 | + ), |
| 246 | + ComposerState::ExactSafe |
| 247 | + ); |
| 248 | + } |
| 249 | + |
| 250 | + #[test] |
| 251 | + fn accepted_receipt_requires_history_and_an_empty_idle_composer() { |
| 252 | + let accepted = screen("", &format!(" ┃ {EXPECTED}\n\n"), "ctrl+p commands"); |
| 253 | + assert_eq!( |
| 254 | + OpenCode.receipt( |
| 255 | + &Screen { |
| 256 | + raw: &accepted, |
| 257 | + plain: &accepted |
| 258 | + }, |
| 259 | + EXPECTED |
| 260 | + ), |
| 261 | + ReceiptState::Accepted |
| 262 | + ); |
| 263 | + } |
| 264 | + |
| 265 | + #[test] |
| 266 | + fn ordinary_transcript_text_is_not_an_acceptance_receipt() { |
| 267 | + let transcript = screen( |
| 268 | + "", |
| 269 | + &format!("assistant repeated {EXPECTED}\n\n"), |
| 270 | + "ctrl+p commands", |
| 271 | + ); |
| 272 | + assert_eq!( |
| 273 | + OpenCode.receipt( |
| 274 | + &Screen { |
| 275 | + raw: &transcript, |
| 276 | + plain: &transcript |
| 277 | + }, |
| 278 | + EXPECTED |
| 279 | + ), |
| 280 | + ReceiptState::NotRetained |
| 281 | + ); |
| 282 | + } |
| 283 | + |
| 284 | + #[test] |
| 285 | + fn hard_line_break_in_composer_is_not_exact() { |
| 286 | + let multiline = screen( |
| 287 | + &EXPECTED.replace(" from=", "\n ┃ from="), |
| 288 | + "answer", |
| 289 | + "ctrl+p commands", |
| 290 | + ); |
| 291 | + assert_eq!( |
| 292 | + OpenCode.classify( |
| 293 | + &Screen { |
| 294 | + raw: &multiline, |
| 295 | + plain: &multiline |
| 296 | + }, |
| 297 | + EXPECTED |
| 298 | + ), |
| 299 | + ComposerState::Changed |
| 300 | + ); |
| 301 | + } |
| 302 | + |
| 303 | + #[test] |
| 304 | + fn old_matching_box_separated_by_output_is_not_a_receipt() { |
| 305 | + let old = screen( |
| 306 | + "", |
| 307 | + &format!(" ┃ {EXPECTED}\n\nassistant output\n"), |
| 308 | + "ctrl+p commands", |
| 309 | + ); |
| 310 | + assert_eq!( |
| 311 | + OpenCode.receipt( |
| 312 | + &Screen { |
| 313 | + raw: &old, |
| 314 | + plain: &old |
| 315 | + }, |
| 316 | + EXPECTED |
| 317 | + ), |
| 318 | + ReceiptState::NotRetained |
| 319 | + ); |
| 320 | + } |
| 321 | + |
| 322 | + #[test] |
| 323 | + fn modified_submitted_box_is_not_an_acceptance_receipt() { |
| 324 | + for submitted in [ |
| 325 | + format!("human prefix {EXPECTED}"), |
| 326 | + format!("{EXPECTED} human suffix"), |
| 327 | + ] { |
| 328 | + let modified = screen("", &format!(" ┃ {submitted}\n\n"), "ctrl+p commands"); |
| 329 | + assert_eq!( |
| 330 | + OpenCode.receipt( |
| 331 | + &Screen { |
| 332 | + raw: &modified, |
| 333 | + plain: &modified |
| 334 | + }, |
| 335 | + EXPECTED |
| 336 | + ), |
| 337 | + ReceiptState::NotRetained |
| 338 | + ); |
| 339 | + } |
| 340 | + } |
| 341 | +} |
0 commit comments