@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,95 @@
1
+ # Contributing
2
+
3
+ ## UI
4
+
5
+ ### Color hierarchy (Beads pages)
6
+
7
+ Use these text colors consistently, from strongest to weakest emphasis:
8
+
9
+ 1. `accent`
10
+ 2. `warning` (only for attention/error states)
11
+ 3. default/plain text
12
+ 4. `muted`
13
+ 5. `dim`
14
+
15
+ ### Color inventory by page
16
+
17
+ #### List page
18
+
19
+ - `accent`
20
+ - selected row prefix/text
21
+ - selected preview title
22
+ - `muted`
23
+ - page title (`Tasks`)
24
+ - global top/bottom borders
25
+ - row metadata (status/type)
26
+ - `dim`
27
+ - keyboard helper text (primary + secondary)
28
+ - footer divider
29
+ - select-list scroll info
30
+ - `warning`
31
+ - no-match/search warning text
32
+ - default/plain
33
+ - non-selected row title and summary body
34
+
35
+ #### Show page
36
+
37
+ - `accent`
38
+ - focused field labels (`Title`, `Description`)
39
+ - editing status in title area
40
+ - save success status (`✓ Saved`)
41
+ - selected-task arrow prefix
42
+ - focused field border color
43
+ - `muted`
44
+ - page title base (`Tasks`)
45
+ - unfocused field labels
46
+ - blurred field borders
47
+ - `dim`
48
+ - keyboard helper lines
49
+ - global top/bottom borders
50
+ - footer divider
51
+ - `warning`
52
+ - save failure status
53
+ - default/plain
54
+ - form field content text
55
+
56
+ ### UX rules for list/show flow
57
+
58
+ - Keep list/show visual hierarchy aligned.
59
+ - Keep keyboard helper spacing/padding symmetric across pages.
60
+ - Keep selected task identity formatting shared via view-model helpers (avoid duplicate formatting logic).
61
+ - Prefer extracting reusable rendering/formatting primitives over repeating inline style logic.
62
+
63
+ ## Task adapter quick guide
64
+
65
+ To add a backend adapter, create one file in `src/backend/adapters/` with a **default export** that satisfies `TaskAdapterInitializer`.
66
+
67
+ Required shape:
68
+
69
+ - `id: string`
70
+ - `isApplicable(): boolean` (detect if adapter should be used in current workspace)
71
+ - `initialize(pi)` returning a `TaskAdapter` with:
72
+ - `statusMap` (internal camelCase status -> backend status)
73
+ - `taskTypes` (toggle order, first is default)
74
+ - `priorities` (highest-first order, middle is default)
75
+ - optional `priorityHotkeys` (`key -> priority`, overrides default `1..N` rank hotkeys)
76
+ - `list`, `show`, `update`, `create`
77
+
78
+ Resolution behavior:
79
+
80
+ 1. `PI_TASKS_BACKEND` selects adapter by `id`
81
+ 2. otherwise the first adapter with `isApplicable() === true` is used
82
+ 3. if none apply, first loaded adapter is used as fallback
83
+
84
+ Keep backend-specific field mapping inside adapter files only (e.g. beads `issue_type`, `in_progress`, `created_at`, `due_at`), and keep app-level types in task-oriented camelCase (`taskType`, `inProgress`, `createdAt`, `dueAt`).
85
+
86
+ ## Implementation details
87
+
88
+ - stable list layout with aligned task identity/meta and fixed-height description preview
89
+ - unified task form architecture for both Edit and Create pages
90
+ - create flow that keeps editing context after initial save and updates the same task on subsequent saves
91
+ - keyboard-first interaction model with intent-based shortcuts and consistent list/show navigation behavior
92
+ - mvc-like architecture: control flow designed for concise, maintainable extension code
93
+ - inline header save feedback states with optional status icons
94
+ - shared view-model formatting primitives to keep list/show/create rendering in sync
95
+ - task serialization and work handoff prompt generation
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Soleone
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # @soleone/pi-tasks
2
+
3
+ Task management extension for the [pi coding agent](https://github.com/badlogic/pi-mono), designed for pluggable task backends.
4
+
5
+ <img width="2373" height="1305" alt="image" src="https://github.com/user-attachments/assets/af210b63-f993-447d-9668-3308874d493c" />
6
+
7
+ ## Quick start
8
+
9
+ 1. Installation: `pi install npm:@soleone/pi-tasks`
10
+ 2. Toggle the Tasks UI with `ctrl + x`, or use `/tasks`.
11
+
12
+ ## Usage
13
+
14
+ - Navigate up with `w` and `s` (arrows also work)
15
+ - `space` to change status
16
+ - `0` to `4` to change priority
17
+ - `t` to change task type
18
+ - `f` for keyword search (title, description)
19
+ - `q` or `Esc` to go back
20
+
21
+ ### List view
22
+
23
+ - `e` to edit a task
24
+ - `Enter` to work off a task
25
+ - `Tab` to insert task details in prompt and close Tasks UI
26
+ - `c` to create a new task
27
+
28
+ ### Edit view
29
+
30
+ - `Tab` to switch focus between inputs
31
+ - `Enter` to save
32
+
33
+ ## Backend selection
34
+
35
+ By default, the extension auto-detects the first applicable backend. If none are applicable, it falls back to `todo-md`.
36
+
37
+ Set `PI_TASKS_BACKEND` to explicitly choose a backend implementation.
38
+ Currently supported values:
39
+
40
+ - `beads`
41
+ - `todo-md`
42
+
43
+ ### TODO.md backend
44
+
45
+ The `todo-md` backend reads/writes a markdown task file (default: `TODO.md`; if `todo.md` already exists, it is used).
46
+
47
+ Optional env var:
48
+
49
+ - `PI_TASKS_TODO_PATH` — override the TODO file path
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@soleone/pi-tasks",
3
+ "version": "0.2.1",
4
+ "description": "Task management extension for the pi coding agent",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/soleone/pi-tasks.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/soleone/pi-tasks/issues"
13
+ },
14
+ "homepage": "https://github.com/soleone/pi-tasks#readme",
15
+ "pi": {
16
+ "extensions": [
17
+ "./src/index.ts"
18
+ ]
19
+ },
20
+ "exports": {
21
+ ".": "./src/index.ts"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "README.md",
26
+ "CONTRIBUTING.md",
27
+ "LICENSE"
28
+ ],
29
+ "keywords": [
30
+ "pi",
31
+ "tasks",
32
+ "task-management",
33
+ "tui"
34
+ ],
35
+ "scripts": {
36
+ "release": "release-it",
37
+ "release:dry": "release-it --dry-run"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "devDependencies": {
43
+ "@release-it/conventional-changelog": "^10.0.5",
44
+ "release-it": "^19.2.4"
45
+ }
46
+ }
@@ -0,0 +1,305 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
2
+ import { spawnSync } from "node:child_process"
3
+ import { existsSync } from "node:fs"
4
+ import { resolve } from "node:path"
5
+ import type { Task, TaskStatus } from "../../models/task.ts"
6
+ import type { CreateTaskInput, TaskAdapter, TaskAdapterInitializer, TaskStatusMap, TaskUpdate } from "../api.ts"
7
+
8
+ const MAX_LIST_RESULTS = 100
9
+ const STATUS_MAP = {
10
+ open: "open",
11
+ inProgress: "in_progress",
12
+ closed: "closed",
13
+ } satisfies TaskStatusMap
14
+ const TASK_TYPES = ["task", "feature", "bug", "chore", "epic"]
15
+ const PRIORITIES = ["p0", "p1", "p2", "p3", "p4"]
16
+ const PRIORITY_HOTKEYS: Record<string, string> = {
17
+ "0": "p0",
18
+ "1": "p1",
19
+ "2": "p2",
20
+ "3": "p3",
21
+ "4": "p4",
22
+ }
23
+
24
+ const OPEN_TASK_LIST_ARGS = [
25
+ "list",
26
+ "--status", STATUS_MAP.open,
27
+ "--limit", String(MAX_LIST_RESULTS),
28
+ "--sort", "priority",
29
+ "--json",
30
+ ]
31
+
32
+ const IN_PROGRESS_TASK_LIST_ARGS = [
33
+ "list",
34
+ "--status", STATUS_MAP.inProgress,
35
+ "--limit", String(MAX_LIST_RESULTS),
36
+ "--sort", "priority",
37
+ "--json",
38
+ ]
39
+
40
+ interface BeadsIssue {
41
+ id: string
42
+ title: string
43
+ description?: string
44
+ status: string
45
+ priority?: number
46
+ issue_type?: string
47
+ owner?: string
48
+ created_at?: string
49
+ due_at?: string
50
+ due?: string
51
+ updated_at?: string
52
+ dependency_count?: number
53
+ dependent_count?: number
54
+ comment_count?: number
55
+ }
56
+
57
+ function toPriorityLabel(value: number | undefined): string | undefined {
58
+ if (value === undefined) return undefined
59
+ const label = `p${value}`
60
+ return PRIORITIES.includes(label) ? label : undefined
61
+ }
62
+
63
+ function toPriorityValue(label: string | undefined): number | undefined {
64
+ if (!label) return undefined
65
+ const match = label.toLowerCase().match(/^p(\d)$/)
66
+ if (!match) return undefined
67
+ return Number(match[1])
68
+ }
69
+
70
+ function toRequiredPriorityValue(label: string): number {
71
+ const value = toPriorityValue(label)
72
+ if (value === undefined) {
73
+ throw new Error(`Unsupported priority for beads backend: ${label}`)
74
+ }
75
+ return value
76
+ }
77
+
78
+ function fromBackendStatus(status: string): TaskStatus {
79
+ for (const [internalStatus, backendStatus] of Object.entries(STATUS_MAP)) {
80
+ if (backendStatus === status) return internalStatus as TaskStatus
81
+ }
82
+ return "open"
83
+ }
84
+
85
+ function toBackendStatus(status: TaskStatus): string {
86
+ const mapped = STATUS_MAP[status]
87
+ if (!mapped) throw new Error(`Unsupported status for beads backend: ${status}`)
88
+ return mapped
89
+ }
90
+
91
+ function toTask(beadsIssue: BeadsIssue): Task {
92
+ const task: Task = {
93
+ ref: beadsIssue.id,
94
+ id: beadsIssue.id,
95
+ title: beadsIssue.title,
96
+ description: beadsIssue.description ?? "",
97
+ status: fromBackendStatus(beadsIssue.status),
98
+ owner: beadsIssue.owner,
99
+ priority: toPriorityLabel(beadsIssue.priority),
100
+ }
101
+
102
+ if (beadsIssue.issue_type !== undefined) task.taskType = beadsIssue.issue_type
103
+ if (beadsIssue.created_at !== undefined) task.createdAt = beadsIssue.created_at
104
+ if (beadsIssue.due_at !== undefined) task.dueAt = beadsIssue.due_at
105
+ if (beadsIssue.due !== undefined) task.dueAt = beadsIssue.due
106
+ if (beadsIssue.updated_at !== undefined) task.updatedAt = beadsIssue.updated_at
107
+ if (beadsIssue.dependency_count !== undefined) task.dependencyCount = beadsIssue.dependency_count
108
+ if (beadsIssue.dependent_count !== undefined) task.dependentCount = beadsIssue.dependent_count
109
+ if (beadsIssue.comment_count !== undefined) task.commentCount = beadsIssue.comment_count
110
+
111
+ return task
112
+ }
113
+
114
+ function taskStatusSortRank(status: Task["status"]): number {
115
+ if (status === "inProgress") return 0
116
+ if (status === "open") return 1
117
+ return 2
118
+ }
119
+
120
+ function taskPrioritySortRank(priority: string | undefined): number {
121
+ if (!priority) return PRIORITIES.length + 1
122
+ const index = PRIORITIES.indexOf(priority)
123
+ return index >= 0 ? index : PRIORITIES.length
124
+ }
125
+
126
+ function sortActiveTasks(tasks: Task[]): Task[] {
127
+ return [...tasks].sort((left, right) => {
128
+ const statusOrder = taskStatusSortRank(left.status) - taskStatusSortRank(right.status)
129
+ if (statusOrder !== 0) return statusOrder
130
+
131
+ const priorityOrder = taskPrioritySortRank(left.priority) - taskPrioritySortRank(right.priority)
132
+ if (priorityOrder !== 0) return priorityOrder
133
+
134
+ return left.ref.localeCompare(right.ref)
135
+ })
136
+ }
137
+
138
+ function fromTaskUpdateToBeadsArgs(update: TaskUpdate): string[] {
139
+ const args: string[] = []
140
+
141
+ if (update.title !== undefined) {
142
+ args.push("--title", update.title.trim())
143
+ }
144
+
145
+ if (update.description !== undefined) {
146
+ args.push("--description", update.description)
147
+ }
148
+
149
+ if (update.status !== undefined) {
150
+ args.push("--status", toBackendStatus(update.status))
151
+ }
152
+
153
+ if (update.priority !== undefined) {
154
+ args.push("--priority", String(toRequiredPriorityValue(update.priority)))
155
+ }
156
+
157
+ if (update.taskType !== undefined) {
158
+ args.push("--type", update.taskType || TASK_TYPES[0])
159
+ }
160
+
161
+ if (update.dueAt !== undefined) {
162
+ args.push("--due", update.dueAt)
163
+ }
164
+
165
+ return args
166
+ }
167
+
168
+ function parseJsonArray<T>(stdout: string, context: string): T[] {
169
+ try {
170
+ const parsed = JSON.parse(stdout)
171
+ if (!Array.isArray(parsed)) throw new Error("expected JSON array")
172
+ return parsed as T[]
173
+ } catch (e) {
174
+ const msg = e instanceof Error ? e.message : String(e)
175
+ throw new Error(`Failed to parse bd output (${context}): ${msg}`)
176
+ }
177
+ }
178
+
179
+ function parseJsonObject<T>(stdout: string, context: string): T {
180
+ try {
181
+ const parsed = JSON.parse(stdout)
182
+ if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") {
183
+ throw new Error("expected JSON object")
184
+ }
185
+ return parsed as T
186
+ } catch (e) {
187
+ const msg = e instanceof Error ? e.message : String(e)
188
+ throw new Error(`Failed to parse bd output (${context}): ${msg}`)
189
+ }
190
+ }
191
+
192
+ function isApplicable(): boolean {
193
+ if (!existsSync(resolve(process.cwd(), ".beads"))) return false
194
+
195
+ const result = spawnSync("bd", ["--version"], {
196
+ stdio: "ignore",
197
+ })
198
+
199
+ return !result.error
200
+ }
201
+
202
+ function initialize(pi: ExtensionAPI): TaskAdapter {
203
+ async function execBd(args: string[], timeout = 30_000): Promise<string> {
204
+ const result = await pi.exec("bd", args, { timeout })
205
+ if (result.code !== 0) {
206
+ const details = (result.stderr || result.stdout || "").trim()
207
+ throw new Error(details.length > 0 ? details : `bd ${args.join(" ")} failed (code ${result.code})`)
208
+ }
209
+ return result.stdout
210
+ }
211
+
212
+ async function update(ref: string, update: TaskUpdate): Promise<void> {
213
+ const args = fromTaskUpdateToBeadsArgs(update)
214
+ if (args.length === 0) return
215
+
216
+ await execBd(["update", ref, ...args])
217
+ }
218
+
219
+ return {
220
+ id: "beads",
221
+ statusMap: STATUS_MAP,
222
+ taskTypes: TASK_TYPES,
223
+ priorities: PRIORITIES,
224
+ priorityHotkeys: PRIORITY_HOTKEYS,
225
+
226
+ async list(): Promise<Task[]> {
227
+ const [openOut, inProgressOut] = await Promise.all([
228
+ execBd(OPEN_TASK_LIST_ARGS),
229
+ execBd(IN_PROGRESS_TASK_LIST_ARGS),
230
+ ])
231
+
232
+ const openIssues = parseJsonArray<BeadsIssue>(openOut, "list open")
233
+ const inProgressIssues = parseJsonArray<BeadsIssue>(inProgressOut, "list in_progress")
234
+
235
+ const dedupedById = new Map<string, Task>()
236
+ for (const issue of [...inProgressIssues, ...openIssues]) {
237
+ dedupedById.set(issue.id, toTask(issue))
238
+ }
239
+
240
+ return sortActiveTasks([...dedupedById.values()]).slice(0, MAX_LIST_RESULTS)
241
+ },
242
+
243
+ async show(ref: string): Promise<Task> {
244
+ const out = await execBd(["show", ref, "--json"])
245
+ const beadsIssues = parseJsonArray<BeadsIssue>(out, `show ${ref}`)
246
+ const task = beadsIssues[0]
247
+ if (!task) throw new Error(`Task not found: ${ref}`)
248
+ return toTask(task)
249
+ },
250
+
251
+ update,
252
+
253
+ async create(input: CreateTaskInput): Promise<Task> {
254
+ const title = input.title.trim()
255
+ const status = input.status ?? "open"
256
+ const selectedPriority = input.priority ?? PRIORITIES[Math.floor(PRIORITIES.length / 2)]
257
+ const createArgs = [
258
+ "create",
259
+ "--title", title,
260
+ "--priority", String(toRequiredPriorityValue(selectedPriority)),
261
+ "--type", input.taskType || TASK_TYPES[0],
262
+ "--json",
263
+ ]
264
+
265
+ if (input.description && input.description.length > 0) {
266
+ createArgs.splice(3, 0, "--description", input.description)
267
+ }
268
+
269
+ if (input.dueAt && input.dueAt.length > 0) {
270
+ createArgs.splice(3, 0, "--due", input.dueAt)
271
+ }
272
+
273
+ const out = await execBd(createArgs)
274
+ const created = toTask(parseJsonObject<BeadsIssue>(out, "create"))
275
+
276
+ if (status !== "open") {
277
+ await update(created.ref, { status })
278
+ created.status = status
279
+ }
280
+
281
+ created.title = title
282
+ created.description = input.description ?? ""
283
+
284
+ if (input.priority !== undefined) {
285
+ created.priority = input.priority
286
+ }
287
+
288
+ if (input.taskType !== undefined) {
289
+ created.taskType = input.taskType
290
+ }
291
+
292
+ if (input.dueAt !== undefined) {
293
+ created.dueAt = input.dueAt
294
+ }
295
+
296
+ return created
297
+ },
298
+ }
299
+ }
300
+
301
+ export default {
302
+ id: "beads",
303
+ isApplicable,
304
+ initialize,
305
+ } satisfies TaskAdapterInitializer