newt-core 0.7.5

Newt-Agent core types, errors, and the NeMoCode-style tier router
Documentation
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
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
//! Terminal output helpers for the agentic loop.
//!
//! Moved verbatim from `newt-tui` in Step 9.7 so the loop and the inline
//! progress it prints (tool calls, retries, trim notices) stay together.
//! Everything here writes straight to stdout — headless callers (Step 9.8's
//! ACP worker) run with `color: false` and capture/ignore the stream.

use crossterm::{
    execute,
    style::{Color as CtColor, Print, ResetColor, SetForegroundColor},
};
use std::io::{self, Write};

// The terminal-line primitives (palette, width, single-line fitting) moved to
// the public `newt_core::tty` module — `agentic::display` is private with a
// curated re-export list, and *that privacy was the mechanical cause* of the
// duplicate frame sets and open-coded erase escapes elsewhere in the workspace.
// Re-exported here so every call site in `agentic` is unchanged.
pub use crate::tty::NEWT_ORANGE_CT;
pub(crate) use crate::tty::{term_cols, FADE_CT};

// The multi-line wrapper moved up to `tty::width::wrap_line` with the rest of
// the width model (`docs/decisions/tty_widget_suite.md` §3.0). Aliased under its
// old name so every call site and every test in this module is unchanged — the
// promotion is a move, not a behavior change.
pub(crate) use crate::tty::width::wrap_line as wrap_to_width;

/// Print a newt narrator line.
///
/// The `▸` marker stays the **default text color**: a colored sigil on every
/// narrator line reads as noise, and the saturated logo orange is exactly the
/// hue that's hard to parse on this operator's display (accessibility note —
/// never lean on a deep saturated color for anything readable). No-color: `>`.
pub fn print_newt(msg: &str, color: bool, verbose: bool) {
    println!("{}", newt_line(msg, color, verbose));
}

/// The narrator line [`print_newt`] prints, as a string.
///
/// Split out so a caller holding a [`crate::tty::PromptWindow`] can route the
/// SAME bytes through `PromptWindow::notice` instead of `println!` — a notice
/// emitted while a question is on screen must go through the arbiter, or it
/// races the very ticker it was meant to be protected from.
pub fn newt_line(msg: &str, color: bool, verbose: bool) -> String {
    let prefix = if color {
        if verbose {
            "newt ▸  "
        } else {
            "▸  "
        }
    } else if verbose {
        "newt >  "
    } else {
        ">  "
    };
    format!("{prefix}{msg}")
}

/// Print one row of a selectable list in newt's default list style.
///
/// The **active** row is flagged with a red `▸` margin sigil and a green
/// `◀ active` tag; inactive rows align under it with two leading spaces (the
/// `▸ ` sigil consumes one of those two columns, so labels line up). The label
/// itself is always default-colored — only the small arrow sigils carry color,
/// and the words `▸`/`active` carry the meaning too, so nothing depends on
/// color alone.
pub fn print_list_item(label: &str, active: bool, color: bool) {
    if !active {
        println!("  {label}");
        return;
    }
    if color {
        execute!(
            io::stdout(),
            SetForegroundColor(CtColor::Red),
            Print("▸ "),
            ResetColor,
            Print(label),
            Print("  "),
            SetForegroundColor(CtColor::Red),
            Print("◀ "),
            SetForegroundColor(CtColor::Green),
            Print("active"),
            ResetColor,
            Print("\n"),
        )
        .ok();
    } else {
        println!("> {label}  <- active");
    }
}

/// Print a harness-originated notice — an adaptation/diagnostic message from
/// newt *itself* (context-budget fail-open, compression latch, …), NOT model
/// output and NOT a plain narrator line. Rendered in amber with a `newt:` label
/// so it reads as the harness speaking and doesn't blend into the conversation
/// (the failure mode the operator flagged). Multi-line text stays amber; the
/// marker leads the first line.
pub fn print_harness_notice(msg: &str, color: bool) {
    if color {
        execute!(
            io::stdout(),
            SetForegroundColor(CtColor::DarkYellow),
            Print(format!("⚠  newt: {msg}\n")),
            ResetColor,
        )
        .ok();
    } else {
        println!("⚠  newt: {msg}");
    }
    io::stdout().flush().ok();
}

/// Print a single-line debug diagnostic (dimmed, prefix `[debug]`).
/// Only called when `ChatCtx.debug` is true — guard at the call site.
pub(crate) fn print_debug(msg: &str, color: bool) {
    if color {
        execute!(
            io::stdout(),
            SetForegroundColor(CtColor::DarkGrey),
            Print(format!("[debug] {msg}\n")),
            ResetColor,
        )
        .ok();
    } else {
        println!("[debug] {msg}");
    }
    io::stdout().flush().ok();
}

/// Print a deeper diagnostic intended for backend compatibility issue reports.
pub(crate) fn print_trace(msg: &str, color: bool) {
    if color {
        execute!(
            io::stdout(),
            SetForegroundColor(CtColor::DarkGrey),
            Print(format!("[trace] {msg}\n")),
            ResetColor,
        )
        .ok();
    } else {
        println!("[trace] {msg}");
    }
    io::stdout().flush().ok();
}

/// Insert thousands separators into a token count for display.
pub(crate) fn fmt_tokens(n: u32) -> String {
    let s = n.to_string();
    let mut out = String::with_capacity(s.len() + s.len() / 3);
    for (i, c) in s.chars().rev().enumerate() {
        if i > 0 && i % 3 == 0 {
            out.push(',');
        }
        out.push(c);
    }
    out.chars().rev().collect()
}

// --- Context-budget gauge formatting (Step 24.5, #559) ---------------------
//
// The token gauge shows how full the context window is BEFORE compression
// fires. Two display registers: a `used/budget` fraction in `k` (thousands) for
// the live header — `899k/1024k` — and a single compact figure that rolls a
// round window up to `M` (where **1M = 1024k**) for summary contexts.

/// Tokens as a rounded `k` (thousands) figure, e.g. `899_000 → "899k"`. The
/// fraction register used by the live gauge.
pub(crate) fn fmt_tokens_k(n: u32) -> String {
    format!("{}k", (n + 500) / 1000)
}

/// Tokens as a compact figure: `"Nk"` below 1024k, otherwise `"N[.N]M"` with
/// **1M = 1024k** (so a 1,024,000-token window reads `1M`, 1,536,000 → `1.5M`).
pub fn fmt_tokens_compact(n: u32) -> String {
    let k = (n + 500) / 1000;
    if k >= 1024 {
        let m = k as f64 / 1024.0;
        if (m - m.round()).abs() < 0.05 {
            format!("{}M", m.round() as u64)
        } else {
            format!("{m:.1}M")
        }
    } else {
        format!("{k}k")
    }
}

/// `used/budget` gauge in `k`, e.g. `"899k/1024k"`.
pub fn fmt_token_gauge(used: u32, budget: u32) -> String {
    format!("{}/{}", fmt_tokens_k(used), fmt_tokens_k(budget))
}

/// Fill-level band for the gauge — color-type-agnostic so each caller maps it to
/// its own palette (crossterm for the scroller, ratatui for the rich header).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GaugeLevel {
    /// Under 75% — comfortable.
    Ok,
    /// 75–90% — approaching the send budget.
    Warn,
    /// 90%+ — compression is imminent.
    Critical,
}

/// Classify a `used/budget` fill into a [`GaugeLevel`] (green / amber / red).
pub fn gauge_level(used: u32, budget: u32) -> GaugeLevel {
    let pct = if budget == 0 {
        0
    } else {
        (used as u64 * 100 / budget as u64) as u32
    };
    if pct >= 90 {
        GaugeLevel::Critical
    } else if pct >= 75 {
        GaugeLevel::Warn
    } else {
        GaugeLevel::Ok
    }
}

/// Print a context-overflow adaptation notice to the TUI stream.
pub(crate) fn emit_overflow_notice(
    color: bool,
    usage: Option<&crate::TokenUsage>,
    safe_context: Option<u32>,
    model: &str,
    attempt: u32,
) {
    let token_str = usage
        .map(|u| format!("{} tokens", fmt_tokens(u.input_tokens)))
        .unwrap_or_else(|| "unknown tokens".to_string());
    let safe_str = safe_context
        .map(|s| format!(" > {} safe window for {model}", fmt_tokens(s)))
        .unwrap_or_default();
    let msg = format!(
        "⚠  context overflow likely ({token_str}{safe_str})\n⟳  trimming context and retrying (attempt {attempt}/2)…"
    );
    if color {
        execute!(
            io::stdout(),
            SetForegroundColor(CtColor::DarkYellow),
            Print(format!("{msg}\n")),
            ResetColor,
        )
        .ok();
    } else {
        println!("{msg}");
    }
    io::stdout().flush().ok();
}

/// Print a one-line compression notice (Step 18.4, #247). Always visible —
/// the B6 baseline's failure mode was context loss with *no event anywhere*;
/// "visibly degrades" is the acceptance bar.
/// The compression-notice text + whether it is the **loud static-marker last
/// resort** (Step 24.7, #559). Pure → testable; `emit_compression_notice`
/// prints it. Distinct registers per outcome: `✓` summarized, `⧉` pruned, and a
/// loud `⛔` for the static marker — the #548 "silent context loss" fix.
pub(crate) fn compression_notice_text(
    action: super::compress::CompressAction,
    before: usize,
    after: usize,
    suffix: &str,
) -> (String, bool) {
    use super::compress::CompressAction;
    let b = fmt_tokens(before.min(u32::MAX as usize) as u32);
    let a = fmt_tokens(after.min(u32::MAX as usize) as u32);
    match action {
        CompressAction::StaticFallback => (
            format!(
                "⛔  summary unavailable — context compacted to a marker \
                 (~{b} → ~{a} est. tokens{suffix}). Re-read files if needed."
            ),
            true,
        ),
        CompressAction::Summarized => (
            format!("✓  context summarized: ~{b} → ~{a} est. tokens{suffix}"),
            false,
        ),
        other => (
            format!(
                "⧉  context compressed: ~{b} → ~{a} est. tokens ({}{suffix})",
                other.describe()
            ),
            false,
        ),
    }
}

pub(crate) fn emit_compression_notice(
    color: bool,
    before: usize,
    after: usize,
    action: super::compress::CompressAction,
    suffix: &str,
) {
    let (msg, loud) = compression_notice_text(action, before, after, suffix);
    // The static-marker last resort is RED + loud (24.7) so it can't be missed;
    // other outcomes stay amber.
    let hue = if loud {
        CtColor::Red
    } else {
        CtColor::DarkYellow
    };
    if color {
        execute!(
            io::stdout(),
            SetForegroundColor(hue),
            Print(format!("{msg}\n")),
            ResetColor,
        )
        .ok();
    } else {
        println!("{msg}");
    }
    io::stdout().flush().ok();
}

/// Print a visible retry indicator to the TUI so the user knows why there's
/// a pause rather than seeing a silent hang.
pub(crate) fn print_retry_indicator(attempt: u32, delay: std::time::Duration, color: bool) {
    let delay_s = delay.as_secs_f32();
    let msg = format!("  ↻ connection lost — retrying in {delay_s:.1}s (attempt {attempt})…\n");
    if color {
        execute!(
            io::stdout(),
            SetForegroundColor(CtColor::Rgb {
                r: 200,
                g: 140,
                b: 0
            }),
            Print(&msg),
            ResetColor,
        )
        .ok();
    } else {
        print!("{msg}");
    }
    io::stdout().flush().ok();
}

fn tool_call_lines(name: &str, detail: &str, cols: usize) -> Vec<String> {
    // #1153: WORD-WRAP the full detail across as many lines as it needs — the
    // operator must be able to audit exactly what command/path ran, so the
    // command is never truncated with `…`. Continuation lines are indented to
    // align under the detail. Keep the "⚙  {name}: " prefix whole (it's short).
    let prefix_w = 3 + name.chars().count() + 2; // "⚙  " + name + ": "
    let detail_w = cols.saturating_sub(prefix_w).max(8);
    let wrapped = wrap_to_width(detail, detail_w);
    let indent = " ".repeat(prefix_w);
    wrapped
        .into_iter()
        .enumerate()
        .map(|(i, line)| {
            if i == 0 {
                format!("⚙  {name}: {line}")
            } else {
                format!("{indent}{line}")
            }
        })
        .collect()
}

/// #1235: the SPILL VIEW — a bounded, TAIL-biased rendering of completed
/// tool output. Pure: returns the exact lines to print (gutter glyphs
/// included) so the unit tier tests the geometry without a terminal.
///
/// Shape (per the issue sketch): when the output fits in `view` lines it is
/// shown whole with the `▒` gutter and the `…` end-of-output marker; when it
/// overflows, the LAST `view` lines are shown (the tail is where grep hits
/// and errors live), the `▲` boundary line carries the hidden count, and the
/// `▓` thumb marks the tail position. `view == 0` means unbounded (no gutter
/// — the raw historical behavior). This is the completion-time foundation;
/// live tail-follow and interactive scrolling are gated on a superseding
/// decision doc (plain_scroller_tui.md bans multi-line redraws) plus a streaming
/// dispatch seam — see #1235 for the ladder.
/// #1235: the resolved spill-view height — a process-wide knob following the
/// `output_budget` atomics precedent (set at per-turn config resolve, read at
/// the display site) so the value reaches the shell echo without threading a
/// parallel param through every tool signature. Default 3 (`[tui] spill_lines`).
static SPILL_LINES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(3);

/// Set the spill-view height (per-turn, from the resolved config).
pub fn set_spill_lines(n: usize) {
    SPILL_LINES.store(n, std::sync::atomic::Ordering::Relaxed);
}

/// The current spill-view height.
pub(crate) fn spill_lines() -> usize {
    SPILL_LINES.load(std::sync::atomic::Ordering::Relaxed)
}

/// The one place that names the recovery path out of a truncated view (#1433).
///
/// Every truncation marker interpolates THIS, so a third one cannot silently
/// ship without it — which is exactly how `:{HIDDEN_TAIL}` below drifted from
/// the boundary marker and left the operator stuck. codex solves it the same
/// way with a single `TRANSCRIPT_HINT` constant; pi derives the text from its
/// live binding table so a rebind can never desync a hint.
pub(crate) const SPILL_RECOVERY_HINT: &str = "/spill N raises this view";

pub(crate) fn spill_view_lines(output: &str, view: usize, columns: usize) -> Vec<String> {
    // #1433: the budget is spent in RENDERED rows, not logical lines — counting
    // lines let one 4000-char diagnostic consume an unbounded number of them.
    //
    // But the emitted text stays UNWRAPPED. This excerpt is the canonical
    // committed block, and `plain_scroller_tui.md` names scrollback as
    // "searchable, copy-pasteable, and capturable with script/asciinema".
    // Hard-wrapping would insert a newline and a gutter mid-sentence: visually
    // identical to the terminal's own soft-wrap, but it breaks copy-paste and
    // breaks search across the wrap point. So we MEASURE with the wrapper and
    // EMIT the original.
    //
    // The gutter glyph costs two columns ("▒ "), so content wraps at
    // `columns - 2`.
    let content_width = columns.saturating_sub(2).max(1);
    let rows_of = |l: &str| wrap_to_width(l, content_width).len().max(1);

    let lines: Vec<&str> = output.lines().collect();
    if lines.is_empty() {
        return Vec::new();
    }
    if view == 0 {
        return lines.iter().map(|l| (*l).to_string()).collect();
    }

    // Walk from the tail (where errors and hits live) taking whole lines while
    // their rendered rows fit the budget.
    let mut kept = 0usize;
    let mut used = 0usize;
    for l in lines.iter().rev() {
        let r = rows_of(l);
        if kept > 0 && used + r > view {
            break;
        }
        kept += 1;
        used += r;
        if used >= view {
            break;
        }
    }
    let start = lines.len() - kept;
    let tail = &lines[start..];

    // A single line wider than the whole budget is the pathological case the
    // issue is about. It is the ONLY case where the text is altered, and it is
    // altered the same way the rest of this function already works: keep the
    // TAIL, which is where the error is, and say so.
    if used > view && tail.len() == 1 {
        let wrapped = wrap_to_width(tail[0], content_width);
        let dropped = wrapped.len() - view;
        let mut out = vec![format!(
            "▲ {dropped} more wrapped rows above · {SPILL_RECOVERY_HINT}"
        )];
        for (i, row) in wrapped[dropped..].iter().enumerate() {
            let glyph = if i + 1 == view { '▓' } else { '▒' };
            out.push(format!("{glyph} {row}"));
        }
        out.push("…".to_string());
        return out;
    }

    let lines: Vec<String> = tail.iter().map(|l| (*l).to_string()).collect();
    let hidden_logical = start;
    let mut out = Vec::new();
    if hidden_logical == 0 {
        for l in &lines {
            out.push(format!("▒ {l}"));
        }
        out.push("…".to_string());
        return out;
    }
    let hidden = hidden_logical;
    // #1263: this excerpt is PLAIN PRINTED TEXT — it deliberately shares the
    // ▲/▒/▓ glyphs with the live viewport, so without this hint it masqueraded
    // as the interactive scroller (the diagnosed operator tried to expand it in
    // scrollback). Name the real recovery path at the point of use.
    out.push(format!(
        "▲ {hidden} more lines above · {SPILL_RECOVERY_HINT}"
    ));
    let tail = &lines[..];
    for (i, l) in tail.iter().enumerate() {
        let glyph = if i + 1 == tail.len() { '▓' } else { '▒' };
        out.push(format!("{glyph} {l}"));
    }
    out.push("…".to_string());
    out
}

/// Injected writer for one tool's operator-facing audit block. Production uses
/// stdout; tests use a `Vec<u8>` so dispatcher routing can be verified without
/// process-wide fd redirection.
pub(crate) struct ToolDisplay<W: Write> {
    writer: W,
    color: bool,
    cols: usize,
    spill_lines: usize,
    result_override: Option<String>,
}

impl<W: Write> ToolDisplay<W> {
    pub(crate) fn new(writer: W, color: bool, cols: usize, spill_lines: usize) -> Self {
        Self {
            writer,
            color,
            cols,
            spill_lines,
            result_override: None,
        }
    }

    pub(crate) fn call(&mut self, name: &str, detail: &str) {
        let lines = tool_call_lines(name, detail, self.cols);
        for (i, line) in lines.iter().enumerate() {
            if self.color {
                if i == 0 {
                    let prefix = format!("⚙  {name}");
                    let suffix = line.strip_prefix(&prefix).unwrap_or(line);
                    execute!(
                        &mut self.writer,
                        SetForegroundColor(NEWT_ORANGE_CT),
                        Print(prefix),
                        ResetColor,
                        SetForegroundColor(CtColor::DarkGrey),
                        Print(suffix),
                        ResetColor,
                        Print("\n"),
                    )
                    .ok();
                } else {
                    execute!(
                        &mut self.writer,
                        SetForegroundColor(CtColor::DarkGrey),
                        Print(line),
                        ResetColor,
                        Print("\n"),
                    )
                    .ok();
                }
            } else {
                writeln!(&mut self.writer, "{line}").ok();
            }
        }
        self.writer.flush().ok();
    }

    pub(crate) fn result(&mut self, output: &str) {
        let overridden = self.result_override.take();
        let output = overridden.as_deref().unwrap_or(output);
        let output = if output.trim().is_empty() {
            "(no output)"
        } else {
            output
        };
        let rendered = spill_view_lines(output, self.spill_lines, self.cols).join("\n");
        if self.color {
            execute!(
                &mut self.writer,
                SetForegroundColor(CtColor::DarkGrey),
                Print(format!("{rendered}\n")),
                ResetColor,
            )
            .ok();
        } else {
            writeln!(&mut self.writer, "{rendered}").ok();
        }
        self.writer.flush().ok();
    }

    #[cfg(test)]
    pub(crate) fn into_inner(self) -> W {
        self.writer
    }
}

/// Non-final presentation events available to the execution layer. Header and
/// completed-result rendering are intentionally absent: the outer dispatcher
/// owns those exactly once for every return path.
pub(crate) trait ToolPresentation: Send {
    fn preview(&mut self, output: &str, max_lines: usize);
    fn document(&mut self, output: &str);
    fn override_result(&mut self, output: String);
}

impl<W: Write + Send> ToolPresentation for ToolDisplay<W> {
    fn preview(&mut self, output: &str, max_lines: usize) {
        let lines: Vec<&str> = output.lines().collect();
        let shown = if max_lines == 0 {
            lines.len()
        } else {
            lines.len().min(max_lines)
        };
        let mut rendered = lines[..shown].join("\n");
        let hidden = lines.len().saturating_sub(shown);
        if hidden > 0 {
            if !rendered.is_empty() {
                rendered.push('\n');
            }
            rendered.push_str(&format!(
                "  … ({hidden} more lines hidden · {SPILL_RECOVERY_HINT})"
            ));
        }
        if rendered.is_empty() {
            return;
        }
        if self.color {
            execute!(
                &mut self.writer,
                SetForegroundColor(CtColor::DarkGrey),
                Print(format!("{rendered}\n")),
                ResetColor,
            )
            .ok();
        } else {
            writeln!(&mut self.writer, "{rendered}").ok();
        }
        self.writer.flush().ok();
    }

    fn document(&mut self, output: &str) {
        writeln!(&mut self.writer, "{output}").ok();
        self.writer.flush().ok();
    }

    fn override_result(&mut self, output: String) {
        self.result_override = Some(output);
    }
}

/// Print a tool-call header so the user can see what the agent is doing.
#[cfg(test)]
pub(crate) fn print_tool_call(name: &str, detail: &str, color: bool) {
    ToolDisplay::new(io::stdout(), color, term_cols(), spill_lines()).call(name, detail);
}

/// Print completed tool output using the universal #1235 spill height. The
/// legacy `tool_output_lines` argument remains in compatibility signatures but
/// no longer overrides `[tui].spill_lines`.
#[cfg(test)]
pub(crate) fn print_tool_output(output: &str, _tool_output_lines: usize, color: bool) {
    ToolDisplay::new(io::stdout(), color, term_cols(), spill_lines()).result(output);
}

#[cfg(test)]
mod tests {
    use super::{
        fmt_tokens, print_harness_notice, print_list_item, print_newt, spill_view_lines,
        tool_call_lines, wrap_to_width, SPILL_RECOVERY_HINT,
    };

    /// #1433: the excerpt is capped by LOGICAL lines, so its "N rows" promise
    /// only holds when the output happens to be narrow. One long line — a
    /// compiler diagnostic, a minified JSON blob, a base64 payload — autowraps
    /// past the budget and floods the scrollback.
    ///
    /// codex names this rule explicitly (`exec_cell/render.rs`): "Wrap first so
    /// that truncation is applied to on-screen lines rather than logical lines.
    /// This ensures that a small number of very long lines cannot flood the
    /// viewport."
    ///
    /// Note the asymmetry this closes: the LIVE viewport already clips to width
    /// (`docs/decisions/live_spill_viewport.md` §3). Only the committed excerpt
    /// did not — so the same output was bounded while running and unbounded
    /// once finished, which is backwards from what an operator expects.
    #[test]
    fn one_long_line_cannot_flood_the_row_budget() {
        const COLS: usize = 80;
        let long = "x".repeat(400);
        let out = spill_view_lines(&long, 3, COLS);

        let rendered_rows: usize = out.iter().map(|l| wrap_to_width(l, COLS).len()).sum();
        // header + 3 body rows + the trailing ellipsis
        assert!(
            rendered_rows <= 5,
            "a single {}-char line rendered {rendered_rows} rows at {COLS} columns \
             against a 3-row budget:\n{out:#?}",
            long.len()
        );
    }

    /// #1433: the budget is measured in rendered rows, but the TEXT is not
    /// rewrapped. This excerpt is the canonical committed block, and
    /// `plain_scroller_tui.md` names scrollback as "searchable, copy-pasteable,
    /// and capturable with script/asciinema" — hard-wrapping would insert a
    /// newline and a gutter mid-sentence, which looks identical to the
    /// terminal's own soft-wrap but breaks copy-paste and breaks search across
    /// the wrap point.
    ///
    /// This is the regression that first showed up as
    /// `artifact_read_central_display_never_echoes_recovered_body` failing: a
    /// 103-character line split "…44 of 44 body / characters…" and a `contains`
    /// assertion stopped matching. The assertion was right and the wrap was
    /// wrong.
    #[test]
    fn a_line_wider_than_the_terminal_is_measured_but_not_rewrapped() {
        const COLS: usize = 40;
        let wide = "artifact:abc: returned 44 of 44 body characters at offset 0 (complete)";
        assert!(wide.len() > COLS, "the fixture must exceed the width");

        let out = spill_view_lines(&format!("first\n{wide}"), 3, COLS);
        let joined = out.join("\n");
        assert!(
            joined.contains("returned 44 of 44 body characters"),
            "the phrase was split across a wrap — copy-paste and search are \
             broken by rewrapping the canonical block:\n{joined}"
        );
    }

    /// #1433: measuring in rows must still SPEND the budget in rows — a wide
    /// line costs what it actually occupies, so fewer lines are shown, not more
    /// rows than asked for.
    #[test]
    fn a_wide_line_spends_the_row_budget_it_actually_occupies() {
        const COLS: usize = 20;
        // ~3 rows at width 18 (20 minus the "▒ " gutter).
        let wide = "w".repeat(50);
        let out = spill_view_lines(&format!("a\nb\nc\n{wide}"), 3, COLS);

        let body: Vec<&String> = out
            .iter()
            .filter(|l| l.starts_with('▒') || l.starts_with('▓'))
            .collect();
        assert_eq!(
            body.len(),
            1,
            "the wide line alone should consume the 3-row budget, leaving no \
             room for a/b/c:\n{out:#?}"
        );
        assert!(out[0].contains("more lines above"), "{out:#?}");
        assert!(
            out[0].contains(SPILL_RECOVERY_HINT),
            "every truncation marker names the way out:\n{out:#?}"
        );
    }

    /// #1235: the spill view is TAIL-biased with the issue's gutter glyphs —
    /// small outputs show whole (▒ gutter + … end marker), overflow shows the
    /// LAST `view` lines with the ▲ hidden-count boundary and the ▓ thumb on
    /// the tail line. view=0 = unbounded raw (historical behavior).
    #[test]
    fn spill_view_is_tail_biased_with_gutter_glyphs() {
        // Fits: whole output, ▒ gutter, end marker.
        let small = spill_view_lines("a\nb\nc", 3, 80);
        assert_eq!(small, vec!["▒ a", "▒ b", "▒ c", "…"]);

        // Overflows: LAST view lines (tail is where errors/hits live),
        // ▲ carries the hidden count, ▓ thumbs the tail.
        let big = spill_view_lines("l1\nl2\nl3\nl4\nl5", 3, 80);
        assert_eq!(
            big,
            vec![
                "▲ 2 more lines above · /spill N raises this view",
                "▒ l3",
                "▒ l4",
                "▓ l5",
                "…"
            ]
        );

        // Unbounded: raw lines, no gutter.
        assert_eq!(spill_view_lines("x\ny", 0, 80), vec!["x", "y"]);
        // Empty: nothing.
        assert!(spill_view_lines("", 3, 80).is_empty());
    }

    #[test]
    fn completed_tool_output_uses_the_spill_view() {
        let output = "l1\nl2\nl3\nl4\nl5";

        assert_eq!(
            spill_view_lines(output, 3, 80),
            vec![
                "▲ 2 more lines above · /spill N raises this view",
                "▒ l3",
                "▒ l4",
                "▓ l5",
                "…"
            ]
        );
        let raw: Vec<String> = output.lines().map(str::to_string).collect();
        assert_eq!(spill_view_lines(output, 0, 80), raw);
    }

    /// #1263: the COMPLETED excerpt names its real recovery path at the point
    /// of use — it is plain printed text sharing the live viewport's glyphs, so
    /// without the hint it masqueraded as the interactive scroller (the
    /// diagnosed operator tried to expand it in scrollback and could not).
    #[test]
    fn completed_excerpt_names_its_recovery_path() {
        let lines = spill_view_lines("l1\nl2\nl3\nl4\nl5", 3, 80);
        assert!(
            lines[0].contains("/spill N raises this view"),
            "the ▲ boundary must carry the recovery hint: {:?}",
            lines[0]
        );
        // #1263 fingerprint pin (the other half lives in the spill_view tests):
        // the completed excerpt's last row is the INERT `…` — never the live
        // frame's ⧉/▣ boundary.
        assert_eq!(lines.last().map(String::as_str), Some("…"));
        // The fits-entirely form is inert-terminated too.
        let small = spill_view_lines("a\nb", 3, 80);
        assert_eq!(small.last().map(String::as_str), Some("…"));
    }

    #[test]
    fn tool_call_lines_wrap_without_losing_the_command() {
        assert_eq!(
            tool_call_lines("find", ". (name=*.rs, type=f)", 80),
            vec!["⚙  find: . (name=*.rs, type=f)"]
        );
    }

    #[test]
    fn wrap_to_width_never_drops_and_hard_splits_long_tokens() {
        // #1153: the full command must survive — reassembling the wrap yields
        // the original (spaces preserved via split_inclusive).
        let cmd = "grep -rn \"NudgerProfile|resolve.*knob|KNOWN_KNOBS\" --include=*.rs newt-core/src newt-cli/src";
        let wrapped = super::wrap_to_width(cmd, 20);
        assert_eq!(wrapped.join(""), cmd, "no characters lost");
        assert!(
            wrapped.iter().all(|l| l.chars().count() <= 20),
            "each line fits"
        );
        assert!(wrapped.len() > 1, "long command actually wraps");

        // A single token longer than the width is hard-split, not dropped.
        let long = "a".repeat(50);
        let w = super::wrap_to_width(&long, 10);
        assert_eq!(w.join(""), long);
        assert_eq!(w.len(), 5);

        // Short input stays one line.
        assert_eq!(super::wrap_to_width("cargo build", 40), vec!["cargo build"]);
        // Embedded newlines split into separate logical lines.
        assert_eq!(super::wrap_to_width("a\nb", 40), vec!["a", "b"]);
    }

    #[test]
    fn fmt_tokens_inserts_thousands_separators() {
        assert_eq!(fmt_tokens(0), "0");
        assert_eq!(fmt_tokens(999), "999");
        assert_eq!(fmt_tokens(1_000), "1,000");
        assert_eq!(fmt_tokens(1_234_567), "1,234,567");
    }

    #[test]
    fn gauge_formatting_k_compact_fraction_and_level() {
        use super::{fmt_token_gauge, fmt_tokens_compact, fmt_tokens_k, gauge_level, GaugeLevel};
        // k (rounded thousands)
        assert_eq!(fmt_tokens_k(899_000), "899k");
        assert_eq!(fmt_tokens_k(1_024_000), "1024k");
        assert_eq!(fmt_tokens_k(512_400), "512k");
        // compact: 1M = 1024k; round M drops the .0
        assert_eq!(fmt_tokens_compact(899_000), "899k");
        assert_eq!(fmt_tokens_compact(1_024_000), "1M");
        assert_eq!(fmt_tokens_compact(2_048_000), "2M");
        assert_eq!(fmt_tokens_compact(1_536_000), "1.5M");
        // fraction
        assert_eq!(fmt_token_gauge(899_000, 1_024_000), "899k/1024k");
        // level bands: <75 Ok, 75–90 Warn, ≥90 Critical
        assert_eq!(gauge_level(100, 1000), GaugeLevel::Ok);
        assert_eq!(gauge_level(740, 1000), GaugeLevel::Ok);
        assert_eq!(gauge_level(750, 1000), GaugeLevel::Warn);
        assert_eq!(gauge_level(890, 1000), GaugeLevel::Warn);
        assert_eq!(gauge_level(900, 1000), GaugeLevel::Critical);
        assert_eq!(gauge_level(0, 0), GaugeLevel::Ok); // no budget → no panic
    }

    #[test]
    fn compression_notice_text_registers_per_outcome() {
        use super::compression_notice_text;
        use crate::agentic::compress::CompressAction;
        // The static-marker last resort is LOUD and degraded-sounding (24.7).
        let (msg, loud) =
            compression_notice_text(CompressAction::StaticFallback, 10_000, 6_000, "");
        assert!(loud, "static marker is the loud last resort");
        assert!(msg.starts_with("⛔"), "{msg}");
        assert!(msg.contains("summary unavailable"), "{msg}");
        assert!(msg.contains("Re-read files"), "{msg}");
        // Success and prune are calm, distinct glyphs.
        let (msg, loud) = compression_notice_text(CompressAction::Summarized, 10_000, 6_000, "");
        assert!(!loud);
        assert!(msg.starts_with("✓") && msg.contains("summarized"), "{msg}");
        let (msg, loud) = compression_notice_text(CompressAction::Pruned, 10_000, 6_000, "");
        assert!(!loud);
        assert!(
            msg.starts_with("⧉") && msg.contains("structural prune"),
            "{msg}"
        );
        // The over-budget suffix rides along.
        let (msg, _) = compression_notice_text(
            CompressAction::Summarized,
            10_000,
            6_000,
            ", still over budget",
        );
        assert!(msg.contains(", still over budget"), "{msg}");
    }

    /// `docs/decisions/tty_widget_suite.md` §5 row 3: `Notice` must reproduce
    /// this builder's bytes **exactly** before any call site is migrated onto
    /// it. Every one of the three registers is a glyph + the two-space gutter +
    /// text, so the widget's composition is the right shape and the migration
    /// in step 5 is a deletion rather than a rewrite.
    ///
    /// Byte-for-byte, not "starts with" — the assertions above are the loose
    /// ones this deliberately is not.
    #[test]
    fn notice_reproduces_the_compression_notice_bytes() {
        use super::compression_notice_text;
        use crate::agentic::compress::CompressAction;
        use crate::tty::{Level, Notice};

        let cases = [
            (CompressAction::StaticFallback, Level::Loud, "⛔"),
            (CompressAction::Summarized, Level::Ok, "✓"),
            (CompressAction::Pruned, Level::Info, "⧉"),
        ];
        for (action, level, glyph) in cases {
            let (msg, _) = compression_notice_text(action, 10_000, 6_000, "");
            let body = msg
                .strip_prefix(glyph)
                .and_then(|r| r.strip_prefix("  "))
                .unwrap_or_else(|| panic!("{action:?} is not `{glyph}` + two spaces: {msg:?}"));
            assert_eq!(
                Notice::new(level, glyph, body).gap(2).line(),
                msg,
                "Notice must reproduce {action:?}'s bytes exactly"
            );
        }
    }

    /// Visual preview for UX review (run with `--ignored --nocapture`): the three
    /// compression-notice registers with their colors.
    #[test]
    #[ignore = "visual preview; run with --ignored --nocapture"]
    fn compression_notice_visual_preview() {
        use super::compression_notice_text;
        use crate::agentic::compress::CompressAction;
        let paint = |loud: bool, s: &str| {
            if loud {
                format!("\x1b[31m{s}\x1b[0m") // red, loud
            } else {
                format!("\x1b[33m{s}\x1b[0m") // amber
            }
        };
        println!("\n  compression-notice registers (24.7):");
        for action in [
            CompressAction::Pruned,
            CompressAction::Summarized,
            CompressAction::StaticFallback,
        ] {
            let (msg, loud) = compression_notice_text(action, 1_024_000, 600_000, "");
            println!("    {}", paint(loud, &msg));
        }
        println!();
    }

    /// Visual preview for UX review (run with `--nocapture`). Not an assertion —
    /// prints the gauge at several fills with colors so the format/thresholds
    /// can be eyeballed. Ignored by default so it never adds CI noise.
    #[test]
    #[ignore = "visual preview; run with --ignored --nocapture"]
    fn gauge_visual_preview() {
        use super::{fmt_token_gauge, fmt_tokens_compact, gauge_level, GaugeLevel};
        use crossterm::style::Color;
        let color = |lvl: GaugeLevel| match lvl {
            GaugeLevel::Ok => Color::Green,
            GaugeLevel::Warn => Color::DarkYellow,
            GaugeLevel::Critical => Color::Red,
        };
        let paint = |c: Color, s: &str| match c {
            Color::Green => format!("\x1b[32m{s}\x1b[0m"),
            Color::DarkYellow => format!("\x1b[33m{s}\x1b[0m"),
            Color::Red => format!("\x1b[31m{s}\x1b[0m"),
            _ => s.to_string(),
        };
        let budget = 1_024_000;
        println!("\n  context-budget gauge — fraction form (live header):");
        for used in [102_000u32, 512_000, 800_000, 972_000, 1_010_000] {
            let lvl = gauge_level(used, budget);
            let g = fmt_token_gauge(used, budget);
            println!("    {:<14} {:?}", paint(color(lvl), &g), lvl);
        }
        println!("\n  compact budget form (1M = 1024k):");
        for n in [899_000u32, 1_024_000, 1_536_000, 2_048_000] {
            println!("    {n:>9} → {}", fmt_tokens_compact(n));
        }
        println!(
            "\n  mock header:\n    [2026-06-22 14:32:01] vi --INSERT-- nemotron @ REDACTED-HOST   {}\n",
            paint(color(gauge_level(972_000, budget)), &fmt_token_gauge(972_000, budget)),
        );
    }

    /// The narrator + list-item printers write to stdout (hard to capture here),
    /// so this just exercises every branch — color/no-color × active/inactive ×
    /// verbose — to keep them from rotting and to cover them for the gate.
    #[test]
    fn printers_cover_every_branch_without_panicking() {
        for color in [true, false] {
            for verbose in [true, false] {
                print_newt("narrator line", color, verbose);
            }
            print_list_item("name · ollama · model @ url", true, color);
            print_list_item("name · ollama · model @ url", false, color);
            print_harness_notice(
                "over budget — dispatching and letting the backend decide",
                color,
            );
        }
    }
}