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