@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,44 @@
|
|
|
1
|
+
import { buildTaskListTextParts, type Task } from "./task.ts"
|
|
2
|
+
|
|
3
|
+
export interface TaskListRowOptions {
|
|
4
|
+
maxLabelWidth?: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface TaskListRowModel {
|
|
8
|
+
ref: string
|
|
9
|
+
label: string
|
|
10
|
+
description: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const DESCRIPTION_PART_SEPARATOR = "\u241F"
|
|
14
|
+
|
|
15
|
+
function encodeDescription(meta: string, summary?: string): string {
|
|
16
|
+
return summary ? `${meta}${DESCRIPTION_PART_SEPARATOR}${summary}` : meta
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function decodeDescription(text: string): { meta: string; summary?: string } {
|
|
20
|
+
const [meta, summary] = text.split(DESCRIPTION_PART_SEPARATOR)
|
|
21
|
+
return { meta: meta || "", summary }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function stripAnsi(str: string): string {
|
|
25
|
+
return str.replace(/\x1b\[[0-9;]*m/g, "")
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildListRowModel(task: Task, options: TaskListRowOptions = {}): TaskListRowModel {
|
|
29
|
+
const { maxLabelWidth } = options
|
|
30
|
+
const parts = buildTaskListTextParts(task)
|
|
31
|
+
const baseLabel = `${parts.identity} ${parts.title}`
|
|
32
|
+
const visibleWidth = stripAnsi(baseLabel).length
|
|
33
|
+
|
|
34
|
+
let label = baseLabel
|
|
35
|
+
if (maxLabelWidth !== undefined && visibleWidth < maxLabelWidth) {
|
|
36
|
+
label += " ".repeat(maxLabelWidth - visibleWidth)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
ref: task.ref,
|
|
41
|
+
label,
|
|
42
|
+
description: encodeDescription(parts.meta, parts.summary),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export type TaskStatus = "open" | "inProgress" | "blocked" | "deferred" | "closed"
|
|
2
|
+
|
|
3
|
+
export interface Task {
|
|
4
|
+
ref: string
|
|
5
|
+
id?: string
|
|
6
|
+
title: string
|
|
7
|
+
description?: string
|
|
8
|
+
status: TaskStatus
|
|
9
|
+
priority?: string
|
|
10
|
+
taskType?: string
|
|
11
|
+
owner?: string
|
|
12
|
+
createdAt?: string
|
|
13
|
+
dueAt?: string
|
|
14
|
+
updatedAt?: string
|
|
15
|
+
dependencyCount?: number
|
|
16
|
+
dependentCount?: number
|
|
17
|
+
commentCount?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface TaskListElements {
|
|
21
|
+
id?: string
|
|
22
|
+
title: string
|
|
23
|
+
status: string
|
|
24
|
+
type: string
|
|
25
|
+
summary?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TaskListTextParts {
|
|
29
|
+
identity: string
|
|
30
|
+
title: string
|
|
31
|
+
meta: string
|
|
32
|
+
summary?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const PRIORITY_RANK_COLORS = [
|
|
36
|
+
"\x1b[38;5;196m",
|
|
37
|
+
"\x1b[38;5;208m",
|
|
38
|
+
"\x1b[38;5;34m",
|
|
39
|
+
"\x1b[38;5;33m",
|
|
40
|
+
"\x1b[38;5;245m",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
const STATUS_SYMBOLS: Record<TaskStatus, string> = {
|
|
44
|
+
open: "○",
|
|
45
|
+
inProgress: "◑",
|
|
46
|
+
blocked: "✖",
|
|
47
|
+
deferred: "⏸",
|
|
48
|
+
closed: "✓",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const MUTED_TEXT = "\x1b[38;5;245m"
|
|
52
|
+
const ANSI_RESET = "\x1b[0m"
|
|
53
|
+
|
|
54
|
+
function priorityRank(priority: string | undefined): number | undefined {
|
|
55
|
+
if (!priority) return undefined
|
|
56
|
+
const match = priority.toLowerCase().match(/^p(\d)$/)
|
|
57
|
+
if (!match) return undefined
|
|
58
|
+
return Number(match[1])
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function formatTaskPriority(priority: string | undefined): string {
|
|
62
|
+
if (priority === undefined || priority === null || priority.length === 0) return "P?"
|
|
63
|
+
|
|
64
|
+
const rank = priorityRank(priority)
|
|
65
|
+
const color = rank !== undefined ? PRIORITY_RANK_COLORS[rank] ?? "" : ""
|
|
66
|
+
return `${color}${priority.toUpperCase()}${ANSI_RESET}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stripIdPrefix(id: string): string {
|
|
70
|
+
const idx = id.indexOf("-")
|
|
71
|
+
return idx >= 0 ? id.slice(idx + 1) : id
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function formatTaskTypeCode(taskType: string | undefined): string {
|
|
75
|
+
return (taskType || "task").slice(0, 4).padEnd(4)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function toKebabCase(value: string): string {
|
|
79
|
+
return value.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function formatTaskStatusSymbol(status: TaskStatus): string {
|
|
83
|
+
return STATUS_SYMBOLS[status] ?? "?"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function firstLine(text: string | undefined): string | undefined {
|
|
87
|
+
if (!text) return undefined
|
|
88
|
+
const line = text.split(/\r?\n/)[0]?.trim()
|
|
89
|
+
return line && line.length > 0 ? line : undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildTaskListElements(task: Task): TaskListElements {
|
|
93
|
+
return {
|
|
94
|
+
id: task.id ? stripIdPrefix(task.id) : undefined,
|
|
95
|
+
title: task.title,
|
|
96
|
+
status: formatTaskStatusSymbol(task.status),
|
|
97
|
+
type: formatTaskTypeCode(task.taskType),
|
|
98
|
+
summary: firstLine(task.description),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function buildTaskIdentityText(priority: string | undefined, idText?: string): string {
|
|
103
|
+
if (!idText) return formatTaskPriority(priority)
|
|
104
|
+
const mutedId = `${MUTED_TEXT}${idText}${ANSI_RESET}`
|
|
105
|
+
return `${formatTaskPriority(priority)} ${mutedId}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function buildTaskListTextParts(task: Task): TaskListTextParts {
|
|
109
|
+
const elements = buildTaskListElements(task)
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
identity: buildTaskIdentityText(task.priority, elements.id),
|
|
113
|
+
title: elements.title,
|
|
114
|
+
meta: `${elements.status} ${elements.type}`,
|
|
115
|
+
summary: elements.summary,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Editor, Text, type Component, type EditorTheme, type Focusable } from "@mariozechner/pi-tui"
|
|
2
|
+
|
|
3
|
+
interface BlurEditorFieldOptions {
|
|
4
|
+
stripTopBorder?: boolean
|
|
5
|
+
blurredBorderColor?: (str: string) => string
|
|
6
|
+
paddingX?: number
|
|
7
|
+
indentX?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class BlurEditorField implements Component, Focusable {
|
|
11
|
+
focused = false
|
|
12
|
+
onChange?: (text: string) => void
|
|
13
|
+
|
|
14
|
+
private editor: Editor
|
|
15
|
+
private previewText: Text
|
|
16
|
+
private stripTopBorder: boolean
|
|
17
|
+
private blurredBorderColor: (str: string) => string
|
|
18
|
+
private indentX: number
|
|
19
|
+
|
|
20
|
+
constructor(tui: any, theme: EditorTheme, options: BlurEditorFieldOptions = {}) {
|
|
21
|
+
const paddingX = options.paddingX ?? 1
|
|
22
|
+
|
|
23
|
+
this.editor = new Editor(tui, theme)
|
|
24
|
+
this.editor.setPaddingX(paddingX)
|
|
25
|
+
this.previewText = new Text("", paddingX, 0)
|
|
26
|
+
this.stripTopBorder = options.stripTopBorder ?? true
|
|
27
|
+
this.blurredBorderColor = options.blurredBorderColor ?? theme.borderColor
|
|
28
|
+
this.indentX = Math.max(0, options.indentX ?? 0)
|
|
29
|
+
|
|
30
|
+
this.editor.onChange = (text: string) => {
|
|
31
|
+
this.onChange?.(text)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
set disableSubmit(value: boolean) {
|
|
36
|
+
this.editor.disableSubmit = value
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
setText(text: string): void {
|
|
40
|
+
this.editor.setText(text)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
getText(): string {
|
|
44
|
+
return this.editor.getText()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
insertTextAtCursor(text: string): void {
|
|
48
|
+
this.editor.insertTextAtCursor(text)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
invalidate(): void {
|
|
52
|
+
this.editor.invalidate()
|
|
53
|
+
this.previewText.invalidate()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
render(width: number): string[] {
|
|
57
|
+
const innerWidth = Math.max(1, width - this.indentX)
|
|
58
|
+
const indent = " ".repeat(this.indentX)
|
|
59
|
+
const withIndent = (lines: string[]) => lines.map(line => `${indent}${line}`)
|
|
60
|
+
|
|
61
|
+
if (!this.focused) {
|
|
62
|
+
this.previewText.setText(this.editor.getText())
|
|
63
|
+
const contentLines = this.previewText.render(innerWidth)
|
|
64
|
+
const lines = contentLines.length > 0 ? contentLines : [" ".repeat(Math.max(0, innerWidth))]
|
|
65
|
+
const borderLine = this.blurredBorderColor("─".repeat(Math.max(0, innerWidth)))
|
|
66
|
+
return withIndent([...lines, borderLine])
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const lines = this.editor.render(innerWidth)
|
|
70
|
+
const visibleLines = !this.stripTopBorder || lines.length <= 1 ? lines : lines.slice(1)
|
|
71
|
+
return withIndent(visibleLines)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
handleInput(data: string): void {
|
|
75
|
+
if (!this.focused) return
|
|
76
|
+
this.editor.handleInput(data)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const KEYBOARD_HELP_PADDING_X = 1
|
|
2
|
+
|
|
3
|
+
type ThemeLike = {
|
|
4
|
+
fg: (color: string, text: string) => string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function formatKeyboardHelp(theme: ThemeLike, helpText: string): string {
|
|
8
|
+
const trimmed = helpText.trim()
|
|
9
|
+
if (trimmed.length === 0) return ""
|
|
10
|
+
|
|
11
|
+
const entries = trimmed.split(" • ").map((entry) => entry.trim()).filter(Boolean)
|
|
12
|
+
|
|
13
|
+
return entries
|
|
14
|
+
.map((entry) => {
|
|
15
|
+
const splitIdx = entry.indexOf(" ")
|
|
16
|
+
if (splitIdx === -1) {
|
|
17
|
+
return theme.fg("muted", entry)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const key = entry.slice(0, splitIdx)
|
|
21
|
+
const action = entry.slice(splitIdx + 1)
|
|
22
|
+
return `${theme.fg("muted", key)} ${theme.fg("dim", action)}`
|
|
23
|
+
})
|
|
24
|
+
.join(theme.fg("dim", " • "))
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Component } from "@mariozechner/pi-tui"
|
|
2
|
+
|
|
3
|
+
export class MinHeightContainer implements Component {
|
|
4
|
+
constructor(private child: Component, private minHeight: number) {}
|
|
5
|
+
|
|
6
|
+
invalidate(): void {
|
|
7
|
+
this.child.invalidate()
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
render(width: number): string[] {
|
|
11
|
+
const lines = this.child.render(width)
|
|
12
|
+
if (lines.length >= this.minHeight) return lines
|
|
13
|
+
const padLine = " ".repeat(Math.max(0, width))
|
|
14
|
+
return [...lines, ...Array(this.minHeight - lines.length).fill(padLine)]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
handleInput(data: string): void {
|
|
18
|
+
const childWithInput = this.child as Component & { handleInput?: (input: string) => void }
|
|
19
|
+
childWithInput.handleInput?.(data)
|
|
20
|
+
}
|
|
21
|
+
}
|