| 1 | //! Real-PTY proof for the screen-mode switch (`/fullscreen` · `/inline`). |
| 2 | //! |
| 3 | //! Two claims are checked against the *control stream*, not a screenshot, |
| 4 | //! because "did the TUI take the alternate screen" is a terminal-mode fact: |
| 5 | //! |
| 6 | //! 1. `tui.alternate_screen = "never"` starts the session inline — DEC private |
| 7 | //! mode 1049 is never enabled, so the shell's scrollback stays intact — and |
| 8 | //! the shell still paints. |
| 9 | //! 2. `/fullscreen` moves the live terminal onto the alternate screen and |
| 10 | //! `/inline` moves it back, in-process, with the transcript still painting |
| 11 | //! afterwards. A probe that fails would roll back and leave 1049 where it |
| 12 | //! was; this asserts the successful path actually flips it. |
| 13 | |
| 14 | #![cfg(all(unix, feature = "long-running-tests"))] |
| 15 | |
| 16 | use std::time::Duration; |
| 17 | |
| 18 | use super::qa_harness; |
| 19 | use qa_harness::harness::{Harness, make_sealed_workspace}; |
| 20 | use qa_harness::keys; |
| 21 | use qa_harness::modes::mode; |
| 22 | |
| 23 | const ROWS: u16 = 24; |
| 24 | const COLS: u16 = 80; |
| 25 | const STARTUP_WAIT: Duration = Duration::from_secs(15); |
| 26 | const SETTLE_WAIT: Duration = Duration::from_secs(5); |
| 27 | /// Stable proof the live shell repainted after a screen change: the composer |
| 28 | /// placeholder, which every live-shell frame paints in both screen modes. |
| 29 | /// The old `ctx` label no longer qualifies — it stays silent with no model |
| 30 | /// connected, and the workspace caption only paints in the inline stage. |
| 31 | const LIVE_SHELL_SENTINEL: &str = "Type a message"; |
| 32 | |
| 33 | #[test] |
| 34 | fn offline_queue_late_unbracketed_submit_keeps_composer_and_commands_responsive() { |
| 35 | // #5999 requires the burst heuristic to stay armed: type_line() uses |
| 36 | // bracketed paste and would hide the original queue/session-id wedge. |
| 37 | for (rows, cols) in [(24, 80), (32, 100)] { |
| 38 | for delay_ms in [150, 250, 400] { |
| 39 | let workspace = make_sealed_workspace().expect("sealed workspace"); |
| 40 | std::fs::write(workspace.home().join(".codewhale/.onboarded"), "") |
| 41 | .expect("onboarded marker"); |
| 42 | let trust_dir = workspace.workspace().join(".deepseek"); |
| 43 | std::fs::create_dir_all(&trust_dir).expect("workspace trust dir"); |
| 44 | std::fs::write(trust_dir.join("trusted"), "").expect("workspace trust marker"); |
| 45 | let mut tui = Harness::builder(Harness::cargo_bin("codewhale-tui")) |
| 46 | .cwd(workspace.workspace()) |
| 47 | .clear_env() |
| 48 | .seal_home(workspace.home()) |
| 49 | .env("CODEWHALE_DISABLE_MODELS_DEV_FETCH", "1") |
| 50 | .env("CODEWHALE_NO_UPDATE_CHECK", "1") |
| 51 | .env("NO_ANIMATIONS", "1") |
| 52 | .args([ |
| 53 | "--workspace", |
| 54 | workspace.workspace().to_str().expect("workspace UTF-8"), |
| 55 | "--no-project-config", |
| 56 | "--fresh", |
| 57 | ]) |
| 58 | .size(rows, cols) |
| 59 | .spawn() |
| 60 | .expect("start offline TUI"); |
| 61 | |
| 62 | wait_or_panic( |
| 63 | &mut tui, |
| 64 | "Choose your model provider", |
| 65 | STARTUP_WAIT, |
| 66 | "provider", |
| 67 | ); |
| 68 | tui.send(keys::key::ctrl('o')).expect("Explore Offline"); |
| 69 | wait_or_panic(&mut tui, "You're ready.", SETTLE_WAIT, "offline ready"); |
| 70 | tui.send(keys::key::enter()).expect("leave onboarding"); |
| 71 | wait_or_panic(&mut tui, "New session", STARTUP_WAIT, "launch card"); |
| 72 | tui.wait_for_idle(Duration::from_millis(100), SETTLE_WAIT) |
| 73 | .expect("composer ready"); |
| 74 | tui.send(keys::key::ctrl('u')) |
| 75 | .expect("clear suggested prompt"); |
| 76 | |
| 77 | tui.send(keys::key::text("late queue draft")) |
| 78 | .expect("raw prompt bytes"); |
| 79 | std::thread::sleep(Duration::from_millis(delay_ms)); |
| 80 | tui.send(keys::key::enter()).expect("late submit"); |
| 81 | wait_or_panic( |
| 82 | &mut tui, |
| 83 | "Queued #1", |
| 84 | STARTUP_WAIT, |
| 85 | &format!("offline queue receipt ({cols}x{rows}, {delay_ms}ms submit)"), |
| 86 | ); |
| 87 | |
| 88 | tui.send(keys::key::ctrl('u')).expect("clear queued draft"); |
| 89 | tui.send(keys::key::text("input is still live")) |
| 90 | .expect("type after queued submit"); |
| 91 | wait_or_panic( |
| 92 | &mut tui, |
| 93 | "input is still live", |
| 94 | SETTLE_WAIT, |
| 95 | "composer liveness", |
| 96 | ); |
| 97 | tui.send(keys::key::ctrl('u')) |
| 98 | .expect("clear liveness probe"); |
| 99 | tui.wait_for(|frame| !frame.contains("input is still live"), SETTLE_WAIT) |
| 100 | .expect("Ctrl+U still clears the composer"); |
| 101 | tui.send(keys::key::text("/queue drop 1")) |
| 102 | .expect("type queue command"); |
| 103 | // Keep the heuristic armed, but let this raw command's burst |
| 104 | // settle before Enter so it is not a pasted newline. |
| 105 | tui.wait_for_idle(Duration::from_millis(300), SETTLE_WAIT) |
| 106 | .expect("queue command settles"); |
| 107 | tui.send(keys::key::enter()).expect("execute queue command"); |
| 108 | wait_or_panic( |
| 109 | &mut tui, |
| 110 | "Dropped queued message", |
| 111 | SETTLE_WAIT, |
| 112 | "command liveness", |
| 113 | ); |
| 114 | assert!( |
| 115 | !tui.frame().contains("engine session id diverged"), |
| 116 | "{cols}x{rows}, {delay_ms}ms submit: {}", |
| 117 | tui.diagnostics() |
| 118 | ); |
| 119 | tui.shutdown(); |
| 120 | } |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | #[test] |
| 125 | fn inline_start_never_takes_the_alternate_screen_and_screen_commands_switch_it() { |
| 126 | let workspace = make_sealed_workspace().expect("sealed workspace"); |
| 127 | std::fs::write(workspace.home().join(".codewhale/.onboarded"), "").expect("onboarded marker"); |
| 128 | let trust_dir = workspace.workspace().join(".deepseek"); |
| 129 | std::fs::create_dir_all(&trust_dir).expect("workspace trust dir"); |
| 130 | std::fs::write(trust_dir.join("trusted"), "").expect("workspace trust marker"); |
| 131 | |
| 132 | // The existing knob is the startup switch: `never` now means inline. |
| 133 | for relative in [".codewhale/config.toml", ".deepseek/config.toml"] { |
| 134 | let path = workspace.home().join(relative); |
| 135 | let mut config = std::fs::read_to_string(&path).unwrap_or_default(); |
| 136 | config.push_str("\n[tui]\nalternate_screen = \"never\"\n"); |
| 137 | std::fs::write(&path, config).expect("seed inline screen mode"); |
| 138 | } |
| 139 | |
| 140 | let mut tui = Harness::builder(Harness::cargo_bin("codewhale-tui")) |
| 141 | .cwd(workspace.workspace()) |
| 142 | .clear_env() |
| 143 | .seal_home(workspace.home()) |
| 144 | .env("CODEWHALE_DISABLE_MODELS_DEV_FETCH", "1") |
| 145 | .env("CODEWHALE_NO_UPDATE_CHECK", "1") |
| 146 | .env("NO_ANIMATIONS", "1") |
| 147 | .env("RUST_LOG", "warn") |
| 148 | .args([ |
| 149 | "--workspace", |
| 150 | workspace.workspace().to_str().expect("workspace UTF-8"), |
| 151 | "--no-project-config", |
| 152 | "--fresh", |
| 153 | ]) |
| 154 | .size(ROWS, COLS) |
| 155 | .spawn() |
| 156 | .expect("start distributed TUI binary"); |
| 157 | |
| 158 | enter_live_shell(&mut tui); |
| 159 | |
| 160 | // Claim 1: the session came up without ever taking the alternate screen. |
| 161 | tui.pump(); |
| 162 | assert_ne!( |
| 163 | tui.terminal_modes().state(mode::ALT_SCREEN), |
| 164 | Some(true), |
| 165 | "inline startup must not enable DEC 1049\n{}", |
| 166 | tui.diagnostics() |
| 167 | ); |
| 168 | assert!( |
| 169 | tui.frame().contains(LIVE_SHELL_SENTINEL), |
| 170 | "inline shell painted no info line\n{}", |
| 171 | tui.diagnostics() |
| 172 | ); |
| 173 | |
| 174 | // Claim 2: `/fullscreen` takes the alternate screen in-process. In |
| 175 | // Explore Offline the first prompt is parked by the offline queue |
| 176 | // ("Queued #1 … Enter send now"), and while a queued draft is held the |
| 177 | // composer answers to the queue — a follow-up command's Enter would |
| 178 | // send the draft instead of executing the command. Drop the queue |
| 179 | // first, exactly the way the footer tells a human to. |
| 180 | if tui.frame().contains("Queued #1") { |
| 181 | tui.send(keys::key::text("/queue drop 1")) |
| 182 | .expect("type /queue drop 1"); |
| 183 | tui.send(keys::key::enter()).expect("submit /queue drop 1"); |
| 184 | wait_or_panic( |
| 185 | &mut tui, |
| 186 | "Dropped queued message", |
| 187 | Duration::from_secs(20), |
| 188 | "queue drop receipt", |
| 189 | ); |
| 190 | } |
| 191 | tui.send(keys::key::ctrl('u')).expect("clear seeded input"); |
| 192 | tui.send(keys::key::text("/fullscreen")) |
| 193 | .expect("type /fullscreen"); |
| 194 | tui.send(keys::key::enter()).expect("submit /fullscreen"); |
| 195 | wait_for_alt_screen(&mut tui, true, "/fullscreen"); |
| 196 | wait_or_panic( |
| 197 | &mut tui, |
| 198 | LIVE_SHELL_SENTINEL, |
| 199 | SETTLE_WAIT, |
| 200 | "fullscreen repaint", |
| 201 | ); |
| 202 | |
| 203 | // …and `/inline` gives the terminal back. |
| 204 | tui.send(keys::key::text("/inline")).expect("type /inline"); |
| 205 | tui.send(keys::key::enter()).expect("submit /inline"); |
| 206 | wait_for_alt_screen(&mut tui, false, "/inline"); |
| 207 | wait_or_panic(&mut tui, LIVE_SHELL_SENTINEL, SETTLE_WAIT, "inline repaint"); |
| 208 | |
| 209 | // Claim 3: the inline viewport follows the terminal size. Stock ratatui |
| 210 | // keeps an inline viewport at the rows it was built with, so without the |
| 211 | // refit a taller window would leave the new bottom rows blank. |
| 212 | tui.resize(ROWS + 8, COLS).expect("grow the terminal"); |
| 213 | wait_for_bottom_rows_painted(&mut tui, "grow to 32 rows"); |
| 214 | tui.resize(ROWS, COLS).expect("shrink the terminal back"); |
| 215 | wait_for_bottom_rows_painted(&mut tui, "shrink back to 24 rows"); |
| 216 | assert_ne!( |
| 217 | tui.terminal_modes().state(mode::ALT_SCREEN), |
| 218 | Some(true), |
| 219 | "resizing inline must not take the alternate screen\n{}", |
| 220 | tui.diagnostics() |
| 221 | ); |
| 222 | |
| 223 | tui.shutdown(); |
| 224 | } |
| 225 | |
| 226 | /// The live shell paints its composer at the bottom of the viewport, so a |
| 227 | /// viewport that fits the terminal has text within its last rows. |
| 228 | fn wait_for_bottom_rows_painted(tui: &mut Harness, label: &str) { |
| 229 | let painted = tui.wait_for( |
| 230 | |frame| { |
| 231 | let rows = frame.rows(); |
| 232 | (rows.saturating_sub(4)..rows).any(|y| !frame.row(y).trim().is_empty()) |
| 233 | }, |
| 234 | SETTLE_WAIT, |
| 235 | ); |
| 236 | if painted.is_err() { |
| 237 | panic!( |
| 238 | "{label}: nothing painted in the bottom rows after resize\n{}", |
| 239 | tui.diagnostics() |
| 240 | ); |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | /// Walk the real onboarding into deterministic offline-explore mode: no |
| 245 | /// provider, no credentials, no network. |
| 246 | fn enter_live_shell(tui: &mut Harness) { |
| 247 | wait_or_panic(tui, "Choose your model provider", STARTUP_WAIT, "provider"); |
| 248 | tui.send(keys::key::ctrl('o')) |
| 249 | .expect("choose Explore Offline"); |
| 250 | wait_or_panic(tui, "You're ready.", SETTLE_WAIT, "offline explore ready"); |
| 251 | tui.send(keys::key::enter()).expect("leave onboarding"); |
| 252 | wait_or_panic(tui, "New session", STARTUP_WAIT, "launch card"); |
| 253 | // Typing goes straight to the composer; Enter sends the first message |
| 254 | // and the session begins (the card dissolved on the first keystroke). |
| 255 | // type_line, not send+enter: a zero-gap PTY write is paste-classified |
| 256 | // and the immediate Enter would be absorbed as a pasted newline. |
| 257 | tui.type_line("start the session") |
| 258 | .expect("type and send the first prompt"); |
| 259 | if tui |
| 260 | .wait_for(|frame| !frame.text().contains('\u{2442}'), STARTUP_WAIT) |
| 261 | .is_err() |
| 262 | { |
| 263 | panic!( |
| 264 | "the first prompt did not enter the live shell\n{}", |
| 265 | tui.diagnostics() |
| 266 | ); |
| 267 | } |
| 268 | tui.wait_for_idle(Duration::from_millis(300), SETTLE_WAIT) |
| 269 | .expect("session shell settles"); |
| 270 | } |
| 271 | |
| 272 | fn wait_for_alt_screen(tui: &mut Harness, expected: bool, label: &str) { |
| 273 | let deadline = std::time::Instant::now() + qa_harness::harness::ci_scaled(STARTUP_WAIT); |
| 274 | loop { |
| 275 | tui.pump(); |
| 276 | if tui.terminal_modes().state(mode::ALT_SCREEN) == Some(expected) { |
| 277 | return; |
| 278 | } |
| 279 | if std::time::Instant::now() >= deadline { |
| 280 | panic!( |
| 281 | "{label}: alternate screen never became {expected}\n{}", |
| 282 | tui.diagnostics() |
| 283 | ); |
| 284 | } |
| 285 | std::thread::sleep(Duration::from_millis(40)); |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | fn wait_or_panic(tui: &mut Harness, needle: &str, timeout: Duration, label: &str) { |
| 290 | if tui.wait_for_text(needle, timeout).is_err() { |
| 291 | panic!("{label}: {needle:?} not visible\n{}", tui.diagnostics()); |
| 292 | } |
| 293 | } |
| 294 |