返回 CodeWhale
screen_mode_inline_pty.rs
根目录 / crates / tui / tests / cucumber / screen_mode_inline_pty.rs
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
294 lines RUST