Ctrl K
ComunidadGuías…

src/interactive.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// SPDX-License-Identifier: GPL-3.0-or-later
/**
 * Interactive mode (`--interactive`): a zero-dependency terminal UI over the
 * finished report. Arrow keys pick a finding, Enter opens the details, c
 * copies the suggested fix to the clipboard, f shows the safe-fix commands
 * (from src/fix.js) when the finding has any, q quits. Nothing is ever
 * executed from here — "apply" still means copying a command or re-running
 * with --fix --yes.
 *
 * Requires a TTY on stdin; callers fall back to the printed report otherwise.
 */
import readline from "node:readline";
import { exec } from "node:child_process";
import { shq } from "./utils.js";

const A = {
  bold: "\x1b[1m", dim: "\x1b[2m", reset: "\x1b[0m",
  red: "\x1b[31m", yellow: "\x1b[33m", blue: "\x1b[34m", cyan: "\x1b[36m", green: "\x1b[32m",
};
const SEV_COLOR = { high: A.red, medium: A.yellow, info: A.blue };
const SEV_ICON = { high: "🔴", medium: "🟡", info: "🔵" };

const CLEAR = "\x1b[2J\x1b[H";
const SHOW_CURSOR = "\x1b[?25h";

/** Order findings the way the report does: high → medium → info. */
const SEV_RANK = { high: 0, medium: 1, info: 2 };
function sorted(findings) {
  return [...findings].sort((a, b) => (SEV_RANK[a.severity] ?? 9) - (SEV_RANK[b.severity] ?? 9));
}

/** First command of the safe-fix plan for this finding's code, if any. */
export function fixCommandFor(plan, code) {
  const entry = (plan || []).find((p) => p.code === code && p.commands.some((c) => c.tier === "apply"));
  return entry ? entry.commands.find((c) => c.tier === "apply").cmd : null;
}

/**
 * Clipboard without dependencies: wl-copy (Wayland), xclip (X11), pbcopy
 * (macOS). When none exists, emit an OSC 52 escape — many terminals
 * (kitty, alacritty, foot, tmux) handle it natively.
 */
export function copyToClipboard(text, cb = () => {}) {
  const payload = shq(text);
  const tried = [];
  const attempt = (i) => {
    const candidates = [
      ["wl-copy", `printf %s ${payload} | wl-copy`],
      ["xclip", `printf %s ${payload} | xclip -selection clipboard`],
      ["pbcopy", `printf %s ${payload} | pbcopy`],
    ];
    if (i >= candidates.length) {
      try {
        const b64 = Buffer.from(text, "utf8").toString("base64");
        process.stdout.write(`\x1b]52;c;${b64}\x07`);
        cb("copied (OSC52)");
      } catch {
        cb("failed");
      }
      return;
    }
    const [name, cmd] = candidates[i];
    exec(`command -v ${name} >/dev/null 2>&1 && { ${cmd}; }`, (err) => {
      if (!err) cb(`copied (${name})`);
      else attempt(i + 1);
    });
  };
  attempt(0);
}

/** Render one list frame. */
function renderList(items, sel, subtitle = "") {
  const out = [CLEAR, `${A.bold}🩺 Linux Doctor${A.reset} — ${items.length} finding(s). ${A.dim}${subtitle}${A.reset}`, ""];
  items.forEach((f, i) => {
    const cur = i === sel;
    const color = SEV_COLOR[f.severity] || "";
    const marker = cur ? `${A.cyan}❯${A.reset} ` : "  ";
    const badge = f.isNew ? `${A.cyan}${A.bold} NEW${A.reset}` : "";
    out.push(`${marker}${color}${String(i + 1).padStart(2)}. ${SEV_ICON[f.severity] || "·"} ${f.title}${A.reset}${badge}`);
  });
  out.push("", `${A.dim}↑/↓ or j/k select · Enter details · q quit${A.reset}`);
  return out.join("\n");
}

/** Render one detail frame. */
function renderDetail(f, fixCmd) {
  const color = SEV_COLOR[f.severity] || "";
  const out = [CLEAR, `${color}${A.bold}${f.title}${A.reset}`];
  out.push(`${A.dim}${f.severity}${f.code ? ` · ${f.code}` : ""}${A.reset}`, "");
  if (f.detail) out.push(f.detail, "");
  if (f.evidence) {
    out.push(`${A.dim}Evidence:${A.reset}`);
    for (const line of String(f.evidence).split("\n").slice(0, 10)) out.push(`  ${line}`);
    out.push("");
  }
  if (f.fix) out.push(`${A.dim}How to fix:${A.reset}`, `  ${f.fix}`, "");
  if (fixCmd) out.push(`${A.green}Safe fix available:${A.reset} ${A.bold}${fixCmd}${A.reset}`, "");
  out.push(`${A.dim}[c] copy fix text${fixCmd ? " · [f] copy safe-fix command" : ""} · Esc/b back · q quit${A.reset}`);
  return out.join("\n");
}

/**
 * Run the picker over `findings`. Resolves with "quit" when the user leaves
 * (or Ctrl+C) — the caller decides the exit code afterwards. Rejects only if
 * raw mode is unavailable (no TTY).
 */
export function runInteractive(findings, { plan = [] } = {}) {
  return new Promise((resolve, reject) => {
    if (!process.stdin.isTTY || !process.stdin.setRawMode) {
      reject(new Error("interactive mode needs a terminal (TTY) on stdin"));
      return;
    }
    const items = sorted(findings);
    if (items.length === 0) {
      console.log(CLEAR + "✅ Nothing to review — no findings.");
      resolve("done");
      return;
    }
    let sel = 0;
    let view = "list"; // "list" | "detail"
    let dirty = true;

    readline.emitKeypressEvents(process.stdin);
    const wasRaw = process.stdin.isRaw;
    process.stdin.setRawMode(true);
    process.stdin.resume();
    process.stdout.write("\x1b[?25l"); // hide cursor while drawing

    const cleanup = (result) => {
      process.stdin.removeListener("keypress", onKey);
      process.stdin.setRawMode(false);
      process.stdin.pause();
      process.stdout.write(SHOW_CURSOR);
      resolve(result);
    };
    const onKey = (ch, key) => {
      const name = key?.name ?? String(ch ?? "");
      if (key?.ctrl && name === "c") {
        cleanup("quit");
        return;
      }
      if (view === "list") {
        if (name === "down" || ch === "j") sel = Math.min(items.length - 1, sel + 1);
        else if (name === "up" || ch === "k") sel = Math.max(0, sel - 1);
        else if (name === "return" || ch === "o" || name === "space") view = "detail";
        else if (ch === "q") {
          cleanup("quit");
          return;
        } else dirty = false;
      } else {
        if (name === "escape" || ch === "b" || name === "left") view = "list";
        else if (ch === "c") {
          const f = items[sel];
          copyToClipboard(f.fix || f.detail || f.title, (how) => {
            process.stdout.write(`\r\x1b[K${A.dim}clipboard: ${how}${A.reset}`);
            dirty = true; // redraw over the status line next keypress
          });
          dirty = false;
        } else if (ch === "f") {
          const cmd = fixCommandFor(plan, items[sel].code);
          if (cmd) {
            copyToClipboard(cmd, (how) => {
              process.stdout.write(`\r\x1b[K${A.dim}safe-fix copied (${how}) — run it yourself, or --fix --yes${A.reset}`);
              dirty = true;
            });
          }
          dirty = false;
        } else if (ch === "q") {
          cleanup("quit");
          return;
        } else dirty = false;
      }
      if (dirty) {
        dirty = false;
        process.stdout.write(view === "list" ? renderList(items, sel) : renderDetail(items[sel], fixCommandFor(plan, items[sel].code)));
      }
    };
    process.stdin.on("keypress", onKey);
    process.stdout.write(renderList(items, sel));
  });
}