@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 +9 -4
- package/index.ts +3 -0
- package/package.json +4 -3
- package/src/backend/adapters/beads.ts +19 -1
- package/src/backend/adapters/sq.ts +325 -0
- package/src/backend/api.ts +6 -0
- package/src/controllers/list.ts +16 -6
- package/src/controllers/show.ts +3 -3
- package/src/extension.ts +54 -11
- package/src/ui/components/blur-editor.ts +29 -1
- package/src/ui/components/select-list-with-columns.ts +6 -2
- package/src/ui/pages/list.ts +14 -5
- package/src/ui/pages/show.ts +114 -39
- package/src/index.ts +0 -7
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
|
|
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
|
-
- `
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soleone/pi-tasks",
|
|
3
|
-
"version": "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
|
-
"./
|
|
17
|
+
"./index.ts"
|
|
18
18
|
]
|
|
19
19
|
},
|
|
20
20
|
"exports": {
|
|
21
|
-
".": "./
|
|
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 {
|
|
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
|
package/src/backend/api.ts
CHANGED
|
@@ -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>
|
package/src/controllers/list.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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: "
|
|
127
|
-
match: (data) => data === "
|
|
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
|
|
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
|
|
210
|
+
parts.push(state.filtered ? "a/esc clear filter" : "a/esc back")
|
|
201
211
|
}
|
|
202
212
|
|
|
203
213
|
return parts.join(" • ")
|
package/src/controllers/show.ts
CHANGED
|
@@ -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
|
|
59
|
-
if (focus === "desc") return "enter newline • tab save • esc
|
|
60
|
-
return "tab title • enter save • esc
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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
|
|
90
|
-
const
|
|
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) {
|
package/src/ui/pages/list.ts
CHANGED
|
@@ -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:
|
|
14
|
-
valueColumnWidth:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = ""
|
package/src/ui/pages/show.ts
CHANGED
|
@@ -26,7 +26,7 @@ interface ShowTaskFormOptions {
|
|
|
26
26
|
mode: FormMode
|
|
27
27
|
subtitle: string
|
|
28
28
|
task: Task
|
|
29
|
-
|
|
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(
|
|
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,
|
|
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:
|
|
194
|
-
|
|
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:
|
|
211
|
-
|
|
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 (
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
249
|
-
saveIndicator
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
256
|
-
if (!disposed) renderLayout()
|
|
302
|
+
if (savePromise === activeSave) savePromise = null
|
|
257
303
|
}
|
|
304
|
+
}
|
|
258
305
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
348
|
+
const primaryHelp = buildPrimaryHelpText(focus)
|
|
290
349
|
const secondaryHelp = buildSecondaryHelpText(focus, priorities, priorityHotkeys)
|
|
291
|
-
|
|
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 (
|
|
405
|
+
if (isExplicitNewLineCommand(data)) {
|
|
340
406
|
descEditor.insertTextAtCursor("\n")
|
|
341
407
|
requestRender()
|
|
342
408
|
return
|
|
343
409
|
}
|
|
344
410
|
|
|
345
|
-
if (
|
|
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 === "
|
|
369
|
-
|
|
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
|
|
401
|
-
|
|
475
|
+
if (closeKeys.some(closeKey => matchesKey(data, closeKey))) {
|
|
476
|
+
exitForm("close_list")
|
|
402
477
|
return
|
|
403
478
|
}
|
|
404
479
|
|