#!/usr/bin/env python3
# Miracle Claw (miracle_claw)
# Copyright (c) 2026 Milagro Distribution Corp.
# All rights reserved. See LICENSE file for terms.
"""mlg — Milagro Claw: a talk-to-LLM-and-run-code shell.

Usage:
    mlg                       Start interactive REPL
    mlg "list .txt files"     One-shot mode (single question, run, exit)
    mlg --model NAME          Override model (default: milagro-dev)
    mlg --url URL             Override MAIC endpoint
    mlg --auto                Auto-run read-only (SAFE) commands without asking
    mlg --yes / -y            Auto-run SAFE + NORMAL without asking
                             (DANGEROUS still requires YES-EXEC)
    mlg --no-stream           Disable streaming response
    mlg --no-context          Skip project context injection
    mlg --no-memory           Skip cross-session memory injection
    mlg --help                Show this help

Environment:
    MILAGRO_API_KEY    Bearer token for MAIC gateway (required)
    MIRACLE_CLAW_HOME  Override config dir (default: ~/.miracle_claw)

Architecture:
    mlg (this file) — entry point + REPL loop only.
    ui.py           — ANSI color + banner.
    runtime.py      — language runners (python/bash/perl).
    llm.py          — MAIC gateway client + JSON envelope parsing.
    context.py      — dir snapshot + project context snippets.
    commands.py     — slash command dispatch + LLM turn.
    history.py      — JSONL log read/write + cross-session memory.
    repl.py         — line editor (readline replacement).
    safety.py       — risk classifier for shell commands.
    completion.py   — tab completion.
"""
from __future__ import annotations
import argparse
import atexit
import os
import pathlib
import signal
import struct
import sys
try:
    import fcntl
    import termios
    _HAVE_TIOCGWINSZ = True
except ImportError:
    _HAVE_TIOCGWINSZ = False
try:
    _TTY_FALLBACK_ROWS = int(os.environ.get("LINES", "24"))
    _TTY_FALLBACK_COLS = int(os.environ.get("COLUMNS", "80"))
except ValueError:
    _TTY_FALLBACK_ROWS, _TTY_FALLBACK_COLS = 24, 80

# Allow running `python3 mlg` from anywhere.
HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))

# If a voice_venv sits next to this script, prepend its site-packages to
# sys.path so the lazy `import faster_whisper` inside commands.cmd_voice
# works without re-execing mlg inside the venv. Cheap and reversible.
_VOICE_VENV_SP = HERE / "voice_venv" / "lib" / f"python{sys.version_info.major}.{sys.version_info.minor}" / "site-packages"
if _VOICE_VENV_SP.is_dir() and str(_VOICE_VENV_SP) not in sys.path:
    sys.path.insert(0, str(_VOICE_VENV_SP))

from ui import color, C, draw_footer, footer_row1, footer_row2
from llm import (
    DEFAULT_URL, DEFAULT_MODEL, REQUEST_TIMEOUT,
    load_system_prompt,
)
from commands import handle_turn, handle_repl_line, HELP_TEXT
from context import project_context
from history import recent_messages
from repl import LineEditor, CompletionAdapter, RawMode, CANCEL
import auth  # MAIC user-auth client (Phase 1)





def _term_size() -> tuple[int, int]:
    """Return (rows, cols) of the controlling terminal. Falls back to
    $LINES/$COLUMNS env vars, then 24x80."""
    if _HAVE_TIOCGWINSZ:
        try:
            buf = struct.pack("HHHH", 0, 0, 0, 0)
            buf = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, buf)
            rows, cols, _, _ = struct.unpack("HHHH", buf)
            if rows > 0 and cols > 0:
                return rows, cols
        except (OSError, ValueError):
            pass
    return _TTY_FALLBACK_ROWS, _TTY_FALLBACK_COLS


def _build_arg_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        prog="mlg",
        description="Milagro Claw: talk-to-LLM-and-run-code shell.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=HELP_TEXT,
    )
    p.add_argument("question", nargs="*", help="One-shot question (skips REPL)")
    p.add_argument("--model", default=DEFAULT_MODEL, help=f"Model name (default: {DEFAULT_MODEL})")
    p.add_argument("--url",   default=DEFAULT_URL,   help=f"MAIC endpoint (default: {DEFAULT_URL})")
    p.add_argument("--auto", action="store_true", help="Auto-run read-only commands without asking")
    p.add_argument("--yes", "-y", action="store_true",
                   help="Auto-run SAFE and NORMAL commands without confirming. DANGEROUS still asks.")
    p.add_argument("--no-stream", action="store_true", help="Disable streaming response")
    p.add_argument("--no-color", action="store_true", help="Disable colored output")
    p.add_argument("--no-context", action="store_true", help="Skip project context injection")
    p.add_argument("--no-memory", action="store_true", help="Skip cross-session memory injection")
    p.add_argument("--memory-turns", type=int, default=4,
                   help="Number of recent turns to inject as memory (default: 4)")
    p.add_argument("--timeout", type=int, default=REQUEST_TIMEOUT, help="API timeout (seconds)")
    p.add_argument("--no-session", action="store_true",
                   help="Skip loading/saving the persisted session file")
    p.add_argument("--reset-session", action="store_true",
                   help="Clear the persisted session file before starting")
    p.add_argument("--cwd", default=None,
                   help="Starting directory (default: load from session, else $PWD)")
    p.add_argument("--api-key", default=None,
                   help="Override the MAIC API key (escape hatch — bypasses login)")
    p.add_argument("--setup", action="store_true",
                   help="Run the first-run wizard (create admin + login)")
    p.add_argument("--tier", default=None,
                   help="Override active tier (local) for this run")
    p.add_argument("--login", action="store_true",
                   help="Log in to MAIC (interactive; saves session)")
    p.add_argument("--logout", action="store_true",
                   help="Log out of MAIC and exit")
    p.add_argument("--register", action="store_true",
                   help="Create a new MAIC account (interactive)")
    return p


def _check_api_key(args=None) -> str:
    """Resolve the MAIC API key.

    Order of precedence:
      1. --api-key CLI flag (escape hatch for headless / CI use)
      2. MILAGRO_API_KEY env var (legacy; warns loudly)
      3. Per-user mk_*** from the active tier's cached key
         (created at login time, chmod 600, 30-day rotation)
    """
    from tiers import get_active_tier
    tier = get_active_tier()

    # 1. Explicit --api-key flag (escape hatch).
    if args is not None and getattr(args, "api_key", None):
        return args.api_key

    # 3. Tier-cached per-user key (preferred).
    override = tier.api_key()
    if override:
        return override

    # 2. Legacy env var (with warning).
    api_key = os.environ.get("MILAGRO_API_KEY")
    if api_key:
        print("⚠️  MILAGRO_API_KEY is set in the environment.", file=sys.stderr)
        print("    This is a legacy auth path. Login-based auth is preferred.", file=sys.stderr)
        print("    Run `mlg --setup` to create an admin account.", file=sys.stderr)
        return api_key

    # Nothing — explain the situation.
    print("❌  No MAIC API key available.", file=sys.stderr)
    print("    Run:  mlg --setup       (first-run, creates admin)", file=sys.stderr)
    print("    Or:   mlg --login       (log in with existing admin)", file=sys.stderr)
    print("    Or:   mlg --api-key MK_xxx  (headless escape hatch)", file=sys.stderr)
    sys.exit(2)


def _build_context(args, cwd, history_dir) -> tuple[str, list[dict]]:
    """Build system prompt + project context + memory once at startup."""
    system_prompt = load_system_prompt()

    proj_ctx = ""
    if not args.no_context:
        try:
            proj_ctx = project_context(cwd)
        except Exception as e:
            print(color(f"  ⚠️  could not build project context: {e}", C.YELLOW))

    memory: list[dict] = []
    if not args.no_memory:
        try:
            memory = recent_messages(history_dir, limit=args.memory_turns * 2)
        except Exception as e:
            print(color(f"  ⚠️  could not load memory: {e}", C.YELLOW))

    return system_prompt, proj_ctx, memory


def main() -> None:
    p = _build_arg_parser()
    args = p.parse_args()

    # ─── Tier + auth pre-flight (handle flags before key check) ─────
    if args.tier:
        from tiers import set_active_tier
        try:
            set_active_tier(args.tier)
        except ValueError as e:
            print(f"❌  {e}", file=sys.stderr)
            sys.exit(2)
    if args.logout:
        import auth as _auth
        _auth.logout()
        print("✅  Logged out.")
        sys.exit(0)
    if args.login or args.register:
        import auth as _auth
        from wizard import _login_flow, _register_flow
        try:
            if args.register:
                _register_flow()
            else:
                _login_flow()
        except SystemExit:
            raise
        except Exception as e:
            print(f"❌  {e}", file=sys.stderr)
            sys.exit(1)
        # After login/register: switch to local tier if not already.
        from tiers import get_active_tier, set_active_tier
        if get_active_tier().name != "local":
            set_active_tier("local")
        # No question → exit cleanly. Otherwise fall through.
        if not args.question:
            sys.exit(0)
    if args.setup:
        from wizard import run_setup_wizard
        try:
            run_setup_wizard()
        except SystemExit:
            raise
        except Exception as e:
            print(f"❌  setup wizard failed: {e}", file=sys.stderr)
            sys.exit(1)
        if not args.question:
            sys.exit(0)

    # ─── Tier-aware setup ─────────────────────────────────────────────
    # Resolve the active tier (single tier: "local"). The tier's boot()
    # checks for a valid session and prompts for login if missing.
    from tiers import get_active_tier
    from wizard import needs_setup
    tier = get_active_tier()
    if needs_setup():
        # First run on this machine. Run the setup wizard.
        from wizard import run_setup_wizard
        run_setup_wizard()
    try:
        tier.boot({})
    except SystemExit:
        raise
    except Exception as e:
        print(f"⚠️  tier boot failed: {e}", file=sys.stderr)
        sys.exit(1)

    api_key = _check_api_key(args)
    color_on = not args.no_color and sys.stdout.isatty()

    home = pathlib.Path(os.environ.get("MIRACLE_CLAW_HOME", pathlib.Path.home() / ".miracle_claw"))
    history_dir = home / "history"
    history_dir.mkdir(parents=True, exist_ok=True)

    # ─── Session persistence (cwd, model, url) ─────────────────────────
    from session import load as session_load, save as session_save, clear as session_clear
    saved = {} if args.reset_session else (session_load() if not args.no_session else {})
    if args.reset_session:
        session_clear()

    # cwd: explicit --cwd > saved cwd > current dir
    if args.cwd:
        cwd = pathlib.Path(args.cwd).expanduser().resolve()
    elif "cwd" in saved:
        cwd = pathlib.Path(saved["cwd"]).expanduser().resolve()
    else:
        cwd = pathlib.Path.cwd()

    system_prompt, proj_ctx, memory = _build_context(args, cwd, history_dir)

    # Org CLAW.md (Phase 2): overrides personal CLAW.md if the org has
    # one set. Org admin owns the rules; personal CLAW.md is only used
    # when no org CLAW.md exists.
    import auth as _auth
    try:
        org_claw = _auth.org_get_setting("claw_md")
        if org_claw:
            system_prompt = org_claw
    except Exception:
        pass

    # Mutable state passed to slash commands.
    # Restored fields: model, url (if not overridden on command line).
    # URL default: tier-aware (customer → Milagro cloud).
    tier_url = tier.maic_url()
    state = {
        "model": saved.get("model") or args.model,
        "url":   saved.get("url")   or args.url   or tier_url or "",
        "auto":  args.auto,
        "yes":   args.yes,
        "stream": not args.no_stream,
        "timeout": args.timeout,
        # Tier (for the greeting and /tier command).
        "tier":  tier.name,
        "user":  auth.current_user() if tier.name != "personal" else None,
        # Token usage — accumulated across turns for the footer HUD.
        # Reset to 0 each session (not persisted — start fresh on reload).
        "total_tokens": 0,
        "last_turn_tokens": 0,
        "last_prompt_tokens": 0,
        "last_completion_tokens": 0,
        "total_prompt_tokens": 0,
        "total_completion_tokens": 0,
    }
    # Resolve model: prefer saved resolved name if user didn't override.
    from llm import resolve_model
    if saved.get("model_resolved") and saved.get("model") == state["model"]:
        # The saved model name matches the resolved one in state; trust it.
        state["model_resolved"] = saved["model_resolved"]
        state["model_transport"] = saved.get("model_transport", "maic")
    else:
        _resolved, _transport = resolve_model(state["model"])
        state["model_resolved"] = _resolved
        state["model_transport"] = _transport

    # Persist on normal exit (Ctrl-C / SIGTERM / return). Best-effort.
    def _persist_session() -> None:
        if args.no_session:
            return
        try:
            session_save(state, cwd=str(cwd))
        except Exception as e:
            import sys as _sys
            print(f"⚠️  session save failed: {type(e).__name__}: {e}",
                  file=_sys.stderr)
    atexit.register(_persist_session)

    # Shared context dict for handle_repl_line — anything that's expensive
    # to recompute per-line lives here.
    def _reader(limit: int) -> list[dict]:
        from history import recent_entries
        return recent_entries(history_dir, limit=limit)
    def _clear_chat_region() -> None:
        """Clear the chat scroll region (preserve box + footer).

        In TTY mode: uses DECSTBM scroll region + ED selective erase to
        wipe only the chat area (rows 1..scroll_bottom). The box at the
        bottom and the footer BELOW it are OUTSIDE the scroll region and
        therefore untouched.

        In non-TTY mode (one-shot `--question`): falls back to a full
        screen clear since there's no scroll region set.

        Late-binding lookups for `is_tty` and `layout` are required so
        this function can be registered in ctx before they are defined.
        """
        if not is_tty:  # noqa: F821
            sys.stdout.write("\033[2J\033[H")
            sys.stdout.flush()
            return
        sys.stdout.write("\033[1;1H")     # home (top of scroll region)
        sys.stdout.write("\033[J")        # erase to end of screen (within region)
        sys.stdout.write("\033[1;1H")     # re-home for next prompt
        sys.stdout.flush()

    ctx = {
        "state":         state,
        "system_prompt": system_prompt,
        "api_key":       api_key,
        "color_on":      color_on,
        "cwd":           cwd,
        "history_dir":   history_dir,
        "project_ctx":   proj_ctx,
        "memory":        memory,
        "history_reader": _reader,
        "clear_chat":    _clear_chat_region,
        # Pricing for /cost (USD per 1M tokens). Override with env vars
        # MILAGRO_INPUT_PRICE_PER_M / MILAGRO_OUTPUT_PRICE_PER_M if you're
        # running a different model (e.g. Claude Opus, local Ollama).
        "pricing": {
            "input":  float(os.environ.get("MILAGRO_INPUT_PRICE_PER_M",  "0.50")),
            "output": float(os.environ.get("MILAGRO_OUTPUT_PRICE_PER_M", "1.50")),
        },
    }

    # ─── One-shot mode ──────────────────────────────────────────────
    if args.question:
        question = " ".join(args.question)
        handle_turn(
            question=question,
            system_prompt=system_prompt,
            cwd=cwd,
            model=args.model,
            url=args.url,
            api_key=api_key,
            auto=args.auto,
            yes=args.yes,
            stream=not args.no_stream,
            timeout=args.timeout,
            color_on=color_on,
            history_dir=history_dir,
            project_ctx=proj_ctx,
            memory=memory,
            model_resolved=state.get("model_resolved") or args.model,
            model_transport=state.get("model_transport", "maic"),
        )
        return

    # ChatGPT-style: input box pinned at the BOTTOM, transcript scrolls above.
    # Screen layout (rows numbered top-to-bottom):
    #   row 1..(H-BOX_TOTAL_ROWS):  scroll region (chat) — terminal auto-scrolls
    #                                here when content overflows. The box below
    #                                is OUTSIDE this region so it never gets
    #                                overwritten by auto-scroll.
    #   row H-BOX_TOTAL_ROWS+1:     ╭───╮  top border of input box
    #   row ...                     │⚡ ...│ viewport row 0
    #   row ...                     │     │ viewport row 1
    #   row H:                      ╰───╯  bottom border
    #
    # At the start of each turn we jump to the bottom of the scroll region
    # (row H-BOX_TOTAL_ROWS-FOOTER_ROWS) and print the question + response there.
    # The terminal handles scrolling within the region; the box + footer stay put.
    from multiline_repl import MultiLineEditor, RawMode as ML_RawMode
    # Banner info is rendered into the FOOTER below the box, not above it.
    # That way the model/url/cwd info stays visible no matter how much chat
    # history scrolls off the top.

    try:
        from completion import Completer as _RealCompleter  # noqa: F401
        completer = CompletionAdapter(cwd=cwd)
    except ImportError:
        completer = None

    prompt_text = "⚡ "
    BOX_INNER_ROWS = 2  # compact: just enough for one-line + a bit of overflow
    BOX_TOTAL_ROWS = BOX_INNER_ROWS + 2   # + top + bottom border
    FOOTER_ROWS = 2                        # model/url/cwd line + help hint

    editor = MultiLineEditor(
        prompt=prompt_text,
        completer=completer,
        stream=sys.stdout,
        left_margin=0,
        viewport_rows=BOX_INNER_ROWS,
        viewport_width=80,
        placeholder="Ask Anything",
    )

    # Byte stream for input. We read directly with os.read() so that
    # signals (SIGWINCH, SIGINT) interrupt the read with InterruptedError
    # instead of being silently retried by BufferedReader.
    _stdin_fd = None
    try:
        _stdin_fd = sys.stdin.fileno()
    except (OSError, ValueError):
        pass
    stream = sys.stdin.buffer if hasattr(sys.stdin, "buffer") else sys.stdin

    # TTY/PTY detection (covers mlg-serve case)
    is_tty = False
    try:
        is_tty = (os.isatty(sys.stdin.fileno())
                  or os.isatty(sys.stdout.fileno()))
    except (OSError, ValueError):
        pass

    # Mutable layout state (updated by SIGWINCH handler when terminal is resized)
    layout = {
        "rows": 24,
        "cols": 80,
        "box_top_row": 19,
        "box_inner_first_row": 20,
        "box_bottom_row": 22,
        "footer_row1": 23,
        "footer_row2": 24,
        "scroll_bottom_row": 18,   # last row of the chat scroll region
    }

    def _do_layout(*, clear: bool = True) -> None:
        """Re-query terminal size and redraw the box at the new position.

        Sets a DECSTBM scroll region covering rows 1..(H-BOX_TOTAL_ROWS-FOOTER_ROWS)
        so the chat area auto-scrolls independently of the box + footer.
        Both are OUTSIDE the scroll region and stay pinned at the bottom
        no matter how much chat scrolls.

        `clear=True` wipes the screen first (used on resize when the
        new row count would orphan old content). On initial layout
        (`clear=False`) we leave the chat area empty (no banner).
        """
        rows, cols = _term_size()
        rows = max(rows, BOX_TOTAL_ROWS + FOOTER_ROWS + 5)
        layout["rows"] = rows
        layout["cols"] = cols
        layout["box_top_row"] = rows - BOX_TOTAL_ROWS - FOOTER_ROWS + 1
        layout["box_inner_first_row"] = layout["box_top_row"] + 1
        layout["box_bottom_row"] = layout["box_top_row"] + BOX_TOTAL_ROWS - 1
        layout["footer_row1"] = layout["box_bottom_row"] + 1
        layout["footer_row2"] = layout["footer_row1"] + 1
        layout["scroll_bottom_row"] = layout["box_top_row"] - 1

        if clear:
            # Reset scroll region to full screen, clear, home.
            sys.stdout.write("\033[r\033[2J\033[H")
        else:
            # Just reset scroll region (without clearing) and home.
            sys.stdout.write("\033[r\033[H")
        # Set scroll region: rows 1..scroll_bottom_row (chat area).
        # The box + footer rows are OUTSIDE this region and stay
        # pinned at the bottom even when the chat fills up.
        sys.stdout.write(
            f"\033[1;{layout['scroll_bottom_row']}r"
        )
        # Draw ALL box borders first (top + bottom). Then render the
        # viewport inside the borders. This ordering matters: render_absolute()
        # leaves the cursor at the user's logical typing position INSIDE
        # the viewport, which means drawing the bottom border AFTER it
        # would overwrite viewport content. So we draw the bottom border
        # first, while the cursor is still at row 1 col 1.
        sys.stdout.write("\033[" + str(layout["box_top_row"]) + ";1H")
        sys.stdout.write(color(
            "\u256d" + "\u2500" * 78 + "\u256e",
            C.DIM, enabled=color_on,
        ))
        sys.stdout.write("\033[" + str(layout["box_bottom_row"]) + ";1H")
        sys.stdout.write(color(
            "\u2570" + "\u2500" * 78 + "\u256f",
            C.DIM, enabled=color_on,
        ))
        # Now render the inner viewport. Its cursor will end up inside the
        # box at the typing position.
        editor.render_absolute(layout["box_inner_first_row"])
        # Draw the footer (model/url/cwd + help hint) at the pinned rows
        # BELOW the box. These rows are outside the scroll region so they
        # never get overwritten by chat output.
        _redraw_footer()
        # Move cursor to row 1 (top of scroll region) so the chat area
        # starts fresh.
        sys.stdout.write("\033[1;1H")
        sys.stdout.flush()

    def _redraw_footer() -> None:
        """Redraw the footer (outside the scroll region) with current state.

        Reads `state["total_tokens"]` / `state["last_turn_tokens"]` to
        show a token count when total >= 1000 (silent for short sessions).
        Pulls the latest cwd from `ctx["cwd"]` (set by /cd) so the footer
        reflects post-cd changes without needing a separate callback.
        """
        current_cwd = ctx.get("cwd", cwd)
        # Use the resolved model name when available (set by /model).
        current_model = state.get("model_resolved") or args.model
        row1_text, row1_style = footer_row1(
            current_model, args.url, str(current_cwd),
            tokens_total=state.get("total_tokens", 0),
            tokens_last=state.get("last_turn_tokens", 0),
        )
        draw_footer(
            row1_text=row1_text,
            row1_style=row1_style,
            row2_text=footer_row2(),
            row1=layout["footer_row1"],
            color_on=color_on,
        )

    if is_tty:
        # Save cursor + hide it.
        sys.stdout.write("\033[?25l")
        sys.stdout.flush()

        def _cleanup_terminal() -> None:
            try:
                sys.stdout.write("\033[?25h")  # show cursor
                sys.stdout.write("\033[r")     # full-screen scroll region
                sys.stdout.flush()
            except Exception:
                pass
        atexit.register(_cleanup_terminal)

        # SIGWINCH handler
        _sigwinch_pending = [False]
        def _on_sigwinch(signum, frame):
            _sigwinch_pending[0] = True
        try:
            signal.signal(signal.SIGWINCH, _on_sigwinch)
        except (AttributeError, ValueError):
            pass

        # Initial layout — don't clear the screen (nothing to wipe on startup).
        _do_layout(clear=False)

    else:
        # Non-TTY fallback — just print the prompt, no box.
        sys.stdout.write(editor.prompt)
        sys.stdout.flush()

    def _redraw_box() -> None:
        """Walk cursor to the box's first inner row and re-render it.
        Leaves the cursor at the logical position inside the box (the
        caller's cursor before this call is NOT restored — wrap calls
        in DECSC/DECRC if you need to restore)."""
        if not is_tty:
            return
        editor.render_absolute(layout["box_inner_first_row"])

    # Local reference so SIGWINCH handler can flip it
    with ML_RawMode():
        while True:
            # Check for SIGWINCH (terminal resize) and relayout.
            if _sigwinch_pending[0]:
                _sigwinch_pending[0] = False
                _do_layout()
                continue
            try:
                if _stdin_fd is not None:
                    # Use select() with a short timeout so we periodically
                    # check the SIGWINCH flag even when no data is arriving.
                    import select as _sel
                    rdy, _, _ = _sel.select([_stdin_fd], [], [], 0.1)
                    if not rdy:
                        # No input ready — loop back and recheck the flag.
                        continue
                    b = os.read(_stdin_fd, 1)
                else:
                    b = stream.read(1)
            except InterruptedError:
                sys.stderr.write("[loop] read interrupted by signal\n")
                sys.stderr.flush()
                # Signal (e.g. SIGWINCH) interrupted the read. Loop back
                # so the flag check at the top can handle it.
                continue
            except (EOFError, KeyboardInterrupt):
                sys.stdout.write("\n")
                _redraw_box()
                # Hand the bye message over to the alt screen, then leave it
                print(color("👋 bye", C.DIM, enabled=color_on))
                _cleanup_terminal()
                break
            if not b:
                print(color("👋 bye", C.DIM, enabled=color_on))
                _cleanup_terminal()
                break

            lines = editor.feed(b)
            submitted = None
            cancelled = False
            for line in lines:
                from multiline_repl import CANCEL
                if line is CANCEL:
                    cancelled = True
                    continue
                if line is None:
                    continue
                submitted = line

            if cancelled:
                editor.clear()
                _redraw_box()
                continue

            if submitted is not None:
                # Jump to the bottom of the scroll region (just above the
                # box) and print the question there. The terminal handles
                # scrolling within the region naturally — the box below is
                # OUTSIDE the region and stays pinned at the bottom.
                if is_tty:
                    # Position cursor at the bottom of the scroll region
                    # (row scroll_bottom_row). Newlines here will scroll
                    # within the region without touching the box.
                    sys.stdout.write(
                        "\033[" + str(layout["scroll_bottom_row"]) + ";1H"
                    )
                    if submitted.strip():
                        # User prompt icon — defaults to 👤 (person, per David 2026-06-28).
                        # Override via MILAGRO_USER_ICON env var.
                        uicon = os.environ.get("MILAGRO_USER_ICON", "👤")
                        # Pad so text aligns with LLM-prompt column.
                        if len(uicon) > 1:
                            prompt_prefix = f"  {uicon} "
                        else:
                            prompt_prefix = f"  {uicon} "
                        sys.stdout.write(color(
                            prompt_prefix + submitted.replace("\n", "\n    "),
                            C.BOLD, C.WHITE, enabled=color_on,
                        ) + "\n")
                    action = handle_repl_line(raw=submitted, cwd=cwd,
                                               state=state, ctx=ctx)
                else:
                    sys.stdout.write("\n")
                    if submitted.strip():
                        uicon = os.environ.get("MILAGRO_USER_ICON", "👤")
                        prompt_prefix = f"  {uicon} "
                        sys.stdout.write(prompt_prefix + submitted + "\n")
                    action = handle_repl_line(raw=submitted, cwd=cwd,
                                               state=state, ctx=ctx)
                if action == "exit":
                    _cleanup_terminal()
                    return
                # Clear the box + redraw it for the next question.
                editor.clear()
                if is_tty:
                    _redraw_box()
                    # Redraw footer too — tokens may have changed and the
                    # model/url/cwd line could need refreshing (e.g. after
                    # a /model or /cd command).
                    _redraw_footer()
                else:
                    sys.stdout.write(editor.prompt)
                    sys.stdout.flush()
                continue

            # Plain keystroke — redraw the box (cursor moves to logical pos).
            _redraw_box()


if __name__ == "__main__":
    main()