@soleone/pi-tasks 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +95 -0
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/package.json +46 -0
- package/src/backend/adapters/beads.ts +305 -0
- package/src/backend/adapters/todo-md.ts +446 -0
- package/src/backend/api.ts +40 -0
- package/src/backend/resolver.ts +86 -0
- package/src/controllers/list.ts +208 -0
- package/src/controllers/show.ts +81 -0
- package/src/extension.ts +358 -0
- package/src/index.ts +7 -0
- package/src/lib/task-serialization.ts +45 -0
- package/src/models/list-item.ts +44 -0
- package/src/models/task.ts +117 -0
- package/src/ui/components/blur-editor.ts +78 -0
- package/src/ui/components/keyboard-help.ts +25 -0
- package/src/ui/components/min-height.ts +21 -0
- package/src/ui/pages/list.ts +540 -0
- package/src/ui/pages/show.ts +425 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { Key, matchesKey } from "@mariozechner/pi-tui"
|
|
2
|
+
|
|
3
|
+
export type ListIntent =
|
|
4
|
+
| { type: "cancel" }
|
|
5
|
+
| { type: "searchStart" }
|
|
6
|
+
| { type: "searchCancel" }
|
|
7
|
+
| { type: "searchApply" }
|
|
8
|
+
| { type: "searchBackspace" }
|
|
9
|
+
| { type: "searchAppend"; value: string }
|
|
10
|
+
| { type: "moveSelection"; delta: number }
|
|
11
|
+
| { type: "work" }
|
|
12
|
+
| { type: "edit" }
|
|
13
|
+
| { type: "toggleStatus" }
|
|
14
|
+
| { type: "setPriority"; priority: string }
|
|
15
|
+
| { type: "scrollDescription"; delta: number }
|
|
16
|
+
| { type: "toggleType" }
|
|
17
|
+
| { type: "create" }
|
|
18
|
+
| { type: "insert" }
|
|
19
|
+
| { type: "delegate" }
|
|
20
|
+
|
|
21
|
+
export interface ListControllerState {
|
|
22
|
+
searching: boolean
|
|
23
|
+
filtered: boolean
|
|
24
|
+
allowSearch: boolean
|
|
25
|
+
allowPriority: boolean
|
|
26
|
+
ctrlQ: string
|
|
27
|
+
priorities: string[]
|
|
28
|
+
priorityHotkeys?: Record<string, string>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type ShortcutContext = "default" | "search"
|
|
32
|
+
|
|
33
|
+
interface ShortcutDefinition {
|
|
34
|
+
context: ShortcutContext
|
|
35
|
+
help?: string | ((state: ListControllerState) => string)
|
|
36
|
+
showInHelp?: (state: ListControllerState) => boolean
|
|
37
|
+
match: (data: string, state: ListControllerState) => boolean
|
|
38
|
+
intent: (data: string, state: ListControllerState) => ListIntent
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parsePriorityKey(
|
|
42
|
+
data: string,
|
|
43
|
+
priorities: string[],
|
|
44
|
+
priorityHotkeys?: Record<string, string>,
|
|
45
|
+
): string | null {
|
|
46
|
+
if (data.length !== 1) return null
|
|
47
|
+
|
|
48
|
+
const hotkeyPriority = priorityHotkeys?.[data]
|
|
49
|
+
if (hotkeyPriority && priorities.includes(hotkeyPriority)) return hotkeyPriority
|
|
50
|
+
|
|
51
|
+
const rank = parseInt(data, 10)
|
|
52
|
+
if (isNaN(rank) || rank < 1 || rank > priorities.length) return null
|
|
53
|
+
return priorities[rank - 1] ?? null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildPriorityHelpText(priorities: string[], priorityHotkeys?: Record<string, string>): string {
|
|
57
|
+
const hotkeyKeys = priorityHotkeys ? Object.keys(priorityHotkeys).sort((a, b) => a.localeCompare(b)) : []
|
|
58
|
+
if (hotkeyKeys.length > 0) {
|
|
59
|
+
return `${hotkeyKeys.join("/")} priority`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (priorities.length === 0) return "priority"
|
|
63
|
+
if (priorities.length === 1) return "1 priority"
|
|
64
|
+
return `1-${priorities.length} priority`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isPrintable(data: string): boolean {
|
|
68
|
+
return data.length === 1 && data.charCodeAt(0) >= 32 && data.charCodeAt(0) < 127
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const MOVE_KEYS: Record<string, number> = {
|
|
72
|
+
w: -1,
|
|
73
|
+
W: -1,
|
|
74
|
+
s: 1,
|
|
75
|
+
S: 1,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const SCROLL_KEYS: Record<string, number> = {
|
|
79
|
+
j: 1,
|
|
80
|
+
k: -1,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
|
|
84
|
+
{
|
|
85
|
+
context: "search",
|
|
86
|
+
match: (data, state) => data === state.ctrlQ,
|
|
87
|
+
intent: () => ({ type: "cancel" }),
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
context: "search",
|
|
91
|
+
help: "esc cancel",
|
|
92
|
+
match: (data) => matchesKey(data, Key.escape),
|
|
93
|
+
intent: () => ({ type: "searchCancel" }),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
context: "search",
|
|
97
|
+
help: "enter apply",
|
|
98
|
+
match: (data) => matchesKey(data, Key.enter),
|
|
99
|
+
intent: () => ({ type: "searchApply" }),
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
context: "search",
|
|
103
|
+
match: (data) => matchesKey(data, Key.backspace),
|
|
104
|
+
intent: () => ({ type: "searchBackspace" }),
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
context: "search",
|
|
108
|
+
help: "type to search",
|
|
109
|
+
match: (data) => isPrintable(data),
|
|
110
|
+
intent: (data) => ({ type: "searchAppend", value: data }),
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
context: "default",
|
|
114
|
+
help: "w/s navigate",
|
|
115
|
+
match: (data) => data in MOVE_KEYS,
|
|
116
|
+
intent: (data) => ({ type: "moveSelection", delta: MOVE_KEYS[data] ?? 1 }),
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
context: "default",
|
|
120
|
+
help: "enter work",
|
|
121
|
+
match: (data) => matchesKey(data, Key.enter),
|
|
122
|
+
intent: () => ({ type: "work" }),
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
context: "default",
|
|
126
|
+
help: "e edit",
|
|
127
|
+
match: (data) => data === "e" || data === "E" || matchesKey(data, Key.right),
|
|
128
|
+
intent: () => ({ type: "edit" }),
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
context: "default",
|
|
132
|
+
help: (state) => buildPriorityHelpText(state.priorities, state.priorityHotkeys),
|
|
133
|
+
showInHelp: (state) => state.allowPriority,
|
|
134
|
+
match: (data, state) => state.allowPriority && parsePriorityKey(data, state.priorities, state.priorityHotkeys) !== null,
|
|
135
|
+
intent: (data, state) => ({
|
|
136
|
+
type: "setPriority",
|
|
137
|
+
priority: parsePriorityKey(data, state.priorities, state.priorityHotkeys) ?? state.priorities[0] ?? "",
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
context: "default",
|
|
142
|
+
help: "f find",
|
|
143
|
+
showInHelp: (state) => state.allowSearch,
|
|
144
|
+
match: (data, state) => state.allowSearch && (data === "f" || data === "F"),
|
|
145
|
+
intent: () => ({ type: "searchStart" }),
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
context: "default",
|
|
149
|
+
match: (data) => data === " ",
|
|
150
|
+
intent: () => ({ type: "toggleStatus" }),
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
context: "default",
|
|
154
|
+
match: (data) => data in SCROLL_KEYS,
|
|
155
|
+
intent: (data) => ({ type: "scrollDescription", delta: SCROLL_KEYS[data] ?? 1 }),
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
context: "default",
|
|
159
|
+
help: "t type",
|
|
160
|
+
match: (data) => data === "t" || data === "T",
|
|
161
|
+
intent: () => ({ type: "toggleType" }),
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
context: "default",
|
|
165
|
+
help: "c create",
|
|
166
|
+
match: (data) => data === "c" || data === "C",
|
|
167
|
+
intent: () => ({ type: "create" }),
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
context: "default",
|
|
171
|
+
help: "tab insert",
|
|
172
|
+
match: (data) => matchesKey(data, Key.tab),
|
|
173
|
+
intent: () => ({ type: "insert" }),
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
context: "default",
|
|
177
|
+
match: (data, state) => data === state.ctrlQ,
|
|
178
|
+
intent: () => ({ type: "cancel" }),
|
|
179
|
+
},
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
export function resolveListIntent(data: string, state: ListControllerState): ListIntent {
|
|
183
|
+
const context: ShortcutContext = state.searching ? "search" : "default"
|
|
184
|
+
for (const shortcut of SHORTCUT_DEFINITIONS) {
|
|
185
|
+
if (shortcut.context !== context) continue
|
|
186
|
+
if (shortcut.match(data, state)) return shortcut.intent(data, state)
|
|
187
|
+
}
|
|
188
|
+
return { type: "delegate" }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function buildListPrimaryHelpText(state: ListControllerState): string {
|
|
192
|
+
const context: ShortcutContext = state.searching ? "search" : "default"
|
|
193
|
+
const parts = SHORTCUT_DEFINITIONS
|
|
194
|
+
.filter(s => s.context === context)
|
|
195
|
+
.filter(s => !!s.help)
|
|
196
|
+
.filter(s => (s.showInHelp ? s.showInHelp(state) : true))
|
|
197
|
+
.map(s => (typeof s.help === "function" ? s.help(state) : s.help as string))
|
|
198
|
+
|
|
199
|
+
if (context === "default") {
|
|
200
|
+
parts.push(state.filtered ? "esc clear filter" : "esc cancel")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return parts.join(" • ")
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function buildListSecondaryHelpText(): string {
|
|
207
|
+
return "space status • j/k scroll"
|
|
208
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { TaskStatus } from "../models/task.ts"
|
|
2
|
+
|
|
3
|
+
export type FormFocus = "nav" | "title" | "desc"
|
|
4
|
+
export type FormMode = "edit" | "create"
|
|
5
|
+
|
|
6
|
+
export interface FormDraft {
|
|
7
|
+
title: string
|
|
8
|
+
description: string
|
|
9
|
+
status: TaskStatus
|
|
10
|
+
priority: string | undefined
|
|
11
|
+
taskType: string | undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type HeaderStatusColor = "dim" | "accent" | "warning"
|
|
15
|
+
|
|
16
|
+
export interface HeaderStatus {
|
|
17
|
+
message: string
|
|
18
|
+
icon?: string
|
|
19
|
+
color: HeaderStatusColor
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const FOCUS_LABELS: Record<Exclude<FormFocus, "nav">, string> = {
|
|
23
|
+
title: "Title",
|
|
24
|
+
desc: "Description",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function normalizeDraft(draft: FormDraft): FormDraft {
|
|
28
|
+
return {
|
|
29
|
+
...draft,
|
|
30
|
+
title: draft.title.trim(),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isSameDraft(a: FormDraft, b: FormDraft): boolean {
|
|
35
|
+
const left = normalizeDraft(a)
|
|
36
|
+
const right = normalizeDraft(b)
|
|
37
|
+
return (
|
|
38
|
+
left.title === right.title &&
|
|
39
|
+
left.description === right.description &&
|
|
40
|
+
left.status === right.status &&
|
|
41
|
+
left.priority === right.priority &&
|
|
42
|
+
left.taskType === right.taskType
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getHeaderStatus(
|
|
47
|
+
saveIndicator: "saving" | "saved" | "error" | undefined,
|
|
48
|
+
focus: FormFocus,
|
|
49
|
+
): HeaderStatus | undefined {
|
|
50
|
+
if (saveIndicator === "saving") return { message: "Saving…", icon: "⟳", color: "dim" }
|
|
51
|
+
if (saveIndicator === "saved") return { message: "Saved", icon: "âś“", color: "accent" }
|
|
52
|
+
if (saveIndicator === "error") return { message: "Save failed", color: "warning" }
|
|
53
|
+
if (focus === "title" || focus === "desc") return { message: `Editing ${FOCUS_LABELS[focus].toLowerCase()}`, color: "accent" }
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildPrimaryHelpText(focus: FormFocus): string {
|
|
58
|
+
if (focus === "title") return "enter save • tab description • esc back"
|
|
59
|
+
if (focus === "desc") return "enter newline • tab save • esc back"
|
|
60
|
+
return "tab title • enter save • esc/q back • ctrl+x close"
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function buildPriorityHelpText(priorities: string[], priorityHotkeys?: Record<string, string>): string {
|
|
64
|
+
const hotkeyKeys = priorityHotkeys ? Object.keys(priorityHotkeys).sort((a, b) => a.localeCompare(b)) : []
|
|
65
|
+
if (hotkeyKeys.length > 0) {
|
|
66
|
+
return `${hotkeyKeys.join("/")} priority`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (priorities.length === 0) return "priority"
|
|
70
|
+
if (priorities.length === 1) return "1 priority"
|
|
71
|
+
return `1-${priorities.length} priority`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildSecondaryHelpText(
|
|
75
|
+
focus: FormFocus,
|
|
76
|
+
priorities: string[],
|
|
77
|
+
priorityHotkeys?: Record<string, string>,
|
|
78
|
+
): string {
|
|
79
|
+
if (focus !== "nav") return ""
|
|
80
|
+
return `space status • ${buildPriorityHelpText(priorities, priorityHotkeys)} • t type`
|
|
81
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"
|
|
2
|
+
import initializeAdapter from "./backend/resolver.ts"
|
|
3
|
+
import type { Task, TaskStatus } from "./models/task.ts"
|
|
4
|
+
import { buildTaskWorkPrompt, serializeTask } from "./lib/task-serialization.ts"
|
|
5
|
+
import { showTaskList } from "./ui/pages/list.ts"
|
|
6
|
+
import { showTaskForm } from "./ui/pages/show.ts"
|
|
7
|
+
import type { TaskUpdate } from "./backend/api.ts"
|
|
8
|
+
|
|
9
|
+
const CTRL_Q = "\x11"
|
|
10
|
+
|
|
11
|
+
function parsePriorityKey(
|
|
12
|
+
data: string,
|
|
13
|
+
priorities: string[],
|
|
14
|
+
priorityHotkeys?: Record<string, string>,
|
|
15
|
+
): string | null {
|
|
16
|
+
if (data.length !== 1) return null
|
|
17
|
+
|
|
18
|
+
const hotkeyPriority = priorityHotkeys?.[data]
|
|
19
|
+
if (hotkeyPriority && priorities.includes(hotkeyPriority)) return hotkeyPriority
|
|
20
|
+
|
|
21
|
+
const rank = parseInt(data, 10)
|
|
22
|
+
if (isNaN(rank) || rank < 1 || rank > priorities.length) return null
|
|
23
|
+
return priorities[rank - 1] ?? null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cycleStatus(current: TaskStatus, statusMap: Record<string, string>): TaskStatus {
|
|
27
|
+
const statusCycle = Object.keys(statusMap) as TaskStatus[]
|
|
28
|
+
if (statusCycle.length === 0) return "open"
|
|
29
|
+
const idx = statusCycle.indexOf(current)
|
|
30
|
+
if (idx === -1) return statusCycle[0]
|
|
31
|
+
return statusCycle[(idx + 1) % statusCycle.length]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function cycleTaskType(current: string | undefined, taskTypes: string[]): string {
|
|
35
|
+
if (taskTypes.length === 0) return "task"
|
|
36
|
+
const normalized = current || taskTypes[0]
|
|
37
|
+
const idx = taskTypes.indexOf(normalized)
|
|
38
|
+
if (idx === -1) return taskTypes[0]
|
|
39
|
+
return taskTypes[(idx + 1) % taskTypes.length]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function defaultPriority(priorities: string[]): string | undefined {
|
|
43
|
+
if (priorities.length === 0) return undefined
|
|
44
|
+
return priorities[Math.floor(priorities.length / 2)]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function defaultTaskType(taskTypes: string[]): string | undefined {
|
|
48
|
+
return taskTypes[0]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function hasUniqueValues(values: string[]): boolean {
|
|
52
|
+
return new Set(values).size === values.length
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validateBackendConfiguration(backend: {
|
|
56
|
+
id: string
|
|
57
|
+
statusMap: Record<string, string>
|
|
58
|
+
taskTypes: string[]
|
|
59
|
+
priorities: string[]
|
|
60
|
+
priorityHotkeys?: Record<string, string>
|
|
61
|
+
}): void {
|
|
62
|
+
const statusKeys = Object.keys(backend.statusMap)
|
|
63
|
+
if (statusKeys.length === 0) {
|
|
64
|
+
throw new Error(`Invalid backend config (${backend.id}): statusMap must not be empty`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!statusKeys.includes("open") || !statusKeys.includes("closed")) {
|
|
68
|
+
throw new Error(`Invalid backend config (${backend.id}): statusMap must include open and closed`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (backend.taskTypes.length === 0) {
|
|
72
|
+
throw new Error(`Invalid backend config (${backend.id}): taskTypes must not be empty`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!hasUniqueValues(backend.taskTypes)) {
|
|
76
|
+
throw new Error(`Invalid backend config (${backend.id}): taskTypes must be unique`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (backend.priorities.length < 3 || backend.priorities.length > 5) {
|
|
80
|
+
throw new Error(`Invalid backend config (${backend.id}): priorities must contain 3 to 5 values`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!hasUniqueValues(backend.priorities)) {
|
|
84
|
+
throw new Error(`Invalid backend config (${backend.id}): priorities must be unique`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (backend.priorityHotkeys) {
|
|
88
|
+
for (const [key, priority] of Object.entries(backend.priorityHotkeys)) {
|
|
89
|
+
if (key.length !== 1) {
|
|
90
|
+
throw new Error(`Invalid backend config (${backend.id}): priority hotkey keys must be a single character`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!backend.priorities.includes(priority)) {
|
|
94
|
+
throw new Error(`Invalid backend config (${backend.id}): priority hotkey ${key} points to unsupported priority ${priority}`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface EditTaskResult {
|
|
101
|
+
updatedTask: Task | null
|
|
102
|
+
closeList: boolean
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function buildTaskUpdate(previous: Task, next: {
|
|
106
|
+
title: string
|
|
107
|
+
description: string
|
|
108
|
+
status: TaskStatus
|
|
109
|
+
priority: string | undefined
|
|
110
|
+
taskType: string | undefined
|
|
111
|
+
}): TaskUpdate {
|
|
112
|
+
const update: TaskUpdate = {}
|
|
113
|
+
|
|
114
|
+
const nextTitle = next.title.trim()
|
|
115
|
+
if (nextTitle !== previous.title.trim()) {
|
|
116
|
+
update.title = nextTitle
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (next.description !== (previous.description ?? "")) {
|
|
120
|
+
update.description = next.description
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (next.status !== previous.status) {
|
|
124
|
+
update.status = next.status
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (next.priority !== previous.priority && next.priority !== undefined) {
|
|
128
|
+
update.priority = next.priority
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (next.taskType !== previous.taskType) {
|
|
132
|
+
update.taskType = next.taskType || "task"
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return update
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function hasTaskUpdate(update: TaskUpdate): boolean {
|
|
139
|
+
return Object.keys(update).length > 0
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function applyDraftToTask(
|
|
143
|
+
task: Task,
|
|
144
|
+
draft: {
|
|
145
|
+
title: string
|
|
146
|
+
description: string
|
|
147
|
+
status: TaskStatus
|
|
148
|
+
priority: string | undefined
|
|
149
|
+
taskType: string | undefined
|
|
150
|
+
},
|
|
151
|
+
): Task {
|
|
152
|
+
const nextTask: Task = {
|
|
153
|
+
...task,
|
|
154
|
+
title: draft.title.trim(),
|
|
155
|
+
description: draft.description,
|
|
156
|
+
status: draft.status,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (draft.priority !== undefined) {
|
|
160
|
+
nextTask.priority = draft.priority
|
|
161
|
+
} else {
|
|
162
|
+
delete nextTask.priority
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (draft.taskType !== undefined) {
|
|
166
|
+
nextTask.taskType = draft.taskType
|
|
167
|
+
} else {
|
|
168
|
+
delete nextTask.taskType
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return nextTask
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export default function registerExtension(pi: ExtensionAPI) {
|
|
175
|
+
const backend = initializeAdapter(pi)
|
|
176
|
+
validateBackendConfiguration(backend)
|
|
177
|
+
|
|
178
|
+
const nextStatus = (status: TaskStatus): TaskStatus => cycleStatus(status, backend.statusMap)
|
|
179
|
+
const nextTaskType = (current: string | undefined): string => cycleTaskType(current, backend.taskTypes)
|
|
180
|
+
const nextPriorityFromKey = (data: string): string | null => parsePriorityKey(
|
|
181
|
+
data,
|
|
182
|
+
backend.priorities,
|
|
183
|
+
backend.priorityHotkeys,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
async function listTasks(): Promise<Task[]> {
|
|
187
|
+
return backend.list()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function showTask(ref: string): Promise<Task> {
|
|
191
|
+
return backend.show(ref)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function needsTaskDetailsForEdit(task: Task): boolean {
|
|
195
|
+
return task.description === undefined
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function getTaskForEdit(ref: string, fromList?: Task): Promise<Task> {
|
|
199
|
+
if (!fromList) return showTask(ref)
|
|
200
|
+
if (needsTaskDetailsForEdit(fromList)) return showTask(ref)
|
|
201
|
+
return { ...fromList }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function updateTask(ref: string, update: TaskUpdate): Promise<void> {
|
|
205
|
+
await backend.update(ref, update)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function editTask(ctx: ExtensionCommandContext, ref: string, fromList?: Task): Promise<EditTaskResult> {
|
|
209
|
+
let task = await getTaskForEdit(ref, fromList)
|
|
210
|
+
|
|
211
|
+
const formResult = await showTaskForm(ctx, {
|
|
212
|
+
mode: "edit",
|
|
213
|
+
subtitle: "Edit",
|
|
214
|
+
task,
|
|
215
|
+
ctrlQ: CTRL_Q,
|
|
216
|
+
cycleStatus: nextStatus,
|
|
217
|
+
cycleTaskType: nextTaskType,
|
|
218
|
+
parsePriorityKey: nextPriorityFromKey,
|
|
219
|
+
priorities: backend.priorities,
|
|
220
|
+
priorityHotkeys: backend.priorityHotkeys,
|
|
221
|
+
onSave: async (draft) => {
|
|
222
|
+
const update = buildTaskUpdate(task, {
|
|
223
|
+
title: draft.title,
|
|
224
|
+
description: draft.description,
|
|
225
|
+
status: draft.status,
|
|
226
|
+
priority: draft.priority,
|
|
227
|
+
taskType: draft.taskType,
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
if (!hasTaskUpdate(update)) return false
|
|
231
|
+
|
|
232
|
+
await updateTask(ref, update)
|
|
233
|
+
task = applyDraftToTask(task, {
|
|
234
|
+
title: draft.title,
|
|
235
|
+
description: draft.description,
|
|
236
|
+
status: draft.status,
|
|
237
|
+
priority: draft.priority,
|
|
238
|
+
taskType: draft.taskType,
|
|
239
|
+
})
|
|
240
|
+
return true
|
|
241
|
+
},
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
updatedTask: task,
|
|
246
|
+
closeList: formResult.action === "close_list",
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function createTask(ctx: ExtensionCommandContext): Promise<Task | null> {
|
|
251
|
+
let createdTask: Task | null = null
|
|
252
|
+
|
|
253
|
+
await showTaskForm(ctx, {
|
|
254
|
+
mode: "create",
|
|
255
|
+
subtitle: "Create",
|
|
256
|
+
task: {
|
|
257
|
+
ref: "new",
|
|
258
|
+
title: "",
|
|
259
|
+
description: "",
|
|
260
|
+
status: "open",
|
|
261
|
+
priority: defaultPriority(backend.priorities),
|
|
262
|
+
taskType: defaultTaskType(backend.taskTypes),
|
|
263
|
+
},
|
|
264
|
+
ctrlQ: CTRL_Q,
|
|
265
|
+
cycleStatus: nextStatus,
|
|
266
|
+
cycleTaskType: nextTaskType,
|
|
267
|
+
parsePriorityKey: nextPriorityFromKey,
|
|
268
|
+
priorities: backend.priorities,
|
|
269
|
+
priorityHotkeys: backend.priorityHotkeys,
|
|
270
|
+
onSave: async (draft) => {
|
|
271
|
+
const title = draft.title.trim()
|
|
272
|
+
if (title.length === 0) {
|
|
273
|
+
throw new Error("Title is required")
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!createdTask) {
|
|
277
|
+
createdTask = await backend.create({
|
|
278
|
+
title,
|
|
279
|
+
description: draft.description,
|
|
280
|
+
status: draft.status,
|
|
281
|
+
priority: draft.priority,
|
|
282
|
+
taskType: draft.taskType,
|
|
283
|
+
})
|
|
284
|
+
return true
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const update = buildTaskUpdate(createdTask, {
|
|
288
|
+
title,
|
|
289
|
+
description: draft.description,
|
|
290
|
+
status: draft.status,
|
|
291
|
+
priority: draft.priority,
|
|
292
|
+
taskType: draft.taskType,
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
if (!hasTaskUpdate(update)) return false
|
|
296
|
+
|
|
297
|
+
await updateTask(createdTask.ref, update)
|
|
298
|
+
createdTask = applyDraftToTask(createdTask, {
|
|
299
|
+
title,
|
|
300
|
+
description: draft.description,
|
|
301
|
+
status: draft.status,
|
|
302
|
+
priority: draft.priority,
|
|
303
|
+
taskType: draft.taskType,
|
|
304
|
+
})
|
|
305
|
+
return true
|
|
306
|
+
},
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
return createdTask
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function browseTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
313
|
+
const pageTitle = "Tasks"
|
|
314
|
+
const backendLabel = backend.id
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
backend.invalidateCache?.()
|
|
318
|
+
ctx.ui.setStatus("tasks", "Loading…")
|
|
319
|
+
const tasks = await listTasks()
|
|
320
|
+
ctx.ui.setStatus("tasks", undefined)
|
|
321
|
+
|
|
322
|
+
await showTaskList(ctx, {
|
|
323
|
+
title: pageTitle,
|
|
324
|
+
subtitle: backendLabel,
|
|
325
|
+
tasks,
|
|
326
|
+
ctrlQ: CTRL_Q,
|
|
327
|
+
priorities: backend.priorities,
|
|
328
|
+
priorityHotkeys: backend.priorityHotkeys,
|
|
329
|
+
cycleStatus: nextStatus,
|
|
330
|
+
cycleTaskType: nextTaskType,
|
|
331
|
+
onUpdateTask: updateTask,
|
|
332
|
+
onWork: (task) => pi.sendUserMessage(buildTaskWorkPrompt(task)),
|
|
333
|
+
onInsert: (task) => ctx.ui.pasteToEditor(`${serializeTask(task)} `),
|
|
334
|
+
onEdit: (ref, task) => editTask(ctx, ref, task),
|
|
335
|
+
onCreate: () => createTask(ctx),
|
|
336
|
+
})
|
|
337
|
+
} catch (e) {
|
|
338
|
+
ctx.ui.setStatus("tasks", undefined)
|
|
339
|
+
ctx.ui.notify(e instanceof Error ? e.message : String(e), "error")
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
pi.registerCommand("tasks", {
|
|
344
|
+
description: "Open task list",
|
|
345
|
+
handler: async (_rawArgs, ctx) => {
|
|
346
|
+
if (!ctx.hasUI) return
|
|
347
|
+
await browseTasks(ctx)
|
|
348
|
+
},
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
pi.registerShortcut("ctrl+x", {
|
|
352
|
+
description: "Open task list",
|
|
353
|
+
handler: async (ctx) => {
|
|
354
|
+
if (!ctx.hasUI) return
|
|
355
|
+
await browseTasks(ctx as ExtensionCommandContext)
|
|
356
|
+
},
|
|
357
|
+
})
|
|
358
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Task } from "../models/task.ts"
|
|
2
|
+
import { toKebabCase } from "../models/task.ts"
|
|
3
|
+
|
|
4
|
+
export function serializeTask(task: Task): string {
|
|
5
|
+
const parts = [
|
|
6
|
+
`title="${task.title}"`,
|
|
7
|
+
`status=${toKebabCase(task.status)}`,
|
|
8
|
+
`priority=${task.priority ?? "unknown"}`,
|
|
9
|
+
`type=${task.taskType || "task"}`,
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
if (task.id) {
|
|
13
|
+
parts.unshift(`id=${task.id}`)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const description = task.description?.trim()
|
|
17
|
+
if (description) {
|
|
18
|
+
parts.push(`description="${description.replaceAll("\n", "\\n")}"`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (task.dueAt) {
|
|
22
|
+
parts.push(`due="${task.dueAt}"`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return `task(${parts.join(", ")})`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildTaskWorkPrompt(task: Task): string {
|
|
29
|
+
const leadLine = task.id
|
|
30
|
+
? `Work on task ${task.id}: ${task.title}`
|
|
31
|
+
: `Work on task: ${task.title}`
|
|
32
|
+
|
|
33
|
+
const lines = [
|
|
34
|
+
leadLine,
|
|
35
|
+
"",
|
|
36
|
+
`Status: ${toKebabCase(task.status)}`,
|
|
37
|
+
`Priority: ${task.priority ?? "unknown"}`,
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
if (task.description && task.description.trim()) {
|
|
41
|
+
lines.push("", "Context:", task.description.trim())
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return lines.join("\n")
|
|
45
|
+
}
|