newt-core 0.8.0

Newt-Agent core types, errors, and the NeMoCode-style tier router
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
//! **The one terminal adapter** — render a `SurfaceInteraction` on a sealed
//! window and report what the operator did.
//!
//! Moved down from `newt-tui` by F0a (#1922). It had always been the ONE
//! place a `PromptWindow` becomes a rendered prompt and a read, but it sat in
//! `newt-tui` and was `pub(crate)` there, so the `newt-cli` command flows had
//! no route to the typed path. #1911 named that as the obstacle and left the
//! five sites it migrated holding raw question strings.
//!
//! **The route is not a new trait.** Everything this function needs already
//! lives here — [`crate::markup::plain`], [`crate::tty::modal`],
//! [`PromptWindow`], [`SurfaceInteraction`], [`HumanQuestionOutcome`] — and
//! `newt-core` already owns the rest of the `interaction_*` family. The
//! adapter was the only member living upstairs. Shared behaviour moves DOWN
//! into the minimal layer; that is the same rule that brought `Echo` here in
//! D1b-1 and `interaction_form` in D1b-2, and it is why no third `Console` is
//! needed to reach the typed path from `newt-cli`.
//!
//! What did NOT come down is the slash-command back-out. "A leading slash is
//! a TUI command, not an answer" is chat-surface policy — `newt dock approve`
//! has no chat prompt to send you back to — so it stays in `newt-tui` as a
//! wrapper around this.

use crate::interaction_surface::SurfaceInteraction;
use crate::markup::plain;
use crate::tty::{read_prompt_window_line, Echo, PromptLine, PromptWindow, MODAL_INPUT_GLYPH};
use crate::HumanQuestionOutcome;
use newt_interaction::{ControlKind, InteractionDefinition};
use std::io::{self, IsTerminal as _};

/// **The echo policy is DERIVED, never passed in** (D1b-1, #1892).
///
/// A caller that had to remember "this one is a secret" would eventually
/// forget, and the failure is silent and permanent: the key lands in the
/// scrollback. The definition already says which controls are secret, so it
/// decides — and the one terminal adapter cannot be asked to echo a secret
/// because nothing offers it the choice.
///
/// **Any** secret control masks the whole read, rather than only a definition
/// whose sole control is secret. The adapter reads ONE line for the whole
/// definition, so a form mixing a username and a password would otherwise
/// echo the line that contains both. Fail closed.
#[must_use]
pub fn echo_for(definition: &InteractionDefinition) -> Echo {
    if definition
        .controls
        .iter()
        .any(|control| matches!(control.kind, ControlKind::Secret))
    {
        Echo::Stars
    } else {
        Echo::Chars
    }
}

/// Render one semantic interaction on the terminal and report what the
/// operator did.
///
/// Runs on whichever thread owns the terminal, and renders through
/// `markup::plain`, the one plain projection (C0a/C0b/C0c).
///
/// Pure of session state on purpose: the Back/Exit CONTROL side effects
/// (cancelling a turn, requesting exit) belong to the caller, so this returns
/// the typed outcome and the caller applies them.
///
/// The four outcomes are four different facts and none may be mistaken for
/// another — the distinction #1908 built into `read_line_into` and #1911
/// found three `newt-cli` sites getting wrong:
///
/// * `Answer` — a submitted line, INCLUDING an explicitly empty one.
/// * `InputClosed` — EOF. The stream ended; nobody answered. **Not** an empty
///   answer, and never consent.
/// * `Cancelled` / `ExitRequested` — the operator pressed Esc or Ctrl-C.
/// * `InputFailed` — the read errored. No human, as opposed to a human who
///   said nothing.
#[must_use]
pub fn present_on_terminal(
    window: &PromptWindow,
    interaction: &SurfaceInteraction,
) -> HumanQuestionOutcome {
    present_on_terminal_with_layout(window, interaction, None)
}

/// Present one semantic interaction with its already-rendered PLAIN body
/// manually wrapped to `width` on an interactive terminal.
///
/// Only the immutable body is wrapped. The final editable answer row remains
/// one terminal row, which is load bearing for modal redraw: backspace clears
/// and repaints that same row rather than wrapping into the mounted chat box.
/// If either side is not a TTY, the width is ignored and the exact legacy
/// headless bytes are preserved.
#[must_use]
pub fn present_on_terminal_with_width(
    window: &PromptWindow,
    interaction: &SurfaceInteraction,
    width: usize,
) -> HumanQuestionOutcome {
    let width = (io::stdin().is_terminal() && window.output_is_terminal()).then_some(width);
    present_on_terminal_with_layout(window, interaction, width)
}

fn prompt_text(definition: &InteractionDefinition, width: Option<usize>) -> String {
    let body = plain::render(definition);
    let body = match width {
        Some(width) => crate::tty::wrap_line(&body, width).join("\n"),
        None => body,
    };
    format!("{body}\n{MODAL_INPUT_GLYPH}")
}

fn present_on_terminal_with_layout(
    window: &PromptWindow,
    interaction: &SurfaceInteraction,
    width: Option<usize>,
) -> HumanQuestionOutcome {
    let prompt = prompt_text(&interaction.definition, width);
    match read_prompt_window_line(window, &prompt, echo_for(&interaction.definition)) {
        Ok(PromptLine::Line(answer)) => {
            HumanQuestionOutcome::Answer(interaction.answer_or_default(&answer).to_owned())
        }
        Ok(PromptLine::Eof) => HumanQuestionOutcome::InputClosed,
        Ok(PromptLine::Back) => HumanQuestionOutcome::Cancelled,
        Ok(PromptLine::Exit) => HumanQuestionOutcome::ExitRequested,
        Err(_) => HumanQuestionOutcome::InputFailed,
    }
}
/// Present a choice and resolve what the operator typed against the options
/// it offered.
///
/// `None` means **no usable answer**, and deliberately collapses four
/// different reasons into one refusal: the stream closed, the operator
/// cancelled, the read failed, or what they typed matched nothing offered.
/// Every caller of this is a confirm or a menu whose fail-closed arm is the
/// same in all four cases, and the ones that need to tell them apart call
/// [`present_on_terminal`] directly.
///
/// **EOF is not consent.** That is the whole reason this exists as one
/// function rather than five copies of `read a line, then resolve it`: #1911
/// found three `newt-cli` sites getting it wrong, one of them
/// (`dgx.rs:637`) fail-closed only by accident of `matches!` falling through.
/// Here the EOF arm cannot be forgotten, because it is not written per site.
///
/// This composes two things that already exist —
/// [`present_on_terminal`] and [`crate::interaction_form::resolve`], which
/// delegates to D0's one resolver. It adds no parsing and no formatting of
/// its own, so it is not a third anything: delete it and every caller can
/// still spell the two calls by hand.
#[must_use]
pub fn resolve_on_terminal(
    window: &PromptWindow,
    definition: &InteractionDefinition,
) -> Option<newt_interaction::OptionId> {
    let interaction = SurfaceInteraction::blocking(definition.clone());
    match present_on_terminal(window, &interaction) {
        HumanQuestionOutcome::Answer(line) => {
            crate::interaction_form::resolve(definition, line.trim())
        }
        HumanQuestionOutcome::InputClosed
        | HumanQuestionOutcome::Cancelled
        | HumanQuestionOutcome::ExitRequested
        | HumanQuestionOutcome::InputFailed
        | HumanQuestionOutcome::Unavailable => None,
    }
}

/// Whether the operator affirmatively chose `yes`.
///
/// `blank` is what an EMPTY answer means, spelled at the call site so a
/// reader can see which way each prompt's default falls — the parameter D1b-3
/// introduced for the setup wizard, for the reason it found there: `[Y/n]`
/// and `[y/N]` were both written `is_yes(&ans, _)` and only one of them was
/// dangerous. **Blank may decline; it may never commit** unless the caller
/// says so explicitly and the prompt displays it.
///
/// Everything else is `false` — `no`, an unrecognised answer, EOF, a cancel,
/// a failed read. **EOF in particular can never be consent**, and it cannot
/// be forgotten here the way it was at three of the five sites #1911 found,
/// because the arm is written once rather than per caller.
#[must_use]
pub fn confirmed_on_terminal(
    window: &PromptWindow,
    definition: &InteractionDefinition,
    blank: bool,
) -> bool {
    let interaction = SurfaceInteraction::blocking(definition.clone());
    confirm_from_outcome(
        definition,
        &present_on_terminal(window, &interaction),
        blank,
    )
}

/// The confirm decision, as a pure function of the outcome.
///
/// Split from [`confirmed_on_terminal`] so the contract is testable without a
/// terminal — exhaustively, over every [`HumanQuestionOutcome`] variant
/// rather than the ones a test remembered to try. It replaces
/// `mcp_probe_cmd`'s `consent_given(bytes_read, input)`, which was the same
/// idea keyed on a byte count: `bytes_read > 0` was that site's way of saying
/// "EOF is not an answer", and the outcome enum says it in the type.
///
/// The match is exhaustive rather than `_ => false`, so a new outcome variant
/// has to be classified here instead of silently defaulting to "declined" —
/// which would be safe but would hide a case somebody needs to think about.
#[must_use]
fn confirm_from_outcome(
    definition: &InteractionDefinition,
    outcome: &HumanQuestionOutcome,
    blank: bool,
) -> bool {
    match outcome {
        HumanQuestionOutcome::Answer(line) => {
            let line = line.trim();
            if line.is_empty() {
                return blank;
            }
            crate::interaction_form::resolve(definition, line)
                .is_some_and(|picked| picked.as_str() == crate::interaction_form::YES)
        }
        // Not a human declining — a human who never answered. Every one of
        // these is refused, and none of them may be read as consent.
        HumanQuestionOutcome::InputClosed
        | HumanQuestionOutcome::Cancelled
        | HumanQuestionOutcome::ExitRequested
        | HumanQuestionOutcome::InputFailed
        | HumanQuestionOutcome::Unavailable => false,
    }
}

#[cfg(test)]
mod width_layout {
    use super::prompt_text;
    use crate::markup::plain;
    use crate::tty::{str_width, wrap_line, MODAL_INPUT_GLYPH};
    use newt_interaction::{InteractionDefinition, InteractionKind};

    fn definition() -> InteractionDefinition {
        InteractionDefinition::new(
            InteractionKind::Form,
            "Prompt — choose a deliberately long destination path",
            vec![],
        )
    }

    #[test]
    fn width_layout_wraps_only_the_canonical_plain_body() {
        let definition = definition();
        let canonical = plain::render(&definition);
        let expected_body = wrap_line(&canonical, 12).join("\n");
        let prompt = prompt_text(&definition, Some(12));

        assert_eq!(prompt, format!("{expected_body}\n{MODAL_INPUT_GLYPH}"));
        let (body, answer_row) = prompt.rsplit_once('\n').expect("answer row");
        assert_eq!(answer_row, MODAL_INPUT_GLYPH);
        assert!(
            body.lines().all(|line| str_width(line) <= 12),
            "body was not manually wrapped to the terminal width: {body:?}"
        );
    }

    #[test]
    fn no_width_preserves_the_exact_headless_prompt_bytes() {
        let definition = definition();
        assert_eq!(
            prompt_text(&definition, None),
            format!("{}\n{MODAL_INPUT_GLYPH}", plain::render(&definition))
        );
    }
}

#[cfg(test)]
mod f0a_confirm {
    use super::confirm_from_outcome;
    use crate::interaction_form::confirm;
    use crate::HumanQuestionOutcome;

    fn q() -> newt_interaction::InteractionDefinition {
        confirm("proceed?", "", "yes, do it", "no, stop")
    }

    /// **EOF is never consent, and neither is any other non-answer.**
    ///
    /// This replaces `mcp_probe_cmd::consent_given`'s test, which asserted
    /// the same contract against a byte count. #1911 found three of five
    /// `newt-cli` sites getting this wrong — `dgx.rs:637` fail-closed only by
    /// accident of `matches!` falling through — because each site wrote the
    /// EOF arm itself. It is written once now, and this covers every variant
    /// rather than the ones a caller thought of.
    #[test]
    fn no_outcome_but_an_affirmative_answer_confirms() {
        for outcome in [
            HumanQuestionOutcome::InputClosed,
            HumanQuestionOutcome::Cancelled,
            HumanQuestionOutcome::ExitRequested,
            HumanQuestionOutcome::InputFailed,
            HumanQuestionOutcome::Unavailable,
        ] {
            // Not even when the prompt's blank default is YES: a stream that
            // ended did not press Enter.
            assert!(!confirm_from_outcome(&q(), &outcome, true), "{outcome:?}");
            assert!(!confirm_from_outcome(&q(), &outcome, false), "{outcome:?}");
        }
        // `no`, and anything unrecognised, decline too.
        for typed in ["n", "no", "maybe", "1", "yes please"] {
            let a = HumanQuestionOutcome::Answer(typed.to_string());
            assert!(!confirm_from_outcome(&q(), &a, true), "{typed:?}");
        }
    }

    /// **The anti-vacuous twin.** Everything above is a refusal, which a
    /// function returning `false` unconditionally would also satisfy — and
    /// that function would make every confirm in the tree unanswerable.
    #[test]
    fn an_affirmative_answer_does_confirm() {
        for typed in ["y", "Y", "yes", "  y  "] {
            let a = HumanQuestionOutcome::Answer(typed.to_string());
            assert!(confirm_from_outcome(&q(), &a, false), "{typed:?}");
        }
    }

    /// Blank is the one case the CALLER decides, spelled at each call site.
    /// `[Y/n]` passes `true`, `[y/N]` passes `false`, and the prompt shows
    /// which — blank may decline, and may only consent where a default is
    /// displayed and a human pressed Enter to accept it.
    #[test]
    fn blank_means_what_the_call_site_says_and_nothing_else() {
        for blank in [true, false] {
            for typed in ["", "   ", "\t"] {
                let a = HumanQuestionOutcome::Answer(typed.to_string());
                assert_eq!(confirm_from_outcome(&q(), &a, blank), blank, "{typed:?}");
            }
        }
    }
}

#[cfg(test)]
mod d1b_echo {
    use super::echo_for;
    use crate::tty::Echo;
    use newt_interaction::{
        ChoiceOption, Control, ControlId, ControlKind, InteractionDefinition, InteractionKind,
        OptionId, Requirement, SemanticRole,
    };

    fn control(kind: ControlKind) -> Control {
        Control {
            id: ControlId::new("field").expect("valid"),
            kind,
            label: "API key".to_string(),
            requirement: Requirement::Required,
        }
    }

    fn form(controls: Vec<Control>) -> InteractionDefinition {
        InteractionDefinition::new(InteractionKind::Form, "credentials", controls)
    }

    /// A secret control masks the read, and nothing else does.
    #[test]
    fn a_secret_control_masks_the_read() {
        assert_eq!(
            echo_for(&form(vec![control(ControlKind::Secret)])),
            Echo::Stars
        );
    }

    /// **The anti-vacuous twin.** If `echo_for` returned `Stars`
    /// unconditionally the test above would pass while every ordinary prompt
    /// silently stopped echoing — a prompt that shows nothing reads as a hung
    /// terminal, which is the defect `Echo::Stars` documents avoiding.
    #[test]
    fn an_ordinary_control_still_echoes() {
        for kind in [
            ControlKind::Text,
            ControlKind::Toggle,
            ControlKind::Choice {
                options: vec![ChoiceOption {
                    id: OptionId::new("yes").expect("valid"),
                    role: SemanticRole::Allow,
                    label: "yes".to_string(),
                    key: "y".to_string(),
                    aliases: vec![],
                }],
            },
        ] {
            assert_eq!(
                echo_for(&form(vec![control(kind.clone())])),
                Echo::Chars,
                "{kind:?} is not a secret and must echo"
            );
        }
        // A form with NO controls has nothing to hide.
        assert_eq!(echo_for(&form(vec![])), Echo::Chars);
    }

    /// **Fail closed on a mixed form.** The adapter reads ONE line for the
    /// whole definition, so a form pairing a username with a password would
    /// echo the line carrying both if the policy keyed on "the only control".
    #[test]
    fn a_secret_anywhere_masks_the_whole_form() {
        let mixed = form(vec![
            control(ControlKind::Text),
            control(ControlKind::Secret),
        ]);
        assert_eq!(echo_for(&mixed), Echo::Stars, "secret last");
        let mixed = form(vec![
            control(ControlKind::Secret),
            control(ControlKind::Text),
        ]);
        assert_eq!(echo_for(&mixed), Echo::Stars, "secret first");
    }
}