From 8469a6137c4ff76843697f26a1dc7c7dae345a12 Mon Sep 17 00:00:00 2001 From: Agile Agents Date: Fri, 11 Sep 2026 00:05:08 +0800 Subject: [PATCH] agile(issue-104): implement ticket --- README.details.md | 10 +- README.details.zh-HK.md | 6 + src/cli/commands/tui.ts | 64 ++++++- src/cli/task-board-renderer.ts | 133 ++++++++----- src/cli/task-board-session.ts | 138 ++++++++++--- src/cli/tui-renderer.ts | 90 +++++++++ test/cli/help.test.ts | 1 + test/cli/task-board-renderer.test.ts | 31 +++ test/cli/task-board-session.test.ts | 277 ++++++++++++++++++++++++++- test/cli/tui-renderer.test.ts | 64 +++++++ 10 files changed, 725 insertions(+), 89 deletions(-) create mode 100644 src/cli/tui-renderer.ts create mode 100644 test/cli/tui-renderer.test.ts diff --git a/README.details.md b/README.details.md index 2820bfa..1dfea50 100644 --- a/README.details.md +++ b/README.details.md @@ -364,6 +364,14 @@ Use `roc-create-tasks` in your coding assistant to create and approve tasks. ## The task board +`tui` opens Welcome with setup/connection status, even before Roc settings or +GitHub login are available. `task board` opens Tasks directly. Both are read-only: +neither starts a scheduler or changes tasks. Switch pages with Tab, 1/2, or a +mouse click on the top tabs. R refreshes; failed reads keep the last snapshot +marked stale. Selection and details survive page switches and resizing. Narrow +terminals stack the board; use PgUp/PgDn to scroll long pages/details while the +tabs stay visible. Piped `task board` output remains a plain snapshot. + `task board` reads GitHub checkpoints every 30 seconds and shows persisted status, attempts, models, usage and PR links. Use `--all` for other cycles and `--history` to include retired Issues. Press Enter for details and Q to quit. @@ -460,7 +468,7 @@ cycle current Show the active Agile cycle task publish-github MANIFEST Publish approved tasks to GitHub task list [--all] [--history] List GitHub tasks task board [--all] [--history] Open the read-only board -tui Open the same board +tui Open Welcome and the Tasks monitor task trust-hooks ISSUE --phase PHASE Approve an exact hook configuration task retire ISSUE --reason TEXT Close an Issue without completing it scheduler run [--base-branch BRANCH] [--concurrency 1-8] [--once] [--auto-merge] diff --git a/README.details.zh-HK.md b/README.details.zh-HK.md index 53280cc..b2ac095 100644 --- a/README.details.zh-HK.md +++ b/README.details.zh-HK.md @@ -282,6 +282,12 @@ flowchart TD ## 進度、恢復及 hooks +`tui` 預設開啟 Welcome,即使尚未設定 Roc 或登入 GitHub,仍會顯示待設定/連線狀態。 +`task board` 直接開啟 Tasks;兩者只供監看,不會啟動 scheduler 或更改任務。 +按 Tab、1/2 或點擊頂部分頁切換,R 刷新。刷新失敗會保留上次資料並標示過期。 +切換分頁或縮放視窗會保留選中任務及詳情;窄視窗採用直向布局, +PgUp/PgDn 可捲動長頁及詳情而保留頂部分頁。非 TTY 的 `task board` 仍輸出純文字快照。 + `task board` 每 30 秒讀取 GitHub checkpoints,顯示狀態、attempt、模型、用量及 PR。 按 Enter 查看詳情,Q 離開;`--all` 包含其他週期,`--history` 包含已退役 Issue。 即時工具動作在 daemon terminal 顯示,看板不會串流每個工具事件。 diff --git a/src/cli/commands/tui.ts b/src/cli/commands/tui.ts index 764bb05..7b80c58 100644 --- a/src/cli/commands/tui.ts +++ b/src/cli/commands/tui.ts @@ -1,4 +1,7 @@ +import { homedir } from "node:os"; import type { Command } from "commander"; +import { activeAgileCycle } from "../../domain/agile-cycle"; +import { loadRocSettingsIfPresent } from "../../settings"; import { commandProjectRoot, currentCycle, @@ -9,6 +12,7 @@ import { resolveProjectDisplaySlug } from "../project-root"; import { buildTaskBoardSnapshot } from "../task-board-model"; import { renderTaskBoard } from "../task-board-renderer"; import { runTaskBoardSession } from "../task-board-session"; +import { renderWelcome } from "../tui-renderer"; import type { CliCommandContext } from "../types"; /** Displays GitHub checkpoints without creating a local task database. */ @@ -76,15 +80,69 @@ export async function executeTaskBoard( } } -/** Registers the read-only board alias. */ +/** Opens Welcome immediately; configuration and remote failures remain recoverable status. */ +export async function executeTui(context: CliCommandContext): Promise { + const { input, output } = context.io; + if (!input?.isTTY || !output?.isTTY) { + context.io.out(renderWelcome(output?.columns ?? 80)); + return 0; + } + let projectSlug: string | undefined; + try { + await runTaskBoardSession({ + input, + output, + /** Returns the project label resolved during checkpoint refresh. */ + get projectSlug() { + return projectSlug; + }, + initialTab: "welcome", + refreshIntervalMs: 30000, + /** Loads settings and remote checkpoints while leaving setup failures recoverable. */ + async read() { + const settings = await loadRocSettingsIfPresent( + context.runtime.homeRoot ?? homedir(), + ); + if (!settings) + throw new Error( + "Roc settings not configured. Run roc-it onboard, then press R. GitHub connection not checked.", + ); + const repoPath = await commandProjectRoot(context, { + allowCurrentDirectory: true, + }); + projectSlug = await resolveProjectDisplaySlug(repoPath); + if (!context.runtime.readTasks) + throw new Error("GitHub task reads are unavailable"); + const snapshot = await context.runtime.readTasks(repoPath); + const cycle = activeAgileCycle( + settings.cycle, + context.runtime.now?.() ?? new Date(), + ); + return buildTaskBoardSnapshot({ + tasks: snapshot.tasks, + inspection: snapshot.inspection, + currentCycleId: cycle.id, + remoteCheckpoints: true, + usageIncomplete: snapshot.usageIncomplete, + }); + }, + }); + return 0; + } catch (error) { + context.io.err(errorMessage(error)); + return 1; + } +} + +/** Registers the read-only Welcome entry. */ export function registerTuiCommand( program: Command, context: CliCommandContext, ): void { program .command("tui") - .description("Open the GitHub task board") + .description("Open Welcome and the read-only Tasks monitor") .action(async () => { - context.exitCode = await executeTaskBoard(context); + context.exitCode = await executeTui(context); }); } diff --git a/src/cli/task-board-renderer.ts b/src/cli/task-board-renderer.ts index 4760052..1c4a964 100644 --- a/src/cli/task-board-renderer.ts +++ b/src/cli/task-board-renderer.ts @@ -601,7 +601,11 @@ function renderDetails( ? item.evidence .split("\n") .flatMap((line) => wrap(`Evidence: ${line}`, width, " ")) - : [" Evidence: No item-level evidence recorded."]), + : wrap( + "Evidence: No item-level evidence recorded.", + width, + " ", + )), ]), ]; const retirement = @@ -809,43 +813,17 @@ export function renderTaskBoard( ); } -/** Maps a one-based terminal mouse position to a board card or Done header control. */ -export function taskBoardHitTest( +/** Shares rendered card and Done-header bounds between mouse input and keyboard scrolling. */ +function taskBoardRegions( snapshot: TaskBoardSnapshot, - point: { x: number; y: number }, - options: TaskBoardRenderOptions = {}, -): TaskBoardHit | undefined { + options: TaskBoardRenderOptions, +) { const width = Math.max(1, Math.floor(options.width ?? 100)); const columns = boardColumns(snapshot); const doneExpanded = options.doneExpanded === true || options.expandedDone === true || snapshot.history === true; - if (options.detailMode === "full") return undefined; - - if (width < narrowWidth) { - let row = 3; - for (const column of columns) { - if (point.y === row && column.name === "Done") return { kind: "done" }; - row += 1; - if (column.name === "Done" && !doneExpanded) { - row += 1; - continue; - } - if (column.tasks.length === 0) { - row += 1; - continue; - } - for (const [index, task] of column.tasks.entries()) { - const height = cardHeight(task, snapshot); - if (point.y >= row && point.y < row + height) - return { kind: "task", taskId: task.id }; - row += height + (index < column.tasks.length - 1 ? 1 : 0); - } - } - return undefined; - } - const detail = taskById( columns, options.detailTaskId ?? @@ -856,25 +834,78 @@ export function taskBoardHitTest( const detailWidth = detail === undefined ? 0 : Math.floor(width * 0.3); const boardWidth = detail === undefined ? width : width - detailWidth - 3; const cellWidth = Math.max(1, Math.floor((boardWidth - 9) / 4)); - const columnIndex = Math.floor((point.x - 1) / (cellWidth + 3)); - const column = columns[columnIndex]; - const columnStart = columnIndex * (cellWidth + 3) + 1; - if ( - column === undefined || - point.x < columnStart || - point.x >= columnStart + cellWidth || - point.y < 3 - ) - return undefined; - if (point.y === 3 && column.name === "Done") return { kind: "done" }; - if (point.y <= 4 || (column.name === "Done" && !doneExpanded)) - return undefined; - let row = 5; - for (const [index, task] of column.tasks.entries()) { - const height = cardHeight(task, snapshot); - if (point.y >= row && point.y < row + height) - return { kind: "task", taskId: task.id }; - row += height + (index < column.tasks.length - 1 ? 1 : 0); + const narrow = width < narrowWidth; + const regions: { + hit: TaskBoardHit; + x: number; + y: number; + width: number; + height: number; + }[] = []; + if (options.detailMode === "full" || (narrow && detail !== undefined)) + return regions; + + let row = 3; + for (const [columnIndex, column] of columns.entries()) { + if (!narrow) row = 3; + const x = narrow ? 1 : columnIndex * (cellWidth + 3) + 1; + const regionWidth = narrow ? width : cellWidth; + if (column.name === "Done") + regions.push({ + hit: { kind: "done" }, + x, + y: row, + width: regionWidth, + height: 1, + }); + row += narrow ? 1 : 2; + if ( + (column.name === "Done" && !doneExpanded) || + column.tasks.length === 0 + ) { + row += 1; + continue; + } + for (const [index, task] of column.tasks.entries()) { + const height = cardHeight(task, snapshot); + regions.push({ + hit: { kind: "task", taskId: task.id }, + x, + y: row, + width: regionWidth, + height, + }); + row += height + (index < column.tasks.length - 1 ? 1 : 0); + } } - return undefined; + return regions; +} + +/** Returns the selected card's zero-based row range with an exclusive end. */ +export function taskBoardSelectionRows( + snapshot: TaskBoardSnapshot, + options: TaskBoardRenderOptions, +): { start: number; end: number } | undefined { + const selectedId = options.selectedTaskId ?? options.selectedId; + const region = taskBoardRegions(snapshot, options).find( + ({ hit }) => hit.kind === "task" && hit.taskId === selectedId, + ); + return region === undefined + ? undefined + : { start: region.y - 1, end: region.y - 1 + region.height }; +} + +/** Maps a one-based terminal mouse position to a board card or Done header control. */ +export function taskBoardHitTest( + snapshot: TaskBoardSnapshot, + point: { x: number; y: number }, + options: TaskBoardRenderOptions = {}, +): TaskBoardHit | undefined { + return taskBoardRegions(snapshot, options).find( + (region) => + point.x >= region.x && + point.x < region.x + region.width && + point.y >= region.y && + point.y < region.y + region.height, + )?.hit; } diff --git a/src/cli/task-board-session.ts b/src/cli/task-board-session.ts index 797b870..ee46747 100644 --- a/src/cli/task-board-session.ts +++ b/src/cli/task-board-session.ts @@ -1,6 +1,18 @@ +import { stripVTControlCharacters } from "node:util"; import { renderHelpBox } from "./help-box"; import type { TaskBoardSnapshot } from "./task-board-model"; -import { renderTaskBoard, taskBoardHitTest } from "./task-board-renderer"; +import { + renderTaskBoard, + taskBoardHitTest, + taskBoardSelectionRows, +} from "./task-board-renderer"; +import { + renderTuiFrame, + renderWelcome, + type TuiTab, + tabTargets, + tuiTabs, +} from "./tui-renderer"; import type { CliTerminalInput, CliTerminalOutput } from "./types"; export type TaskBoardSessionOptions = { @@ -11,6 +23,7 @@ export type TaskBoardSessionOptions = { /** Optionally supplies the project-scoped label prefix resolved for this session. */ projectSlug?: string; refreshIntervalMs?: number; + initialTab?: TuiTab; }; type DetailMode = "peek" | "full" | "none"; @@ -22,17 +35,12 @@ const showCursor = "\u001B[?25h"; const enableMouse = "\u001B[?1000h\u001B[?1006h"; const disableMouse = "\u001B[?1000l\u001B[?1006l"; const clearScreen = "\u001B[2J\u001B[H"; -const red = "\u001B[31m"; -const reset = "\u001B[0m"; /** Converts an unknown failure into text that is safe to place in the status area. */ function errorText(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -/** Clips one status line to the current terminal width. */ -function statusLine(value: string, width: number): string { - return Array.from(value).slice(0, Math.max(1, width)).join(""); + return stripVTControlCharacters( + error instanceof Error ? error.message : String(error), + ).replace(/[\r\t]/gu, " "); } /** Renders the keyboard fallback reference without requiring a board snapshot. */ @@ -40,6 +48,8 @@ function renderHelp(width: number): string { return renderHelpBox( "Task board controls", [ + "Tab / 1 / 2 Switch pages (or click a tab)", + "PgUp/PgDn Scroll page or task details", "↑/↓ or J/K Select a task", "Space Peek at the selected task", "Enter Open full task details", @@ -61,6 +71,10 @@ export async function runTaskBoardSession( if (input.isTTY === false || output.isTTY === false || !input.setRawMode) throw new Error("Task board requires an interactive terminal"); + let tab = options.initialTab ?? "tasks"; + const scrolls: Record = { welcome: 0, tasks: 0 }; + let bodyOffset = 0; + let bodyRows = 1; let snapshot: TaskBoardSnapshot | undefined; let selectedTaskId: string | undefined; let detailMode: DetailMode = "none"; @@ -84,11 +98,17 @@ export async function runTaskBoardSession( }; /** Draws the latest successful frame or a readable recovery status after a failed read. */ - const render = () => { + const render = (revealSelection = false) => { const width = Math.max(1, output.columns ?? 80); let frame: string; if (helpVisible) frame = renderHelp(width); - else if (snapshot === undefined) frame = "Task board data is unavailable."; + else if (tab === "welcome") frame = renderWelcome(width); + else if (snapshot === undefined) + frame = renderHelpBox( + "Tasks", + "Data unavailable. Refresh with R after setup.", + width, + ); else { frame = renderTaskBoard(snapshot, { width, @@ -101,11 +121,33 @@ export async function runTaskBoardSession( doneExpanded, }); } - if (lastError !== undefined) { - const error = statusLine(`Error: ${lastError}`, width); - frame = `${frame}\n\n${red}${error}${reset}`; - } - output.write(`${clearScreen}${frame}`); + const status = + lastError !== undefined + ? `${snapshot ? "STALE — last successful snapshot retained" : "Setup / connection needs attention"}\nError: ${errorText(lastError)}\nR retries; this monitor never starts execution.` + : snapshot + ? "GitHub checkpoints loaded · Read-only" + : "Checking settings and GitHub connection… · Read-only"; + const viewport = renderTuiFrame({ + tab, + body: frame, + status, + width, + rows: Math.max(1, output.rows ?? 40), + scroll: scrolls[tab], + revealRows: + revealSelection && tab === "tasks" && detailMode === "none" && snapshot + ? taskBoardSelectionRows(snapshot, { + width, + selectedTaskId, + detailMode, + doneExpanded, + }) + : undefined, + }); + bodyOffset = viewport.bodyOffset; + bodyRows = viewport.bodyRows; + scrolls[tab] = viewport.scroll; + output.write(`${clearScreen}${viewport.text}`); }; /** Reads one snapshot and leaves the previous frame in place when that read fails. */ @@ -153,7 +195,7 @@ export async function runTaskBoardSession( tasks[(current + offset + tasks.length) % tasks.length]?.id; detailMode = "none"; helpVisible = false; - render(); + render(true); }; /** Applies one supported task-board action without changing task or scheduler state. */ @@ -173,6 +215,8 @@ export async function runTaskBoardSession( finish(); return; } + if (tab !== "tasks" && !["refresh", "help", "escape"].includes(action)) + return; if (action === "next" || action === "previous") { moveSelection(action === "next" ? 1 : -1); return; @@ -190,6 +234,7 @@ export async function runTaskBoardSession( if (!helpVisible && detailMode === "none") return; helpVisible = false; detailMode = "none"; + scrolls[tab] = 0; render(); return; } @@ -201,15 +246,40 @@ export async function runTaskBoardSession( if (selectedTaskId === undefined) return; helpVisible = false; detailMode = action === "peek" ? "peek" : "full"; + scrolls.tasks = 0; + render(); + }; + + /** Changes only the page, retaining task identity, detail mode and viewport. */ + const switchTab = (next: TuiTab) => { + tab = next; + helpVisible = false; render(); }; /** Handles a decoded mouse-reporting click if it lands on a board control. */ const click = (button: number, x: number, y: number) => { - if (snapshot === undefined || button >= 64 || (button & 3) !== 0) return; + if (button >= 64 || (button & 3) !== 0) return; + if (y === 1) { + const target = tabTargets().find( + (target) => + x >= target.start && x <= target.end && x <= (output.columns ?? 80), + ); + if (target) switchTab(target.id); + return; + } + if ( + tab !== "tasks" || + helpVisible || + snapshot === undefined || + y <= bodyOffset || + y > bodyOffset + bodyRows + ) + return; + if (detailMode === "peek" && (output.columns ?? 80) < 88) return; const hit = taskBoardHitTest( snapshot, - { x, y }, + { x, y: y - bodyOffset + scrolls.tasks }, { width: Math.max(1, output.columns ?? 80), selectedTaskId, @@ -226,6 +296,7 @@ export async function runTaskBoardSession( selectedTaskId = hit.taskId; helpVisible = false; detailMode = "full"; + scrolls.tasks = 0; render(); } }; @@ -255,6 +326,15 @@ export async function runTaskBoardSession( continue; } if (inputBuffer.startsWith("\u001B[<")) return; + // biome-ignore lint/suspicious/noControlCharactersInRegex: parses terminal paging keys. + const page = inputBuffer.match(/^\u001B\[([56])~/u); + if (page) { + inputBuffer = inputBuffer.slice(page[0].length); + scrolls[tab] += (page[1] === "6" ? 1 : -1) * bodyRows; + render(); + continue; + } + if (["\u001B[5", "\u001B[6"].includes(inputBuffer)) return; if (inputBuffer.startsWith("\u001B[A")) { inputBuffer = inputBuffer.slice(3); act("previous"); @@ -271,7 +351,15 @@ export async function runTaskBoardSession( } const key = inputBuffer[0]; inputBuffer = inputBuffer.slice(1); - if (key === "\u0003") act("quit"); + if (key === "\t") + switchTab( + tuiTabs[ + (tuiTabs.findIndex((item) => item.id === tab) + 1) % tuiTabs.length + ]?.id ?? "welcome", + ); + else if (key === "1") switchTab("welcome"); + else if (key === "2") switchTab("tasks"); + else if (key === "\u0003") act("quit"); else if (key === "\u001B") act("escape"); else if (key === "\r" || key === "\n") act("details"); else if (key === " ") act("peek"); @@ -369,17 +457,11 @@ export async function runTaskBoardSession( output.on("close", onOutputClose); process.once("SIGINT", onSignal); interval = setInterval(requestRefresh, options.refreshIntervalMs ?? 1_000); - refreshInFlight = true; try { - await refresh(); + render(); + requestRefresh(); } catch (error) { finish(error); - } finally { - refreshInFlight = false; - if (refreshQueued && !closed) { - refreshQueued = false; - requestRefresh(); - } } await stopped; } finally { diff --git a/src/cli/tui-renderer.ts b/src/cli/tui-renderer.ts new file mode 100644 index 0000000..970e5df --- /dev/null +++ b/src/cli/tui-renderer.ts @@ -0,0 +1,90 @@ +import { stripVTControlCharacters } from "node:util"; +import { renderHelpBox } from "./help-box"; + +/** Extend this list when additional read-only pages are available. */ +export const tuiTabs = [ + { id: "welcome", label: "Welcome" }, + { id: "tasks", label: "Tasks" }, +] as const; +export type TuiTab = (typeof tuiTabs)[number]["id"]; + +/** Shares exactly the same tab geometry between rendering and mouse input. */ +export function tabTargets() { + let start = 1; + return tuiTabs.map((tab, index) => { + const label = `${index + 1} ${tab.label}`; + const text = ` ${label} `; + const target = { ...tab, text, start, end: start + text.length - 1 }; + start += text.length + 1; + return target; + }); +} + +/** Renders setup guidance without requiring settings or a remote snapshot. */ +export function renderWelcome(width: number): string { + return [ + renderHelpBox( + "Welcome to Roc", + "Read-only workspace monitor.\nNo scheduler or agents are started here.", + width, + ), + "", + renderHelpBox( + "Getting started", + "Use Tasks to inspect GitHub checkpoints.\nSetup: roc-it onboard\nGitHub login: gh auth login\nRefresh with R after setup; nothing is changed by this monitor.", + width, + ), + ].join("\n"); +} + +/** Frames a bounded viewport while keeping navigation and recovery status visible. */ +export function renderTuiFrame(options: { + tab: TuiTab; + body: string; + status: string; + width: number; + rows: number; + scroll: number; + revealRows?: { start: number; end: number }; +}) { + const { width, rows } = options; + /** Strips terminal controls and clips the footer to the available width. */ + const clip = (text: string) => stripVTControlCharacters(text).slice(0, width); + const tabs = tabTargets() + .filter((tab) => tab.start <= width) + .map((tab) => { + const text = tab.text.slice(0, width - tab.start + 1); + return tab.id === options.tab ? `\u001B[1;7m${text}\u001B[0m` : text; + }) + .join("│"); + const header = [tabs, "─".repeat(width)]; + // Limit notices on short screens, leaving at least one row for the page. + const notice = renderHelpBox("Monitor status", options.status, width).split( + "\n", + ); + header.push(...notice.slice(0, Math.max(0, rows - 5)), ""); + const available = Math.max(1, rows - header.length - 1); + const lines = options.body.split("\n"); + let scroll = Math.min( + Math.max(0, options.scroll), + Math.max(0, lines.length - available), + ); + const reveal = options.revealRows; + if (reveal !== undefined) { + if (reveal.start < scroll) scroll = reveal.start; + else if (reveal.end > scroll + available) + scroll = Math.min(reveal.start, reveal.end - available); + scroll = Math.max(0, Math.min(scroll, lines.length - available)); + } + const footer = clip( + `${width < 65 ? "Tab/1/2 · R · Q · ? · PgUp/PgDn" : "Tab/1/2 pages · R refresh · Q quit · ? help · PgUp/PgDn scroll"} (${scroll + 1}-${Math.min(lines.length, scroll + available)}/${lines.length})`, + ); + return { + text: [...header, ...lines.slice(scroll, scroll + available), footer] + .slice(0, rows) + .join("\n"), + bodyOffset: header.length, + bodyRows: available, + scroll, + }; +} diff --git a/test/cli/help.test.ts b/test/cli/help.test.ts index 3ce4ece..00d203b 100644 --- a/test/cli/help.test.ts +++ b/test/cli/help.test.ts @@ -27,6 +27,7 @@ test("empty arguments, help, and --help describe the public command tree", async expect(help).toContain("task"); expect(help).toContain("tokens"); expect(help).toContain("tui"); + expect(help).toContain("Open Welcome and the read-only Tasks monitor"); expect(help).toContain("scheduler"); expect(help).not.toContain("--db"); expect(help).not.toContain("--repo"); diff --git a/test/cli/task-board-renderer.test.ts b/test/cli/task-board-renderer.test.ts index 8a4ba39..c295137 100644 --- a/test/cli/task-board-renderer.test.ts +++ b/test/cli/task-board-renderer.test.ts @@ -7,6 +7,7 @@ import type { import { renderTaskBoard, taskBoardHitTest, + taskBoardSelectionRows, } from "../../src/cli/task-board-renderer"; const tokens = { @@ -118,6 +119,36 @@ const snapshot: TaskBoardSnapshot = { }, }; +test.each([40, 80, 120])( + "selection bounds match variable-height cards and mouse cells at %i columns", + (width) => { + const options = { + width, + color: false, + detailMode: "none" as const, + doneExpanded: true, + }; + for (const item of snapshot.tasks) { + const selected = { ...options, selectedTaskId: item.id }; + const lines = renderTaskBoard(snapshot, selected).split("\n"); + const start = lines.findIndex((line) => line.includes("▌")); + const x = displayWidth(lines[start]?.split("▌")[0] ?? "") + 1; + const height = 2 + Number(item.blockingDependencyIds.length > 0); + expect(taskBoardSelectionRows(snapshot, selected)).toEqual({ + start, + end: start + height, + }); + for (let row = start; row < start + height; row++) + expect(taskBoardHitTest(snapshot, { x, y: row + 1 }, selected)).toEqual( + { kind: "task", taskId: item.id }, + ); + expect( + taskBoardHitTest(snapshot, { x, y: start + height + 1 }, selected), + ).not.toEqual({ kind: "task", taskId: item.id }); + } + }, +); + test("details show elapsed, attempt and merge waiting time with partial usage", () => { const item = task({ id: "timed", diff --git a/test/cli/task-board-session.test.ts b/test/cli/task-board-session.test.ts index 1335e2f..eb0b351 100644 --- a/test/cli/task-board-session.test.ts +++ b/test/cli/task-board-session.test.ts @@ -1,11 +1,17 @@ import { expect, test } from "bun:test"; import { EventEmitter } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { stripVTControlCharacters } from "node:util"; +import { runCli } from "../../src/cli/run"; import type { TaskBoardSnapshot, TaskBoardTask, } from "../../src/cli/task-board-model"; import { runTaskBoardSession } from "../../src/cli/task-board-session"; +import { githubTaskSnapshot } from "../../src/github/execution-view"; +import { saveRocSettings } from "../../src/settings"; const tokens = { inputTokens: 0, @@ -100,6 +106,7 @@ class Input extends EventEmitter { class Output extends EventEmitter { isTTY = true; columns = 120; + rows = 60; writes: string[] = []; failFrame = false; deferWriteCallbacks = false; @@ -206,9 +213,12 @@ test("opens clicked cards as full details, toggles Done by mouse, and retains se }); await waitFor(() => frame(output).includes("Ready · 2")); - input.emit("data", "\u001B[<0;1;7M"); + const offset = stripVTControlCharacters(frame(output)) + .split("\n") + .findIndex((line) => line.startsWith("Roc")); + input.emit("data", `\u001B[<0;1;${7 + offset}M`); expect(frame(output)).not.toContain("Task first"); - input.emit("data", "\u001B[<0;1;8M"); + input.emit("data", `\u001B[<0;1;${8 + offset}M`); expect(frame(output)).toContain("Task second"); input.emit("data", "\u001B"); await Bun.sleep(25); @@ -217,13 +227,126 @@ test("opens clicked cards as full details, toggles Done by mouse, and retains se expect(stripVTControlCharacters(frame(output))).toContain("▌ second"); output.columns = 120; output.emit("resize"); - input.emit("data", "\u001B[<0;91;3M"); + input.emit("data", `\u001B[<0;91;${3 + offset}M`); expect(frame(output)).toContain("finished work"); input.emit("data", "\u0003"); await running; expectRestored(input, output); }); +test.each([120, 80, 40])( + "keeps keyboard selection visible on a long board at %i columns", + async (width) => { + const input = new Input(); + const output = new Output(); + output.columns = width; + output.rows = 24; + const tasks = Array.from({ length: 12 }, (_, index) => + task({ id: `row-${String(index + 1).padStart(2, "0")}` }), + ); + const snapshot = { + ...board(), + tasks, + columns: { ready: tasks, inProgress: [], attention: [], done: [] }, + }; + const running = runTaskBoardSession({ + input: input as never, + output: output as never, + read: () => snapshot, + }); + + try { + await waitFor(() => frame(output).includes("Ready · 12")); + input.emit("data", "jjjjjj"); + expect(stripVTControlCharacters(frame(output))).toMatch( + /▌[^\n]*row-07 work[^\n]*\n {4}ready/u, + ); + const selectedFrame = frame(output); + input.emit("data", "\u001B[6~"); + expect(frame(output)).not.toBe(selectedFrame); + for (const [keys, expectedId] of [ + ["j", "#project-8"], + ["\u001B[A", "#project-7"], + ["k", "#project-6"], + ["\u001B[5~j", "#project-7"], + ["jjjjj", "#project-12"], + ["\u001B[B", "#project-1"], + ["k", "#project-12"], + ]) { + input.emit("data", keys); + expect(stripVTControlCharacters(frame(output))).toContain( + `▌ ${expectedId} `, + ); + } + } finally { + input.emit("data", "q"); + await running; + } + expectRestored(input, output); + }, +); + +test.each([40, 80, 120])( + "selects across populated columns and clicks scrolled cards at %i columns", + async (width) => { + const input = new Input(); + const output = new Output(); + output.columns = width; + output.rows = 24; + const tasks = Array.from({ length: 12 }, (_, index) => + task({ + id: `mixed-${index + 1}`, + title: `work ${index + 1}`, + column: index < 6 ? "ready" : "attention", + rawStatus: index < 6 ? "ready" : "needs_input", + blockingDependencyIds: index < 6 ? [] : ["setup"], + }), + ); + const snapshot = { + ...board(), + tasks, + columns: { + ready: tasks.slice(0, 6), + inProgress: [], + attention: tasks.slice(6), + done: [], + }, + }; + const running = runTaskBoardSession({ + input: input as never, + output: output as never, + read: () => snapshot, + }); + try { + await waitFor(() => frame(output).includes("Ready · 6")); + for (const [keys, id] of [ + ["jjjjjj", 7], + ["\u001B[6~\u001B[B", 8], + ["\u001B[A", 7], + ["jjjjj", 12], + ["j", 1], + ["k", 12], + ] as const) { + input.emit("data", keys); + const lines = stripVTControlCharacters(frame(output)).split("\n"); + const row = lines.findIndex((line) => line.includes("▌")); + expect(lines[row]).toContain(`#project-${id} `); + expect(lines[row + 1]).toContain(id < 7 ? "ready" : "needs_input"); + } + // The last card is selected below the initial viewport in every layout. + const lines = stripVTControlCharacters(frame(output)).split("\n"); + const row = lines.findIndex((line) => line.includes("▌")); + const x = (lines[row]?.indexOf("▌") ?? -1) + 1; + input.emit("data", `\u001B[<0;${x};${row + 1}M`); + expect(frame(output)).toContain("Task mixed-12"); + } finally { + input.emit("data", "q"); + await running; + } + expectRestored(input, output); + }, +); + test("keeps the last valid frame on a transient read failure and retries on demand", async () => { const input = new Input(); const output = new Output(); @@ -243,9 +366,7 @@ test("keeps the last valid frame on a transient read failure and retries on dema input.emit("data", "R"); await waitFor(() => frame(output).includes("temporary read failure")); const errorFrame = frame(output); - expect(errorFrame).toContain( - "\u001B[31mError: temporary read failure\u001B[0m", - ); + expect(errorFrame).toContain("STALE"); expect(stripVTControlCharacters(errorFrame)).toContain( "Error: temporary read failure", ); @@ -366,3 +487,147 @@ test("keeps the output error listener through deferred restoration writes", asyn await running; expectRestored(input, output); }); + +test("Welcome navigation retains details across keyboard, mouse, resize and paged evidence", async () => { + const input = new Input(); + const output = new Output(); + output.columns = 80; + output.rows = 24; + const snapshot = board(); + const second = snapshot.tasks[1]; + if (!second) throw new Error("Missing fixture task"); + second.issueUrl = "https://github.com/example/repo/issues/2"; + second.pullRequestUrl = "https://github.com/example/repo/pull/3"; + second.acceptanceChecklist = [ + { + criterionIndex: 0, + criterion: "read only", + status: "passed", + evidence: "fixture evidence", + }, + ]; + const running = runTaskBoardSession({ + input: input as never, + output: output as never, + initialTab: "welcome", + read: () => snapshot, + }); + await waitFor(() => frame(output).includes("checkpoints loaded")); + expect(frame(output)).toContain("Welcome to Roc"); + input.emit("data", "\tj\r"); + expect(frame(output)).toContain("Task second"); + input.emit("data", "1"); + expect(frame(output)).toContain("Welcome to Roc"); + output.columns = 40; + output.emit("resize"); + input.emit("data", "\u001B[<0;15;1M"); + expect(frame(output)).toContain("Task second"); + const pages: string[] = [frame(output)]; + for (let i = 0; i < 10; i++) { + input.emit("data", "\u001B[6~"); + pages.push(frame(output)); + expect(frame(output).split("\n").length).toBeLessThanOrEqual(24); + expect(stripVTControlCharacters(frame(output)).split("\n")[0]).toContain( + "1 Welcome", + ); + } + expect(pages.join("\n")).toContain("Issue:"); + expect(pages.join("\n")).toContain("PR:"); + expect(pages.join("\n")).toContain("fixture evidence"); + input.emit("data", "\u001B[5~"); + input.emit("data", "q"); + await running; + expectRestored(input, output); +}); + +test("quit and terminal errors restore immediately during the pending initial read", async () => { + for (const failure of [false, true]) { + const input = new Input(); + const output = new Output(); + let release!: (snapshot: TaskBoardSnapshot) => void; + const running = runTaskBoardSession({ + input: input as never, + output: output as never, + initialTab: "welcome", + read: () => + new Promise((resolve) => { + release = resolve; + }), + }); + expect(frame(output)).toContain("Welcome to Roc"); + expect(frame(output)).toContain("Checking settings"); + if (failure) { + output.emit("error", new Error("pending output failure")); + await expect(running).rejects.toThrow("pending output failure"); + } else { + input.emit("data", "q"); + await running; + } + expectRestored(input, output); + const writes = output.writes.length; + release(board()); + await Bun.sleep(1); + expect(output.writes.length).toBe(writes); + } +}); + +test("CLI entries are read only, Welcome survives absent settings and rejected GitHub reads", async () => { + const root = await mkdtemp(join(tmpdir(), "roc-tui-entry-")); + let runs = 0; + let reads = 0; + let rejectRead = false; + const runtime = { + projectRoot: root, + homeRoot: root, + async runScheduler() { + runs++; + }, + async readTasks() { + reads++; + if (rejectRead) throw new Error("GitHub authentication unavailable"); + return githubTaskSnapshot([], []); + }, + }; + try { + for (const mode of ["missing", "remote failure", "welcome", "tasks"]) { + if (mode !== "missing") + await saveRocSettings({ cycle: { type: "weekly" } }, root); + rejectRead = mode === "remote failure"; + const input = new Input(); + const output = new Output(); + const errors: string[] = []; + const running = runCli( + mode === "tasks" ? ["task", "board"] : ["tui"], + { + input: input as never, + output: output as never, + out() {}, + err(message) { + errors.push(message); + }, + }, + runtime, + ); + await waitFor(() => + frame(output).includes( + mode === "missing" + ? "not configured" + : mode === "remote failure" + ? "authentication unavailable" + : "checkpoints loaded", + ), + ); + expect(frame(output)).toContain( + mode === "tasks" ? "GitHub checkpoints" : "Welcome to Roc", + ); + if (mode === "missing") expect(reads).toBe(0); + input.emit("data", "q"); + expect(await running).toBe(0); + expect(errors).toEqual([]); + expectRestored(input, output); + } + expect(runs).toBe(0); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/cli/tui-renderer.test.ts b/test/cli/tui-renderer.test.ts new file mode 100644 index 0000000..15bdf5e --- /dev/null +++ b/test/cli/tui-renderer.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from "bun:test"; +import { stripVTControlCharacters } from "node:util"; +import { + renderTuiFrame, + renderWelcome, + tabTargets, +} from "../../src/cli/tui-renderer"; + +test("reveals only out-of-view cards without undoing manual paging", () => { + const options = { + tab: "tasks" as const, + width: 80, + rows: 24, + scroll: 20, + body: Array.from({ length: 100 }, (_, index) => `row ${index}`).join("\n"), + status: "Read-only", + }; + const paged = renderTuiFrame(options); + expect(paged.scroll).toBe(20); + expect( + renderTuiFrame({ ...options, revealRows: { start: 20, end: 22 } }).scroll, + ).toBe(20); + expect( + renderTuiFrame({ ...options, revealRows: { start: 0, end: 2 } }).scroll, + ).toBe(0); + const below = renderTuiFrame({ + ...options, + revealRows: { start: 60, end: 64 }, + }); + expect(below.scroll).toBe(64 - below.bodyRows); + expect(below.text).toContain("row 60\nrow 61\nrow 62\nrow 63"); +}); + +test("shared tabs and bordered Welcome fit narrow and short viewports", () => { + for (const width of [1, 10, 20, 40, 80]) { + for (const rows of [1, 6, 24]) { + const frame = renderTuiFrame({ + tab: "welcome", + width, + rows, + scroll: 0, + body: renderWelcome(width), + status: "Setup needs attention. GitHub connection not checked.", + }); + const lines = stripVTControlCharacters(frame.text).split("\n"); + expect(lines.length).toBeLessThanOrEqual(rows); + for (const line of lines) + expect(Array.from(line).length).toBeLessThanOrEqual(width); + } + } + const frame = renderTuiFrame({ + tab: "tasks", + width: 80, + rows: 24, + scroll: 100, + body: "one\ntwo", + status: "Read-only", + }); + const tabs = stripVTControlCharacters(frame.text).split("\n")[0] ?? ""; + for (const target of tabTargets()) + expect(tabs.slice(target.start - 1, target.end)).toBe(target.text); + expect(frame.scroll).toBe(0); + expect(frame.text).toContain("\u001B[1;7m 2 Tasks "); +});