@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,540 @@
1
+ import { DynamicBorder, type ExtensionCommandContext } from "@mariozechner/pi-coding-agent"
2
+ import { Container, SelectList, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui"
3
+ import { toKebabCase, type Task, type TaskStatus } from "../../models/task.ts"
4
+ import type { TaskUpdate } from "../../backend/api.ts"
5
+ import { DESCRIPTION_PART_SEPARATOR, buildListRowModel, decodeDescription, stripAnsi } from "../../models/list-item.ts"
6
+ import { buildListPrimaryHelpText, buildListSecondaryHelpText, resolveListIntent } from "../../controllers/list.ts"
7
+ import { KEYBOARD_HELP_PADDING_X, formatKeyboardHelp } from "../components/keyboard-help.ts"
8
+ import { MinHeightContainer } from "../components/min-height.ts"
9
+
10
+ const LIST_PAGE_CONTENT_MIN_HEIGHT = 20
11
+
12
+ export interface ListPageConfig {
13
+ title: string
14
+ subtitle?: string
15
+ tasks: Task[]
16
+ allowPriority?: boolean
17
+ allowSearch?: boolean
18
+ filterTerm?: string
19
+ priorities: string[]
20
+ priorityHotkeys?: Record<string, string>
21
+ ctrlQ: string
22
+ cycleStatus: (status: TaskStatus) => TaskStatus
23
+ cycleTaskType: (current: string | undefined) => string
24
+ onUpdateTask: (ref: string, update: TaskUpdate) => Promise<void>
25
+ onWork: (task: Task) => void
26
+ onInsert: (task: Task) => void
27
+ onEdit: (ref: string, task: Task | undefined) => Promise<{ updatedTask: Task | null; closeList: boolean }>
28
+ onCreate: () => Promise<Task | null>
29
+ }
30
+
31
+ function truncateDescription(desc: string | undefined, maxLines: number): string[] {
32
+ if (!desc || !desc.trim()) return ["(no description)"]
33
+ const allLines = desc.split(/\r?\n/)
34
+ const lines = allLines.slice(0, maxLines)
35
+ if (allLines.length > maxLines) lines.push("...")
36
+ return lines
37
+ }
38
+
39
+ function matchesFilter(task: Task, term: string): boolean {
40
+ const lower = term.toLowerCase()
41
+ return (
42
+ task.title.toLowerCase().includes(lower) ||
43
+ (task.description ?? "").toLowerCase().includes(lower) ||
44
+ (task.id ?? "").toLowerCase().includes(lower) ||
45
+ toKebabCase(task.status).includes(lower)
46
+ )
47
+ }
48
+
49
+ function buildHeaderText(
50
+ theme: any,
51
+ title: string,
52
+ subtitle: string | undefined,
53
+ searching: boolean,
54
+ searchBuffer: string,
55
+ filterTerm: string,
56
+ ): string {
57
+ if (searching) return theme.fg("muted", theme.bold(`Search: ${searchBuffer}_`))
58
+ if (filterTerm) return theme.fg("muted", theme.bold(`${title} [filter: ${filterTerm}]`))
59
+
60
+ const subtitlePart = subtitle ? theme.fg("dim", ` • ${subtitle}`) : ""
61
+ return `${theme.fg("muted", theme.bold(title))}${subtitlePart}`
62
+ }
63
+
64
+ export async function showTaskList(ctx: ExtensionCommandContext, config: ListPageConfig): Promise<void> {
65
+ const { title, subtitle, tasks, allowPriority = true, allowSearch = true } = config
66
+
67
+ const displayTasks = [...tasks]
68
+ let filterTerm = config.filterTerm || ""
69
+ let rememberedSelectedRef: string | undefined
70
+
71
+ while (true) {
72
+ const visible = filterTerm
73
+ ? displayTasks.filter(i => matchesFilter(i, filterTerm))
74
+ : displayTasks
75
+
76
+ if (visible.length === 0 && filterTerm) {
77
+ ctx.ui.notify(`No matches for "${filterTerm}"`, "warning")
78
+ filterTerm = ""
79
+ continue
80
+ }
81
+
82
+ const getMaxLabelWidth = () => Math.max(...displayTasks.map(i =>
83
+ stripAnsi(buildListRowModel(i).label).length
84
+ ))
85
+
86
+ let selectedRef: string | undefined
87
+ const result = await ctx.ui.custom<"cancel" | "select" | "create">((tui: any, theme: any, _kb: any, done: any) => {
88
+ const container = new Container()
89
+ let searching = false
90
+ let searchBuffer = ""
91
+ let descScroll = 0
92
+
93
+ const headerContainer = new Container()
94
+ const listAreaContainer = new Container()
95
+ const footerContainer = new Container()
96
+ const paddedListAreaContainer = new MinHeightContainer(listAreaContainer, LIST_PAGE_CONTENT_MIN_HEIGHT)
97
+
98
+ container.addChild(headerContainer)
99
+ container.addChild(paddedListAreaContainer)
100
+ container.addChild(footerContainer)
101
+
102
+ const titleText = new Text("", 1, 0)
103
+
104
+ const META_SUMMARY_SEPARATOR = " "
105
+ const accentMarker = "__ACCENT_MARKER__"
106
+ const accentedMarker = theme.fg("accent", accentMarker)
107
+ const markerIndex = accentedMarker.indexOf(accentMarker)
108
+ const accentPrefix = markerIndex >= 0 ? accentedMarker.slice(0, markerIndex) : ""
109
+ const accentSuffix = markerIndex >= 0 ? accentedMarker.slice(markerIndex + accentMarker.length) : "\x1b[0m"
110
+ const applyAccentWithAnsi = (text: string) => {
111
+ const normalized = text.replaceAll(DESCRIPTION_PART_SEPARATOR, META_SUMMARY_SEPARATOR)
112
+ if (!accentPrefix) return theme.fg("accent", normalized)
113
+ return `${accentPrefix}${normalized.replace(/\x1b\[0m/g, `\x1b[0m${accentPrefix}`)}${accentSuffix}`
114
+ }
115
+
116
+ const styleDescription = (text: string) => {
117
+ const { meta, summary } = decodeDescription(text)
118
+ if (!summary) return theme.fg("muted", meta)
119
+ return `${theme.fg("muted", meta)}${META_SUMMARY_SEPARATOR}${summary}`
120
+ }
121
+
122
+ const getItems = () => {
123
+ const filtered = filterTerm
124
+ ? displayTasks.filter(i => matchesFilter(i, filterTerm))
125
+ : displayTasks
126
+ const maxLabelWidth = getMaxLabelWidth()
127
+ return filtered.map((task) => {
128
+ const row = buildListRowModel(task, { maxLabelWidth })
129
+ return {
130
+ value: row.ref,
131
+ label: row.label,
132
+ description: row.description,
133
+ }
134
+ })
135
+ }
136
+
137
+ let items = getItems()
138
+ let selectList = new SelectList(items, Math.min(items.length, 10), {
139
+ selectedPrefix: (t: string) => theme.fg("accent", t),
140
+ selectedText: (t: string) => applyAccentWithAnsi(t),
141
+ description: (t: string) => styleDescription(t),
142
+ scrollInfo: (t: string) => theme.fg("dim", t),
143
+ noMatch: (t: string) => theme.fg("warning", t),
144
+ })
145
+
146
+ if (rememberedSelectedRef) {
147
+ const rememberedIndex = items.findIndex(i => i.value === rememberedSelectedRef)
148
+ if (rememberedIndex >= 0) selectList.setSelectedIndex(rememberedIndex)
149
+ }
150
+
151
+ selectList.onSelectionChange = () => {
152
+ const selected = selectList.getSelectedItem()
153
+ if (selected) rememberedSelectedRef = selected.value
154
+ updateDescPreview()
155
+ tui.requestRender()
156
+ }
157
+ selectList.onSelect = () => {
158
+ const sel = selectList.getSelectedItem()
159
+ if (sel) {
160
+ selectedRef = sel.value
161
+ rememberedSelectedRef = sel.value
162
+ }
163
+ done("select")
164
+ }
165
+ selectList.onCancel = () => {
166
+ if (filterTerm) {
167
+ filterTerm = ""
168
+ items = getItems()
169
+ selectList = new SelectList(items, Math.min(items.length, 10), selectList.theme)
170
+ selectList.onSelectionChange = selectList.onSelectionChange
171
+ selectList.onSelect = selectList.onSelect
172
+ selectList.onCancel = selectList.onCancel
173
+ container.invalidate()
174
+ tui.requestRender()
175
+ } else {
176
+ done("cancel")
177
+ }
178
+ }
179
+
180
+ const renderListArea = () => {
181
+ while (listAreaContainer.children.length > 0) {
182
+ listAreaContainer.removeChild(listAreaContainer.children[0])
183
+ }
184
+ listAreaContainer.addChild(selectList)
185
+ listAreaContainer.addChild(new Spacer(1))
186
+ listAreaContainer.addChild(itemPreviewContainer)
187
+ }
188
+
189
+ const wrapText = (text: string, width: number, maxLines: number): string[] => {
190
+ const lines: string[] = []
191
+ const safeWidth = Math.max(1, width)
192
+
193
+ if (text.length === 0) return [""]
194
+
195
+ const words = text.split(" ")
196
+ let currentLine = ""
197
+
198
+ const flushLine = () => {
199
+ if (lines.length < maxLines) lines.push(currentLine)
200
+ currentLine = ""
201
+ }
202
+
203
+ for (const word of words) {
204
+ const candidate = currentLine ? `${currentLine} ${word}` : word
205
+
206
+ if (stripAnsi(candidate).length <= safeWidth) {
207
+ currentLine = candidate
208
+ continue
209
+ }
210
+
211
+ if (currentLine) {
212
+ flushLine()
213
+ if (lines.length >= maxLines) break
214
+ }
215
+
216
+ let remaining = word
217
+ while (stripAnsi(remaining).length > safeWidth) {
218
+ const chunk = remaining.slice(0, safeWidth)
219
+ if (lines.length < maxLines) lines.push(chunk)
220
+ if (lines.length >= maxLines) break
221
+ remaining = remaining.slice(safeWidth)
222
+ }
223
+ if (lines.length >= maxLines) break
224
+ currentLine = remaining
225
+ }
226
+
227
+ if (currentLine && lines.length < maxLines) lines.push(currentLine)
228
+ return lines.slice(0, maxLines)
229
+ }
230
+
231
+ const buildDescText = (descLines: string[], width: number): string => {
232
+ const wrappedLines: string[] = []
233
+ for (const line of descLines) {
234
+ const wrapped = wrapText(line, width, 7 - wrappedLines.length)
235
+ wrappedLines.push(...wrapped)
236
+ if (wrappedLines.length >= 7) break
237
+ }
238
+ while (wrappedLines.length < 7) wrappedLines.push("")
239
+ return wrappedLines.join("\n")
240
+ }
241
+
242
+ const previewTitleText = new Text("", 0, 0)
243
+ const descTextComponent = new Text(buildDescText([], 80), 0, 0)
244
+ const itemPreviewContainer = new Container()
245
+ itemPreviewContainer.addChild(previewTitleText)
246
+ itemPreviewContainer.addChild(descTextComponent)
247
+
248
+ let lastWidth = 80
249
+
250
+ const updateDescPreview = () => {
251
+ const selected = selectList.getSelectedItem()
252
+ if (!selected) {
253
+ previewTitleText.setText("")
254
+ descTextComponent.setText(buildDescText([], lastWidth))
255
+ return
256
+ }
257
+
258
+ descScroll = 0
259
+ const task = displayTasks.find(i => i.ref === selected.value)
260
+ if (!task) {
261
+ previewTitleText.setText("")
262
+ descTextComponent.setText(buildDescText([], lastWidth))
263
+ return
264
+ }
265
+
266
+ previewTitleText.setText(theme.fg("accent", theme.bold(task.title)))
267
+ const descLines = truncateDescription(task.description, 100)
268
+ descTextComponent.setText(buildDescText(descLines, lastWidth))
269
+ }
270
+ if (items[0]) updateDescPreview()
271
+
272
+ headerContainer.addChild(new DynamicBorder((s: string) => theme.fg("dim", s)))
273
+ headerContainer.addChild(titleText)
274
+
275
+ const helpText = new Text("", KEYBOARD_HELP_PADDING_X, 0)
276
+ const shortcutsText = new Text(formatKeyboardHelp(theme, buildListSecondaryHelpText()), KEYBOARD_HELP_PADDING_X, 0)
277
+
278
+ footerContainer.addChild(new DynamicBorder((s: string) => theme.fg("dim", s)))
279
+ footerContainer.addChild(helpText)
280
+ footerContainer.addChild(shortcutsText)
281
+ footerContainer.addChild(new DynamicBorder((s: string) => theme.fg("dim", s)))
282
+
283
+ renderListArea()
284
+
285
+ const refreshDisplay = () => {
286
+ titleText.setText(buildHeaderText(theme, title, subtitle, searching, searchBuffer, filterTerm))
287
+ helpText.setText(formatKeyboardHelp(theme, buildListPrimaryHelpText({
288
+ searching,
289
+ filtered: !!filterTerm,
290
+ allowPriority,
291
+ allowSearch,
292
+ ctrlQ: config.ctrlQ,
293
+ priorities: config.priorities,
294
+ priorityHotkeys: config.priorityHotkeys,
295
+ })))
296
+ }
297
+ refreshDisplay()
298
+
299
+ const moveSelection = (delta: number) => {
300
+ if (items.length === 0) return
301
+ const selected = selectList.getSelectedItem()
302
+ const currentIndex = selected ? items.findIndex(i => i.value === selected.value) : 0
303
+ const normalizedIndex = currentIndex >= 0 ? currentIndex : 0
304
+ const nextIndex = (normalizedIndex + delta + items.length) % items.length
305
+ selectList.setSelectedIndex(nextIndex)
306
+ updateDescPreview()
307
+ container.invalidate()
308
+ tui.requestRender()
309
+ }
310
+
311
+ const getSelectedTask = (): Task | undefined => {
312
+ const selected = selectList.getSelectedItem()
313
+ if (!selected) return undefined
314
+ rememberedSelectedRef = selected.value
315
+ return displayTasks.find(i => i.ref === selected.value)
316
+ }
317
+
318
+ const withSelectedTask = (run: (task: Task) => void): void => {
319
+ const task = getSelectedTask()
320
+ if (!task) return
321
+ run(task)
322
+ }
323
+
324
+ const rebuildAndRender = () => {
325
+ items = getItems()
326
+ const prevSelected = selectList.getSelectedItem()
327
+
328
+ selectList = new SelectList(items, Math.min(items.length, 10), {
329
+ selectedPrefix: (t: string) => theme.fg("accent", t),
330
+ selectedText: (t: string) => applyAccentWithAnsi(t),
331
+ description: (t: string) => styleDescription(t),
332
+ scrollInfo: (t: string) => theme.fg("dim", t),
333
+ noMatch: (t: string) => theme.fg("warning", t),
334
+ })
335
+
336
+ selectList.onSelectionChange = () => {
337
+ const selected = selectList.getSelectedItem()
338
+ if (selected) rememberedSelectedRef = selected.value
339
+ updateDescPreview()
340
+ tui.requestRender()
341
+ }
342
+ selectList.onSelect = () => {
343
+ const sel = selectList.getSelectedItem()
344
+ if (sel) {
345
+ selectedRef = sel.value
346
+ rememberedSelectedRef = sel.value
347
+ }
348
+ done("select")
349
+ }
350
+ selectList.onCancel = () => {
351
+ if (filterTerm) {
352
+ filterTerm = ""
353
+ rebuildAndRender()
354
+ } else {
355
+ done("cancel")
356
+ }
357
+ }
358
+
359
+ renderListArea()
360
+
361
+ if (prevSelected) {
362
+ const newIdx = items.findIndex(i => i.value === prevSelected.value)
363
+ if (newIdx >= 0) selectList.setSelectedIndex(newIdx)
364
+ }
365
+
366
+ refreshDisplay()
367
+ updateDescPreview()
368
+ container.invalidate()
369
+ tui.requestRender()
370
+ }
371
+
372
+ return {
373
+ render: (w: number) => {
374
+ lastWidth = w
375
+ return container.render(w).map((l: string) => truncateToWidth(l, w))
376
+ },
377
+ invalidate: () => container.invalidate(),
378
+ handleInput: (data: string) => {
379
+ const intent = resolveListIntent(data, {
380
+ searching,
381
+ filtered: !!filterTerm,
382
+ allowSearch,
383
+ allowPriority,
384
+ ctrlQ: config.ctrlQ,
385
+ priorities: config.priorities,
386
+ priorityHotkeys: config.priorityHotkeys,
387
+ })
388
+
389
+ switch (intent.type) {
390
+ case "cancel":
391
+ done("cancel")
392
+ return
393
+
394
+ case "searchStart":
395
+ searching = true
396
+ searchBuffer = ""
397
+ refreshDisplay()
398
+ container.invalidate()
399
+ tui.requestRender()
400
+ return
401
+
402
+ case "searchCancel":
403
+ searching = false
404
+ searchBuffer = ""
405
+ refreshDisplay()
406
+ container.invalidate()
407
+ tui.requestRender()
408
+ return
409
+
410
+ case "searchApply":
411
+ filterTerm = searchBuffer.trim()
412
+ searching = false
413
+ rebuildAndRender()
414
+ refreshDisplay()
415
+ return
416
+
417
+ case "searchBackspace":
418
+ searchBuffer = searchBuffer.slice(0, -1)
419
+ refreshDisplay()
420
+ container.invalidate()
421
+ tui.requestRender()
422
+ return
423
+
424
+ case "searchAppend":
425
+ searchBuffer += intent.value
426
+ refreshDisplay()
427
+ container.invalidate()
428
+ tui.requestRender()
429
+ return
430
+
431
+ case "moveSelection":
432
+ moveSelection(intent.delta)
433
+ return
434
+
435
+ case "work":
436
+ withSelectedTask((task) => {
437
+ done("cancel")
438
+ config.onWork(task)
439
+ })
440
+ return
441
+
442
+ case "edit":
443
+ withSelectedTask((task) => {
444
+ selectedRef = task.ref
445
+ done("select")
446
+ })
447
+ return
448
+
449
+ case "toggleStatus":
450
+ withSelectedTask((task) => {
451
+ const newStatus = config.cycleStatus(task.status)
452
+ task.status = newStatus
453
+ void config.onUpdateTask(task.ref, { status: newStatus })
454
+ rebuildAndRender()
455
+ })
456
+ return
457
+
458
+ case "setPriority":
459
+ withSelectedTask((task) => {
460
+ if (task.priority === intent.priority) return
461
+ task.priority = intent.priority
462
+ void config.onUpdateTask(task.ref, { priority: intent.priority })
463
+ rebuildAndRender()
464
+ })
465
+ return
466
+
467
+ case "scrollDescription":
468
+ withSelectedTask((task) => {
469
+ const descLines = truncateDescription(task.description, 100)
470
+ const allWrapped: string[] = []
471
+ for (const line of descLines) {
472
+ const wrapped = wrapText(line, lastWidth, 100)
473
+ allWrapped.push(...wrapped)
474
+ }
475
+ const maxScroll = Math.max(0, allWrapped.length - 7)
476
+ if (intent.delta > 0 && descScroll < maxScroll) {
477
+ descScroll++
478
+ } else if (intent.delta < 0 && descScroll > 0) {
479
+ descScroll--
480
+ }
481
+ const visible = allWrapped.slice(descScroll, descScroll + 7)
482
+ while (visible.length < 7) visible.push("")
483
+ descTextComponent.setText(visible.join("\n"))
484
+ container.invalidate()
485
+ tui.requestRender()
486
+ })
487
+ return
488
+
489
+ case "toggleType":
490
+ withSelectedTask((task) => {
491
+ const newType = config.cycleTaskType(task.taskType)
492
+ task.taskType = newType
493
+ void config.onUpdateTask(task.ref, { taskType: newType })
494
+ rebuildAndRender()
495
+ })
496
+ return
497
+
498
+ case "create":
499
+ done("create")
500
+ return
501
+
502
+ case "insert":
503
+ withSelectedTask((task) => {
504
+ done("cancel")
505
+ config.onInsert(task)
506
+ })
507
+ return
508
+
509
+ case "delegate":
510
+ selectList.handleInput(data)
511
+ tui.requestRender()
512
+ return
513
+ }
514
+ },
515
+ }
516
+ })
517
+
518
+ if (result === "cancel") return
519
+
520
+ if (result === "create") {
521
+ const createdTask = await config.onCreate()
522
+ if (createdTask) {
523
+ displayTasks.unshift(createdTask)
524
+ rememberedSelectedRef = createdTask.ref
525
+ }
526
+ continue
527
+ }
528
+
529
+ if (result === "select" && selectedRef) {
530
+ rememberedSelectedRef = selectedRef
531
+ const currentTask = displayTasks.find(i => i.ref === selectedRef)
532
+ const editResult = await config.onEdit(selectedRef, currentTask)
533
+ if (editResult.updatedTask) {
534
+ const idx = displayTasks.findIndex(i => i.ref === selectedRef)
535
+ if (idx !== -1) displayTasks[idx] = editResult.updatedTask
536
+ }
537
+ if (editResult.closeList) return
538
+ }
539
+ }
540
+ }