kanbango 3.6.2 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +25 -18
- package/API.md +58 -170
- package/CHANGELOG.md +76 -0
- package/LLM_AGENTS.md +21 -502
- package/README.md +45 -12
- package/agent-playbook.js +27 -49
- package/bin/kanban.js +395 -300
- package/config-wizard.js +441 -0
- package/index.html +124 -36
- package/kanban.js +892 -263
- package/mcp-server.js +319 -196
- package/package.json +1 -1
- package/plan.js +39 -40
- package/plugins/tui-kanban-controller.js +117 -0
- package/plugins/tui-kanban.tsx +314 -0
- package/workflow.js +368 -21
- package/.ai/lessons.jsonl +0 -9
- package/.ai/retro/close.json +0 -1
- package/.ai/retro/last-run.json +0 -1
- package/.ait-quality.yml +0 -25
- package/bin/kanban-cmd.js +0 -40
- package/kan2.md +0 -76
- package/kanbango.md +0 -48
- package/planv2.md +0 -317
- package/tests/fixtures/fake-opencode.js +0 -69
- package/tests/index.js +0 -19
- package/tests/kanban-cli.js +0 -118
- package/tests/kanban.js +0 -104
- package/tests/run.js +0 -33
package/package.json
CHANGED
package/plan.js
CHANGED
|
@@ -55,17 +55,13 @@ async function detectTestRunner(projectRoot = process.cwd()) {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
'Add a supported project manifest or set OPENCODE_TEST_COMMAND', { project_root: projectRoot });
|
|
58
|
+
return null;
|
|
60
59
|
}
|
|
61
60
|
|
|
62
61
|
function planSubtasks(implementationSteps) {
|
|
63
|
-
const steps =
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
...implementationSteps,
|
|
67
|
-
'Run tests and confirm green'
|
|
68
|
-
];
|
|
62
|
+
const steps = Array.isArray(implementationSteps)
|
|
63
|
+
? implementationSteps.filter(Boolean).map(String)
|
|
64
|
+
: [];
|
|
69
65
|
return steps.map((text, index) => ({ id: `st-${index + 1}`, text, done: false, description: '' }));
|
|
70
66
|
}
|
|
71
67
|
|
|
@@ -93,6 +89,8 @@ async function create(payload = {}) {
|
|
|
93
89
|
test_cases: payload.test_cases,
|
|
94
90
|
subtasks: planSubtasks(implementationSteps),
|
|
95
91
|
notes: payload.notes,
|
|
92
|
+
depends_on: payload.depends_on,
|
|
93
|
+
files: payload.files,
|
|
96
94
|
plan: { runner, status: 'active' },
|
|
97
95
|
evidence: []
|
|
98
96
|
});
|
|
@@ -100,44 +98,41 @@ async function create(payload = {}) {
|
|
|
100
98
|
}
|
|
101
99
|
|
|
102
100
|
async function advance(payload = {}) {
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
:
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
'Provide the zero-based index of an incomplete subtask', { index, total_subtasks: task.subtasks.length });
|
|
109
|
-
}
|
|
110
|
-
const subtasks = task.subtasks.map((subtask, subtaskIndex) => ({
|
|
111
|
-
...subtask,
|
|
112
|
-
done: subtaskIndex === index ? true : subtask.done
|
|
113
|
-
}));
|
|
114
|
-
const updated = await kanban.updateTask(task.id, { subtasks });
|
|
101
|
+
const hasIndex = payload.index !== undefined && payload.index !== null && payload.index !== '';
|
|
102
|
+
const patch = hasIndex
|
|
103
|
+
? { mark_subtask_done: Number(payload.index) }
|
|
104
|
+
: { mark_subtask_done: null };
|
|
105
|
+
const updated = await kanban.updateTask(payload.task_id, patch);
|
|
115
106
|
return result(updated, { current_step: updated.subtasks.findIndex((subtask) => !subtask.done) });
|
|
116
107
|
}
|
|
117
108
|
|
|
118
109
|
async function evidence(payload = {}) {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
110
|
+
const diff = payload.diff !== undefined ? String(payload.diff) : '';
|
|
111
|
+
const summary = payload.summary !== undefined ? String(payload.summary) : '';
|
|
112
|
+
const testCommand = payload.test_command !== undefined ? String(payload.test_command) : '';
|
|
113
|
+
if (!diff && !summary && !testCommand) {
|
|
114
|
+
throw planError('MISSING_REQUIRED_FIELD', 'diff, summary, or test_command is required',
|
|
115
|
+
'Provide at least one of diff, summary, or test_command', { field: 'diff' });
|
|
125
116
|
}
|
|
126
|
-
if (!Number.isInteger(payload.exit_code)) {
|
|
117
|
+
if (payload.exit_code !== undefined && !Number.isInteger(payload.exit_code)) {
|
|
127
118
|
throw planError('VALIDATION_ERROR', 'exit_code must be an integer',
|
|
128
|
-
'Use the process exit code from the test command', { field: 'exit_code' });
|
|
119
|
+
'Use the process exit code from the test command, or omit exit_code', { field: 'exit_code' });
|
|
129
120
|
}
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
121
|
+
const patch = {
|
|
122
|
+
appendEvidence: {
|
|
123
|
+
diff,
|
|
124
|
+
test_command: testCommand,
|
|
125
|
+
stdout: payload.stdout !== undefined ? String(payload.stdout) : '',
|
|
126
|
+
stderr: payload.stderr !== undefined ? String(payload.stderr) : '',
|
|
127
|
+
exit_code: Number.isInteger(payload.exit_code) ? payload.exit_code : null,
|
|
128
|
+
summary,
|
|
129
|
+
created: new Date().toISOString()
|
|
130
|
+
}
|
|
138
131
|
};
|
|
139
|
-
|
|
140
|
-
|
|
132
|
+
if (payload.files !== undefined) patch.files = payload.files;
|
|
133
|
+
const updated = await kanban.updateTask(payload.task_id, patch);
|
|
134
|
+
const evidenceList = updated.evidence || [];
|
|
135
|
+
return result(updated, { evidence: evidenceList[evidenceList.length - 1] });
|
|
141
136
|
}
|
|
142
137
|
|
|
143
138
|
async function done(payload = {}) {
|
|
@@ -147,11 +142,15 @@ async function done(payload = {}) {
|
|
|
147
142
|
throw planError('PLAN_INCOMPLETE', 'Plan has incomplete subtasks',
|
|
148
143
|
'Advance every plan step before marking the workflow done', { incomplete });
|
|
149
144
|
}
|
|
145
|
+
const workflow = require('./workflow.js');
|
|
146
|
+
const targetColumn = await workflow.planDoneColumn();
|
|
150
147
|
const updated = await kanban.updateTask(task.id, {
|
|
151
|
-
column:
|
|
148
|
+
column: targetColumn,
|
|
152
149
|
plan: { ...(task.plan || {}), status: 'done' }
|
|
153
150
|
});
|
|
154
|
-
|
|
151
|
+
const extra = { status: 'done', column: updated.column };
|
|
152
|
+
if (Array.isArray(updated.unblocked_tasks)) extra.unblocked_tasks = updated.unblocked_tasks;
|
|
153
|
+
return result(updated, extra);
|
|
155
154
|
}
|
|
156
155
|
|
|
157
156
|
async function status(taskId) {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
const LIST_COLUMNS = ['active', 'planned', 'icebox'];
|
|
4
|
+
|
|
5
|
+
const kanbanPalette = {
|
|
6
|
+
commandName: 'kanban.open',
|
|
7
|
+
title: 'Kanban',
|
|
8
|
+
desc: 'Browse board and paste a start prompt',
|
|
9
|
+
category: 'Kanban',
|
|
10
|
+
slashName: 'kanban',
|
|
11
|
+
shortcut: 'ctrl+alt+k'
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function defaultKanban() {
|
|
15
|
+
return require(path.join(__dirname, '..', 'kanban.js'));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatEpicLabel(epic) {
|
|
19
|
+
if (!epic || !epic.id) return '—';
|
|
20
|
+
const title = typeof epic.title === 'string' ? epic.title.trim() : '';
|
|
21
|
+
return title ? `${epic.id}/${title}` : String(epic.id);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function formatStartPrompt(task, epic) {
|
|
25
|
+
const id = task && task.id != null ? String(task.id) : '';
|
|
26
|
+
const title = task && task.title != null ? String(task.title) : '';
|
|
27
|
+
return `Zacznij implementować zadanie #${id}: ${title} z epiku ${formatEpicLabel(epic)}.`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function epicLookup(epics) {
|
|
31
|
+
const byId = new Map();
|
|
32
|
+
for (const epic of epics || []) {
|
|
33
|
+
if (epic && epic.id) byId.set(epic.id, epic);
|
|
34
|
+
}
|
|
35
|
+
return byId;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function shapeRow(task, epicsById, allTasks, unmetDependencies) {
|
|
39
|
+
const unmet = unmetDependencies(task, allTasks) || [];
|
|
40
|
+
const epic = task.epic_id ? epicsById.get(task.epic_id) : null;
|
|
41
|
+
return {
|
|
42
|
+
id: task.id,
|
|
43
|
+
title: task.title || '',
|
|
44
|
+
column: task.column,
|
|
45
|
+
epic_id: task.epic_id || '',
|
|
46
|
+
epicTitle: epic && epic.title ? epic.title : '',
|
|
47
|
+
epicLabel: formatEpicLabel(epic || (task.epic_id ? { id: task.epic_id } : null)),
|
|
48
|
+
blocked: unmet.length > 0,
|
|
49
|
+
unmet_dependencies: unmet,
|
|
50
|
+
description: task.description || '',
|
|
51
|
+
acceptance_criteria: Array.isArray(task.acceptance_criteria) ? task.acceptance_criteria : [],
|
|
52
|
+
depends_on: Array.isArray(task.depends_on) ? task.depends_on : []
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function toBoardRows(tasks, epics, helpers = {}) {
|
|
57
|
+
const filterTasksForList = helpers.filterTasksForList || ((list) => list);
|
|
58
|
+
const unmetDependencies = helpers.unmetDependencies || (() => []);
|
|
59
|
+
const filtered = filterTasksForList(tasks || [], epics || [], {});
|
|
60
|
+
const epicsById = epicLookup(epics);
|
|
61
|
+
const byColumn = new Map(LIST_COLUMNS.map((column) => [column, []]));
|
|
62
|
+
for (const task of filtered) {
|
|
63
|
+
if (!task || !byColumn.has(task.column)) continue;
|
|
64
|
+
byColumn.get(task.column).push(shapeRow(task, epicsById, tasks, unmetDependencies));
|
|
65
|
+
}
|
|
66
|
+
return LIST_COLUMNS.flatMap((column) => byColumn.get(column));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function loadBoard(options = {}) {
|
|
70
|
+
const kanban = options.kanban || defaultKanban();
|
|
71
|
+
const cwd = options.cwd;
|
|
72
|
+
const previous = process.cwd();
|
|
73
|
+
let changed = false;
|
|
74
|
+
try {
|
|
75
|
+
if (cwd && cwd !== previous) {
|
|
76
|
+
process.chdir(cwd);
|
|
77
|
+
changed = true;
|
|
78
|
+
}
|
|
79
|
+
const tasks = await kanban.allTasks();
|
|
80
|
+
const epics = await kanban.listEpicEntities();
|
|
81
|
+
return {
|
|
82
|
+
rows: toBoardRows(tasks, epics, {
|
|
83
|
+
filterTasksForList: kanban.filterTasksForList,
|
|
84
|
+
unmetDependencies: kanban.unmetDependencies
|
|
85
|
+
}),
|
|
86
|
+
tasks,
|
|
87
|
+
epics
|
|
88
|
+
};
|
|
89
|
+
} finally {
|
|
90
|
+
if (changed) process.chdir(previous);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function moveSelection(index, delta, length) {
|
|
95
|
+
if (!length || length <= 0) return 0;
|
|
96
|
+
const next = Number(index || 0) + Number(delta || 0);
|
|
97
|
+
if (next < 0) return 0;
|
|
98
|
+
if (next >= length) return length - 1;
|
|
99
|
+
return next;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function selectedRow(rows, index) {
|
|
103
|
+
if (!Array.isArray(rows) || rows.length === 0) return null;
|
|
104
|
+
if (index < 0 || index >= rows.length) return null;
|
|
105
|
+
return rows[index];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = {
|
|
109
|
+
LIST_COLUMNS,
|
|
110
|
+
kanbanPalette,
|
|
111
|
+
formatEpicLabel,
|
|
112
|
+
formatStartPrompt,
|
|
113
|
+
toBoardRows,
|
|
114
|
+
loadBoard,
|
|
115
|
+
moveSelection,
|
|
116
|
+
selectedRow
|
|
117
|
+
};
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiThemeCurrent } from '@opencode-ai/plugin/tui';
|
|
4
|
+
import { createSignal, For, Show, type Accessor } from 'solid-js';
|
|
5
|
+
|
|
6
|
+
type BoardRow = {
|
|
7
|
+
id: string;
|
|
8
|
+
title: string;
|
|
9
|
+
column: string;
|
|
10
|
+
epic_id: string;
|
|
11
|
+
epicTitle: string;
|
|
12
|
+
epicLabel: string;
|
|
13
|
+
blocked: boolean;
|
|
14
|
+
unmet_dependencies: Array<{ id: string }>;
|
|
15
|
+
description: string;
|
|
16
|
+
acceptance_criteria: string[];
|
|
17
|
+
depends_on: string[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const requireController = createRequire(import.meta.url);
|
|
21
|
+
const controller = requireController('./tui-kanban-controller.js') as {
|
|
22
|
+
kanbanPalette: {
|
|
23
|
+
commandName: string;
|
|
24
|
+
title: string;
|
|
25
|
+
desc: string;
|
|
26
|
+
category: string;
|
|
27
|
+
slashName: string;
|
|
28
|
+
shortcut: string;
|
|
29
|
+
};
|
|
30
|
+
formatStartPrompt: (
|
|
31
|
+
task: { id: string; title: string },
|
|
32
|
+
epic: { id?: string; title?: string } | null
|
|
33
|
+
) => string;
|
|
34
|
+
loadBoard: (options?: { cwd?: string }) => Promise<{ rows: BoardRow[] }>;
|
|
35
|
+
moveSelection: (index: number, delta: number, length: number) => number;
|
|
36
|
+
selectedRow: (rows: BoardRow[], index: number) => BoardRow | null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const id = 'kanbango.tui';
|
|
40
|
+
type TuiApi = TuiPluginApi;
|
|
41
|
+
type Theme = TuiThemeCurrent;
|
|
42
|
+
|
|
43
|
+
type View =
|
|
44
|
+
| { status: 'loading' }
|
|
45
|
+
| { status: 'error'; message: string }
|
|
46
|
+
| { status: 'ready'; rows: BoardRow[]; selected: number };
|
|
47
|
+
|
|
48
|
+
type DialogCtrl = {
|
|
49
|
+
view: Accessor<View>;
|
|
50
|
+
reload: () => Promise<void>;
|
|
51
|
+
move: (delta: number) => void;
|
|
52
|
+
confirm: () => Promise<void>;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
let dialogOpen = false;
|
|
56
|
+
let dialogCtrl: DialogCtrl | null = null;
|
|
57
|
+
|
|
58
|
+
function closeKanbanDialog(api: TuiApi) {
|
|
59
|
+
dialogOpen = false;
|
|
60
|
+
dialogCtrl = null;
|
|
61
|
+
api.ui.dialog.clear();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function createDialogController(api: TuiApi): DialogCtrl {
|
|
65
|
+
const [view, setView] = createSignal<View>({ status: 'loading' });
|
|
66
|
+
|
|
67
|
+
const reload = async () => {
|
|
68
|
+
setView({ status: 'loading' });
|
|
69
|
+
try {
|
|
70
|
+
const board = await controller.loadBoard({ cwd: api.state.path.directory });
|
|
71
|
+
setView({
|
|
72
|
+
status: 'ready',
|
|
73
|
+
rows: board.rows,
|
|
74
|
+
selected: controller.moveSelection(0, 0, board.rows.length)
|
|
75
|
+
});
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
78
|
+
setView({ status: 'error', message });
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const move = (delta: number) => {
|
|
83
|
+
setView((current) => {
|
|
84
|
+
if (current.status !== 'ready') return current;
|
|
85
|
+
return {
|
|
86
|
+
...current,
|
|
87
|
+
selected: controller.moveSelection(current.selected, delta, current.rows.length)
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const confirm = async () => {
|
|
93
|
+
const current = view();
|
|
94
|
+
if (current.status !== 'ready') return;
|
|
95
|
+
const row = controller.selectedRow(current.rows, current.selected);
|
|
96
|
+
if (!row) return;
|
|
97
|
+
const epic = row.epic_id ? { id: row.epic_id, title: row.epicTitle } : null;
|
|
98
|
+
const text = controller.formatStartPrompt(row, epic);
|
|
99
|
+
await api.client.tui.appendPrompt({ text });
|
|
100
|
+
closeKanbanDialog(api);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return { view, reload, move, confirm };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function openKanbanDialog(api: TuiApi) {
|
|
107
|
+
const ctrl = createDialogController(api);
|
|
108
|
+
dialogCtrl = ctrl;
|
|
109
|
+
dialogOpen = true;
|
|
110
|
+
api.ui.dialog.setSize('large');
|
|
111
|
+
api.ui.dialog.replace(
|
|
112
|
+
() => <KanbanDialog api={api} ctrl={ctrl} />,
|
|
113
|
+
() => {
|
|
114
|
+
dialogOpen = false;
|
|
115
|
+
if (dialogCtrl === ctrl) dialogCtrl = null;
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
void ctrl.reload();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function registerPalette(api: TuiApi) {
|
|
122
|
+
api.keymap.registerLayer({
|
|
123
|
+
commands: [
|
|
124
|
+
{
|
|
125
|
+
name: controller.kanbanPalette.commandName,
|
|
126
|
+
title: controller.kanbanPalette.title,
|
|
127
|
+
desc: controller.kanbanPalette.desc,
|
|
128
|
+
category: controller.kanbanPalette.category,
|
|
129
|
+
namespace: 'palette',
|
|
130
|
+
slashName: controller.kanbanPalette.slashName,
|
|
131
|
+
run() {
|
|
132
|
+
openKanbanDialog(api);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
]
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function registerShortcut(api: TuiApi) {
|
|
140
|
+
api.keymap.registerLayer({
|
|
141
|
+
enabled: () => !dialogOpen,
|
|
142
|
+
bindings: [
|
|
143
|
+
{
|
|
144
|
+
key: controller.kanbanPalette.shortcut,
|
|
145
|
+
cmd: () => openKanbanDialog(api),
|
|
146
|
+
desc: controller.kanbanPalette.title
|
|
147
|
+
}
|
|
148
|
+
]
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function registerDialogKeys(api: TuiApi) {
|
|
153
|
+
api.keymap.registerLayer({
|
|
154
|
+
enabled: () => dialogOpen && api.ui.dialog.open,
|
|
155
|
+
bindings: [
|
|
156
|
+
{ key: 'up', cmd: () => dialogCtrl?.move(-1), desc: 'Previous task' },
|
|
157
|
+
{ key: 'down', cmd: () => dialogCtrl?.move(1), desc: 'Next task' },
|
|
158
|
+
{ key: 'k', cmd: () => dialogCtrl?.move(-1), desc: 'Previous task' },
|
|
159
|
+
{ key: 'j', cmd: () => dialogCtrl?.move(1), desc: 'Next task' },
|
|
160
|
+
{ key: 'return', cmd: () => void dialogCtrl?.confirm(), desc: 'Paste start prompt' },
|
|
161
|
+
{ key: 'escape', cmd: () => closeKanbanDialog(api), desc: 'Close' }
|
|
162
|
+
]
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const tui: TuiPlugin = async (api) => {
|
|
167
|
+
registerPalette(api);
|
|
168
|
+
registerShortcut(api);
|
|
169
|
+
registerDialogKeys(api);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
function KanbanDialog(props: { api: TuiApi; ctrl: DialogCtrl }) {
|
|
173
|
+
const theme = () => props.api.theme.current as Theme;
|
|
174
|
+
const state = () => props.ctrl.view();
|
|
175
|
+
const selected = () => {
|
|
176
|
+
const current = state();
|
|
177
|
+
if (current.status !== 'ready') return null;
|
|
178
|
+
return controller.selectedRow(current.rows, current.selected);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<box flexDirection="column" width="100%" height="100%" padding={1} gap={1}>
|
|
183
|
+
<text>
|
|
184
|
+
<span style={{ fg: theme().primary, bold: true }}>Kanban</span>
|
|
185
|
+
<span style={{ fg: theme().textMuted }}> · Enter paste start · Esc close</span>
|
|
186
|
+
</text>
|
|
187
|
+
<Show when={state().status === 'loading'}>
|
|
188
|
+
<text fg={theme().textMuted}>Loading board…</text>
|
|
189
|
+
</Show>
|
|
190
|
+
<Show when={state().status === 'error'}>
|
|
191
|
+
<text fg={theme().error}>{(state() as { message: string }).message}</text>
|
|
192
|
+
</Show>
|
|
193
|
+
<Show when={state().status === 'ready'}>
|
|
194
|
+
<box flexDirection="row" width="100%" height="100%" gap={1} flexGrow={1}>
|
|
195
|
+
<TaskList
|
|
196
|
+
rows={(state() as Extract<View, { status: 'ready' }>).rows}
|
|
197
|
+
selected={(state() as Extract<View, { status: 'ready' }>).selected}
|
|
198
|
+
theme={theme()}
|
|
199
|
+
/>
|
|
200
|
+
<DetailPanel row={selected()} theme={theme()} />
|
|
201
|
+
</box>
|
|
202
|
+
</Show>
|
|
203
|
+
</box>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function groupedRows(rows: BoardRow[]) {
|
|
208
|
+
const groups: Array<{ column: string; items: Array<{ row: BoardRow; index: number }> }> = [];
|
|
209
|
+
for (let index = 0; index < rows.length; index++) {
|
|
210
|
+
const row = rows[index];
|
|
211
|
+
const last = groups[groups.length - 1];
|
|
212
|
+
if (!last || last.column !== row.column) {
|
|
213
|
+
groups.push({ column: row.column, items: [{ row, index }] });
|
|
214
|
+
} else {
|
|
215
|
+
last.items.push({ row, index });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return groups;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function TaskList(props: { rows: BoardRow[]; selected: number; theme: Theme }) {
|
|
222
|
+
const groups = () => groupedRows(props.rows);
|
|
223
|
+
return (
|
|
224
|
+
<box
|
|
225
|
+
flexDirection="column"
|
|
226
|
+
flexGrow={1}
|
|
227
|
+
width="50%"
|
|
228
|
+
height="100%"
|
|
229
|
+
border={true}
|
|
230
|
+
borderColor={props.theme.border}
|
|
231
|
+
padding={1}
|
|
232
|
+
title=" tasks "
|
|
233
|
+
titleAlignment="left"
|
|
234
|
+
>
|
|
235
|
+
<Show when={props.rows.length > 0} fallback={<text fg={props.theme.textMuted}>No open tasks</text>}>
|
|
236
|
+
<For each={groups()}>
|
|
237
|
+
{(group) => (
|
|
238
|
+
<box flexDirection="column" gap={0}>
|
|
239
|
+
<text>
|
|
240
|
+
<span style={{ fg: props.theme.accent ?? props.theme.primary, bold: true }}>
|
|
241
|
+
{group.column}
|
|
242
|
+
</span>
|
|
243
|
+
</text>
|
|
244
|
+
<For each={group.items}>
|
|
245
|
+
{(item) => (
|
|
246
|
+
<TaskRow row={item.row} selected={item.index === props.selected} theme={props.theme} />
|
|
247
|
+
)}
|
|
248
|
+
</For>
|
|
249
|
+
</box>
|
|
250
|
+
)}
|
|
251
|
+
</For>
|
|
252
|
+
</Show>
|
|
253
|
+
</box>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function TaskRow(props: { row: BoardRow; selected: boolean; theme: Theme }) {
|
|
258
|
+
const mark = props.row.blocked ? '!' : ' ';
|
|
259
|
+
return (
|
|
260
|
+
<text>
|
|
261
|
+
<span style={{ fg: props.theme.primary }}>{props.selected ? '❯ ' : ' '}</span>
|
|
262
|
+
<span style={{ fg: props.row.blocked ? props.theme.error : props.theme.textMuted }}>{mark}</span>
|
|
263
|
+
<span style={{ fg: props.selected ? props.theme.primary : props.theme.textMuted }}>
|
|
264
|
+
{` ${props.row.id} `}
|
|
265
|
+
</span>
|
|
266
|
+
<span style={{ fg: props.selected ? props.theme.text : props.theme.textMuted }}>
|
|
267
|
+
{props.row.title}
|
|
268
|
+
</span>
|
|
269
|
+
<span style={{ fg: props.theme.textMuted }}>{` ${props.row.epicLabel}`}</span>
|
|
270
|
+
</text>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function DetailPanel(props: { row: BoardRow | null; theme: Theme }) {
|
|
275
|
+
const row = () => props.row;
|
|
276
|
+
const ac = () => row()?.acceptance_criteria ?? [];
|
|
277
|
+
const unmet = () => (row()?.unmet_dependencies ?? []).map((item) => item.id).join(', ');
|
|
278
|
+
return (
|
|
279
|
+
<box
|
|
280
|
+
flexDirection="column"
|
|
281
|
+
flexGrow={1}
|
|
282
|
+
width="50%"
|
|
283
|
+
height="100%"
|
|
284
|
+
border={true}
|
|
285
|
+
borderColor={props.theme.borderActive ?? props.theme.primary}
|
|
286
|
+
padding={1}
|
|
287
|
+
title=" detail "
|
|
288
|
+
titleAlignment="left"
|
|
289
|
+
gap={0}
|
|
290
|
+
>
|
|
291
|
+
<Show when={row()} fallback={<text fg={props.theme.textMuted}>Select a task</text>}>
|
|
292
|
+
<text>
|
|
293
|
+
<span style={{ fg: props.theme.primary, bold: true }}>{row()!.id}</span>
|
|
294
|
+
<span style={{ fg: props.theme.text }}>{` ${row()!.title}`}</span>
|
|
295
|
+
</text>
|
|
296
|
+
<text fg={props.theme.textMuted}>{`epic ${row()!.epicLabel}`}</text>
|
|
297
|
+
<Show when={row()!.blocked}>
|
|
298
|
+
<text fg={props.theme.error}>{`blocked ${unmet() || 'yes'}`}</text>
|
|
299
|
+
</Show>
|
|
300
|
+
<text fg={props.theme.textMuted}>
|
|
301
|
+
{`depends_on ${row()!.depends_on.length ? row()!.depends_on.join(', ') : '—'}`}
|
|
302
|
+
</text>
|
|
303
|
+
<text fg={props.theme.text}>{row()!.description || '(no description)'}</text>
|
|
304
|
+
<text fg={props.theme.textMuted}>acceptance criteria</text>
|
|
305
|
+
<Show when={ac().length > 0} fallback={<text fg={props.theme.textMuted}>—</text>}>
|
|
306
|
+
<For each={ac()}>{(line) => <text fg={props.theme.text}>{`- ${line}`}</text>}</For>
|
|
307
|
+
</Show>
|
|
308
|
+
</Show>
|
|
309
|
+
</box>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const plugin: TuiPluginModule & { id: string } = { id, tui };
|
|
314
|
+
export default plugin;
|