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
613 changes: 609 additions & 4 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,14 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.2.14",
"commander": "^12.1.0",
"ink": "^6.8.0",
"ink-text-input": "^6.0.0",
"js-yaml": "^4.1.1",
"ora": "^8.1.1",
"picocolors": "^1.1.1",
"react": "^19.2.4",
"yaml": "^2.6.1"
},
"devDependencies": {
Expand Down
28 changes: 28 additions & 0 deletions src/commands/board.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Command } from 'commander';
import React from 'react';
import { render } from 'ink';

import { Board } from '../components/Board.js';
import { findProjectRoot } from '../lib/config.js';
import * as logger from '../lib/logger.js';

function fatalAndExit(message: string): never {
logger.fatal(message);
process.exit(1);
}

export async function runBoard(): Promise<void> {
const projectRoot = findProjectRoot();
if (!projectRoot) {
fatalAndExit('Not inside a vexdo project.');
}

const instance = render(<Board projectRoot={projectRoot} />);
await instance.waitUntilExit();
}

export function registerBoardCommand(program: Command): void {
program.command('board').description('Open interactive task board').action(() => {
void runBoard();
});
}
319 changes: 319 additions & 0 deletions src/components/Board.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
import { spawnSync } from 'node:child_process';

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Text, useApp, useInput } from 'ink';

import { loadBoardState, type BoardState, type TaskSummary } from '../lib/board.js';

interface BoardProps {
projectRoot: string;
}

type ColumnKey = 'backlog' | 'in_progress' | 'review' | 'done';

interface ColumnDefinition {
key: ColumnKey;
title: string;
tasks: TaskSummary[];
}

function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(value, max));
}

function runExternal(command: string, args: string[]): { ok: boolean; message: string } {
const wasRaw = process.stdin.isTTY && process.stdin.isRaw;
if (process.stdin.isTTY && wasRaw) {
process.stdin.setRawMode(false);
}

const result = spawnSync(command, args, { stdio: 'inherit' });

if (process.stdin.isTTY && wasRaw) {
process.stdin.setRawMode(true);
}

if (result.error) {
return {
ok: false,
message: result.error.message,
};
}

return {
ok: result.status === 0,
message: result.status === 0 ? 'Command completed.' : `Command exited with status ${String(result.status)}.`,
};
}

function openInEditor(filePath: string): { ok: boolean; message: string } {
const editor = process.env.EDITOR;
if (!editor) {
return { ok: false, message: 'EDITOR is not set.' };
}

return runExternal(editor, [filePath]);
}

function runPrimaryAction(column: ColumnKey, task: TaskSummary): { ok: boolean; message: string } {
if (task.blocked) {
return runExternal('vexdo', ['logs', task.id]);
}

if (column === 'backlog') {
return runExternal('vexdo', ['start', task.path]);
}

if (column === 'in_progress') {
return runExternal('vexdo', ['status']);
}

if (column === 'review') {
return runExternal('vexdo', ['submit']);
}

return openInEditor(task.path);
}

function TaskCard({ task, selected }: { task: TaskSummary; selected: boolean }): React.JSX.Element {
const prefix = task.blocked ? '⚠ ' : '';
return <Text>{`${selected ? '> ' : ' '}${prefix}${task.id}`}</Text>;
}

function Column({
title,
count,
tasks,
selectedRow,
active,
}: {
title: string;
count: number;
tasks: TaskSummary[];
selectedRow: number;
active: boolean;
}): React.JSX.Element {
return (
<Box flexDirection="column" flexGrow={1} borderStyle="single" paddingX={1}>
<Text color={active ? 'cyan' : undefined}>{title}</Text>
<Text dimColor>{`(${String(count)})`}</Text>
<Box flexDirection="column" marginTop={1}>
{tasks.length === 0 ? <Text dimColor> —</Text> : null}
{tasks.map((task, index) => (
<TaskCard key={`${task.path}-${task.id}`} task={task} selected={active && index === selectedRow} />
))}
</Box>
</Box>
);
}

function StatusBar({
task,
column,
message,
confirmAbort,
}: {
task: TaskSummary | undefined;
column: ColumnKey;
message: string | null;
confirmAbort: boolean;
}): React.JSX.Element {
if (!task) {
return (
<Box flexDirection="column" borderStyle="single" paddingX={1}>
<Text dimColor>No task selected.</Text>
<Text dimColor>[←/→/↑/↓] navigate [r] refresh [q] quit</Text>
</Box>
);
}

const primaryLabel = task.blocked
? '[↵] logs'
: column === 'backlog'
? '[↵] start'
: column === 'in_progress'
? '[↵] status'
: column === 'review'
? '[↵] submit'
: '[↵] edit';

return (
<Box flexDirection="column" borderStyle="single" paddingX={1}>
<Text>{`${task.id} · ${task.title}`}</Text>
<Text>
{`${primaryLabel} [e] edit [l] logs [a] abort [r] refresh [q] quit${confirmAbort ? ' Confirm abort: press [a] again' : ''}`}
</Text>
{message ? <Text color="yellow">{message}</Text> : null}
</Box>
);
}

export function Board({ projectRoot }: BoardProps): React.JSX.Element {
const { exit } = useApp();
const [state, setState] = useState<BoardState | null>(null);
const [loading, setLoading] = useState(true);
const [cursor, setCursor] = useState({ column: 0, row: 0 });
const [message, setMessage] = useState<string | null>(null);
const [confirmAbort, setConfirmAbort] = useState(false);

const refresh = useCallback(async () => {
setLoading(true);
try {
const next = await loadBoardState(projectRoot);
setState(next);
setMessage(null);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
setMessage(`Failed to load board: ${errorMessage}`);
} finally {
setLoading(false);
}
}, [projectRoot]);

useEffect(() => {
void refresh();
}, [refresh]);

const columns = useMemo<ColumnDefinition[]>(() => {
if (!state) {
return [
{ key: 'backlog', title: 'BACKLOG', tasks: [] },
{ key: 'in_progress', title: 'IN PROGRESS', tasks: [] },
{ key: 'review', title: 'REVIEW', tasks: [] },
{ key: 'done', title: 'DONE', tasks: [] },
];
}

return [
{ key: 'backlog', title: 'BACKLOG', tasks: [...state.blocked, ...state.backlog] },
{ key: 'in_progress', title: 'IN PROGRESS', tasks: state.in_progress },
{ key: 'review', title: 'REVIEW', tasks: state.review },
{ key: 'done', title: 'DONE', tasks: state.done },
];
}, [state]);

const activeColumn = columns[cursor.column] ?? columns[0];
const selectedTask = activeColumn.tasks.at(cursor.row);

useEffect(() => {
const nextColumn = clamp(cursor.column, 0, columns.length - 1);
const maxRow = Math.max(0, (columns[nextColumn]?.tasks.length ?? 1) - 1);
const nextRow = clamp(cursor.row, 0, maxRow);

if (nextColumn !== cursor.column || nextRow !== cursor.row) {
setCursor({ column: nextColumn, row: nextRow });
}
}, [columns, cursor.column, cursor.row]);

useInput((input, key) => {
if (input === 'q' || (key.ctrl && input === 'c')) {
exit();
return;
}

if (key.leftArrow) {
setConfirmAbort(false);
setCursor((prev) => {
const nextColumn = clamp(prev.column - 1, 0, columns.length - 1);
const maxRow = Math.max(0, columns[nextColumn].tasks.length - 1);
return { column: nextColumn, row: clamp(prev.row, 0, maxRow) };
});
return;
}

if (key.rightArrow) {
setConfirmAbort(false);
setCursor((prev) => {
const nextColumn = clamp(prev.column + 1, 0, columns.length - 1);
const maxRow = Math.max(0, columns[nextColumn].tasks.length - 1);
return { column: nextColumn, row: clamp(prev.row, 0, maxRow) };
});
return;
}

if (key.upArrow) {
setConfirmAbort(false);
setCursor((prev) => ({ ...prev, row: clamp(prev.row - 1, 0, Math.max(0, activeColumn.tasks.length - 1)) }));
return;
}

if (key.downArrow) {
setConfirmAbort(false);
setCursor((prev) => ({ ...prev, row: clamp(prev.row + 1, 0, Math.max(0, activeColumn.tasks.length - 1)) }));
return;
}

if (input === 'r') {
setConfirmAbort(false);
void refresh();
return;
}

if (key.return && selectedTask) {
const result = runPrimaryAction(activeColumn.key, selectedTask);
setMessage(result.message);
setConfirmAbort(false);
void refresh();
return;
}

if (input === 'e' && selectedTask) {
const result = openInEditor(selectedTask.path);
setMessage(result.message);
setConfirmAbort(false);
return;
}

if (input === 'l' && selectedTask) {
const result = runExternal('vexdo', ['logs', selectedTask.id]);
setMessage(result.message);
setConfirmAbort(false);
return;
}

if (input === 'a' && selectedTask) {
if (!confirmAbort) {
setConfirmAbort(true);
setMessage(`Confirm abort task '${selectedTask.id}' by pressing 'a' again.`);
return;
}

const result = runExternal('vexdo', ['abort']);
setMessage(result.message);
setConfirmAbort(false);
void refresh();
}
});

const terminalRows = typeof process.stdout.rows === 'number' ? process.stdout.rows : 24;

return (
<Box flexDirection="column" height={terminalRows}>
<Box justifyContent="space-between" borderStyle="single" paddingX={1}>
<Text>vexdo board</Text>
<Text>[q] quit</Text>
</Box>

{loading ? (
<Box borderStyle="single" paddingX={1}>
<Text>Loading tasks…</Text>
</Box>
) : (
<Box flexGrow={1}>
{columns.map((column, index) => (
<Column
key={column.key}
title={column.title}
count={column.tasks.length}
tasks={column.tasks}
selectedRow={cursor.row}
active={index === cursor.column}
/>
))}
</Box>
)}

<StatusBar task={selectedTask} column={activeColumn.key} message={message} confirmAbort={confirmAbort} />
</Box>
);
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'node:path';
import { Command } from 'commander';

import { registerAbortCommand } from './commands/abort.js';
import { registerBoardCommand } from './commands/board.js';
import { registerFixCommand } from './commands/fix.js';
import { registerInitCommand } from './commands/init.js';
import { registerLogsCommand } from './commands/logs.js';
Expand Down Expand Up @@ -38,5 +39,6 @@ registerSubmitCommand(program);
registerStatusCommand(program);
registerAbortCommand(program);
registerLogsCommand(program);
registerBoardCommand(program);

program.parse(process.argv);
Loading
Loading