Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.details.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand Down
6 changes: 6 additions & 0 deletions README.details.zh-HK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 顯示,看板不會串流每個工具事件。
Expand Down
64 changes: 61 additions & 3 deletions src/cli/commands/tui.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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. */
Expand Down Expand Up @@ -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<number> {
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);
});
}
133 changes: 82 additions & 51 deletions src/cli/task-board-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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 ??
Expand All @@ -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;
}
Loading
Loading