shariq-pi-extensions 0.2.21 → 0.2.23
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 +1 -0
- package/docs/ARCHITECTURE.md +1 -0
- package/docs/EXTENSIONS.md +6 -0
- package/extensions/factory-provider/factory/api-keys.ts +15 -5
- package/extensions/subagents/README.md +2 -2
- package/extensions/subagents/src/backends/pi.ts +1 -0
- package/extensions/task-list/README.md +107 -0
- package/extensions/task-list/index.ts +471 -0
- package/extensions/task-list/state.ts +225 -0
- package/extensions/task-list/types.ts +58 -0
- package/extensions/task-list/ui.ts +263 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -31,6 +31,7 @@ The package contains:
|
|
|
31
31
|
- Firecrawl search and scraping
|
|
32
32
|
- Git status UI
|
|
33
33
|
- persistent task goals
|
|
34
|
+
- branch-safe model-maintained task lists with live progress UI and compaction continuity
|
|
34
35
|
- configurable steer, interrupt, or follow-up input behavior
|
|
35
36
|
- dedicated multi-agent orchestration
|
|
36
37
|
- Smart Compaction with high-fidelity checkpointing, delta-merging, and custom model routing
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -43,6 +43,7 @@ Installed package directories are treated as immutable. Extensions resolve writa
|
|
|
43
43
|
- Pi Memory state: `<agent-dir>/pi-memory/`
|
|
44
44
|
- Subagent configuration and catalog: paths derived from `getAgentDir()`
|
|
45
45
|
- Orchestration settings and run ledgers: `<agent-dir>/orchestration/`
|
|
46
|
+
- Goal and Task List state: branch-local Pi session entries; neither writes a separate runtime-state file
|
|
46
47
|
- project configuration: paths derived from Pi's `CONFIG_DIR_NAME`
|
|
47
48
|
|
|
48
49
|
Credentials remain in Pi auth storage, environment variables, service credential stores, or ignored machine-local files. Package source never contains an API key, OAuth token, database, cache, or session.
|
package/docs/EXTENSIONS.md
CHANGED
|
@@ -34,6 +34,12 @@ The curated catalog intentionally excludes `-fast` variants and the removals doc
|
|
|
34
34
|
|
|
35
35
|
The goal extension adds persistent, branch-safe objectives, progress evidence, budgets, pause/resume controls, and strict completion/blocker gates. Use `/goal` for the operator UI and the `create_goal`, `get_goal`, `update_goal_progress`, and `update_goal` tools for agent-controlled state.
|
|
36
36
|
|
|
37
|
+
### [Task List](../extensions/task-list/README.md)
|
|
38
|
+
|
|
39
|
+
`task_list` gives the active model a branch-safe ordered checklist for ordinary multi-step work; it is separate from persistent Goals and may be used alongside them. Writes replace the full list, retain stable IDs, support pending/in-progress/completed/blocked/cancelled states and priorities, and require an active item while pending work remains. The prompt contract requires same-message list/action calls and immediate verified status updates. Runtime reminders catch missing or stale bookkeeping, with at most one same-model follow-up if an active list is still stale when Pi settles.
|
|
40
|
+
|
|
41
|
+
State is stored in Pi session entries and reconstructed on resume, reload, and tree navigation. Active work is re-injected when compaction removes the latest snapshot from model context. `/tasks` opens the interactive dashboard and direct editor; the compact live widget auto-hides after all work finishes. Subagents receive the same tool under every capability policy, but each child maintains its own session-local list rather than changing the parent's list.
|
|
42
|
+
|
|
37
43
|
### [Subagents](../extensions/subagents/README.md)
|
|
38
44
|
|
|
39
45
|
The subagent extension runs flat Pi child agents with profiles, capability policies, continuation, result delivery, optional worktrees, pre-warmed task dispatch, instant cascading cancellation, cross-session persistence, and a dashboard. Configuration lives in `<agent-dir>/subagents.json`; trusted projects may override it through their Pi config directory. The configured concurrency ceiling is 50.
|
|
@@ -133,9 +133,19 @@ function activeKeyEntries(modelId?: string) {
|
|
|
133
133
|
|
|
134
134
|
export function classifyFactoryKeyCooldown(message: string): { ms: number; kind: "auth" | "rate" | "quota" } | null {
|
|
135
135
|
const lower = message.toLowerCase();
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
if (
|
|
137
|
+
lower.includes("invalid api key") ||
|
|
138
|
+
lower.includes("api key revoked") ||
|
|
139
|
+
lower.includes("api key expired") ||
|
|
140
|
+
lower.includes("forbidden") ||
|
|
141
|
+
lower.includes("unauthorized") ||
|
|
142
|
+
lower.includes("permission") ||
|
|
143
|
+
lower.includes("access denied") ||
|
|
144
|
+
/\b401\b/.test(lower) ||
|
|
145
|
+
/\b403\b/.test(lower)
|
|
146
|
+
) {
|
|
147
|
+
return { ms: AUTH_COOLDOWN_MS, kind: "auth" };
|
|
148
|
+
}
|
|
139
149
|
if (/\b429\b/.test(lower) || lower.includes("rate limit")) return { ms: RATE_COOLDOWN_MS, kind: "rate" };
|
|
140
150
|
if (lower.includes("quota") || lower.includes("billing") || lower.includes("credit") || lower.includes("usage limit") || lower.includes("exhaust")) return { ms: DEFAULT_COOLDOWN_MS, kind: "quota" };
|
|
141
151
|
return null;
|
|
@@ -325,8 +335,8 @@ export function streamSimpleFactoryApiKeyResponses(model: any, context: any, opt
|
|
|
325
335
|
const error = errorText(event);
|
|
326
336
|
if (error) {
|
|
327
337
|
lastError = error;
|
|
328
|
-
|
|
329
|
-
if (
|
|
338
|
+
markKeyFailure(key, error, model.id);
|
|
339
|
+
if (!hasStarted) {
|
|
330
340
|
retriedBeforeStart = true;
|
|
331
341
|
break;
|
|
332
342
|
}
|
|
@@ -42,7 +42,7 @@ Capability modes:
|
|
|
42
42
|
- `execute` — the read-only allowlist plus shell and background-terminal execution, without direct file edit tools
|
|
43
43
|
- `all` — full child tool access
|
|
44
44
|
|
|
45
|
-
Restrictive modes fail closed: newly registered extension tools remain unavailable until they are explicitly classified. This prevents another extension from silently bypassing the selected capability.
|
|
45
|
+
Restrictive modes fail closed: newly registered extension tools remain unavailable until they are explicitly classified. This prevents another extension from silently bypassing the selected capability. The session-only `task_list` planning tool is explicitly classified as safe in every capability mode, so each child can maintain its own task list without receiving file-write or command-execution authority.
|
|
46
46
|
|
|
47
47
|
Optional user profiles and personas can be defined in `~/.pi/agent/subagents.json`. Trusted projects may override them in `.pi/subagents.json`:
|
|
48
48
|
|
|
@@ -64,7 +64,7 @@ Optional user profiles and personas can be defined in `~/.pi/agent/subagents.jso
|
|
|
64
64
|
}
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
Project configuration is ignored when the project is not trusted. Concurrency is bounded to 1–50; this
|
|
67
|
+
Project configuration is ignored when the project is not trusted. Concurrency is bounded to 1–50; this suite defaults to 50. `/subagents profiles` provides discovery, while `/subagents config` opens a validated editor for global or trusted-project configuration.
|
|
68
68
|
|
|
69
69
|
## Context and continuation
|
|
70
70
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Pi Task List
|
|
2
|
+
|
|
3
|
+
A branch-safe task list for ordinary multi-step work. The active model creates and maintains the list while it works; there is no background planner or separate task-list agent.
|
|
4
|
+
|
|
5
|
+
This extension is independent of the Goal extension. A normal task can use Task List without becoming a persistent goal, and an explicitly created goal can use its own evidence ledger and Task List at the same time.
|
|
6
|
+
|
|
7
|
+
## Model tool
|
|
8
|
+
|
|
9
|
+
`task_list` reads or replaces the current session list:
|
|
10
|
+
|
|
11
|
+
- omit `tasks` to read the current list;
|
|
12
|
+
- supply `tasks` to replace the entire ordered list;
|
|
13
|
+
- send stable IDs so progress survives revisions;
|
|
14
|
+
- use `explanation` when scope, order, or approach changes.
|
|
15
|
+
|
|
16
|
+
Each item has:
|
|
17
|
+
|
|
18
|
+
- `id` — stable letters/numbers/dots/underscores/hyphens identifier;
|
|
19
|
+
- `content` — a concrete outcome, including exact user-provided commands or literals when relevant;
|
|
20
|
+
- `status` — `pending`, `in_progress`, `completed`, `blocked`, or `cancelled`;
|
|
21
|
+
- `priority` — `high`, `medium`, or `low` (`medium` by default);
|
|
22
|
+
- `note` — optional evidence, blocker, cancellation reason, or execution detail.
|
|
23
|
+
|
|
24
|
+
A list may contain up to 64 items. A model write with pending work must keep at least one item `in_progress`. Sequential work should have one active item; several are allowed only when work is genuinely running in parallel.
|
|
25
|
+
|
|
26
|
+
## Update discipline
|
|
27
|
+
|
|
28
|
+
The tool definition and Pi prompt guidance explicitly require the model to:
|
|
29
|
+
|
|
30
|
+
1. create a list for requests with at least three distinct actions, multiple requested tasks, or meaningful phases;
|
|
31
|
+
2. skip the list for direct answers and one- or two-action work;
|
|
32
|
+
3. send the initial list in the same assistant message as the first real action;
|
|
33
|
+
4. update the list as each step changes instead of batching bookkeeping at the end;
|
|
34
|
+
5. mark work complete only after its outcome is verified;
|
|
35
|
+
6. preserve every user-requested item and exact command, flag, path, and success condition;
|
|
36
|
+
7. reconcile the full list before the final response.
|
|
37
|
+
|
|
38
|
+
The runtime reinforces these instructions without assigning the list to another worker:
|
|
39
|
+
|
|
40
|
+
- after two substantive tool calls with no list, the next model context receives a conditional reminder;
|
|
41
|
+
- after substantive work makes an active list stale, the next model context asks for an immediate update paired with the next action;
|
|
42
|
+
- if the model still stops with a stale active list, Pi starts at most one follow-up model turn so the same model can update it and continue or finish;
|
|
43
|
+
- status-only inspection tools do not count as substantive progress.
|
|
44
|
+
|
|
45
|
+
The list remains a coordination aid, not evidence that implementation or verification succeeded.
|
|
46
|
+
|
|
47
|
+
## Continuity
|
|
48
|
+
|
|
49
|
+
Every update stores a complete immutable snapshot in Pi custom session entries. State is reconstructed from the active branch on startup, resume, reload, and tree navigation, so branching restores the list that belonged to that point in history.
|
|
50
|
+
|
|
51
|
+
Active items are injected into model context only when the current snapshot is no longer represented there, including after compaction. Completed and cancelled work is summarized by count in that continuity message so the model does not redo it.
|
|
52
|
+
|
|
53
|
+
No external task database or writable package file is used.
|
|
54
|
+
|
|
55
|
+
## Subagents
|
|
56
|
+
|
|
57
|
+
Pi subagents load this extension with their normal child resources. `task_list` is explicitly allowed under every child capability policy because it changes only the child session's planning state. Each child owns an independent list in its own persistent Pi session; a child does not mutate the parent model's list.
|
|
58
|
+
|
|
59
|
+
## User interface
|
|
60
|
+
|
|
61
|
+
Run:
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
/tasks
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
While work is active, a compact widget above the editor shows progress and the current items. A finished list lingers for four seconds so the final checkmark is visible, then clears from the live chrome while remaining available in session history.
|
|
68
|
+
|
|
69
|
+
`/tasks` opens the full-width interactive dashboard:
|
|
70
|
+
|
|
71
|
+
- `↑`/`↓` or `j`/`k` — select an item;
|
|
72
|
+
- `space` — advance pending → in progress → completed;
|
|
73
|
+
- `b` — block/unblock;
|
|
74
|
+
- `c` — cancel/restore;
|
|
75
|
+
- `p` — cycle priority;
|
|
76
|
+
- `a` — add;
|
|
77
|
+
- `e` — edit;
|
|
78
|
+
- `d` — delete with confirmation;
|
|
79
|
+
- `h` — hide/show completed and cancelled items;
|
|
80
|
+
- `X` — clear the list with confirmation;
|
|
81
|
+
- `Esc` or `q` — close.
|
|
82
|
+
|
|
83
|
+
User edits automatically promote the next pending item when no task remains in progress. `/tasks clear` provides the same guarded clear action without opening the dashboard. In print, JSON, or RPC-oriented use, the tool remains fully functional and `/tasks status` falls back to a text summary where a custom terminal dashboard is unavailable.
|
|
84
|
+
|
|
85
|
+
## Design inputs
|
|
86
|
+
|
|
87
|
+
The implementation combines the strongest verified patterns from the compared harnesses:
|
|
88
|
+
|
|
89
|
+
- Pi's branch-aware tool-result/session-entry model and custom TUI surfaces;
|
|
90
|
+
- Codex's concise ordered plan, optional update explanation, and timely status transitions;
|
|
91
|
+
- OpenCode's whole-list replacement and persistent session projection;
|
|
92
|
+
- Grok Build's compact live panel, cancelled state, compaction continuity, and stale-list reminders;
|
|
93
|
+
- DeepSeek Harness's strict input validation, parallel-active policy, and dedicated composer panel;
|
|
94
|
+
- Hermes Agent's stable IDs, read-or-write tool, merge-informed state model, four-state UI, and post-compaction active-list injection;
|
|
95
|
+
- Factory Droid's same-message task/action rule, three-action threshold, real-time completion discipline, stale-plan warning, and compact TodoWrite presentation.
|
|
96
|
+
|
|
97
|
+
OpenClaw's inspected task/update surfaces do not provide a comparable model-maintained coding-session todo tool, so no incompatible lifecycle was copied from them.
|
|
98
|
+
|
|
99
|
+
## Validation
|
|
100
|
+
|
|
101
|
+
From the repository root:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
bun test extensions/task-list extensions/subagents/context-config.test.ts
|
|
105
|
+
bun x tsc --noEmit
|
|
106
|
+
npm run validate
|
|
107
|
+
```
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
3
|
+
import type {
|
|
4
|
+
ExtensionAPI,
|
|
5
|
+
ExtensionCommandContext,
|
|
6
|
+
ExtensionContext,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
import { oneLine } from "../shared/tui-dashboard.ts";
|
|
11
|
+
import {
|
|
12
|
+
TASK_LIST_ENTRY,
|
|
13
|
+
TASK_LIST_TOOL,
|
|
14
|
+
MAX_TASKS,
|
|
15
|
+
MAX_TASK_CONTENT_CHARS,
|
|
16
|
+
buildUpdatedTaskList,
|
|
17
|
+
copyTaskListState,
|
|
18
|
+
emptyTaskListState,
|
|
19
|
+
hasActiveTasks,
|
|
20
|
+
restoreTaskList,
|
|
21
|
+
taskCounts,
|
|
22
|
+
taskListContext,
|
|
23
|
+
taskListText,
|
|
24
|
+
} from "./state.ts";
|
|
25
|
+
import type {
|
|
26
|
+
TaskItem,
|
|
27
|
+
TaskListDetails,
|
|
28
|
+
TaskListInput,
|
|
29
|
+
TaskListSnapshot,
|
|
30
|
+
TaskListState,
|
|
31
|
+
TaskPriority,
|
|
32
|
+
TaskStatus,
|
|
33
|
+
} from "./types.ts";
|
|
34
|
+
import { TASK_PRIORITIES, TASK_STATUSES } from "./types.ts";
|
|
35
|
+
import { openTaskDashboard, taskGlyph } from "./ui.ts";
|
|
36
|
+
|
|
37
|
+
const WIDGET_ID = "task-list";
|
|
38
|
+
const FINISHED_LINGER_MS = 4_000;
|
|
39
|
+
const WORK_TOOL_EXCLUSIONS = new Set([
|
|
40
|
+
TASK_LIST_TOOL,
|
|
41
|
+
"get_goal",
|
|
42
|
+
"list_agents",
|
|
43
|
+
"check_agent",
|
|
44
|
+
"wait_agent",
|
|
45
|
+
"list_terminals",
|
|
46
|
+
"pi_memory_status",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const TaskListParams = Type.Object({
|
|
50
|
+
tasks: Type.Optional(Type.Array(
|
|
51
|
+
Type.Object({
|
|
52
|
+
id: Type.String({ minLength: 1, maxLength: 80, description: "Stable identifier using letters, numbers, dots, underscores, or hyphens." }),
|
|
53
|
+
content: Type.String({ minLength: 1, maxLength: 500, description: "Short, concrete task outcome. Preserve user-supplied commands and exact literals." }),
|
|
54
|
+
status: StringEnum(TASK_STATUSES, { description: "pending | in_progress | completed | blocked | cancelled" }),
|
|
55
|
+
priority: Type.Optional(StringEnum(TASK_PRIORITIES, { description: "Defaults to medium; use high only when order or urgency materially requires it." })),
|
|
56
|
+
note: Type.Optional(Type.String({ maxLength: 1_000, description: "Concise evidence, blocker, cancellation reason, or execution detail." })),
|
|
57
|
+
}, { additionalProperties: false }),
|
|
58
|
+
{ maxItems: 64, description: "The complete ordered task list. Supplying this field replaces the previous list." },
|
|
59
|
+
)),
|
|
60
|
+
explanation: Type.Optional(Type.String({ maxLength: 1_000, description: "Why the list changed, especially after a scope or approach change." })),
|
|
61
|
+
}, { additionalProperties: false });
|
|
62
|
+
|
|
63
|
+
function statusColor(status: TaskStatus): "accent" | "success" | "warning" | "error" | "muted" {
|
|
64
|
+
if (status === "in_progress") return "accent";
|
|
65
|
+
if (status === "completed") return "success";
|
|
66
|
+
if (status === "blocked") return "error";
|
|
67
|
+
if (status === "cancelled") return "muted";
|
|
68
|
+
return "muted";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function terminal(status: TaskStatus): boolean {
|
|
72
|
+
return status === "completed" || status === "cancelled";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function summaryLine(state: TaskListState): string {
|
|
76
|
+
const counts = taskCounts(state.tasks);
|
|
77
|
+
return `${counts.completed}/${counts.total} completed · ${counts.inProgress} active · ${counts.pending} pending · ${counts.blocked} blocked · ${counts.cancelled} cancelled`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function nextUniqueId(state: TaskListState, content: string): string {
|
|
81
|
+
const base = content
|
|
82
|
+
.normalize("NFKD")
|
|
83
|
+
.toLowerCase()
|
|
84
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
85
|
+
.replace(/^-+|-+$/g, "")
|
|
86
|
+
.slice(0, 40) || "task";
|
|
87
|
+
const existing = new Set(state.tasks.map((task) => task.id));
|
|
88
|
+
if (!existing.has(base)) return base;
|
|
89
|
+
for (let suffix = 2; suffix < 10_000; suffix++) {
|
|
90
|
+
const candidate = `${base}-${suffix}`;
|
|
91
|
+
if (!existing.has(candidate)) return candidate;
|
|
92
|
+
}
|
|
93
|
+
return `task-${Date.now()}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default function taskListExtension(pi: ExtensionAPI) {
|
|
97
|
+
let state = emptyTaskListState();
|
|
98
|
+
let lastCtx: ExtensionContext | null = null;
|
|
99
|
+
let finishedTimer: ReturnType<typeof setTimeout> | undefined;
|
|
100
|
+
let sequence = 0;
|
|
101
|
+
let lastTaskSequence = 0;
|
|
102
|
+
let lastWorkSequence = 0;
|
|
103
|
+
let workCallsSinceUser = 0;
|
|
104
|
+
let taskCallsSinceUser = 0;
|
|
105
|
+
let nudgeCount = 0;
|
|
106
|
+
let staleAtAgentEnd = false;
|
|
107
|
+
|
|
108
|
+
function cancelFinishedTimer(): void {
|
|
109
|
+
if (!finishedTimer) return;
|
|
110
|
+
clearTimeout(finishedTimer);
|
|
111
|
+
finishedTimer = undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function updatePresentation(ctx: ExtensionContext): void {
|
|
115
|
+
lastCtx = ctx;
|
|
116
|
+
cancelFinishedTimer();
|
|
117
|
+
if (!ctx.hasUI || state.tasks.length === 0) {
|
|
118
|
+
ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
119
|
+
ctx.ui.setStatus(WIDGET_ID, undefined);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const counts = taskCounts(state.tasks);
|
|
124
|
+
const active = hasActiveTasks(state);
|
|
125
|
+
const renderWidget = (_tui: unknown, theme: ExtensionContext["ui"]["theme"]) => ({
|
|
126
|
+
render(width: number): string[] {
|
|
127
|
+
const current = state.tasks.filter((task) => !terminal(task.status));
|
|
128
|
+
const finished = counts.completed + counts.cancelled;
|
|
129
|
+
const header = truncateToWidth(
|
|
130
|
+
`${theme.fg(active ? "accent" : "success", theme.bold("Tasks"))} ${theme.fg("muted", `${finished}/${counts.total}`)}${counts.blocked ? theme.fg("warning", ` · ${counts.blocked} blocked`) : ""}`,
|
|
131
|
+
width,
|
|
132
|
+
);
|
|
133
|
+
const source = active ? current : state.tasks;
|
|
134
|
+
const shown = (active ? source.slice(0, 5) : source.slice(-3));
|
|
135
|
+
const lines = shown.map((task) => {
|
|
136
|
+
const color = statusColor(task.status);
|
|
137
|
+
const label = `${theme.fg(color, taskGlyph(task.status))} ${theme.fg(terminal(task.status) ? "muted" : "text", oneLine(task.content))}`;
|
|
138
|
+
return truncateToWidth(label, width);
|
|
139
|
+
});
|
|
140
|
+
const hidden = source.length - shown.length;
|
|
141
|
+
if (hidden > 0) lines.push(truncateToWidth(theme.fg("dim", `… +${hidden} more · /tasks`), width));
|
|
142
|
+
return [header, ...lines];
|
|
143
|
+
},
|
|
144
|
+
invalidate() {},
|
|
145
|
+
});
|
|
146
|
+
ctx.ui.setWidget(WIDGET_ID, renderWidget);
|
|
147
|
+
if (active) {
|
|
148
|
+
const status = counts.blocked > 0
|
|
149
|
+
? `Tasks ${counts.completed}/${counts.total} · ${counts.blocked} blocked`
|
|
150
|
+
: `Tasks ${counts.completed}/${counts.total} · ${counts.inProgress} active`;
|
|
151
|
+
ctx.ui.setStatus(WIDGET_ID, ctx.ui.theme.fg(counts.blocked > 0 ? "warning" : "accent", status));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
ctx.ui.setStatus(WIDGET_ID, ctx.ui.theme.fg("success", `Tasks complete ${counts.completed}/${counts.total}`));
|
|
156
|
+
finishedTimer = setTimeout(() => {
|
|
157
|
+
finishedTimer = undefined;
|
|
158
|
+
if (lastCtx !== ctx || hasActiveTasks(state)) return;
|
|
159
|
+
ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
160
|
+
ctx.ui.setStatus(WIDGET_ID, undefined);
|
|
161
|
+
}, FINISHED_LINGER_MS);
|
|
162
|
+
finishedTimer.unref?.();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function persist(ctx: ExtensionContext): void {
|
|
166
|
+
lastCtx = ctx;
|
|
167
|
+
pi.appendEntry(TASK_LIST_ENTRY, { state: copyTaskListState(state) } satisfies TaskListSnapshot);
|
|
168
|
+
updatePresentation(ctx);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function replaceState(next: TaskListState, ctx: ExtensionContext): void {
|
|
172
|
+
state = next;
|
|
173
|
+
persist(ctx);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function ensureActiveTask(tasks: TaskItem[]): TaskItem[] {
|
|
177
|
+
if (tasks.some((task) => task.status === "in_progress")) return tasks;
|
|
178
|
+
const next = tasks.find((task) => task.status === "pending");
|
|
179
|
+
return next
|
|
180
|
+
? tasks.map((task) => task.id === next.id ? { ...task, status: "in_progress" } : task)
|
|
181
|
+
: tasks;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function mutateState(ctx: ExtensionContext, mutate: (tasks: TaskItem[]) => TaskItem[], explanation?: string): void {
|
|
185
|
+
const timestamp = Date.now();
|
|
186
|
+
const previous = new Map(state.tasks.map((task) => [task.id, task]));
|
|
187
|
+
const next = ensureActiveTask(mutate(state.tasks.map((task) => ({ ...task }))));
|
|
188
|
+
state = {
|
|
189
|
+
revision: state.revision + 1,
|
|
190
|
+
tasks: next.map((task) => {
|
|
191
|
+
const prior = previous.get(task.id);
|
|
192
|
+
const unchanged = prior && prior.content === task.content && prior.status === task.status && prior.priority === task.priority && prior.note === task.note;
|
|
193
|
+
return { ...task, updatedAt: unchanged ? prior.updatedAt : timestamp };
|
|
194
|
+
}),
|
|
195
|
+
explanation,
|
|
196
|
+
updatedAt: timestamp,
|
|
197
|
+
};
|
|
198
|
+
persist(ctx);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function currentRevisionVisible(messages: ReadonlyArray<unknown>): boolean {
|
|
202
|
+
return messages.some((message) => {
|
|
203
|
+
if (!message || typeof message !== "object") return false;
|
|
204
|
+
const candidate = message as { role?: string; toolName?: string; details?: Partial<TaskListDetails> };
|
|
205
|
+
return candidate.role === "toolResult" && candidate.toolName === TASK_LIST_TOOL && (candidate.details?.state?.revision ?? -1) >= state.revision;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function reminderText(): string | null {
|
|
210
|
+
if (hasActiveTasks(state) && lastWorkSequence > lastTaskSequence) {
|
|
211
|
+
return "The task list is stale after substantive work. Before more narration, call task_list with the complete updated list: mark finished work completed only if verified, keep current work in_progress, and pair the update with the next action tool when work remains.";
|
|
212
|
+
}
|
|
213
|
+
if (state.tasks.length === 0 && taskCallsSinceUser === 0 && workCallsSinceUser >= 2) {
|
|
214
|
+
return "You have started multi-action work without task_list. If this request requires at least three distinct actions or contains multiple user tasks, create the complete list now and call task_list in the same assistant message as the next action. Do not create a retroactive list if the work is already complete or was genuinely trivial.";
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
pi.on("input", async (event) => {
|
|
220
|
+
if (event.source === "extension") return;
|
|
221
|
+
workCallsSinceUser = 0;
|
|
222
|
+
taskCallsSinceUser = 0;
|
|
223
|
+
lastTaskSequence = 0;
|
|
224
|
+
lastWorkSequence = 0;
|
|
225
|
+
staleAtAgentEnd = false;
|
|
226
|
+
nudgeCount = 0;
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
230
|
+
lastCtx = ctx;
|
|
231
|
+
state = restoreTaskList(ctx);
|
|
232
|
+
sequence = 0;
|
|
233
|
+
lastTaskSequence = 0;
|
|
234
|
+
lastWorkSequence = 0;
|
|
235
|
+
workCallsSinceUser = 0;
|
|
236
|
+
taskCallsSinceUser = 0;
|
|
237
|
+
nudgeCount = 0;
|
|
238
|
+
updatePresentation(ctx);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
242
|
+
state = restoreTaskList(ctx);
|
|
243
|
+
updatePresentation(ctx);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
pi.on("tool_execution_start", async (event, ctx) => {
|
|
247
|
+
lastCtx = ctx;
|
|
248
|
+
sequence++;
|
|
249
|
+
if (event.toolName === TASK_LIST_TOOL) {
|
|
250
|
+
lastTaskSequence = sequence;
|
|
251
|
+
taskCallsSinceUser++;
|
|
252
|
+
} else if (!WORK_TOOL_EXCLUSIONS.has(event.toolName)) {
|
|
253
|
+
lastWorkSequence = sequence;
|
|
254
|
+
workCallsSinceUser++;
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
pi.on("context", async (event) => {
|
|
259
|
+
const additions: AgentMessage[] = [];
|
|
260
|
+
if (hasActiveTasks(state) && !currentRevisionVisible(event.messages)) {
|
|
261
|
+
additions.push({
|
|
262
|
+
role: "custom",
|
|
263
|
+
customType: "task-list-context",
|
|
264
|
+
content: taskListContext(state),
|
|
265
|
+
display: false,
|
|
266
|
+
details: { revision: state.revision },
|
|
267
|
+
timestamp: Date.now(),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
const reminder = reminderText();
|
|
271
|
+
if (reminder) {
|
|
272
|
+
additions.push({
|
|
273
|
+
role: "custom",
|
|
274
|
+
customType: "task-list-reminder",
|
|
275
|
+
content: `<task_list_reminder>${reminder}</task_list_reminder>`,
|
|
276
|
+
display: false,
|
|
277
|
+
details: { revision: state.revision },
|
|
278
|
+
timestamp: Date.now(),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return additions.length > 0 ? { messages: [...event.messages, ...additions] } : undefined;
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
pi.on("agent_end", async () => {
|
|
285
|
+
staleAtAgentEnd = hasActiveTasks(state) && lastWorkSequence > lastTaskSequence;
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
289
|
+
lastCtx = ctx;
|
|
290
|
+
if (!staleAtAgentEnd || nudgeCount >= 1 || ctx.hasPendingMessages()) return;
|
|
291
|
+
staleAtAgentEnd = false;
|
|
292
|
+
nudgeCount++;
|
|
293
|
+
pi.sendMessage({
|
|
294
|
+
customType: "task-list-reminder",
|
|
295
|
+
content: "You stopped with a stale active task list. Update task_list now. If work remains, pair that update with the next concrete action; if work is finished, mark verified items completed and cancelled items with a reason before the final response.",
|
|
296
|
+
display: false,
|
|
297
|
+
details: { revision: state.revision, kind: "settled-stale-list" },
|
|
298
|
+
}, { deliverAs: "followUp", triggerTurn: true });
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
pi.on("session_shutdown", async () => {
|
|
302
|
+
cancelFinishedTimer();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
async function runDashboard(ctx: ExtensionCommandContext): Promise<void> {
|
|
306
|
+
if (ctx.mode !== "tui") {
|
|
307
|
+
ctx.ui.notify(taskListText(state), "info");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
while (true) {
|
|
311
|
+
const action = await openTaskDashboard(ctx, { getState: () => state });
|
|
312
|
+
if (action.kind === "close") return;
|
|
313
|
+
if (action.kind === "clear") {
|
|
314
|
+
if (!state.tasks.length) continue;
|
|
315
|
+
const confirmed = await ctx.ui.confirm("Clear task list?", "This removes every current task from this session branch.");
|
|
316
|
+
if (confirmed) mutateState(ctx, () => [], "Task list cleared by user.");
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (action.kind === "add") {
|
|
320
|
+
if (state.tasks.length >= MAX_TASKS) {
|
|
321
|
+
ctx.ui.notify(`A task list can contain at most ${MAX_TASKS} items.`, "warning");
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const content = await ctx.ui.input("Add task", "Short, concrete outcome");
|
|
325
|
+
const cleaned = content?.trim().normalize("NFKC");
|
|
326
|
+
if (!cleaned) continue;
|
|
327
|
+
if (Array.from(cleaned).length > MAX_TASK_CONTENT_CHARS) {
|
|
328
|
+
ctx.ui.notify(`Task content exceeds ${MAX_TASK_CONTENT_CHARS} characters.`, "warning");
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const id = nextUniqueId(state, cleaned);
|
|
332
|
+
mutateState(ctx, (tasks) => [...tasks, {
|
|
333
|
+
id,
|
|
334
|
+
content: cleaned,
|
|
335
|
+
status: tasks.some((task) => task.status === "in_progress") ? "pending" : "in_progress",
|
|
336
|
+
priority: "medium",
|
|
337
|
+
updatedAt: Date.now(),
|
|
338
|
+
}], "Task added by user.");
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const task = state.tasks.find((item) => item.id === action.id);
|
|
342
|
+
if (!task) continue;
|
|
343
|
+
if (action.kind === "edit") {
|
|
344
|
+
const content = await ctx.ui.input("Edit task", task.content);
|
|
345
|
+
const cleaned = content?.trim().normalize("NFKC");
|
|
346
|
+
if (!cleaned) continue;
|
|
347
|
+
if (Array.from(cleaned).length > MAX_TASK_CONTENT_CHARS) {
|
|
348
|
+
ctx.ui.notify(`Task content exceeds ${MAX_TASK_CONTENT_CHARS} characters.`, "warning");
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
mutateState(ctx, (tasks) => tasks.map((item) => item.id === task.id ? { ...item, content: cleaned } : item), "Task edited by user.");
|
|
352
|
+
} else if (action.kind === "delete") {
|
|
353
|
+
const confirmed = await ctx.ui.confirm("Delete task?", task.content);
|
|
354
|
+
if (confirmed) mutateState(ctx, (tasks) => tasks.filter((item) => item.id !== task.id), "Task deleted by user.");
|
|
355
|
+
} else if (action.kind === "status") {
|
|
356
|
+
mutateState(ctx, (tasks) => tasks.map((item) => item.id === task.id ? { ...item, status: action.status } : item), "Task status changed by user.");
|
|
357
|
+
} else if (action.kind === "priority") {
|
|
358
|
+
mutateState(ctx, (tasks) => tasks.map((item) => item.id === task.id ? { ...item, priority: action.priority } : item), "Task priority changed by user.");
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
pi.registerCommand("tasks", {
|
|
364
|
+
description: "Open the current session task list",
|
|
365
|
+
getArgumentCompletions: (prefix) => {
|
|
366
|
+
const options = ["status", "clear"];
|
|
367
|
+
const matches = options.filter((option) => option.startsWith(prefix));
|
|
368
|
+
return matches.length ? matches.map((value) => ({ value, label: value })) : null;
|
|
369
|
+
},
|
|
370
|
+
handler: async (args, ctx) => {
|
|
371
|
+
lastCtx = ctx;
|
|
372
|
+
const command = args.trim().toLowerCase();
|
|
373
|
+
if (!command || command === "status") {
|
|
374
|
+
await runDashboard(ctx);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (command === "clear") {
|
|
378
|
+
if (!state.tasks.length) {
|
|
379
|
+
ctx.ui.notify("No tasks to clear.", "info");
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const confirmed = await ctx.ui.confirm("Clear task list?", "This removes every current task from this session branch.");
|
|
383
|
+
if (!confirmed) return;
|
|
384
|
+
mutateState(ctx, () => [], "Task list cleared by user.");
|
|
385
|
+
ctx.ui.notify("Task list cleared.", "info");
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
ctx.ui.notify("Usage: /tasks [status|clear]", "warning");
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
pi.registerTool({
|
|
393
|
+
name: TASK_LIST_TOOL,
|
|
394
|
+
label: "Task List",
|
|
395
|
+
description: `Read or replace the ordered task list for this coding session. Omit tasks to read it. When tasks is supplied, it is the COMPLETE replacement list, not a patch.
|
|
396
|
+
|
|
397
|
+
Use task_list for work with at least three distinct actions, multiple user-requested tasks, or meaningful phases that need visible progress. Skip it for a direct answer or one or two simple actions.
|
|
398
|
+
|
|
399
|
+
Start the list before substantive work and call task_list in the SAME assistant message as the first action tool. Never spend a turn only announcing or updating the list when another action can run. Keep stable ids and preserve every user-requested item, exact command, flag, path, and success condition.
|
|
400
|
+
|
|
401
|
+
Update the list as work happens, not after several steps: mark a finished item completed only after its outcome is verified, move the next sequential item to in_progress, and issue that next action in the same assistant message. Keep one in_progress task for sequential work; use several only when work is genuinely running in parallel. Use blocked only for a concrete unresolved dependency and cancelled only when an item is no longer required, with the reason in note.
|
|
402
|
+
|
|
403
|
+
Before the final response, reconcile the whole list with actual results. No item may remain pending or in_progress if the requested work is finished. Do not claim completion from the list itself.`,
|
|
404
|
+
promptSnippet: "Read or replace the current session's complete task list and progress state.",
|
|
405
|
+
promptGuidelines: [
|
|
406
|
+
"Use task_list for requests with at least three distinct actions, multiple requested tasks, or meaningful phases; skip it for direct answers and one- or two-action work.",
|
|
407
|
+
"Create task_list before substantive multi-step work and send the update in the same assistant message as the first real action; never spend a turn on task bookkeeping alone when another action exists.",
|
|
408
|
+
"Every task_list write replaces the entire ordered list. Preserve stable ids, all unfinished and user-requested work, and exact commands, flags, paths, and success conditions.",
|
|
409
|
+
"Update task_list immediately as each step changes: complete only verified work, set the next sequential item in_progress, and pair the update with the next action. Multiple in_progress items require genuinely parallel work.",
|
|
410
|
+
"Before a final response, reconcile task_list with observed results and leave no stale pending or in_progress items when the requested work is finished. The list is not proof of completion.",
|
|
411
|
+
],
|
|
412
|
+
parameters: TaskListParams,
|
|
413
|
+
async execute(_toolCallId, params: TaskListInput, _signal, _onUpdate, ctx) {
|
|
414
|
+
lastCtx = ctx;
|
|
415
|
+
if (params.tasks !== undefined) {
|
|
416
|
+
replaceState(buildUpdatedTaskList(state, params), ctx);
|
|
417
|
+
lastTaskSequence = Math.max(lastTaskSequence, sequence);
|
|
418
|
+
}
|
|
419
|
+
const action = params.tasks === undefined ? "read" : "update";
|
|
420
|
+
const details: TaskListDetails = {
|
|
421
|
+
action,
|
|
422
|
+
state: copyTaskListState(state),
|
|
423
|
+
counts: taskCounts(state.tasks),
|
|
424
|
+
};
|
|
425
|
+
return {
|
|
426
|
+
content: [{ type: "text", text: taskListText(state) }],
|
|
427
|
+
details,
|
|
428
|
+
};
|
|
429
|
+
},
|
|
430
|
+
renderCall(args, theme, context) {
|
|
431
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
432
|
+
const action = args.tasks === undefined ? "read" : "update";
|
|
433
|
+
const count = args.tasks?.length;
|
|
434
|
+
text.setText(
|
|
435
|
+
theme.fg("toolTitle", theme.bold("task_list ")) +
|
|
436
|
+
theme.fg("muted", action) +
|
|
437
|
+
(count == null ? "" : theme.fg("dim", ` · ${count} item${count === 1 ? "" : "s"}`)),
|
|
438
|
+
);
|
|
439
|
+
return text;
|
|
440
|
+
},
|
|
441
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
442
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
443
|
+
if (isPartial) {
|
|
444
|
+
text.setText(theme.fg("warning", "Updating task list…"));
|
|
445
|
+
return text;
|
|
446
|
+
}
|
|
447
|
+
const details = result.details as TaskListDetails | undefined;
|
|
448
|
+
if (!details) {
|
|
449
|
+
const first = result.content[0];
|
|
450
|
+
text.setText(first?.type === "text" ? first.text : "");
|
|
451
|
+
return text;
|
|
452
|
+
}
|
|
453
|
+
const counts = details.counts;
|
|
454
|
+
const complete = counts.total > 0 && counts.completed + counts.cancelled === counts.total;
|
|
455
|
+
let output = theme.fg(complete ? "success" : "accent", complete ? "✓ " : "◆ ") + theme.fg("muted", summaryLine(details.state));
|
|
456
|
+
const visible = expanded
|
|
457
|
+
? details.state.tasks
|
|
458
|
+
: details.state.tasks.filter((task) => task.status === "in_progress" || task.status === "blocked").slice(0, 3);
|
|
459
|
+
for (const task of visible) {
|
|
460
|
+
const color = statusColor(task.status);
|
|
461
|
+
const content = terminal(task.status) ? theme.strikethrough(oneLine(task.content)) : oneLine(task.content);
|
|
462
|
+
output += `\n${theme.fg(color, taskGlyph(task.status))} ${theme.fg(color, content)}${task.note && expanded ? theme.fg("dim", ` — ${oneLine(task.note)}`) : ""}`;
|
|
463
|
+
}
|
|
464
|
+
if (!expanded && visible.length === 0 && counts.total > 0) {
|
|
465
|
+
output += `\n${theme.fg("dim", complete ? "All tasks finished" : "No active task")}`;
|
|
466
|
+
}
|
|
467
|
+
text.setText(output);
|
|
468
|
+
return text;
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type {
|
|
3
|
+
TaskCounts,
|
|
4
|
+
TaskItem,
|
|
5
|
+
TaskListDetails,
|
|
6
|
+
TaskListInput,
|
|
7
|
+
TaskListSnapshot,
|
|
8
|
+
TaskListState,
|
|
9
|
+
TaskPriority,
|
|
10
|
+
TaskStatus,
|
|
11
|
+
} from "./types.ts";
|
|
12
|
+
import { TASK_PRIORITIES, TASK_STATUSES } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
export const TASK_LIST_ENTRY = "task-list-state";
|
|
15
|
+
export const TASK_LIST_TOOL = "task_list";
|
|
16
|
+
export const MAX_TASKS = 64;
|
|
17
|
+
export const MAX_TASK_CONTENT_CHARS = 500;
|
|
18
|
+
export const MAX_TASK_NOTE_CHARS = 1_000;
|
|
19
|
+
export const MAX_EXPLANATION_CHARS = 1_000;
|
|
20
|
+
|
|
21
|
+
const statusSet = new Set<string>(TASK_STATUSES);
|
|
22
|
+
const prioritySet = new Set<string>(TASK_PRIORITIES);
|
|
23
|
+
|
|
24
|
+
function now(): number {
|
|
25
|
+
return Date.now();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function emptyTaskListState(): TaskListState {
|
|
29
|
+
return { revision: 0, tasks: [], updatedAt: now() };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function copyTaskListState(state: TaskListState): TaskListState {
|
|
33
|
+
return {
|
|
34
|
+
...state,
|
|
35
|
+
tasks: state.tasks.map((task) => ({ ...task })),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeStoredState(value: unknown): TaskListState | null {
|
|
40
|
+
if (!value || typeof value !== "object") return null;
|
|
41
|
+
const candidate = value as Partial<TaskListState>;
|
|
42
|
+
if (!Array.isArray(candidate.tasks)) return null;
|
|
43
|
+
|
|
44
|
+
const tasks: TaskItem[] = [];
|
|
45
|
+
const ids = new Set<string>();
|
|
46
|
+
for (const raw of candidate.tasks) {
|
|
47
|
+
if (!raw || typeof raw !== "object") return null;
|
|
48
|
+
const task = raw as Partial<TaskItem>;
|
|
49
|
+
if (
|
|
50
|
+
typeof task.id !== "string" ||
|
|
51
|
+
typeof task.content !== "string" ||
|
|
52
|
+
typeof task.status !== "string" ||
|
|
53
|
+
!statusSet.has(task.status)
|
|
54
|
+
) return null;
|
|
55
|
+
const id = task.id.trim();
|
|
56
|
+
const content = task.content.trim();
|
|
57
|
+
if (
|
|
58
|
+
!id ||
|
|
59
|
+
!content ||
|
|
60
|
+
!/^[A-Za-z0-9._-]+$/.test(id) ||
|
|
61
|
+
Array.from(id).length > 80 ||
|
|
62
|
+
Array.from(content).length > MAX_TASK_CONTENT_CHARS ||
|
|
63
|
+
ids.has(id)
|
|
64
|
+
) return null;
|
|
65
|
+
ids.add(id);
|
|
66
|
+
const priority = typeof task.priority === "string" && prioritySet.has(task.priority)
|
|
67
|
+
? task.priority as TaskPriority
|
|
68
|
+
: "medium";
|
|
69
|
+
const note = typeof task.note === "string" && task.note.trim() ? task.note.trim() : undefined;
|
|
70
|
+
if (note && Array.from(note).length > MAX_TASK_NOTE_CHARS) return null;
|
|
71
|
+
tasks.push({
|
|
72
|
+
id,
|
|
73
|
+
content,
|
|
74
|
+
status: task.status as TaskStatus,
|
|
75
|
+
priority,
|
|
76
|
+
note,
|
|
77
|
+
updatedAt: Number.isSafeInteger(task.updatedAt) ? task.updatedAt! : now(),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (tasks.length > MAX_TASKS) return null;
|
|
82
|
+
const explanation = typeof candidate.explanation === "string" && candidate.explanation.trim()
|
|
83
|
+
? candidate.explanation.trim()
|
|
84
|
+
: undefined;
|
|
85
|
+
if (explanation && Array.from(explanation).length > MAX_EXPLANATION_CHARS) return null;
|
|
86
|
+
return {
|
|
87
|
+
revision: Number.isSafeInteger(candidate.revision) && candidate.revision! >= 0
|
|
88
|
+
? candidate.revision!
|
|
89
|
+
: 0,
|
|
90
|
+
tasks,
|
|
91
|
+
explanation,
|
|
92
|
+
updatedAt: Number.isSafeInteger(candidate.updatedAt) ? candidate.updatedAt! : now(),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function restoreTaskList(ctx: ExtensionContext): TaskListState {
|
|
97
|
+
let restored: TaskListState | null = null;
|
|
98
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
99
|
+
if (entry.type === "custom" && entry.customType === TASK_LIST_ENTRY) {
|
|
100
|
+
const snapshot = entry.data as Partial<TaskListSnapshot> | undefined;
|
|
101
|
+
restored = normalizeStoredState(snapshot?.state) ?? restored;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (entry.type !== "message" || entry.message.role !== "toolResult") continue;
|
|
105
|
+
if (entry.message.toolName !== TASK_LIST_TOOL) continue;
|
|
106
|
+
const details = entry.message.details as Partial<TaskListDetails> | undefined;
|
|
107
|
+
restored = normalizeStoredState(details?.state) ?? restored;
|
|
108
|
+
}
|
|
109
|
+
return restored ?? emptyTaskListState();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function cleanBounded(value: string, field: string, max: number): string {
|
|
113
|
+
const cleaned = value.trim().normalize("NFKC");
|
|
114
|
+
if (!cleaned) throw new Error(`${field} must not be empty.`);
|
|
115
|
+
if (Array.from(cleaned).length > max) {
|
|
116
|
+
throw new Error(`${field} exceeds ${max.toLocaleString()} characters.`);
|
|
117
|
+
}
|
|
118
|
+
return cleaned;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function buildUpdatedTaskList(current: TaskListState, input: TaskListInput): TaskListState {
|
|
122
|
+
if (input.tasks === undefined) return copyTaskListState(current);
|
|
123
|
+
if (input.tasks.length > MAX_TASKS) {
|
|
124
|
+
throw new Error(`A task list can contain at most ${MAX_TASKS} items.`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const ids = new Set<string>();
|
|
128
|
+
const previous = new Map(current.tasks.map((task) => [task.id, task]));
|
|
129
|
+
const timestamp = now();
|
|
130
|
+
const tasks = input.tasks.map((raw, index): TaskItem => {
|
|
131
|
+
const id = cleanBounded(raw.id, `Task ${index + 1} id`, 80);
|
|
132
|
+
if (!/^[A-Za-z0-9._-]+$/.test(id)) {
|
|
133
|
+
throw new Error(`Task id "${id}" may contain only letters, numbers, dots, underscores, and hyphens.`);
|
|
134
|
+
}
|
|
135
|
+
if (ids.has(id)) throw new Error(`Duplicate task id "${id}".`);
|
|
136
|
+
ids.add(id);
|
|
137
|
+
if (!statusSet.has(raw.status)) throw new Error(`Invalid status for task "${id}".`);
|
|
138
|
+
const priority = raw.priority ?? previous.get(id)?.priority ?? "medium";
|
|
139
|
+
if (!prioritySet.has(priority)) throw new Error(`Invalid priority for task "${id}".`);
|
|
140
|
+
const content = cleanBounded(raw.content, `Task "${id}" content`, MAX_TASK_CONTENT_CHARS);
|
|
141
|
+
const note = raw.note == null || !raw.note.trim()
|
|
142
|
+
? undefined
|
|
143
|
+
: cleanBounded(raw.note, `Task "${id}" note`, MAX_TASK_NOTE_CHARS);
|
|
144
|
+
const prior = previous.get(id);
|
|
145
|
+
const unchanged = prior && prior.content === content && prior.status === raw.status && prior.priority === priority && prior.note === note;
|
|
146
|
+
return {
|
|
147
|
+
id,
|
|
148
|
+
content,
|
|
149
|
+
status: raw.status,
|
|
150
|
+
priority,
|
|
151
|
+
note,
|
|
152
|
+
updatedAt: unchanged ? prior.updatedAt : timestamp,
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const unfinished = tasks.filter((task) => task.status === "pending" || task.status === "in_progress");
|
|
157
|
+
if (unfinished.some((task) => task.status === "pending") && !unfinished.some((task) => task.status === "in_progress")) {
|
|
158
|
+
throw new Error("At least one task must be in_progress while pending work remains.");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const explanation = input.explanation == null || !input.explanation.trim()
|
|
162
|
+
? undefined
|
|
163
|
+
: cleanBounded(input.explanation, "Task-list explanation", MAX_EXPLANATION_CHARS);
|
|
164
|
+
return {
|
|
165
|
+
revision: current.revision + 1,
|
|
166
|
+
tasks,
|
|
167
|
+
explanation,
|
|
168
|
+
updatedAt: timestamp,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function taskCounts(tasks: ReadonlyArray<TaskItem>): TaskCounts {
|
|
173
|
+
return {
|
|
174
|
+
total: tasks.length,
|
|
175
|
+
pending: tasks.filter((task) => task.status === "pending").length,
|
|
176
|
+
inProgress: tasks.filter((task) => task.status === "in_progress").length,
|
|
177
|
+
completed: tasks.filter((task) => task.status === "completed").length,
|
|
178
|
+
blocked: tasks.filter((task) => task.status === "blocked").length,
|
|
179
|
+
cancelled: tasks.filter((task) => task.status === "cancelled").length,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function hasActiveTasks(state: TaskListState): boolean {
|
|
184
|
+
return state.tasks.some((task) => task.status === "pending" || task.status === "in_progress" || task.status === "blocked");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function taskListText(state: TaskListState): string {
|
|
188
|
+
const counts = taskCounts(state.tasks);
|
|
189
|
+
if (counts.total === 0) return "No tasks are currently tracked.";
|
|
190
|
+
const glyph: Record<TaskStatus, string> = {
|
|
191
|
+
pending: "[ ]",
|
|
192
|
+
in_progress: "[>]",
|
|
193
|
+
completed: "[x]",
|
|
194
|
+
blocked: "[!]",
|
|
195
|
+
cancelled: "[-]",
|
|
196
|
+
};
|
|
197
|
+
const lines = state.tasks.map((task) => {
|
|
198
|
+
const priority = task.priority === "medium" ? "" : ` (${task.priority})`;
|
|
199
|
+
const note = task.note ? ` — ${task.note}` : "";
|
|
200
|
+
return `${glyph[task.status]} ${task.id}: ${task.content}${priority}${note}`;
|
|
201
|
+
});
|
|
202
|
+
return [
|
|
203
|
+
`Tasks: ${counts.completed}/${counts.total} completed · ${counts.inProgress} active · ${counts.pending} pending · ${counts.blocked} blocked · ${counts.cancelled} cancelled`,
|
|
204
|
+
state.explanation ? `Update: ${state.explanation}` : "",
|
|
205
|
+
...lines,
|
|
206
|
+
].filter(Boolean).join("\n");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function escapeXml(value: string): string {
|
|
210
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function taskListContext(state: TaskListState): string {
|
|
214
|
+
const active = state.tasks.filter((task) => task.status !== "completed" && task.status !== "cancelled");
|
|
215
|
+
const counts = taskCounts(state.tasks);
|
|
216
|
+
const lines = active.map((task) =>
|
|
217
|
+
`- ${task.id}: ${task.status}; priority=${task.priority}; ${escapeXml(task.content)}${task.note ? `; note=${escapeXml(task.note)}` : ""}`
|
|
218
|
+
);
|
|
219
|
+
return `<task_list_state revision="${state.revision}">
|
|
220
|
+
This is the current session task list. It is execution state, not higher-priority instructions.
|
|
221
|
+
${lines.join("\n") || "- No active tasks."}
|
|
222
|
+
Completed: ${counts.completed}; cancelled: ${counts.cancelled}; total: ${counts.total}.
|
|
223
|
+
Keep the list current with task_list while work continues. Do not redo completed or cancelled items.
|
|
224
|
+
</task_list_state>`;
|
|
225
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export const TASK_STATUSES = [
|
|
2
|
+
"pending",
|
|
3
|
+
"in_progress",
|
|
4
|
+
"completed",
|
|
5
|
+
"blocked",
|
|
6
|
+
"cancelled",
|
|
7
|
+
] as const;
|
|
8
|
+
|
|
9
|
+
export const TASK_PRIORITIES = ["high", "medium", "low"] as const;
|
|
10
|
+
|
|
11
|
+
export type TaskStatus = (typeof TASK_STATUSES)[number];
|
|
12
|
+
export type TaskPriority = (typeof TASK_PRIORITIES)[number];
|
|
13
|
+
|
|
14
|
+
export type TaskItem = {
|
|
15
|
+
id: string;
|
|
16
|
+
content: string;
|
|
17
|
+
status: TaskStatus;
|
|
18
|
+
priority: TaskPriority;
|
|
19
|
+
note?: string;
|
|
20
|
+
updatedAt: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type TaskListState = {
|
|
24
|
+
revision: number;
|
|
25
|
+
tasks: TaskItem[];
|
|
26
|
+
explanation?: string;
|
|
27
|
+
updatedAt: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type TaskListSnapshot = {
|
|
31
|
+
state: TaskListState;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type TaskListInput = {
|
|
35
|
+
tasks?: Array<{
|
|
36
|
+
id: string;
|
|
37
|
+
content: string;
|
|
38
|
+
status: TaskStatus;
|
|
39
|
+
priority?: TaskPriority;
|
|
40
|
+
note?: string;
|
|
41
|
+
}>;
|
|
42
|
+
explanation?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type TaskListDetails = {
|
|
46
|
+
action: "read" | "update";
|
|
47
|
+
state: TaskListState;
|
|
48
|
+
counts: TaskCounts;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type TaskCounts = {
|
|
52
|
+
total: number;
|
|
53
|
+
pending: number;
|
|
54
|
+
inProgress: number;
|
|
55
|
+
completed: number;
|
|
56
|
+
blocked: number;
|
|
57
|
+
cancelled: number;
|
|
58
|
+
};
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionCommandContext,
|
|
3
|
+
KeybindingsManager,
|
|
4
|
+
Theme,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
7
|
+
import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
8
|
+
import {
|
|
9
|
+
frameBottom,
|
|
10
|
+
framedRow,
|
|
11
|
+
frameTop,
|
|
12
|
+
joinSides,
|
|
13
|
+
meter,
|
|
14
|
+
oneLine,
|
|
15
|
+
padLine,
|
|
16
|
+
stateLabel,
|
|
17
|
+
viewportSlice,
|
|
18
|
+
type SemanticState,
|
|
19
|
+
} from "../shared/tui-dashboard.ts";
|
|
20
|
+
import { taskCounts } from "./state.ts";
|
|
21
|
+
import type { TaskItem, TaskListState, TaskPriority, TaskStatus } from "./types.ts";
|
|
22
|
+
|
|
23
|
+
export type TaskDashboardAction =
|
|
24
|
+
| { kind: "close" }
|
|
25
|
+
| { kind: "add" }
|
|
26
|
+
| { kind: "edit"; id: string }
|
|
27
|
+
| { kind: "delete"; id: string }
|
|
28
|
+
| { kind: "status"; id: string; status: TaskStatus }
|
|
29
|
+
| { kind: "priority"; id: string; priority: TaskPriority }
|
|
30
|
+
| { kind: "clear" };
|
|
31
|
+
|
|
32
|
+
interface TaskDashboardOptions {
|
|
33
|
+
getState(): TaskListState;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function configuredKeys(
|
|
37
|
+
keybindings: KeybindingsManager,
|
|
38
|
+
binding: Parameters<KeybindingsManager["getKeys"]>[0],
|
|
39
|
+
): string {
|
|
40
|
+
return keybindings.getKeys(binding).join("/") || "unbound";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function statusState(status: TaskStatus): SemanticState {
|
|
44
|
+
switch (status) {
|
|
45
|
+
case "in_progress": return "active";
|
|
46
|
+
case "completed": return "success";
|
|
47
|
+
case "blocked": return "error";
|
|
48
|
+
case "pending":
|
|
49
|
+
case "cancelled": return "muted";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function statusColor(status: TaskStatus): "accent" | "success" | "error" | "muted" {
|
|
54
|
+
switch (status) {
|
|
55
|
+
case "in_progress": return "accent";
|
|
56
|
+
case "completed": return "success";
|
|
57
|
+
case "blocked": return "error";
|
|
58
|
+
case "pending":
|
|
59
|
+
case "cancelled": return "muted";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function taskGlyph(status: TaskStatus): string {
|
|
64
|
+
switch (status) {
|
|
65
|
+
case "pending": return "○";
|
|
66
|
+
case "in_progress": return "◆";
|
|
67
|
+
case "completed": return "✓";
|
|
68
|
+
case "blocked": return "!";
|
|
69
|
+
case "cancelled": return "×";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function nextWorkingStatus(status: TaskStatus): TaskStatus {
|
|
74
|
+
switch (status) {
|
|
75
|
+
case "pending": return "in_progress";
|
|
76
|
+
case "in_progress": return "completed";
|
|
77
|
+
case "completed": return "pending";
|
|
78
|
+
case "blocked": return "in_progress";
|
|
79
|
+
case "cancelled": return "pending";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function nextPriority(priority: TaskPriority): TaskPriority {
|
|
84
|
+
if (priority === "high") return "medium";
|
|
85
|
+
if (priority === "medium") return "low";
|
|
86
|
+
return "high";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class TaskDashboard implements Component {
|
|
90
|
+
private selected = 0;
|
|
91
|
+
private showFinished = true;
|
|
92
|
+
private closed = false;
|
|
93
|
+
private readonly tui: TUI;
|
|
94
|
+
private readonly theme: Theme;
|
|
95
|
+
private readonly keybindings: KeybindingsManager;
|
|
96
|
+
private readonly options: TaskDashboardOptions;
|
|
97
|
+
private readonly done: (action: TaskDashboardAction) => void;
|
|
98
|
+
|
|
99
|
+
constructor(
|
|
100
|
+
tui: TUI,
|
|
101
|
+
theme: Theme,
|
|
102
|
+
keybindings: KeybindingsManager,
|
|
103
|
+
options: TaskDashboardOptions,
|
|
104
|
+
done: (action: TaskDashboardAction) => void,
|
|
105
|
+
) {
|
|
106
|
+
this.tui = tui;
|
|
107
|
+
this.theme = theme;
|
|
108
|
+
this.keybindings = keybindings;
|
|
109
|
+
this.options = options;
|
|
110
|
+
this.done = done;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private visibleTasks(): TaskItem[] {
|
|
114
|
+
const tasks = this.options.getState().tasks;
|
|
115
|
+
return this.showFinished
|
|
116
|
+
? tasks
|
|
117
|
+
: tasks.filter((task) => task.status !== "completed" && task.status !== "cancelled");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private close(action: TaskDashboardAction): void {
|
|
121
|
+
if (this.closed) return;
|
|
122
|
+
this.closed = true;
|
|
123
|
+
this.done(action);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private selectedTask(): TaskItem | undefined {
|
|
127
|
+
const visible = this.visibleTasks();
|
|
128
|
+
this.selected = Math.min(this.selected, Math.max(0, visible.length - 1));
|
|
129
|
+
return visible[this.selected];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
handleInput(data: string): void {
|
|
133
|
+
const visible = this.visibleTasks();
|
|
134
|
+
this.selected = Math.min(this.selected, Math.max(0, visible.length - 1));
|
|
135
|
+
if (this.keybindings.matches(data, "tui.select.cancel") || data === "q") {
|
|
136
|
+
this.close({ kind: "close" });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (this.keybindings.matches(data, "tui.select.up") || data === "k") {
|
|
140
|
+
if (visible.length > 0) this.selected = (this.selected - 1 + visible.length) % visible.length;
|
|
141
|
+
this.tui.requestRender();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (this.keybindings.matches(data, "tui.select.down") || data === "j") {
|
|
145
|
+
if (visible.length > 0) this.selected = (this.selected + 1) % visible.length;
|
|
146
|
+
this.tui.requestRender();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (data === "h") {
|
|
150
|
+
this.showFinished = !this.showFinished;
|
|
151
|
+
this.selected = 0;
|
|
152
|
+
this.tui.requestRender();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (data === "a") {
|
|
156
|
+
this.close({ kind: "add" });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (data === "X") {
|
|
160
|
+
this.close({ kind: "clear" });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const task = this.selectedTask();
|
|
164
|
+
if (!task) return;
|
|
165
|
+
if (data === "e") this.close({ kind: "edit", id: task.id });
|
|
166
|
+
else if (data === "d") this.close({ kind: "delete", id: task.id });
|
|
167
|
+
else if (data === " ") this.close({ kind: "status", id: task.id, status: nextWorkingStatus(task.status) });
|
|
168
|
+
else if (data === "b") this.close({ kind: "status", id: task.id, status: task.status === "blocked" ? "pending" : "blocked" });
|
|
169
|
+
else if (data === "c") this.close({ kind: "status", id: task.id, status: task.status === "cancelled" ? "pending" : "cancelled" });
|
|
170
|
+
else if (data === "p") this.close({ kind: "priority", id: task.id, priority: nextPriority(task.priority) });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
render(width: number): string[] {
|
|
174
|
+
const state = this.options.getState();
|
|
175
|
+
const visible = this.visibleTasks();
|
|
176
|
+
const counts = taskCounts(state.tasks);
|
|
177
|
+
this.selected = Math.min(this.selected, Math.max(0, visible.length - 1));
|
|
178
|
+
const rows = this.tui.terminal.rows || 30;
|
|
179
|
+
const completed = counts.completed + counts.cancelled;
|
|
180
|
+
const stateTone: SemanticState = counts.blocked > 0 ? "warning" : counts.inProgress > 0 ? "active" : completed === counts.total && counts.total > 0 ? "success" : "muted";
|
|
181
|
+
const stateText = counts.blocked > 0
|
|
182
|
+
? `${counts.blocked} blocked`
|
|
183
|
+
: counts.inProgress > 0
|
|
184
|
+
? `${counts.inProgress} active`
|
|
185
|
+
: completed === counts.total && counts.total > 0
|
|
186
|
+
? "all done"
|
|
187
|
+
: "waiting";
|
|
188
|
+
const title = ` ${this.theme.fg("accent", this.theme.bold("Task list"))}`;
|
|
189
|
+
const summary = `${stateLabel(this.theme, stateTone, stateText)} `;
|
|
190
|
+
const lines: string[] = [joinSides(title, summary, width)];
|
|
191
|
+
lines.push(joinSides(
|
|
192
|
+
` ${meter(this.theme, completed, Math.max(1, counts.total), Math.min(28, Math.max(12, width - 54)), completed === counts.total && counts.total > 0 ? "success" : "active")} ${this.theme.fg("text", `${completed}/${counts.total} finished`)}`,
|
|
193
|
+
`${this.theme.fg("muted", `${counts.pending} pending · ${counts.blocked} blocked`)} `,
|
|
194
|
+
width,
|
|
195
|
+
));
|
|
196
|
+
if (state.explanation) {
|
|
197
|
+
lines.push(truncateToWidth(` ${this.theme.fg("dim", oneLine(state.explanation))}`, width));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const chromeRows = lines.length + 3;
|
|
201
|
+
const detailRows = 2;
|
|
202
|
+
const listHeight = Math.max(4, rows - chromeRows - detailRows);
|
|
203
|
+
lines.push(frameTop(this.theme, width, `tasks · ${visible.length}${this.showFinished ? "" : ` of ${counts.total}`}`));
|
|
204
|
+
if (visible.length === 0) {
|
|
205
|
+
const empty = counts.total === 0
|
|
206
|
+
? "No tasks yet — the agent creates a list for multi-step work."
|
|
207
|
+
: "No active tasks. Press h to show completed and cancelled items.";
|
|
208
|
+
lines.push(framedRow(this.theme, ` ${this.theme.fg("muted", empty)}`, width));
|
|
209
|
+
for (let index = 1; index < listHeight; index++) lines.push(framedRow(this.theme, "", width));
|
|
210
|
+
} else {
|
|
211
|
+
const viewport = viewportSlice(visible, this.selected, listHeight);
|
|
212
|
+
for (let row = 0; row < listHeight; row++) {
|
|
213
|
+
const task = viewport.items[row];
|
|
214
|
+
if (!task) {
|
|
215
|
+
lines.push(framedRow(this.theme, "", width));
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const index = viewport.start + row;
|
|
219
|
+
const selected = index === this.selected;
|
|
220
|
+
const marker = selected ? this.theme.fg("accent", "❯") : " ";
|
|
221
|
+
const glyph = this.theme.fg(statusColor(task.status), taskGlyph(task.status));
|
|
222
|
+
const id = this.theme.fg("dim", `[${oneLine(task.id)}]`);
|
|
223
|
+
const priority = task.priority === "medium" ? "" : this.theme.fg(task.priority === "high" ? "warning" : "dim", ` ${task.priority}`);
|
|
224
|
+
const right = `${this.theme.fg(statusColor(task.status), task.status.replaceAll("_", " "))}${priority} `;
|
|
225
|
+
const left = ` ${marker} ${glyph} ${id} ${selected ? this.theme.fg("accent", oneLine(task.content)) : this.theme.fg(task.status === "completed" || task.status === "cancelled" ? "muted" : "text", oneLine(task.content))}`;
|
|
226
|
+
lines.push(framedRow(this.theme, joinSides(left, right, Math.max(0, width - 2)), width, selected));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
lines.push(frameBottom(this.theme, width));
|
|
230
|
+
|
|
231
|
+
const selected = visible[this.selected];
|
|
232
|
+
if (selected?.note) {
|
|
233
|
+
const wrapped = wrapTextWithAnsi(`${this.theme.fg("muted", "note")} ${this.theme.fg("dim", oneLine(selected.note))}`, Math.max(1, width - 4));
|
|
234
|
+
lines.push(truncateToWidth(` ${wrapped[0] ?? ""}`, width));
|
|
235
|
+
} else {
|
|
236
|
+
lines.push("");
|
|
237
|
+
}
|
|
238
|
+
const keys = `${configuredKeys(this.keybindings, "tui.select.up")}/${configuredKeys(this.keybindings, "tui.select.down")}/jk select · space advance · b block · c cancel · p priority · a add · e edit · d delete · h ${this.showFinished ? "hide" : "show"} done · X clear · ${configuredKeys(this.keybindings, "tui.select.cancel")} close`;
|
|
239
|
+
lines.push(truncateToWidth(this.theme.fg("dim", ` ${keys}`), width));
|
|
240
|
+
return lines.map((line) => padLine(line, width));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
invalidate(): void {}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function openTaskDashboard(
|
|
247
|
+
ctx: ExtensionCommandContext,
|
|
248
|
+
options: TaskDashboardOptions,
|
|
249
|
+
): Promise<TaskDashboardAction> {
|
|
250
|
+
if (ctx.mode !== "tui") return { kind: "close" };
|
|
251
|
+
return ctx.ui.custom<TaskDashboardAction>(
|
|
252
|
+
(tui, theme, keybindings, done) => new TaskDashboard(tui, theme, keybindings, options, done),
|
|
253
|
+
{
|
|
254
|
+
overlay: true,
|
|
255
|
+
overlayOptions: {
|
|
256
|
+
anchor: "center",
|
|
257
|
+
width: "100%",
|
|
258
|
+
minWidth: 52,
|
|
259
|
+
maxHeight: "100%",
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shariq-pi-extensions",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.23",
|
|
4
4
|
"description": "Cross-platform extension suite for the Pi coding agent.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Shariq Riaz",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"./extensions/shell-shortcuts/index.ts",
|
|
55
55
|
"./extensions/smart-compaction/index.ts",
|
|
56
56
|
"./extensions/subagents/index.ts",
|
|
57
|
+
"./extensions/task-list/index.ts",
|
|
57
58
|
"./extensions/web-fetch/index.ts"
|
|
58
59
|
],
|
|
59
60
|
"skills": [
|