newt-tui 0.8.0

Newt-Agent TUI surfaces (ratatui): code mode + pilot mode
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
//! Real-PTY regression tests for permission prompt behavior.

use std::sync::atomic::Ordering;
use std::time::Duration;

use tests_pty::Pty;

use crate::danger;
use crate::permissions::{
    permission_definition, permission_interaction, prompt_permission_choice, PermissionPromptState,
    PromptPermissionGate,
};
use newt_core::caveats::{Caveats, CountBound, Scope};
use newt_core::tty::{LineCaps, Sink, Spinner, MODAL_INPUT_GLYPH};
use newt_core::{DenialKind, PermissionGate as _, PermissionRequest};

const CHILD_TEST: &str = "prompt_visibility_test::prompt_scenario_child";

// `pub(crate)`: shared with `transcript_pager_pty_test` (#1677) — the reuse
// discipline says one child-reaper for the real-PTY tier, not a copy per test.
pub(crate) fn wait_for_child(
    child: &mut std::process::Child,
    timeout: Duration,
) -> Option<std::process::ExitStatus> {
    wait_for_child_nudging(child, timeout, || {})
}

/// Poll the child to exit, invoking `nudge` once per second while it hasn't.
/// Kills (and reaps) the child at `timeout`.
fn wait_for_child_nudging(
    child: &mut std::process::Child,
    timeout: Duration,
    mut nudge: impl FnMut(),
) -> Option<std::process::ExitStatus> {
    let deadline = std::time::Instant::now() + timeout;
    loop {
        nudge();
        let slice_end = std::cmp::min(std::time::Instant::now() + Duration::from_secs(1), deadline);
        loop {
            if let Some(status) = child.try_wait().expect("poll prompt child") {
                return Some(status);
            }
            if std::time::Instant::now() >= slice_end {
                break;
            }
            std::thread::sleep(Duration::from_millis(10));
        }
        if std::time::Instant::now() >= deadline {
            child.kill().ok();
            child.wait().ok();
            return None;
        }
    }
}

fn no_net_caveats() -> Caveats {
    Caveats {
        fs_read: Scope::only(["/ws".to_string()]),
        fs_write: Scope::only(["/ws".to_string()]),
        exec: Scope::only(["cargo".to_string()]),
        net: Scope::none(),
        max_calls: CountBound::Unlimited,
        valid_for_generation: Scope::All,
    }
}

fn web_fetch_request(host: &str) -> PermissionRequest {
    PermissionRequest {
        tool: "web_fetch".to_string(),
        kind: DenialKind::Net,
        target: host.to_string(),
        reason: format!("net does not permit '{host}'"),
        harness_bound: false,
    }
}

#[test]
#[ignore = "child process of the prompt-visibility regression test"]
fn prompt_scenario_child() {
    let visibility = std::env::var_os("NEWT_PROMPT_VISIBILITY_CHILD").is_some();
    let controls = std::env::var_os("NEWT_PROMPT_CONTROLS_CHILD").is_some();
    let web_controls = std::env::var_os("NEWT_PROMPT_WEB_CONTROLS_CHILD").is_some();
    let mcp_once = std::env::var_os("NEWT_PROMPT_MCP_ONCE_CHILD").is_some();
    let mcp_deny = std::env::var_os("NEWT_PROMPT_MCP_DENY_CHILD").is_some();
    if !visibility && !controls && !web_controls && !mcp_once && !mcp_deny {
        return;
    }

    if controls {
        let window = newt_core::tty::Terminal::suspend_for_prompt(
            newt_core::tty::TerminalTaker::RichSurfaceModal,
        );
        let definition = permission_definition(
            &web_fetch_request("example.com"),
            &danger::DangerTable::builtin(),
            newt_interaction::Audience::Terminal,
        );
        let interaction = newt_core::interaction_surface::SurfaceInteraction::blocking(definition);
        let choice = prompt_permission_choice(&window, &interaction);
        drop(window);
        println!("PROMPT-CONTROL:{choice:?}");
        return;
    }

    if web_controls {
        let root = tempfile::tempdir().expect("temp conversation root");
        let workspace = tempfile::tempdir().expect("temp permission workspace");
        let store = newt_core::ConversationStore::new(root.path(), workspace.path(), 100)
            .expect("web store");
        let conv = store
            .create("conv-prompt-visibility-web", None)
            .expect("conversation");
        let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
        let exit = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
        let request = web_fetch_request("example.com");
        let mut state = PermissionPromptState::default();
        state.web_store = Some(store);
        {
            let mut gate = PromptPermissionGate {
                pending_command_retries: Default::default(),
                state: &mut state,
                base: no_net_caveats(),
                key_path: None,
                conversation_id: conv,
                log_path: None,
                denials_path: None,
                config_path: None,
                preset_clamp: None,
                delegation: None,
                danger: danger::DangerTable::builtin(),
                color: true,
                verbose: false,
                // Far longer than the parent's 10s wait: this test asserts a
                // control key ABORTS the web wait, so the fallback timeout must
                // never win the race. On a starved CI runner a 2s deadline
                // (wall-clock) could elapse before the child was scheduled to
                // read the typed key, resolving as a spurious web-timeout Deny
                // (`false:false`) instead of the control's cancel/exit. With 30s
                // the control always wins if read within the parent's budget; a
                // genuine "control never read" bug still fails (parent times out).
                authorization_prompts_enabled: true,
                web_decision_timeout: std::time::Duration::from_secs(30),
                cancel: Some(cancel.as_ref()),
                exit: Some(exit.as_ref()),
                ask_surface: None,
                #[cfg(feature = "rich-tui")]
                open_panel: None,
                ask_human: prompt_permission_choice,
            };
            let _decision = gate.ask(std::slice::from_ref(&request));
        }
        println!(
            "PROMPT-WEB-CONTROL:{:?}:{:?}:{:?}",
            cancel.load(Ordering::Acquire),
            exit.load(Ordering::Acquire),
            state.decisions.len()
        );
        return;
    }

    let spinner = Spinner::start_with_caps(LineCaps::Own, "thinking…", Sink::Stdout, true)
        .expect("the pty is a real terminal, so the spinner takes the line");
    std::thread::sleep(Duration::from_millis(250));

    let mut request = web_fetch_request("example.com");
    let mut state = PermissionPromptState::default();
    if mcp_once || mcp_deny {
        request.tool = "mcp connect".into();
        let config: newt_core::ToolPermissions = toml::from_str(if mcp_deny {
            "mcp_net_prompt_default = 'deny'"
        } else {
            ""
        })
        .expect("valid MCP prompt configuration");
        state.mcp_net_prompt_default = Some(config.mcp_net_prompt_default);
    }
    let allowed = {
        let mut gate = PromptPermissionGate {
            pending_command_retries: Default::default(),
            state: &mut state,
            base: no_net_caveats(),
            key_path: None,
            conversation_id: "conv-prompt-visibility".to_string(),
            log_path: None,
            denials_path: None,
            config_path: None,
            preset_clamp: None,
            delegation: None,
            danger: danger::DangerTable::builtin(),
            color: true,
            verbose: false,
            authorization_prompts_enabled: true,
            web_decision_timeout: std::time::Duration::from_secs(2),
            cancel: None,
            exit: None,
            ask_surface: None,
            #[cfg(feature = "rich-tui")]
            open_panel: None,
            ask_human: prompt_permission_choice,
        };
        matches!(
            gate.ask(std::slice::from_ref(&request)),
            newt_core::PermissionDecision::Allow(_)
        )
    };
    drop(spinner);
    if mcp_once || mcp_deny {
        println!("PROMPT-MCP-DEFAULT:{allowed}:{}", state.decisions[0].scope);
    }
}

fn prompt_control_child(env: &str, expected: &str, key: &str, child: &str) {
    let pty = Pty::open();
    let mut child = std::process::Command::new(std::env::current_exe().expect("self test"))
        .args(["--exact", child, "--ignored", "--nocapture"])
        .env(env, "1")
        .stdin(pty.slave_stdio())
        .stdout(pty.slave_stdio())
        .stderr(std::process::Stdio::null())
        .spawn()
        .expect("spawn control child");
    // Type only once the child's prompt is actually on screen. A fixed pre-type
    // sleep raced the child's terminal setup on loaded CI runners: the key
    // landed in cooked mode, was echoed (`^C`) and swallowed by the line
    // discipline, and the web-decision timeout then resolved "Deny" instead of
    // the control under test.
    //
    // #2075: this loop used to TAKE with `screen()` and discard what it read,
    // so a marker split across two reads could never match — a flake the 20 ms
    // sleep inside `screen()` was hiding by making each read large. Waiting
    // peeks instead, so the marker is matched whole however the bytes arrive,
    // and stays on the buffer for the reads below.
    assert!(
        pty.wait_for_screen("example.com", newt_core::test_guard::HANG_GUARD),
        "child never presented its prompt; screen={:?}",
        pty.screen()
    );
    // The marker narrows the race but cannot close it alone: the TTY child's
    // modal reader enters raw mode BEFORE rendering the question (its window
    // only sets canonical mode), but the web child prints its banner just
    // BEFORE its first reader arm — a sliver of cooked mode where Ctrl-C/
    // Ctrl-D can still be eaten. So re-type the key each second until the
    // child exits: a swallowed key is re-sent once the reader is armed (typed
    // bytes are kernel-buffered across the raw-mode switch, never flushed),
    // and an extra byte after resolution is simply never read. The cap is the
    // shared hang guard, far above the web-decision timeout, so a genuinely
    // non-immediate control still loses to that timeout and fails the screen
    // assertion; the cap itself only names a hung child.
    let status = wait_for_child_nudging(&mut child, newt_core::test_guard::HANG_GUARD, || {
        pty.type_in(key);
    });
    // #2075: exact once the child has exited, instead of whatever a 20 ms
    // sleep had collected.
    let screen = pty.screen_when_finished(status.is_some());
    assert!(
        status.is_some_and(|s| s.success()) && screen.contains(expected),
        "{key:?} {screen:?}"
    );
}

/// Real-PTY grounding test for the prompt CONTROL keys — Esc→Back,
/// Ctrl-C/Ctrl-D→Exit — on both the local-tty prompt and the web-wait prompt.
/// Unlike the newline-terminated `"d\r"` of the visibility test (which the
/// cooked-mode line discipline delivers deterministically), this types raw
/// control BYTES that only take effect once the child's modal reader is in raw
/// mode. Under the parallel per-PR load the child can be CPU-starved past that
/// window, so a nudged byte is echoed (`^C`) and swallowed in cooked mode —
/// making the test inherently non-deterministic in the multi-threaded unit run.
/// It therefore lives in the single-threaded real-PTY tier
/// (`.github/workflows/newt-tui-pty.yml`, weekly + release), NOT the per-PR
/// `cargo test --workspace`, exactly as the output-oracle real tier is gated.
///
/// It GROUNDS the mocked control-key unit tests (`permissions.rs` /
/// `question.rs`: `Action` parse + `PermissionAction`/`HumanQuestionOutcome`
/// mapping) — those assert the key→outcome mapping in memory; this proves a
/// real terminal actually delivers the bytes that trigger it. See the CLAUDE.md
/// testing tiers and the real-resource-migration issue #514.
#[serial_test::serial(prompt_stdin)]
#[test]
#[ignore = "real-PTY control-byte tier; runs single-threaded in newt-tui-pty.yml (weekly/release), not the parallel per-PR gate — see doc comment"]
fn permission_prompt_controls_are_immediate_and_distinct() {
    for (key, tty, web) in [
        ("\u{1b}", "Back", "true:false"),
        ("\u{3}", "Exit", "true:true"),
        ("\u{4}", "Exit", "true:true"),
    ] {
        prompt_control_child(
            "NEWT_PROMPT_CONTROLS_CHILD",
            &format!("PROMPT-CONTROL:{tty}"),
            key,
            CHILD_TEST,
        );
        prompt_control_child(
            "NEWT_PROMPT_WEB_CONTROLS_CHILD",
            &format!("PROMPT-WEB-CONTROL:{web}:"),
            key,
            CHILD_TEST,
        );
    }
}

/// Grounds the mocked MCP blank-answer/lifetime tests: a real Enter byte
/// confirms the shipped AllowOnce or configured Deny through the direct gate.
#[serial_test::serial(prompt_stdin)]
#[test]
fn mcp_net_prompt_enter_confirms_configured_default_on_a_real_terminal() {
    for (environment, allowed) in [
        ("NEWT_PROMPT_MCP_ONCE_CHILD", true),
        ("NEWT_PROMPT_MCP_DENY_CHILD", false),
    ] {
        prompt_control_child(
            environment,
            &format!("PROMPT-MCP-DEFAULT:{allowed}:once"),
            "\r",
            CHILD_TEST,
        );
    }
}

#[serial_test::serial(prompt_stdin)]
#[test]
fn a_permission_prompt_is_visible_and_survives_a_live_spinner() {
    let pty = Pty::open();

    let mut child = std::process::Command::new(
        std::env::current_exe().expect("the test binary re-invokes itself"),
    )
    .args(["--exact", CHILD_TEST, "--ignored", "--nocapture"])
    .env("NEWT_PROMPT_VISIBILITY_CHILD", "1")
    .stdin(pty.slave_stdio())
    .stdout(pty.slave_stdio())
    .stderr(std::process::Stdio::null())
    .spawn()
    .expect("spawn the pty child");

    // Type only once the prompt is ON the screen: the glyph arriving is the
    // event. The former 600 ms "human thinking time" bet that the child (which
    // itself dwells 250 ms before rendering) was at its prompt by then; under
    // load the keystroke's echo landed before the prompt and the pre-push hook
    // rejected unrelated pushes (measured 2026-10-01).
    assert!(
        pty.wait_for_screen("⊘", newt_core::test_guard::HANG_GUARD),
        "the prompt never rendered; screen={:?}",
        pty.screen()
    );
    pty.type_in("d\r");

    let status = wait_for_child(&mut child, newt_core::test_guard::HANG_GUARD);

    let screen = pty.screen_when_finished(status.is_some());
    assert!(
        status.is_some_and(|status| status.success()),
        "child failed; screen={screen:?}"
    );

    let prompt_start = screen
        .find('⊘')
        .unwrap_or_else(|| panic!("question missing; screen={screen:?}"));
    let echo_rel = screen[prompt_start..]
        .find("d\r\n")
        .or_else(|| screen[prompt_start..].find("d\n"))
        .unwrap_or_else(|| panic!("keystroke not echoed; screen={screen:?}"));
    let window = &screen[prompt_start..prompt_start + echo_rel];

    // C0a (#1856): the expectation is re-derived from the production
    // renderer, so this real-PTY grounding tracks `plain::render` rather
    // than a copy of its output.
    let expected_interaction = permission_interaction(
        &web_fetch_request("example.com"),
        &danger::DangerTable::builtin(),
        newt_core::PermissionAction::AllowOnce,
    );
    let expected_prompt = newt_core::markup::plain::render(&expected_interaction.definition);

    let window_lf = window.replace("\r\n", "\n");
    assert!(
        window_lf.contains(expected_prompt.trim_end()),
        "question not fully visible\nscreen={screen:?}"
    );

    let intruders: Vec<char> = window
        .chars()
        .filter(|c| ('\u{2800}'..='\u{28FF}').contains(c))
        .collect();
    assert!(
        intruders.is_empty(),
        "expected no redraw after prompt appears\nscreen={screen:?}\nintruders={intruders:?}"
    );

    // VISIBLE means visible (#1959). SGR sequences are zero-width, so a row
    // written as `❯ \x1b[0m` ends, to the operator, at the chevron. #1959 gave
    // the modal input row an accent, and an accent must reset after itself or
    // the text the user types inherits it — which appends exactly such a
    // sequence. Measuring the raw byte tail would make this an assertion about
    // which INVISIBLE bytes come last, which is not what it is for.
    //
    // The guard is unchanged in strength: anything the operator can actually
    // SEE after the chevron — a spinner frame, a stray glyph, a redraw — still
    // lands at the end of the visible tail and still fails.
    let visible = strip_ansi(window);
    let visible_tail = visible.trim_end_matches(|c: char| c.is_whitespace());
    // The input line ends with the modal chevron the user types behind. Assert
    // against the production glyph itself (not a hardcoded `>`) so this
    // visibility check tracks `MODAL_INPUT_GLYPH` and can't drift from it.
    assert!(
        visible_tail.ends_with(MODAL_INPUT_GLYPH.trim_end()),
        "menu missing before input\nvisible_tail={visible_tail:?}\nscreen={screen:?}"
    );
}

/// Everything in `text` an operator can see: CSI/OSC escape sequences removed,
/// printable characters kept in order.
///
/// Local to this file on purpose. `cockpit::ansi::visible_width` is the
/// nearest existing thing and would be the right one to widen, but it lives
/// under `mod cockpit`, which is `rich-tui`-gated, and this test is not —
/// reaching for it would gate a unix regression test on a feature it does not
/// otherwise need. Kept deliberately small: it recognises the two escape
/// shapes a terminal writer actually emits here.
use newt_core::tty::width::strip_ansi;

#[cfg(test)]
mod strip_ansi_tests {
    use super::strip_ansi;

    /// The exact shape #1959's accent emits, plus the redraw around it.
    #[test]
    fn an_sgr_wrapped_row_reduces_to_its_visible_characters() {
        assert_eq!(strip_ansi("\u{1b}[38;2;255;165;90m❯ \u{1b}[0m"), "❯ ");
        assert_eq!(
            strip_ansi("a\u{1b}[0m\u{1b}[1G\u{1b}[2K\u{1b}[38;2;1;2;3m❯ \u{1b}[0m"),
            "a❯ "
        );
    }

    /// **Anti-vacuous twin.** A stripper that returned the empty string, or
    /// that ate ordinary text, would satisfy the assertion it feeds. It keeps
    /// every visible character — including one painted AFTER the chevron,
    /// which is the case the visibility guard exists to catch.
    #[test]
    fn a_visible_character_after_the_chevron_survives_stripping() {
        let painted_over = "\u{1b}[38;2;1;2;3m❯ \u{1b}[0m⠋";
        let visible = strip_ansi(painted_over);
        assert_eq!(visible, "❯ ⠋");
        assert!(
            !visible.trim_end().ends_with('❯'),
            "a spinner frame after the chevron must still break the tail check"
        );
    }

    /// Plain text is untouched, and an OSC hyperlink is removed whole.
    #[test]
    fn plain_text_is_untouched_and_osc_is_removed() {
        assert_eq!(strip_ansi("[a]llow once"), "[a]llow once");
        assert_eq!(
            strip_ansi("\u{1b}]8;;http://x\u{7}link\u{1b}]8;;\u{7}"),
            "link"
        );
    }
}