@xxkeefer/mrkl 0.4.0 → 0.4.1

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.
@@ -0,0 +1,252 @@
1
+ import { Fzf } from 'fzf';
2
+
3
+ const ESC = "\x1B";
4
+ const CSI = `${ESC}[`;
5
+ const ALT_SCREEN_ON = `${CSI}?1049h`;
6
+ const ALT_SCREEN_OFF = `${CSI}?1049l`;
7
+ const CURSOR_HIDE = `${CSI}?25l`;
8
+ const CURSOR_SHOW = `${CSI}?25h`;
9
+ const CLEAR_SCREEN = `${CSI}2J${CSI}H`;
10
+ const BOLD = `${CSI}1m`;
11
+ const UNDERLINE = `${CSI}4m`;
12
+ const RESET = `${CSI}0m`;
13
+ const INVERSE = `${CSI}7m`;
14
+ const FG_CYAN = `${CSI}36m`;
15
+ const FG_YELLOW = `${CSI}33m`;
16
+ const FG_GREEN = `${CSI}32m`;
17
+ const FG_RED = `${CSI}31m`;
18
+ const FG_GRAY = `${CSI}90m`;
19
+ function statusColor(status) {
20
+ switch (status) {
21
+ case "todo":
22
+ return FG_YELLOW;
23
+ case "in-progress":
24
+ return FG_CYAN;
25
+ case "done":
26
+ return FG_GREEN;
27
+ case "closed":
28
+ return FG_RED;
29
+ default:
30
+ return "";
31
+ }
32
+ }
33
+ function buildEntries(tasks) {
34
+ return tasks.map((task) => ({
35
+ task,
36
+ searchText: `${task.id} ${task.type} ${task.status} ${task.title} ${task.description}`
37
+ }));
38
+ }
39
+ async function interactiveList(tasks, archivedTasks) {
40
+ const { stdin, stdout } = process;
41
+ const datasets = [
42
+ { label: "Tasks", entries: buildEntries(tasks) },
43
+ { label: "Archive", entries: buildEntries(archivedTasks) }
44
+ ];
45
+ let activeTab = 0;
46
+ let query = "";
47
+ let selectedIndex = 0;
48
+ let scrollOffset = 0;
49
+ function getFiltered() {
50
+ const entries = datasets[activeTab].entries;
51
+ if (!query) return entries;
52
+ const fzf = new Fzf(entries, { selector: (e) => e.searchText });
53
+ return fzf.find(query).map((r) => r.item);
54
+ }
55
+ function getTermSize() {
56
+ return { cols: stdout.columns || 80, rows: stdout.rows || 24 };
57
+ }
58
+ function render() {
59
+ const { cols, rows } = getTermSize();
60
+ const filtered = getFiltered();
61
+ const buf = [];
62
+ const tabParts = datasets.map((ds, i) => {
63
+ if (i === activeTab) {
64
+ return `${FG_CYAN}${BOLD}[${ds.label}]${RESET}`;
65
+ }
66
+ return `${FG_GRAY} ${ds.label} ${RESET}`;
67
+ });
68
+ buf.push(tabParts.join(" "));
69
+ buf.push("");
70
+ buf.push(`${FG_CYAN}>${RESET} ${query}${UNDERLINE} ${RESET}`);
71
+ const listWidth = Math.floor(cols * 0.55);
72
+ const previewWidth = cols - listWidth - 3;
73
+ buf.push(`${FG_GRAY}${"\u2500".repeat(listWidth)}\u252C${"\u2500".repeat(previewWidth + 2)}${RESET}`);
74
+ const headerLine = formatRow("ID", "TYPE", "STATUS", "TITLE", listWidth);
75
+ buf.push(`${BOLD}${headerLine}${RESET}${FG_GRAY}\u2502${RESET}${BOLD} Preview${RESET}`);
76
+ buf.push(`${FG_GRAY}${"\u2500".repeat(listWidth)}\u253C${"\u2500".repeat(previewWidth + 2)}${RESET}`);
77
+ const contentRows = rows - 9;
78
+ const maxVisible = Math.max(1, contentRows);
79
+ if (filtered.length === 0) {
80
+ selectedIndex = 0;
81
+ } else {
82
+ selectedIndex = Math.min(selectedIndex, filtered.length - 1);
83
+ selectedIndex = Math.max(selectedIndex, 0);
84
+ }
85
+ if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
86
+ if (selectedIndex >= scrollOffset + maxVisible) scrollOffset = selectedIndex - maxVisible + 1;
87
+ const selectedTask = filtered[selectedIndex]?.task;
88
+ const previewLines = buildPreviewLines(selectedTask, previewWidth);
89
+ for (let i = 0; i < maxVisible; i++) {
90
+ const taskIdx = scrollOffset + i;
91
+ const entry = filtered[taskIdx];
92
+ let leftPart;
93
+ if (!entry) {
94
+ leftPart = " ".repeat(listWidth);
95
+ } else {
96
+ const isSelected = taskIdx === selectedIndex;
97
+ const row = formatRow(entry.task.id, entry.task.type, entry.task.status, entry.task.title, listWidth);
98
+ if (isSelected) {
99
+ leftPart = `${INVERSE}${row}${RESET}`;
100
+ } else {
101
+ const sc = statusColor(entry.task.status);
102
+ leftPart = colorizeRow(entry.task.id, entry.task.type, entry.task.status, entry.task.title, listWidth, sc);
103
+ }
104
+ }
105
+ const rightPart = previewLines[i] ?? "";
106
+ buf.push(`${leftPart}${FG_GRAY}\u2502${RESET} ${rightPart}`);
107
+ }
108
+ buf.push(`${FG_GRAY}${"\u2500".repeat(listWidth)}\u2534${"\u2500".repeat(previewWidth + 2)}${RESET}`);
109
+ const countInfo = `${filtered.length}/${datasets[activeTab].entries.length}`;
110
+ buf.push(`${FG_GRAY}${countInfo} tasks \u2191\u2193: navigate Tab: switch Enter: select Esc: quit Type to search${RESET}`);
111
+ stdout.write(CLEAR_SCREEN + buf.join("\n"));
112
+ }
113
+ function formatRow(id, type, status, title, width) {
114
+ const idCol = id.padEnd(14);
115
+ const typeCol = type.padEnd(12);
116
+ const statusCol = status.padEnd(14);
117
+ const usedWidth = 14 + 12 + 14;
118
+ const titleWidth = Math.max(1, width - usedWidth);
119
+ const titleCol = title.length > titleWidth ? title.slice(0, titleWidth - 1) + "\u2026" : title.padEnd(titleWidth);
120
+ return `${idCol}${typeCol}${statusCol}${titleCol}`;
121
+ }
122
+ function colorizeRow(id, type, status, title, width, sc) {
123
+ const idCol = id.padEnd(14);
124
+ const typeCol = type.padEnd(12);
125
+ const statusCol = status.padEnd(14);
126
+ const usedWidth = 14 + 12 + 14;
127
+ const titleWidth = Math.max(1, width - usedWidth);
128
+ const titleCol = title.length > titleWidth ? title.slice(0, titleWidth - 1) + "\u2026" : title.padEnd(titleWidth);
129
+ return `${FG_CYAN}${idCol}${RESET}${typeCol}${sc}${statusCol}${RESET}${titleCol}`;
130
+ }
131
+ function buildPreviewLines(task, width) {
132
+ if (!task) return [];
133
+ const lines = [];
134
+ lines.push(`${BOLD}${task.id}${RESET} ${FG_GRAY}${task.type}${RESET} ${statusColor(task.status)}${task.status}${RESET}`);
135
+ lines.push(`${BOLD}${task.title}${RESET}`);
136
+ lines.push("");
137
+ if (task.description) {
138
+ lines.push(`${UNDERLINE}Description${RESET}`);
139
+ for (const line of wrapText(task.description, width)) {
140
+ lines.push(line);
141
+ }
142
+ lines.push("");
143
+ }
144
+ if (task.acceptance_criteria.length > 0) {
145
+ lines.push(`${UNDERLINE}Acceptance Criteria${RESET}`);
146
+ for (const ac of task.acceptance_criteria) {
147
+ for (const line of wrapText(`- [ ] ${ac}`, width)) {
148
+ lines.push(line);
149
+ }
150
+ }
151
+ }
152
+ return lines;
153
+ }
154
+ function wrapText(text, width) {
155
+ if (width <= 0) return [text];
156
+ const result = [];
157
+ for (const rawLine of text.split("\n")) {
158
+ const words = rawLine.split(" ");
159
+ let current = "";
160
+ for (const word of words) {
161
+ if (current.length + word.length + 1 > width && current.length > 0) {
162
+ result.push(current);
163
+ current = word;
164
+ } else {
165
+ current = current ? `${current} ${word}` : word;
166
+ }
167
+ }
168
+ if (current) result.push(current);
169
+ }
170
+ return result;
171
+ }
172
+ if (stdin.isTTY) stdin.setRawMode(true);
173
+ stdin.resume();
174
+ stdin.setEncoding("utf-8");
175
+ stdout.write(ALT_SCREEN_ON + CURSOR_HIDE);
176
+ render();
177
+ return new Promise((resolve) => {
178
+ function cleanup() {
179
+ stdout.write(CURSOR_SHOW + ALT_SCREEN_OFF);
180
+ if (stdin.isTTY) stdin.setRawMode(false);
181
+ stdin.pause();
182
+ stdin.removeListener("data", onData);
183
+ }
184
+ function onData(data) {
185
+ const filtered = getFiltered();
186
+ for (let i = 0; i < data.length; i++) {
187
+ const ch = data[i];
188
+ if (ch === ESC) {
189
+ if (data[i + 1] === "[") {
190
+ const arrow = data[i + 2];
191
+ if (arrow === "A") {
192
+ if (selectedIndex > 0) selectedIndex--;
193
+ i += 2;
194
+ continue;
195
+ }
196
+ if (arrow === "B") {
197
+ if (selectedIndex < filtered.length - 1) selectedIndex++;
198
+ i += 2;
199
+ continue;
200
+ }
201
+ i += 2;
202
+ continue;
203
+ }
204
+ cleanup();
205
+ resolve();
206
+ return;
207
+ }
208
+ if (ch === "") {
209
+ cleanup();
210
+ resolve();
211
+ return;
212
+ }
213
+ if (ch === " ") {
214
+ activeTab = (activeTab + 1) % datasets.length;
215
+ query = "";
216
+ selectedIndex = 0;
217
+ scrollOffset = 0;
218
+ continue;
219
+ }
220
+ if (ch === "\r" || ch === "\n") {
221
+ cleanup();
222
+ const selected = getFiltered()[selectedIndex];
223
+ if (selected) {
224
+ process.stdout.write(`
225
+ Selected: ${selected.task.id} - ${selected.task.title}
226
+ `);
227
+ }
228
+ resolve();
229
+ return;
230
+ }
231
+ if (ch === "\x7F" || ch === "\b") {
232
+ if (query.length > 0) {
233
+ query = query.slice(0, -1);
234
+ selectedIndex = 0;
235
+ scrollOffset = 0;
236
+ }
237
+ continue;
238
+ }
239
+ if (ch >= " " && ch <= "~") {
240
+ query += ch;
241
+ selectedIndex = 0;
242
+ scrollOffset = 0;
243
+ }
244
+ }
245
+ render();
246
+ }
247
+ stdin.on("data", onData);
248
+ stdout.on("resize", () => render());
249
+ });
250
+ }
251
+
252
+ export { interactiveList };
package/dist/cli.mjs CHANGED
@@ -231,6 +231,25 @@ function executePrune(dir, filenames) {
231
231
  unlinkSync(join(archiveDir, f));
232
232
  }
233
233
  }
234
+ function listArchivedTasks(filter) {
235
+ const config = loadConfig(filter.dir);
236
+ const archiveDir = join(filter.dir, config.tasks_dir, ".archive");
237
+ if (!existsSync(archiveDir)) return [];
238
+ const files = readdirSync(archiveDir).filter((f) => f.endsWith(".md") && !f.startsWith("."));
239
+ let tasks = files.flatMap((f) => {
240
+ try {
241
+ const content = readFileSync(join(archiveDir, f), "utf-8");
242
+ const task = parse(content, f);
243
+ if (!task.id || !task.type || !task.status) return [];
244
+ return [task];
245
+ } catch {
246
+ return [];
247
+ }
248
+ });
249
+ if (filter.type) tasks = tasks.filter((t) => t.type === filter.type);
250
+ if (filter.status) tasks = tasks.filter((t) => t.status === filter.status);
251
+ return tasks;
252
+ }
234
253
  function closeTask(dir, id) {
235
254
  const config = loadConfig(dir);
236
255
  const tasksDir = join(dir, config.tasks_dir);
@@ -357,6 +376,12 @@ const createCommand = defineCommand({
357
376
  }
358
377
  });
359
378
 
379
+ const COL_ID = 14;
380
+ const COL_TYPE = 12;
381
+ const COL_STATUS = 14;
382
+ function formatRow(id, type, status, title) {
383
+ return `${id.padEnd(COL_ID)}${type.padEnd(COL_TYPE)}${status.padEnd(COL_STATUS)}${title}`;
384
+ }
360
385
  const listCommand = defineCommand({
361
386
  meta: {
362
387
  name: "list",
@@ -372,23 +397,46 @@ const listCommand = defineCommand({
372
397
  type: "string",
373
398
  alias: "s",
374
399
  description: "Filter by status (todo, in-progress, done)"
400
+ },
401
+ plain: {
402
+ type: "boolean",
403
+ alias: "p",
404
+ description: "Plain text output (no interactive TUI)"
375
405
  }
376
406
  },
377
- run({ args }) {
407
+ async run({ args }) {
378
408
  const dir = process.cwd();
379
409
  try {
380
- const tasks = listTasks({
410
+ const filter = {
381
411
  dir,
382
412
  type: args.type,
383
413
  status: args.status
384
- });
385
- if (tasks.length === 0) {
386
- consola.info("\u{1F4ED} No tasks found");
414
+ };
415
+ const tasks = listTasks(filter);
416
+ const archivedTasks = listArchivedTasks(filter);
417
+ if (tasks.length === 0 && archivedTasks.length === 0) {
418
+ consola.info("No tasks found");
387
419
  return;
388
420
  }
389
- for (const task of tasks) {
390
- consola.log(`${task.id} ${task.type.padEnd(10)} ${task.status.padEnd(12)} ${task.title}`);
421
+ const usePlain = args.plain || !process.stdout.isTTY;
422
+ if (usePlain) {
423
+ consola.log(formatRow("ID", "TYPE", "STATUS", "TITLE"));
424
+ consola.log("\u2500".repeat(60));
425
+ for (const task of tasks) {
426
+ consola.log(formatRow(task.id, task.type, task.status, task.title));
427
+ }
428
+ if (archivedTasks.length > 0) {
429
+ consola.log("");
430
+ consola.log(`Archive (${archivedTasks.length}):`);
431
+ consola.log("\u2500".repeat(60));
432
+ for (const task of archivedTasks) {
433
+ consola.log(formatRow(task.id, task.type, task.status, task.title));
434
+ }
435
+ }
436
+ return;
391
437
  }
438
+ const { interactiveList } = await import('./chunks/list-tui.mjs');
439
+ await interactiveList(tasks, archivedTasks);
392
440
  } catch (err) {
393
441
  consola.error(String(err.message));
394
442
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xxkeefer/mrkl",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "Lightweight CLI tool for structured markdown task tracking",
6
6
  "bin": {
@@ -28,6 +28,7 @@
28
28
  "dependencies": {
29
29
  "citty": "^0.1.6",
30
30
  "consola": "^3.4.0",
31
+ "fzf": "^0.5.2",
31
32
  "gray-matter": "^4.0.3",
32
33
  "smol-toml": "^1.3.1"
33
34
  },