zz_

app/tui/AssistApp.tsx

zzmq.dev@gmail.com/zz / app / tui / AssistApp.tsx @ 5e5720fb Download
Ladder Summary
  • file The file defines a full‑screen Ink/TUI component that drives the **`zz work --assist`** workflow: it lets the user browse claimable work or review items, chat with a language‑model about the draft/recommendation, and then run the scripted actions (propose, accept, revise, commit, deny, etc.) that create or update a changeset, while handling scrolling, mouse input, terminal sizing and clipboard integration. **Exports** - `AssistAppProps` – the prop interface for the component. - **default export** `AssistApp` (a React functional component) – the interactive assistant UI. (No other functions or classes are exported.)
// `zz work --assist`'s interface: pick a claimable work/changeset_review
// ask out of the repos you're watching, then chat with the model about
// its draft (or, for a review, its recommendation) before explicitly
// submitting it back into the protocol as a real ANSWER -- a
// human-in-the-loop counterpart to `zz work`'s own fully autonomous
// claim-draft-verify-submit loop, not a replacement for it.
//
// A real full-screen frame (cli/commands/workAssist.ts calls
// enterFullscreen, same as `zz top`/`zz work`'s own TUIs), not a plain
// scrolling CLI session -- but the transcript still needs to scroll
// back through earlier turns, which an alt-screen app can't get from
// the terminal's own scrollback (there isn't one in the alternate
// buffer). So this owns its own scroll offset exactly the way
// TabView.tsx's content panes do: flatten the transcript into lines,
// window it by `scroll`, and let arrows/Page Up/Down/Home/End/mouse
// wheel move that offset (see useMouse.ts) -- deliberately never j/k,
// unlike TabView's own read-only panes, since those are free characters
// in a chat message here.
//
// Nothing is cloned until an ask is actually picked
// (proposeContributorWork's own shard-clone step handles that, same as
// the autonomous path); the picker itself only ever reads lane_list
// across the watched repos.
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, useInput } from "ink";
import { spawnSync } from "node:child_process";
import { claimableFromSnapshots, type WorkScope, type ClaimableEntry } from "../engine/workqueue.js";
import type { ChatBackend } from "../agent/chatBackend.js";
import {
  proposeContributorWork,
  acceptContributorWork,
  reviseContributorWork,
  commitContributorWork,
  denyContributorWork,
  releaseAsk,
  prepareReviewWorkspace,
  proposeReview,
  acceptReview,
  discussReview,
  type ContributorProposal,
  type ReviewProposal,
  type ScriptedFlowOptions,
} from "../agent/scriptedWork.js";
import { remoteCallTool, callRemoteTool } from "../cli/lib/remoteTools.js";
import { diffFileSets } from "../cli/lib/fileDiff.js";
import type { ResolvedRepo } from "../cli/lib/repo.js";
import type { RepoWatchStore } from "../cli/lib/watchedRepos.js";
import { ask } from "../engine/lane.js";
import { useTerminalSize } from "./useTerminalSize.js";
import { useMouse } from "./useMouse.js";
import { copyToClipboard } from "./clipboard.js";
import { useLaneSnapshots } from "./useLaneSnapshots.js";
import { LaneList } from "./LaneList.js";
import { LaneDetail } from "./LaneDetail.js";
import { enterFullscreen, exitFullscreen } from "./fullscreen.js";
import { BACKDROP, PANEL, BORDER, MUTED, CYAN, YELLOW, GREEN, RED } from "./theme.js";

export interface AssistAppProps {
  watch: RepoWatchStore;
  agent: string;
  scope: WorkScope;
  useRemote: boolean;
  backend: ChatBackend;
  modeRun: <T>(fn: () => Promise<T>) => Promise<T>;
  authedCloneUrlFor?: (slug: string) => string;
  onExit: () => Promise<void>;
}

interface TranscriptEntry {
  role: "assistant" | "user" | "system";
  content: string;
}

function describeAsk(ask: ClaimableEntry["ask"]): string {
  const payload = ask.payload as { description?: string; taskDescription?: string } | undefined;
  return payload?.description ?? payload?.taskDescription ?? JSON.stringify(ask.payload);
}

// repoDir note: the draft is already written straight into the shard's
// working tree on disk the moment it verifies (scriptedWork.ts's own
// writeFilesToWorkingTree) -- a plain file write, not a git commit. You
// can `cd` there right now in another terminal and look at/run it;
// /commit is what turns it into a real changeset (a git commit,
// server-side, plus the matching Ladder rungs), not what first puts it
// on disk.
function formatFreshProposal(p: ContributorProposal["proposal"], repoDir: string): string {
  const filesBlock = p.files.map((f) => `--- ${f.path} ---\n${f.content}`).join("\n\n");
  return `${p.summary}\n(commit message: "${p.message}")\n\nWritten to ${repoDir} -- cd there to inspect or test it. /commit turns this into a real changeset.\n\n${filesBlock}`;
}

function formatRevisedProposal(previous: ContributorProposal["proposal"], p: ContributorProposal["proposal"], repoDir: string): string {
  const diff = diffFileSets(previous.files, p.files);
  return `${p.summary}\n(commit message: "${p.message}")\n\nRewritten to ${repoDir}.\n\n${diff || "(no file changes)"}`;
}

function formatDecision(d: ReviewProposal): string {
  return `Decision: ${d.decision}\nReason: ${d.reason}`;
}

const roleLabel: Record<TranscriptEntry["role"], string> = { assistant: "model", user: "you", system: "system" };
const roleColor: Record<TranscriptEntry["role"], string> = { assistant: GREEN, user: CYAN, system: RED };

interface Line {
  text: string;
  color?: string;
  bold?: boolean;
}

/** Word-wraps one logical line to fit `maxWidth` columns -- chat content (the model's output, a diff, anything printed in the scrollable log) wraps rather than truncating, unlike the single-line chrome elsewhere (headers, the picker list, status/footer text), which truncates with an ellipsis instead. Wrapping has to happen here, in JS, rather than leaning on ink's own wrap="wrap" on the Text: the scroll windowing below counts logical lines to decide how many fit on screen, and that count is only accurate if one Line here always renders as exactly one row -- letting ink wrap a too-long string at render time would silently make the window show more or less than what's actually visible. */
function wrapText(text: string, maxWidth: number): string[] {
  if (maxWidth <= 0 || text.length <= maxWidth) return [text];
  const out: string[] = [];
  let remaining = text;
  while (remaining.length > maxWidth) {
    let breakAt = remaining.lastIndexOf(" ", maxWidth);
    if (breakAt <= 0) breakAt = maxWidth; // no good word-break -- hard break (e.g. a long code line)
    out.push(remaining.slice(0, breakAt));
    remaining = remaining.slice(breakAt).replace(/^ /, "");
  }
  out.push(remaining);
  return out;
}

/** Flattens the transcript (plus the in-flight streaming turn, if any) into individual, pre-wrapped lines for scroll windowing -- a single entry (a whole file diff, several files) can be many lines, and TabView's own convention (see its header comment) scrolls by line, not by entry. */
function transcriptLines(entries: TranscriptEntry[], live: { label: string; text: string } | null, maxWidth: number): Line[] {
  const lines: Line[] = [];
  for (const e of entries) {
    lines.push({ text: `${roleLabel[e.role]}:`, color: roleColor[e.role], bold: true });
    for (const l of e.content.split("\n")) for (const w of wrapText(l, maxWidth)) lines.push({ text: w });
    lines.push({ text: "" });
  }
  if (live) {
    lines.push({ text: `${live.label}:`, color: YELLOW, bold: true });
    for (const l of live.text.split("\n")) for (const w of wrapText(l, maxWidth)) lines.push({ text: w });
  }
  return lines;
}

/** The full transcript as plain text, for ctrl+y's copy-to-clipboard -- there's no mouse-drag text selection available while the alt-screen mouse-reporting TUI owns the mouse (Option/Shift-drag still works, a standard terminal convention, but isn't discoverable), so this is the one-keystroke way to get a path, a diff, or anything else the model printed onto the real system clipboard. */
function transcriptPlainText(entries: TranscriptEntry[]): string {
  return entries.map((e) => `${roleLabel[e.role]}: ${e.content}`).join("\n\n");
}

export function AssistApp(props: AssistAppProps): React.ReactElement {
  const { watch, agent, scope, useRemote, backend, modeRun, authedCloneUrlFor, onExit } = props;
  // Default screen is the lane picker, not a flat cross-repo ask list --
  // pick a lane, see all its asks, then enter/c from there. The old
  // flat "every claimable ask across every watched repo" screen is its
  // own tab here ("unclaimed"), left/right arrow to switch, rather than
  // a row mixed into the lane list itself.
  const [phase, setPhase] = useState<"lanePicker" | "laneAsks" | "detail" | "session">("lanePicker");
  const [lanePickerTab, setLanePickerTab] = useState<"lanes" | "unclaimed">("lanes");
  const [laneEditing, setLaneEditing] = useState(false);
  const [laneDetailTarget, setLaneDetailTarget] = useState<{ repo: string; laneId: string } | null>(null);
  // Where "back" (detail's esc, session's cancel/pause/done) actually
  // returns to -- set whenever an ask is viewed/claimed, since that can
  // now happen from either a real lane's own ask list (laneAsks) or the
  // lane picker's own "unclaimed" tab, and those need different return
  // screens (laneAsks itself, vs. lanePicker with the unclaimed tab
  // left exactly as it was).
  const [returnPhase, setReturnPhase] = useState<"lanePicker" | "laneAsks">("lanePicker");
  const [previewing, setPreviewing] = useState<ClaimableEntry | null>(null);
  const [selected, setSelected] = useState<ClaimableEntry | null>(null);
  const [entries, setEntries] = useState<TranscriptEntry[]>([]);
  const [liveLabel, setLiveLabel] = useState("");
  const [liveText, setLiveText] = useState("");
  const [status, setStatus] = useState("");
  const [busy, setBusy] = useState(false);
  const [done, setDone] = useState(false);
  const [inputValue, setInputValue] = useState("");
  const [scroll, setScroll] = useState(0);
  const [flash, setFlash] = useState<string | null>(null);
  const { rows, columns } = useTerminalSize();
  // Fixed rows outside the scrollable content box: header (1 -- always
  // exactly one line, wrap="truncate-end" below guarantees it, which
  // is the whole point of keeping the ask's own description OUT of
  // this header and in the scrollable log instead, see pushEntry below).
  // The content box itself is sized by Yoga's own flexGrow (see its
  // `flexGrow={1}` below), not a hand-computed height -- that's what
  // makes it correctly fill whatever's actually left after the other
  // (fixed, one-line-each) rows, on any terminal size, including one
  // too small to fit a hardcoded guess. This row count is only ever
  // used for JS-side pagination math (how many lines a Page Up jumps,
  // how big the ctrl+y-free scroll window is) -- getting it slightly
  // wrong just changes how far one page-scroll moves, not whether
  // anything fits on screen.
  const CHROME_ROWS = 8; // header(1) + border(2) + status(1) + input(1) + footer(1) + a little slack for Yoga's own rounding
  const innerRows = Math.max(1, rows - CHROME_ROWS);
  const pageSize = Math.max(1, innerRows - 1);

  const flowOptsRef = useRef<ScriptedFlowOptions | null>(null);
  const workResultRef = useRef<ContributorProposal | null>(null);
  const reviewResultRef = useRef<ReviewProposal | null>(null);
  const entriesRef = useRef<TranscriptEntry[]>([]);
  entriesRef.current = entries;
  // Whichever real directory on disk the current session is actually
  // working in -- the parent repo until/unless a shard gets created
  // (onShardReady, below) or a review workspace gets prepared, which
  // both update this to the real shard clone. ctrl+/ (or typing "!")
  // opens a shell
  // there directly, so you can poke at the exact files being discussed
  // without hunting for the path yourself.
  const shardRepoDirRef = useRef<string | null>(null);

  // "n" from the picker -- browse lanes across the same watched repos
  // and post a plain "work" ask into whichever one you pick, same
  // payload shape `zz repo ask`/zz top's own lane tab post, reusing
  // LaneList's own interactive mode directly rather than building a
  // second picker.
  const laneSnapshots = useLaneSnapshots(
    watch.get().map((r) => ({ repo: r.slug, stateDir: r.stateDir })),
    2000,
    useRemote,
  );
  const onPostAsk = async (repoSlug: string, laneId: string, description: string): Promise<void> => {
    if (useRemote) {
      await callRemoteTool(repoSlug, "ask_post", { lane_id: laneId, kind: "work", from: agent, payload: { description }, depends_on: [] });
    } else {
      await ask(repoSlug, laneId, { kind: "work", from: agent, payload: { description } });
    }
  };

  function pushEntry(role: TranscriptEntry["role"], content: string): void {
    setEntries((e) => [...e, { role, content }]);
  }

  // Snap back to the live edge (scroll=0) whenever a new turn actually
  // lands -- not on every streaming token (that would make scrolling up
  // mid-stream pointless, since the view would yank back down on the
  // very next token), just when pushEntry's array actually grows.
  useEffect(() => {
    setScroll(0);
  }, [entries.length]);

  // Also reset on every phase change -- the detail view and the
  // session view each start their own fresh scroll position, not
  // whatever was left over from whichever view was open before.
  useEffect(() => {
    setScroll(0);
  }, [phase]);

  const scrollUp = () => setScroll((s) => s + 1);
  const scrollDown = () => setScroll((s) => Math.max(0, s - 1));
  useMouse({ onWheelUp: scrollUp, onWheelDown: scrollDown });

  useEffect(() => {
    if (!flash) return;
    const id = setTimeout(() => setFlash(null), 1500);
    return () => clearTimeout(id);
  }, [flash]);

  // Backs both the lane picker's own "unclaimed (N)" row count and the
  // unclaimed pseudo-lane's own ask list. Derived straight from
  // laneSnapshots (useLaneSnapshots, below -- already polling the same
  // lane_list data for the lane picker's own display) via
  // claimableFromSnapshots -- the pure, synchronous half of
  // claimableAcrossRepos (engine/workqueue.ts) -- rather than a second,
  // separate poll that would fetch the exact same data over the
  // network twice. This is also why this no longer needs its own
  // useEffect/interval at all: it's just a plain computed value.
  const claimableList = useMemo(
    () => claimableFromSnapshots(laneSnapshots, agent, scope).filter((c) => c.ask.kind === "work" || c.ask.kind === "changeset_review"),
    [laneSnapshots, agent, scope],
  );

  // Session driver -- runs once when an ask is picked: builds this
  // ask's ScriptedFlowOptions, then drafts the first proposal/decision.
  // Never clones anything itself -- proposeContributorWork's own
  // shard-clone step (remote mode) handles that exactly when needed;
  // local mode's repos are already on disk by the time they're
  // watchable at all. ensureShard (worker/src/webApp.ts) is
  // deterministic/idempotent per task, so re-picking an already-claimed
  // ask reuses the same shard rather than creating a new one -- this is
  // what makes "pause (leave it claimed), come back later" safe: a
  // /commit before pausing is real git history in that same shard.
  useEffect(() => {
    if (phase !== "session" || !selected) return;
    let cancelled = false;
    const resolvedParent = watch.get().find((r) => r.slug === selected.repo) as ResolvedRepo;
    const flowOpts: ScriptedFlowOptions = {
      resolved: resolvedParent,
      laneId: selected.laneId,
      theAsk: selected.ask,
      agent,
      alreadyClaimed: selected.alreadyClaimed,
      backend,
      onLog: (line) => {
        if (!cancelled) setStatus(line);
      },
      onPartial: (text) => {
        if (!cancelled) setLiveText(text);
      },
      callTool: useRemote ? remoteCallTool : undefined,
      authedCloneUrlFor,
      onShardReady: (repo) => {
        watch.add(repo);
        shardRepoDirRef.current = repo.repoDir;
      },
    };
    flowOptsRef.current = flowOpts;
    workResultRef.current = null;
    reviewResultRef.current = null;
    // Default to the parent repo's own clone until/unless a shard (or
    // review workspace) replaces it above/below -- correct as-is for
    // local mode, which never shards at all.
    shardRepoDirRef.current = resolvedParent.repoDir;
    // The full ask description lives here, in the scrollable log, not
    // in the fixed header above it -- a header whose height depends on
    // how long a task description happens to be is exactly what broke
    // the layout on a small terminal before (see CHROME_ROWS' own
    // comment).
    setEntries([
      { role: "system", content: `Task: ${describeAsk(selected.ask)}` },
      ...(selected.alreadyClaimed ? [{ role: "system" as const, content: "Resuming -- you already hold this claim. Drafting against the current state of your shard." }] : []),
    ]);
    setLiveLabel("");
    setLiveText("");
    setDone(false);
    setBusy(true);

    (async () => {
      try {
        if (selected.ask.kind === "work") {
          setLiveLabel("drafting");
          setStatus("drafting...");
          const res = await modeRun(() => proposeContributorWork(flowOpts));
          if (cancelled) return;
          setLiveLabel("");
          setLiveText("");
          if (!res.ok) {
            pushEntry("assistant", `Couldn't produce a verified draft: ${res.denialReason}`);
            setStatus("type feedback to try again, /deny [reason], /release, or esc to pause");
          } else {
            workResultRef.current = res.result;
            pushEntry("assistant", formatFreshProposal(res.result.proposal, res.result.resolved.repoDir));
            setStatus("type feedback, /submit, /commit, /deny [reason], /release, or esc to pause");
          }
        } else {
          setStatus("preparing the files for review...");
          const workspace = await modeRun(() => prepareReviewWorkspace(flowOpts));
          if (cancelled) return;
          shardRepoDirRef.current = workspace.repoDir;
          pushEntry("system", `Written to ${workspace.repoDir} -- cd there to browse or test it.\n\n${workspace.diff}`);
          setLiveLabel("thinking");
          setStatus("drafting a recommendation...");
          const proposal = await modeRun(() => proposeReview(flowOpts));
          if (cancelled) return;
          setLiveLabel("");
          setLiveText("");
          reviewResultRef.current = proposal;
          pushEntry("assistant", formatDecision(proposal));
          setStatus("ask questions, /approve [reason], /request-changes [reason], /release, or esc to pause");
        }
      } catch (err) {
        if (!cancelled) {
          const message = err instanceof Error ? err.message : String(err);
          pushEntry("system", `Error: ${message}`);
          setStatus("esc to go back");
        }
      } finally {
        if (!cancelled) {
          setBusy(false);
          setLiveLabel("");
          setLiveText("");
        }
      }
    })();

    return () => {
      cancelled = true;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [phase, selected]);

  // Drops out of the TUI into a real shell in the shard directory this
  // session is actually working in, then restores the alt-screen and
  // raw mode exactly where it left off. Reachable both as a hotkey
  // (ctrl+/) and by typing "!" into the feedback box -- the two spots
  // someone might reach for a shell escape from, not two different
  // features.
  function openShell(): void {
    const dir = shardRepoDirRef.current;
    if (!dir) return;
    exitFullscreen();
    const wasRaw = process.stdin.isTTY && process.stdin.isRaw;
    if (process.stdin.isTTY) process.stdin.setRawMode(false);
    spawnSync(process.env.SHELL || "/bin/bash", [], { cwd: dir, stdio: "inherit" });
    if (process.stdin.isTTY && wasRaw) process.stdin.setRawMode(true);
    enterFullscreen();
    setFlash(`back from shell (${dir})`);
  }

  async function handleCommand(cmd: string, arg: string): Promise<void> {
    const flowOpts = flowOptsRef.current;
    if (!flowOpts || !selected) return;

    if (cmd === "cancel") {
      pushEntry("system", "Paused -- still claimed. Pick it again any time to resume.");
      setPhase(returnPhase);
      setSelected(null);
      return;
    }
    if (cmd === "release") {
      setBusy(true);
      setStatus("releasing...");
      await modeRun(() => releaseAsk(flowOpts));
      pushEntry("system", "Released -- claimable by anyone again.");
      setStatus("");
      setDone(true);
      setBusy(false);
      return;
    }

    if (selected.ask.kind === "work") {
      if (cmd === "submit" && workResultRef.current) {
        setBusy(true);
        setStatus("submitting...");
        await modeRun(() => acceptContributorWork(flowOpts, workResultRef.current!));
        pushEntry("system", "Submitted for review.");
        setStatus("");
        setDone(true);
        setBusy(false);
        return;
      }
      if (cmd === "commit" && workResultRef.current) {
        if (!authedCloneUrlFor) {
          pushEntry("system", "Local mode has no shard to commit to independently -- /submit when ready.");
          return;
        }
        setBusy(true);
        setStatus("committing...");
        const committed = await modeRun(() => commitContributorWork(flowOpts, workResultRef.current!));
        workResultRef.current = committed;
        pushEntry("system", `Real changeset ${committed.shardSource?.commitOid.slice(0, 10)} committed. Still claimed -- /submit when ready, or pause (esc) and come back any time; this is saved history now, not just a file on disk.`);
        setStatus("type feedback, /submit, /commit, /deny [reason], /release, or esc to pause");
        setBusy(false);
        return;
      }
      if (cmd === "deny") {
        setBusy(true);
        setStatus("denying...");
        await modeRun(() => denyContributorWork(flowOpts, arg || "rejected by human"));
        pushEntry("system", "Denied.");
        setStatus("");
        setDone(true);
        setBusy(false);
        return;
      }
      pushEntry("system", `Unknown command "/${cmd}". Try /submit, /commit, /deny [reason], /release, or /cancel.`);
      return;
    }

    if ((cmd === "approve" || cmd === "request-changes") && reviewResultRef.current) {
      const decision = cmd === "approve" ? ("approve" as const) : ("request_changes" as const);
      setBusy(true);
      setStatus(cmd === "approve" ? "approving..." : "requesting changes...");
      await modeRun(() => acceptReview(flowOpts, { decision, reason: arg || reviewResultRef.current!.reason }));
      pushEntry("system", cmd === "approve" ? "Approved." : "Requested changes -- posted a new work ask with your feedback and the rejected draft, claimable by anyone (the original contributor or someone else) to revise.");
      setStatus("");
      setDone(true);
      setBusy(false);
      return;
    }
    pushEntry("system", `Unknown command "/${cmd}". Try /approve [reason], /request-changes [reason], /release, or /cancel.`);
  }

  async function handleFeedback(text: string): Promise<void> {
    const flowOpts = flowOptsRef.current;
    if (!flowOpts || !selected) return;
    pushEntry("user", text);
    setBusy(true);
    if (selected.ask.kind === "work") {
      if (!workResultRef.current) {
        setLiveLabel("drafting");
        setStatus("retrying from scratch...");
        const res = await modeRun(() => proposeContributorWork(flowOpts));
        setLiveLabel("");
        setLiveText("");
        if (!res.ok) {
          pushEntry("assistant", `Still couldn't produce a verified draft: ${res.denialReason}`);
          setStatus("type feedback to try again, /deny [reason], /release, or esc to pause");
        } else {
          workResultRef.current = res.result;
          pushEntry("assistant", formatFreshProposal(res.result.proposal, res.result.resolved.repoDir));
          setStatus("type feedback, /submit, /commit, /deny [reason], /release, or esc to pause");
        }
      } else {
        const previous = workResultRef.current.proposal;
        setLiveLabel("revising");
        setStatus("revising...");
        const res = await modeRun(() => reviseContributorWork(flowOpts, workResultRef.current!, text));
        setLiveLabel("");
        setLiveText("");
        if (!res.ok) {
          pushEntry("assistant", `Couldn't produce a verified revision: ${res.denialReason}`);
        } else {
          workResultRef.current = res.result;
          pushEntry("assistant", formatRevisedProposal(previous, res.result.proposal, res.result.resolved.repoDir));
        }
        setStatus("type feedback, /submit, /commit, /deny [reason], /release, or esc to pause");
      }
    } else {
      setLiveLabel("thinking");
      setStatus("thinking...");
      const conversation = entriesRef.current.filter((e) => e.role !== "system").map((e) => ({ role: e.role === "user" ? ("user" as const) : ("assistant" as const), content: e.content }));
      const answer = await modeRun(() => discussReview(flowOpts, conversation, text));
      setLiveLabel("");
      setLiveText("");
      pushEntry("assistant", answer);
      setStatus("ask questions, /approve [reason], /request-changes [reason], /release, or esc to pause");
    }
    setBusy(false);
  }

  useInput((input, key) => {
    if (key.ctrl && input === "c") {
      void onExit().then(() => process.exit(0));
      return;
    }
    // ctrl+y, not a bare "y" -- a bare key would be indistinguishable
    // from someone typing the letter "y" into a chat message (the same
    // reason scroll deliberately never uses j/k either), and this needs
    // to work regardless of phase/busy/done, unlike everything below.
    if (key.ctrl && input === "y") {
      if (phase === "detail") {
        if (previewing) {
          copyToClipboard(`[${previewing.ask.kind}] ${previewing.repo}\n${describeAsk(previewing.ask)}`);
          setFlash("copied to clipboard");
        }
      } else if (phase === "session") {
        copyToClipboard(transcriptPlainText(entries));
        setFlash("copied the transcript to clipboard");
      }
      // phase === "lanePicker"/"laneAsks": nothing to copy at this
      // outer level (LaneList/LaneDetail manage their own selection
      // internally, not exposed up here) -- a no-op rather than copying
      // something irrelevant.
      return;
    }
    // ctrl+/: drop into a real shell in the shard directory this
    // session is actually working in -- same reasoning as ctrl+y above
    // for why this is ctrl-gated rather than a bare letter (and not
    // ctrl+t, which collides with common terminal/tmux bindings). Only
    // meaningful once a session has a directory on disk at all, so
    // scoped to "session" (not the picker/lanes phases, which have
    // none). Deliberately not gated on busy/done: if the model is mid
    // stream, spawnSync still blocks correctly, it's just unusual to do
    // on purpose. Typing "!" into the feedback box (below) reaches the
    // same openShell().
    // Terminals vary in how they encode ctrl+/ (historically the 0x1F
    // "unit separator" byte) -- accept both the literal "/" ink
    // sometimes reports and the "_" some terminals send instead, so
    // the hotkey isn't silently dead on a terminal that picks the other
    // encoding.
    if (key.ctrl && (input === "/" || input === "_") && phase === "session" && shardRepoDirRef.current) {
      openShell();
      return;
    }
    if (phase === "lanePicker") {
      // Up/down/enter navigation (and, on the "unclaimed" tab, s/c/n)
      // are LaneList's/LaneDetail's own -- each manages its own cursor,
      // same as Explore.tsx's repo list. laneEditing guards left/right
      // the same way it guards "laneAsks"' own esc: the unclaimed tab's
      // composer is free text, and left/right are real characters
      // someone might type into a description. This outer handler only
      // owns quit and tab-switching.
      if (input === "q") {
        void onExit().then(() => process.exit(0));
        return;
      }
      if (laneEditing) return;
      if (key.leftArrow || input === "1") setLanePickerTab("lanes");
      else if (key.rightArrow || input === "2") setLanePickerTab("unclaimed");
      return;
    }
    if (phase === "laneAsks") {
      // Composing (LaneDetail's own text box) owns the keyboard
      // entirely -- even esc, which LaneDetail interprets itself as
      // "cancel composing," not "leave this screen." Viewing/claiming
      // an ask (enter/c) is LaneDetail's own onViewAsk/onClaimAsk,
      // which push phase to "detail"/"session" directly -- this outer
      // handler only owns esc, to leave the list and go back to the
      // lane picker.
      if (laneEditing) return;
      if (key.escape) setPhase("lanePicker");
      return;
    }
    if (phase === "detail") {
      if (key.upArrow) {
        scrollUp();
        return;
      }
      if (key.downArrow) {
        scrollDown();
        return;
      }
      if (key.pageUp) {
        setScroll((s) => s + pageSize);
        return;
      }
      if (key.pageDown) {
        setScroll((s) => Math.max(0, s - pageSize));
        return;
      }
      if (key.home) {
        setScroll(Number.MAX_SAFE_INTEGER); // clamped to the real max when rendered
        return;
      }
      if (key.end) {
        setScroll(0);
        return;
      }
      if (key.escape) {
        setPreviewing(null);
        setPhase(returnPhase);
        return;
      }
      // "c" only, not key.return -- enter means exactly one thing
      // everywhere in the picker/detail flow (view detail), and a
      // separate key means claim, so pressing enter twice in a row
      // (e.g. to view, then reflexively confirm) never accidentally
      // claims something you only meant to look at.
      if (input === "c" && previewing) {
        setSelected(previewing);
        setPreviewing(null);
        setPhase("session");
      }
      return;
    }
    // session phase -- scrolling (arrows/Page Up/Down/Home/End) always
    // available, independent of busy/done, and deliberately never j/k
    // (those are free characters in a chat message here, unlike
    // TabView's own read-only content panes).
    if (key.upArrow) {
      scrollUp();
      return;
    }
    if (key.downArrow) {
      scrollDown();
      return;
    }
    if (key.pageUp) {
      setScroll((s) => s + pageSize);
      return;
    }
    if (key.pageDown) {
      setScroll((s) => Math.max(0, s - pageSize));
      return;
    }
    if (key.home) {
      setScroll(Number.MAX_SAFE_INTEGER); // clamped to the real max when rendered
      return;
    }
    if (key.end) {
      setScroll(0);
      return;
    }
    if (done) {
      if (key.return || key.escape) {
        setPhase(returnPhase);
        setSelected(null);
        setDone(false);
      }
      return;
    }
    if (busy) return;
    if (key.escape) {
      pushEntry("system", "Paused -- still claimed. Pick it again any time to resume.");
      setPhase(returnPhase);
      setSelected(null);
      return;
    }
    if (key.return) {
      const text = inputValue.trim();
      setInputValue("");
      if (!text) return;
      if (text === "!") {
        openShell();
      } else if (text.startsWith("/")) {
        const [cmd, ...rest] = text.slice(1).split(" ");
        void handleCommand(cmd, rest.join(" ").trim());
      } else {
        void handleFeedback(text);
      }
      return;
    }
    if (key.backspace || key.delete) {
      setInputValue((v) => v.slice(0, -1));
    } else if (input && !key.ctrl && !key.meta) {
      setInputValue((v) => v + input);
    }
  });

  if (phase === "lanePicker") {
    return (
      <Box flexDirection="column" width={columns} height={rows} backgroundColor={BACKDROP}>
        <Box backgroundColor={PANEL} paddingX={1}>
          <Text bold wrap="truncate-end">
            <Text color={lanePickerTab === "lanes" ? CYAN : MUTED}>1:lanes</Text>
            {"  "}
            <Text color={lanePickerTab === "unclaimed" ? CYAN : MUTED}>
              2:unclaimed ({claimableList.length})
            </Text>
            <Text color={MUTED}> -- zz work --assist ({backend.name})</Text>
          </Text>
        </Box>
        <Box borderStyle="round" borderColor={BORDER} flexDirection="column" flexGrow={1} overflow="hidden" paddingX={1}>
          {lanePickerTab === "lanes" ? (
            <LaneList
              snapshots={laneSnapshots}
              maxRows={innerRows}
              scrollOffset={0}
              onSelect={(repo, laneId) => {
                setLaneDetailTarget({ repo, laneId });
                setPhase("laneAsks");
              }}
            />
          ) : (
            <LaneDetail
              repo=""
              laneId=""
              snapshots={laneSnapshots}
              maxRows={innerRows}
              identity={agent}
              onPostAsk={onPostAsk}
              onBack={() => setLanePickerTab("lanes")}
              onEditingChange={setLaneEditing}
              claimableEntries={claimableList}
              allowPost={false}
              onViewAsk={(entry) => {
                setReturnPhase("lanePicker");
                setPreviewing(entry);
                setPhase("detail");
              }}
              onClaimAsk={(entry) => {
                setReturnPhase("lanePicker");
                setSelected(entry);
                setPhase("session");
              }}
            />
          )}
        </Box>
        <Box>
          <Text color={flash ? GREEN : MUTED} wrap="truncate-end">
            {flash ?? "←/→: tabs ↑/↓ select enter: view q/ctrl+c: quit"}
          </Text>
        </Box>
      </Box>
    );
  }

  if (phase === "laneAsks" && laneDetailTarget) {
    return (
      <Box flexDirection="column" width={columns} height={rows} backgroundColor={BACKDROP}>
        <Box backgroundColor={PANEL} paddingX={1}>
          <Text bold color={CYAN} wrap="truncate-end">
            {laneDetailTarget.repo}/{laneDetailTarget.laneId} ({backend.name})
          </Text>
        </Box>
        <Box borderStyle="round" borderColor={BORDER} flexDirection="column" flexGrow={1} overflow="hidden" paddingX={1}>
          <LaneDetail
            repo={laneDetailTarget.repo}
            laneId={laneDetailTarget.laneId}
            snapshots={laneSnapshots}
            maxRows={innerRows}
            identity={agent}
            onPostAsk={onPostAsk}
            onBack={() => {
              setLaneDetailTarget(null);
              setPhase("lanePicker");
            }}
            onEditingChange={setLaneEditing}
            onViewAsk={(entry) => {
              setReturnPhase("laneAsks");
              setPreviewing(entry);
              setPhase("detail");
            }}
            onClaimAsk={(entry) => {
              setReturnPhase("laneAsks");
              setSelected(entry);
              setPhase("session");
            }}
          />
        </Box>
        <Box>
          <Text color={MUTED} wrap="truncate-end">
            esc: back to lanes
          </Text>
        </Box>
      </Box>
    );
  }

  if (phase === "detail" && previewing) {
    const detailTextWidth = Math.max(10, columns - 4);
    const detailLines: Line[] = [];
    for (const l of describeAsk(previewing.ask).split("\n")) for (const w of wrapText(l, detailTextWidth)) detailLines.push({ text: w });
    // -1 reserves the "showing X-Y of Z" indicator's own row -- always
    // visible, not just once scrolled, specifically so a single-line
    // scroll move has something concrete to confirm it actually
    // happened (the content itself can look unchanged near a blank
    // line, which is what made 1-line scrolling feel like it was doing
    // nothing).
    const detailInnerRows = Math.max(1, rows - 7); // header(1) + border(2) + indicator(1) + footer(1) + a little slack
    const dMaxOffset = Math.max(0, detailLines.length - detailInnerRows);
    const dOffset = Math.min(scroll, dMaxOffset);
    const dEnd = detailLines.length - dOffset;
    const dShown = detailLines.slice(Math.max(0, dEnd - detailInnerRows), dEnd);
    return (
      <Box flexDirection="column" width={columns} height={rows} backgroundColor={BACKDROP}>
        <Box backgroundColor={PANEL} paddingX={1}>
          <Text bold color={CYAN} wrap="truncate-end">
            [{previewing.ask.kind}] {previewing.repo}
            {previewing.alreadyClaimed ? " (claimed)" : ""}
          </Text>
        </Box>
        <Box borderStyle="round" borderColor={BORDER} flexDirection="column" flexGrow={1} overflow="hidden" paddingX={1}>
          <Text color={MUTED}>
            showing {Math.max(0, dEnd - dShown.length) + 1}-{dEnd} of {detailLines.length}
            {dOffset > 0 ? ` -- ${dOffset} back (End to catch up)` : ""}
          </Text>
          {dShown.map((l, i) => (
            <Text key={i}>{l.text || " "}</Text>
          ))}
        </Box>
        <Box>
          <Text color={flash ? GREEN : MUTED} wrap="truncate-end">
            {flash ?? "↑/↓ PgUp/PgDn Home/End: scroll c: claim and start ctrl+y: copy esc: back to list"}
          </Text>
        </Box>
      </Box>
    );
  }

  // Border (2) + paddingX*2 (2) -- the content box's own real inner
  // width, which every line gets pre-wrapped to fit (see wrapText's own
  // comment on why this can't just be ink's wrap="wrap" on the Text).
  const textWidth = Math.max(10, columns - 4);
  const liveTurn = liveText ? { label: liveLabel, text: liveText } : null;
  const allLines = transcriptLines(entries, liveTurn, textWidth);
  // -1 reserves the "showing X-Y of Z" indicator's own row, always
  // visible rather than only once scrolled -- a single-line scroll move
  // needs something concrete to confirm it actually happened, since the
  // content itself can look unchanged near a blank line (what made
  // 1-line scrolling feel like it was doing nothing).
  const windowSize = Math.max(1, innerRows - 1);
  const maxOffset = Math.max(0, allLines.length - windowSize);
  const offset = Math.min(scroll, maxOffset);
  const end = allLines.length - offset;
  const shown = allLines.slice(Math.max(0, end - windowSize), end);

  return (
    <Box flexDirection="column" width={columns} height={rows} backgroundColor={BACKDROP}>
      <Box backgroundColor={PANEL} paddingX={1}>
        <Text bold color={CYAN} wrap="truncate-end">
          {selected?.ask.kind} {selected?.ask.id} in {selected?.repo}/{selected?.laneId} ({backend.name})
        </Text>
      </Box>
      <Box borderStyle="round" borderColor={BORDER} flexDirection="column" flexGrow={1} overflow="hidden" paddingX={1}>
        <Text color={MUTED}>
          showing {Math.max(0, end - shown.length) + 1}-{end} of {allLines.length}
          {offset > 0 ? ` -- ${offset} back (End to catch up)` : ", live"}
        </Text>
        {shown.map((l, i) => (
          <Text key={i} color={l.color} bold={l.bold}>
            {l.text || " "}
          </Text>
        ))}
      </Box>
      <Text color={flash ? GREEN : busy ? YELLOW : MUTED} wrap="truncate-end">
        {flash ?? status}
      </Text>
      {!done && (
        <Box width={columns}>
          <Text color={MUTED}>{"> "}</Text>
          <Text wrap="truncate-start">{inputValue}</Text>
          <Text color={MUTED}>{busy ? "" : "_"}</Text>
        </Box>
      )}
      {done && <Text color={MUTED}>enter/esc: back to the ask list</Text>}
      <Box>
        <Text color={MUTED} wrap="truncate-end">
          ↑/↓ PgUp/PgDn Home/End or wheel: scroll ctrl+y or option/shift-drag: copy ctrl+/ or !: shell esc: pause ctrl+c: quit
        </Text>
      </Box>
    </Box>
  );
}