Files
OpenQode/bin/ui/components/CleanTodoList.mjs

157 lines
4.4 KiB
JavaScript

/**
* Clean TODO Checklist Component
*
* Based on sst/opencode todowrite rendering
* Credit: https://github.com/sst/opencode
*
* Clean [ ]/[x] checklist with status highlighting
*/
import React from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../tui-theme.mjs';
import { getCapabilities } from '../../terminal-profile.mjs';
const h = React.createElement;
// TODO status types
export const TODO_STATUS = {
PENDING: 'pending',
IN_PROGRESS: 'in_progress',
DONE: 'done'
};
/**
* Single TODO item
*/
const TodoItem = ({ text, status = TODO_STATUS.PENDING, width = 80 }) => {
const caps = getCapabilities();
// Checkbox
const checkbox = status === TODO_STATUS.DONE
? '[x]'
: status === TODO_STATUS.IN_PROGRESS
? '[/]'
: '[ ]';
// Status-based styling
const textColor = status === TODO_STATUS.DONE
? colors.muted
: status === TODO_STATUS.IN_PROGRESS
? colors.success
: colors.fg;
const isDim = status === TODO_STATUS.DONE;
// Truncate text if needed
const maxTextWidth = width - 5;
const displayText = text.length > maxTextWidth
? text.slice(0, maxTextWidth - 1) + '…'
: text;
return h(Box, { flexDirection: 'row' },
h(Text, {
color: status === TODO_STATUS.IN_PROGRESS ? colors.success : colors.muted
}, checkbox + ' '),
h(Text, {
color: textColor,
dimColor: isDim,
strikethrough: status === TODO_STATUS.DONE
}, displayText)
);
};
/**
* Clean TODO List - OpenCode style
*
* Props:
* - items: array of { text, status }
* - isExpanded: show full list or summary
* - width: available width
*/
const CleanTodoList = ({
items = [],
isExpanded = false,
title = 'Tasks',
width = 80
}) => {
const caps = getCapabilities();
// Count stats
const total = items.length;
const done = items.filter(i => i.status === TODO_STATUS.DONE).length;
const inProgress = items.filter(i => i.status === TODO_STATUS.IN_PROGRESS).length;
// Summary line
const summaryText = `${done}/${total} done`;
const progressIcon = caps.unicodeOK ? '▰' : '#';
const emptyIcon = caps.unicodeOK ? '▱' : '-';
// Progress bar (visual)
const progressWidth = Math.min(10, width - 20);
const filledCount = total > 0 ? Math.round((done / total) * progressWidth) : 0;
const progressBar = progressIcon.repeat(filledCount) + emptyIcon.repeat(progressWidth - filledCount);
// Collapsed view: just summary
if (!isExpanded && total > 3) {
return h(Box, { flexDirection: 'column' },
h(Box, { flexDirection: 'row' },
h(Text, { color: colors.muted, bold: true }, title + ': '),
h(Text, { color: colors.accent }, progressBar),
h(Text, { color: colors.muted }, ` ${summaryText}`)
),
// Show in-progress items even when collapsed
...items
.filter(i => i.status === TODO_STATUS.IN_PROGRESS)
.slice(0, 2)
.map((item, i) => h(TodoItem, {
key: i,
text: item.text,
status: item.status,
width
}))
);
}
// Expanded view: full list
return h(Box, { flexDirection: 'column' },
// Header
h(Box, { flexDirection: 'row', marginBottom: 0 },
h(Text, { color: colors.muted, bold: true }, title + ' '),
h(Text, { color: colors.accent }, progressBar),
h(Text, { color: colors.muted }, ` ${summaryText}`)
),
// Items
...items.map((item, i) => h(TodoItem, {
key: i,
text: item.text,
status: item.status,
width
}))
);
};
/**
* Convert legacy todo format to clean format
*/
function normalizeTodos(todos) {
if (!Array.isArray(todos)) return [];
return todos.map(todo => {
// Handle string items
if (typeof todo === 'string') {
return { text: todo, status: TODO_STATUS.PENDING };
}
// Handle object items
return {
text: todo.text || todo.content || todo.title || String(todo),
status: todo.status || (todo.done ? TODO_STATUS.DONE : TODO_STATUS.PENDING)
};
});
}
export default CleanTodoList;
export { CleanTodoList, TodoItem, normalizeTodos };