@todu/pi-extensions 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/dist/domain/habit.d.ts +38 -0
- package/dist/domain/habit.js +1 -0
- package/dist/domain/note.d.ts +21 -0
- package/dist/domain/note.js +1 -0
- package/dist/domain/recurring.d.ts +29 -0
- package/dist/domain/recurring.js +1 -0
- package/dist/domain/task-actions.d.ts +24 -0
- package/dist/domain/task-actions.js +1 -0
- package/dist/domain/task.d.ts +50 -0
- package/dist/domain/task.js +1 -0
- package/dist/extension/current-task-context.d.ts +26 -0
- package/dist/extension/current-task-context.js +140 -0
- package/dist/extension/register-commands.d.ts +114 -0
- package/dist/extension/register-commands.js +1214 -0
- package/dist/extension/register-events.d.ts +3 -0
- package/dist/extension/register-events.js +30 -0
- package/dist/extension/register-tools.d.ts +17 -0
- package/dist/extension/register-tools.js +36 -0
- package/dist/extension/register-ui.d.ts +3 -0
- package/dist/extension/register-ui.js +7 -0
- package/dist/extension/sync-status-context.d.ts +26 -0
- package/dist/extension/sync-status-context.js +162 -0
- package/dist/extension/task-browse-filter-context.d.ts +16 -0
- package/dist/extension/task-browse-filter-context.js +40 -0
- package/dist/flows/browse-tasks.d.ts +7 -0
- package/dist/flows/browse-tasks.js +2 -0
- package/dist/flows/comment-on-task.d.ts +7 -0
- package/dist/flows/comment-on-task.js +2 -0
- package/dist/flows/create-task.d.ts +7 -0
- package/dist/flows/create-task.js +2 -0
- package/dist/flows/pick-current-task.d.ts +7 -0
- package/dist/flows/pick-current-task.js +4 -0
- package/dist/flows/show-task-detail.d.ts +7 -0
- package/dist/flows/show-task-detail.js +2 -0
- package/dist/flows/update-task.d.ts +7 -0
- package/dist/flows/update-task.js +2 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +12 -0
- package/dist/services/habit-service.d.ts +38 -0
- package/dist/services/habit-service.js +1 -0
- package/dist/services/note-service.d.ts +5 -0
- package/dist/services/note-service.js +1 -0
- package/dist/services/project-integration-service.d.ts +109 -0
- package/dist/services/project-integration-service.js +122 -0
- package/dist/services/project-service.d.ts +27 -0
- package/dist/services/project-service.js +8 -0
- package/dist/services/recurring-service.d.ts +37 -0
- package/dist/services/recurring-service.js +1 -0
- package/dist/services/repo-context.d.ts +55 -0
- package/dist/services/repo-context.js +135 -0
- package/dist/services/task-browse-filter-store.d.ts +31 -0
- package/dist/services/task-browse-filter-store.js +47 -0
- package/dist/services/task-service.d.ts +42 -0
- package/dist/services/task-service.js +1 -0
- package/dist/services/task-session-store.d.ts +30 -0
- package/dist/services/task-session-store.js +55 -0
- package/dist/services/todu/daemon-client.d.ts +93 -0
- package/dist/services/todu/daemon-client.js +660 -0
- package/dist/services/todu/daemon-config.d.ts +17 -0
- package/dist/services/todu/daemon-config.js +38 -0
- package/dist/services/todu/daemon-connection.d.ts +61 -0
- package/dist/services/todu/daemon-connection.js +633 -0
- package/dist/services/todu/daemon-events.d.ts +11 -0
- package/dist/services/todu/daemon-events.js +1 -0
- package/dist/services/todu/default-task-service.d.ts +34 -0
- package/dist/services/todu/default-task-service.js +109 -0
- package/dist/services/todu/todu-habit-service.d.ts +24 -0
- package/dist/services/todu/todu-habit-service.js +80 -0
- package/dist/services/todu/todu-note-service.d.ts +20 -0
- package/dist/services/todu/todu-note-service.js +35 -0
- package/dist/services/todu/todu-project-integration-service.d.ts +27 -0
- package/dist/services/todu/todu-project-integration-service.js +45 -0
- package/dist/services/todu/todu-project-service.d.ts +24 -0
- package/dist/services/todu/todu-project-service.js +42 -0
- package/dist/services/todu/todu-recurring-service.d.ts +27 -0
- package/dist/services/todu/todu-recurring-service.js +72 -0
- package/dist/services/todu/todu-task-service.d.ts +23 -0
- package/dist/services/todu/todu-task-service.js +80 -0
- package/dist/tools/habit-mutation-tools.d.ts +170 -0
- package/dist/tools/habit-mutation-tools.js +363 -0
- package/dist/tools/habit-read-tools.d.ts +61 -0
- package/dist/tools/habit-read-tools.js +152 -0
- package/dist/tools/note-read-tools.d.ts +79 -0
- package/dist/tools/note-read-tools.js +148 -0
- package/dist/tools/project-integration-tools.d.ts +92 -0
- package/dist/tools/project-integration-tools.js +344 -0
- package/dist/tools/project-mutation-tools.d.ts +100 -0
- package/dist/tools/project-mutation-tools.js +205 -0
- package/dist/tools/project-read-tools.d.ts +59 -0
- package/dist/tools/project-read-tools.js +131 -0
- package/dist/tools/recurring-mutation-tools.d.ts +130 -0
- package/dist/tools/recurring-mutation-tools.js +317 -0
- package/dist/tools/recurring-read-tools.d.ts +31 -0
- package/dist/tools/recurring-read-tools.js +57 -0
- package/dist/tools/task-mutation-tools.d.ts +159 -0
- package/dist/tools/task-mutation-tools.js +340 -0
- package/dist/tools/task-read-tools.d.ts +91 -0
- package/dist/tools/task-read-tools.js +186 -0
- package/dist/ui/components/habit-table.d.ts +5 -0
- package/dist/ui/components/habit-table.js +34 -0
- package/dist/ui/components/loaders.d.ts +6 -0
- package/dist/ui/components/loaders.js +5 -0
- package/dist/ui/components/task-detail.d.ts +19 -0
- package/dist/ui/components/task-detail.js +74 -0
- package/dist/ui/components/task-list.d.ts +8 -0
- package/dist/ui/components/task-list.js +7 -0
- package/dist/ui/components/task-settings.d.ts +7 -0
- package/dist/ui/components/task-settings.js +12 -0
- package/dist/ui/renderers/task-tool-renderer.d.ts +4 -0
- package/dist/ui/renderers/task-tool-renderer.js +4 -0
- package/dist/ui/widgets/current-task-widget.d.ts +7 -0
- package/dist/ui/widgets/current-task-widget.js +20 -0
- package/dist/ui/widgets/next-actions-widget.d.ts +7 -0
- package/dist/ui/widgets/next-actions-widget.js +5 -0
- package/dist/utils/key-hints.d.ts +6 -0
- package/dist/utils/key-hints.js +2 -0
- package/dist/utils/schedule.d.ts +35 -0
- package/dist/utils/schedule.js +111 -0
- package/dist/utils/task-filters.d.ts +3 -0
- package/dist/utils/task-filters.js +7 -0
- package/dist/utils/task-format.d.ts +4 -0
- package/dist/utils/task-format.js +6 -0
- package/dist/utils/timezone.d.ts +2 -0
- package/dist/utils/timezone.js +9 -0
- package/package.json +79 -0
|
@@ -0,0 +1,1214 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { BorderedLoader, DynamicBorder } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { Container, SelectList, Text } from "@mariozechner/pi-tui";
|
|
4
|
+
import { browseTasks } from "../flows/browse-tasks.js";
|
|
5
|
+
import { commentOnTask } from "../flows/comment-on-task.js";
|
|
6
|
+
import { createTask } from "../flows/create-task.js";
|
|
7
|
+
import { showTaskDetail } from "../flows/show-task-detail.js";
|
|
8
|
+
import { updateTask } from "../flows/update-task.js";
|
|
9
|
+
import { getDefaultToduTaskServiceRuntime } from "../services/todu/default-task-service.js";
|
|
10
|
+
import { createTaskBrowseFilterState } from "../services/task-browse-filter-store.js";
|
|
11
|
+
import { createRepoContextService } from "../services/repo-context.js";
|
|
12
|
+
import { createTaskDetailActionItems, createTaskDetailViewModel, formatTaskPriorityLabel, formatTaskStatusLabel, } from "../ui/components/task-detail.js";
|
|
13
|
+
import { createTaskListItem } from "../ui/components/task-list.js";
|
|
14
|
+
import { formatHabitTable } from "../ui/components/habit-table.js";
|
|
15
|
+
import { createTaskLoaderViewModel } from "../ui/components/loaders.js";
|
|
16
|
+
import { taskPriorityOptions, taskStatusOptions, } from "../ui/components/task-settings.js";
|
|
17
|
+
import { getDefaultCurrentTaskContextController } from "./current-task-context.js";
|
|
18
|
+
import { getDefaultTaskBrowseFilterContextController, } from "./task-browse-filter-context.js";
|
|
19
|
+
const DEFAULT_TASK_BROWSE_FILTER_STATE = createTaskBrowseFilterState();
|
|
20
|
+
const createTasksCommandHandler = (dependencies = {}) => {
|
|
21
|
+
const getTaskService = dependencies.getTaskService ?? (() => getDefaultToduTaskServiceRuntime().ensureConnected());
|
|
22
|
+
const getTaskBrowseFilterController = () => dependencies.taskBrowseFilterController ?? getDefaultTaskBrowseFilterContextController();
|
|
23
|
+
const editTaskBrowseFilters = dependencies.editTaskBrowseFilters ?? showTaskBrowseFilterMode;
|
|
24
|
+
const resolveDefaultProject = dependencies.resolveDefaultProject ?? resolveDefaultProjectFromRepo;
|
|
25
|
+
const loadTasks = dependencies.loadTasks ?? loadTasksWithLoader;
|
|
26
|
+
const showTaskBrowseView = dependencies.showTaskBrowseView ?? selectTaskBrowseViewAction;
|
|
27
|
+
const showEmptyState = dependencies.showEmptyState ?? showEmptyTasksState;
|
|
28
|
+
const setCurrentTask = dependencies.setCurrentTask ??
|
|
29
|
+
((ctx, task) => getDefaultCurrentTaskContextController().setCurrentTask(ctx, task));
|
|
30
|
+
const openTaskDetail = dependencies.openTaskDetail ??
|
|
31
|
+
((ctx, taskService, taskId) => openSelectedTaskDetail(ctx, taskService, taskId, {
|
|
32
|
+
setCurrentTask,
|
|
33
|
+
showTaskDetailView: dependencies.showTaskDetailView,
|
|
34
|
+
selectTaskStatus: dependencies.selectTaskStatus,
|
|
35
|
+
selectTaskPriority: dependencies.selectTaskPriority,
|
|
36
|
+
editTaskComment: dependencies.editTaskComment,
|
|
37
|
+
}));
|
|
38
|
+
return async (_args, ctx) => {
|
|
39
|
+
if (!ctx.hasUI) {
|
|
40
|
+
process.stderr.write("/tasks requires interactive mode\n");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const taskBrowseFilterController = getTaskBrowseFilterController();
|
|
45
|
+
await taskBrowseFilterController.restoreFromBranch(ctx);
|
|
46
|
+
const taskService = await getTaskService();
|
|
47
|
+
let mode;
|
|
48
|
+
if (taskBrowseFilterController.getState().hasSavedFilter) {
|
|
49
|
+
mode = "view";
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
const defaultState = await resolveDefaultTaskBrowseFilterState(taskService, resolveDefaultProject);
|
|
53
|
+
await taskBrowseFilterController.setState(ctx, defaultState);
|
|
54
|
+
mode = "view";
|
|
55
|
+
}
|
|
56
|
+
while (true) {
|
|
57
|
+
if (mode === "filter") {
|
|
58
|
+
const filterEditResult = await editTaskBrowseFilters(ctx, taskService, taskBrowseFilterController.getState());
|
|
59
|
+
if (filterEditResult.status === "cancelled") {
|
|
60
|
+
ctx.ui.notify("Task browse cancelled", "info");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
await taskBrowseFilterController.setState(ctx, filterEditResult.filterState);
|
|
64
|
+
mode = "view";
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const filterState = taskBrowseFilterController.getState();
|
|
68
|
+
const taskFilter = createTaskFilterFromBrowseState(filterState);
|
|
69
|
+
const filterSummary = formatTaskBrowseFilterSummary(filterState);
|
|
70
|
+
const loadedTasks = await loadTasks(ctx, taskService, taskFilter, filterSummary);
|
|
71
|
+
if (loadedTasks.status === "cancelled") {
|
|
72
|
+
ctx.ui.notify("Task browse cancelled", "info");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (loadedTasks.status === "error") {
|
|
76
|
+
ctx.ui.notify(loadedTasks.message, "error");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (loadedTasks.tasks.length === 0) {
|
|
80
|
+
const emptyAction = await showEmptyState(ctx, filterState);
|
|
81
|
+
if (emptyAction === "change-filters") {
|
|
82
|
+
mode = "filter";
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (emptyAction === "clear-filters") {
|
|
86
|
+
await taskBrowseFilterController.setState(ctx, createSavedTaskBrowseFilterState());
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const browseViewResult = await showTaskBrowseView(ctx, loadedTasks.tasks, filterState);
|
|
92
|
+
if (browseViewResult.status === "closed") {
|
|
93
|
+
ctx.ui.notify("Task browse cancelled", "info");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (browseViewResult.status === "change-filters") {
|
|
97
|
+
mode = "filter";
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (browseViewResult.status === "clear-filters") {
|
|
101
|
+
await taskBrowseFilterController.setState(ctx, createSavedTaskBrowseFilterState());
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
await openTaskDetail(ctx, taskService, browseViewResult.taskId);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
ctx.ui.notify(formatTasksCommandError(error, "Failed to browse tasks"), "error");
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
const createTaskCommandHandler = (dependencies = {}) => {
|
|
114
|
+
const getTaskService = dependencies.getTaskService ?? (() => getDefaultToduTaskServiceRuntime().ensureConnected());
|
|
115
|
+
const getCurrentTaskId = dependencies.getCurrentTaskId ??
|
|
116
|
+
(() => getDefaultCurrentTaskContextController().getState().currentTaskId);
|
|
117
|
+
const setCurrentTask = dependencies.setCurrentTask ??
|
|
118
|
+
((ctx, task) => getDefaultCurrentTaskContextController().setCurrentTask(ctx, task));
|
|
119
|
+
const openTaskDetail = dependencies.openTaskDetail ??
|
|
120
|
+
((ctx, taskService, taskId) => openTaskDetailHub(ctx, taskService, taskId, {
|
|
121
|
+
setCurrentTask,
|
|
122
|
+
showTaskDetailView: dependencies.showTaskDetailView,
|
|
123
|
+
selectTaskStatus: dependencies.selectTaskStatus,
|
|
124
|
+
selectTaskPriority: dependencies.selectTaskPriority,
|
|
125
|
+
editTaskComment: dependencies.editTaskComment,
|
|
126
|
+
}));
|
|
127
|
+
return async (args, ctx) => {
|
|
128
|
+
if (!ctx.hasUI) {
|
|
129
|
+
process.stderr.write("/task requires interactive mode\n");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const requestedTaskId = resolveRequestedTaskId(args, getCurrentTaskId());
|
|
133
|
+
if (!requestedTaskId) {
|
|
134
|
+
ctx.ui.notify("No task selected. Run /tasks or pass a task ID.", "warning");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const taskService = await getTaskService();
|
|
139
|
+
await openTaskDetail(ctx, taskService, requestedTaskId);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
ctx.ui.notify(formatTasksCommandError(error, "Failed to open task detail"), "error");
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
const createTaskClearCommandHandler = (dependencies = {}) => {
|
|
147
|
+
const getCurrentTaskId = dependencies.getCurrentTaskId ??
|
|
148
|
+
(() => getDefaultCurrentTaskContextController().getState().currentTaskId);
|
|
149
|
+
const clearCurrentTask = dependencies.clearCurrentTask ??
|
|
150
|
+
((ctx) => getDefaultCurrentTaskContextController().clearCurrentTask(ctx));
|
|
151
|
+
return async (_args, ctx) => {
|
|
152
|
+
const currentTaskId = getCurrentTaskId();
|
|
153
|
+
if (!currentTaskId) {
|
|
154
|
+
if (ctx.hasUI) {
|
|
155
|
+
ctx.ui.notify("No current task to clear", "info");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
process.stdout.write("No current task to clear\n");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
await clearCurrentTask(ctx);
|
|
163
|
+
if (ctx.hasUI) {
|
|
164
|
+
ctx.ui.notify("Cleared current task", "info");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
process.stdout.write("Cleared current task\n");
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
if (ctx.hasUI) {
|
|
171
|
+
ctx.ui.notify(formatTasksCommandError(error, "Failed to clear current task"), "error");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
process.stderr.write(`${formatTasksCommandError(error, "Failed to clear current task")}\n`);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
const createTaskNewCommandHandler = (dependencies = {}) => {
|
|
179
|
+
const getTaskService = dependencies.getTaskService ?? (() => getDefaultToduTaskServiceRuntime().ensureConnected());
|
|
180
|
+
const promptTaskTitle = dependencies.promptTaskTitle ?? promptRequiredTaskTitle;
|
|
181
|
+
const selectTaskProject = dependencies.selectTaskProject ?? selectProjectForTaskCreation;
|
|
182
|
+
const editTaskExplanation = dependencies.editTaskExplanation ?? editNewTaskExplanation;
|
|
183
|
+
const confirmTaskAuthoring = dependencies.confirmTaskAuthoring ?? confirmTaskAuthoringHelp;
|
|
184
|
+
const requestTaskAuthoringAssistance = dependencies.requestTaskAuthoringAssistance;
|
|
185
|
+
const setCurrentTask = dependencies.setCurrentTask ??
|
|
186
|
+
((ctx, task) => getDefaultCurrentTaskContextController().setCurrentTask(ctx, task));
|
|
187
|
+
const openTaskDetail = dependencies.openTaskDetail ??
|
|
188
|
+
((ctx, taskService, taskId) => openTaskDetailHub(ctx, taskService, taskId, {
|
|
189
|
+
setCurrentTask,
|
|
190
|
+
showTaskDetailView: dependencies.showTaskDetailView,
|
|
191
|
+
selectTaskStatus: dependencies.selectTaskStatus,
|
|
192
|
+
selectTaskPriority: dependencies.selectTaskPriority,
|
|
193
|
+
editTaskComment: dependencies.editTaskComment,
|
|
194
|
+
}));
|
|
195
|
+
return async (_args, ctx) => {
|
|
196
|
+
if (!ctx.hasUI) {
|
|
197
|
+
process.stderr.write("/task-new requires interactive mode\n");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const title = await promptTaskTitle(ctx);
|
|
201
|
+
if (!title) {
|
|
202
|
+
ctx.ui.notify("Task creation cancelled", "info");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
let taskService;
|
|
206
|
+
try {
|
|
207
|
+
taskService = await getTaskService();
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
ctx.ui.notify(formatTasksCommandError(error, "Failed to start task creation"), "error");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
let projectSelection;
|
|
214
|
+
try {
|
|
215
|
+
projectSelection = await selectTaskProject(ctx, taskService);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
ctx.ui.notify(formatTasksCommandError(error, "Failed to load projects"), "error");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (projectSelection.status === "unavailable") {
|
|
222
|
+
ctx.ui.notify("No projects available for new tasks", "warning");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (projectSelection.status === "cancelled") {
|
|
226
|
+
ctx.ui.notify("Task creation cancelled", "info");
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
let explanationInput;
|
|
230
|
+
try {
|
|
231
|
+
explanationInput = await editTaskExplanation(ctx, title);
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
ctx.ui.notify(formatTasksCommandError(error, "Failed to collect task explanation"), "error");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (explanationInput === undefined) {
|
|
238
|
+
ctx.ui.notify("Task creation cancelled", "info");
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const initialDraft = {
|
|
242
|
+
title,
|
|
243
|
+
description: normalizeOptionalTaskDescription(explanationInput),
|
|
244
|
+
projectName: projectSelection.project.name,
|
|
245
|
+
};
|
|
246
|
+
let finalDraft = {
|
|
247
|
+
title: initialDraft.title,
|
|
248
|
+
description: initialDraft.description,
|
|
249
|
+
};
|
|
250
|
+
let wantsTaskAuthoringHelp;
|
|
251
|
+
try {
|
|
252
|
+
wantsTaskAuthoringHelp = await confirmTaskAuthoring(ctx, initialDraft);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
ctx.ui.notify(formatTasksCommandError(error, "Failed to confirm task authoring"), "error");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (wantsTaskAuthoringHelp) {
|
|
259
|
+
if (!requestTaskAuthoringAssistance) {
|
|
260
|
+
ctx.ui.notify("Task authoring assistance is unavailable", "error");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
let authoredDraft;
|
|
264
|
+
try {
|
|
265
|
+
authoredDraft = await requestTaskAuthoringAssistance(ctx, initialDraft);
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
ctx.ui.notify(formatTasksCommandError(error, "Failed to complete task authoring"), "error");
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (!authoredDraft) {
|
|
272
|
+
ctx.ui.notify("Task creation cancelled", "info");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const authoredTitle = authoredDraft.title.trim();
|
|
276
|
+
if (authoredTitle.length === 0) {
|
|
277
|
+
ctx.ui.notify("Task authoring returned an empty title", "error");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
finalDraft = {
|
|
281
|
+
title: authoredTitle,
|
|
282
|
+
description: normalizeOptionalTaskDescription(authoredDraft.description ?? ""),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
let createdTask;
|
|
286
|
+
try {
|
|
287
|
+
createdTask = await createTask({ taskService }, {
|
|
288
|
+
title: finalDraft.title,
|
|
289
|
+
projectId: projectSelection.project.id,
|
|
290
|
+
description: finalDraft.description,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
ctx.ui.notify(formatTasksCommandError(error, "Failed to create task"), "error");
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
ctx.ui.notify(`Created task ${createdTask.title}`, "info");
|
|
298
|
+
try {
|
|
299
|
+
await openTaskDetail(ctx, taskService, createdTask.id);
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
ctx.ui.notify(formatTasksCommandError(error, `Created task ${createdTask.title} but failed to open task detail`), "error");
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
};
|
|
306
|
+
const createHabitsCommandHandler = (dependencies = {}) => {
|
|
307
|
+
const getHabitService = dependencies.getHabitService ??
|
|
308
|
+
(() => getDefaultToduTaskServiceRuntime().ensureHabitServiceConnected());
|
|
309
|
+
return async (_args, ctx) => {
|
|
310
|
+
if (!ctx.hasUI) {
|
|
311
|
+
process.stderr.write("/habits requires interactive mode\n");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
let habits = [];
|
|
316
|
+
let loadError = null;
|
|
317
|
+
await ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
318
|
+
const loader = new BorderedLoader(tui, theme, "Loading habits...");
|
|
319
|
+
loader.onAbort = () => done();
|
|
320
|
+
void getHabitService()
|
|
321
|
+
.then((habitService) => habitService.listHabitsWithStreaks())
|
|
322
|
+
.then((result) => {
|
|
323
|
+
habits = result;
|
|
324
|
+
done();
|
|
325
|
+
})
|
|
326
|
+
.catch((error) => {
|
|
327
|
+
loadError = formatTasksCommandError(error, "Failed to load habits");
|
|
328
|
+
done();
|
|
329
|
+
});
|
|
330
|
+
return loader;
|
|
331
|
+
});
|
|
332
|
+
if (loadError) {
|
|
333
|
+
ctx.ui.notify(loadError, "error");
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (habits.length === 0) {
|
|
337
|
+
ctx.ui.notify("No habits found", "info");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const table = formatHabitTable(habits);
|
|
341
|
+
await ctx.ui.custom((_tui, theme, _keybindings, done) => {
|
|
342
|
+
const container = new Container();
|
|
343
|
+
container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
344
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Habits")), 1, 0));
|
|
345
|
+
container.addChild(new Text(table, 1, 1));
|
|
346
|
+
container.addChild(new Text(theme.fg("dim", "esc close"), 1, 0));
|
|
347
|
+
container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
348
|
+
return {
|
|
349
|
+
render: (width) => container.render(width),
|
|
350
|
+
invalidate: () => container.invalidate(),
|
|
351
|
+
handleInput: (data) => {
|
|
352
|
+
if (data === "\x1b" || data === "q") {
|
|
353
|
+
done();
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
ctx.ui.notify(formatTasksCommandError(error, "Failed to show habits"), "error");
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
};
|
|
364
|
+
const registerCommands = (pi, dependencies = {}) => {
|
|
365
|
+
getDefaultCurrentTaskContextController(pi);
|
|
366
|
+
getDefaultTaskBrowseFilterContextController(pi);
|
|
367
|
+
pi.registerCommand("tasks", {
|
|
368
|
+
description: "Browse and filter todu tasks",
|
|
369
|
+
handler: createTasksCommandHandler(dependencies),
|
|
370
|
+
});
|
|
371
|
+
pi.registerCommand("task", {
|
|
372
|
+
description: "Show the current task or a specific task by ID",
|
|
373
|
+
handler: createTaskCommandHandler(dependencies),
|
|
374
|
+
});
|
|
375
|
+
pi.registerCommand("task-clear", {
|
|
376
|
+
description: "Clear the current task context",
|
|
377
|
+
handler: createTaskClearCommandHandler(dependencies),
|
|
378
|
+
});
|
|
379
|
+
pi.registerCommand("task-new", {
|
|
380
|
+
description: "Create a new todu task",
|
|
381
|
+
handler: createTaskNewCommandHandler({
|
|
382
|
+
...dependencies,
|
|
383
|
+
requestTaskAuthoringAssistance: dependencies.requestTaskAuthoringAssistance ??
|
|
384
|
+
((ctx, draft) => requestTaskAuthoringAssistance(pi, ctx, draft)),
|
|
385
|
+
}),
|
|
386
|
+
});
|
|
387
|
+
pi.registerCommand("habits", {
|
|
388
|
+
description: "Show habits with streak and today status",
|
|
389
|
+
handler: createHabitsCommandHandler(dependencies),
|
|
390
|
+
});
|
|
391
|
+
};
|
|
392
|
+
const loadTasksWithLoader = async (ctx, taskService, filter, filterSummary) => ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
393
|
+
const loader = new BorderedLoader(tui, theme, createTaskLoaderViewModel(`Loading tasks · ${filterSummary}`).label);
|
|
394
|
+
let settled = false;
|
|
395
|
+
const settle = (result) => {
|
|
396
|
+
if (settled) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
settled = true;
|
|
400
|
+
done(result);
|
|
401
|
+
};
|
|
402
|
+
loader.onAbort = () => settle({ status: "cancelled" });
|
|
403
|
+
void browseTasks({ taskService }, filter)
|
|
404
|
+
.then((tasks) => {
|
|
405
|
+
settle({ status: "loaded", tasks });
|
|
406
|
+
})
|
|
407
|
+
.catch((error) => {
|
|
408
|
+
settle({
|
|
409
|
+
status: "error",
|
|
410
|
+
message: formatTasksCommandError(error, "Failed to load filtered tasks"),
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
return loader;
|
|
414
|
+
});
|
|
415
|
+
const showTaskBrowseFilterMode = async (ctx, taskService, currentState) => {
|
|
416
|
+
const projects = [...(await taskService.listProjects())].sort((left, right) => left.name.localeCompare(right.name));
|
|
417
|
+
let draftState = createTaskBrowseFilterState({
|
|
418
|
+
...currentState,
|
|
419
|
+
hasSavedFilter: true,
|
|
420
|
+
});
|
|
421
|
+
while (true) {
|
|
422
|
+
const action = await selectTaskBrowseFilterModeAction(ctx, draftState, projects);
|
|
423
|
+
if (!action) {
|
|
424
|
+
return { status: "cancelled" };
|
|
425
|
+
}
|
|
426
|
+
if (action === "apply") {
|
|
427
|
+
return { status: "saved", filterState: draftState };
|
|
428
|
+
}
|
|
429
|
+
if (action === "reset") {
|
|
430
|
+
draftState = createSavedTaskBrowseFilterState();
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
if (action === "status") {
|
|
434
|
+
const nextStatus = await selectOptionalTaskSettingFromList(ctx, {
|
|
435
|
+
title: "Filter by status",
|
|
436
|
+
options: createTaskBrowseStatusOptions(),
|
|
437
|
+
currentValue: draftState.status,
|
|
438
|
+
currentLabel: formatTaskBrowseStatusFilterLabel(draftState.status),
|
|
439
|
+
actionLabel: "status filter",
|
|
440
|
+
});
|
|
441
|
+
if (nextStatus !== undefined) {
|
|
442
|
+
draftState = createTaskBrowseFilterState({
|
|
443
|
+
...draftState,
|
|
444
|
+
hasSavedFilter: true,
|
|
445
|
+
status: nextStatus,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (action === "priority") {
|
|
451
|
+
const nextPriority = await selectOptionalTaskSettingFromList(ctx, {
|
|
452
|
+
title: "Filter by priority",
|
|
453
|
+
options: createTaskBrowsePriorityOptions(),
|
|
454
|
+
currentValue: draftState.priority,
|
|
455
|
+
currentLabel: formatTaskBrowsePriorityFilterLabel(draftState.priority),
|
|
456
|
+
actionLabel: "priority filter",
|
|
457
|
+
});
|
|
458
|
+
if (nextPriority !== undefined) {
|
|
459
|
+
draftState = createTaskBrowseFilterState({
|
|
460
|
+
...draftState,
|
|
461
|
+
hasSavedFilter: true,
|
|
462
|
+
priority: nextPriority,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const nextProject = await selectTaskBrowseProjectFilter(ctx, projects, draftState.projectId);
|
|
468
|
+
if (nextProject !== undefined) {
|
|
469
|
+
draftState = createTaskBrowseFilterState({
|
|
470
|
+
...draftState,
|
|
471
|
+
hasSavedFilter: true,
|
|
472
|
+
projectId: nextProject.projectId,
|
|
473
|
+
projectName: nextProject.projectName,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
const selectTaskBrowseViewAction = async (ctx, tasks, filterState) => {
|
|
479
|
+
const items = [
|
|
480
|
+
{
|
|
481
|
+
value: "action:change-filters",
|
|
482
|
+
label: "Change filters",
|
|
483
|
+
description: "Edit status, priority, or project filters",
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
value: "action:clear-filters",
|
|
487
|
+
label: "Clear filters",
|
|
488
|
+
description: "Reset all filters to Any",
|
|
489
|
+
},
|
|
490
|
+
...tasks.map((task) => ({
|
|
491
|
+
...createTaskListItem(task),
|
|
492
|
+
value: `task:${task.id}`,
|
|
493
|
+
})),
|
|
494
|
+
];
|
|
495
|
+
return ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
496
|
+
const container = new Container();
|
|
497
|
+
container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
498
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Browse Tasks")), 1, 0));
|
|
499
|
+
container.addChild(new Text(theme.fg("muted", `Filters: ${formatTaskBrowseFilterSummary(filterState)}`), 1, 0));
|
|
500
|
+
const selectList = new SelectList(items, Math.min(items.length, 12), {
|
|
501
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
502
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
503
|
+
description: (text) => theme.fg("muted", text),
|
|
504
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
505
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
506
|
+
});
|
|
507
|
+
selectList.onSelect = (item) => {
|
|
508
|
+
const value = item.value;
|
|
509
|
+
if (value === "action:change-filters") {
|
|
510
|
+
done({ status: "change-filters" });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (value === "action:clear-filters") {
|
|
514
|
+
done({ status: "clear-filters" });
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
done({ status: "selected", taskId: value.replace(/^task:/, "") });
|
|
518
|
+
};
|
|
519
|
+
selectList.onCancel = () => done({ status: "closed" });
|
|
520
|
+
container.addChild(selectList);
|
|
521
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc close"), 1, 0));
|
|
522
|
+
container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
523
|
+
return {
|
|
524
|
+
render: (width) => container.render(width),
|
|
525
|
+
invalidate: () => container.invalidate(),
|
|
526
|
+
handleInput: (data) => {
|
|
527
|
+
selectList.handleInput(data);
|
|
528
|
+
tui.requestRender();
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
});
|
|
532
|
+
};
|
|
533
|
+
const showEmptyTasksState = async (ctx, filterState) => ctx.ui.custom((_tui, theme, _keybindings, done) => {
|
|
534
|
+
const items = [
|
|
535
|
+
{
|
|
536
|
+
value: "change-filters",
|
|
537
|
+
label: "Change filters",
|
|
538
|
+
description: "Adjust the current task filters",
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
value: "clear-filters",
|
|
542
|
+
label: "Clear filters",
|
|
543
|
+
description: "Reset all filters to Any",
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
value: "close",
|
|
547
|
+
label: "Close",
|
|
548
|
+
description: "Exit task browsing",
|
|
549
|
+
},
|
|
550
|
+
];
|
|
551
|
+
const container = new Container();
|
|
552
|
+
container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
553
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Browse Tasks")), 1, 0));
|
|
554
|
+
container.addChild(new Text(theme.fg("muted", `No tasks match the current filters: ${formatTaskBrowseFilterSummary(filterState)}`), 1, 1));
|
|
555
|
+
const selectList = new SelectList(items, items.length, {
|
|
556
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
557
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
558
|
+
description: (text) => theme.fg("muted", text),
|
|
559
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
560
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
561
|
+
});
|
|
562
|
+
selectList.onSelect = (item) => done(item.value);
|
|
563
|
+
selectList.onCancel = () => done("close");
|
|
564
|
+
container.addChild(selectList);
|
|
565
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc close"), 1, 0));
|
|
566
|
+
container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
567
|
+
return {
|
|
568
|
+
render: (width) => container.render(width),
|
|
569
|
+
invalidate: () => container.invalidate(),
|
|
570
|
+
handleInput: (data) => {
|
|
571
|
+
selectList.handleInput(data);
|
|
572
|
+
_tui.requestRender();
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
});
|
|
576
|
+
const selectTaskBrowseFilterModeAction = async (ctx, filterState, projects) => {
|
|
577
|
+
const items = [
|
|
578
|
+
{
|
|
579
|
+
value: "status",
|
|
580
|
+
label: `Status: ${formatTaskBrowseStatusFilterLabel(filterState.status)}`,
|
|
581
|
+
description: "Choose a status filter or Any",
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
value: "priority",
|
|
585
|
+
label: `Priority: ${formatTaskBrowsePriorityFilterLabel(filterState.priority)}`,
|
|
586
|
+
description: "Choose a priority filter or Any",
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
value: "project",
|
|
590
|
+
label: `Project: ${formatTaskBrowseProjectFilterLabel(filterState.projectId, projects.find((project) => project.id === filterState.projectId)?.name ??
|
|
591
|
+
filterState.projectName)}`,
|
|
592
|
+
description: "Choose a project filter or Any",
|
|
593
|
+
},
|
|
594
|
+
{
|
|
595
|
+
value: "apply",
|
|
596
|
+
label: "View tasks",
|
|
597
|
+
description: `Open the filtered list (${formatTaskBrowseFilterSummary(filterState)})`,
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
value: "reset",
|
|
601
|
+
label: "Reset filters",
|
|
602
|
+
description: "Set status, priority, and project back to Any",
|
|
603
|
+
},
|
|
604
|
+
];
|
|
605
|
+
return ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
606
|
+
const container = new Container();
|
|
607
|
+
container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
608
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Task Filters")), 1, 0));
|
|
609
|
+
container.addChild(new Text(theme.fg("muted", "Set the filters to use when browsing tasks."), 1, 0));
|
|
610
|
+
const selectList = new SelectList(items, items.length, {
|
|
611
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
612
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
613
|
+
description: (text) => theme.fg("muted", text),
|
|
614
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
615
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
616
|
+
});
|
|
617
|
+
selectList.onSelect = (item) => done(item.value);
|
|
618
|
+
selectList.onCancel = () => done(null);
|
|
619
|
+
container.addChild(selectList);
|
|
620
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));
|
|
621
|
+
container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
622
|
+
return {
|
|
623
|
+
render: (width) => container.render(width),
|
|
624
|
+
invalidate: () => container.invalidate(),
|
|
625
|
+
handleInput: (data) => {
|
|
626
|
+
selectList.handleInput(data);
|
|
627
|
+
tui.requestRender();
|
|
628
|
+
},
|
|
629
|
+
};
|
|
630
|
+
});
|
|
631
|
+
};
|
|
632
|
+
const selectOptionalTaskSettingFromList = async (ctx, options) => {
|
|
633
|
+
const items = options.options.map((option) => ({
|
|
634
|
+
value: option.value ?? "__any__",
|
|
635
|
+
label: option.label,
|
|
636
|
+
description: option.value === options.currentValue
|
|
637
|
+
? `${option.label} (current)`
|
|
638
|
+
: `Set ${options.actionLabel} to ${option.label}`,
|
|
639
|
+
}));
|
|
640
|
+
return ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
641
|
+
const container = new Container();
|
|
642
|
+
container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
643
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(options.title)), 1, 0));
|
|
644
|
+
container.addChild(new Text(theme.fg("muted", `Current ${options.actionLabel}: ${options.currentLabel}`), 1, 0));
|
|
645
|
+
const selectList = new SelectList(items, items.length, {
|
|
646
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
647
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
648
|
+
description: (text) => theme.fg("muted", text),
|
|
649
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
650
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
651
|
+
});
|
|
652
|
+
selectList.onSelect = (item) => {
|
|
653
|
+
const selectedValue = item.value === "__any__" ? null : item.value;
|
|
654
|
+
done(selectedValue);
|
|
655
|
+
};
|
|
656
|
+
selectList.onCancel = () => done(undefined);
|
|
657
|
+
container.addChild(selectList);
|
|
658
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));
|
|
659
|
+
container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
660
|
+
return {
|
|
661
|
+
render: (width) => container.render(width),
|
|
662
|
+
invalidate: () => container.invalidate(),
|
|
663
|
+
handleInput: (data) => {
|
|
664
|
+
selectList.handleInput(data);
|
|
665
|
+
tui.requestRender();
|
|
666
|
+
},
|
|
667
|
+
};
|
|
668
|
+
});
|
|
669
|
+
};
|
|
670
|
+
const selectTaskBrowseProjectFilter = async (ctx, projects, currentProjectId) => {
|
|
671
|
+
const nextProjectId = await selectOptionalTaskSettingFromList(ctx, {
|
|
672
|
+
title: "Filter by project",
|
|
673
|
+
options: [
|
|
674
|
+
{ label: "Any", value: null },
|
|
675
|
+
...projects.map((project) => ({
|
|
676
|
+
label: project.name,
|
|
677
|
+
value: project.id,
|
|
678
|
+
})),
|
|
679
|
+
],
|
|
680
|
+
currentValue: currentProjectId,
|
|
681
|
+
currentLabel: formatTaskBrowseProjectFilterLabel(currentProjectId, projects.find((project) => project.id === currentProjectId)?.name ?? null),
|
|
682
|
+
actionLabel: "project filter",
|
|
683
|
+
});
|
|
684
|
+
if (nextProjectId === undefined) {
|
|
685
|
+
return undefined;
|
|
686
|
+
}
|
|
687
|
+
return {
|
|
688
|
+
projectId: nextProjectId,
|
|
689
|
+
projectName: nextProjectId
|
|
690
|
+
? (projects.find((project) => project.id === nextProjectId)?.name ?? nextProjectId)
|
|
691
|
+
: null,
|
|
692
|
+
};
|
|
693
|
+
};
|
|
694
|
+
const createTaskBrowseStatusOptions = () => [
|
|
695
|
+
{ label: "Any", value: null },
|
|
696
|
+
...taskStatusOptions,
|
|
697
|
+
];
|
|
698
|
+
const createTaskBrowsePriorityOptions = () => [
|
|
699
|
+
{ label: "Any", value: null },
|
|
700
|
+
...taskPriorityOptions,
|
|
701
|
+
];
|
|
702
|
+
const createSavedTaskBrowseFilterState = (overrides = {}) => createTaskBrowseFilterState({
|
|
703
|
+
hasSavedFilter: true,
|
|
704
|
+
...overrides,
|
|
705
|
+
});
|
|
706
|
+
const createTaskFilterFromBrowseState = (filterState) => ({
|
|
707
|
+
statuses: filterState.status ? [filterState.status] : undefined,
|
|
708
|
+
priorities: filterState.priority ? [filterState.priority] : undefined,
|
|
709
|
+
projectId: filterState.projectId ?? undefined,
|
|
710
|
+
});
|
|
711
|
+
const formatTaskBrowseFilterSummary = (filterState) => [
|
|
712
|
+
`Status ${formatTaskBrowseStatusFilterLabel(filterState.status)}`,
|
|
713
|
+
`Priority ${formatTaskBrowsePriorityFilterLabel(filterState.priority)}`,
|
|
714
|
+
`Project ${formatTaskBrowseProjectFilterLabel(filterState.projectId, filterState.projectName)}`,
|
|
715
|
+
].join(" • ");
|
|
716
|
+
const formatTaskBrowseStatusFilterLabel = (status) => status ? formatTaskStatusLabel(status) : "Any";
|
|
717
|
+
const formatTaskBrowsePriorityFilterLabel = (priority) => priority ? formatTaskPriorityLabel(priority) : "Any";
|
|
718
|
+
const formatTaskBrowseProjectFilterLabel = (projectId, projectName) => {
|
|
719
|
+
if (!projectId) {
|
|
720
|
+
return "Any";
|
|
721
|
+
}
|
|
722
|
+
return projectName ?? projectId;
|
|
723
|
+
};
|
|
724
|
+
const promptRequiredTaskTitle = async (ctx) => {
|
|
725
|
+
while (true) {
|
|
726
|
+
const title = await ctx.ui.input("New task title", "What needs to be done?");
|
|
727
|
+
if (title === undefined) {
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
const trimmedTitle = title.trim();
|
|
731
|
+
if (trimmedTitle.length > 0) {
|
|
732
|
+
return trimmedTitle;
|
|
733
|
+
}
|
|
734
|
+
ctx.ui.notify("Task title is required", "warning");
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
const selectProjectForTaskCreation = async (ctx, taskService) => {
|
|
738
|
+
const projects = [...(await taskService.listProjects())].sort((left, right) => left.name.localeCompare(right.name));
|
|
739
|
+
if (projects.length === 0) {
|
|
740
|
+
return { status: "unavailable" };
|
|
741
|
+
}
|
|
742
|
+
const selectedProjectId = await selectProjectFromList(ctx, projects);
|
|
743
|
+
if (!selectedProjectId) {
|
|
744
|
+
return { status: "cancelled" };
|
|
745
|
+
}
|
|
746
|
+
const selectedProject = projects.find((project) => project.id === selectedProjectId);
|
|
747
|
+
if (!selectedProject) {
|
|
748
|
+
return { status: "cancelled" };
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
status: "selected",
|
|
752
|
+
project: selectedProject,
|
|
753
|
+
};
|
|
754
|
+
};
|
|
755
|
+
const selectProjectFromList = async (ctx, projects) => {
|
|
756
|
+
const items = projects.map((project) => ({
|
|
757
|
+
value: project.id,
|
|
758
|
+
label: project.name,
|
|
759
|
+
description: `${project.status} • ${project.priority} • ${project.id}`,
|
|
760
|
+
}));
|
|
761
|
+
return ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
762
|
+
const container = new Container();
|
|
763
|
+
container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
764
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Select Project")), 1, 0));
|
|
765
|
+
container.addChild(new Text(theme.fg("muted", "Choose the project for the new task."), 1, 0));
|
|
766
|
+
const selectList = new SelectList(items, Math.min(items.length, 10), {
|
|
767
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
768
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
769
|
+
description: (text) => theme.fg("muted", text),
|
|
770
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
771
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
772
|
+
});
|
|
773
|
+
selectList.onSelect = (item) => done(item.value);
|
|
774
|
+
selectList.onCancel = () => done(null);
|
|
775
|
+
container.addChild(selectList);
|
|
776
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));
|
|
777
|
+
container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
778
|
+
return {
|
|
779
|
+
render: (width) => container.render(width),
|
|
780
|
+
invalidate: () => container.invalidate(),
|
|
781
|
+
handleInput: (data) => {
|
|
782
|
+
selectList.handleInput(data);
|
|
783
|
+
tui.requestRender();
|
|
784
|
+
},
|
|
785
|
+
};
|
|
786
|
+
});
|
|
787
|
+
};
|
|
788
|
+
const editNewTaskExplanation = async (ctx, taskTitle) => ctx.ui.editor(`Explain the task in your own words (optional) · ${taskTitle}`, "");
|
|
789
|
+
const confirmTaskAuthoringHelp = async (ctx, draft) => ctx.ui.confirm("Task authoring", `Do you want help with task authoring before creating ${draft.title} in ${draft.projectName}?`);
|
|
790
|
+
const requestTaskAuthoringAssistance = async (pi, ctx, draft) => {
|
|
791
|
+
let pendingError = null;
|
|
792
|
+
const result = await ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
793
|
+
const loader = new BorderedLoader(tui, theme, `Refining task draft · ${draft.title}`);
|
|
794
|
+
let settled = false;
|
|
795
|
+
const settle = (value) => {
|
|
796
|
+
if (settled) {
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
settled = true;
|
|
800
|
+
done(value);
|
|
801
|
+
};
|
|
802
|
+
loader.onAbort = () => settle(null);
|
|
803
|
+
const commandArgs = ["--mode", "json", "--print", "--no-session", "--no-extensions"];
|
|
804
|
+
if (ctx.model) {
|
|
805
|
+
commandArgs.push("--model", `${ctx.model.provider}/${ctx.model.id}`);
|
|
806
|
+
}
|
|
807
|
+
commandArgs.push(buildTaskAuthoringSkillPrompt(draft));
|
|
808
|
+
void pi
|
|
809
|
+
.exec("pi", commandArgs, { signal: loader.signal, timeout: 120_000 })
|
|
810
|
+
.then((execResult) => {
|
|
811
|
+
if (execResult.code !== 0) {
|
|
812
|
+
const errorOutput = [execResult.stderr, execResult.stdout]
|
|
813
|
+
.map((value) => value.trim())
|
|
814
|
+
.find(Boolean);
|
|
815
|
+
throw new Error(errorOutput ?? `Task authoring subprocess failed with exit code ${execResult.code}`);
|
|
816
|
+
}
|
|
817
|
+
settle(parseTaskAuthoringResponse(extractAssistantTextFromJsonOutput(execResult.stdout)));
|
|
818
|
+
})
|
|
819
|
+
.catch((error) => {
|
|
820
|
+
if (loader.signal.aborted) {
|
|
821
|
+
settle(null);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
pendingError =
|
|
825
|
+
error instanceof Error ? error : new Error("Task authoring subprocess failed");
|
|
826
|
+
settle(null);
|
|
827
|
+
});
|
|
828
|
+
return loader;
|
|
829
|
+
});
|
|
830
|
+
if (pendingError) {
|
|
831
|
+
throw pendingError;
|
|
832
|
+
}
|
|
833
|
+
return result;
|
|
834
|
+
};
|
|
835
|
+
const buildTaskAuthoringSkillPrompt = (draft) => {
|
|
836
|
+
const explanation = draft.description ?? "(none provided)";
|
|
837
|
+
return [
|
|
838
|
+
"/skill:task-authoring",
|
|
839
|
+
"This is a task authoring request.",
|
|
840
|
+
"Use task authoring to turn the following rough draft into a finalized task title and markdown description.",
|
|
841
|
+
"Do not ask follow-up questions. Use only the information provided below.",
|
|
842
|
+
"Return the result in this exact format:",
|
|
843
|
+
"Title: <title>",
|
|
844
|
+
"",
|
|
845
|
+
"<markdown description>",
|
|
846
|
+
"",
|
|
847
|
+
"The title should be 60 characters or fewer.",
|
|
848
|
+
"",
|
|
849
|
+
`Project: ${draft.projectName}`,
|
|
850
|
+
`Draft title: ${draft.title}`,
|
|
851
|
+
"User explanation:",
|
|
852
|
+
explanation,
|
|
853
|
+
].join("\n");
|
|
854
|
+
};
|
|
855
|
+
const extractAssistantTextFromJsonOutput = (output) => {
|
|
856
|
+
const lines = output
|
|
857
|
+
.split("\n")
|
|
858
|
+
.map((line) => line.trim())
|
|
859
|
+
.filter((line) => line.length > 0);
|
|
860
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
861
|
+
let event;
|
|
862
|
+
try {
|
|
863
|
+
event = JSON.parse(lines[index] ?? "");
|
|
864
|
+
}
|
|
865
|
+
catch {
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
if (!isRecord(event) || event.type !== "message_end" || !isRecord(event.message)) {
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
const { message } = event;
|
|
872
|
+
if (message.role !== "assistant" || !Array.isArray(message.content)) {
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
const text = message.content
|
|
876
|
+
.filter((block) => isRecord(block) && block.type === "text" && typeof block.text === "string")
|
|
877
|
+
.map((block) => block.text)
|
|
878
|
+
.join("\n")
|
|
879
|
+
.trim();
|
|
880
|
+
if (text.length > 0) {
|
|
881
|
+
return text;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
throw new Error("Task authoring did not return a response");
|
|
885
|
+
};
|
|
886
|
+
const parseTaskAuthoringResponse = (responseText) => {
|
|
887
|
+
const jsonResult = parseTaskAuthoringJsonResponse(responseText);
|
|
888
|
+
if (jsonResult) {
|
|
889
|
+
return jsonResult;
|
|
890
|
+
}
|
|
891
|
+
const formattedResult = parseTaskAuthoringFormattedResponse(responseText);
|
|
892
|
+
if (formattedResult) {
|
|
893
|
+
return formattedResult;
|
|
894
|
+
}
|
|
895
|
+
throw new Error("Task authoring did not return a recognizable title and description");
|
|
896
|
+
};
|
|
897
|
+
const parseTaskAuthoringJsonResponse = (responseText) => {
|
|
898
|
+
const rawJson = extractJsonObject(responseText);
|
|
899
|
+
if (!rawJson) {
|
|
900
|
+
return null;
|
|
901
|
+
}
|
|
902
|
+
let parsed;
|
|
903
|
+
try {
|
|
904
|
+
parsed = JSON.parse(rawJson);
|
|
905
|
+
}
|
|
906
|
+
catch {
|
|
907
|
+
return null;
|
|
908
|
+
}
|
|
909
|
+
if (!isRecord(parsed)) {
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
const title = parsed.title;
|
|
913
|
+
if (typeof title !== "string" || title.trim().length === 0) {
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
const description = parsed.description;
|
|
917
|
+
if (description !== null && description !== undefined && typeof description !== "string") {
|
|
918
|
+
return null;
|
|
919
|
+
}
|
|
920
|
+
return {
|
|
921
|
+
title: title.trim(),
|
|
922
|
+
description: typeof description === "string" ? normalizeOptionalTaskDescription(description) : null,
|
|
923
|
+
};
|
|
924
|
+
};
|
|
925
|
+
const parseTaskAuthoringFormattedResponse = (responseText) => {
|
|
926
|
+
const normalizedText = responseText.replace(/\r\n/g, "\n").trim();
|
|
927
|
+
if (normalizedText.length === 0) {
|
|
928
|
+
return null;
|
|
929
|
+
}
|
|
930
|
+
const titleMatch = normalizedText.match(/^Title:\s*(.+)$/im);
|
|
931
|
+
if (titleMatch?.[1]) {
|
|
932
|
+
const title = titleMatch[1].trim();
|
|
933
|
+
const description = normalizedText
|
|
934
|
+
.slice((titleMatch.index ?? 0) + titleMatch[0].length)
|
|
935
|
+
.replace(/^\s*Description:\s*/i, "")
|
|
936
|
+
.trim();
|
|
937
|
+
if (title.length === 0) {
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
return {
|
|
941
|
+
title,
|
|
942
|
+
description: normalizeOptionalTaskDescription(description),
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
const firstLineBreak = normalizedText.indexOf("\n");
|
|
946
|
+
if (firstLineBreak < 0) {
|
|
947
|
+
return null;
|
|
948
|
+
}
|
|
949
|
+
const title = normalizedText.slice(0, firstLineBreak).trim();
|
|
950
|
+
const description = normalizedText.slice(firstLineBreak + 1).trim();
|
|
951
|
+
if (title.length === 0 || description.length === 0) {
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
if (title.startsWith("#")) {
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
return {
|
|
958
|
+
title,
|
|
959
|
+
description: normalizeOptionalTaskDescription(description),
|
|
960
|
+
};
|
|
961
|
+
};
|
|
962
|
+
const extractJsonObject = (value) => {
|
|
963
|
+
const trimmedValue = value.trim();
|
|
964
|
+
if (trimmedValue.startsWith("{") && trimmedValue.endsWith("}")) {
|
|
965
|
+
return trimmedValue;
|
|
966
|
+
}
|
|
967
|
+
const fencedMatch = trimmedValue.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
|
968
|
+
if (fencedMatch?.[1]) {
|
|
969
|
+
return fencedMatch[1].trim();
|
|
970
|
+
}
|
|
971
|
+
const firstBraceIndex = trimmedValue.indexOf("{");
|
|
972
|
+
const lastBraceIndex = trimmedValue.lastIndexOf("}");
|
|
973
|
+
if (firstBraceIndex < 0 || lastBraceIndex <= firstBraceIndex) {
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
return trimmedValue.slice(firstBraceIndex, lastBraceIndex + 1);
|
|
977
|
+
};
|
|
978
|
+
const normalizeOptionalTaskDescription = (description) => {
|
|
979
|
+
const trimmedDescription = description.trim();
|
|
980
|
+
return trimmedDescription.length > 0 ? trimmedDescription : null;
|
|
981
|
+
};
|
|
982
|
+
const openSelectedTaskDetail = async (ctx, taskService, taskId, dependencies) => {
|
|
983
|
+
const task = await showTaskDetail({ taskService }, taskId);
|
|
984
|
+
if (!task) {
|
|
985
|
+
ctx.ui.notify("Selected task no longer exists", "warning");
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
await openTaskDetailHub(ctx, taskService, taskId, dependencies);
|
|
989
|
+
};
|
|
990
|
+
const openTaskDetailHub = async (ctx, taskService, taskId, dependencies) => {
|
|
991
|
+
const showTaskDetailView = dependencies.showTaskDetailView ?? selectTaskDetailAction;
|
|
992
|
+
const selectStatus = dependencies.selectTaskStatus ?? selectTaskStatusFromList;
|
|
993
|
+
const selectPriority = dependencies.selectTaskPriority ?? selectTaskPriorityFromList;
|
|
994
|
+
const editComment = dependencies.editTaskComment ?? editTaskComment;
|
|
995
|
+
while (true) {
|
|
996
|
+
const task = await showTaskDetail({ taskService }, taskId);
|
|
997
|
+
if (!task) {
|
|
998
|
+
ctx.ui.notify("Selected task no longer exists", "warning");
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
const action = await showTaskDetailView(ctx, task);
|
|
1002
|
+
if (!action) {
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
if (action === "pickup") {
|
|
1006
|
+
await dependencies.setCurrentTask(ctx, task);
|
|
1007
|
+
ctx.ui.setEditorText(`pickup task ${task.id}`);
|
|
1008
|
+
ctx.ui.notify(`Prepared pickup workflow for ${task.title}`, "info");
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
if (action === "set-current") {
|
|
1012
|
+
await dependencies.setCurrentTask(ctx, task);
|
|
1013
|
+
ctx.ui.notify(`Current task set to ${task.title}`, "info");
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
if (action === "update-status") {
|
|
1017
|
+
const nextStatus = await selectStatus(ctx, task);
|
|
1018
|
+
if (!nextStatus || nextStatus === task.status) {
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
try {
|
|
1022
|
+
const updatedTask = await updateTask({ taskService }, {
|
|
1023
|
+
taskId: task.id,
|
|
1024
|
+
status: nextStatus,
|
|
1025
|
+
});
|
|
1026
|
+
await syncCurrentTaskIfFocused(ctx, updatedTask, dependencies.setCurrentTask);
|
|
1027
|
+
ctx.ui.notify(`Updated ${task.title} to ${nextStatus}`, "info");
|
|
1028
|
+
}
|
|
1029
|
+
catch (error) {
|
|
1030
|
+
ctx.ui.notify(formatTasksCommandError(error, "Failed to update task status"), "error");
|
|
1031
|
+
}
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
if (action === "update-priority") {
|
|
1035
|
+
const nextPriority = await selectPriority(ctx, task);
|
|
1036
|
+
if (!nextPriority || nextPriority === task.priority) {
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
try {
|
|
1040
|
+
const updatedTask = await updateTask({ taskService }, {
|
|
1041
|
+
taskId: task.id,
|
|
1042
|
+
priority: nextPriority,
|
|
1043
|
+
});
|
|
1044
|
+
await syncCurrentTaskIfFocused(ctx, updatedTask, dependencies.setCurrentTask);
|
|
1045
|
+
ctx.ui.notify(`Updated ${task.title} priority to ${nextPriority}`, "info");
|
|
1046
|
+
}
|
|
1047
|
+
catch (error) {
|
|
1048
|
+
ctx.ui.notify(formatTasksCommandError(error, "Failed to update task priority"), "error");
|
|
1049
|
+
}
|
|
1050
|
+
continue;
|
|
1051
|
+
}
|
|
1052
|
+
const commentContent = await editComment(ctx, task);
|
|
1053
|
+
if (!commentContent || commentContent.trim().length === 0) {
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
await commentOnTask({ taskService }, {
|
|
1057
|
+
taskId: task.id,
|
|
1058
|
+
content: commentContent.trim(),
|
|
1059
|
+
});
|
|
1060
|
+
const refreshedTask = await showTaskDetail({ taskService }, task.id);
|
|
1061
|
+
if (refreshedTask) {
|
|
1062
|
+
await syncCurrentTaskIfFocused(ctx, refreshedTask, dependencies.setCurrentTask);
|
|
1063
|
+
}
|
|
1064
|
+
ctx.ui.notify(`Added comment to ${task.title}`, "info");
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
const selectTaskDetailAction = async (ctx, task) => {
|
|
1068
|
+
const viewModel = createTaskDetailViewModel(task);
|
|
1069
|
+
const actionItems = createTaskDetailActionItems(task);
|
|
1070
|
+
return ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
1071
|
+
const container = new Container();
|
|
1072
|
+
container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
1073
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(viewModel.title)), 1, 0));
|
|
1074
|
+
container.addChild(new Text(theme.fg("muted", viewModel.body), 1, 0));
|
|
1075
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Quick actions")), 1, 0));
|
|
1076
|
+
const selectList = new SelectList(actionItems, actionItems.length, {
|
|
1077
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
1078
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
1079
|
+
description: (text) => theme.fg("muted", text),
|
|
1080
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
1081
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
1082
|
+
});
|
|
1083
|
+
selectList.onSelect = (item) => done(item.value);
|
|
1084
|
+
selectList.onCancel = () => done(null);
|
|
1085
|
+
container.addChild(selectList);
|
|
1086
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc close"), 1, 0));
|
|
1087
|
+
container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
1088
|
+
return {
|
|
1089
|
+
render: (width) => container.render(width),
|
|
1090
|
+
invalidate: () => container.invalidate(),
|
|
1091
|
+
handleInput: (data) => {
|
|
1092
|
+
selectList.handleInput(data);
|
|
1093
|
+
tui.requestRender();
|
|
1094
|
+
},
|
|
1095
|
+
};
|
|
1096
|
+
});
|
|
1097
|
+
};
|
|
1098
|
+
const selectTaskStatusFromList = async (ctx, task) => selectTaskSettingFromList(ctx, {
|
|
1099
|
+
title: `Update status · ${task.title}`,
|
|
1100
|
+
options: taskStatusOptions,
|
|
1101
|
+
currentValue: task.status,
|
|
1102
|
+
currentLabel: formatTaskStatusLabel(task.status),
|
|
1103
|
+
actionLabel: "status",
|
|
1104
|
+
});
|
|
1105
|
+
const selectTaskPriorityFromList = async (ctx, task) => selectTaskSettingFromList(ctx, {
|
|
1106
|
+
title: `Update priority · ${task.title}`,
|
|
1107
|
+
options: taskPriorityOptions,
|
|
1108
|
+
currentValue: task.priority,
|
|
1109
|
+
currentLabel: formatTaskPriorityLabel(task.priority),
|
|
1110
|
+
actionLabel: "priority",
|
|
1111
|
+
});
|
|
1112
|
+
const selectTaskSettingFromList = async (ctx, options) => {
|
|
1113
|
+
const items = options.options.map((option) => ({
|
|
1114
|
+
value: option.value,
|
|
1115
|
+
label: option.label,
|
|
1116
|
+
description: option.value === options.currentValue
|
|
1117
|
+
? `${option.label} (current)`
|
|
1118
|
+
: `Set ${options.actionLabel} to ${option.label}`,
|
|
1119
|
+
}));
|
|
1120
|
+
return ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
1121
|
+
const container = new Container();
|
|
1122
|
+
container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
1123
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(options.title)), 1, 0));
|
|
1124
|
+
container.addChild(new Text(theme.fg("muted", `Current ${options.actionLabel}: ${options.currentLabel}`), 1, 0));
|
|
1125
|
+
const selectList = new SelectList(items, items.length, {
|
|
1126
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
1127
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
1128
|
+
description: (text) => theme.fg("muted", text),
|
|
1129
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
1130
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
1131
|
+
});
|
|
1132
|
+
selectList.onSelect = (item) => done(item.value);
|
|
1133
|
+
selectList.onCancel = () => done(null);
|
|
1134
|
+
container.addChild(selectList);
|
|
1135
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));
|
|
1136
|
+
container.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
1137
|
+
return {
|
|
1138
|
+
render: (width) => container.render(width),
|
|
1139
|
+
invalidate: () => container.invalidate(),
|
|
1140
|
+
handleInput: (data) => {
|
|
1141
|
+
selectList.handleInput(data);
|
|
1142
|
+
tui.requestRender();
|
|
1143
|
+
},
|
|
1144
|
+
};
|
|
1145
|
+
});
|
|
1146
|
+
};
|
|
1147
|
+
const editTaskComment = async (ctx, task) => {
|
|
1148
|
+
const content = await ctx.ui.editor(`Add comment · ${task.title}`, "");
|
|
1149
|
+
return content ?? null;
|
|
1150
|
+
};
|
|
1151
|
+
const syncCurrentTaskIfFocused = async (ctx, task, setCurrentTask) => {
|
|
1152
|
+
const currentTaskId = getFocusedTaskId();
|
|
1153
|
+
if (currentTaskId !== task.id) {
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
await setCurrentTask(ctx, task);
|
|
1157
|
+
};
|
|
1158
|
+
const getFocusedTaskId = () => {
|
|
1159
|
+
try {
|
|
1160
|
+
return getDefaultCurrentTaskContextController().getState().currentTaskId;
|
|
1161
|
+
}
|
|
1162
|
+
catch {
|
|
1163
|
+
return null;
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
1166
|
+
const resolveRequestedTaskId = (args, currentTaskId) => {
|
|
1167
|
+
const trimmedArgs = args.trim();
|
|
1168
|
+
return trimmedArgs.length > 0 ? trimmedArgs : currentTaskId;
|
|
1169
|
+
};
|
|
1170
|
+
const resolveDefaultTaskBrowseFilterState = async (taskService, resolveDefaultProject) => {
|
|
1171
|
+
const project = await resolveDefaultProject(taskService).catch(() => null);
|
|
1172
|
+
return createTaskBrowseFilterState({
|
|
1173
|
+
hasSavedFilter: true,
|
|
1174
|
+
status: "active",
|
|
1175
|
+
priority: "high",
|
|
1176
|
+
projectId: project?.projectId ?? null,
|
|
1177
|
+
projectName: project?.projectName ?? null,
|
|
1178
|
+
});
|
|
1179
|
+
};
|
|
1180
|
+
const resolveDefaultProjectFromRepo = async (taskService, repoContextService = createRepoContextService()) => {
|
|
1181
|
+
const projects = await taskService.listProjects();
|
|
1182
|
+
if (projects.length === 0) {
|
|
1183
|
+
return null;
|
|
1184
|
+
}
|
|
1185
|
+
const repoResult = await repoContextService.resolveRepository();
|
|
1186
|
+
let candidateName = null;
|
|
1187
|
+
if (repoResult.kind === "resolved") {
|
|
1188
|
+
const targetRef = repoResult.repository.targetRef;
|
|
1189
|
+
const repoName = targetRef.includes("/") ? targetRef.split("/").pop() : targetRef;
|
|
1190
|
+
candidateName = repoName;
|
|
1191
|
+
}
|
|
1192
|
+
if (!candidateName) {
|
|
1193
|
+
candidateName = path.basename(process.cwd());
|
|
1194
|
+
}
|
|
1195
|
+
if (!candidateName) {
|
|
1196
|
+
return null;
|
|
1197
|
+
}
|
|
1198
|
+
const match = matchProjectByName(projects, candidateName);
|
|
1199
|
+
return match ? { projectId: match.id, projectName: match.name } : null;
|
|
1200
|
+
};
|
|
1201
|
+
const matchProjectByName = (projects, candidateName) => {
|
|
1202
|
+
const normalized = candidateName.toLowerCase();
|
|
1203
|
+
return (projects.find((project) => project.name.toLowerCase() === normalized) ??
|
|
1204
|
+
projects.find((project) => project.name.toLowerCase().replace(/[\s_-]+/g, "-") === normalized.replace(/[\s_-]+/g, "-")) ??
|
|
1205
|
+
null);
|
|
1206
|
+
};
|
|
1207
|
+
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
1208
|
+
const formatTasksCommandError = (error, prefix) => {
|
|
1209
|
+
if (error instanceof Error && error.message.trim().length > 0) {
|
|
1210
|
+
return `${prefix}: ${error.message}`;
|
|
1211
|
+
}
|
|
1212
|
+
return prefix;
|
|
1213
|
+
};
|
|
1214
|
+
export { createHabitsCommandHandler, createTaskClearCommandHandler, createTaskCommandHandler, createTaskNewCommandHandler, createTasksCommandHandler, createSavedTaskBrowseFilterState, createTaskFilterFromBrowseState, DEFAULT_TASK_BROWSE_FILTER_STATE, editTaskComment, formatTaskBrowseFilterSummary, formatTasksCommandError, loadTasksWithLoader, matchProjectByName, openSelectedTaskDetail, openTaskDetailHub, registerCommands, resolveDefaultProjectFromRepo, resolveDefaultTaskBrowseFilterState, resolveRequestedTaskId, selectTaskBrowseViewAction, selectTaskDetailAction, selectTaskPriorityFromList, selectTaskStatusFromList, showEmptyTasksState, showTaskBrowseFilterMode, syncCurrentTaskIfFocused, };
|