@soleone/pi-tasks 0.4.1 → 0.5.0

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/README.md CHANGED
@@ -30,25 +30,24 @@ Task management extension for the [pi coding agent](https://github.com/badlogic/
30
30
  - `Tab` to switch focus between inputs
31
31
  - `Enter` to save
32
32
 
33
- ## Backend selection
33
+ ## Task backends
34
34
 
35
- By default, the extension auto-detects the first applicable backend. If none are applicable, it falls back to `todo-md`.
35
+ By default, the extension auto-detects the first applicable backend. If none are applicable, it falls back to `todo-md`. Projects with a `.tq` directory use the `tq` backend for that session; otherwise `sq` remains the recommended default.
36
36
 
37
- Set `PI_TASKS_BACKEND` to explicitly choose a backend implementation.
38
- Currently supported values:
37
+ For most setups, `sq` is recommended as the default backend. It is lightweight, works well in brand new directories, and can create its local data on demand. Install it from the [`sq` installation guide](https://github.com/DerekStride/sq?tab=readme-ov-file#installation).
39
38
 
40
- - `beads`
41
- - `sq`
42
- - `todo-md`
39
+ ### Supported backends:
43
40
 
44
- ### Sift Queue (`sq`) backend
41
+ - [sq](https://github.com/DerekStride/sq) - Uses the `sq` cli to manage tasks in a `.sift` directory via a `issues.jsonl` file. No initialization necessary.
42
+ - `tq` - Uses the `tq` cli to manage tasks in a `.tq/tasks.jsonl` file. Automatically preferred when a `.tq` directory is detected.
43
+ - [beads](https://github.com/steveyegge/beads) - Uses the `bd` cli to manage tasks into a `.beads` directory containing multiple files.
44
+ - `todo-md` - Creates or reads a `TODO.md` file with different sections to emulate priority.
45
45
 
46
- The `sq` backend integrates with [sift-queue](https://github.com/shopify-playground/sift) and reads/writes tasks through the `sq` CLI.
46
+ ## Optional env vars:
47
47
 
48
- ### TODO.md backend
49
-
50
- The `todo-md` backend reads/writes a markdown task file (default: `TODO.md`; if `todo.md` already exists, it is used).
51
-
52
- Optional env var:
53
-
54
- - `PI_TASKS_TODO_PATH` — override the TODO file path
48
+ - `PI_TASKS_TODO_PATH` - override the TODO file path
49
+ - `PI_TASKS_BACKEND` - to explicitly choose a backend implementation. Currently supported values:
50
+ - `sq`
51
+ - `tq`
52
+ - `beads`
53
+ - `todo-md`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soleone/pi-tasks",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Task management extension for the pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,320 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
2
+ import type { Task, TaskStatus } from "../../../models/task.ts"
3
+ import type {
4
+ CreateTaskInput,
5
+ TaskAdapter,
6
+ TaskAdapterInitializer,
7
+ TaskSessionContextMessage,
8
+ TaskStatusMap,
9
+ TaskUpdate,
10
+ } from "../../api.ts"
11
+
12
+ const MAX_LIST_RESULTS = 100
13
+ const PI_TASKS_METADATA_KEY = "pi_tasks"
14
+
15
+ const STATUS_MAP = {
16
+ open: "pending",
17
+ inProgress: "in_progress",
18
+ closed: "closed",
19
+ } satisfies TaskStatusMap
20
+
21
+ const TASK_TYPES = ["task", "feature", "bug", "chore", "epic"]
22
+ const PRIORITIES = ["p0", "p1", "p2", "p3", "p4"]
23
+ const PRIORITY_HOTKEYS: Record<string, string> = {
24
+ "0": "p0",
25
+ "1": "p1",
26
+ "2": "p2",
27
+ "3": "p3",
28
+ "4": "p4",
29
+ }
30
+
31
+ interface SqCompatibleAdapterOptions {
32
+ id: string
33
+ command: string
34
+ sessionContextMessage: TaskSessionContextMessage
35
+ isApplicable(): boolean
36
+ }
37
+
38
+ interface SqCompatibleItem {
39
+ id: string
40
+ title?: string
41
+ description?: string
42
+ status: string
43
+ priority?: number | string
44
+ metadata?: Record<string, unknown>
45
+ blocked_by?: string[]
46
+ created_at?: string
47
+ updated_at?: string
48
+ }
49
+
50
+ interface TaskMetadata {
51
+ taskType?: string
52
+ dueAt?: string
53
+ }
54
+
55
+ function normalizePriority(value: unknown): string | undefined {
56
+ if (typeof value === "number") {
57
+ const label = `p${value}`
58
+ return PRIORITIES.includes(label) ? label : undefined
59
+ }
60
+
61
+ if (typeof value !== "string") return undefined
62
+
63
+ const numericPriority = Number.parseInt(value, 10)
64
+ if (String(numericPriority) === value.trim()) {
65
+ return normalizePriority(numericPriority)
66
+ }
67
+
68
+ const normalized = value.toLowerCase()
69
+ return PRIORITIES.includes(normalized) ? normalized : undefined
70
+ }
71
+
72
+ function toBackendPriority(priority: string, backendId: string): string {
73
+ const normalized = normalizePriority(priority)
74
+ if (!normalized) throw new Error(`Unsupported priority for ${backendId} backend: ${priority}`)
75
+ return normalized.slice(1)
76
+ }
77
+
78
+ function normalizeText(value: unknown): string | undefined {
79
+ if (typeof value !== "string") return undefined
80
+ const trimmed = value.trim()
81
+ return trimmed.length > 0 ? trimmed : undefined
82
+ }
83
+
84
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
85
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined
86
+ return value as Record<string, unknown>
87
+ }
88
+
89
+ function extractTaskMetadata(metadata: Record<string, unknown> | undefined): TaskMetadata {
90
+ if (!metadata) return {}
91
+
92
+ const piTasks = asRecord(metadata[PI_TASKS_METADATA_KEY])
93
+ if (!piTasks) return {}
94
+
95
+ return {
96
+ taskType: normalizeText(piTasks.taskType),
97
+ dueAt: normalizeText(piTasks.dueAt),
98
+ }
99
+ }
100
+
101
+ function buildPiTasksMetadata(input: { taskType?: string, dueAt?: string }): Record<string, unknown> | undefined {
102
+ const piTasks: Record<string, unknown> = {}
103
+
104
+ if (input.taskType !== undefined) {
105
+ piTasks.taskType = input.taskType || TASK_TYPES[0]
106
+ }
107
+
108
+ if (input.dueAt !== undefined) {
109
+ piTasks.dueAt = input.dueAt
110
+ }
111
+
112
+ return Object.keys(piTasks).length > 0
113
+ ? { [PI_TASKS_METADATA_KEY]: piTasks }
114
+ : undefined
115
+ }
116
+
117
+ function toBackendStatus(status: TaskStatus, backendId: string): string {
118
+ const mapped = STATUS_MAP[status]
119
+ if (!mapped) throw new Error(`Unsupported status for ${backendId} backend: ${status}`)
120
+ return mapped
121
+ }
122
+
123
+ function optionWithValue(option: string, value: string): string {
124
+ return `${option}=${value}`
125
+ }
126
+
127
+ function fromBackendStatus(status: string, blockedBy: string[] | undefined): TaskStatus {
128
+ if (status === STATUS_MAP.inProgress) return "inProgress"
129
+ if (status === STATUS_MAP.closed) return "closed"
130
+ if ((blockedBy?.length ?? 0) > 0) return "blocked"
131
+ return "open"
132
+ }
133
+
134
+ function toTask(item: SqCompatibleItem): Task {
135
+ const metadata = extractTaskMetadata(item.metadata)
136
+
137
+ return {
138
+ ref: item.id,
139
+ id: item.id,
140
+ title: item.title?.trim() || item.id,
141
+ description: item.description ?? "",
142
+ status: fromBackendStatus(item.status, item.blocked_by),
143
+ priority: normalizePriority(item.priority),
144
+ taskType: metadata.taskType,
145
+ createdAt: item.created_at,
146
+ updatedAt: item.updated_at,
147
+ dueAt: metadata.dueAt,
148
+ dependencyCount: item.blocked_by?.length,
149
+ }
150
+ }
151
+
152
+ function taskStatusSortRank(status: TaskStatus): number {
153
+ if (status === "inProgress") return 0
154
+ if (status === "open") return 1
155
+ if (status === "blocked") return 2
156
+ return 3
157
+ }
158
+
159
+ function taskPrioritySortRank(priority: string | undefined): number {
160
+ if (!priority) return PRIORITIES.length + 1
161
+ const index = PRIORITIES.indexOf(priority)
162
+ return index >= 0 ? index : PRIORITIES.length
163
+ }
164
+
165
+ function sortActiveTasks(tasks: Task[]): Task[] {
166
+ return [...tasks].sort((left, right) => {
167
+ const statusOrder = taskStatusSortRank(left.status) - taskStatusSortRank(right.status)
168
+ if (statusOrder !== 0) return statusOrder
169
+
170
+ const priorityOrder = taskPrioritySortRank(left.priority) - taskPrioritySortRank(right.priority)
171
+ if (priorityOrder !== 0) return priorityOrder
172
+
173
+ return left.ref.localeCompare(right.ref)
174
+ })
175
+ }
176
+
177
+ function parseJsonArray<T>(stdout: string, context: string, command: string): T[] {
178
+ try {
179
+ const parsed = JSON.parse(stdout)
180
+ if (!Array.isArray(parsed)) throw new Error("expected JSON array")
181
+ return parsed as T[]
182
+ } catch (error) {
183
+ const message = error instanceof Error ? error.message : String(error)
184
+ throw new Error(`Failed to parse ${command} output (${context}): ${message}`)
185
+ }
186
+ }
187
+
188
+ function parseJsonObject<T>(stdout: string, context: string, command: string): T {
189
+ try {
190
+ const parsed = JSON.parse(stdout)
191
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
192
+ throw new Error("expected JSON object")
193
+ }
194
+ return parsed as T
195
+ } catch (error) {
196
+ const message = error instanceof Error ? error.message : String(error)
197
+ throw new Error(`Failed to parse ${command} output (${context}): ${message}`)
198
+ }
199
+ }
200
+
201
+ function initialize(pi: ExtensionAPI, options: SqCompatibleAdapterOptions): TaskAdapter {
202
+ async function execCommand(args: string[], timeout = 30_000): Promise<string> {
203
+ const result = await pi.exec(options.command, args, { timeout })
204
+ if (result.code !== 0) {
205
+ const details = (result.stderr || result.stdout || "").trim()
206
+ throw new Error(details.length > 0 ? details : `${options.command} ${args.join(" ")} failed (code ${result.code})`)
207
+ }
208
+
209
+ return result.stdout
210
+ }
211
+
212
+ async function showRaw(ref: string): Promise<SqCompatibleItem> {
213
+ const out = await execCommand(["show", ref, "--json"])
214
+ return parseJsonObject<SqCompatibleItem>(out, `show ${ref}`, options.command)
215
+ }
216
+
217
+ return {
218
+ id: options.id,
219
+ statusMap: STATUS_MAP,
220
+ taskTypes: TASK_TYPES,
221
+ priorities: PRIORITIES,
222
+ priorityHotkeys: PRIORITY_HOTKEYS,
223
+ sessionContextMessage: options.sessionContextMessage,
224
+
225
+ async list(): Promise<Task[]> {
226
+ const [pendingOut, inProgressOut] = await Promise.all([
227
+ execCommand(["list", "--status", STATUS_MAP.open, "--json"]),
228
+ execCommand(["list", "--status", STATUS_MAP.inProgress, "--json"]),
229
+ ])
230
+
231
+ const pendingItems = parseJsonArray<SqCompatibleItem>(pendingOut, "list pending", options.command)
232
+ const inProgressItems = parseJsonArray<SqCompatibleItem>(inProgressOut, "list in_progress", options.command)
233
+
234
+ const dedupedById = new Map<string, Task>()
235
+ for (const item of [...inProgressItems, ...pendingItems]) {
236
+ dedupedById.set(item.id, toTask(item))
237
+ }
238
+
239
+ return sortActiveTasks([...dedupedById.values()]).slice(0, MAX_LIST_RESULTS)
240
+ },
241
+
242
+ async show(ref: string): Promise<Task> {
243
+ return toTask(await showRaw(ref))
244
+ },
245
+
246
+ async update(ref: string, update: TaskUpdate): Promise<void> {
247
+ const args = ["edit", ref]
248
+
249
+ if (update.title !== undefined) {
250
+ args.push(optionWithValue("--set-title", update.title.trim()))
251
+ }
252
+
253
+ if (update.description !== undefined) {
254
+ args.push(optionWithValue("--set-description", update.description))
255
+ }
256
+
257
+ if (update.status !== undefined) {
258
+ args.push(optionWithValue("--set-status", toBackendStatus(update.status, options.id)))
259
+ }
260
+
261
+ if (update.priority !== undefined) {
262
+ args.push(optionWithValue("--set-priority", toBackendPriority(update.priority, options.id)))
263
+ }
264
+
265
+ const metadataPatch = buildPiTasksMetadata({
266
+ taskType: update.taskType,
267
+ dueAt: update.dueAt,
268
+ })
269
+
270
+ if (metadataPatch) {
271
+ args.push(optionWithValue("--merge-metadata", JSON.stringify(metadataPatch)))
272
+ }
273
+
274
+ if (args.length === 2) return
275
+ await execCommand(args)
276
+ },
277
+
278
+ async create(input: CreateTaskInput): Promise<Task> {
279
+ const title = input.title.trim()
280
+ const description = input.description ?? ""
281
+ const selectedPriority = input.priority ?? PRIORITIES[Math.floor(PRIORITIES.length / 2)]
282
+ const metadata = buildPiTasksMetadata({
283
+ taskType: input.taskType || TASK_TYPES[0],
284
+ dueAt: input.dueAt,
285
+ })
286
+ const sourceText = description.trim().length > 0 ? description : title
287
+
288
+ const args = [
289
+ "add",
290
+ optionWithValue("--title", title),
291
+ optionWithValue("--description", description),
292
+ optionWithValue("--priority", toBackendPriority(selectedPriority, options.id)),
293
+ optionWithValue("--text", sourceText),
294
+ "--json",
295
+ ]
296
+
297
+ if (metadata) {
298
+ args.push(optionWithValue("--metadata", JSON.stringify(metadata)))
299
+ }
300
+
301
+ const out = await execCommand(args)
302
+ const created = parseJsonObject<SqCompatibleItem>(out, "create", options.command)
303
+
304
+ if (input.status && input.status !== "open") {
305
+ await execCommand(["edit", created.id, optionWithValue("--set-status", toBackendStatus(input.status, options.id))])
306
+ return toTask(await showRaw(created.id))
307
+ }
308
+
309
+ return toTask(created)
310
+ },
311
+ }
312
+ }
313
+
314
+ export function createSqCompatibleAdapterInitializer(options: SqCompatibleAdapterOptions): TaskAdapterInitializer {
315
+ return {
316
+ id: options.id,
317
+ isApplicable: options.isApplicable,
318
+ initialize: (pi) => initialize(pi, options),
319
+ }
320
+ }
@@ -1,33 +1,6 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
2
1
  import { spawnSync } from "node:child_process"
3
- import type { Task, TaskStatus } from "../../models/task.ts"
4
- import type {
5
- CreateTaskInput,
6
- TaskAdapter,
7
- TaskAdapterInitializer,
8
- TaskSessionContextMessage,
9
- TaskStatusMap,
10
- TaskUpdate,
11
- } from "../api.ts"
12
-
13
- const MAX_LIST_RESULTS = 100
14
- const PI_TASKS_METADATA_KEY = "pi_tasks"
15
-
16
- const STATUS_MAP = {
17
- open: "pending",
18
- inProgress: "in_progress",
19
- closed: "closed",
20
- } satisfies TaskStatusMap
21
-
22
- const TASK_TYPES = ["task", "feature", "bug", "chore", "epic"]
23
- const PRIORITIES = ["p0", "p1", "p2", "p3", "p4"]
24
- const PRIORITY_HOTKEYS: Record<string, string> = {
25
- "0": "p0",
26
- "1": "p1",
27
- "2": "p2",
28
- "3": "p3",
29
- "4": "p4",
30
- }
2
+ import type { TaskSessionContextMessage } from "../api.ts"
3
+ import { createSqCompatibleAdapterInitializer } from "./shared/sq-compatible.ts"
31
4
 
32
5
  const SESSION_CONTEXT_MESSAGE: TaskSessionContextMessage = {
33
6
  customType: "pi-tasks-backend-context-sq-v1",
@@ -41,285 +14,14 @@ const SESSION_CONTEXT_MESSAGE: TaskSessionContextMessage = {
41
14
  ].join(" "),
42
15
  }
43
16
 
44
- interface SqItem {
45
- id: string
46
- title?: string
47
- description?: string
48
- status: string
49
- priority?: number | string
50
- metadata?: Record<string, unknown>
51
- blocked_by?: string[]
52
- created_at?: string
53
- updated_at?: string
54
- }
55
-
56
- interface TaskMetadata {
57
- taskType?: string
58
- dueAt?: string
59
- }
60
-
61
- function normalizePriority(value: unknown): string | undefined {
62
- if (typeof value === "number") {
63
- const label = `p${value}`
64
- return PRIORITIES.includes(label) ? label : undefined
65
- }
66
-
67
- if (typeof value !== "string") return undefined
68
-
69
- const numericPriority = Number.parseInt(value, 10)
70
- if (String(numericPriority) === value.trim()) {
71
- return normalizePriority(numericPriority)
72
- }
73
-
74
- const normalized = value.toLowerCase()
75
- return PRIORITIES.includes(normalized) ? normalized : undefined
76
- }
77
-
78
- function toBackendPriority(priority: string): string {
79
- const normalized = normalizePriority(priority)
80
- if (!normalized) throw new Error(`Unsupported priority for sq backend: ${priority}`)
81
- return normalized.slice(1)
82
- }
83
-
84
- function normalizeText(value: unknown): string | undefined {
85
- if (typeof value !== "string") return undefined
86
- const trimmed = value.trim()
87
- return trimmed.length > 0 ? trimmed : undefined
88
- }
89
-
90
- function asRecord(value: unknown): Record<string, unknown> | undefined {
91
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined
92
- return value as Record<string, unknown>
93
- }
94
-
95
- function extractTaskMetadata(metadata: Record<string, unknown> | undefined): TaskMetadata {
96
- if (!metadata) return {}
97
-
98
- const piTasks = asRecord(metadata[PI_TASKS_METADATA_KEY])
99
- if (!piTasks) return {}
100
-
101
- return {
102
- taskType: normalizeText(piTasks.taskType),
103
- dueAt: normalizeText(piTasks.dueAt),
104
- }
105
- }
106
-
107
- function buildPiTasksMetadata(input: { taskType?: string, dueAt?: string }): Record<string, unknown> | undefined {
108
- const piTasks: Record<string, unknown> = {}
109
-
110
- if (input.taskType !== undefined) {
111
- piTasks.taskType = input.taskType || TASK_TYPES[0]
112
- }
113
-
114
- if (input.dueAt !== undefined) {
115
- piTasks.dueAt = input.dueAt
116
- }
117
-
118
- return Object.keys(piTasks).length > 0
119
- ? { [PI_TASKS_METADATA_KEY]: piTasks }
120
- : undefined
121
- }
122
-
123
- function toBackendStatus(status: TaskStatus): string {
124
- const mapped = STATUS_MAP[status]
125
- if (!mapped) throw new Error(`Unsupported status for sq backend: ${status}`)
126
- return mapped
127
- }
128
-
129
- function fromBackendStatus(status: string, blockedBy: string[] | undefined): TaskStatus {
130
- if (status === STATUS_MAP.inProgress) return "inProgress"
131
- if (status === STATUS_MAP.closed) return "closed"
132
- if ((blockedBy?.length ?? 0) > 0) return "blocked"
133
- return "open"
134
- }
135
-
136
- function toTask(item: SqItem): Task {
137
- const metadata = extractTaskMetadata(item.metadata)
138
-
139
- return {
140
- ref: item.id,
141
- id: item.id,
142
- title: item.title?.trim() || item.id,
143
- description: item.description ?? "",
144
- status: fromBackendStatus(item.status, item.blocked_by),
145
- priority: normalizePriority(item.priority),
146
- taskType: metadata.taskType,
147
- createdAt: item.created_at,
148
- updatedAt: item.updated_at,
149
- dueAt: metadata.dueAt,
150
- dependencyCount: item.blocked_by?.length,
151
- }
152
- }
153
-
154
- function taskStatusSortRank(status: TaskStatus): number {
155
- if (status === "inProgress") return 0
156
- if (status === "open") return 1
157
- if (status === "blocked") return 2
158
- return 3
159
- }
160
-
161
- function taskPrioritySortRank(priority: string | undefined): number {
162
- if (!priority) return PRIORITIES.length + 1
163
- const index = PRIORITIES.indexOf(priority)
164
- return index >= 0 ? index : PRIORITIES.length
165
- }
166
-
167
- function sortActiveTasks(tasks: Task[]): Task[] {
168
- return [...tasks].sort((left, right) => {
169
- const statusOrder = taskStatusSortRank(left.status) - taskStatusSortRank(right.status)
170
- if (statusOrder !== 0) return statusOrder
171
-
172
- const priorityOrder = taskPrioritySortRank(left.priority) - taskPrioritySortRank(right.priority)
173
- if (priorityOrder !== 0) return priorityOrder
174
-
175
- return left.ref.localeCompare(right.ref)
176
- })
177
- }
178
-
179
- function parseJsonArray<T>(stdout: string, context: string): T[] {
180
- try {
181
- const parsed = JSON.parse(stdout)
182
- if (!Array.isArray(parsed)) throw new Error("expected JSON array")
183
- return parsed as T[]
184
- } catch (error) {
185
- const message = error instanceof Error ? error.message : String(error)
186
- throw new Error(`Failed to parse sq output (${context}): ${message}`)
187
- }
188
- }
189
-
190
- function parseJsonObject<T>(stdout: string, context: string): T {
191
- try {
192
- const parsed = JSON.parse(stdout)
193
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
194
- throw new Error("expected JSON object")
195
- }
196
- return parsed as T
197
- } catch (error) {
198
- const message = error instanceof Error ? error.message : String(error)
199
- throw new Error(`Failed to parse sq output (${context}): ${message}`)
200
- }
201
- }
202
-
203
17
  function isApplicable(): boolean {
204
18
  const result = spawnSync("sq", ["--help"], { stdio: "ignore" })
205
19
  return !result.error
206
20
  }
207
21
 
208
- function initialize(pi: ExtensionAPI): TaskAdapter {
209
- async function execSq(args: string[], timeout = 30_000): Promise<string> {
210
- const result = await pi.exec("sq", args, { timeout })
211
- if (result.code !== 0) {
212
- const details = (result.stderr || result.stdout || "").trim()
213
- throw new Error(details.length > 0 ? details : `sq ${args.join(" ")} failed (code ${result.code})`)
214
- }
215
-
216
- return result.stdout
217
- }
218
-
219
- async function showRaw(ref: string): Promise<SqItem> {
220
- const out = await execSq(["show", ref, "--json"])
221
- return parseJsonObject<SqItem>(out, `show ${ref}`)
222
- }
223
-
224
- return {
225
- id: "sq",
226
- statusMap: STATUS_MAP,
227
- taskTypes: TASK_TYPES,
228
- priorities: PRIORITIES,
229
- priorityHotkeys: PRIORITY_HOTKEYS,
230
- sessionContextMessage: SESSION_CONTEXT_MESSAGE,
231
-
232
- async list(): Promise<Task[]> {
233
- const [pendingOut, inProgressOut] = await Promise.all([
234
- execSq(["list", "--status", STATUS_MAP.open, "--json"]),
235
- execSq(["list", "--status", STATUS_MAP.inProgress, "--json"]),
236
- ])
237
-
238
- const pendingItems = parseJsonArray<SqItem>(pendingOut, "list pending")
239
- const inProgressItems = parseJsonArray<SqItem>(inProgressOut, "list in_progress")
240
-
241
- const dedupedById = new Map<string, Task>()
242
- for (const item of [...inProgressItems, ...pendingItems]) {
243
- dedupedById.set(item.id, toTask(item))
244
- }
245
-
246
- return sortActiveTasks([...dedupedById.values()]).slice(0, MAX_LIST_RESULTS)
247
- },
248
-
249
- async show(ref: string): Promise<Task> {
250
- return toTask(await showRaw(ref))
251
- },
252
-
253
- async update(ref: string, update: TaskUpdate): Promise<void> {
254
- const args = ["edit", ref]
255
-
256
- if (update.title !== undefined) {
257
- args.push("--set-title", update.title.trim())
258
- }
259
-
260
- if (update.description !== undefined) {
261
- args.push("--set-description", update.description)
262
- }
263
-
264
- if (update.status !== undefined) {
265
- args.push("--set-status", toBackendStatus(update.status))
266
- }
267
-
268
- if (update.priority !== undefined) {
269
- args.push("--set-priority", toBackendPriority(update.priority))
270
- }
271
-
272
- const metadataPatch = buildPiTasksMetadata({
273
- taskType: update.taskType,
274
- dueAt: update.dueAt,
275
- })
276
-
277
- if (metadataPatch) {
278
- args.push("--merge-metadata", JSON.stringify(metadataPatch))
279
- }
280
-
281
- if (args.length === 2) return
282
- await execSq(args)
283
- },
284
-
285
- async create(input: CreateTaskInput): Promise<Task> {
286
- const title = input.title.trim()
287
- const description = input.description ?? ""
288
- const selectedPriority = input.priority ?? PRIORITIES[Math.floor(PRIORITIES.length / 2)]
289
- const metadata = buildPiTasksMetadata({
290
- taskType: input.taskType || TASK_TYPES[0],
291
- dueAt: input.dueAt,
292
- })
293
- const sourceText = description.trim().length > 0 ? description : title
294
-
295
- const args = [
296
- "add",
297
- "--title", title,
298
- "--description", description,
299
- "--priority", toBackendPriority(selectedPriority),
300
- "--text", sourceText,
301
- "--json",
302
- ]
303
-
304
- if (metadata) {
305
- args.push("--metadata", JSON.stringify(metadata))
306
- }
307
-
308
- const out = await execSq(args)
309
- const created = parseJsonObject<SqItem>(out, "create")
310
-
311
- if (input.status && input.status !== "open") {
312
- await execSq(["edit", created.id, "--set-status", toBackendStatus(input.status)])
313
- return toTask(await showRaw(created.id))
314
- }
315
-
316
- return toTask(created)
317
- },
318
- }
319
- }
320
-
321
- export default {
22
+ export default createSqCompatibleAdapterInitializer({
322
23
  id: "sq",
24
+ command: "sq",
25
+ sessionContextMessage: SESSION_CONTEXT_MESSAGE,
323
26
  isApplicable,
324
- initialize,
325
- } satisfies TaskAdapterInitializer
27
+ })
@@ -0,0 +1,46 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync } from "node:fs"
3
+ import { dirname, resolve } from "node:path"
4
+ import type { TaskSessionContextMessage } from "../api.ts"
5
+ import { createSqCompatibleAdapterInitializer } from "./shared/sq-compatible.ts"
6
+
7
+ const SESSION_CONTEXT_MESSAGE: TaskSessionContextMessage = {
8
+ customType: "pi-tasks-backend-context-tq-v1",
9
+ content: [
10
+ "The pi-tasks extension is using the `tq` backend for this project because a `.tq` directory was detected.",
11
+ "If you need direct `tq` CLI guidance, run `tq prime`.",
12
+ "When manipulating pi-tasks metadata through `tq`, store it under",
13
+ "`pi_tasks`, for example",
14
+ "`--metadata '{\"pi_tasks\":{\"taskType\":\"TYPE\",\"dueAt\":\"TIMESTAMP\"}}'`,",
15
+ "or merge the same shape with `--merge-metadata`.",
16
+ ].join(" "),
17
+ }
18
+
19
+ function hasTqDirectory(startDirectory = process.cwd()): boolean {
20
+ let currentDirectory = resolve(startDirectory)
21
+
22
+ while (true) {
23
+ if (existsSync(resolve(currentDirectory, ".tq"))) return true
24
+
25
+ const parentDirectory = dirname(currentDirectory)
26
+ if (parentDirectory === currentDirectory) return false
27
+ currentDirectory = parentDirectory
28
+ }
29
+ }
30
+
31
+ function isApplicable(): boolean {
32
+ if (!hasTqDirectory()) return false
33
+
34
+ const result = spawnSync("tq", ["--help"], { stdio: "ignore" })
35
+ if (result.error) {
36
+ throw new Error("Detected a .tq directory, but the `tq` CLI is not available on PATH")
37
+ }
38
+ return true
39
+ }
40
+
41
+ export default createSqCompatibleAdapterInitializer({
42
+ id: "tq",
43
+ command: "tq",
44
+ sessionContextMessage: SESSION_CONTEXT_MESSAGE,
45
+ isApplicable,
46
+ })
@@ -62,20 +62,27 @@ async function loadAdapterInitializers(): Promise<TaskAdapterInitializer[]> {
62
62
 
63
63
  const ADAPTER_INITIALIZERS = await loadAdapterInitializers()
64
64
 
65
+ function findAdapter(id: string): TaskAdapterInitializer | undefined {
66
+ return ADAPTER_INITIALIZERS.find(adapter => adapter.id === id)
67
+ }
68
+
65
69
  function lookup(): TaskAdapterInitializer {
66
70
  const configuredAdapterId = process.env.PI_TASKS_BACKEND?.trim()
67
71
  if (configuredAdapterId) {
68
- const configured = ADAPTER_INITIALIZERS.find(adapter => adapter.id === configuredAdapterId)
72
+ const configured = findAdapter(configuredAdapterId)
69
73
  if (!configured) {
70
74
  throw new Error(`Unsupported tasks backend: ${configuredAdapterId}`)
71
75
  }
72
76
  return configured
73
77
  }
74
78
 
79
+ const tqAdapter = findAdapter("tq")
80
+ if (tqAdapter?.isApplicable()) return tqAdapter
81
+
75
82
  const detected = ADAPTER_INITIALIZERS.find(adapter => adapter.isApplicable())
76
83
  if (detected) return detected
77
84
 
78
- const fallback = ADAPTER_INITIALIZERS.find(adapter => adapter.id === "todo-md")
85
+ const fallback = findAdapter("todo-md")
79
86
  if (fallback) return fallback
80
87
 
81
88
  return ADAPTER_INITIALIZERS[0]
@@ -366,7 +366,10 @@ export async function showTaskList(ctx: ExtensionCommandContext, config: ListPag
366
366
 
367
367
  return {
368
368
  render: (w: number) => {
369
- lastWidth = w
369
+ if (lastWidth !== w) {
370
+ lastWidth = w
371
+ updateDescPreview()
372
+ }
370
373
  return container.render(w).map((l: string) => truncateToWidth(l, w))
371
374
  },
372
375
  invalidate: () => container.invalidate(),