@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,425 @@
|
|
|
1
|
+
import { DynamicBorder, type ExtensionCommandContext } from "@mariozechner/pi-coding-agent"
|
|
2
|
+
import { Container, Key, Spacer, Text, matchesKey, truncateToWidth, visibleWidth, type Component } from "@mariozechner/pi-tui"
|
|
3
|
+
import {
|
|
4
|
+
buildPrimaryHelpText,
|
|
5
|
+
buildSecondaryHelpText,
|
|
6
|
+
getHeaderStatus,
|
|
7
|
+
isSameDraft,
|
|
8
|
+
normalizeDraft,
|
|
9
|
+
type FormDraft,
|
|
10
|
+
type FormFocus,
|
|
11
|
+
type FormMode,
|
|
12
|
+
type HeaderStatus,
|
|
13
|
+
} from "../../controllers/show.ts"
|
|
14
|
+
import { buildTaskIdentityText, buildTaskListTextParts, formatTaskTypeCode, type Task, type TaskStatus } from "../../models/task.ts"
|
|
15
|
+
import { BlurEditorField } from "../components/blur-editor.ts"
|
|
16
|
+
import { KEYBOARD_HELP_PADDING_X, formatKeyboardHelp } from "../components/keyboard-help.ts"
|
|
17
|
+
import { MinHeightContainer } from "../components/min-height.ts"
|
|
18
|
+
|
|
19
|
+
export type TaskFormAction = "back" | "close_list"
|
|
20
|
+
|
|
21
|
+
export interface TaskFormResult {
|
|
22
|
+
action: TaskFormAction
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ShowTaskFormOptions {
|
|
26
|
+
mode: FormMode
|
|
27
|
+
subtitle: string
|
|
28
|
+
task: Task
|
|
29
|
+
ctrlQ: string
|
|
30
|
+
cycleStatus: (status: TaskStatus) => TaskStatus
|
|
31
|
+
cycleTaskType: (taskType: string | undefined) => string
|
|
32
|
+
parsePriorityKey: (data: string) => string | null
|
|
33
|
+
priorities: string[]
|
|
34
|
+
priorityHotkeys?: Record<string, string>
|
|
35
|
+
onSave: (draft: FormDraft) => Promise<boolean>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildPageTitle(theme: any, subtitle: string, status?: HeaderStatus): string {
|
|
39
|
+
const base = `${theme.fg("muted", theme.bold("Tasks"))}${theme.fg("dim", ` • ${subtitle}`)}`
|
|
40
|
+
if (!status) return base
|
|
41
|
+
|
|
42
|
+
const marker = status.icon ? theme.fg(status.color, status.icon) : "•"
|
|
43
|
+
return `${base} ${marker} ${theme.fg(status.color, status.message)}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildSelectedTaskLine(
|
|
47
|
+
mode: FormMode,
|
|
48
|
+
theme: any,
|
|
49
|
+
rowIdentity: string,
|
|
50
|
+
rowMeta: string,
|
|
51
|
+
priority: string | undefined,
|
|
52
|
+
taskType: string | undefined,
|
|
53
|
+
): string {
|
|
54
|
+
if (mode === "create") {
|
|
55
|
+
const identity = buildTaskIdentityText(priority, "new")
|
|
56
|
+
return `${theme.fg("accent", SELECTED_ITEM_PREFIX)}${identity} ${formatTaskTypeCode(taskType)}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return `${theme.fg("accent", SELECTED_ITEM_PREFIX)}${rowIdentity} ${rowMeta}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function fieldLabel(theme: any, label: string, focused: boolean): string {
|
|
63
|
+
const color = focused ? "accent" : "muted"
|
|
64
|
+
return theme.fg(color, theme.bold(` ${label}`))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const SELECTED_ITEM_PREFIX = "› "
|
|
68
|
+
const DESCRIPTION_FIELD_HEIGHT = 8
|
|
69
|
+
const PAGE_CONTENT_MIN_HEIGHT = 19
|
|
70
|
+
|
|
71
|
+
class FixedHeightField implements Component {
|
|
72
|
+
constructor(private child: Component, private height: number) {}
|
|
73
|
+
|
|
74
|
+
invalidate(): void {
|
|
75
|
+
this.child.invalidate()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
render(width: number): string[] {
|
|
79
|
+
const lines = this.child.render(width)
|
|
80
|
+
|
|
81
|
+
if (lines.length === this.height) return lines
|
|
82
|
+
|
|
83
|
+
if (lines.length < this.height) {
|
|
84
|
+
return [...lines, ...Array(this.height - lines.length).fill(" ".repeat(Math.max(0, width)))]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (this.height <= 1) {
|
|
88
|
+
return [lines[lines.length - 1] || ""]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const bottomLine = lines[lines.length - 1] || ""
|
|
92
|
+
const bodyLines = lines.slice(0, lines.length - 1)
|
|
93
|
+
const viewportHeight = this.height - 1
|
|
94
|
+
|
|
95
|
+
const cursorIndex = bodyLines.findIndex(line => line.includes("\x1b[7m"))
|
|
96
|
+
|
|
97
|
+
let start = Math.max(0, bodyLines.length - viewportHeight)
|
|
98
|
+
if (cursorIndex >= 0) {
|
|
99
|
+
if (cursorIndex < start) {
|
|
100
|
+
start = cursorIndex
|
|
101
|
+
} else if (cursorIndex >= start + viewportHeight) {
|
|
102
|
+
start = cursorIndex - viewportHeight + 1
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const clippedBody = bodyLines.slice(start, start + viewportHeight)
|
|
107
|
+
if (clippedBody.length < viewportHeight) {
|
|
108
|
+
clippedBody.push(...Array(viewportHeight - clippedBody.length).fill(" ".repeat(Math.max(0, width))))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return [...clippedBody, bottomLine]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
handleInput(data: string): void {
|
|
115
|
+
const childWithInput = this.child as Component & { handleInput?: (input: string) => void }
|
|
116
|
+
childWithInput.handleInput?.(data)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
class ReservedLineText implements Component {
|
|
121
|
+
private text = ""
|
|
122
|
+
|
|
123
|
+
constructor(private paddingX = 1) {}
|
|
124
|
+
|
|
125
|
+
setText(text: string): void {
|
|
126
|
+
this.text = text
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
invalidate(): void {}
|
|
130
|
+
|
|
131
|
+
render(width: number): string[] {
|
|
132
|
+
const innerWidth = Math.max(0, width - this.paddingX * 2)
|
|
133
|
+
const left = " ".repeat(this.paddingX)
|
|
134
|
+
const right = " ".repeat(this.paddingX)
|
|
135
|
+
|
|
136
|
+
if (!this.text || this.text.trim().length === 0) {
|
|
137
|
+
return [`${left}${" ".repeat(innerWidth)}${right}`]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const content = truncateToWidth(this.text, innerWidth)
|
|
141
|
+
const trailingPadding = Math.max(0, innerWidth - visibleWidth(content))
|
|
142
|
+
return [`${left}${content}${" ".repeat(trailingPadding)}${right}`]
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function showTaskForm(ctx: ExtensionCommandContext, options: ShowTaskFormOptions): Promise<TaskFormResult> {
|
|
147
|
+
const { mode, subtitle, task, ctrlQ, cycleStatus, cycleTaskType, parsePriorityKey, priorities, priorityHotkeys, onSave } = options
|
|
148
|
+
|
|
149
|
+
let taskTypeValue = task.taskType
|
|
150
|
+
let titleValue = task.title
|
|
151
|
+
let descValue = task.description ?? ""
|
|
152
|
+
let statusValue = task.status
|
|
153
|
+
let priorityValue = task.priority
|
|
154
|
+
|
|
155
|
+
return ctx.ui.custom<TaskFormResult>((tui: any, theme: any, _kb: any, done: any) => {
|
|
156
|
+
const container = new Container()
|
|
157
|
+
const headerContainer = new Container()
|
|
158
|
+
const formContainer = new Container()
|
|
159
|
+
const footerContainer = new Container()
|
|
160
|
+
const paddedFormContainer = new MinHeightContainer(formContainer, PAGE_CONTENT_MIN_HEIGHT)
|
|
161
|
+
|
|
162
|
+
container.addChild(headerContainer)
|
|
163
|
+
container.addChild(paddedFormContainer)
|
|
164
|
+
container.addChild(footerContainer)
|
|
165
|
+
|
|
166
|
+
const pageTitleText = new Text("", 1, 0)
|
|
167
|
+
const selectedTaskText = new Text("", 0, 0)
|
|
168
|
+
const titleLabel = new Text("", 0, 0)
|
|
169
|
+
const descLabel = new Text("", 0, 0)
|
|
170
|
+
const helpText = new ReservedLineText(KEYBOARD_HELP_PADDING_X)
|
|
171
|
+
const shortcutsText = new ReservedLineText(KEYBOARD_HELP_PADDING_X)
|
|
172
|
+
|
|
173
|
+
let focus: FormFocus = mode === "create" ? "title" : "nav"
|
|
174
|
+
let saveIndicator: "saving" | "saved" | "error" | undefined
|
|
175
|
+
let saveIndicatorTimer: ReturnType<typeof setTimeout> | undefined
|
|
176
|
+
let saving = false
|
|
177
|
+
let disposed = false
|
|
178
|
+
|
|
179
|
+
const editorTheme = {
|
|
180
|
+
borderColor: (s: string) => theme.fg("accent", s),
|
|
181
|
+
selectList: {
|
|
182
|
+
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
183
|
+
selectedText: (t: string) => theme.fg("accent", t),
|
|
184
|
+
description: (t: string) => theme.fg("muted", t),
|
|
185
|
+
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
186
|
+
noMatch: (t: string) => theme.fg("warning", t),
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const titleEditor = new BlurEditorField(tui, editorTheme, {
|
|
191
|
+
stripTopBorder: true,
|
|
192
|
+
blurredBorderColor: (s: string) => theme.fg("muted", s),
|
|
193
|
+
paddingX: 2,
|
|
194
|
+
indentX: 2,
|
|
195
|
+
})
|
|
196
|
+
titleEditor.setText(titleValue)
|
|
197
|
+
titleEditor.disableSubmit = true
|
|
198
|
+
titleEditor.onChange = (text: string) => {
|
|
199
|
+
const normalized = text.replace(/\r?\n/g, " ")
|
|
200
|
+
if (normalized !== text) {
|
|
201
|
+
titleEditor.setText(normalized)
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
titleValue = normalized
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const descEditor = new BlurEditorField(tui, editorTheme, {
|
|
208
|
+
stripTopBorder: true,
|
|
209
|
+
blurredBorderColor: (s: string) => theme.fg("muted", s),
|
|
210
|
+
paddingX: 2,
|
|
211
|
+
indentX: 2,
|
|
212
|
+
})
|
|
213
|
+
const descEditorField = new FixedHeightField(descEditor, DESCRIPTION_FIELD_HEIGHT)
|
|
214
|
+
descEditor.setText(descValue)
|
|
215
|
+
descEditor.disableSubmit = true
|
|
216
|
+
descEditor.onChange = (text: string) => {
|
|
217
|
+
descValue = text
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const currentDraft = (): FormDraft => ({
|
|
221
|
+
title: titleValue,
|
|
222
|
+
description: descValue,
|
|
223
|
+
status: statusValue,
|
|
224
|
+
priority: priorityValue,
|
|
225
|
+
taskType: taskTypeValue,
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
let lastSavedDraft: FormDraft = currentDraft()
|
|
229
|
+
|
|
230
|
+
const triggerSave = async () => {
|
|
231
|
+
if (saving || disposed) return
|
|
232
|
+
|
|
233
|
+
const draft = currentDraft()
|
|
234
|
+
if (isSameDraft(draft, lastSavedDraft)) return
|
|
235
|
+
|
|
236
|
+
saving = true
|
|
237
|
+
if (saveIndicatorTimer) clearTimeout(saveIndicatorTimer)
|
|
238
|
+
saveIndicator = "saving"
|
|
239
|
+
renderLayout()
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const didSave = await onSave(draft)
|
|
243
|
+
if (disposed) return
|
|
244
|
+
if (!didSave) {
|
|
245
|
+
saveIndicator = undefined
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
lastSavedDraft = normalizeDraft(draft)
|
|
249
|
+
saveIndicator = "saved"
|
|
250
|
+
} catch (e) {
|
|
251
|
+
if (disposed) return
|
|
252
|
+
saveIndicator = "error"
|
|
253
|
+
ctx.ui.notify(e instanceof Error ? e.message : String(e), "error")
|
|
254
|
+
} finally {
|
|
255
|
+
saving = false
|
|
256
|
+
if (!disposed) renderLayout()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (saveIndicator === "saved" && !disposed) {
|
|
260
|
+
saveIndicatorTimer = setTimeout(() => {
|
|
261
|
+
if (disposed) return
|
|
262
|
+
saveIndicator = undefined
|
|
263
|
+
renderLayout()
|
|
264
|
+
}, 5000)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const renderLayout = () => {
|
|
269
|
+
titleEditor.focused = focus === "title"
|
|
270
|
+
descEditor.focused = focus === "desc"
|
|
271
|
+
|
|
272
|
+
const rowParts = buildTaskListTextParts({
|
|
273
|
+
...task,
|
|
274
|
+
title: titleValue,
|
|
275
|
+
description: descValue,
|
|
276
|
+
status: statusValue,
|
|
277
|
+
priority: priorityValue,
|
|
278
|
+
taskType: taskTypeValue,
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
const headerStatus = getHeaderStatus(saveIndicator, focus)
|
|
282
|
+
pageTitleText.setText(buildPageTitle(theme, subtitle, headerStatus))
|
|
283
|
+
selectedTaskText.setText(
|
|
284
|
+
buildSelectedTaskLine(mode, theme, rowParts.identity, rowParts.meta, priorityValue, taskTypeValue),
|
|
285
|
+
)
|
|
286
|
+
titleLabel.setText(fieldLabel(theme, "Title", focus === "title"))
|
|
287
|
+
descLabel.setText(fieldLabel(theme, "Description", focus === "desc"))
|
|
288
|
+
|
|
289
|
+
helpText.setText(formatKeyboardHelp(theme, buildPrimaryHelpText(focus)))
|
|
290
|
+
const secondaryHelp = buildSecondaryHelpText(focus, priorities, priorityHotkeys)
|
|
291
|
+
shortcutsText.setText(secondaryHelp ? formatKeyboardHelp(theme, secondaryHelp) : "")
|
|
292
|
+
|
|
293
|
+
container.invalidate()
|
|
294
|
+
tui.requestRender()
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
headerContainer.addChild(new DynamicBorder((s: string) => theme.fg("dim", s)))
|
|
298
|
+
headerContainer.addChild(pageTitleText)
|
|
299
|
+
headerContainer.addChild(selectedTaskText)
|
|
300
|
+
|
|
301
|
+
formContainer.addChild(new Spacer(1))
|
|
302
|
+
formContainer.addChild(titleLabel)
|
|
303
|
+
formContainer.addChild(titleEditor)
|
|
304
|
+
formContainer.addChild(new Spacer(1))
|
|
305
|
+
formContainer.addChild(descLabel)
|
|
306
|
+
formContainer.addChild(descEditorField)
|
|
307
|
+
|
|
308
|
+
footerContainer.addChild(new DynamicBorder((s: string) => theme.fg("dim", s)))
|
|
309
|
+
footerContainer.addChild(helpText)
|
|
310
|
+
footerContainer.addChild(shortcutsText)
|
|
311
|
+
footerContainer.addChild(new DynamicBorder((s: string) => theme.fg("dim", s)))
|
|
312
|
+
|
|
313
|
+
renderLayout()
|
|
314
|
+
|
|
315
|
+
const requestRender = () => {
|
|
316
|
+
container.invalidate()
|
|
317
|
+
tui.requestRender()
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const handleTitleInput = (data: string) => {
|
|
321
|
+
if (matchesKey(data, Key.enter)) {
|
|
322
|
+
focus = "nav"
|
|
323
|
+
void triggerSave()
|
|
324
|
+
renderLayout()
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (matchesKey(data, Key.tab)) {
|
|
329
|
+
focus = "desc"
|
|
330
|
+
renderLayout()
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
titleEditor.handleInput(data)
|
|
335
|
+
requestRender()
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const handleDescInput = (data: string) => {
|
|
339
|
+
if (matchesKey(data, Key.enter)) {
|
|
340
|
+
descEditor.insertTextAtCursor("\n")
|
|
341
|
+
requestRender()
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (matchesKey(data, Key.tab)) {
|
|
346
|
+
focus = "nav"
|
|
347
|
+
void triggerSave()
|
|
348
|
+
renderLayout()
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
descEditor.handleInput(data)
|
|
353
|
+
requestRender()
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const handleNavInput = (data: string) => {
|
|
357
|
+
if (matchesKey(data, Key.enter)) {
|
|
358
|
+
void triggerSave()
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (matchesKey(data, Key.tab)) {
|
|
363
|
+
focus = "title"
|
|
364
|
+
renderLayout()
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.left) || data === "q" || data === "Q") {
|
|
369
|
+
done({ action: "back" })
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (data === "t" || data === "T") {
|
|
374
|
+
taskTypeValue = cycleTaskType(taskTypeValue)
|
|
375
|
+
renderLayout()
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (data === " ") {
|
|
380
|
+
statusValue = cycleStatus(statusValue)
|
|
381
|
+
renderLayout()
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const priority = parsePriorityKey(data)
|
|
386
|
+
if (priority !== null) {
|
|
387
|
+
priorityValue = priority
|
|
388
|
+
renderLayout()
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
render: (w: number) => container.render(w).map((line: string) => truncateToWidth(line, w)),
|
|
394
|
+
invalidate: () => container.invalidate(),
|
|
395
|
+
dispose: () => {
|
|
396
|
+
disposed = true
|
|
397
|
+
if (saveIndicatorTimer) clearTimeout(saveIndicatorTimer)
|
|
398
|
+
},
|
|
399
|
+
handleInput: (data: string) => {
|
|
400
|
+
if (data === ctrlQ) {
|
|
401
|
+
done({ action: "close_list" })
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (focus !== "nav" && matchesKey(data, Key.escape)) {
|
|
406
|
+
focus = "nav"
|
|
407
|
+
renderLayout()
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (focus === "title") {
|
|
412
|
+
handleTitleInput(data)
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (focus === "desc") {
|
|
417
|
+
handleDescInput(data)
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
handleNavInput(data)
|
|
422
|
+
},
|
|
423
|
+
}
|
|
424
|
+
})
|
|
425
|
+
}
|