mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 08:02:25 +00:00
* Add lefthook formatter checks * Fix SDK mode hydration * Stabilize SDK mode integration test
608 lines
17 KiB
TypeScript
608 lines
17 KiB
TypeScript
import type { AppConfig, HandoffRecord } from "@openhandoff/shared";
|
|
import { spawnSync } from "node:child_process";
|
|
import { createBackendClientFromConfig, filterHandoffs, formatRelativeAge, groupHandoffStatus } from "@openhandoff/client";
|
|
import { CLI_BUILD_ID } from "./build-id.js";
|
|
import { resolveTuiTheme, type TuiTheme } from "./theme.js";
|
|
|
|
interface KeyEventLike {
|
|
name?: string;
|
|
ctrl?: boolean;
|
|
meta?: boolean;
|
|
}
|
|
|
|
const HELP_LINES = [
|
|
"Shortcuts",
|
|
"Ctrl-H toggle cheatsheet",
|
|
"Enter switch to branch",
|
|
"Ctrl-A attach to session",
|
|
"Ctrl-O open PR in browser",
|
|
"Ctrl-X archive branch / close PR",
|
|
"Ctrl-Y merge highlighted PR",
|
|
"Ctrl-S sync handoff with remote",
|
|
"Ctrl-N / Down next row",
|
|
"Ctrl-P / Up previous row",
|
|
"Backspace delete filter",
|
|
"Type filter by branch/PR/author",
|
|
"Esc / Ctrl-C cancel",
|
|
"",
|
|
"Legend",
|
|
"Agent: \u{1F916} running \u{1F4AC} idle \u25CC queued",
|
|
];
|
|
|
|
const COLUMN_WIDTHS = {
|
|
diff: 10,
|
|
agent: 5,
|
|
pr: 6,
|
|
author: 10,
|
|
ci: 7,
|
|
review: 8,
|
|
age: 5,
|
|
} as const;
|
|
|
|
interface DisplayRow {
|
|
name: string;
|
|
diff: string;
|
|
agent: string;
|
|
pr: string;
|
|
author: string;
|
|
ci: string;
|
|
review: string;
|
|
age: string;
|
|
}
|
|
|
|
interface RenderOptions {
|
|
width?: number;
|
|
height?: number;
|
|
}
|
|
|
|
function pad(input: string, width: number): string {
|
|
if (width <= 0) {
|
|
return "";
|
|
}
|
|
const chars = Array.from(input);
|
|
const text = chars.length > width ? `${chars.slice(0, Math.max(1, width - 1)).join("")}…` : input;
|
|
return text.padEnd(width, " ");
|
|
}
|
|
|
|
function truncateToLen(input: string, maxLen: number): string {
|
|
if (maxLen <= 0) {
|
|
return "";
|
|
}
|
|
return Array.from(input).slice(0, maxLen).join("");
|
|
}
|
|
|
|
function fitLine(input: string, width: number): string {
|
|
if (width <= 0) {
|
|
return "";
|
|
}
|
|
const clipped = truncateToLen(input, width);
|
|
const len = Array.from(clipped).length;
|
|
if (len >= width) {
|
|
return clipped;
|
|
}
|
|
return `${clipped}${" ".repeat(width - len)}`;
|
|
}
|
|
|
|
function overlayLine(base: string, overlay: string, startCol: number, width: number): string {
|
|
const out = Array.from(fitLine(base, width));
|
|
const src = Array.from(truncateToLen(overlay, Math.max(0, width - startCol)));
|
|
for (let i = 0; i < src.length; i += 1) {
|
|
const col = startCol + i;
|
|
if (col >= 0 && col < out.length) {
|
|
out[col] = src[i] ?? " ";
|
|
}
|
|
}
|
|
return out.join("");
|
|
}
|
|
|
|
function buildFooterLine(width: number, segments: string[], right: string): string {
|
|
if (width <= 0) {
|
|
return "";
|
|
}
|
|
|
|
const rightLen = Array.from(right).length;
|
|
if (width <= rightLen + 1) {
|
|
return truncateToLen(right, width);
|
|
}
|
|
|
|
const leftMax = width - rightLen - 1;
|
|
let used = 0;
|
|
let left = "";
|
|
let first = true;
|
|
|
|
for (const segment of segments) {
|
|
const chunk = first ? segment : ` | ${segment}`;
|
|
const clipped = truncateToLen(chunk, leftMax - used);
|
|
if (!clipped) {
|
|
break;
|
|
}
|
|
left += clipped;
|
|
used += Array.from(clipped).length;
|
|
first = false;
|
|
if (used >= leftMax) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
const padding = " ".repeat(Math.max(0, leftMax - used) + 1);
|
|
return `${left}${padding}${right}`;
|
|
}
|
|
|
|
function agentSymbol(status: HandoffRecord["status"]): string {
|
|
const group = groupHandoffStatus(status);
|
|
if (group === "running") return "🤖";
|
|
if (group === "idle") return "💬";
|
|
if (group === "error") return "⚠";
|
|
if (group === "queued") return "◌";
|
|
return "-";
|
|
}
|
|
|
|
function toDisplayRow(row: HandoffRecord): DisplayRow {
|
|
const conflictPrefix = row.conflictsWithMain === "true" ? "\u26A0 " : "";
|
|
|
|
const prLabel = row.prUrl ? `#${row.prUrl.match(/\/pull\/(\d+)/)?.[1] ?? "?"}` : row.prSubmitted ? "sub" : "-";
|
|
|
|
const ciLabel = row.ciStatus ?? "-";
|
|
const reviewLabel = row.reviewStatus
|
|
? row.reviewStatus === "approved"
|
|
? "ok"
|
|
: row.reviewStatus === "changes_requested"
|
|
? "chg"
|
|
: row.reviewStatus === "pending"
|
|
? "..."
|
|
: row.reviewStatus
|
|
: "-";
|
|
|
|
return {
|
|
name: `${conflictPrefix}${row.title || row.branchName}`,
|
|
diff: row.diffStat ?? "-",
|
|
agent: agentSymbol(row.status),
|
|
pr: prLabel,
|
|
author: row.prAuthor ?? "-",
|
|
ci: ciLabel,
|
|
review: reviewLabel,
|
|
age: formatRelativeAge(row.updatedAt),
|
|
};
|
|
}
|
|
|
|
function helpLines(width: number): string[] {
|
|
const popupWidth = Math.max(40, Math.min(width - 2, 100));
|
|
const innerWidth = Math.max(2, popupWidth - 2);
|
|
const borderTop = `┌${"─".repeat(innerWidth)}┐`;
|
|
const borderBottom = `└${"─".repeat(innerWidth)}┘`;
|
|
|
|
const lines = [borderTop];
|
|
for (const line of HELP_LINES) {
|
|
lines.push(`│${pad(line, innerWidth)}│`);
|
|
}
|
|
lines.push(borderBottom);
|
|
return lines;
|
|
}
|
|
|
|
export function formatRows(
|
|
rows: HandoffRecord[],
|
|
selected: number,
|
|
workspaceId: string,
|
|
status: string,
|
|
searchQuery = "",
|
|
showHelp = false,
|
|
options: RenderOptions = {},
|
|
): string {
|
|
const totalWidth = options.width ?? process.stdout.columns ?? 120;
|
|
const totalHeight = Math.max(6, options.height ?? process.stdout.rows ?? 24);
|
|
const fixedWidth =
|
|
COLUMN_WIDTHS.diff + COLUMN_WIDTHS.agent + COLUMN_WIDTHS.pr + COLUMN_WIDTHS.author + COLUMN_WIDTHS.ci + COLUMN_WIDTHS.review + COLUMN_WIDTHS.age;
|
|
const separators = 7;
|
|
const prefixWidth = 2;
|
|
const branchWidth = Math.max(20, totalWidth - (fixedWidth + separators + prefixWidth));
|
|
|
|
const branchHeader = searchQuery ? `Branch/PR: ${searchQuery}_` : "Branch/PR (type to filter)";
|
|
const header = [
|
|
` ${pad(branchHeader, branchWidth)} ${pad("Diff", COLUMN_WIDTHS.diff)} ${pad("Agent", COLUMN_WIDTHS.agent)} ${pad("PR", COLUMN_WIDTHS.pr)} ${pad("Author", COLUMN_WIDTHS.author)} ${pad("CI", COLUMN_WIDTHS.ci)} ${pad("Review", COLUMN_WIDTHS.review)} ${pad("Age", COLUMN_WIDTHS.age)}`,
|
|
"-".repeat(Math.max(24, Math.min(totalWidth, 180))),
|
|
];
|
|
|
|
const body =
|
|
rows.length === 0
|
|
? ["No branches found."]
|
|
: rows.map((row, index) => {
|
|
const marker = index === selected ? "┃ " : " ";
|
|
const display = toDisplayRow(row);
|
|
return `${marker}${pad(display.name, branchWidth)} ${pad(display.diff, COLUMN_WIDTHS.diff)} ${pad(display.agent, COLUMN_WIDTHS.agent)} ${pad(display.pr, COLUMN_WIDTHS.pr)} ${pad(display.author, COLUMN_WIDTHS.author)} ${pad(display.ci, COLUMN_WIDTHS.ci)} ${pad(display.review, COLUMN_WIDTHS.review)} ${pad(display.age, COLUMN_WIDTHS.age)}`;
|
|
});
|
|
|
|
const footer = fitLine(buildFooterLine(totalWidth, ["Ctrl-H:cheatsheet", `workspace:${workspaceId}`, status], `v${CLI_BUILD_ID}`), totalWidth);
|
|
|
|
const contentHeight = totalHeight - 1;
|
|
const lines = [...header, ...body].map((line) => fitLine(line, totalWidth));
|
|
const page = lines.slice(0, contentHeight);
|
|
while (page.length < contentHeight) {
|
|
page.push(" ".repeat(totalWidth));
|
|
}
|
|
|
|
if (showHelp) {
|
|
const popup = helpLines(totalWidth);
|
|
const startRow = Math.max(0, Math.floor((contentHeight - popup.length) / 2));
|
|
for (let i = 0; i < popup.length; i += 1) {
|
|
const target = startRow + i;
|
|
if (target >= page.length) {
|
|
break;
|
|
}
|
|
const popupLine = popup[i] ?? "";
|
|
const popupLen = Array.from(popupLine).length;
|
|
const startCol = Math.max(0, Math.floor((totalWidth - popupLen) / 2));
|
|
page[target] = overlayLine(page[target] ?? "", popupLine, startCol, totalWidth);
|
|
}
|
|
}
|
|
|
|
return [...page, footer].join("\n");
|
|
}
|
|
|
|
interface OpenTuiLike {
|
|
createCliRenderer?: (options?: Record<string, unknown>) => Promise<any>;
|
|
TextRenderable?: new (
|
|
ctx: any,
|
|
options: { id: string; content: string },
|
|
) => {
|
|
content: unknown;
|
|
fg?: string;
|
|
bg?: string;
|
|
};
|
|
fg?: (color: string) => (input: unknown) => unknown;
|
|
bg?: (color: string) => (input: unknown) => unknown;
|
|
StyledText?: new (chunks: unknown[]) => unknown;
|
|
}
|
|
|
|
interface StyledTextApi {
|
|
fg: (color: string) => (input: unknown) => unknown;
|
|
bg: (color: string) => (input: unknown) => unknown;
|
|
StyledText: new (chunks: unknown[]) => unknown;
|
|
}
|
|
|
|
function buildStyledContent(content: string, theme: TuiTheme, api: StyledTextApi): unknown {
|
|
const lines = content.split("\n");
|
|
const chunks: unknown[] = [];
|
|
const footerIndex = Math.max(0, lines.length - 1);
|
|
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|
const line = lines[i] ?? "";
|
|
|
|
let fgColor = theme.text;
|
|
let bgColor: string | undefined;
|
|
|
|
if (line.startsWith("┃ ")) {
|
|
const marker = "┃ ";
|
|
const rest = line.slice(marker.length);
|
|
bgColor = theme.highlightBg;
|
|
const markerChunk = api.bg(bgColor)(api.fg(theme.selectionBorder)(marker));
|
|
const restChunk = api.bg(bgColor)(api.fg(theme.highlightFg)(rest));
|
|
chunks.push(markerChunk);
|
|
chunks.push(restChunk);
|
|
if (i < lines.length - 1) {
|
|
chunks.push(api.fg(theme.text)("\n"));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (i === 0) {
|
|
fgColor = theme.header;
|
|
} else if (i === 1) {
|
|
fgColor = theme.muted;
|
|
} else if (i === footerIndex) {
|
|
fgColor = theme.status;
|
|
} else if (line.startsWith("┌") || line.startsWith("│") || line.startsWith("└")) {
|
|
fgColor = theme.info;
|
|
}
|
|
|
|
let chunk: unknown = api.fg(fgColor)(line);
|
|
if (bgColor) {
|
|
chunk = api.bg(bgColor)(chunk);
|
|
}
|
|
chunks.push(chunk);
|
|
|
|
if (i < lines.length - 1) {
|
|
chunks.push(api.fg(theme.text)("\n"));
|
|
}
|
|
}
|
|
|
|
return new api.StyledText(chunks);
|
|
}
|
|
|
|
export async function runTui(config: AppConfig, workspaceId: string): Promise<void> {
|
|
const core = (await import("@opentui/core")) as OpenTuiLike;
|
|
const createCliRenderer = core.createCliRenderer;
|
|
const TextRenderable = core.TextRenderable;
|
|
const styleApi = core.fg && core.bg && core.StyledText ? { fg: core.fg, bg: core.bg, StyledText: core.StyledText } : null;
|
|
|
|
if (!createCliRenderer || !TextRenderable) {
|
|
throw new Error("OpenTUI runtime missing createCliRenderer/TextRenderable exports");
|
|
}
|
|
|
|
const themeResolution = resolveTuiTheme(config);
|
|
const client = createBackendClientFromConfig(config);
|
|
const renderer = await createCliRenderer({ exitOnCtrlC: false });
|
|
const text = new TextRenderable(renderer, {
|
|
id: "openhandoff-switch",
|
|
content: "Loading...",
|
|
});
|
|
text.fg = themeResolution.theme.text;
|
|
text.bg = themeResolution.theme.background;
|
|
renderer.root.add(text);
|
|
renderer.start();
|
|
|
|
let allRows: HandoffRecord[] = [];
|
|
let filteredRows: HandoffRecord[] = [];
|
|
let selected = 0;
|
|
let searchQuery = "";
|
|
let showHelp = false;
|
|
let status = "loading...";
|
|
let busy = false;
|
|
let closed = false;
|
|
let timer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
const clampSelected = (): void => {
|
|
if (filteredRows.length === 0) {
|
|
selected = 0;
|
|
return;
|
|
}
|
|
if (selected < 0) {
|
|
selected = 0;
|
|
return;
|
|
}
|
|
if (selected >= filteredRows.length) {
|
|
selected = filteredRows.length - 1;
|
|
}
|
|
};
|
|
|
|
const render = (): void => {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
const output = formatRows(filteredRows, selected, workspaceId, status, searchQuery, showHelp, {
|
|
width: renderer.width ?? process.stdout.columns,
|
|
height: renderer.height ?? process.stdout.rows,
|
|
});
|
|
text.content = styleApi ? buildStyledContent(output, themeResolution.theme, styleApi) : output;
|
|
renderer.requestRender();
|
|
};
|
|
|
|
const refresh = async (): Promise<void> => {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
try {
|
|
allRows = await client.listHandoffs(workspaceId);
|
|
if (closed) {
|
|
return;
|
|
}
|
|
filteredRows = filterHandoffs(allRows, searchQuery);
|
|
clampSelected();
|
|
status = `handoffs=${allRows.length} filtered=${filteredRows.length}`;
|
|
} catch (err) {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
status = err instanceof Error ? err.message : String(err);
|
|
}
|
|
render();
|
|
};
|
|
|
|
const selectedRow = (): HandoffRecord | null => {
|
|
if (filteredRows.length === 0) {
|
|
return null;
|
|
}
|
|
return filteredRows[selected] ?? null;
|
|
};
|
|
|
|
let resolveDone: () => void = () => {};
|
|
const done = new Promise<void>((resolve) => {
|
|
resolveDone = () => resolve();
|
|
});
|
|
|
|
const close = (output?: string): void => {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
closed = true;
|
|
if (timer) {
|
|
clearInterval(timer);
|
|
timer = null;
|
|
}
|
|
process.off("SIGINT", handleSignal);
|
|
process.off("SIGTERM", handleSignal);
|
|
renderer.destroy();
|
|
if (output) {
|
|
console.log(output);
|
|
}
|
|
resolveDone();
|
|
};
|
|
|
|
const handleSignal = (): void => {
|
|
close();
|
|
};
|
|
|
|
const runActionWithRefresh = async (label: string, fn: () => Promise<void>, success: string): Promise<void> => {
|
|
if (busy) {
|
|
return;
|
|
}
|
|
busy = true;
|
|
status = `${label}...`;
|
|
render();
|
|
try {
|
|
await fn();
|
|
status = success;
|
|
await refresh();
|
|
} catch (err) {
|
|
status = err instanceof Error ? err.message : String(err);
|
|
render();
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
};
|
|
|
|
await refresh();
|
|
timer = setInterval(() => {
|
|
void refresh();
|
|
}, 10_000);
|
|
process.once("SIGINT", handleSignal);
|
|
process.once("SIGTERM", handleSignal);
|
|
|
|
const keyInput = (renderer.keyInput ?? renderer.keyHandler) as { on: (name: string, cb: (event: KeyEventLike) => void) => void } | undefined;
|
|
|
|
if (!keyInput) {
|
|
clearInterval(timer);
|
|
renderer.destroy();
|
|
throw new Error("OpenTUI key input handler is unavailable");
|
|
}
|
|
|
|
keyInput.on("keypress", (event: KeyEventLike) => {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
|
|
const name = event.name ?? "";
|
|
const ctrl = Boolean(event.ctrl);
|
|
|
|
if (ctrl && name === "h") {
|
|
showHelp = !showHelp;
|
|
render();
|
|
return;
|
|
}
|
|
|
|
if (showHelp) {
|
|
if (name === "escape") {
|
|
showHelp = false;
|
|
render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (name === "q" || name === "escape" || (ctrl && name === "c")) {
|
|
close();
|
|
return;
|
|
}
|
|
|
|
if ((ctrl && name === "n") || name === "down") {
|
|
if (filteredRows.length > 0) {
|
|
selected = selected >= filteredRows.length - 1 ? 0 : selected + 1;
|
|
render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if ((ctrl && name === "p") || name === "up") {
|
|
if (filteredRows.length > 0) {
|
|
selected = selected <= 0 ? filteredRows.length - 1 : selected - 1;
|
|
render();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (name === "backspace") {
|
|
searchQuery = searchQuery.slice(0, -1);
|
|
filteredRows = filterHandoffs(allRows, searchQuery);
|
|
selected = 0;
|
|
render();
|
|
return;
|
|
}
|
|
|
|
if (name === "return" || name === "enter") {
|
|
const row = selectedRow();
|
|
if (!row || busy) {
|
|
return;
|
|
}
|
|
busy = true;
|
|
status = `switching ${row.handoffId}...`;
|
|
render();
|
|
void (async () => {
|
|
try {
|
|
const result = await client.switchHandoff(workspaceId, row.handoffId);
|
|
close(`cd ${result.switchTarget}`);
|
|
} catch (err) {
|
|
busy = false;
|
|
status = err instanceof Error ? err.message : String(err);
|
|
render();
|
|
}
|
|
})();
|
|
return;
|
|
}
|
|
|
|
if (ctrl && name === "a") {
|
|
const row = selectedRow();
|
|
if (!row || busy) {
|
|
return;
|
|
}
|
|
busy = true;
|
|
status = `attaching ${row.handoffId}...`;
|
|
render();
|
|
void (async () => {
|
|
try {
|
|
const result = await client.attachHandoff(workspaceId, row.handoffId);
|
|
close(`target=${result.target} session=${result.sessionId ?? "none"}`);
|
|
} catch (err) {
|
|
busy = false;
|
|
status = err instanceof Error ? err.message : String(err);
|
|
render();
|
|
}
|
|
})();
|
|
return;
|
|
}
|
|
|
|
if (ctrl && name === "x") {
|
|
const row = selectedRow();
|
|
if (!row) {
|
|
return;
|
|
}
|
|
void runActionWithRefresh(`archiving ${row.handoffId}`, async () => client.runAction(workspaceId, row.handoffId, "archive"), `archived ${row.handoffId}`);
|
|
return;
|
|
}
|
|
|
|
if (ctrl && name === "s") {
|
|
const row = selectedRow();
|
|
if (!row) {
|
|
return;
|
|
}
|
|
void runActionWithRefresh(`syncing ${row.handoffId}`, async () => client.runAction(workspaceId, row.handoffId, "sync"), `synced ${row.handoffId}`);
|
|
return;
|
|
}
|
|
|
|
if (ctrl && name === "y") {
|
|
const row = selectedRow();
|
|
if (!row) {
|
|
return;
|
|
}
|
|
void runActionWithRefresh(
|
|
`merging ${row.handoffId}`,
|
|
async () => {
|
|
await client.runAction(workspaceId, row.handoffId, "merge");
|
|
await client.runAction(workspaceId, row.handoffId, "archive");
|
|
},
|
|
`merged+archived ${row.handoffId}`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (ctrl && name === "o") {
|
|
const row = selectedRow();
|
|
if (!row?.prUrl) {
|
|
status = "no PR URL available for this handoff";
|
|
render();
|
|
return;
|
|
}
|
|
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
|
|
spawnSync(openCmd, [row.prUrl], { stdio: "ignore" });
|
|
status = `opened ${row.prUrl}`;
|
|
render();
|
|
return;
|
|
}
|
|
|
|
if (!ctrl && !event.meta && name.length === 1) {
|
|
searchQuery += name;
|
|
filteredRows = filterHandoffs(allRows, searchQuery);
|
|
selected = 0;
|
|
render();
|
|
}
|
|
});
|
|
|
|
await done;
|
|
}
|