@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,109 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import React from "react";
4
+
5
+ interface InputPromptProps {
6
+ prompt: string;
7
+ defaultValue?: string;
8
+ placeholder?: string;
9
+ onSubmit: (value: string) => void;
10
+ onCancel: () => void;
11
+ onCtrlE?: () => void;
12
+ onPreview?: (value: string) => React.ReactNode;
13
+ footer?: React.ReactNode;
14
+ }
15
+
16
+ export function InputPrompt({ prompt, defaultValue = "", placeholder, onSubmit, onCancel, onCtrlE, onPreview, footer }: InputPromptProps) {
17
+ const [value, setValue] = useState(defaultValue);
18
+ const [cursor, setCursor] = useState(defaultValue.length);
19
+ const [flash, setFlash] = useState("");
20
+ const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
21
+ useEffect(() => () => { if (flashTimer.current) clearTimeout(flashTimer.current); }, []);
22
+
23
+ useInput((input, key) => {
24
+ if (key.escape) {
25
+ onCancel();
26
+ return;
27
+ }
28
+ if (key.ctrl && input === "e" && onCtrlE) {
29
+ onCtrlE();
30
+ return;
31
+ }
32
+ if (key.return) {
33
+ if (value.trim()) {
34
+ onSubmit(value.trim());
35
+ setValue("");
36
+ setCursor(0);
37
+ setFlash("Created!");
38
+ if (flashTimer.current) clearTimeout(flashTimer.current);
39
+ flashTimer.current = setTimeout(() => setFlash(""), 1500);
40
+ }
41
+ return;
42
+ }
43
+ if (key.backspace || key.delete) {
44
+ if (cursor > 0) {
45
+ setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor));
46
+ setCursor((c) => c - 1);
47
+ }
48
+ return;
49
+ }
50
+ if (key.leftArrow) {
51
+ setCursor((c) => Math.max(0, c - 1));
52
+ return;
53
+ }
54
+ if (key.rightArrow) {
55
+ setCursor((c) => Math.min(value.length, c + 1));
56
+ return;
57
+ }
58
+ // Home / Ctrl-A: move cursor to start
59
+ if ((key.ctrl && input === "a") || (key.meta && key.leftArrow)) {
60
+ setCursor(0);
61
+ return;
62
+ }
63
+ // End / Ctrl-E (when no onCtrlE): move cursor to end
64
+ if ((key.ctrl && input === "e" && !onCtrlE) || (key.meta && key.rightArrow)) {
65
+ setCursor(value.length);
66
+ return;
67
+ }
68
+ if (input && !key.ctrl && !key.meta) {
69
+ setValue((v) => v.slice(0, cursor) + input + v.slice(cursor));
70
+ setCursor((c) => c + input.length);
71
+ }
72
+ });
73
+
74
+ const before = value.slice(0, cursor);
75
+ const cursorChar = value[cursor] ?? " ";
76
+ const after = value.slice(cursor + 1);
77
+
78
+ const showPlaceholder = !value && placeholder;
79
+
80
+ return (
81
+ <Box flexDirection="column" borderStyle="single" borderColor="yellow" paddingX={1}>
82
+ <Box>
83
+ <Text>
84
+ <Text color="yellow">{prompt}: </Text>
85
+ {flash ? <Text color="green" bold>{flash} </Text> : null}
86
+ {showPlaceholder ? (
87
+ <Text dimColor>{placeholder}</Text>
88
+ ) : (
89
+ <>
90
+ <Text>{before}</Text>
91
+ <Text backgroundColor="white" color="black">{cursorChar}</Text>
92
+ <Text>{after}</Text>
93
+ </>
94
+ )}
95
+ </Text>
96
+ </Box>
97
+ {onPreview && value.trim() && (
98
+ <Box flexDirection="column" marginTop={1}>
99
+ {onPreview(value)}
100
+ </Box>
101
+ )}
102
+ {footer && (
103
+ <Box marginTop={onPreview && value.trim() ? 0 : 1}>
104
+ {footer}
105
+ </Box>
106
+ )}
107
+ </Box>
108
+ );
109
+ }
@@ -0,0 +1,110 @@
1
+ import { useState, useMemo } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import type { Label } from "../../api/types.ts";
4
+ import { todoistColorMap } from "../../utils/colors.ts";
5
+
6
+ interface LabelPickerProps {
7
+ labels: Label[];
8
+ currentLabels: string[];
9
+ onSave: (labels: string[]) => void;
10
+ onCancel: () => void;
11
+ }
12
+
13
+ export function LabelPicker({ labels, currentLabels, onSave, onCancel }: LabelPickerProps) {
14
+ const [filterText, setFilterText] = useState("");
15
+ const [selectedIndex, setSelectedIndex] = useState(0);
16
+ const [checked, setChecked] = useState<Set<string>>(new Set(currentLabels));
17
+
18
+ const filtered = useMemo(() => {
19
+ if (!filterText) return labels;
20
+ const q = filterText.toLowerCase();
21
+ return labels.filter((l) => l.name.toLowerCase().includes(q));
22
+ }, [labels, filterText]);
23
+
24
+ useInput((input, key) => {
25
+ if (key.escape) {
26
+ onCancel();
27
+ return;
28
+ }
29
+ if (key.return) {
30
+ onSave(Array.from(checked));
31
+ return;
32
+ }
33
+ if (key.backspace || key.delete) {
34
+ setFilterText((v) => v.slice(0, -1));
35
+ setSelectedIndex(0);
36
+ return;
37
+ }
38
+ if (input === " ") {
39
+ const label = filtered[selectedIndex];
40
+ if (label) {
41
+ setChecked((prev) => {
42
+ const next = new Set(prev);
43
+ if (next.has(label.name)) {
44
+ next.delete(label.name);
45
+ } else {
46
+ next.add(label.name);
47
+ }
48
+ return next;
49
+ });
50
+ }
51
+ return;
52
+ }
53
+ if (key.upArrow || (input === "k" && key.ctrl)) {
54
+ setSelectedIndex((i) => Math.max(0, i - 1));
55
+ return;
56
+ }
57
+ if (key.downArrow || (input === "j" && key.ctrl)) {
58
+ setSelectedIndex((i) => Math.min(filtered.length - 1, i + 1));
59
+ return;
60
+ }
61
+ if (input && !key.ctrl && !key.meta) {
62
+ setFilterText((v) => v + input);
63
+ setSelectedIndex(0);
64
+ }
65
+ });
66
+
67
+ return (
68
+ <Box
69
+ flexDirection="column"
70
+ borderStyle="single"
71
+ borderColor="magenta"
72
+ paddingX={1}
73
+ width={40}
74
+ >
75
+ <Box marginBottom={1}>
76
+ <Text bold color="magenta">Labels</Text>
77
+ </Box>
78
+ <Box>
79
+ <Text color="yellow">{"> "}</Text>
80
+ <Text>{filterText || ""}</Text>
81
+ <Text backgroundColor="white" color="black">{" "}</Text>
82
+ </Box>
83
+ <Box flexDirection="column" marginTop={1}>
84
+ {filtered.length === 0 ? (
85
+ <Text color="gray">No matching labels</Text>
86
+ ) : (
87
+ filtered.slice(0, 15).map((label, i) => {
88
+ const isSelected = i === selectedIndex;
89
+ const isChecked = checked.has(label.name);
90
+ const color = todoistColorMap[label.color] ?? "white";
91
+ return (
92
+ <Box key={label.id}>
93
+ <Text
94
+ backgroundColor={isSelected ? "blue" : undefined}
95
+ color={isSelected ? "white" : undefined}
96
+ >
97
+ <Text>{isChecked ? " [x] " : " [ ] "}</Text>
98
+ <Text color={isSelected ? "white" : color}>@{label.name}</Text>
99
+ </Text>
100
+ </Box>
101
+ );
102
+ })
103
+ )}
104
+ </Box>
105
+ <Box marginTop={1}>
106
+ <Text color="gray" dimColor>[Space] toggle [Enter] save [Esc] cancel</Text>
107
+ </Box>
108
+ </Box>
109
+ );
110
+ }
@@ -0,0 +1,275 @@
1
+ import React from "react";
2
+ import { Text } from "ink";
3
+ import type { Task, Project, Label, CreateTaskParams, UpdateTaskParams } from "../../api/types.ts";
4
+ import { InputPrompt } from "./InputPrompt.tsx";
5
+ import { ConfirmDialog } from "./ConfirmDialog.tsx";
6
+ import { HelpOverlay } from "./HelpOverlay.tsx";
7
+ import { SortMenu } from "./SortMenu.tsx";
8
+ import type { SortField } from "./SortMenu.tsx";
9
+ import { CommandPalette } from "./CommandPalette.tsx";
10
+ import type { Command } from "./CommandPalette.tsx";
11
+ import { ProjectPicker } from "./ProjectPicker.tsx";
12
+ import { LabelPicker } from "./LabelPicker.tsx";
13
+ import { EditTaskModal } from "./EditTaskModal.tsx";
14
+ import type { ExtensionRegistry } from "../../plugins/types.ts";
15
+
16
+ type Modal = "none" | "add" | "addSubtask" | "edit" | "delete" | "filter" | "search" | "help" | "sort" | "bulkDelete" | "command" | "due" | "deadline" | "move" | "label" | "editFull" | "createFull" | "rename" | "pluginInput" | "createProject" | "createLabel";
17
+
18
+ interface ModalManagerProps {
19
+ modal: Modal;
20
+ setModal: (modal: Modal) => void;
21
+ selectedTask: Task | undefined;
22
+ selectedIds: Set<string>;
23
+ projects: Project[];
24
+ labels: Label[];
25
+ searchQuery: string;
26
+ setSearchQuery: (q: string) => void;
27
+ sortField: SortField;
28
+ sortDirection: "asc" | "desc";
29
+ filterProjectId: string | undefined;
30
+ filterView: string;
31
+ commands: Command[];
32
+ pluginExtensions?: ExtensionRegistry | null;
33
+ // Handlers
34
+ handleAddTask: (input: string) => Promise<void>;
35
+ handleCreateTaskFull: (params: CreateTaskParams) => Promise<void>;
36
+ handleAddSubtask: (input: string) => Promise<void>;
37
+ handleEditTask: (content: string) => Promise<void>;
38
+ handleRenameTask: (content: string) => Promise<void>;
39
+ handleEditTaskFull: (params: UpdateTaskParams) => Promise<void>;
40
+ handleDeleteConfirm: () => Promise<void>;
41
+ handleBulkDeleteConfirm: () => Promise<void>;
42
+ handleSetDueDate: (due: string) => Promise<void>;
43
+ handleSetDeadline: (deadline: string) => Promise<void>;
44
+ handleMoveToProject: (projectId: string) => Promise<void>;
45
+ handleLabelsSave: (labels: string[]) => Promise<void>;
46
+ handleFilterInput: (query: string) => Promise<void>;
47
+ handleSortSelect: (field: SortField) => void;
48
+ handleSearchSubmit: (value: string) => void;
49
+ handleSearchCancel: () => void;
50
+ renderQuickAddPreview: (value: string) => React.ReactNode;
51
+ handleCreateProject: (name: string) => Promise<void>;
52
+ handleCreateLabel: (name: string) => Promise<void>;
53
+ pendingPluginInput?: {
54
+ label: string;
55
+ placeholder?: string;
56
+ formatPreview?: (value: string) => string;
57
+ } | null;
58
+ handlePluginInput?: (value: string) => void;
59
+ }
60
+
61
+ export function ModalManager({
62
+ modal,
63
+ setModal,
64
+ selectedTask,
65
+ selectedIds,
66
+ projects,
67
+ labels,
68
+ searchQuery,
69
+ setSearchQuery,
70
+ sortField,
71
+ sortDirection,
72
+ filterProjectId,
73
+ filterView,
74
+ commands,
75
+ pluginExtensions,
76
+ handleAddTask,
77
+ handleCreateTaskFull,
78
+ handleAddSubtask,
79
+ handleEditTask,
80
+ handleRenameTask,
81
+ handleEditTaskFull,
82
+ handleDeleteConfirm,
83
+ handleBulkDeleteConfirm,
84
+ handleSetDueDate,
85
+ handleSetDeadline,
86
+ handleMoveToProject,
87
+ handleLabelsSave,
88
+ handleFilterInput,
89
+ handleSortSelect,
90
+ handleSearchSubmit,
91
+ handleSearchCancel,
92
+ handleCreateProject,
93
+ handleCreateLabel,
94
+ renderQuickAddPreview,
95
+ pendingPluginInput,
96
+ handlePluginInput,
97
+ }: ModalManagerProps) {
98
+ return (
99
+ <>
100
+ {modal === "add" && (
101
+ <InputPrompt
102
+ prompt="New task"
103
+ placeholder="Buy milk tomorrow #Shopping p1 @errands"
104
+ onSubmit={handleAddTask}
105
+ onCancel={() => setModal("none")}
106
+ onCtrlE={() => setModal("createFull")}
107
+ onPreview={renderQuickAddPreview}
108
+ footer={
109
+ <Text color="gray" dimColor>
110
+ [Enter] create & continue [Ctrl-E] full editor [Esc] close
111
+ </Text>
112
+ }
113
+ />
114
+ )}
115
+ {modal === "createFull" && (
116
+ <EditTaskModal
117
+ projects={projects}
118
+ labels={labels}
119
+ onSave={() => {}}
120
+ onCreate={handleCreateTaskFull}
121
+ onCancel={() => setModal("none")}
122
+ defaultProjectId={filterProjectId}
123
+ defaultDue={filterView === "Today" ? "today" : undefined}
124
+ />
125
+ )}
126
+ {modal === "addSubtask" && selectedTask && (
127
+ <InputPrompt
128
+ prompt={`Subtask of "${selectedTask.content}"`}
129
+ onSubmit={handleAddSubtask}
130
+ onCancel={() => setModal("none")}
131
+ />
132
+ )}
133
+ {modal === "edit" && selectedTask && (
134
+ <InputPrompt
135
+ prompt="Edit task"
136
+ defaultValue={selectedTask.content}
137
+ onSubmit={handleEditTask}
138
+ onCancel={() => setModal("none")}
139
+ />
140
+ )}
141
+ {modal === "rename" && selectedTask && (
142
+ <InputPrompt
143
+ prompt="Rename"
144
+ defaultValue={selectedTask.content}
145
+ onSubmit={handleRenameTask}
146
+ onCancel={() => setModal("none")}
147
+ />
148
+ )}
149
+ {modal === "editFull" && selectedTask && (
150
+ <EditTaskModal
151
+ task={selectedTask}
152
+ projects={projects}
153
+ labels={labels}
154
+ onSave={handleEditTaskFull}
155
+ onCancel={() => setModal("none")}
156
+ />
157
+ )}
158
+ {modal === "due" && (
159
+ <InputPrompt
160
+ prompt="Due date"
161
+ onSubmit={handleSetDueDate}
162
+ onCancel={() => setModal("none")}
163
+ />
164
+ )}
165
+ {modal === "deadline" && (
166
+ <InputPrompt
167
+ prompt="Deadline (YYYY-MM-DD)"
168
+ onSubmit={handleSetDeadline}
169
+ onCancel={() => setModal("none")}
170
+ />
171
+ )}
172
+ {modal === "move" && (
173
+ <ProjectPicker
174
+ projects={projects}
175
+ onSelect={handleMoveToProject}
176
+ onCancel={() => setModal("none")}
177
+ />
178
+ )}
179
+ {modal === "label" && (selectedTask || selectedIds.size > 0) && (
180
+ <LabelPicker
181
+ labels={labels}
182
+ currentLabels={selectedTask?.labels ?? []}
183
+ onSave={handleLabelsSave}
184
+ onCancel={() => setModal("none")}
185
+ />
186
+ )}
187
+ {modal === "delete" && selectedTask && (
188
+ <ConfirmDialog
189
+ message={`Delete "${selectedTask.content}"?`}
190
+ onConfirm={handleDeleteConfirm}
191
+ onCancel={() => setModal("none")}
192
+ />
193
+ )}
194
+ {modal === "bulkDelete" && (
195
+ <ConfirmDialog
196
+ message={`Delete ${selectedIds.size} selected tasks?`}
197
+ onConfirm={handleBulkDeleteConfirm}
198
+ onCancel={() => setModal("none")}
199
+ />
200
+ )}
201
+ {modal === "filter" && (
202
+ <InputPrompt
203
+ prompt="Filter"
204
+ onSubmit={handleFilterInput}
205
+ onCancel={() => setModal("none")}
206
+ />
207
+ )}
208
+ {modal === "search" && (
209
+ <InputPrompt
210
+ prompt="Search"
211
+ defaultValue={searchQuery}
212
+ onSubmit={(val) => {
213
+ setSearchQuery(val);
214
+ handleSearchSubmit(val);
215
+ }}
216
+ onCancel={handleSearchCancel}
217
+ />
218
+ )}
219
+ {modal === "help" && (
220
+ <HelpOverlay onClose={() => setModal("none")} pluginKeybindings={pluginExtensions?.getKeybindings()} />
221
+ )}
222
+ {modal === "sort" && (
223
+ <SortMenu
224
+ currentSort={sortField}
225
+ currentDirection={sortDirection}
226
+ onSelect={handleSortSelect}
227
+ onCancel={() => setModal("none")}
228
+ />
229
+ )}
230
+ {modal === "pluginInput" && pendingPluginInput && handlePluginInput && (
231
+ <InputPrompt
232
+ prompt={pendingPluginInput.label}
233
+ placeholder={pendingPluginInput.placeholder}
234
+ onSubmit={(val) => {
235
+ handlePluginInput(val);
236
+ }}
237
+ onCancel={() => setModal("none")}
238
+ onPreview={pendingPluginInput.formatPreview ? (val: string) => {
239
+ const preview = pendingPluginInput.formatPreview!(val);
240
+ return <Text color={preview.startsWith("Invalid") || preview.startsWith("!") ? "red" : "green"}>{preview}</Text>;
241
+ } : undefined}
242
+ footer={
243
+ <Text color="gray" dimColor>
244
+ [Enter] confirm [Esc] cancel
245
+ </Text>
246
+ }
247
+ />
248
+ )}
249
+ {modal === "createProject" && (
250
+ <InputPrompt
251
+ prompt="New project name"
252
+ placeholder="My Project"
253
+ onSubmit={handleCreateProject}
254
+ onCancel={() => setModal("none")}
255
+ />
256
+ )}
257
+ {modal === "createLabel" && (
258
+ <InputPrompt
259
+ prompt="New label name"
260
+ placeholder="my-label"
261
+ onSubmit={handleCreateLabel}
262
+ onCancel={() => setModal("none")}
263
+ />
264
+ )}
265
+ {modal === "command" && (
266
+ <CommandPalette
267
+ commands={commands}
268
+ onCancel={() => setModal("none")}
269
+ />
270
+ )}
271
+ </>
272
+ );
273
+ }
274
+
275
+ export type { Modal };
@@ -0,0 +1,95 @@
1
+ import { useState, useMemo } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import type { Project } from "../../api/types.ts";
4
+ import { todoistColorMap } from "../../utils/colors.ts";
5
+
6
+ interface ProjectPickerProps {
7
+ projects: Project[];
8
+ onSelect: (projectId: string) => void;
9
+ onCancel: () => void;
10
+ }
11
+
12
+ export function ProjectPicker({ projects, onSelect, onCancel }: ProjectPickerProps) {
13
+ const [filterText, setFilterText] = useState("");
14
+ const [selectedIndex, setSelectedIndex] = useState(0);
15
+
16
+ const filtered = useMemo(() => {
17
+ if (!filterText) return projects;
18
+ const q = filterText.toLowerCase();
19
+ return projects.filter((p) => p.name.toLowerCase().includes(q));
20
+ }, [projects, filterText]);
21
+
22
+ useInput((input, key) => {
23
+ if (key.escape) {
24
+ onCancel();
25
+ return;
26
+ }
27
+ if (key.return) {
28
+ const project = filtered[selectedIndex];
29
+ if (project) {
30
+ onSelect(project.id);
31
+ }
32
+ return;
33
+ }
34
+ if (key.backspace || key.delete) {
35
+ setFilterText((v) => v.slice(0, -1));
36
+ setSelectedIndex(0);
37
+ return;
38
+ }
39
+ if (key.upArrow || (input === "k" && key.ctrl)) {
40
+ setSelectedIndex((i) => Math.max(0, i - 1));
41
+ return;
42
+ }
43
+ if (key.downArrow || (input === "j" && key.ctrl)) {
44
+ setSelectedIndex((i) => Math.min(filtered.length - 1, i + 1));
45
+ return;
46
+ }
47
+ if (input && !key.ctrl && !key.meta) {
48
+ setFilterText((v) => v + input);
49
+ setSelectedIndex(0);
50
+ }
51
+ });
52
+
53
+ return (
54
+ <Box
55
+ flexDirection="column"
56
+ borderStyle="single"
57
+ borderColor="cyan"
58
+ paddingX={1}
59
+ width={40}
60
+ >
61
+ <Box marginBottom={1}>
62
+ <Text bold color="cyan">Move to project</Text>
63
+ </Box>
64
+ <Box>
65
+ <Text color="yellow">{"> "}</Text>
66
+ <Text>{filterText || ""}</Text>
67
+ <Text backgroundColor="white" color="black">{" "}</Text>
68
+ </Box>
69
+ <Box flexDirection="column" marginTop={1}>
70
+ {filtered.length === 0 ? (
71
+ <Text color="gray">No matching projects</Text>
72
+ ) : (
73
+ filtered.slice(0, 15).map((project, i) => {
74
+ const isSelected = i === selectedIndex;
75
+ const color = todoistColorMap[project.color] ?? "white";
76
+ return (
77
+ <Box key={project.id}>
78
+ <Text
79
+ backgroundColor={isSelected ? "blue" : undefined}
80
+ color={isSelected ? "white" : undefined}
81
+ >
82
+ <Text color={isSelected ? "white" : color}>{" "}{project.is_inbox_project ? "> " : " "}</Text>
83
+ <Text>{project.name}</Text>
84
+ </Text>
85
+ </Box>
86
+ );
87
+ })
88
+ )}
89
+ </Box>
90
+ <Box marginTop={1}>
91
+ <Text color="gray" dimColor>[Enter] select [Esc] cancel</Text>
92
+ </Box>
93
+ </Box>
94
+ );
95
+ }