// `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, useRef, useState } from "react"; import { Box, Text, useInput } from "ink"; import { spawnSync } from "node:child_process"; import { claimableAcrossRepos, 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: (fn: () => Promise) => Promise; authedCloneUrlFor?: (slug: string) => string; onExit: () => Promise; } 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 = { assistant: "model", user: "you", system: "system" }; const roleColor: Record = { 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; const [phase, setPhase] = useState<"picking" | "lanes" | "detail" | "session">("picking"); const [laneEditing, setLaneEditing] = useState(false); const [laneDetailTarget, setLaneDetailTarget] = useState<{ repo: string; laneId: string } | null>(null); const [claimableList, setClaimableList] = useState([]); const [cursor, setCursor] = useState(0); const [previewing, setPreviewing] = useState(null); const [selected, setSelected] = useState(null); const [entries, setEntries] = useState([]); 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(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(null); const workResultRef = useRef(null); const reviewResultRef = useRef(null); const entriesRef = useRef([]); 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(null); const reposKey = watch .get() .map((r) => r.slug) .join(","); // "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 => { 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]); // Picker polling -- claimableAcrossRepos itself (engine/workqueue.ts) // already encodes exactly the same scope/claim-holder rules the real // `zz work` loop obeys, so this reuses it verbatim (wrapped in // modeRun for correct remote backend scoping) instead of re-deriving // the same logic by hand against raw lane_list reads. useEffect(() => { if (phase !== "picking") return; let cancelled = false; const read = async () => { try { const repos = watch.get().map((r) => ({ repo: r.slug })); const next = await modeRun(() => claimableAcrossRepos(repos, agent, scope)); if (!cancelled) setClaimableList(next.filter((c) => c.ask.kind === "work" || c.ask.kind === "changeset_review")); } catch { // transient (a repo not reachable yet, a network blip) -- keep showing the last good list } }; read(); const id = setInterval(read, 2000); return () => { cancelled = true; clearInterval(id); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [phase, agent, scope, reposKey]); useEffect(() => { if (cursor >= claimableList.length) setCursor(Math.max(0, claimableList.length - 1)); }, [claimableList, cursor]); // 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 { const flowOpts = flowOptsRef.current; if (!flowOpts || !selected) return; if (cmd === "cancel") { pushEntry("system", "Paused -- still claimed. Pick it again any time to resume."); setPhase("picking"); 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 { 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 === "picking") { const c = claimableList[cursor]; if (c) { copyToClipboard(`[${c.ask.kind}] ${c.repo}\n${describeAsk(c.ask)}`); setFlash("copied to clipboard"); } } else 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 === "lanes": nothing to copy at this outer level (LaneList // manages its 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 === "picking") { if (input === "q") { void onExit().then(() => process.exit(0)); return; } if (input === "n") { setPhase("lanes"); return; } if (key.upArrow) setCursor((c) => Math.max(0, c - 1)); else if (key.downArrow) setCursor((c) => Math.min(claimableList.length - 1, c + 1)); else if (key.return && claimableList[cursor]) { // Preview first, not an immediate claim -- same reasoning as // Explore's own "view before you track" flow: picking an ask // out of the list is a commitment (it claims it), so seeing // the whole task description first, not just the one-line // summary the list itself shows, matters. setPreviewing(claimableList[cursor]); setPhase("detail"); } return; } if (phase === "lanes") { // Composing (LaneDetail's own text box) owns the keyboard // entirely -- even esc, which LaneDetail interprets itself as // "cancel composing," not "leave this screen." While viewing a // lane's own detail (laneDetailTarget set), LaneDetail's own esc // handler already backs out of that one level (its onBack prop); // this outer handler only owns esc at the plain-list level, to // avoid double-handling the same keystroke. if (laneEditing) return; if (key.escape && !laneDetailTarget) setPhase("picking"); 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("picking"); 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("picking"); setSelected(null); setDone(false); } return; } if (busy) return; if (key.escape) { pushEntry("system", "Paused -- still claimed. Pick it again any time to resume."); setPhase("picking"); 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 === "picking") { // Always exactly two rows per ask -- [kind] repo on one, the // description's own first line on the other, both truncated with // an ellipsis (wrap="truncate-end") rather than wrapped. This is // chrome, a list you're scanning to pick from, not content you're // reading -- the full, wrapped description is one Enter away (the // detail view below), same as Explore.tsx's own "view before you // track" preview. const pickerInnerRows = Math.max(1, rows - 4); // estimate only, box itself uses flexGrow below const itemSlots = Math.max(1, Math.floor(pickerInnerRows / 2)); const windowStart = Math.max(0, Math.min(cursor - Math.floor(itemSlots / 2), Math.max(0, claimableList.length - itemSlots))); const visible = claimableList.slice(windowStart, windowStart + itemSlots); return ( zz work --assist -- pick an ask ({backend.name}) {claimableList.length === 0 ? ( No claimable work/changeset_review asks right now across your watched repos. (refreshing every 2s) ) : ( visible.map((c, i) => { const idx = windowStart + i; const current = idx === cursor; const firstLine = describeAsk(c.ask).split("\n")[0]; return ( {current ? "> " : " "} [{c.ask.kind}] {c.repo} {c.alreadyClaimed ? " (claimed)" : ""} {" "} {firstLine} ); }) )} {flash ?? "↑/↓ select enter: view n: post a new ask ctrl+y: copy q/ctrl+c: quit"} ); } if (phase === "lanes") { return ( {laneDetailTarget ? `${laneDetailTarget.repo}/${laneDetailTarget.laneId}` : "post a new ask -- pick a lane"} ({backend.name}) {laneDetailTarget ? ( setLaneDetailTarget(null)} onEditingChange={setLaneEditing} /> ) : ( setLaneDetailTarget({ repo, laneId })} /> )} esc: back ); } 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 ( [{previewing.ask.kind}] {previewing.repo} {previewing.alreadyClaimed ? " (claimed)" : ""} showing {Math.max(0, dEnd - dShown.length) + 1}-{dEnd} of {detailLines.length} {dOffset > 0 ? ` -- ${dOffset} back (End to catch up)` : ""} {dShown.map((l, i) => ( {l.text || " "} ))} {flash ?? "↑/↓ PgUp/PgDn Home/End: scroll c: claim and start ctrl+y: copy esc: back to list"} ); } // 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 ( {selected?.ask.kind} {selected?.ask.id} in {selected?.repo}/{selected?.laneId} ({backend.name}) showing {Math.max(0, end - shown.length) + 1}-{end} of {allLines.length} {offset > 0 ? ` -- ${offset} back (End to catch up)` : ", live"} {shown.map((l, i) => ( {l.text || " "} ))} {flash ?? status} {!done && ( {"> "} {inputValue} {busy ? "" : "_"} )} {done && enter/esc: back to the ask list} ↑/↓ PgUp/PgDn Home/End or wheel: scroll ctrl+y or option/shift-drag: copy ctrl+/ or !: shell esc: pause ctrl+c: quit ); }