@soleone/pi-tasks 0.2.1 → 0.3.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/package.json
CHANGED
package/src/controllers/list.ts
CHANGED
|
@@ -23,7 +23,7 @@ export interface ListControllerState {
|
|
|
23
23
|
filtered: boolean
|
|
24
24
|
allowSearch: boolean
|
|
25
25
|
allowPriority: boolean
|
|
26
|
-
|
|
26
|
+
closeKey: string
|
|
27
27
|
priorities: string[]
|
|
28
28
|
priorityHotkeys?: Record<string, string>
|
|
29
29
|
}
|
|
@@ -83,7 +83,7 @@ const SCROLL_KEYS: Record<string, number> = {
|
|
|
83
83
|
const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
|
|
84
84
|
{
|
|
85
85
|
context: "search",
|
|
86
|
-
match: (data, state) => data === state.
|
|
86
|
+
match: (data, state) => data === state.closeKey,
|
|
87
87
|
intent: () => ({ type: "cancel" }),
|
|
88
88
|
},
|
|
89
89
|
{
|
|
@@ -174,7 +174,7 @@ const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
|
|
|
174
174
|
},
|
|
175
175
|
{
|
|
176
176
|
context: "default",
|
|
177
|
-
match: (data, state) => data === state.
|
|
177
|
+
match: (data, state) => data === state.closeKey,
|
|
178
178
|
intent: () => ({ type: "cancel" }),
|
|
179
179
|
},
|
|
180
180
|
]
|
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 CTRL_X = "\x18"
|
|
10
10
|
|
|
11
11
|
function parsePriorityKey(
|
|
12
12
|
data: string,
|
|
@@ -212,7 +212,7 @@ export default function registerExtension(pi: ExtensionAPI) {
|
|
|
212
212
|
mode: "edit",
|
|
213
213
|
subtitle: "Edit",
|
|
214
214
|
task,
|
|
215
|
-
|
|
215
|
+
closeKey: CTRL_X,
|
|
216
216
|
cycleStatus: nextStatus,
|
|
217
217
|
cycleTaskType: nextTaskType,
|
|
218
218
|
parsePriorityKey: nextPriorityFromKey,
|
|
@@ -261,7 +261,7 @@ export default function registerExtension(pi: ExtensionAPI) {
|
|
|
261
261
|
priority: defaultPriority(backend.priorities),
|
|
262
262
|
taskType: defaultTaskType(backend.taskTypes),
|
|
263
263
|
},
|
|
264
|
-
|
|
264
|
+
closeKey: CTRL_X,
|
|
265
265
|
cycleStatus: nextStatus,
|
|
266
266
|
cycleTaskType: nextTaskType,
|
|
267
267
|
parsePriorityKey: nextPriorityFromKey,
|
|
@@ -323,7 +323,7 @@ export default function registerExtension(pi: ExtensionAPI) {
|
|
|
323
323
|
title: pageTitle,
|
|
324
324
|
subtitle: backendLabel,
|
|
325
325
|
tasks,
|
|
326
|
-
|
|
326
|
+
closeKey: CTRL_X,
|
|
327
327
|
priorities: backend.priorities,
|
|
328
328
|
priorityHotkeys: backend.priorityHotkeys,
|
|
329
329
|
cycleStatus: nextStatus,
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Key, matchesKey, truncateToWidth, type Component, type SelectItem, type SelectListTheme } from "@mariozechner/pi-tui"
|
|
2
|
+
|
|
3
|
+
// Local variant of pi-tui SelectList with configurable value/description column layout.
|
|
4
|
+
|
|
5
|
+
const normalizeToSingleLine = (text: string): string => text.replace(/[\r\n]+/g, " ").trim()
|
|
6
|
+
|
|
7
|
+
export interface SelectListColumnLayout {
|
|
8
|
+
valueMaxWidth?: number
|
|
9
|
+
valueColumnWidth?: number
|
|
10
|
+
minDescriptionWidth?: number
|
|
11
|
+
minWidthForDescription?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ResolvedSelectListColumnLayout {
|
|
15
|
+
valueMaxWidth: number
|
|
16
|
+
valueColumnWidth: number
|
|
17
|
+
minDescriptionWidth: number
|
|
18
|
+
minWidthForDescription: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_COLUMN_LAYOUT: ResolvedSelectListColumnLayout = {
|
|
22
|
+
valueMaxWidth: 30,
|
|
23
|
+
valueColumnWidth: 32,
|
|
24
|
+
minDescriptionWidth: 10,
|
|
25
|
+
minWidthForDescription: 40,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class SelectListWithColumns implements Component {
|
|
29
|
+
private items: SelectItem[] = []
|
|
30
|
+
private filteredItems: SelectItem[] = []
|
|
31
|
+
private selectedIndex = 0
|
|
32
|
+
private maxVisible = 5
|
|
33
|
+
private theme: SelectListTheme
|
|
34
|
+
private layout: ResolvedSelectListColumnLayout
|
|
35
|
+
|
|
36
|
+
public onSelect?: (item: SelectItem) => void
|
|
37
|
+
public onCancel?: () => void
|
|
38
|
+
public onSelectionChange?: (item: SelectItem) => void
|
|
39
|
+
|
|
40
|
+
constructor(items: SelectItem[], maxVisible: number, theme: SelectListTheme, layout: SelectListColumnLayout = {}) {
|
|
41
|
+
this.items = items
|
|
42
|
+
this.filteredItems = items
|
|
43
|
+
this.maxVisible = maxVisible
|
|
44
|
+
this.theme = theme
|
|
45
|
+
this.layout = {
|
|
46
|
+
...DEFAULT_COLUMN_LAYOUT,
|
|
47
|
+
...layout,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
setSelectedIndex(index: number): void {
|
|
52
|
+
this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
invalidate(): void {
|
|
56
|
+
// No cached state.
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
render(width: number): string[] {
|
|
60
|
+
const lines: string[] = []
|
|
61
|
+
|
|
62
|
+
if (this.filteredItems.length === 0) {
|
|
63
|
+
lines.push(this.theme.noMatch(" No matching commands"))
|
|
64
|
+
return lines
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const startIndex = Math.max(
|
|
68
|
+
0,
|
|
69
|
+
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
|
|
70
|
+
)
|
|
71
|
+
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length)
|
|
72
|
+
|
|
73
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
74
|
+
const item = this.filteredItems[i]
|
|
75
|
+
if (!item) continue
|
|
76
|
+
|
|
77
|
+
const isSelected = i === this.selectedIndex
|
|
78
|
+
const descriptionSingleLine = item.description ? normalizeToSingleLine(item.description) : undefined
|
|
79
|
+
const displayValue = item.label || item.value
|
|
80
|
+
const prefix = isSelected ? "→ " : " "
|
|
81
|
+
|
|
82
|
+
if (!descriptionSingleLine || width <= this.layout.minWidthForDescription) {
|
|
83
|
+
lines.push(this.renderValueOnlyLine(prefix, displayValue, width, isSelected))
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const maxValueWidth = Math.min(this.layout.valueMaxWidth, width - prefix.length - 4)
|
|
88
|
+
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
|
|
91
|
+
const descriptionWidth = width - descriptionStart - 2
|
|
92
|
+
|
|
93
|
+
if (descriptionWidth <= this.layout.minDescriptionWidth) {
|
|
94
|
+
lines.push(this.renderValueOnlyLine(prefix, displayValue, width, isSelected))
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const truncatedDesc = truncateToWidth(descriptionSingleLine, descriptionWidth, "")
|
|
99
|
+
if (isSelected) {
|
|
100
|
+
lines.push(this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`))
|
|
101
|
+
} else {
|
|
102
|
+
lines.push(`${prefix}${truncatedValue}${this.theme.description(spacing + truncatedDesc)}`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (startIndex > 0 || endIndex < this.filteredItems.length) {
|
|
107
|
+
const scrollText = ` (${this.selectedIndex + 1}/${this.filteredItems.length})`
|
|
108
|
+
lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, "")))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return lines
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
handleInput(keyData: string): void {
|
|
115
|
+
if (matchesKey(keyData, Key.up)) {
|
|
116
|
+
this.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1
|
|
117
|
+
this.notifySelectionChange()
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (matchesKey(keyData, Key.down)) {
|
|
122
|
+
this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1
|
|
123
|
+
this.notifySelectionChange()
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (matchesKey(keyData, Key.enter)) {
|
|
128
|
+
const selectedItem = this.filteredItems[this.selectedIndex]
|
|
129
|
+
if (selectedItem && this.onSelect) this.onSelect(selectedItem)
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (matchesKey(keyData, Key.escape) || keyData === "\u0003") {
|
|
134
|
+
if (this.onCancel) this.onCancel()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
getSelectedItem(): SelectItem | null {
|
|
139
|
+
const item = this.filteredItems[this.selectedIndex]
|
|
140
|
+
return item || null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private renderValueOnlyLine(prefix: string, displayValue: string, width: number, isSelected: boolean): string {
|
|
144
|
+
const maxWidth = width - prefix.length - 2
|
|
145
|
+
const line = `${prefix}${truncateToWidth(displayValue, maxWidth, "")}`
|
|
146
|
+
return isSelected ? this.theme.selectedText(line) : line
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private notifySelectionChange(): void {
|
|
150
|
+
const selectedItem = this.filteredItems[this.selectedIndex]
|
|
151
|
+
if (selectedItem && this.onSelectionChange) this.onSelectionChange(selectedItem)
|
|
152
|
+
}
|
|
153
|
+
}
|
package/src/ui/pages/list.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { DynamicBorder, type ExtensionCommandContext } from "@mariozechner/pi-coding-agent"
|
|
2
|
-
import { Container,
|
|
2
|
+
import { Container, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui"
|
|
3
3
|
import { toKebabCase, type Task, type TaskStatus } from "../../models/task.ts"
|
|
4
4
|
import type { TaskUpdate } from "../../backend/api.ts"
|
|
5
5
|
import { DESCRIPTION_PART_SEPARATOR, buildListRowModel, decodeDescription, stripAnsi } from "../../models/list-item.ts"
|
|
6
6
|
import { buildListPrimaryHelpText, buildListSecondaryHelpText, resolveListIntent } from "../../controllers/list.ts"
|
|
7
7
|
import { KEYBOARD_HELP_PADDING_X, formatKeyboardHelp } from "../components/keyboard-help.ts"
|
|
8
8
|
import { MinHeightContainer } from "../components/min-height.ts"
|
|
9
|
+
import { SelectListWithColumns } from "../components/select-list-with-columns.ts"
|
|
9
10
|
|
|
10
11
|
const LIST_PAGE_CONTENT_MIN_HEIGHT = 20
|
|
12
|
+
const TASK_LIST_ROW_LAYOUT = {
|
|
13
|
+
valueMaxWidth: 60,
|
|
14
|
+
valueColumnWidth: 62,
|
|
15
|
+
}
|
|
11
16
|
|
|
12
17
|
export interface ListPageConfig {
|
|
13
18
|
title: string
|
|
@@ -18,7 +23,7 @@ export interface ListPageConfig {
|
|
|
18
23
|
filterTerm?: string
|
|
19
24
|
priorities: string[]
|
|
20
25
|
priorityHotkeys?: Record<string, string>
|
|
21
|
-
|
|
26
|
+
closeKey: string
|
|
22
27
|
cycleStatus: (status: TaskStatus) => TaskStatus
|
|
23
28
|
cycleTaskType: (current: string | undefined) => string
|
|
24
29
|
onUpdateTask: (ref: string, update: TaskUpdate) => Promise<void>
|
|
@@ -134,14 +139,16 @@ export async function showTaskList(ctx: ExtensionCommandContext, config: ListPag
|
|
|
134
139
|
})
|
|
135
140
|
}
|
|
136
141
|
|
|
137
|
-
|
|
138
|
-
let selectList = new SelectList(items, Math.min(items.length, 10), {
|
|
142
|
+
const selectListTheme = {
|
|
139
143
|
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
140
144
|
selectedText: (t: string) => applyAccentWithAnsi(t),
|
|
141
145
|
description: (t: string) => styleDescription(t),
|
|
142
146
|
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
143
147
|
noMatch: (t: string) => theme.fg("warning", t),
|
|
144
|
-
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let items = getItems()
|
|
151
|
+
let selectList = new SelectListWithColumns(items, Math.min(items.length, 10), selectListTheme, TASK_LIST_ROW_LAYOUT)
|
|
145
152
|
|
|
146
153
|
if (rememberedSelectedRef) {
|
|
147
154
|
const rememberedIndex = items.findIndex(i => i.value === rememberedSelectedRef)
|
|
@@ -165,13 +172,7 @@ export async function showTaskList(ctx: ExtensionCommandContext, config: ListPag
|
|
|
165
172
|
selectList.onCancel = () => {
|
|
166
173
|
if (filterTerm) {
|
|
167
174
|
filterTerm = ""
|
|
168
|
-
|
|
169
|
-
selectList = new SelectList(items, Math.min(items.length, 10), selectList.theme)
|
|
170
|
-
selectList.onSelectionChange = selectList.onSelectionChange
|
|
171
|
-
selectList.onSelect = selectList.onSelect
|
|
172
|
-
selectList.onCancel = selectList.onCancel
|
|
173
|
-
container.invalidate()
|
|
174
|
-
tui.requestRender()
|
|
175
|
+
rebuildAndRender()
|
|
175
176
|
} else {
|
|
176
177
|
done("cancel")
|
|
177
178
|
}
|
|
@@ -289,7 +290,7 @@ export async function showTaskList(ctx: ExtensionCommandContext, config: ListPag
|
|
|
289
290
|
filtered: !!filterTerm,
|
|
290
291
|
allowPriority,
|
|
291
292
|
allowSearch,
|
|
292
|
-
|
|
293
|
+
closeKey: config.closeKey,
|
|
293
294
|
priorities: config.priorities,
|
|
294
295
|
priorityHotkeys: config.priorityHotkeys,
|
|
295
296
|
})))
|
|
@@ -325,13 +326,7 @@ export async function showTaskList(ctx: ExtensionCommandContext, config: ListPag
|
|
|
325
326
|
items = getItems()
|
|
326
327
|
const prevSelected = selectList.getSelectedItem()
|
|
327
328
|
|
|
328
|
-
selectList = new
|
|
329
|
-
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
330
|
-
selectedText: (t: string) => applyAccentWithAnsi(t),
|
|
331
|
-
description: (t: string) => styleDescription(t),
|
|
332
|
-
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
333
|
-
noMatch: (t: string) => theme.fg("warning", t),
|
|
334
|
-
})
|
|
329
|
+
selectList = new SelectListWithColumns(items, Math.min(items.length, 10), selectListTheme, TASK_LIST_ROW_LAYOUT)
|
|
335
330
|
|
|
336
331
|
selectList.onSelectionChange = () => {
|
|
337
332
|
const selected = selectList.getSelectedItem()
|
|
@@ -381,7 +376,7 @@ export async function showTaskList(ctx: ExtensionCommandContext, config: ListPag
|
|
|
381
376
|
filtered: !!filterTerm,
|
|
382
377
|
allowSearch,
|
|
383
378
|
allowPriority,
|
|
384
|
-
|
|
379
|
+
closeKey: config.closeKey,
|
|
385
380
|
priorities: config.priorities,
|
|
386
381
|
priorityHotkeys: config.priorityHotkeys,
|
|
387
382
|
})
|
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
|
+
closeKey: string
|
|
30
30
|
cycleStatus: (status: TaskStatus) => TaskStatus
|
|
31
31
|
cycleTaskType: (taskType: string | undefined) => string
|
|
32
32
|
parsePriorityKey: (data: string) => string | null
|
|
@@ -144,7 +144,7 @@ class ReservedLineText implements Component {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
export async function showTaskForm(ctx: ExtensionCommandContext, options: ShowTaskFormOptions): Promise<TaskFormResult> {
|
|
147
|
-
const { mode, subtitle, task,
|
|
147
|
+
const { mode, subtitle, task, closeKey, cycleStatus, cycleTaskType, parsePriorityKey, priorities, priorityHotkeys, onSave } = options
|
|
148
148
|
|
|
149
149
|
let taskTypeValue = task.taskType
|
|
150
150
|
let titleValue = task.title
|
|
@@ -397,7 +397,7 @@ export async function showTaskForm(ctx: ExtensionCommandContext, options: ShowTa
|
|
|
397
397
|
if (saveIndicatorTimer) clearTimeout(saveIndicatorTimer)
|
|
398
398
|
},
|
|
399
399
|
handleInput: (data: string) => {
|
|
400
|
-
if (data ===
|
|
400
|
+
if (data === closeKey) {
|
|
401
401
|
done({ action: "close_list" })
|
|
402
402
|
return
|
|
403
403
|
}
|