@soleone/pi-tasks 0.3.0 → 0.4.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/README.md CHANGED
@@ -7,20 +7,20 @@ Task management extension for the [pi coding agent](https://github.com/badlogic/
7
7
  ## Quick start
8
8
 
9
9
  1. Installation: `pi install npm:@soleone/pi-tasks`
10
- 2. Toggle the Tasks UI with `ctrl + x`, or use `/tasks`.
10
+ 2. Toggle the Tasks UI with `ctrl + shift + r` or `alt + x`, or use `/tasks`.
11
11
 
12
12
  ## Usage
13
13
 
14
- - Navigate up with `w` and `s` (arrows also work)
14
+ - Navigate with `w` / `s` (up / down arrows also work)
15
+ - `a` to go back (`Esc` and left arrow also work)
15
16
  - `space` to change status
16
17
  - `0` to `4` to change priority
17
18
  - `t` to change task type
18
19
  - `f` for keyword search (title, description)
19
- - `q` or `Esc` to go back
20
20
 
21
21
  ### List view
22
22
 
23
- - `e` to edit a task
23
+ - `d` to open task details
24
24
  - `Enter` to work off a task
25
25
  - `Tab` to insert task details in prompt and close Tasks UI
26
26
  - `c` to create a new task
@@ -38,8 +38,13 @@ Set `PI_TASKS_BACKEND` to explicitly choose a backend implementation.
38
38
  Currently supported values:
39
39
 
40
40
  - `beads`
41
+ - `sq`
41
42
  - `todo-md`
42
43
 
44
+ ### Sift Queue (`sq`) backend
45
+
46
+ The `sq` backend integrates with [sift-queue](https://github.com/shopify-playground/sift) and reads/writes tasks through the `sq` CLI.
47
+
43
48
  ### TODO.md backend
44
49
 
45
50
  The `todo-md` backend reads/writes a markdown task file (default: `TODO.md`; if `todo.md` already exists, it is used).
package/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ import registerExtension from "./src/extension.ts"
2
+
3
+ export default registerExtension
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soleone/pi-tasks",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Task management extension for the pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -14,13 +14,14 @@
14
14
  "homepage": "https://github.com/soleone/pi-tasks#readme",
15
15
  "pi": {
16
16
  "extensions": [
17
- "./src/index.ts"
17
+ "./index.ts"
18
18
  ]
19
19
  },
20
20
  "exports": {
21
- ".": "./src/index.ts"
21
+ ".": "./index.ts"
22
22
  },
23
23
  "files": [
24
+ "index.ts",
24
25
  "src",
25
26
  "README.md",
26
27
  "CONTRIBUTING.md",
@@ -3,7 +3,14 @@ import { spawnSync } from "node:child_process"
3
3
  import { existsSync } from "node:fs"
4
4
  import { resolve } from "node:path"
5
5
  import type { Task, TaskStatus } from "../../models/task.ts"
6
- import type { CreateTaskInput, TaskAdapter, TaskAdapterInitializer, TaskStatusMap, TaskUpdate } from "../api.ts"
6
+ import type {
7
+ CreateTaskInput,
8
+ TaskAdapter,
9
+ TaskAdapterInitializer,
10
+ TaskSessionContextMessage,
11
+ TaskStatusMap,
12
+ TaskUpdate,
13
+ } from "../api.ts"
7
14
 
8
15
  const MAX_LIST_RESULTS = 100
9
16
  const STATUS_MAP = {
@@ -21,6 +28,16 @@ const PRIORITY_HOTKEYS: Record<string, string> = {
21
28
  "4": "p4",
22
29
  }
23
30
 
31
+ const SESSION_CONTEXT_MESSAGE: TaskSessionContextMessage = {
32
+ customType: "pi-tasks-backend-context-beads-v1",
33
+ content: [
34
+ "The pi-tasks extension is using the `beads` backend for this project.",
35
+ "If you need a quick overview of the beads workflow, run `bd quickstart` or `bd onboard`.",
36
+ "For most beads commands, agents should prefer the `--json` flag so output is structured and machine-readable.",
37
+ "For more advanced command help, run `bd -h`.",
38
+ ].join(" "),
39
+ }
40
+
24
41
  const OPEN_TASK_LIST_ARGS = [
25
42
  "list",
26
43
  "--status", STATUS_MAP.open,
@@ -222,6 +239,7 @@ function initialize(pi: ExtensionAPI): TaskAdapter {
222
239
  taskTypes: TASK_TYPES,
223
240
  priorities: PRIORITIES,
224
241
  priorityHotkeys: PRIORITY_HOTKEYS,
242
+ sessionContextMessage: SESSION_CONTEXT_MESSAGE,
225
243
 
226
244
  async list(): Promise<Task[]> {
227
245
  const [openOut, inProgressOut] = await Promise.all([
@@ -0,0 +1,325 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
2
+ 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
+ }
31
+
32
+ const SESSION_CONTEXT_MESSAGE: TaskSessionContextMessage = {
33
+ customType: "pi-tasks-backend-context-sq-v1",
34
+ content: [
35
+ "The pi-tasks extension is using the `sq` backend for this project.",
36
+ "If you need direct `sq` CLI guidance, run `sq prime`.",
37
+ "When manipulating pi-tasks metadata through `sq`, store it under",
38
+ "`pi_tasks`, for example",
39
+ "`--metadata '{\"pi_tasks\":{\"taskType\":\"TYPE\",\"dueAt\":\"TIMESTAMP\"}}'`,",
40
+ "or merge the same shape with `--merge-metadata`.",
41
+ ].join(" "),
42
+ }
43
+
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
+ function isApplicable(): boolean {
204
+ const result = spawnSync("sq", ["--help"], { stdio: "ignore" })
205
+ return !result.error
206
+ }
207
+
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 {
322
+ id: "sq",
323
+ isApplicable,
324
+ initialize,
325
+ } satisfies TaskAdapterInitializer
@@ -20,12 +20,18 @@ export interface CreateTaskInput extends TaskUpdate {
20
20
  title: string
21
21
  }
22
22
 
23
+ export interface TaskSessionContextMessage {
24
+ customType: string
25
+ content: string
26
+ }
27
+
23
28
  export interface TaskAdapter {
24
29
  readonly id: string
25
30
  readonly statusMap: TaskStatusMap
26
31
  readonly taskTypes: string[]
27
32
  readonly priorities: string[]
28
33
  readonly priorityHotkeys?: Record<string, string>
34
+ readonly sessionContextMessage?: TaskSessionContextMessage
29
35
  invalidateCache?(): void
30
36
  list(): Promise<Task[]>
31
37
  show(ref: string): Promise<Task>
@@ -2,6 +2,7 @@ import { Key, matchesKey } from "@mariozechner/pi-tui"
2
2
 
3
3
  export type ListIntent =
4
4
  | { type: "cancel" }
5
+ | { type: "back" }
5
6
  | { type: "searchStart" }
6
7
  | { type: "searchCancel" }
7
8
  | { type: "searchApply" }
@@ -23,7 +24,7 @@ export interface ListControllerState {
23
24
  filtered: boolean
24
25
  allowSearch: boolean
25
26
  allowPriority: boolean
26
- closeKey: string
27
+ closeKeys: string[]
27
28
  priorities: string[]
28
29
  priorityHotkeys?: Record<string, string>
29
30
  }
@@ -68,6 +69,10 @@ function isPrintable(data: string): boolean {
68
69
  return data.length === 1 && data.charCodeAt(0) >= 32 && data.charCodeAt(0) < 127
69
70
  }
70
71
 
72
+ function matchesAnyShortcut(data: string, shortcuts: string[]): boolean {
73
+ return shortcuts.some(shortcut => matchesKey(data, shortcut))
74
+ }
75
+
71
76
  const MOVE_KEYS: Record<string, number> = {
72
77
  w: -1,
73
78
  W: -1,
@@ -83,7 +88,7 @@ const SCROLL_KEYS: Record<string, number> = {
83
88
  const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
84
89
  {
85
90
  context: "search",
86
- match: (data, state) => data === state.closeKey,
91
+ match: (data, state) => matchesAnyShortcut(data, state.closeKeys),
87
92
  intent: () => ({ type: "cancel" }),
88
93
  },
89
94
  {
@@ -123,8 +128,8 @@ const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
123
128
  },
124
129
  {
125
130
  context: "default",
126
- help: "e edit",
127
- match: (data) => data === "e" || data === "E" || matchesKey(data, Key.right),
131
+ help: "d details",
132
+ match: (data) => data === "d" || data === "D" || matchesKey(data, Key.right),
128
133
  intent: () => ({ type: "edit" }),
129
134
  },
130
135
  {
@@ -174,7 +179,12 @@ const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
174
179
  },
175
180
  {
176
181
  context: "default",
177
- match: (data, state) => data === state.closeKey,
182
+ match: (data) => data === "a" || data === "A" || matchesKey(data, Key.left),
183
+ intent: () => ({ type: "back" }),
184
+ },
185
+ {
186
+ context: "default",
187
+ match: (data, state) => matchesAnyShortcut(data, state.closeKeys),
178
188
  intent: () => ({ type: "cancel" }),
179
189
  },
180
190
  ]
@@ -197,7 +207,7 @@ export function buildListPrimaryHelpText(state: ListControllerState): string {
197
207
  .map(s => (typeof s.help === "function" ? s.help(state) : s.help as string))
198
208
 
199
209
  if (context === "default") {
200
- parts.push(state.filtered ? "esc clear filter" : "esc cancel")
210
+ parts.push(state.filtered ? "a/esc clear filter" : "a/esc back")
201
211
  }
202
212
 
203
213
  return parts.join(" • ")
@@ -55,9 +55,9 @@ export function getHeaderStatus(
55
55
  }
56
56
 
57
57
  export function buildPrimaryHelpText(focus: FormFocus): string {
58
- if (focus === "title") return "enter save • tab description • esc back"
59
- if (focus === "desc") return "enter newline • tab save • esc back"
60
- return "tab title • enter save • esc/q back • ctrl+x close"
58
+ if (focus === "title") return "shift+tab nav • enter save • tab description • esc nav"
59
+ if (focus === "desc") return "shift+enter newline • shift+tab title • enter save • esc nav"
60
+ return "tab title • enter save • a/esc back"
61
61
  }
62
62
 
63
63
  function buildPriorityHelpText(priorities: string[], priorityHotkeys?: Record<string, string>): string {
package/src/extension.ts CHANGED
@@ -6,7 +6,7 @@ import { showTaskList } from "./ui/pages/list.ts"
6
6
  import { showTaskForm } from "./ui/pages/show.ts"
7
7
  import type { TaskUpdate } from "./backend/api.ts"
8
8
 
9
- const CTRL_X = "\x18"
9
+ const TASK_LIST_SHORTCUTS = ["ctrl+shift+r", "alt+x"]
10
10
 
11
11
  function parsePriorityKey(
12
12
  data: string,
@@ -171,10 +171,49 @@ function applyDraftToTask(
171
171
  return nextTask
172
172
  }
173
173
 
174
+ function branchHasCustomMessage(entries: unknown[], customType: string): boolean {
175
+ return entries.some((entry) => {
176
+ if (!entry || typeof entry !== "object") return false
177
+
178
+ const candidate = entry as {
179
+ type?: unknown
180
+ customType?: unknown
181
+ }
182
+
183
+ return candidate.type === "custom_message" && candidate.customType === customType
184
+ })
185
+ }
186
+
174
187
  export default function registerExtension(pi: ExtensionAPI) {
175
188
  const backend = initializeAdapter(pi)
176
189
  validateBackendConfiguration(backend)
177
190
 
191
+ const backendContextMessage = backend.sessionContextMessage
192
+
193
+ function ensureBackendContextOnBranch(entries: unknown[]): void {
194
+ if (!backendContextMessage) return
195
+ if (branchHasCustomMessage(entries, backendContextMessage.customType)) return
196
+
197
+ pi.sendMessage({
198
+ customType: backendContextMessage.customType,
199
+ content: backendContextMessage.content,
200
+ display: false,
201
+ details: { backendId: backend.id },
202
+ })
203
+ }
204
+
205
+ pi.on("session_start", async (_event, ctx) => {
206
+ ensureBackendContextOnBranch(ctx.sessionManager.getBranch())
207
+ })
208
+
209
+ pi.on("session_switch", async (_event, ctx) => {
210
+ ensureBackendContextOnBranch(ctx.sessionManager.getBranch())
211
+ })
212
+
213
+ pi.on("session_fork", async (_event, ctx) => {
214
+ ensureBackendContextOnBranch(ctx.sessionManager.getBranch())
215
+ })
216
+
178
217
  const nextStatus = (status: TaskStatus): TaskStatus => cycleStatus(status, backend.statusMap)
179
218
  const nextTaskType = (current: string | undefined): string => cycleTaskType(current, backend.taskTypes)
180
219
  const nextPriorityFromKey = (data: string): string | null => parsePriorityKey(
@@ -212,7 +251,7 @@ export default function registerExtension(pi: ExtensionAPI) {
212
251
  mode: "edit",
213
252
  subtitle: "Edit",
214
253
  task,
215
- closeKey: CTRL_X,
254
+ closeKeys: TASK_LIST_SHORTCUTS,
216
255
  cycleStatus: nextStatus,
217
256
  cycleTaskType: nextTaskType,
218
257
  parsePriorityKey: nextPriorityFromKey,
@@ -261,7 +300,7 @@ export default function registerExtension(pi: ExtensionAPI) {
261
300
  priority: defaultPriority(backend.priorities),
262
301
  taskType: defaultTaskType(backend.taskTypes),
263
302
  },
264
- closeKey: CTRL_X,
303
+ closeKeys: TASK_LIST_SHORTCUTS,
265
304
  cycleStatus: nextStatus,
266
305
  cycleTaskType: nextTaskType,
267
306
  parsePriorityKey: nextPriorityFromKey,
@@ -323,7 +362,7 @@ export default function registerExtension(pi: ExtensionAPI) {
323
362
  title: pageTitle,
324
363
  subtitle: backendLabel,
325
364
  tasks,
326
- closeKey: CTRL_X,
365
+ closeKeys: TASK_LIST_SHORTCUTS,
327
366
  priorities: backend.priorities,
328
367
  priorityHotkeys: backend.priorityHotkeys,
329
368
  cycleStatus: nextStatus,
@@ -348,11 +387,15 @@ export default function registerExtension(pi: ExtensionAPI) {
348
387
  },
349
388
  })
350
389
 
351
- pi.registerShortcut("ctrl+x", {
352
- description: "Open task list",
353
- handler: async (ctx) => {
354
- if (!ctx.hasUI) return
355
- await browseTasks(ctx as ExtensionCommandContext)
356
- },
357
- })
390
+ const openTaskListShortcut = async (ctx: ExtensionCommandContext): Promise<void> => {
391
+ if (!ctx.hasUI) return
392
+ await browseTasks(ctx)
393
+ }
394
+
395
+ for (const shortcut of TASK_LIST_SHORTCUTS) {
396
+ pi.registerShortcut(shortcut, {
397
+ description: "Open task list",
398
+ handler: openTaskListShortcut,
399
+ })
400
+ }
358
401
  }
@@ -5,8 +5,12 @@ interface BlurEditorFieldOptions {
5
5
  blurredBorderColor?: (str: string) => string
6
6
  paddingX?: number
7
7
  indentX?: number
8
+ cursorGlyph?: string
9
+ focusedCursorColor?: (str: string) => string
8
10
  }
9
11
 
12
+ const INVERSE_CURSOR_SEGMENT = /\x1b\[7m([\s\S]*?)\x1b\[(?:0|27)m/
13
+
10
14
  export class BlurEditorField implements Component, Focusable {
11
15
  focused = false
12
16
  onChange?: (text: string) => void
@@ -16,6 +20,8 @@ export class BlurEditorField implements Component, Focusable {
16
20
  private stripTopBorder: boolean
17
21
  private blurredBorderColor: (str: string) => string
18
22
  private indentX: number
23
+ private cursorGlyph: string
24
+ private focusedCursorColor: (str: string) => string
19
25
 
20
26
  constructor(tui: any, theme: EditorTheme, options: BlurEditorFieldOptions = {}) {
21
27
  const paddingX = options.paddingX ?? 1
@@ -26,6 +32,8 @@ export class BlurEditorField implements Component, Focusable {
26
32
  this.stripTopBorder = options.stripTopBorder ?? true
27
33
  this.blurredBorderColor = options.blurredBorderColor ?? theme.borderColor
28
34
  this.indentX = Math.max(0, options.indentX ?? 0)
35
+ this.cursorGlyph = options.cursorGlyph ?? "▏"
36
+ this.focusedCursorColor = options.focusedCursorColor ?? ((str: string) => str)
29
37
 
30
38
  this.editor.onChange = (text: string) => {
31
39
  this.onChange?.(text)
@@ -53,6 +61,25 @@ export class BlurEditorField implements Component, Focusable {
53
61
  this.previewText.invalidate()
54
62
  }
55
63
 
64
+ private replaceBlockCursor(line: string): string {
65
+ const cursor = this.focusedCursorColor(this.cursorGlyph)
66
+ return line.replace(INVERSE_CURSOR_SEGMENT, cursor)
67
+ }
68
+
69
+ private stylizeCursor(lines: string[]): string[] {
70
+ let replaced = false
71
+
72
+ return lines.map((line) => {
73
+ if (replaced) return line
74
+
75
+ const updated = this.replaceBlockCursor(line)
76
+ if (updated !== line) {
77
+ replaced = true
78
+ }
79
+ return updated
80
+ })
81
+ }
82
+
56
83
  render(width: number): string[] {
57
84
  const innerWidth = Math.max(1, width - this.indentX)
58
85
  const indent = " ".repeat(this.indentX)
@@ -68,7 +95,8 @@ export class BlurEditorField implements Component, Focusable {
68
95
 
69
96
  const lines = this.editor.render(innerWidth)
70
97
  const visibleLines = !this.stripTopBorder || lines.length <= 1 ? lines : lines.slice(1)
71
- return withIndent(visibleLines)
98
+ const styledLines = this.stylizeCursor(visibleLines)
99
+ return withIndent(styledLines)
72
100
  }
73
101
 
74
102
  handleInput(data: string): void {
@@ -2,7 +2,10 @@ import { Key, matchesKey, truncateToWidth, type Component, type SelectItem, type
2
2
 
3
3
  // Local variant of pi-tui SelectList with configurable value/description column layout.
4
4
 
5
+ const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g
6
+
5
7
  const normalizeToSingleLine = (text: string): string => text.replace(/[\r\n]+/g, " ").trim()
8
+ const visibleWidth = (text: string): number => text.replace(ANSI_ESCAPE_PATTERN, "").length
6
9
 
7
10
  export interface SelectListColumnLayout {
8
11
  valueMaxWidth?: number
@@ -86,8 +89,9 @@ export class SelectListWithColumns implements Component {
86
89
 
87
90
  const maxValueWidth = Math.min(this.layout.valueMaxWidth, width - prefix.length - 4)
88
91
  const truncatedValue = truncateToWidth(displayValue, maxValueWidth, "")
89
- const spacing = " ".repeat(Math.max(1, this.layout.valueColumnWidth - truncatedValue.length))
90
- const descriptionStart = prefix.length + truncatedValue.length + spacing.length
92
+ const truncatedValueWidth = visibleWidth(truncatedValue)
93
+ const spacing = " ".repeat(Math.max(1, this.layout.valueColumnWidth - truncatedValueWidth))
94
+ const descriptionStart = prefix.length + truncatedValueWidth + spacing.length
91
95
  const descriptionWidth = width - descriptionStart - 2
92
96
 
93
97
  if (descriptionWidth <= this.layout.minDescriptionWidth) {
@@ -10,8 +10,8 @@ import { SelectListWithColumns } from "../components/select-list-with-columns.ts
10
10
 
11
11
  const LIST_PAGE_CONTENT_MIN_HEIGHT = 20
12
12
  const TASK_LIST_ROW_LAYOUT = {
13
- valueMaxWidth: 60,
14
- valueColumnWidth: 62,
13
+ valueMaxWidth: 68,
14
+ valueColumnWidth: 70,
15
15
  }
16
16
 
17
17
  export interface ListPageConfig {
@@ -23,7 +23,7 @@ export interface ListPageConfig {
23
23
  filterTerm?: string
24
24
  priorities: string[]
25
25
  priorityHotkeys?: Record<string, string>
26
- closeKey: string
26
+ closeKeys: string[]
27
27
  cycleStatus: (status: TaskStatus) => TaskStatus
28
28
  cycleTaskType: (current: string | undefined) => string
29
29
  onUpdateTask: (ref: string, update: TaskUpdate) => Promise<void>
@@ -290,7 +290,7 @@ export async function showTaskList(ctx: ExtensionCommandContext, config: ListPag
290
290
  filtered: !!filterTerm,
291
291
  allowPriority,
292
292
  allowSearch,
293
- closeKey: config.closeKey,
293
+ closeKeys: config.closeKeys,
294
294
  priorities: config.priorities,
295
295
  priorityHotkeys: config.priorityHotkeys,
296
296
  })))
@@ -376,7 +376,7 @@ export async function showTaskList(ctx: ExtensionCommandContext, config: ListPag
376
376
  filtered: !!filterTerm,
377
377
  allowSearch,
378
378
  allowPriority,
379
- closeKey: config.closeKey,
379
+ closeKeys: config.closeKeys,
380
380
  priorities: config.priorities,
381
381
  priorityHotkeys: config.priorityHotkeys,
382
382
  })
@@ -386,6 +386,15 @@ export async function showTaskList(ctx: ExtensionCommandContext, config: ListPag
386
386
  done("cancel")
387
387
  return
388
388
 
389
+ case "back":
390
+ if (filterTerm) {
391
+ filterTerm = ""
392
+ rebuildAndRender()
393
+ } else {
394
+ done("cancel")
395
+ }
396
+ return
397
+
389
398
  case "searchStart":
390
399
  searching = true
391
400
  searchBuffer = ""
@@ -26,7 +26,7 @@ interface ShowTaskFormOptions {
26
26
  mode: FormMode
27
27
  subtitle: string
28
28
  task: Task
29
- closeKey: string
29
+ closeKeys: string[]
30
30
  cycleStatus: (status: TaskStatus) => TaskStatus
31
31
  cycleTaskType: (taskType: string | undefined) => string
32
32
  parsePriorityKey: (data: string) => string | null
@@ -61,12 +61,27 @@ function buildSelectedTaskLine(
61
61
 
62
62
  function fieldLabel(theme: any, label: string, focused: boolean): string {
63
63
  const color = focused ? "accent" : "muted"
64
- return theme.fg(color, theme.bold(` ${label}`))
64
+ return theme.fg(color, theme.bold(label))
65
65
  }
66
66
 
67
67
  const SELECTED_ITEM_PREFIX = "› "
68
68
  const DESCRIPTION_FIELD_HEIGHT = 8
69
69
  const PAGE_CONTENT_MIN_HEIGHT = 19
70
+ const SHIFT_TAB_SEQUENCE = /^\x1b\[[0-9;]*Z$/
71
+ const SHIFT_ENTER_FALLBACK_SEQUENCE = /^\x1b\[13;2~$/
72
+
73
+ function isShiftTab(data: string): boolean {
74
+ return SHIFT_TAB_SEQUENCE.test(data)
75
+ }
76
+
77
+ function isExplicitNewLineCommand(data: string): boolean {
78
+ return (
79
+ matchesKey(data, Key.shift("enter")) ||
80
+ data === "\n" ||
81
+ data === "\x1b\r" ||
82
+ SHIFT_ENTER_FALLBACK_SEQUENCE.test(data)
83
+ )
84
+ }
70
85
 
71
86
  class FixedHeightField implements Component {
72
87
  constructor(private child: Component, private height: number) {}
@@ -144,7 +159,7 @@ class ReservedLineText implements Component {
144
159
  }
145
160
 
146
161
  export async function showTaskForm(ctx: ExtensionCommandContext, options: ShowTaskFormOptions): Promise<TaskFormResult> {
147
- const { mode, subtitle, task, closeKey, cycleStatus, cycleTaskType, parsePriorityKey, priorities, priorityHotkeys, onSave } = options
162
+ const { mode, subtitle, task, closeKeys, cycleStatus, cycleTaskType, parsePriorityKey, priorities, priorityHotkeys, onSave } = options
148
163
 
149
164
  let taskTypeValue = task.taskType
150
165
  let titleValue = task.title
@@ -168,12 +183,13 @@ export async function showTaskForm(ctx: ExtensionCommandContext, options: ShowTa
168
183
  const titleLabel = new Text("", 0, 0)
169
184
  const descLabel = new Text("", 0, 0)
170
185
  const helpText = new ReservedLineText(KEYBOARD_HELP_PADDING_X)
171
- const shortcutsText = new ReservedLineText(KEYBOARD_HELP_PADDING_X)
172
186
 
173
187
  let focus: FormFocus = mode === "create" ? "title" : "nav"
174
188
  let saveIndicator: "saving" | "saved" | "error" | undefined
175
189
  let saveIndicatorTimer: ReturnType<typeof setTimeout> | undefined
176
190
  let saving = false
191
+ let saveQueued = false
192
+ let savePromise: Promise<void> | null = null
177
193
  let disposed = false
178
194
 
179
195
  const editorTheme = {
@@ -190,8 +206,8 @@ export async function showTaskForm(ctx: ExtensionCommandContext, options: ShowTa
190
206
  const titleEditor = new BlurEditorField(tui, editorTheme, {
191
207
  stripTopBorder: true,
192
208
  blurredBorderColor: (s: string) => theme.fg("muted", s),
193
- paddingX: 2,
194
- indentX: 2,
209
+ paddingX: 0,
210
+ focusedCursorColor: (s: string) => theme.fg("accent", s),
195
211
  })
196
212
  titleEditor.setText(titleValue)
197
213
  titleEditor.disableSubmit = true
@@ -207,8 +223,8 @@ export async function showTaskForm(ctx: ExtensionCommandContext, options: ShowTa
207
223
  const descEditor = new BlurEditorField(tui, editorTheme, {
208
224
  stripTopBorder: true,
209
225
  blurredBorderColor: (s: string) => theme.fg("muted", s),
210
- paddingX: 2,
211
- indentX: 2,
226
+ paddingX: 0,
227
+ focusedCursorColor: (s: string) => theme.fg("accent", s),
212
228
  })
213
229
  const descEditorField = new FixedHeightField(descEditor, DESCRIPTION_FIELD_HEIGHT)
214
230
  descEditor.setText(descValue)
@@ -228,41 +244,84 @@ export async function showTaskForm(ctx: ExtensionCommandContext, options: ShowTa
228
244
  let lastSavedDraft: FormDraft = currentDraft()
229
245
 
230
246
  const triggerSave = async () => {
231
- if (saving || disposed) return
247
+ if (disposed) return
232
248
 
233
249
  const draft = currentDraft()
250
+ if (saving) {
251
+ saveQueued = true
252
+ await savePromise
253
+ return
254
+ }
255
+
234
256
  if (isSameDraft(draft, lastSavedDraft)) return
235
257
 
236
258
  saving = true
259
+ saveQueued = false
237
260
  if (saveIndicatorTimer) clearTimeout(saveIndicatorTimer)
238
261
  saveIndicator = "saving"
239
262
  renderLayout()
240
263
 
241
- try {
242
- const didSave = await onSave(draft)
243
- if (disposed) return
244
- if (!didSave) {
245
- saveIndicator = undefined
246
- return
264
+ const activeSave = (async () => {
265
+ try {
266
+ const didSave = await onSave(draft)
267
+ if (disposed) return
268
+ if (!didSave) {
269
+ saveIndicator = undefined
270
+ return
271
+ }
272
+ lastSavedDraft = normalizeDraft(draft)
273
+ saveIndicator = "saved"
274
+ } catch (e) {
275
+ if (disposed) return
276
+ saveIndicator = "error"
277
+ ctx.ui.notify(e instanceof Error ? e.message : String(e), "error")
278
+ } finally {
279
+ saving = false
280
+ if (!disposed) renderLayout()
247
281
  }
248
- lastSavedDraft = normalizeDraft(draft)
249
- saveIndicator = "saved"
250
- } catch (e) {
251
- if (disposed) return
252
- saveIndicator = "error"
253
- ctx.ui.notify(e instanceof Error ? e.message : String(e), "error")
282
+
283
+ if (saveIndicator === "saved" && !disposed) {
284
+ saveIndicatorTimer = setTimeout(() => {
285
+ if (disposed) return
286
+ saveIndicator = undefined
287
+ renderLayout()
288
+ }, 5000)
289
+ }
290
+
291
+ if (saveQueued && !disposed) {
292
+ saveQueued = false
293
+ await triggerSave()
294
+ }
295
+ })()
296
+
297
+ savePromise = activeSave
298
+
299
+ try {
300
+ await activeSave
254
301
  } finally {
255
- saving = false
256
- if (!disposed) renderLayout()
302
+ if (savePromise === activeSave) savePromise = null
257
303
  }
304
+ }
258
305
 
259
- if (saveIndicator === "saved" && !disposed) {
260
- saveIndicatorTimer = setTimeout(() => {
261
- if (disposed) return
262
- saveIndicator = undefined
263
- renderLayout()
264
- }, 5000)
265
- }
306
+ const canPersistCurrentDraft = (): boolean => {
307
+ if (mode === "edit") return true
308
+ return titleValue.trim().length > 0
309
+ }
310
+
311
+ const triggerAutoSave = () => {
312
+ if (!canPersistCurrentDraft()) return
313
+ void triggerSave()
314
+ }
315
+
316
+ const exitForm = (action: TaskFormAction) => {
317
+ void (async () => {
318
+ if ((saving || !isSameDraft(currentDraft(), lastSavedDraft)) && canPersistCurrentDraft()) {
319
+ await triggerSave()
320
+ if (saving || !isSameDraft(currentDraft(), lastSavedDraft)) return
321
+ }
322
+
323
+ done({ action })
324
+ })()
266
325
  }
267
326
 
268
327
  const renderLayout = () => {
@@ -286,9 +345,11 @@ export async function showTaskForm(ctx: ExtensionCommandContext, options: ShowTa
286
345
  titleLabel.setText(fieldLabel(theme, "Title", focus === "title"))
287
346
  descLabel.setText(fieldLabel(theme, "Description", focus === "desc"))
288
347
 
289
- helpText.setText(formatKeyboardHelp(theme, buildPrimaryHelpText(focus)))
348
+ const primaryHelp = buildPrimaryHelpText(focus)
290
349
  const secondaryHelp = buildSecondaryHelpText(focus, priorities, priorityHotkeys)
291
- shortcutsText.setText(secondaryHelp ? formatKeyboardHelp(theme, secondaryHelp) : "")
350
+ const combinedHelp = secondaryHelp ? `${primaryHelp} • ${secondaryHelp}` : primaryHelp
351
+
352
+ helpText.setText(formatKeyboardHelp(theme, combinedHelp))
292
353
 
293
354
  container.invalidate()
294
355
  tui.requestRender()
@@ -307,7 +368,6 @@ export async function showTaskForm(ctx: ExtensionCommandContext, options: ShowTa
307
368
 
308
369
  footerContainer.addChild(new DynamicBorder((s: string) => theme.fg("dim", s)))
309
370
  footerContainer.addChild(helpText)
310
- footerContainer.addChild(shortcutsText)
311
371
  footerContainer.addChild(new DynamicBorder((s: string) => theme.fg("dim", s)))
312
372
 
313
373
  renderLayout()
@@ -325,6 +385,12 @@ export async function showTaskForm(ctx: ExtensionCommandContext, options: ShowTa
325
385
  return
326
386
  }
327
387
 
388
+ if (isShiftTab(data)) {
389
+ focus = "nav"
390
+ renderLayout()
391
+ return
392
+ }
393
+
328
394
  if (matchesKey(data, Key.tab)) {
329
395
  focus = "desc"
330
396
  renderLayout()
@@ -336,13 +402,19 @@ export async function showTaskForm(ctx: ExtensionCommandContext, options: ShowTa
336
402
  }
337
403
 
338
404
  const handleDescInput = (data: string) => {
339
- if (matchesKey(data, Key.enter)) {
405
+ if (isExplicitNewLineCommand(data)) {
340
406
  descEditor.insertTextAtCursor("\n")
341
407
  requestRender()
342
408
  return
343
409
  }
344
410
 
345
- if (matchesKey(data, Key.tab)) {
411
+ if (isShiftTab(data)) {
412
+ focus = "title"
413
+ renderLayout()
414
+ return
415
+ }
416
+
417
+ if (matchesKey(data, Key.enter) || matchesKey(data, Key.tab)) {
346
418
  focus = "nav"
347
419
  void triggerSave()
348
420
  renderLayout()
@@ -365,20 +437,22 @@ export async function showTaskForm(ctx: ExtensionCommandContext, options: ShowTa
365
437
  return
366
438
  }
367
439
 
368
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.left) || data === "q" || data === "Q") {
369
- done({ action: "back" })
440
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.left) || data === "a" || data === "A") {
441
+ exitForm("back")
370
442
  return
371
443
  }
372
444
 
373
445
  if (data === "t" || data === "T") {
374
446
  taskTypeValue = cycleTaskType(taskTypeValue)
375
447
  renderLayout()
448
+ triggerAutoSave()
376
449
  return
377
450
  }
378
451
 
379
452
  if (data === " ") {
380
453
  statusValue = cycleStatus(statusValue)
381
454
  renderLayout()
455
+ triggerAutoSave()
382
456
  return
383
457
  }
384
458
 
@@ -386,6 +460,7 @@ export async function showTaskForm(ctx: ExtensionCommandContext, options: ShowTa
386
460
  if (priority !== null) {
387
461
  priorityValue = priority
388
462
  renderLayout()
463
+ triggerAutoSave()
389
464
  }
390
465
  }
391
466
 
@@ -397,8 +472,8 @@ export async function showTaskForm(ctx: ExtensionCommandContext, options: ShowTa
397
472
  if (saveIndicatorTimer) clearTimeout(saveIndicatorTimer)
398
473
  },
399
474
  handleInput: (data: string) => {
400
- if (data === closeKey) {
401
- done({ action: "close_list" })
475
+ if (closeKeys.some(closeKey => matchesKey(data, closeKey))) {
476
+ exitForm("close_list")
402
477
  return
403
478
  }
404
479
 
package/src/index.ts DELETED
@@ -1,7 +0,0 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
2
-
3
- import registerExtension from "./extension.ts"
4
-
5
- export default function (pi: ExtensionAPI) {
6
- registerExtension(pi)
7
- }