@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.
@@ -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
+ }