@mindtnv/todoist-cli 0.3.1 → 0.5.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.
Files changed (85) hide show
  1. package/marketplace.json +16 -0
  2. package/package.json +7 -6
  3. package/src/api/activity.ts +8 -0
  4. package/src/api/client.ts +214 -0
  5. package/src/api/comments.ts +18 -0
  6. package/src/api/completed.ts +15 -0
  7. package/src/api/labels.ts +18 -0
  8. package/src/api/projects.ts +22 -0
  9. package/src/api/sections.ts +20 -0
  10. package/src/api/stats.ts +38 -0
  11. package/src/api/tasks.ts +34 -0
  12. package/src/api/types.ts +202 -0
  13. package/src/cli/auth.ts +40 -0
  14. package/src/cli/commands/task/add.ts +328 -0
  15. package/src/cli/commands/task/complete.ts +62 -0
  16. package/src/cli/commands/task/delete.ts +62 -0
  17. package/src/cli/commands/task/helpers.ts +289 -0
  18. package/src/cli/commands/task/index.ts +27 -0
  19. package/src/cli/commands/task/list.ts +151 -0
  20. package/src/cli/commands/task/move.ts +49 -0
  21. package/src/cli/commands/task/reopen.ts +43 -0
  22. package/src/cli/commands/task/show.ts +115 -0
  23. package/src/cli/commands/task/update.ts +122 -0
  24. package/src/cli/comment.ts +83 -0
  25. package/src/cli/completed.ts +87 -0
  26. package/src/cli/completion.ts +360 -0
  27. package/src/cli/filter.ts +115 -0
  28. package/src/cli/index.ts +638 -0
  29. package/src/cli/label.ts +120 -0
  30. package/src/cli/log.ts +57 -0
  31. package/src/cli/matrix.ts +100 -0
  32. package/src/cli/plugin-loader.ts +38 -0
  33. package/src/cli/plugin.ts +289 -0
  34. package/src/cli/project.ts +172 -0
  35. package/src/cli/review.ts +116 -0
  36. package/src/cli/section.ts +98 -0
  37. package/src/cli/stats.ts +62 -0
  38. package/src/cli/template.ts +89 -0
  39. package/src/config/index.ts +229 -0
  40. package/src/plugins/api-proxy.ts +70 -0
  41. package/src/plugins/extension-registry.ts +53 -0
  42. package/src/plugins/hook-registry.ts +36 -0
  43. package/src/plugins/loader.ts +200 -0
  44. package/src/plugins/marketplace-types.ts +55 -0
  45. package/src/plugins/marketplace.ts +576 -0
  46. package/src/plugins/palette-registry.ts +21 -0
  47. package/src/plugins/storage.ts +101 -0
  48. package/src/plugins/types.ts +226 -0
  49. package/src/plugins/view-registry.ts +19 -0
  50. package/src/ui/App.tsx +234 -0
  51. package/src/ui/components/Breadcrumb.tsx +18 -0
  52. package/src/ui/components/CommandPalette.tsx +237 -0
  53. package/src/ui/components/ConfirmDialog.tsx +28 -0
  54. package/src/ui/components/EditTaskModal.tsx +484 -0
  55. package/src/ui/components/HelpOverlay.tsx +195 -0
  56. package/src/ui/components/InputPrompt.tsx +109 -0
  57. package/src/ui/components/LabelPicker.tsx +110 -0
  58. package/src/ui/components/ModalManager.tsx +275 -0
  59. package/src/ui/components/ProjectPicker.tsx +95 -0
  60. package/src/ui/components/Sidebar.tsx +282 -0
  61. package/src/ui/components/SortMenu.tsx +77 -0
  62. package/src/ui/components/StatusBar.tsx +67 -0
  63. package/src/ui/components/TaskList.tsx +258 -0
  64. package/src/ui/components/TaskRow.tsx +105 -0
  65. package/src/ui/hooks/useKeyboardHandler.ts +291 -0
  66. package/src/ui/hooks/useStatusMessage.ts +32 -0
  67. package/src/ui/hooks/useTaskOperations.ts +558 -0
  68. package/src/ui/hooks/useUndoSystem.ts +218 -0
  69. package/src/ui/views/ActivityView.tsx +213 -0
  70. package/src/ui/views/CompletedView.tsx +337 -0
  71. package/src/ui/views/StatsView.tsx +178 -0
  72. package/src/ui/views/TaskDetailView.tsx +438 -0
  73. package/src/ui/views/TasksView.tsx +851 -0
  74. package/src/utils/colors.ts +27 -0
  75. package/src/utils/date-format.ts +54 -0
  76. package/src/utils/errors.ts +159 -0
  77. package/src/utils/exit.ts +11 -0
  78. package/src/utils/format.ts +46 -0
  79. package/src/utils/open-url.ts +9 -0
  80. package/src/utils/output.ts +29 -0
  81. package/src/utils/quick-add.ts +202 -0
  82. package/src/utils/resolve.ts +359 -0
  83. package/src/utils/sorting.ts +27 -0
  84. package/src/utils/validation.ts +88 -0
  85. package/dist/index.js +0 -10989
@@ -0,0 +1,282 @@
1
+ import { useState, useEffect, useMemo } from "react";
2
+ import { Box, Text, useInput, useStdout } from "ink";
3
+ import type { Project, Label, Section } from "../../api/types.ts";
4
+ import { getSections } from "../../api/sections.ts";
5
+ import { mapTodoistColor } from "../../utils/colors.ts";
6
+ import type { PluginViewDefinition } from "../../plugins/types.ts";
7
+
8
+ const SIDEBAR_ICONS: Record<string, string> = {
9
+ inbox: "\u25A3",
10
+ today: "\u25C9",
11
+ upcoming: "\u25B7",
12
+ "view-stats": "\u2261",
13
+ "view-completed": "\u2713",
14
+ "view-activity": "\u2302",
15
+ };
16
+
17
+ export interface SidebarItem {
18
+ id: string;
19
+ label: string;
20
+ type: "builtin" | "separator" | "project" | "label" | "section" | "view";
21
+ color?: string;
22
+ taskCount?: number;
23
+ }
24
+
25
+ interface SidebarProps {
26
+ projects: Project[];
27
+ labels: Label[];
28
+ tasks?: import("../../api/types.ts").Task[];
29
+ activeProjectId?: string;
30
+ selectedIndex: number;
31
+ isFocused: boolean;
32
+ onSelect: (item: SidebarItem) => void;
33
+ onIndexChange: (index: number) => void;
34
+ onNavigate?: (view: string) => void;
35
+ pluginViews?: PluginViewDefinition[];
36
+ }
37
+
38
+ export function buildSidebarItems(
39
+ projects: Project[],
40
+ labels: Label[],
41
+ tasks?: import("../../api/types.ts").Task[],
42
+ sections?: Section[],
43
+ activeProjectId?: string,
44
+ pluginViews?: PluginViewDefinition[],
45
+ ): SidebarItem[] {
46
+ const taskCountByProject = new Map<string, number>();
47
+ const taskCountByLabel = new Map<string, number>();
48
+ if (tasks) {
49
+ for (const t of tasks) {
50
+ taskCountByProject.set(t.project_id, (taskCountByProject.get(t.project_id) ?? 0) + 1);
51
+ for (const l of t.labels) {
52
+ taskCountByLabel.set(l, (taskCountByLabel.get(l) ?? 0) + 1);
53
+ }
54
+ }
55
+ }
56
+
57
+ const inboxProject = projects.find((p) => p.is_inbox_project);
58
+ const today = new Date();
59
+ const localDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
60
+ const todayCount = tasks ? tasks.filter((t) => t.due?.date === localDate).length : undefined;
61
+ const upcomingCount = tasks ? tasks.filter((t) => t.due !== null && t.due.date >= localDate).length : undefined;
62
+
63
+ const items: SidebarItem[] = [
64
+ { id: "inbox", label: "Inbox", type: "builtin", taskCount: inboxProject && tasks ? taskCountByProject.get(inboxProject.id) : undefined },
65
+ { id: "today", label: "Today", type: "builtin", taskCount: todayCount },
66
+ { id: "upcoming", label: "Upcoming", type: "builtin", taskCount: upcomingCount },
67
+ ];
68
+
69
+ if (projects.length > 0) {
70
+ items.push({ id: "sep-projects", label: "Projects", type: "separator" });
71
+ for (const p of projects) {
72
+ if (!p.is_inbox_project) {
73
+ items.push({
74
+ id: p.id,
75
+ label: p.name,
76
+ type: "project",
77
+ color: mapTodoistColor(p.color),
78
+ taskCount: taskCountByProject.get(p.id),
79
+ });
80
+ // Show sections under the active project
81
+ if (activeProjectId && p.id === activeProjectId && sections) {
82
+ const projectSections = sections.filter((s) => s.project_id === p.id);
83
+ for (const s of projectSections) {
84
+ const sectionTaskCount = tasks
85
+ ? tasks.filter((t) => t.section_id === s.id).length
86
+ : undefined;
87
+ items.push({
88
+ id: `section-${s.id}`,
89
+ label: ` ${s.name}`,
90
+ type: "section",
91
+ color: mapTodoistColor(p.color),
92
+ taskCount: sectionTaskCount,
93
+ });
94
+ }
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ if (labels.length > 0) {
101
+ items.push({ id: "sep-labels", label: "Labels", type: "separator" });
102
+ for (const l of labels) {
103
+ items.push({
104
+ id: l.id,
105
+ label: `@${l.name}`,
106
+ type: "label",
107
+ color: mapTodoistColor(l.color),
108
+ taskCount: taskCountByLabel.get(l.name),
109
+ });
110
+ }
111
+ }
112
+
113
+ items.push({ id: "sep-views", label: "Views", type: "separator" });
114
+ items.push({ id: "view-stats", label: "Stats", type: "view", color: "cyan" });
115
+ items.push({ id: "view-completed", label: "Completed", type: "view", color: "green" });
116
+ items.push({ id: "view-activity", label: "Activity", type: "view", color: "yellow" });
117
+
118
+ const sidebarPluginViews = pluginViews?.filter(v => v.sidebar) ?? [];
119
+ if (sidebarPluginViews.length > 0) {
120
+ items.push({ id: "sep-plugins", label: "Plugins", type: "separator" });
121
+ for (const pv of sidebarPluginViews) {
122
+ items.push({
123
+ id: `plugin-${pv.name}`,
124
+ label: pv.label,
125
+ type: "view",
126
+ color: "magenta",
127
+ });
128
+ }
129
+ }
130
+
131
+ return items;
132
+ }
133
+
134
+ export function Sidebar({
135
+ projects,
136
+ labels,
137
+ tasks,
138
+ activeProjectId,
139
+ selectedIndex,
140
+ isFocused,
141
+ onSelect,
142
+ onIndexChange,
143
+ onNavigate,
144
+ pluginViews,
145
+ }: SidebarProps) {
146
+ const [sections, setSections] = useState<Section[]>([]);
147
+ const { stdout } = useStdout();
148
+ // Reserve lines for title, border, padding (~5 lines overhead)
149
+ const sidebarViewHeight = Math.max(5, (stdout?.rows ?? 24) - 5);
150
+
151
+ useEffect(() => {
152
+ if (!activeProjectId) {
153
+ setSections([]);
154
+ return;
155
+ }
156
+ let cancelled = false;
157
+ getSections(activeProjectId)
158
+ .then((s) => {
159
+ if (!cancelled) setSections(s);
160
+ })
161
+ .catch(() => {
162
+ // Sections are optional; ignore errors
163
+ });
164
+ return () => {
165
+ cancelled = true;
166
+ };
167
+ }, [activeProjectId]);
168
+
169
+ const items = buildSidebarItems(projects, labels, tasks, sections, activeProjectId, pluginViews);
170
+
171
+ // Build icon map including plugin view icons
172
+ const iconMap = useMemo(() => {
173
+ const icons: Record<string, string> = { ...SIDEBAR_ICONS };
174
+ for (const pv of pluginViews ?? []) {
175
+ if (pv.sidebar?.icon) {
176
+ icons[`plugin-${pv.name}`] = pv.sidebar.icon;
177
+ }
178
+ }
179
+ return icons;
180
+ }, [pluginViews]);
181
+
182
+ // Compute adaptive sidebar width: clamp between 20 and 36
183
+ const sidebarWidth = useMemo(() => {
184
+ const lengths = items
185
+ .filter((item) => item.type !== "separator")
186
+ .map((item) => {
187
+ const countStr = item.taskCount != null ? ` (${item.taskCount})`.length : 0;
188
+ return item.label.length + countStr + 4; // 4 for prefix "> " and padding
189
+ });
190
+ return Math.min(38, Math.max(24, Math.max(...lengths, 24)));
191
+ }, [items]);
192
+
193
+ useInput(
194
+ (input, key) => {
195
+ if (!isFocused) return;
196
+
197
+ if (key.upArrow || input === "k") {
198
+ let next = selectedIndex - 1;
199
+ while (next >= 0 && items[next]?.type === "separator") next--;
200
+ if (next >= 0) onIndexChange(next);
201
+ } else if (key.downArrow || input === "j") {
202
+ let next = selectedIndex + 1;
203
+ while (next < items.length && items[next]?.type === "separator") next++;
204
+ if (next < items.length) onIndexChange(next);
205
+ } else if (key.return) {
206
+ const item = items[selectedIndex];
207
+ if (item && item.type === "view" && onNavigate) {
208
+ const viewName = item.id.startsWith("plugin-")
209
+ ? item.id.replace("plugin-", "")
210
+ : item.id.replace("view-", "");
211
+ onNavigate(viewName);
212
+ } else if (item && item.type !== "separator" && item.type !== "view") {
213
+ onSelect(item);
214
+ }
215
+ }
216
+ },
217
+ );
218
+
219
+ return (
220
+ <Box
221
+ flexDirection="column"
222
+ width={sidebarWidth}
223
+ borderStyle="single"
224
+ borderColor={isFocused ? "green" : "gray"}
225
+ paddingX={1}
226
+ >
227
+ <Text bold color="green">Todoist</Text>
228
+ <Box marginTop={1} flexDirection="column">
229
+ {(() => {
230
+ // Viewport-based scrolling
231
+ let scrollStart = 0;
232
+ if (items.length > sidebarViewHeight) {
233
+ const half = Math.floor(sidebarViewHeight / 2);
234
+ scrollStart = Math.max(0, selectedIndex - half);
235
+ const scrollEnd = scrollStart + sidebarViewHeight;
236
+ if (scrollEnd > items.length) {
237
+ scrollStart = Math.max(0, items.length - sidebarViewHeight);
238
+ }
239
+ }
240
+ const visibleItems = items.length > sidebarViewHeight
241
+ ? items.slice(scrollStart, scrollStart + sidebarViewHeight)
242
+ : items;
243
+
244
+ return visibleItems.map((item, vi) => {
245
+ const i = scrollStart + vi;
246
+ if (item.type === "separator") {
247
+ const isFirstSeparator = i === items.findIndex((it) => it.type === "separator");
248
+ return (
249
+ <Box key={item.id} marginTop={isFirstSeparator ? 0 : 1} flexDirection="column">
250
+ <Text color="gray" dimColor bold>
251
+ {item.label.toUpperCase()}
252
+ </Text>
253
+ </Box>
254
+ );
255
+ }
256
+ const isSelected = i === selectedIndex && isFocused;
257
+ const itemColor = isSelected ? "black" : item.color ?? (item.type === "builtin" ? "white" : "cyan");
258
+ const countStr = item.taskCount != null ? ` (${item.taskCount})` : "";
259
+ const prefix = i === selectedIndex ? "> " : " ";
260
+ const icon = iconMap[item.id];
261
+ const iconPrefix = icon ? `${icon} ` : "";
262
+ const fullLabel = `${iconPrefix}${item.label}`;
263
+ const maxLabelLen = sidebarWidth - 2 - prefix.length - countStr.length;
264
+ const displayLabel = fullLabel.length > maxLabelLen && maxLabelLen > 3
265
+ ? fullLabel.slice(0, maxLabelLen - 1) + "\u2026"
266
+ : fullLabel;
267
+ return (
268
+ <Text
269
+ key={item.id}
270
+ backgroundColor={isSelected ? "green" : undefined}
271
+ color={itemColor}
272
+ bold={isSelected}
273
+ >
274
+ {prefix}{displayLabel}<Text color={isSelected ? "black" : "gray"}>{countStr}</Text>
275
+ </Text>
276
+ );
277
+ });
278
+ })()}
279
+ </Box>
280
+ </Box>
281
+ );
282
+ }
@@ -0,0 +1,77 @@
1
+ import { useState } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+
4
+ import type { SortField } from "../../utils/sorting.ts";
5
+ export type { SortField };
6
+
7
+ interface SortMenuProps {
8
+ currentSort: SortField;
9
+ currentDirection: "asc" | "desc";
10
+ onSelect: (field: SortField) => void;
11
+ onCancel: () => void;
12
+ }
13
+
14
+ const sortOptions: { field: SortField; label: string }[] = [
15
+ { field: "priority", label: "Priority" },
16
+ { field: "due", label: "Due date" },
17
+ { field: "name", label: "Name" },
18
+ { field: "project", label: "Project" },
19
+ ];
20
+
21
+ export function SortMenu({ currentSort, currentDirection, onSelect, onCancel }: SortMenuProps) {
22
+ const currentIndex = sortOptions.findIndex((o) => o.field === currentSort);
23
+ const [selectedIndex, setSelectedIndex] = useState(currentIndex >= 0 ? currentIndex : 0);
24
+
25
+ useInput((input, key) => {
26
+ if (key.escape) {
27
+ onCancel();
28
+ return;
29
+ }
30
+ if (key.return) {
31
+ const option = sortOptions[selectedIndex];
32
+ if (option) {
33
+ onSelect(option.field);
34
+ }
35
+ return;
36
+ }
37
+ if (key.upArrow || input === "k") {
38
+ setSelectedIndex((i) => Math.max(0, i - 1));
39
+ } else if (key.downArrow || input === "j") {
40
+ setSelectedIndex((i) => Math.min(sortOptions.length - 1, i + 1));
41
+ }
42
+ });
43
+
44
+ return (
45
+ <Box
46
+ flexDirection="column"
47
+ borderStyle="single"
48
+ borderColor="magenta"
49
+ paddingX={2}
50
+ paddingY={1}
51
+ width={30}
52
+ >
53
+ <Box marginBottom={1}>
54
+ <Text bold color="magenta">Sort by</Text>
55
+ </Box>
56
+ {sortOptions.map((option, i) => {
57
+ const isActive = i === selectedIndex;
58
+ const isCurrent = option.field === currentSort;
59
+ const directionArrow = isCurrent ? (currentDirection === "asc" ? " ↑" : " ↓") : "";
60
+ return (
61
+ <Box key={option.field}>
62
+ <Text
63
+ backgroundColor={isActive ? "magenta" : undefined}
64
+ color={isActive ? "black" : isCurrent ? "magenta" : "white"}
65
+ bold={isActive}
66
+ >
67
+ {isActive ? "> " : " "}{option.label}{isCurrent ? " *" : ""}{directionArrow}
68
+ </Text>
69
+ </Box>
70
+ );
71
+ })}
72
+ <Box marginTop={1}>
73
+ <Text color="gray" dimColor>Arrow keys + Enter to select (same field toggles direction), Esc to cancel</Text>
74
+ </Box>
75
+ </Box>
76
+ );
77
+ }
@@ -0,0 +1,67 @@
1
+ import React, { useState, useEffect, useRef } from "react";
2
+ import { Box, Text } from "ink";
3
+ import type { StatusBarItemDefinition, PluginContext } from "../../plugins/types.ts";
4
+
5
+ interface StatusBarProps {
6
+ items: StatusBarItemDefinition[];
7
+ contextMap: Map<string, PluginContext>;
8
+ }
9
+
10
+ export function StatusBar({ items, contextMap }: StatusBarProps) {
11
+ const [tick, setTick] = useState(0);
12
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
13
+
14
+ useEffect(() => {
15
+ if (items.length === 0) return;
16
+
17
+ const intervals = items
18
+ .map((item) => item.refreshInterval)
19
+ .filter((v): v is number => typeof v === "number" && v > 0);
20
+
21
+ if (intervals.length === 0) return;
22
+
23
+ // Reset tick to prevent unbounded growth over long sessions
24
+ setTick(0);
25
+
26
+ const minInterval = Math.min(...intervals);
27
+ intervalRef.current = setInterval(() => {
28
+ // Use modular increment to avoid Number.MAX_SAFE_INTEGER overflow in very long sessions
29
+ setTick((t) => (t + 1) % 1_000_000);
30
+ }, minInterval);
31
+
32
+ return () => {
33
+ if (intervalRef.current) {
34
+ clearInterval(intervalRef.current);
35
+ intervalRef.current = null;
36
+ }
37
+ };
38
+ }, [items]);
39
+
40
+ if (items.length === 0) return null;
41
+
42
+ const rendered = items
43
+ .map((item) => {
44
+ const ctx = contextMap.get(item.id);
45
+ if (!ctx) return null;
46
+ const text = item.render(ctx);
47
+ if (!text) return null;
48
+ const color = item.color ? item.color(ctx) : undefined;
49
+ return { id: item.id, text, color };
50
+ })
51
+ .filter(Boolean) as { id: string; text: string; color: string | undefined }[];
52
+
53
+ if (rendered.length === 0) return null;
54
+
55
+ return (
56
+ <Box borderStyle="single" borderColor="gray" paddingX={1} justifyContent="space-between">
57
+ <Box gap={1}>
58
+ {rendered.map((item, i) => (
59
+ <React.Fragment key={item.id}>
60
+ {i > 0 && <Text color="gray" dimColor>{"\u2502"}</Text>}
61
+ <Text color={item.color}>{item.text}</Text>
62
+ </React.Fragment>
63
+ ))}
64
+ </Box>
65
+ </Box>
66
+ );
67
+ }
@@ -0,0 +1,258 @@
1
+ import { useMemo, useState, useEffect, useRef } from "react";
2
+ import { Box, Text, useInput, useStdout } from "ink";
3
+ import type { Task } from "../../api/types.ts";
4
+ import type { TaskColumnDefinition, PluginContext } from "../../plugins/types.ts";
5
+ import { TaskRow } from "./TaskRow.tsx";
6
+
7
+ interface TaskListProps {
8
+ tasks: Task[];
9
+ selectedIndex: number;
10
+ isFocused: boolean;
11
+ onIndexChange: (index: number) => void;
12
+ selectedIds?: Set<string>;
13
+ viewHeight?: number;
14
+ sortField?: string;
15
+ searchQuery?: string;
16
+ pluginColumns?: TaskColumnDefinition[];
17
+ pluginColumnContextMap?: Map<string, PluginContext>;
18
+ }
19
+
20
+ interface FlatTask {
21
+ task: Task;
22
+ depth: number;
23
+ }
24
+
25
+ function buildTree(tasks: Task[]): FlatTask[] {
26
+ const byParent = new Map<string | null, Task[]>();
27
+ for (const t of tasks) {
28
+ const parentKey = t.parent_id ?? null;
29
+ const existing = byParent.get(parentKey);
30
+ if (existing) {
31
+ existing.push(t);
32
+ } else {
33
+ byParent.set(parentKey, [t]);
34
+ }
35
+ }
36
+
37
+ const taskIds = new Set(tasks.map((t) => t.id));
38
+ const result: FlatTask[] = [];
39
+
40
+ function walk(parentId: string | null, depth: number) {
41
+ const children = byParent.get(parentId);
42
+ if (!children) return;
43
+ for (const child of children) {
44
+ result.push({ task: child, depth });
45
+ walk(child.id, depth + 1);
46
+ }
47
+ }
48
+
49
+ // Start with tasks whose parent is null or whose parent is not in the current set
50
+ const roots = tasks.filter((t) => t.parent_id === null || !taskIds.has(t.parent_id));
51
+
52
+ // Walk from roots
53
+ for (const root of roots) {
54
+ result.push({ task: root, depth: 0 });
55
+ walk(root.id, 1);
56
+ }
57
+
58
+ // Add any orphans (tasks whose parent is in set but weren't reached — shouldn't happen, but safety)
59
+ const visited = new Set(result.map((r) => r.task.id));
60
+ for (const t of tasks) {
61
+ if (!visited.has(t.id)) {
62
+ result.push({ task: t, depth: 0 });
63
+ }
64
+ }
65
+
66
+ return result;
67
+ }
68
+
69
+ export function TaskList({
70
+ tasks,
71
+ selectedIndex,
72
+ isFocused,
73
+ onIndexChange,
74
+ selectedIds,
75
+ viewHeight: viewHeightProp,
76
+ sortField,
77
+ searchQuery,
78
+ pluginColumns,
79
+ pluginColumnContextMap,
80
+ }: TaskListProps) {
81
+ const { stdout } = useStdout();
82
+ // Reserve lines for header, border, status bar, etc. (~8 lines overhead)
83
+ const dynamicHeight = stdout?.rows ? Math.max(5, stdout.rows - 8) : 20;
84
+ const viewHeight = viewHeightProp ?? dynamicHeight;
85
+ const flatTasks = useMemo(() => buildTree(tasks), [tasks]);
86
+
87
+ const [pendingG, setPendingG] = useState(false);
88
+ const pendingGTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
89
+
90
+ useEffect(() => {
91
+ if (pendingG) {
92
+ pendingGTimer.current = setTimeout(() => setPendingG(false), 1000);
93
+ return () => { if (pendingGTimer.current) clearTimeout(pendingGTimer.current); };
94
+ }
95
+ }, [pendingG]);
96
+
97
+ useEffect(() => {
98
+ if (!isFocused) {
99
+ setPendingG(false);
100
+ if (pendingGTimer.current) clearTimeout(pendingGTimer.current);
101
+ }
102
+ }, [isFocused]);
103
+
104
+ useInput(
105
+ (input, key) => {
106
+ if (!isFocused) return;
107
+
108
+ if (pendingG) {
109
+ setPendingG(false);
110
+ if (input === "g") {
111
+ // gg -> go to top
112
+ onIndexChange(0);
113
+ return;
114
+ }
115
+ // not gg, ignore
116
+ return;
117
+ }
118
+
119
+ if (input === "g") {
120
+ setPendingG(true);
121
+ return;
122
+ }
123
+ if (input === "G") {
124
+ onIndexChange(Math.max(0, flatTasks.length - 1));
125
+ return;
126
+ }
127
+ if (key.ctrl && input === "d") {
128
+ onIndexChange(Math.min(flatTasks.length - 1, selectedIndex + Math.floor(viewHeight / 2)));
129
+ return;
130
+ }
131
+ if (key.ctrl && input === "u") {
132
+ onIndexChange(Math.max(0, selectedIndex - Math.floor(viewHeight / 2)));
133
+ return;
134
+ }
135
+
136
+ if (key.upArrow || input === "k") {
137
+ onIndexChange(Math.max(0, selectedIndex - 1));
138
+ } else if (key.downArrow || input === "j") {
139
+ onIndexChange(Math.min(flatTasks.length - 1, selectedIndex + 1));
140
+ }
141
+ },
142
+ );
143
+
144
+ if (flatTasks.length === 0) {
145
+ return (
146
+ <Box
147
+ flexDirection="column"
148
+ flexGrow={1}
149
+ borderStyle="single"
150
+ borderColor={isFocused ? "blue" : "gray"}
151
+ paddingX={1}
152
+ justifyContent="center"
153
+ alignItems="center"
154
+ >
155
+ <Text color="gray">No tasks here</Text>
156
+ <Box marginTop={1}>
157
+ <Text color="gray" dimColor>Press </Text>
158
+ <Text color="green">a</Text>
159
+ <Text color="gray" dimColor> to add a task or </Text>
160
+ <Text color="cyan">/</Text>
161
+ <Text color="gray" dimColor> to search</Text>
162
+ </Box>
163
+ </Box>
164
+ );
165
+ }
166
+
167
+ const halfHeight = Math.floor(viewHeight / 2);
168
+ let scrollStart = Math.max(0, selectedIndex - halfHeight);
169
+ const scrollEnd = Math.min(flatTasks.length, scrollStart + viewHeight);
170
+ if (scrollEnd === flatTasks.length) {
171
+ scrollStart = Math.max(0, flatTasks.length - viewHeight);
172
+ }
173
+ const visibleTasks = flatTasks.slice(scrollStart, scrollEnd);
174
+
175
+ return (
176
+ <Box
177
+ flexDirection="column"
178
+ flexGrow={1}
179
+ borderStyle="single"
180
+ borderColor={isFocused ? "blue" : "gray"}
181
+ paddingX={1}
182
+ >
183
+ <Box marginBottom={1}>
184
+ <Text bold color="blue">Tasks</Text>
185
+ <Text color="gray">{` (${flatTasks.length})`}</Text>
186
+ </Box>
187
+ {sortField === "due" ? (
188
+ (() => {
189
+ const today = new Date();
190
+ const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
191
+ const tomorrow = new Date(today);
192
+ tomorrow.setDate(tomorrow.getDate() + 1);
193
+ const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, "0")}-${String(tomorrow.getDate()).padStart(2, "0")}`;
194
+ let lastGroup = "";
195
+ // Track what group the task before the visible window was in
196
+ for (let k = 0; k < scrollStart; k++) {
197
+ const ft = flatTasks[k];
198
+ if (ft && ft.depth === 0) {
199
+ const d = ft.task.due?.date ?? "9999-99-99";
200
+ if (d < todayStr) lastGroup = "Overdue";
201
+ else if (d === todayStr) lastGroup = "Today";
202
+ else if (d === tomorrowStr) lastGroup = "Tomorrow";
203
+ else if (d === "9999-99-99") lastGroup = "No date";
204
+ else lastGroup = d;
205
+ }
206
+ }
207
+ return visibleTasks.map((item, i) => {
208
+ const dueDate = item.task.due?.date ?? "9999-99-99";
209
+ let group: string;
210
+ if (dueDate < todayStr) group = "Overdue";
211
+ else if (dueDate === todayStr) group = "Today";
212
+ else if (dueDate === tomorrowStr) group = "Tomorrow";
213
+ else if (dueDate === "9999-99-99") group = "No date";
214
+ else group = dueDate;
215
+ const showHeader = group !== lastGroup && item.depth === 0;
216
+ if (showHeader) lastGroup = group;
217
+ return (
218
+ <Box key={item.task.id} flexDirection="column">
219
+ {showHeader && (
220
+ <Text color="yellow" bold dimColor>{`-- ${group} --`}</Text>
221
+ )}
222
+ <TaskRow
223
+ task={item.task}
224
+ isSelected={scrollStart + i === selectedIndex}
225
+ isMarked={selectedIds?.has(item.task.id)}
226
+ depth={item.depth}
227
+ searchQuery={searchQuery}
228
+ pluginColumns={pluginColumns}
229
+ pluginColumnContextMap={pluginColumnContextMap}
230
+ />
231
+ </Box>
232
+ );
233
+ });
234
+ })()
235
+ ) : (
236
+ visibleTasks.map((item, i) => (
237
+ <TaskRow
238
+ key={item.task.id}
239
+ task={item.task}
240
+ isSelected={scrollStart + i === selectedIndex}
241
+ isMarked={selectedIds?.has(item.task.id)}
242
+ depth={item.depth}
243
+ searchQuery={searchQuery}
244
+ pluginColumns={pluginColumns}
245
+ pluginColumnContextMap={pluginColumnContextMap}
246
+ />
247
+ ))
248
+ )}
249
+ {flatTasks.length > viewHeight && (
250
+ <Box marginTop={1}>
251
+ <Text color="gray" dimColor>
252
+ {scrollStart + 1}-{scrollEnd}/{flatTasks.length}
253
+ </Text>
254
+ </Box>
255
+ )}
256
+ </Box>
257
+ );
258
+ }