@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.
- package/marketplace.json +16 -0
- package/package.json +7 -6
- package/src/api/activity.ts +8 -0
- package/src/api/client.ts +214 -0
- package/src/api/comments.ts +18 -0
- package/src/api/completed.ts +15 -0
- package/src/api/labels.ts +18 -0
- package/src/api/projects.ts +22 -0
- package/src/api/sections.ts +20 -0
- package/src/api/stats.ts +38 -0
- package/src/api/tasks.ts +34 -0
- package/src/api/types.ts +202 -0
- package/src/cli/auth.ts +40 -0
- package/src/cli/commands/task/add.ts +328 -0
- package/src/cli/commands/task/complete.ts +62 -0
- package/src/cli/commands/task/delete.ts +62 -0
- package/src/cli/commands/task/helpers.ts +289 -0
- package/src/cli/commands/task/index.ts +27 -0
- package/src/cli/commands/task/list.ts +151 -0
- package/src/cli/commands/task/move.ts +49 -0
- package/src/cli/commands/task/reopen.ts +43 -0
- package/src/cli/commands/task/show.ts +115 -0
- package/src/cli/commands/task/update.ts +122 -0
- package/src/cli/comment.ts +83 -0
- package/src/cli/completed.ts +87 -0
- package/src/cli/completion.ts +360 -0
- package/src/cli/filter.ts +115 -0
- package/src/cli/index.ts +638 -0
- package/src/cli/label.ts +120 -0
- package/src/cli/log.ts +57 -0
- package/src/cli/matrix.ts +100 -0
- package/src/cli/plugin-loader.ts +38 -0
- package/src/cli/plugin.ts +289 -0
- package/src/cli/project.ts +172 -0
- package/src/cli/review.ts +116 -0
- package/src/cli/section.ts +98 -0
- package/src/cli/stats.ts +62 -0
- package/src/cli/template.ts +89 -0
- package/src/config/index.ts +229 -0
- package/src/plugins/api-proxy.ts +70 -0
- package/src/plugins/extension-registry.ts +53 -0
- package/src/plugins/hook-registry.ts +36 -0
- package/src/plugins/loader.ts +200 -0
- package/src/plugins/marketplace-types.ts +55 -0
- package/src/plugins/marketplace.ts +576 -0
- package/src/plugins/palette-registry.ts +21 -0
- package/src/plugins/storage.ts +101 -0
- package/src/plugins/types.ts +226 -0
- package/src/plugins/view-registry.ts +19 -0
- package/src/ui/App.tsx +234 -0
- package/src/ui/components/Breadcrumb.tsx +18 -0
- package/src/ui/components/CommandPalette.tsx +237 -0
- package/src/ui/components/ConfirmDialog.tsx +28 -0
- package/src/ui/components/EditTaskModal.tsx +484 -0
- package/src/ui/components/HelpOverlay.tsx +195 -0
- package/src/ui/components/InputPrompt.tsx +109 -0
- package/src/ui/components/LabelPicker.tsx +110 -0
- package/src/ui/components/ModalManager.tsx +275 -0
- package/src/ui/components/ProjectPicker.tsx +95 -0
- package/src/ui/components/Sidebar.tsx +282 -0
- package/src/ui/components/SortMenu.tsx +77 -0
- package/src/ui/components/StatusBar.tsx +67 -0
- package/src/ui/components/TaskList.tsx +258 -0
- package/src/ui/components/TaskRow.tsx +105 -0
- package/src/ui/hooks/useKeyboardHandler.ts +291 -0
- package/src/ui/hooks/useStatusMessage.ts +32 -0
- package/src/ui/hooks/useTaskOperations.ts +558 -0
- package/src/ui/hooks/useUndoSystem.ts +218 -0
- package/src/ui/views/ActivityView.tsx +213 -0
- package/src/ui/views/CompletedView.tsx +337 -0
- package/src/ui/views/StatsView.tsx +178 -0
- package/src/ui/views/TaskDetailView.tsx +438 -0
- package/src/ui/views/TasksView.tsx +851 -0
- package/src/utils/colors.ts +27 -0
- package/src/utils/date-format.ts +54 -0
- package/src/utils/errors.ts +159 -0
- package/src/utils/exit.ts +11 -0
- package/src/utils/format.ts +46 -0
- package/src/utils/open-url.ts +9 -0
- package/src/utils/output.ts +29 -0
- package/src/utils/quick-add.ts +202 -0
- package/src/utils/resolve.ts +359 -0
- package/src/utils/sorting.ts +27 -0
- package/src/utils/validation.ts +88 -0
- package/dist/index.js +0 -10989
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { useState, useCallback, useMemo } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import type { Task, Project, Label, Priority, UpdateTaskParams, CreateTaskParams } from "../../api/types.ts";
|
|
4
|
+
|
|
5
|
+
interface EditTaskModalProps {
|
|
6
|
+
task?: Task;
|
|
7
|
+
projects: Project[];
|
|
8
|
+
labels: Label[];
|
|
9
|
+
onSave: (params: UpdateTaskParams & { project_id?: string }) => void;
|
|
10
|
+
onCreate?: (params: CreateTaskParams) => void;
|
|
11
|
+
onCancel: () => void;
|
|
12
|
+
defaultProjectId?: string;
|
|
13
|
+
defaultDue?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type FieldName = "content" | "description" | "priority" | "due" | "deadline" | "labels" | "project";
|
|
17
|
+
const FIELDS: FieldName[] = ["content", "description", "priority", "due", "deadline", "labels", "project"];
|
|
18
|
+
|
|
19
|
+
const priorityLabels: Record<number, { label: string; color: string; dot: string }> = {
|
|
20
|
+
1: { label: "Normal", color: "white", dot: "○" },
|
|
21
|
+
2: { label: "Medium", color: "blue", dot: "●" },
|
|
22
|
+
3: { label: "High", color: "yellow", dot: "●" },
|
|
23
|
+
4: { label: "Urgent", color: "red", dot: "●" },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const VIEW_SIZE = 8;
|
|
27
|
+
|
|
28
|
+
interface CursorState {
|
|
29
|
+
content: number;
|
|
30
|
+
description: number;
|
|
31
|
+
due: number;
|
|
32
|
+
deadline: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface FormState {
|
|
36
|
+
content: string;
|
|
37
|
+
description: string;
|
|
38
|
+
due: string;
|
|
39
|
+
deadline: string;
|
|
40
|
+
priority: Priority;
|
|
41
|
+
labels: Set<string>;
|
|
42
|
+
projectId: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Handle text input for a field: backspace, arrows, character insertion. Returns true if the input was handled. */
|
|
46
|
+
function handleTextInput(
|
|
47
|
+
input: string,
|
|
48
|
+
key: { backspace?: boolean; delete?: boolean; leftArrow?: boolean; rightArrow?: boolean; ctrl?: boolean; meta?: boolean },
|
|
49
|
+
value: string,
|
|
50
|
+
cursor: number,
|
|
51
|
+
setValue: (updater: (v: string) => string) => void,
|
|
52
|
+
setCursor: (updater: (c: number) => number) => void,
|
|
53
|
+
): boolean {
|
|
54
|
+
if (key.backspace || key.delete) {
|
|
55
|
+
if (cursor > 0) {
|
|
56
|
+
setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor));
|
|
57
|
+
setCursor((c) => c - 1);
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
if (key.leftArrow) {
|
|
62
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
if (key.rightArrow) {
|
|
66
|
+
setCursor((c) => Math.min(value.length, c + 1));
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
if (input && !key.ctrl && !key.meta) {
|
|
70
|
+
setValue((v) => v.slice(0, cursor) + input + v.slice(cursor));
|
|
71
|
+
setCursor((c) => c + input.length);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function EditTaskModal({ task, projects, labels, onSave, onCreate, onCancel, defaultProjectId, defaultDue }: EditTaskModalProps) {
|
|
78
|
+
const isCreateMode = !task;
|
|
79
|
+
|
|
80
|
+
const initialProjectId = task?.project_id ?? defaultProjectId ?? projects[0]?.id ?? "";
|
|
81
|
+
const initialDue = task?.due?.string ?? defaultDue ?? "";
|
|
82
|
+
|
|
83
|
+
const [activeField, setActiveField] = useState(0);
|
|
84
|
+
|
|
85
|
+
const [form, setForm] = useState<FormState>({
|
|
86
|
+
content: task?.content ?? "",
|
|
87
|
+
description: task?.description ?? "",
|
|
88
|
+
due: initialDue,
|
|
89
|
+
deadline: task?.deadline?.date ?? "",
|
|
90
|
+
priority: task?.priority ?? 1,
|
|
91
|
+
labels: new Set(task?.labels ?? []),
|
|
92
|
+
projectId: initialProjectId,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const [cursors, setCursors] = useState<CursorState>({
|
|
96
|
+
content: (task?.content ?? "").length,
|
|
97
|
+
description: (task?.description ?? "").length,
|
|
98
|
+
due: initialDue.length,
|
|
99
|
+
deadline: (task?.deadline?.date ?? "").length,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const [labelIndex, setLabelIndex] = useState(0);
|
|
103
|
+
const [labelScrollOffset, setLabelScrollOffset] = useState(0);
|
|
104
|
+
const [projectIndex, setProjectIndex] = useState(
|
|
105
|
+
Math.max(0, projects.findIndex((p) => p.id === initialProjectId))
|
|
106
|
+
);
|
|
107
|
+
const [projectScrollOffset, setProjectScrollOffset] = useState(0);
|
|
108
|
+
const [confirmDiscard, setConfirmDiscard] = useState(false);
|
|
109
|
+
|
|
110
|
+
const setFormField = useCallback(<K extends keyof FormState>(field: K, value: FormState[K]) => {
|
|
111
|
+
setForm((prev) => ({ ...prev, [field]: value }));
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
const isDirty = useMemo(() => {
|
|
115
|
+
const initContent = task?.content ?? "";
|
|
116
|
+
const initDesc = task?.description ?? "";
|
|
117
|
+
const initPriority = task?.priority ?? 1;
|
|
118
|
+
const initDue = task?.due?.string ?? defaultDue ?? "";
|
|
119
|
+
const initDeadline = task?.deadline?.date ?? "";
|
|
120
|
+
const initLabels = new Set(task?.labels ?? []);
|
|
121
|
+
const initProject = initialProjectId;
|
|
122
|
+
if (form.content !== initContent) return true;
|
|
123
|
+
if (form.description !== initDesc) return true;
|
|
124
|
+
if (form.priority !== initPriority) return true;
|
|
125
|
+
if (form.due !== initDue) return true;
|
|
126
|
+
if (form.deadline !== initDeadline) return true;
|
|
127
|
+
if (form.projectId !== initProject) return true;
|
|
128
|
+
if (form.labels.size !== initLabels.size) return true;
|
|
129
|
+
for (const l of form.labels) {
|
|
130
|
+
if (!initLabels.has(l)) return true;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}, [form, task, defaultDue, initialProjectId]);
|
|
134
|
+
|
|
135
|
+
const handleSave = useCallback(() => {
|
|
136
|
+
if (isCreateMode && onCreate) {
|
|
137
|
+
const params: CreateTaskParams = { content: form.content };
|
|
138
|
+
if (form.description) params.description = form.description;
|
|
139
|
+
if (form.priority !== 1) params.priority = form.priority;
|
|
140
|
+
if (form.due) params.due_string = form.due;
|
|
141
|
+
if (form.deadline) params.deadline_date = form.deadline;
|
|
142
|
+
const labelsList = Array.from(form.labels);
|
|
143
|
+
if (labelsList.length > 0) params.labels = labelsList;
|
|
144
|
+
if (form.projectId) params.project_id = form.projectId;
|
|
145
|
+
onCreate(params);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!task) return;
|
|
150
|
+
const params: UpdateTaskParams & { project_id?: string } = {};
|
|
151
|
+
if (form.content !== task.content) params.content = form.content;
|
|
152
|
+
if (form.description !== task.description) params.description = form.description;
|
|
153
|
+
if (form.priority !== task.priority) params.priority = form.priority;
|
|
154
|
+
if (form.due !== (task.due?.string ?? "")) {
|
|
155
|
+
if (form.due === "" || form.due.toLowerCase() === "none" || form.due.toLowerCase() === "clear") {
|
|
156
|
+
params.due_string = "no date";
|
|
157
|
+
} else {
|
|
158
|
+
params.due_string = form.due;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const oldDeadline = task.deadline?.date ?? "";
|
|
162
|
+
if (form.deadline !== oldDeadline) {
|
|
163
|
+
if (form.deadline === "" || form.deadline.toLowerCase() === "none" || form.deadline.toLowerCase() === "clear") {
|
|
164
|
+
params.deadline_date = null;
|
|
165
|
+
} else {
|
|
166
|
+
params.deadline_date = form.deadline;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const newLabels = Array.from(form.labels);
|
|
170
|
+
const oldLabels = [...task.labels].sort();
|
|
171
|
+
const sortedNew = [...newLabels].sort();
|
|
172
|
+
if (JSON.stringify(oldLabels) !== JSON.stringify(sortedNew)) {
|
|
173
|
+
params.labels = newLabels;
|
|
174
|
+
}
|
|
175
|
+
if (form.projectId !== task.project_id) params.project_id = form.projectId;
|
|
176
|
+
onSave(params);
|
|
177
|
+
}, [form, task, onSave, onCreate, isCreateMode]);
|
|
178
|
+
|
|
179
|
+
const currentField = FIELDS[activeField];
|
|
180
|
+
|
|
181
|
+
// Compute visible window for labels
|
|
182
|
+
const visibleLabels = useMemo(() => {
|
|
183
|
+
return labels.slice(labelScrollOffset, labelScrollOffset + VIEW_SIZE);
|
|
184
|
+
}, [labels, labelScrollOffset]);
|
|
185
|
+
|
|
186
|
+
// Compute visible window for projects
|
|
187
|
+
const visibleProjects = useMemo(() => {
|
|
188
|
+
return projects.slice(projectScrollOffset, projectScrollOffset + VIEW_SIZE);
|
|
189
|
+
}, [projects, projectScrollOffset]);
|
|
190
|
+
|
|
191
|
+
const scrollLabelTo = useCallback((newIndex: number) => {
|
|
192
|
+
setLabelIndex(newIndex);
|
|
193
|
+
if (newIndex < labelScrollOffset) {
|
|
194
|
+
setLabelScrollOffset(newIndex);
|
|
195
|
+
} else if (newIndex >= labelScrollOffset + VIEW_SIZE) {
|
|
196
|
+
setLabelScrollOffset(newIndex - VIEW_SIZE + 1);
|
|
197
|
+
}
|
|
198
|
+
}, [labelScrollOffset]);
|
|
199
|
+
|
|
200
|
+
const scrollProjectTo = useCallback((newIndex: number) => {
|
|
201
|
+
setProjectIndex(newIndex);
|
|
202
|
+
setFormField("projectId", projects[newIndex]?.id ?? form.projectId);
|
|
203
|
+
if (newIndex < projectScrollOffset) {
|
|
204
|
+
setProjectScrollOffset(newIndex);
|
|
205
|
+
} else if (newIndex >= projectScrollOffset + VIEW_SIZE) {
|
|
206
|
+
setProjectScrollOffset(newIndex - VIEW_SIZE + 1);
|
|
207
|
+
}
|
|
208
|
+
}, [projectScrollOffset, projects, form.projectId, setFormField]);
|
|
209
|
+
|
|
210
|
+
useInput((input, key) => {
|
|
211
|
+
// Handle discard confirmation
|
|
212
|
+
if (confirmDiscard) {
|
|
213
|
+
if (input === "y" || input === "Y") {
|
|
214
|
+
onCancel();
|
|
215
|
+
} else {
|
|
216
|
+
setConfirmDiscard(false);
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// Ctrl-S to save from anywhere
|
|
221
|
+
if (key.ctrl && input === "s") {
|
|
222
|
+
handleSave();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (key.escape) {
|
|
226
|
+
if (isDirty) {
|
|
227
|
+
setConfirmDiscard(true);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
onCancel();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
// Ctrl-P: backward field navigation (alternative to Shift-Tab for terminals that don't support it)
|
|
234
|
+
if (key.ctrl && input === "p") {
|
|
235
|
+
setActiveField((i) => (i > 0 ? i - 1 : FIELDS.length - 1));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (key.tab) {
|
|
239
|
+
if (key.shift) {
|
|
240
|
+
setActiveField((i) => (i > 0 ? i - 1 : FIELDS.length - 1));
|
|
241
|
+
} else {
|
|
242
|
+
setActiveField((i) => (i < FIELDS.length - 1 ? i + 1 : 0));
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Field-specific input handling
|
|
248
|
+
if (currentField === "content") {
|
|
249
|
+
if (key.return) {
|
|
250
|
+
setActiveField((i) => Math.min(FIELDS.length - 1, i + 1));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
handleTextInput(
|
|
254
|
+
input, key, form.content, cursors.content,
|
|
255
|
+
(updater) => setForm((f) => ({ ...f, content: updater(f.content) })),
|
|
256
|
+
(updater) => setCursors((c) => ({ ...c, content: updater(c.content) })),
|
|
257
|
+
);
|
|
258
|
+
} else if (currentField === "description") {
|
|
259
|
+
if (key.return) {
|
|
260
|
+
setActiveField((i) => Math.min(FIELDS.length - 1, i + 1));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
handleTextInput(
|
|
264
|
+
input, key, form.description, cursors.description,
|
|
265
|
+
(updater) => setForm((f) => ({ ...f, description: updater(f.description) })),
|
|
266
|
+
(updater) => setCursors((c) => ({ ...c, description: updater(c.description) })),
|
|
267
|
+
);
|
|
268
|
+
} else if (currentField === "priority") {
|
|
269
|
+
if (input === "1" || input === "2" || input === "3" || input === "4") {
|
|
270
|
+
setFormField("priority", Number(input) as Priority);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (key.return) {
|
|
274
|
+
setActiveField((i) => Math.min(FIELDS.length - 1, i + 1));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
} else if (currentField === "due") {
|
|
278
|
+
if (key.return) {
|
|
279
|
+
setActiveField((i) => Math.min(FIELDS.length - 1, i + 1));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
handleTextInput(
|
|
283
|
+
input, key, form.due, cursors.due,
|
|
284
|
+
(updater) => setForm((f) => ({ ...f, due: updater(f.due) })),
|
|
285
|
+
(updater) => setCursors((c) => ({ ...c, due: updater(c.due) })),
|
|
286
|
+
);
|
|
287
|
+
} else if (currentField === "deadline") {
|
|
288
|
+
if (key.return) {
|
|
289
|
+
setActiveField((i) => Math.min(FIELDS.length - 1, i + 1));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
handleTextInput(
|
|
293
|
+
input, key, form.deadline, cursors.deadline,
|
|
294
|
+
(updater) => setForm((f) => ({ ...f, deadline: updater(f.deadline) })),
|
|
295
|
+
(updater) => setCursors((c) => ({ ...c, deadline: updater(c.deadline) })),
|
|
296
|
+
);
|
|
297
|
+
} else if (currentField === "labels") {
|
|
298
|
+
if (key.return) {
|
|
299
|
+
setActiveField((i) => Math.min(FIELDS.length - 1, i + 1));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (key.upArrow || input === "k") {
|
|
303
|
+
scrollLabelTo(Math.max(0, labelIndex - 1));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (key.downArrow || input === "j") {
|
|
307
|
+
scrollLabelTo(Math.min(labels.length - 1, labelIndex + 1));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (input === " ") {
|
|
311
|
+
const label = labels[labelIndex];
|
|
312
|
+
if (label) {
|
|
313
|
+
setForm((prev) => {
|
|
314
|
+
const next = new Set(prev.labels);
|
|
315
|
+
if (next.has(label.name)) {
|
|
316
|
+
next.delete(label.name);
|
|
317
|
+
} else {
|
|
318
|
+
next.add(label.name);
|
|
319
|
+
}
|
|
320
|
+
return { ...prev, labels: next };
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
} else if (currentField === "project") {
|
|
326
|
+
if (key.return) {
|
|
327
|
+
handleSave();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (key.upArrow || input === "k") {
|
|
331
|
+
scrollProjectTo(Math.max(0, projectIndex - 1));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (key.downArrow || input === "j") {
|
|
335
|
+
scrollProjectTo(Math.min(projects.length - 1, projectIndex + 1));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const renderTextField = (label: string, value: string, cursor: number, isActive: boolean) => {
|
|
342
|
+
const before = value.slice(0, cursor);
|
|
343
|
+
const cursorChar = value[cursor] ?? " ";
|
|
344
|
+
const after = value.slice(cursor + 1);
|
|
345
|
+
return (
|
|
346
|
+
<Box>
|
|
347
|
+
<Box width={14}>
|
|
348
|
+
<Text color={isActive ? "yellow" : "gray"}>{label}:</Text>
|
|
349
|
+
</Box>
|
|
350
|
+
{isActive ? (
|
|
351
|
+
<Text>
|
|
352
|
+
<Text>{before}</Text>
|
|
353
|
+
<Text backgroundColor="white" color="black">{cursorChar}</Text>
|
|
354
|
+
<Text>{after}</Text>
|
|
355
|
+
</Text>
|
|
356
|
+
) : (
|
|
357
|
+
<Text color="gray">{value || "(empty)"}</Text>
|
|
358
|
+
)}
|
|
359
|
+
</Box>
|
|
360
|
+
);
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
return (
|
|
364
|
+
<Box
|
|
365
|
+
flexDirection="column"
|
|
366
|
+
borderStyle="single"
|
|
367
|
+
borderColor="cyan"
|
|
368
|
+
paddingX={2}
|
|
369
|
+
paddingY={1}
|
|
370
|
+
>
|
|
371
|
+
<Box marginBottom={1}>
|
|
372
|
+
<Text bold color="cyan">{isCreateMode ? "New Task" : "Edit Task"}</Text>
|
|
373
|
+
</Box>
|
|
374
|
+
|
|
375
|
+
{renderTextField("Content", form.content, cursors.content, currentField === "content")}
|
|
376
|
+
{renderTextField("Description", form.description, cursors.description, currentField === "description")}
|
|
377
|
+
|
|
378
|
+
{/* Priority */}
|
|
379
|
+
<Box>
|
|
380
|
+
<Box width={14}>
|
|
381
|
+
<Text color={currentField === "priority" ? "yellow" : "gray"}>Priority:</Text>
|
|
382
|
+
</Box>
|
|
383
|
+
{([1, 2, 3, 4] as const).map((p) => {
|
|
384
|
+
const info = priorityLabels[p]!;
|
|
385
|
+
const isActive = form.priority === p;
|
|
386
|
+
return (
|
|
387
|
+
<Box key={p} marginRight={1}>
|
|
388
|
+
<Text
|
|
389
|
+
color={isActive ? info.color : "gray"}
|
|
390
|
+
bold={isActive}
|
|
391
|
+
>
|
|
392
|
+
{isActive ? `[${info.dot} ${p}: ${info.label}]` : `${info.dot} ${p}: ${info.label}`}
|
|
393
|
+
</Text>
|
|
394
|
+
</Box>
|
|
395
|
+
);
|
|
396
|
+
})}
|
|
397
|
+
</Box>
|
|
398
|
+
|
|
399
|
+
{renderTextField("Due", form.due, cursors.due, currentField === "due")}
|
|
400
|
+
{renderTextField("Deadline", form.deadline, cursors.deadline, currentField === "deadline")}
|
|
401
|
+
|
|
402
|
+
{/* Labels */}
|
|
403
|
+
<Box flexDirection="column">
|
|
404
|
+
<Box>
|
|
405
|
+
<Box width={14}>
|
|
406
|
+
<Text color={currentField === "labels" ? "yellow" : "gray"}>Labels:</Text>
|
|
407
|
+
</Box>
|
|
408
|
+
<Text color="magenta">
|
|
409
|
+
{form.labels.size > 0
|
|
410
|
+
? Array.from(form.labels).map((l) => `@${l}`).join(" ")
|
|
411
|
+
: "(none)"}
|
|
412
|
+
</Text>
|
|
413
|
+
</Box>
|
|
414
|
+
{currentField === "labels" && labels.length > 0 && (
|
|
415
|
+
<Box flexDirection="column" marginLeft={14}>
|
|
416
|
+
{labelScrollOffset > 0 && (
|
|
417
|
+
<Text color="gray"> {"\u25B2"} {labelScrollOffset} more above</Text>
|
|
418
|
+
)}
|
|
419
|
+
{visibleLabels.map((label, i) => {
|
|
420
|
+
const actualIndex = labelScrollOffset + i;
|
|
421
|
+
return (
|
|
422
|
+
<Box key={label.id}>
|
|
423
|
+
<Text
|
|
424
|
+
backgroundColor={actualIndex === labelIndex ? "blue" : undefined}
|
|
425
|
+
color={actualIndex === labelIndex ? "white" : undefined}
|
|
426
|
+
>
|
|
427
|
+
{form.labels.has(label.name) ? "[x] " : "[ ] "}
|
|
428
|
+
<Text color={actualIndex === labelIndex ? "white" : "magenta"}>@{label.name}</Text>
|
|
429
|
+
</Text>
|
|
430
|
+
</Box>
|
|
431
|
+
);
|
|
432
|
+
})}
|
|
433
|
+
{labelScrollOffset + VIEW_SIZE < labels.length && (
|
|
434
|
+
<Text color="gray"> {"\u25BC"} {labels.length - labelScrollOffset - VIEW_SIZE} more below</Text>
|
|
435
|
+
)}
|
|
436
|
+
</Box>
|
|
437
|
+
)}
|
|
438
|
+
</Box>
|
|
439
|
+
|
|
440
|
+
{/* Project */}
|
|
441
|
+
<Box flexDirection="column">
|
|
442
|
+
<Box>
|
|
443
|
+
<Box width={14}>
|
|
444
|
+
<Text color={currentField === "project" ? "yellow" : "gray"}>Project:</Text>
|
|
445
|
+
</Box>
|
|
446
|
+
<Text color="cyan">
|
|
447
|
+
{projects.find((p) => p.id === form.projectId)?.name ?? "Unknown"}
|
|
448
|
+
</Text>
|
|
449
|
+
</Box>
|
|
450
|
+
{currentField === "project" && (
|
|
451
|
+
<Box flexDirection="column" marginLeft={14}>
|
|
452
|
+
{projectScrollOffset > 0 && (
|
|
453
|
+
<Text color="gray"> {"\u25B2"} {projectScrollOffset} more above</Text>
|
|
454
|
+
)}
|
|
455
|
+
{visibleProjects.map((project, i) => {
|
|
456
|
+
const actualIndex = projectScrollOffset + i;
|
|
457
|
+
return (
|
|
458
|
+
<Box key={project.id}>
|
|
459
|
+
<Text
|
|
460
|
+
backgroundColor={actualIndex === projectIndex ? "blue" : undefined}
|
|
461
|
+
color={actualIndex === projectIndex ? "white" : undefined}
|
|
462
|
+
>
|
|
463
|
+
{actualIndex === projectIndex ? "> " : " "}{project.name}
|
|
464
|
+
</Text>
|
|
465
|
+
</Box>
|
|
466
|
+
);
|
|
467
|
+
})}
|
|
468
|
+
{projectScrollOffset + VIEW_SIZE < projects.length && (
|
|
469
|
+
<Text color="gray"> {"\u25BC"} {projects.length - projectScrollOffset - VIEW_SIZE} more below</Text>
|
|
470
|
+
)}
|
|
471
|
+
</Box>
|
|
472
|
+
)}
|
|
473
|
+
</Box>
|
|
474
|
+
|
|
475
|
+
<Box marginTop={1}>
|
|
476
|
+
{confirmDiscard ? (
|
|
477
|
+
<Text color="yellow" bold>Discard changes? (y/n)</Text>
|
|
478
|
+
) : (
|
|
479
|
+
<Text color="gray" dimColor>[Tab] next field [Ctrl-S] save [Esc] cancel</Text>
|
|
480
|
+
)}
|
|
481
|
+
</Box>
|
|
482
|
+
</Box>
|
|
483
|
+
);
|
|
484
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { useState, useMemo } from "react";
|
|
2
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
3
|
+
import type { KeybindingDefinition } from "../../plugins/types.ts";
|
|
4
|
+
|
|
5
|
+
interface HelpOverlayProps {
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
pluginKeybindings?: KeybindingDefinition[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface KeyBinding {
|
|
11
|
+
key: string;
|
|
12
|
+
description: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Section {
|
|
16
|
+
title: string;
|
|
17
|
+
bindings: KeyBinding[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const helpSections: Section[] = [
|
|
21
|
+
{
|
|
22
|
+
title: "Navigation",
|
|
23
|
+
bindings: [
|
|
24
|
+
{ key: "j / Down", description: "Move down" },
|
|
25
|
+
{ key: "k / Up", description: "Move up" },
|
|
26
|
+
{ key: "gg", description: "Go to first task" },
|
|
27
|
+
{ key: "G", description: "Go to last task" },
|
|
28
|
+
{ key: "Ctrl-d", description: "Page down" },
|
|
29
|
+
{ key: "Ctrl-u", description: "Page up" },
|
|
30
|
+
{ key: "Tab", description: "Switch panel" },
|
|
31
|
+
{ key: "h", description: "Go to sidebar (from tasks)" },
|
|
32
|
+
{ key: "l", description: "Go to tasks (from sidebar)" },
|
|
33
|
+
{ key: "Enter", description: "Open task detail" },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
title: "Task Actions",
|
|
38
|
+
bindings: [
|
|
39
|
+
{ key: "a", description: "Add new task" },
|
|
40
|
+
{ key: "A (Shift-a)", description: "Add subtask" },
|
|
41
|
+
{ key: "e", description: "Edit task (full modal)" },
|
|
42
|
+
{ key: "r", description: "Rename task (inline)" },
|
|
43
|
+
{ key: "c", description: "Complete task" },
|
|
44
|
+
{ key: "d", description: "Delete task" },
|
|
45
|
+
{ key: "1 / 2 / 3 / 4", description: "Set priority" },
|
|
46
|
+
{ key: "t", description: "Set due date" },
|
|
47
|
+
{ key: "D (Shift-d)", description: "Set deadline" },
|
|
48
|
+
{ key: "m", description: "Move to project" },
|
|
49
|
+
{ key: "l", description: "Edit labels" },
|
|
50
|
+
{ key: "o", description: "Open in browser" },
|
|
51
|
+
{ key: "y", description: "Copy task URL" },
|
|
52
|
+
{ key: "Y (Shift-y)", description: "Duplicate task" },
|
|
53
|
+
{ key: "u", description: "Undo last action (10s)" },
|
|
54
|
+
{ key: "U (Shift-u)", description: "Redo last undo (10s)" },
|
|
55
|
+
{ key: "R (Shift-r)", description: "Refresh tasks" },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
title: "Selection",
|
|
60
|
+
bindings: [
|
|
61
|
+
{ key: "Space", description: "Toggle select task" },
|
|
62
|
+
{ key: "v", description: "Range select (press twice)" },
|
|
63
|
+
{ key: "Ctrl-a", description: "Select all visible" },
|
|
64
|
+
{ key: "Ctrl-n", description: "Clear all selection" },
|
|
65
|
+
{ key: "c", description: "Complete selected (multi)" },
|
|
66
|
+
{ key: "d", description: "Delete selected (multi)" },
|
|
67
|
+
{ key: "Esc", description: "Clear selection" },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
title: "Search & Sort",
|
|
72
|
+
bindings: [
|
|
73
|
+
{ key: "/", description: "Fuzzy search tasks" },
|
|
74
|
+
{ key: "s", description: "Open sort menu" },
|
|
75
|
+
{ key: "f", description: "API filter query" },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
title: "Quick Filters",
|
|
80
|
+
bindings: [
|
|
81
|
+
{ key: "!", description: "Show Inbox" },
|
|
82
|
+
{ key: "@", description: "Show Today" },
|
|
83
|
+
{ key: "#", description: "Show Upcoming" },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
title: "Detail View",
|
|
88
|
+
bindings: [
|
|
89
|
+
{ key: "e", description: "Edit full task" },
|
|
90
|
+
{ key: "c", description: "Complete task" },
|
|
91
|
+
{ key: "d", description: "Delete task" },
|
|
92
|
+
{ key: "1-4", description: "Set priority" },
|
|
93
|
+
{ key: "t", description: "Set due date" },
|
|
94
|
+
{ key: "D", description: "Set deadline" },
|
|
95
|
+
{ key: "m", description: "Move to project" },
|
|
96
|
+
{ key: "l", description: "Edit labels" },
|
|
97
|
+
{ key: "n", description: "Add comment" },
|
|
98
|
+
{ key: "o", description: "Open in browser" },
|
|
99
|
+
{ key: "j / k", description: "Scroll content" },
|
|
100
|
+
{ key: "Esc", description: "Go back" },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
title: "General",
|
|
105
|
+
bindings: [
|
|
106
|
+
{ key: ":", description: "Command palette" },
|
|
107
|
+
{ key: "?", description: "Toggle this help" },
|
|
108
|
+
{ key: "q", description: "Quit" },
|
|
109
|
+
{ key: "Esc", description: "Cancel / Go back" },
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
export function HelpOverlay({ onClose, pluginKeybindings }: HelpOverlayProps) {
|
|
115
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
116
|
+
const { stdout } = useStdout();
|
|
117
|
+
|
|
118
|
+
const allSections = useMemo(() => {
|
|
119
|
+
const sections = [...helpSections];
|
|
120
|
+
if (pluginKeybindings?.length) {
|
|
121
|
+
const groups = new Map<string, Array<{ key: string; description: string }>>();
|
|
122
|
+
for (const kb of pluginKeybindings) {
|
|
123
|
+
const existing = groups.get(kb.helpSection) ?? [];
|
|
124
|
+
existing.push({ key: kb.key, description: kb.description });
|
|
125
|
+
groups.set(kb.helpSection, existing);
|
|
126
|
+
}
|
|
127
|
+
for (const [title, bindings] of groups) {
|
|
128
|
+
sections.push({ title, bindings });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return sections;
|
|
132
|
+
}, [pluginKeybindings]);
|
|
133
|
+
|
|
134
|
+
// Build flat list of all lines for scrolling
|
|
135
|
+
const allLines: Array<{ type: "title"; text: string } | { type: "binding"; key: string; desc: string }> = [];
|
|
136
|
+
for (const section of allSections) {
|
|
137
|
+
allLines.push({ type: "title", text: section.title });
|
|
138
|
+
for (const b of section.bindings) {
|
|
139
|
+
allLines.push({ type: "binding", key: b.key, desc: b.description });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const viewHeight = Math.max(5, (stdout?.rows ?? 24) - 10);
|
|
143
|
+
const helpWidth = Math.min(60, Math.max(40, (stdout?.columns ?? 80) - 10));
|
|
144
|
+
const maxScroll = Math.max(0, allLines.length - viewHeight);
|
|
145
|
+
|
|
146
|
+
useInput((input, key) => {
|
|
147
|
+
if (input === "?" || input === "q" || key.escape) {
|
|
148
|
+
onClose();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (input === "j" || key.downArrow) {
|
|
152
|
+
setScrollOffset((s) => Math.min(s + 1, maxScroll));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (input === "k" || key.upArrow) {
|
|
156
|
+
setScrollOffset((s) => Math.max(0, s - 1));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const visibleLines = allLines.slice(scrollOffset, scrollOffset + viewHeight);
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<Box
|
|
165
|
+
flexDirection="column"
|
|
166
|
+
borderStyle="double"
|
|
167
|
+
borderColor="cyan"
|
|
168
|
+
paddingX={2}
|
|
169
|
+
paddingY={1}
|
|
170
|
+
width={helpWidth}
|
|
171
|
+
>
|
|
172
|
+
<Box justifyContent="center" marginBottom={1}>
|
|
173
|
+
<Text bold color="cyan">Keyboard Shortcuts</Text>
|
|
174
|
+
</Box>
|
|
175
|
+
{visibleLines.map((line, i) => {
|
|
176
|
+
if (line.type === "title") {
|
|
177
|
+
return (
|
|
178
|
+
<Text key={`t-${i}`} bold color="yellow">{line.text}</Text>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return (
|
|
182
|
+
<Box key={`b-${i}`}>
|
|
183
|
+
<Box width={16}>
|
|
184
|
+
<Text color="green">{line.key}</Text>
|
|
185
|
+
</Box>
|
|
186
|
+
<Text>{line.desc}</Text>
|
|
187
|
+
</Box>
|
|
188
|
+
);
|
|
189
|
+
})}
|
|
190
|
+
<Box justifyContent="center" marginTop={1}>
|
|
191
|
+
<Text color="gray" dimColor>j/k scroll | ? or Esc or q to close</Text>
|
|
192
|
+
</Box>
|
|
193
|
+
</Box>
|
|
194
|
+
);
|
|
195
|
+
}
|