import time
from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock, patch

import cli as cli_mod
from cli import HermesCLI


def _make_cli(model: str = "anthropic/claude-sonnet-4-20250514"):
    cli_obj = HermesCLI.__new__(HermesCLI)
    cli_obj.model = model
    cli_obj.session_start = datetime.now() - timedelta(minutes=14, seconds=32)
    cli_obj.conversation_history = [{"role": "user", "content": "hi"}]
    cli_obj.agent = None
    return cli_obj


def _attach_agent(
    cli_obj,
    *,
    input_tokens: int | None = None,
    output_tokens: int | None = None,
    cache_read_tokens: int = 0,
    cache_write_tokens: int = 0,
    prompt_tokens: int,
    completion_tokens: int,
    total_tokens: int,
    api_calls: int,
    context_tokens: int,
    context_length: int,
    compressions: int = 0,
):
    cli_obj.agent = SimpleNamespace(
        model=cli_obj.model,
        provider="anthropic" if cli_obj.model.startswith("anthropic/") else None,
        base_url="",
        session_input_tokens=input_tokens if input_tokens is not None else prompt_tokens,
        session_output_tokens=output_tokens if output_tokens is not None else completion_tokens,
        session_cache_read_tokens=cache_read_tokens,
        session_cache_write_tokens=cache_write_tokens,
        session_prompt_tokens=prompt_tokens,
        session_completion_tokens=completion_tokens,
        session_total_tokens=total_tokens,
        session_api_calls=api_calls,
        get_rate_limit_state=lambda: None,
        context_compressor=SimpleNamespace(
            last_prompt_tokens=context_tokens,
            context_length=context_length,
            compression_count=compressions,
        ),
    )
    return cli_obj


class TestCLIStatusBar:
    def test_context_style_thresholds(self):
        cli_obj = _make_cli()

        assert cli_obj._status_bar_context_style(None) == "class:status-bar-dim"
        assert cli_obj._status_bar_context_style(10) == "class:status-bar-good"
        assert cli_obj._status_bar_context_style(50) == "class:status-bar-warn"
        assert cli_obj._status_bar_context_style(81) == "class:status-bar-bad"
        assert cli_obj._status_bar_context_style(95) == "class:status-bar-critical"

    def test_build_status_bar_text_for_wide_terminal(self):
        cli_obj = _attach_agent(
            _make_cli(),
            prompt_tokens=10_230,
            completion_tokens=2_220,
            total_tokens=12_450,
            api_calls=7,
            context_tokens=12_450,
            context_length=200_000,
        )

        text = cli_obj._build_status_bar_text(width=120)

        assert "claude-sonnet-4-20250514" in text
        assert "12.4K/200K" in text
        assert "6%" in text
        assert "$0.06" not in text  # cost hidden by default
        assert "15m" in text

    def test_post_compression_sentinel_does_not_render_negative(self):
        """Right after a compression, last_prompt_tokens is parked at the -1
        sentinel until the next API call reports real usage. The status bar
        must clamp it to 0 instead of rendering "-1/200K" / "-1%".
        """
        cli_obj = _attach_agent(
            _make_cli(),
            prompt_tokens=10_230,
            completion_tokens=2_220,
            total_tokens=12_450,
            api_calls=7,
            context_tokens=-1,
            context_length=200_000,
        )

        snapshot = cli_obj._get_status_bar_snapshot()
        assert snapshot["context_tokens"] == 0
        assert snapshot["context_percent"] == 0

        text = cli_obj._build_status_bar_text(width=120)
        assert "-1" not in text
        assert "0/200K" in text

    def test_input_height_counts_prompt_only_on_first_wrapped_row(self):
        # Regression for prompt_toolkit classic CLI resize glitches: the prompt
        # is inserted by BeforeInput only on logical line 0. At three terminal
        # cells, "⚔ " leaves one cell for the first input character, but
        # wrapped continuation rows use the full three cells. Estimating every
        # wrapped row as one-cell wide over-allocates the TextArea and can leave
        # stale prompt/input cells visible after resize.
        assert cli_mod._estimate_tui_input_height(["abcdef"], "⚔ ", 3) == 3

    def test_input_height_counts_wide_characters_using_cell_width(self):
        # Prompt width (2 cells) + ten CJK chars (20 cells) = 22 display cells,
        # which wraps to two rows at 14 terminal columns.
        assert cli_mod._estimate_tui_input_height(["你" * 10], "❯ ", 14) == 2

    def test_input_height_clamps_zero_width_to_one_cell(self):
        # Some terminals briefly report zero columns during resize. Treat that
        # as a one-cell terminal rather than falling back to a fake wide width.
        assert cli_mod._estimate_tui_input_height(["abcd"], "", 0) == 4

    def test_build_status_bar_text_no_cost_in_status_bar(self):
        cli_obj = _attach_agent(
            _make_cli(),
            prompt_tokens=10000,
            completion_tokens=5000,
            total_tokens=15000,
            api_calls=7,
            context_tokens=50000,
            context_length=200_000,
        )

        text = cli_obj._build_status_bar_text(width=120)
        assert "$" not in text  # cost is never shown in status bar

    def test_build_status_bar_text_collapses_for_narrow_terminal(self):
        cli_obj = _attach_agent(
            _make_cli(),
            prompt_tokens=10000,
            completion_tokens=2400,
            total_tokens=12400,
            api_calls=7,
            context_tokens=12400,
            context_length=200_000,
        )

        text = cli_obj._build_status_bar_text(width=60)

        assert "⚕" in text
        assert "$0.06" not in text  # cost hidden by default
        assert "15m" in text
        assert "200K" not in text

    def test_build_status_bar_text_handles_missing_agent(self):
        cli_obj = _make_cli()

        text = cli_obj._build_status_bar_text(width=100)

        assert "⚕" in text
        assert "claude-sonnet-4-20250514" in text

    def test_compression_count_shown_in_wide_status_bar(self):
        cli_obj = _attach_agent(
            _make_cli(),
            prompt_tokens=10_230,
            completion_tokens=2_220,
            total_tokens=12_450,
            api_calls=7,
            context_tokens=12_450,
            context_length=200_000,
            compressions=3,
        )

        text = cli_obj._build_status_bar_text(width=120)

        assert "🗜️ 3" in text

    def test_compression_count_hidden_when_zero(self):
        cli_obj = _attach_agent(
            _make_cli(),
            prompt_tokens=10_230,
            completion_tokens=2_220,
            total_tokens=12_450,
            api_calls=7,
            context_tokens=12_450,
            context_length=200_000,
            compressions=0,
        )

        text = cli_obj._build_status_bar_text(width=120)

        assert "🗜️" not in text

    def test_compression_count_shown_in_medium_status_bar(self):
        cli_obj = _attach_agent(
            _make_cli(),
            prompt_tokens=10_000,
            completion_tokens=2_400,
            total_tokens=12_400,
            api_calls=7,
            context_tokens=12_400,
            context_length=200_000,
            compressions=2,
        )

        text = cli_obj._build_status_bar_text(width=60)

        assert "🗜️ 2" in text

    def test_compression_count_hidden_in_narrow_status_bar(self):
        cli_obj = _attach_agent(
            _make_cli(),
            prompt_tokens=10_000,
            completion_tokens=2_400,
            total_tokens=12_400,
            api_calls=7,
            context_tokens=12_400,
            context_length=200_000,
            compressions=5,
        )

        text = cli_obj._build_status_bar_text(width=50)

        assert "🗜️" not in text

    def test_compression_count_style_thresholds(self):
        cli_obj = _make_cli()

        assert cli_obj._compression_count_style(1) == "class:status-bar-dim"
        assert cli_obj._compression_count_style(4) == "class:status-bar-dim"
        assert cli_obj._compression_count_style(5) == "class:status-bar-warn"
        assert cli_obj._compression_count_style(9) == "class:status-bar-warn"
        assert cli_obj._compression_count_style(10) == "class:status-bar-bad"
        assert cli_obj._compression_count_style(25) == "class:status-bar-bad"

    def test_compression_count_in_wide_fragments(self):
        cli_obj = _attach_agent(
            _make_cli(),
            prompt_tokens=10_230,
            completion_tokens=2_220,
            total_tokens=12_450,
            api_calls=7,
            context_tokens=12_450,
            context_length=200_000,
            compressions=7,
        )
        cli_obj._status_bar_visible = True

        frags = cli_obj._get_status_bar_fragments()
        frag_texts = [text for _, text in frags]

        assert "🗜️ 7" in frag_texts
        frag_styles = {text: style for style, text in frags}
        assert frag_styles["🗜️ 7"] == "class:status-bar-warn"

    def test_compression_count_absent_from_fragments_when_zero(self):
        cli_obj = _attach_agent(
            _make_cli(),
            prompt_tokens=10_230,
            completion_tokens=2_220,
            total_tokens=12_450,
            api_calls=7,
            context_tokens=12_450,
            context_length=200_000,
            compressions=0,
        )
        cli_obj._status_bar_visible = True

        frags = cli_obj._get_status_bar_fragments()
        frag_texts = [text for _, text in frags]

        assert not any("🗜️" in t for t in frag_texts)

    def test_minimal_tui_chrome_threshold(self):
        cli_obj = _make_cli()

        assert cli_obj._use_minimal_tui_chrome(width=63) is True
        assert cli_obj._use_minimal_tui_chrome(width=64) is False

    def test_bottom_input_rule_hides_on_narrow_terminals(self):
        cli_obj = _make_cli()

        assert cli_obj._tui_input_rule_height("top", width=50) == 1
        assert cli_obj._tui_input_rule_height("bottom", width=50) == 0
        assert cli_obj._tui_input_rule_height("bottom", width=90) == 1

    def test_input_rules_hide_after_resize_until_next_input(self):
        """When _status_bar_suppressed_after_resize is set, both rules hide.

        See _recover_after_resize — column shrink reflows already-rendered
        bars into scrollback, so we hide the separators while the reflow
        settles, then clear the flag (either via the scheduled unsuppress
        timer or the next submitted input).
        """
        cli_obj = _make_cli()
        cli_obj._status_bar_suppressed_after_resize = True

        assert cli_obj._tui_input_rule_height("top", width=90) == 0
        assert cli_obj._tui_input_rule_height("bottom", width=90) == 0

        cli_obj._status_bar_suppressed_after_resize = False
        assert cli_obj._tui_input_rule_height("top", width=90) == 1
        assert cli_obj._tui_input_rule_height("bottom", width=90) == 1

    def test_scheduled_unsuppress_clears_flag_and_repaints_without_input(self):
        """The status bar returns during idle after a resize, without a keypress.

        Regression: the suppression flag was only cleared on the next
        *submitted* input, so a resize/reflow followed by idle left the bar
        hidden indefinitely even while the refresh clock kept ticking. The
        scheduled unsuppress timer must clear the flag and invalidate the app
        on its own.
        """
        cli_obj = _make_cli()
        cli_obj._status_bar_unsuppress_timer = None
        cli_obj._status_bar_suppressed_after_resize = True
        app = MagicMock()
        app.loop = None  # force the synchronous _clear path

        # Schedule with ~0 delay so the timer fires promptly under test.
        cli_obj._schedule_status_bar_unsuppress(app, delay=0.01)
        time.sleep(0.1)

        assert cli_obj._status_bar_suppressed_after_resize is False
        app.invalidate.assert_called()
        # Bar chrome is visible again with no submitted input.
        assert cli_obj._tui_input_rule_height("top", width=90) == 1

    def test_scheduled_unsuppress_debounces_resize_storm(self):
        """A fresh resize cancels the pending unsuppress and restarts it."""
        cli_obj = _make_cli()
        cli_obj._status_bar_unsuppress_timer = None
        cli_obj._status_bar_suppressed_after_resize = True
        app = MagicMock()
        app.loop = None

        # First schedule (long delay) then a second should cancel the first.
        cli_obj._schedule_status_bar_unsuppress(app, delay=5.0)
        first_timer = cli_obj._status_bar_unsuppress_timer
        assert first_timer is not None
        cli_obj._schedule_status_bar_unsuppress(app, delay=0.01)
        assert first_timer is not cli_obj._status_bar_unsuppress_timer
        assert not first_timer.is_alive() or first_timer.finished.is_set()
        time.sleep(0.1)
        assert cli_obj._status_bar_suppressed_after_resize is False

    def test_scrollback_box_width_returns_viewport_width(self):
        """Decorative scrollback boxes use the full viewport width.

        The previous clamp (max 56 cols) was reverted in favour of the
        prompt_toolkit ``_output_screen_diff`` monkey-patch landed in
        #26137, which keeps chrome out of scrollback at the source.
        We accept that an aggressive column-shrink may visually reflow
        already printed Panel borders — that's a cosmetic artifact of
        stamped scrollback history, not a live-render bug.
        """
        from cli import HermesCLI

        # Floor at 32 — narrow terminals still get something usable
        # (avoids negative ``'─' * (w - 2)`` math).
        assert HermesCLI._scrollback_box_width(20) == 32
        assert HermesCLI._scrollback_box_width(32) == 32
        # Above the floor, return the actual viewport width — no cap.
        assert HermesCLI._scrollback_box_width(48) == 48
        assert HermesCLI._scrollback_box_width(80) == 80
        assert HermesCLI._scrollback_box_width(120) == 120
        assert HermesCLI._scrollback_box_width(200) == 200

    def test_agent_spacer_reclaimed_on_narrow_terminals(self):
        cli_obj = _make_cli()
        cli_obj._agent_running = True

        assert cli_obj._agent_spacer_height(width=50) == 0
        assert cli_obj._agent_spacer_height(width=90) == 1
        cli_obj._agent_running = False
        assert cli_obj._agent_spacer_height(width=90) == 0

    def test_spinner_line_hidden_on_narrow_terminals(self):
        cli_obj = _make_cli()
        cli_obj._spinner_text = "thinking"

        assert cli_obj._spinner_widget_height(width=50) == 0
        assert cli_obj._spinner_widget_height(width=90) == 1
        cli_obj._spinner_text = ""
        assert cli_obj._spinner_widget_height(width=90) == 0

    def test_spinner_height_uses_display_width_for_wide_characters(self):
        cli_obj = _make_cli()
        cli_obj._spinner_text = "你" * 40
        cli_obj._tool_start_time = 0

        assert cli_obj._spinner_widget_height(width=64) == 2

    def test_spinner_elapsed_format_is_fixed_width_to_reduce_wrap_jitter(self):
        cli_obj = _make_cli()
        cli_obj._spinner_text = "running tool"

        # Pin the clock: time.monotonic()'s epoch is arbitrary (often near
        # boot), so deriving _tool_start_time from the real monotonic clock
        # made the test fail on hosts where monotonic() < 65.2 — the start
        # time went negative, the (t0 > 0) guard in _render_spinner_text
        # dropped the "(elapsed)" suffix entirely, and the split below hit an
        # IndexError. A fixed clock keeps both elapsed paths deterministic.
        with patch.object(cli_mod.time, "monotonic", return_value=1000.0):
            # <60s path
            cli_obj._tool_start_time = 1000.0 - 9.2
            short = cli_obj._render_spinner_text()

            # >=60s path
            cli_obj._tool_start_time = 1000.0 - 65.2
            long = cli_obj._render_spinner_text()

        short_elapsed = short.split("(", 1)[1].rstrip(")")
        long_elapsed = long.split("(", 1)[1].rstrip(")")

        assert len(short_elapsed) == len(long_elapsed)
        assert "m" in long_elapsed and "s" in long_elapsed

    def test_voice_status_bar_compacts_on_narrow_terminals(self):
        cli_obj = _make_cli()
        cli_obj._voice_mode = True
        cli_obj._voice_recording = False
        cli_obj._voice_processing = False
        cli_obj._voice_tts = True
        cli_obj._voice_continuous = True

        fragments = cli_obj._get_voice_status_fragments(width=50)

        assert fragments == [("class:voice-status", " 🎤 Ctrl+B ")]

    def test_voice_recording_status_bar_compacts_on_narrow_terminals(self):
        cli_obj = _make_cli()
        cli_obj._voice_mode = True
        cli_obj._voice_recording = True
        cli_obj._voice_processing = False

        fragments = cli_obj._get_voice_status_fragments(width=50)

        assert fragments == [("class:voice-status-recording", " ● REC ")]

    # Round-13 Copilot review regressions on #19835. The label in voice
    # status bar / recording hint / placeholder must render the
    # configured ``voice.record_key`` — not hardcoded Ctrl+B. Pinning
    # the cache (``set_voice_record_key_cache``) keeps display in sync
    # with the prompt_toolkit binding without re-reading config on
    # every render.
    def test_voice_status_bar_renders_configured_ctrl_letter(self):
        cli_obj = _make_cli()
        cli_obj._voice_mode = True
        cli_obj._voice_recording = False
        cli_obj._voice_processing = False
        cli_obj._voice_tts = False
        cli_obj._voice_continuous = False
        cli_obj.set_voice_record_key_cache("ctrl+o")

        wide = cli_obj._get_voice_status_fragments(width=120)
        assert any("Ctrl+O to record" in text for _cls, text in wide)

        compact = cli_obj._get_voice_status_fragments(width=50)
        assert compact == [("class:voice-status", " 🎤 Ctrl+O ")]

    def test_voice_recording_status_bar_renders_configured_named_key(self):
        cli_obj = _make_cli()
        cli_obj._voice_mode = True
        cli_obj._voice_recording = True
        cli_obj._voice_processing = False
        cli_obj.set_voice_record_key_cache("ctrl+space")

        fragments = cli_obj._get_voice_status_fragments(width=120)

        assert fragments == [("class:voice-status-recording", " ● REC  Ctrl+Space to stop ")]

    def test_voice_status_bar_falls_back_to_ctrl_b_without_cache(self):
        cli_obj = _make_cli()
        cli_obj._voice_mode = True
        cli_obj._voice_recording = False
        cli_obj._voice_processing = False
        cli_obj._voice_tts = False
        cli_obj._voice_continuous = False
        # No cache set — mirrors pre-startup state; fall back to
        # documented Ctrl+B default (Copilot round-13 review).

        compact = cli_obj._get_voice_status_fragments(width=50)

        assert compact == [("class:voice-status", " 🎤 Ctrl+B ")]

    def test_voice_status_bar_renders_malformed_config_as_default(self):
        cli_obj = _make_cli()
        cli_obj._voice_mode = True
        cli_obj._voice_recording = False
        cli_obj._voice_processing = False
        cli_obj._voice_tts = False
        cli_obj._voice_continuous = False
        # Non-string / typoed configs fall through the formatter to the
        # documented default so the status bar never advertises an
        # invalid shortcut.
        cli_obj.set_voice_record_key_cache(True)

        compact = cli_obj._get_voice_status_fragments(width=50)

        assert compact == [("class:voice-status", " 🎤 Ctrl+B ")]


class TestCLIUsageReport:
    def test_show_usage_omits_cost_reporting(self, capsys):
        cli_obj = _attach_agent(
            _make_cli(),
            prompt_tokens=10_230,
            completion_tokens=2_220,
            total_tokens=12_450,
            api_calls=7,
            context_tokens=12_450,
            context_length=200_000,
            compressions=1,
        )
        cli_obj.verbose = False

        cli_obj._show_usage()
        output = capsys.readouterr().out

        # Token counts and session metadata still shown.
        assert "Model:" in output
        assert "Input tokens:" in output
        assert "Output tokens:" in output
        assert "Total tokens:" in output
        assert "Session duration:" in output
        assert "Compressions:" in output
        # Cost and cache-hit reporting is removed everywhere.
        assert "Total cost:" not in output
        assert "Cost status:" not in output
        assert "Cost source:" not in output
        assert "Cache read tokens:" not in output
        assert "Cache write tokens:" not in output


class TestStatusBarWidthSource:
    """Ensure status bar fragments don't overflow the terminal width."""

    def _make_wide_cli(self):
        cli_obj = _attach_agent(
            _make_cli(),
            prompt_tokens=100_000,
            completion_tokens=5_000,
            total_tokens=105_000,
            api_calls=20,
            context_tokens=100_000,
            context_length=200_000,
        )
        cli_obj._status_bar_visible = True
        return cli_obj

    def test_fragments_fit_within_announced_width(self):
        """Total fragment text length must not exceed the width used to build them."""
        from unittest.mock import MagicMock, patch
        cli_obj = self._make_wide_cli()

        for width in (40, 52, 76, 80, 120, 200):
            mock_app = MagicMock()
            mock_app.output.get_size.return_value = MagicMock(columns=width)

            with patch("prompt_toolkit.application.get_app", return_value=mock_app):
                frags = cli_obj._get_status_bar_fragments()

            total_text = "".join(text for _, text in frags)
            display_width = cli_obj._status_bar_display_width(total_text)
            assert display_width <= width + 4, (  # +4 for minor padding chars
                f"At width={width}, fragment total {display_width} cells overflows "
                f"({total_text!r})"
            )

    def test_fragments_use_pt_width_over_shutil(self):
        """When prompt_toolkit reports a width, shutil.get_terminal_size must not be used."""
        from unittest.mock import MagicMock, patch
        cli_obj = self._make_wide_cli()

        mock_app = MagicMock()
        mock_app.output.get_size.return_value = MagicMock(columns=120)

        with patch("prompt_toolkit.application.get_app", return_value=mock_app) as mock_get_app, \
             patch("shutil.get_terminal_size") as mock_shutil:
            cli_obj._get_status_bar_fragments()

        mock_shutil.assert_not_called()

    def test_fragments_fall_back_to_shutil_when_no_app(self):
        """Outside a TUI context (no running app), shutil must be used as fallback."""
        from unittest.mock import MagicMock, patch
        cli_obj = self._make_wide_cli()

        with patch("prompt_toolkit.application.get_app", side_effect=Exception("no app")), \
             patch("shutil.get_terminal_size", return_value=MagicMock(columns=100)) as mock_shutil:
            frags = cli_obj._get_status_bar_fragments()

        mock_shutil.assert_called()
        assert len(frags) > 0

    def test_build_status_bar_text_uses_pt_width(self):
        """_build_status_bar_text() must also prefer prompt_toolkit width."""
        from unittest.mock import MagicMock, patch
        cli_obj = self._make_wide_cli()

        mock_app = MagicMock()
        mock_app.output.get_size.return_value = MagicMock(columns=80)

        with patch("prompt_toolkit.application.get_app", return_value=mock_app), \
             patch("shutil.get_terminal_size") as mock_shutil:
            text = cli_obj._build_status_bar_text()  # no explicit width

        mock_shutil.assert_not_called()
        assert isinstance(text, str)
        assert len(text) > 0

    def test_explicit_width_skips_pt_lookup(self):
        """An explicit width= argument must bypass both PT and shutil lookups."""
        from unittest.mock import patch
        cli_obj = self._make_wide_cli()

        with patch("prompt_toolkit.application.get_app") as mock_get_app, \
             patch("shutil.get_terminal_size") as mock_shutil:
            text = cli_obj._build_status_bar_text(width=100)

        mock_get_app.assert_not_called()
        mock_shutil.assert_not_called()
        assert len(text) > 0


class TestIdleSinceLastTurn:
    """Time-since-last-final-agent-response read-out on the status bar."""

    def test_hidden_before_first_turn(self):
        assert HermesCLI._format_idle_since(None, turn_live=False) == ""

    def test_hidden_while_turn_is_live(self):
        assert HermesCLI._format_idle_since(time.time() - 30, turn_live=True) == ""

    def test_shows_compact_idle_time_after_turn(self):
        label = HermesCLI._format_idle_since(time.time() - 42, turn_live=False)
        assert label.startswith("✓ ")
        assert label == "✓ 42s"

    def test_scales_to_minutes(self):
        label = HermesCLI._format_idle_since(time.time() - 3 * 60, turn_live=False)
        assert label == "✓ 3m"

    def test_snapshot_carries_idle_since(self):
        cli_obj = _make_cli()
        cli_obj._last_turn_finished_at = time.time() - 10
        cli_obj._prompt_start_time = None
        cli_obj._prompt_duration = 5.0
        snapshot = cli_obj._get_status_bar_snapshot()
        assert snapshot["idle_since"].startswith("✓ ")

    def test_snapshot_idle_empty_during_live_turn(self):
        cli_obj = _make_cli()
        cli_obj._last_turn_finished_at = time.time() - 10
        cli_obj._prompt_start_time = time.time()
        cli_obj._prompt_duration = 0.0
        snapshot = cli_obj._get_status_bar_snapshot()
        assert snapshot["idle_since"] == ""

    def test_wide_status_bar_text_includes_idle(self):
        cli_obj = _attach_agent(
            _make_cli(),
            prompt_tokens=10_230,
            completion_tokens=2_220,
            total_tokens=12_450,
            api_calls=7,
            context_tokens=12_450,
            context_length=200_000,
        )
        cli_obj._last_turn_finished_at = time.time() - 42
        cli_obj._prompt_start_time = None
        cli_obj._prompt_duration = 7.0
        text = cli_obj._build_status_bar_text(width=160)
        assert "✓ 42s" in text
