/**
 * Inline command-suggest dropdown — renders directly below the prompt
 * cursor without taking over the screen.
 *
 * Mental model: Claude Code's `/` autocomplete. The dropdown sits
 * inline beneath the live prompt, showing matching slash commands in
 * two columns (command + one-line description), narrowing as the user
 * types. Unlike picker.ts (which uses the alt-screen buffer for a
 * full-screen list), this widget stays in the normal screen buffer
 * and only paints a few rows of dropdown below the cursor — the
 * surrounding chat output stays visible.
 *
 * Render strategy — RELATIVE cursor positioning only
 * ──────────────────────────────────────────────────
 * The first cut of this module used DECSC/DECRC (`\x1b7`/`\x1b8`) to
 * pin an anchor at the start of the filter, then restored to it on
 * every render. That fails on Windows PowerShell legacy ConHost as
 * soon as the dropdown content scrolls the terminal — the saved
 * position is stored in *visible* coordinates, so after a scroll it
 * lands on the wrong row and renders pile up below the previous one.
 *
 * The fix: track our own row count and use relative cursor moves
 * (`\x1b[<N>A` go up, `\r` col 0, `\x1b[<N>C` advance N cols). Every
 * render writes the FULL prompt line — prompt prefix + filter — so
 * we don't need to "skip over" the prefix to reach the filter cell.
 * The caller hands us the prompt prefix (ANSI-styled string + the
 * visible char count) so we can repaint it each frame.
 *
 * Per-frame sequence:
 *   1. Up `_dropdownRows` lines     → back to the filter row
 *   2. `\r`                          → col 0 of that row
 *   3. `\x1b[J`                      → clear from cursor to end of screen
 *   4. promptPrefix + filter         → repaint the prompt line
 *   5. `\r\n` + each dropdown row    → fill the rows below
 *   6. Up `rowsDrawn` + `\r` + right (promptVisLen + filter.length)
 *                                   → cursor settles at end of filter
 *
 * Steps 1–3 only refer to rows we previously printed (which are still
 * on screen, because they came AFTER the prompt). Even if the screen
 * scrolled between renders, relative up-moves still target our own
 * rows correctly because they're contiguous with the cursor.
 *
 * Key handling
 * ────────────
 * While suggest is active, we detach all non-hotkey `keypress`
 * listeners so readline's line editor stops processing input. A raw
 * `data` listener parses bytes directly:
 *
 *   Ctrl+C / Esc          cancel
 *   Enter (CR or LF)      accept current selection
 *   Up / Down             move selection
 *   Backspace             delete last filter char; dismiss if empty
 *   Tab                   accept without submitting (sentinel:
 *                         trailing space on command)
 *   Printable ASCII       append to filter, reset selection to 0
 */
import { stdin, stdout } from 'node:process';
const ANSI = {
    clearToEnd: '\x1b[J',
    clearLine: '\x1b[K',
    reverse: '\x1b[7m',
    dim: '\x1b[2m',
    reset: '\x1b[0m',
};
/** Cap visible dropdown rows. Trades discoverability for keeping the
 * surrounding chat output visible. Matches Claude Code's behavior. */
const MAX_ROWS = 8;
/** Strip ANSI SGR escape sequences for visible-width math. */
function ansiVisibleLen(s) {
    return s.replace(/\x1b\[[0-9;]*m/g, '').length;
}
/**
 * Show the inline suggest dropdown and resolve when the user picks
 * or cancels.
 *
 * The caller should have cleared rl.line and refreshed the prompt
 * before calling — we repaint the prompt line ourselves but the
 * cursor needs to be on a stable row (not mid-scroll).
 */
export async function inlineSuggest(_rl, items, initialFilter = '/', opts = {}) {
    return new Promise((resolve) => {
        let filter = initialFilter;
        let selected = 0;
        // Rows of dropdown printed on the previous frame (NOT including
        // the filter row). The next frame goes up by this many to land
        // back on the filter row.
        let dropdownRows = 0;
        const promptPrefix = opts.promptPrefix ?? '  ❯ ';
        const promptVisLen = opts.promptVisibleLen ?? ansiVisibleLen(promptPrefix);
        // Defensive: ensure raw mode is on and the stream is flowing.
        const wasRaw = stdin.isRaw;
        try {
            stdin.setRawMode(true);
        }
        catch { /* noop */ }
        stdin.resume();
        // Detach readline's keypress listeners so the line editor doesn't
        // also process input. The hotkey listener (tagged
        // __grawkusHotkey__) stays attached because it has its own
        // bail for `pickerActive`; the others get pulled.
        const allKeypress = stdin.listeners('keypress').slice();
        const togglable = allKeypress.filter((l) => !l.__grawkusHotkey__);
        for (const l of togglable)
            stdin.removeListener('keypress', l);
        function visibleItems() {
            // Strip leading '/' for matching — the filter starts with '/'
            // (since that's the trigger) but the commands also start with
            // '/', so the slash is implicit and we want to match on the
            // letters that come after.
            let f = filter.replace(/^\//, '').toLowerCase();
            // If the filter contains a space, the user is typing ARGS for a
            // command (e.g. "/perm auto"). Match only on the first word so
            // the dropdown still highlights /perm — without this slice,
            // typing space dropped the match count to zero and made the
            // dropdown look broken. Enter then submits the FULL filter with
            // args attached (see the Enter handler below).
            const spaceIdx = f.indexOf(' ');
            const hasArgs = spaceIdx >= 0;
            if (hasArgs)
                f = f.slice(0, spaceIdx);
            if (!f)
                return items.slice();
            return items.filter((it) => {
                const cmdMatch = it.command.toLowerCase().includes(f);
                // Description matching is only useful when the user is still
                // searching — once they've typed past the command name into
                // args, description matches would surface irrelevant items.
                const descMatch = !hasArgs && it.description.toLowerCase().includes(f);
                return cmdMatch || descMatch;
            });
        }
        function render() {
            const visible = visibleItems();
            if (selected >= visible.length)
                selected = Math.max(0, visible.length - 1);
            if (selected < 0)
                selected = 0;
            const shown = visible.slice(0, MAX_ROWS);
            // Column widths — command col sized to longest visible command,
            // clamped to a reasonable range so the description col gets
            // enough space on narrow terminals.
            const cmdCol = Math.min(20, Math.max(10, shown.reduce((m, it) => Math.max(m, it.command.length), 0)));
            const termCols = stdout.columns || 80;
            const descMax = Math.max(20, termCols - cmdCol - 6);
            // ── Reposition to the filter row ──
            // Up by however many dropdown rows we left on screen last
            // frame. Then \r to col 0. Then clear from cursor to end of
            // screen — that erases the rest of the filter row AND every
            // dropdown row below.
            if (dropdownRows > 0) {
                stdout.write(`\x1b[${dropdownRows}A`);
            }
            stdout.write('\r');
            stdout.write(ANSI.clearToEnd);
            // Repaint the prompt line: styled prefix + filter chars. We
            // own this line now (the clear above wiped whatever was here).
            stdout.write(promptPrefix + filter);
            // Draw dropdown rows beneath. Each row gets a "\r\n" prefix
            // so it lands on a fresh line at col 0 regardless of where the
            // previous write left the cursor. (Bare "\n" in raw mode only
            // moves down, not back to col 0.)
            let rowsDrawn = 0;
            if (shown.length === 0) {
                // Hint the args path: if the filter starts with `/` and has
                // letters after it, Enter still submits as-is so the user
                // can type `/customcmd args` even if customcmd isn't in our
                // local catalog.
                const hint = filter.length > 1 && filter.startsWith('/')
                    ? '(no match — Enter submits as-is, Backspace narrows, Esc cancels)'
                    : '(no matches — Backspace to clear, Esc to dismiss)';
                stdout.write(`\r\n  ${ANSI.dim}${hint}${ANSI.reset}`);
                rowsDrawn = 1;
            }
            else {
                for (let i = 0; i < shown.length; i++) {
                    const it = shown[i];
                    const isSel = i === selected;
                    let cmd = it.command;
                    if (cmd.length > cmdCol)
                        cmd = cmd.slice(0, cmdCol - 1) + '…';
                    else
                        cmd = cmd.padEnd(cmdCol, ' ');
                    let desc = it.description;
                    if (desc.length > descMax)
                        desc = desc.slice(0, descMax - 1) + '…';
                    const line = isSel
                        ? `${ANSI.reverse}  ${cmd}  ${desc}${ANSI.reset}`
                        : `  ${cmd}  ${ANSI.dim}${desc}${ANSI.reset}`;
                    stdout.write(`\r\n${line}`);
                    rowsDrawn++;
                }
                if (visible.length > shown.length) {
                    stdout.write(`\r\n  ${ANSI.dim}… ${visible.length - shown.length} more · type to narrow${ANSI.reset}`);
                    rowsDrawn++;
                }
            }
            // Cursor back to the end of the filter on the prompt row.
            // After the last write we're at end-of-last-dropdown-row;
            // go up `rowsDrawn` lines, \r to col 0, then advance to the
            // visible column of end-of-filter.
            stdout.write(`\x1b[${rowsDrawn}A\r`);
            const endCol = promptVisLen + filter.length;
            if (endCol > 0) {
                stdout.write(`\x1b[${endCol}C`);
            }
            dropdownRows = rowsDrawn;
        }
        function teardown() {
            stdin.removeListener('data', onData);
            for (const l of togglable)
                stdin.on('keypress', l);
            try {
                stdin.setRawMode(wasRaw);
            }
            catch { /* noop */ }
            // Wipe the dropdown area. We're at end-of-filter; go down +
            // \r + clear-to-end clears everything we drew below the
            // prompt row. Leave the filter on screen — the caller decides
            // whether to overwrite (on accept) or restore rl.line + redraw
            // (on cancel).
            if (dropdownRows > 0) {
                stdout.write('\r\n');
                stdout.write(ANSI.clearToEnd);
                // Cursor is now at col 0 of the row after the filter.
                // Move back up + to end of filter so caller sees a clean
                // single-row state.
                stdout.write(`\x1b[1A\r`);
                const endCol = promptVisLen + filter.length;
                if (endCol > 0) {
                    stdout.write(`\x1b[${endCol}C`);
                }
            }
            dropdownRows = 0;
        }
        function moveSelection(delta) {
            const visible = visibleItems();
            if (visible.length === 0)
                return;
            selected = (selected + delta + visible.length) % visible.length;
            render();
        }
        function onData(buf) {
            // Defensive wrapper — if any branch throws we don't want the
            // dropdown to silently freeze. Restore listeners so the user
            // can keep typing.
            try {
                // Ctrl+C — cancel.
                if (buf.length === 1 && buf[0] === 0x03) {
                    teardown();
                    resolve({ accepted: false, filter });
                    return;
                }
                // Esc (bare) — cancel. Multi-byte chunks starting with 0x1B
                // are arrow keys / function keys (handled below).
                if (buf.length === 1 && buf[0] === 0x1B) {
                    teardown();
                    resolve({ accepted: false, filter });
                    return;
                }
                // Enter — accept and submit.
                //
                // Three submission paths:
                //   1. Filter contains a space → user typed "/cmd args".
                //      Submit the FULL filter so handleSlashCommand sees the
                //      command AND its arguments (e.g. "/perm auto" sets the
                //      perm mode, "/think on" toggles). This is the path that
                //      was broken before — typing a space yielded zero
                //      matches and Enter did nothing, so users got stuck.
                //   2. No space, dropdown has a match → submit the selected
                //      item's command verbatim.
                //   3. No space, dropdown has no match, but the user typed
                //      something past "/" → submit the raw filter and let
                //      handleSlashCommand say "unknown command". Better than
                //      silently swallowing the Enter.
                if (buf.length === 1 && (buf[0] === 0x0D || buf[0] === 0x0A)) {
                    let toSubmit;
                    if (filter.includes(' ')) {
                        toSubmit = filter;
                    }
                    else {
                        const visible = visibleItems();
                        if (visible.length > 0) {
                            toSubmit = visible[selected].command;
                        }
                        else if (filter.length > 1) {
                            toSubmit = filter;
                        }
                        else {
                            // Filter is "/" alone or empty — nothing to submit. Stay
                            // open so the user can keep typing.
                            return;
                        }
                    }
                    teardown();
                    resolve({ accepted: true, command: toSubmit, filter });
                    return;
                }
                // Backspace (DEL 0x7F on POSIX, BS 0x08 on Windows).
                //
                // Behavior: shrink the filter by one char. If the filter is
                // already empty we dismiss; otherwise we KEEP THE DROPDOWN
                // OPEN even when the filter shrinks to "" (so the user can
                // see the full list when they backspace past the trigger '/'
                // — previous behavior dismissed on filter='/' which felt
                // jumpy when the user just wanted to clear their typing).
                if (buf.length === 1 && (buf[0] === 0x7F || buf[0] === 0x08)) {
                    if (filter.length === 0) {
                        teardown();
                        resolve({ accepted: false, filter });
                        return;
                    }
                    filter = filter.slice(0, -1);
                    selected = 0;
                    render();
                    return;
                }
                // Tab — navigate to the next item (alias for Down arrow).
                // The previous "Tab = fill but don't submit" sentinel was
                // confusing and never matched user muscle memory; treating
                // Tab as navigation matches what most CLI completers do and
                // pairs naturally with Shift+Tab below.
                if (buf.length === 1 && buf[0] === 0x09) {
                    moveSelection(1);
                    return;
                }
                // Shift+Tab — navigate to the previous item. Delivered as
                // the CSI back-tab sequence `\x1b[Z` (3 bytes) on most
                // modern terminals including Windows Terminal + ConHost
                // with VT100 enabled.
                if (buf.length === 3 && buf[0] === 0x1B && buf[1] === 0x5B && buf[2] === 0x5A) {
                    moveSelection(-1);
                    return;
                }
                // Arrow keys: `Esc [ <code>` (3 bytes). Up/Down navigate;
                // Page Up / Page Down skip by 5 (also reachable via the 4-
                // byte forms `\x1b[5~` and `\x1b[6~`).
                if (buf.length >= 3 && buf[0] === 0x1B && buf[1] === 0x5B) {
                    const code = buf[2];
                    if (code === 0x41) {
                        moveSelection(-1);
                        return;
                    } // Up
                    if (code === 0x42) {
                        moveSelection(1);
                        return;
                    } // Down
                    if (buf.length >= 4 && (code === 0x35 || code === 0x36) && buf[3] === 0x7E) {
                        moveSelection(code === 0x35 ? -5 : 5); // Page Up/Down
                        return;
                    }
                    // Left/Right/Home/End and other unhandled CSI escapes:
                    // swallow them silently rather than letting their bytes
                    // fall through to the printable-char branch (which would
                    // see e.g. "[D" and start appending '[' + 'D' to the
                    // filter). Anything starting with ESC [ is some terminal
                    // sequence — definitely not user-intended filter text.
                    return;
                }
                // Any other bare control byte (Ctrl+A..Ctrl+Z minus the ones
                // we explicitly handled above): silently ignore. The user
                // expects Ctrl+letter to do SOMETHING, but we don't have a
                // mapping for most of them in this context — better to do
                // nothing than to dump a 0x01 into the filter.
                if (buf.length === 1 && buf[0] < 0x20)
                    return;
                // Printable input — extend filter and re-render. Robust
                // against mixed chunks: instead of all-or-nothing, strip
                // ANY non-printable bytes from the chunk and append only
                // the printable subset. This means a chunk like `[h, 0x01]`
                // (an 'h' followed by a stray Ctrl+A) still appends the 'h'
                // instead of dropping the whole chunk.
                //
                // This is the per-char update path: every printable byte
                // lands here and triggers render() with the new filter.
                const s = buf.toString('utf-8');
                const printable = s.replace(/[\x00-\x1F\x7F]/g, '');
                if (printable.length > 0) {
                    filter += printable;
                    selected = 0;
                    render();
                }
            }
            catch {
                // Don't let a render error wedge the user in the dropdown
                // with a dead handler. Tear down, resolve as cancelled with
                // the current filter, and let the parent restore the prompt.
                try {
                    teardown();
                }
                catch { /* noop */ }
                resolve({ accepted: false, filter });
            }
        }
        stdin.on('data', onData);
        render();
    });
}
//# sourceMappingURL=inline-suggest.js.map