mirror of
https://github.com/wassname/pi-lgtm.git
synced 2026-06-27 17:01:35 +08:00
fix nudge/sysprompt cc like
description in tools fix widget refresh flickering
This commit is contained in:
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.3.1] - 2026-03-16
|
||||
|
||||
### Added
|
||||
- **Local-by-default task persistence** — tasks now auto-persist to `<cwd>/.pi/tasks/tasks.json` on every mutation and reload on restart. No config needed. Set `PI_TASKS=off` to opt out (CI/automation).
|
||||
- **Settings persistence** — `persistTasks` and `autoCascade` settings survive restarts via `<cwd>/.pi/tasks-config.json`.
|
||||
- **"Persist tasks" toggle in Settings** — `/tasks` → Settings now shows two toggles: auto-execute and persist. Both are saved immediately to `tasks-config.json`.
|
||||
- **Completed tasks excluded from disk** — only `pending` and `in_progress` tasks are written to disk. Completed tasks are in-memory only and pruned on restart.
|
||||
- **Absolute path support** — `TaskStore` now accepts an absolute file path in addition to a short list ID.
|
||||
|
||||
### Changed
|
||||
- **⚠ BREAKING: `PI_TASKS_FILE` / `PI_TASKS_LIST` → `PI_TASKS`** — two env vars consolidated into one. Values: `off` (in-memory), `sprint-1` (named list → `~/.pi/tasks/sprint-1.json`), `/abs/path` (absolute), `./rel/path` (relative to cwd). `PI_TASKS_LIST=name` users: rename to `PI_TASKS=name`.
|
||||
- **Settings menu** — extracted to `src/ui/settings-menu.ts` and rebuilt using `ui.custom()` + `SettingsList` for native TUI rendering: keyboard navigation, live toggle, per-row descriptions, theme-consistent styling.
|
||||
- **`autoCascade` setting** — now loaded from `tasks-config.json` on startup so the toggle survives restarts.
|
||||
- **Hardened `TaskUpdate` description** — added "Before starting work on a task: mark it `in_progress` BEFORE beginning" as an explicit use case. Previously this rule only appeared in `TaskCreate`; now it lives in the tool actually used to set that status.
|
||||
- **Removed `before_agent_start` system prompt injection** — task state is no longer injected into the system prompt on every agent loop. Analysis showed this creates wallpaper noise that trains the model to ignore the task block. Claude Code itself does not do this: the workflow contract lives in tool descriptions (read at decision time) and the periodic `<system-reminder>` nudge (fired when task tools haven't been used recently). Removed the corresponding 3 tests.
|
||||
- **Widget render-once refactor** — `TaskWidget` now registers the widget callback a single time and uses `tui.requestRender()` for subsequent updates instead of calling `setWidget()` on every tick. Rendering logic extracted to `renderWidget()`. Eliminates redundant callback re-registration and keeps a cached `tui` reference for lightweight invalidation.
|
||||
|
||||
## [0.3.0] - 2026-03-14
|
||||
|
||||
### Changed
|
||||
@@ -51,6 +68,7 @@ Initial release — Claude Code-style task tracking and coordination for pi.
|
||||
- **Background process tracker** — output buffering (stdout + stderr), waiter notification, graceful stop with timeout escalation (SIGTERM → 5s → SIGKILL).
|
||||
- **78 unit tests** — task store CRUD, dependencies, warnings, file persistence; widget rendering, icons, spinners, token/duration formatting; process tracker lifecycle.
|
||||
|
||||
[0.3.1]: https://github.com/tintinweb/pi-tasks/releases/tag/v0.3.1
|
||||
[0.3.0]: https://github.com/tintinweb/pi-tasks/releases/tag/v0.3.0
|
||||
[0.2.0]: https://github.com/tintinweb/pi-tasks/releases/tag/v0.2.0
|
||||
[0.1.0]: https://github.com/tintinweb/pi-tasks/releases/tag/v0.1.0
|
||||
|
||||
@@ -14,9 +14,8 @@ https://github.com/user-attachments/assets/1d0ee87a-e0a5-4bfa-a9b9-2f9144cb905b
|
||||
|
||||
- **7 LLM-callable tools** — `TaskCreate`, `TaskList`, `TaskGet`, `TaskUpdate`, `TaskOutput`, `TaskStop`, `TaskExecute` — matching Claude Code's exact tool specs and descriptions
|
||||
- **Persistent widget** — live task list above the editor with `✔`/`◼`/`◻` status icons, strikethrough for completed tasks, star spinner (`✳✽`) for active tasks with elapsed time and token counts
|
||||
- **System-reminder injection** — periodic `<system-reminder>` nudges appended to tool results when task tools haven't been used recently (matches Claude Code's behavior)
|
||||
- **Task state persistence** — current task state injected into system prompt on every agent loop, surviving context compaction
|
||||
- **Prompt guidelines** — system prompt guidelines nudge the LLM to use task tools for complex work
|
||||
- **System-reminder injection** — periodic `<system-reminder>` nudges appended to tool results when task tools haven't been used recently (matches Claude Code's behavior exactly)
|
||||
- **Prompt guidelines** — workflow contract encoded in tool descriptions, nudging the LLM at the point of tool use
|
||||
- **Dependency management** — bidirectional `blocks`/`blockedBy` relationships with warnings for cycles, self-deps, and dangling references
|
||||
- **Shared task lists** — multiple pi sessions can share a file-backed task list for agent team coordination
|
||||
- **File locking** — concurrent access is safe when multiple sessions share a task list
|
||||
@@ -177,17 +176,33 @@ Tasks are created as `pending`. Mark `in_progress` before starting work, `comple
|
||||
- **Raw data preserved:** `TaskGet` shows ALL edges, including completed blockers
|
||||
- **Cleanup on deletion:** removing a task cleans up all edges pointing to it
|
||||
|
||||
## Shared Task Lists
|
||||
## Task Persistence
|
||||
|
||||
Set `PI_TASK_LIST_ID` to enable file-backed storage for agent team coordination:
|
||||
By default tasks persist locally to `<cwd>/.pi/tasks/tasks.json` and reload on restart. Only `pending` and `in_progress` tasks are saved — completed tasks are in-memory only and pruned automatically.
|
||||
|
||||
Settings (`autoCascade`, `persistTasks`) are saved to `<cwd>/.pi/tasks-config.json`.
|
||||
|
||||
### Override via environment variables
|
||||
|
||||
| Variable | Value | Behaviour |
|
||||
|----------|-------|-----------|
|
||||
| `PI_TASKS` | `off` | In-memory only (CI/automation) |
|
||||
| `PI_TASKS` | `sprint-1` | Named shared list at `~/.pi/tasks/sprint-1.json` |
|
||||
| `PI_TASKS` | `/abs/path/tasks.json` | Explicit absolute file path |
|
||||
| `PI_TASKS` | `./tasks.json` | Relative path resolved from cwd |
|
||||
| *(unset)* | | Default local path `<cwd>/.pi/tasks/tasks.json` |
|
||||
|
||||
Named and explicit paths use a file-locked store with stale-lock detection — safe for multiple pi sessions coordinating on the same task list.
|
||||
|
||||
**CI example** (`.envrc`):
|
||||
```bash
|
||||
PI_TASK_LIST_ID=my-project pi
|
||||
export PI_TASKS=off
|
||||
```
|
||||
|
||||
Tasks persist at `~/.pi/tasks/my-project.json`. Multiple sessions sharing the same ID read/write the same list with file locking (`.lock` files with stale-lock detection).
|
||||
|
||||
Without the env var, tasks are session-scoped (in-memory only).
|
||||
**Shared team list** (`.envrc`):
|
||||
```bash
|
||||
export PI_TASKS=my-project
|
||||
```
|
||||
|
||||
## `/tasks` Command
|
||||
|
||||
@@ -203,7 +218,7 @@ Tasks
|
||||
|
||||
- **View all tasks** — select a task to see details and take actions (start, complete, delete)
|
||||
- **Create task** — input prompts for subject and description
|
||||
- **Settings** — toggle auto-cascade (auto-execute unblocked agent tasks on completion)
|
||||
- **Settings** — toggle auto-cascade and task persistence (both survive restarts via `tasks-config.json`)
|
||||
- **Clear completed** — remove all completed tasks
|
||||
|
||||
## Cross-extension Communication with [`@tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents)
|
||||
@@ -261,11 +276,13 @@ If [`pi-subagents`](https://github.com/tintinweb/pi-subagents) is not installed,
|
||||
```
|
||||
src/
|
||||
├── index.ts # Extension entry: 7 tools + /tasks command + widget + subagent integration
|
||||
├── types.ts # Task, TaskStatus, BackgroundProcess, SubagentBridge types
|
||||
├── types.ts # Task, TaskStatus, BackgroundProcess types
|
||||
├── task-store.ts # File-backed store with CRUD, dependencies, locking
|
||||
├── tasks-config.ts # Config persistence (persistTasks, autoCascade) → .pi/tasks-config.json
|
||||
├── process-tracker.ts # Background process output buffering and stop
|
||||
└── ui/
|
||||
└── task-widget.ts # Persistent widget with status icons and spinner
|
||||
├── task-widget.ts # Persistent widget with status icons and spinner
|
||||
└── settings-menu.ts # /tasks → Settings panel (SettingsList TUI component)
|
||||
```
|
||||
|
||||
## Future Work
|
||||
@@ -277,7 +294,7 @@ src/
|
||||
```bash
|
||||
npm install
|
||||
npm run typecheck # TypeScript validation
|
||||
npm test # Run unit tests (102 tests)
|
||||
npm test # Run unit tests (116 tests)
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@tintinweb/pi-tasks",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@tintinweb/pi-tasks",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-coding-agent": "^0.57.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@tintinweb/pi-tasks",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "A pi extension that brings Claude Code-style task tracking and coordination to pi.",
|
||||
"author": "tintinweb",
|
||||
"license": "MIT",
|
||||
|
||||
+22
-54
@@ -19,7 +19,10 @@ import { Type } from "@sinclair/typebox";
|
||||
import { TaskStore } from "./task-store.js";
|
||||
import { ProcessTracker } from "./process-tracker.js";
|
||||
import { TaskWidget, type UICtx } from "./ui/task-widget.js";
|
||||
import { loadTasksConfig } from "./tasks-config.js";
|
||||
import { openSettingsMenu } from "./ui/settings-menu.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
@@ -38,14 +41,22 @@ The task tools haven't been used recently. If you're working on tasks that would
|
||||
</system-reminder>`;
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// Initialize store: use PI_TASK_LIST_ID for shared/file-backed mode
|
||||
const listId = process.env.PI_TASK_LIST_ID;
|
||||
const store = new TaskStore(listId);
|
||||
// Initialize store and config
|
||||
const cfg = loadTasksConfig();
|
||||
const piTasks = process.env.PI_TASKS;
|
||||
const localTasksPath = join(process.cwd(), ".pi", "tasks", "tasks.json");
|
||||
const store =
|
||||
piTasks === "off" ? new TaskStore() :
|
||||
piTasks?.startsWith("/") ? new TaskStore(piTasks) :
|
||||
piTasks?.startsWith(".") ? new TaskStore(resolve(piTasks)) :
|
||||
piTasks ? new TaskStore(piTasks) :
|
||||
cfg.persistTasks === false ? new TaskStore() :
|
||||
new TaskStore(localTasksPath);
|
||||
|
||||
const tracker = new ProcessTracker();
|
||||
const widget = new TaskWidget(store);
|
||||
|
||||
// ── Subagent integration state ──
|
||||
let autoCascadeEnabled = false;
|
||||
/** Latest ExtensionContext — refreshed on every tool execution so cascade always has a valid one. */
|
||||
let latestCtx: ExtensionContext | undefined;
|
||||
/** Cascade config — set by TaskExecute, consumed by completion listener. */
|
||||
@@ -111,7 +122,7 @@ export default function (pi: ExtensionAPI) {
|
||||
widget.setActiveTask(task.id, false);
|
||||
|
||||
// Auto-cascade: find unblocked dependents with agentType
|
||||
if (autoCascadeEnabled && cascadeConfig && latestCtx) {
|
||||
if ((cfg.autoCascade ?? false) && cascadeConfig && latestCtx) {
|
||||
const unblocked = store.list().filter(t =>
|
||||
t.status === "pending" &&
|
||||
t.metadata?.agentType &&
|
||||
@@ -199,44 +210,6 @@ export default function (pi: ExtensionAPI) {
|
||||
};
|
||||
});
|
||||
|
||||
// ── Task state in system prompt ──
|
||||
// Appends current task state to the system prompt on every agent loop.
|
||||
// Ensures the LLM always has task awareness — especially important after
|
||||
// context compaction, when prior task tool results may have been dropped.
|
||||
pi.on("before_agent_start", async (event) => {
|
||||
const tasks = store.list();
|
||||
if (tasks.length === 0) return {};
|
||||
|
||||
const taskSummary = tasks.map(t => {
|
||||
let line = `#${t.id} [${t.status}] ${t.subject}`;
|
||||
if (t.owner) line += ` (${t.owner})`;
|
||||
if (t.blockedBy.length > 0) {
|
||||
const openBlockers = t.blockedBy.filter(bid => {
|
||||
const blocker = store.get(bid);
|
||||
return blocker && blocker.status !== "completed";
|
||||
});
|
||||
if (openBlockers.length > 0) {
|
||||
line += ` [blocked by ${openBlockers.map(id => "#" + id).join(", ")}]`;
|
||||
}
|
||||
}
|
||||
// Mark agent-typed pending tasks with all deps completed as READY
|
||||
if (t.status === "pending" && t.metadata?.agentType) {
|
||||
const allDepsCompleted = t.blockedBy.length === 0 || t.blockedBy.every(bid => {
|
||||
const blocker = store.get(bid);
|
||||
return blocker && blocker.status === "completed";
|
||||
});
|
||||
if (allDepsCompleted) {
|
||||
line += ` [READY — use TaskExecute to start]`;
|
||||
}
|
||||
}
|
||||
return line;
|
||||
}).join("\n");
|
||||
|
||||
return {
|
||||
systemPrompt: event.systemPrompt + `\n\n<task-state>\nCurrent tasks:\n${taskSummary}\n</task-state>`,
|
||||
};
|
||||
});
|
||||
|
||||
// Grab UI + extension context from every tool execution
|
||||
pi.on("tool_execution_start", async (_event, ctx) => {
|
||||
latestCtx = ctx;
|
||||
@@ -450,6 +423,10 @@ Returns full task details:
|
||||
|
||||
## When to Use This Tool
|
||||
|
||||
**Before starting work on a task:**
|
||||
- Mark it in_progress BEFORE beginning — do not start work without updating status first
|
||||
- After resolving, call TaskList to find your next task
|
||||
|
||||
**Mark tasks as resolved:**
|
||||
- When you have completed the work described in a task
|
||||
- When a task is no longer needed or has been superseded
|
||||
@@ -842,17 +819,8 @@ Set up task dependencies:
|
||||
return viewTasks();
|
||||
};
|
||||
|
||||
const settingsMenu = async (): Promise<void> => {
|
||||
const cascadeLabel = `Auto-execute tasks with agents: ${autoCascadeEnabled ? "ON" : "OFF"}`;
|
||||
const choices = [cascadeLabel, "← Back"];
|
||||
const selected = await ui.select("Settings", choices);
|
||||
if (!selected || selected === "← Back") return mainMenu();
|
||||
if (selected === cascadeLabel) {
|
||||
autoCascadeEnabled = !autoCascadeEnabled;
|
||||
return settingsMenu();
|
||||
}
|
||||
return mainMenu();
|
||||
};
|
||||
const settingsMenu = (): Promise<void> =>
|
||||
openSettingsMenu(ui, cfg, mainMenu);
|
||||
|
||||
const createTask = async (): Promise<void> => {
|
||||
const subject = await ui.input("Task subject");
|
||||
|
||||
+11
-12
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { join, dirname, isAbsolute } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import type { Task, TaskStatus, TaskStoreData } from "./types.js";
|
||||
|
||||
@@ -51,7 +51,6 @@ function isProcessRunning(pid: number): boolean {
|
||||
}
|
||||
|
||||
export class TaskStore {
|
||||
private listId: string | undefined;
|
||||
private filePath: string | undefined;
|
||||
private lockPath: string | undefined;
|
||||
|
||||
@@ -59,14 +58,14 @@ export class TaskStore {
|
||||
private nextId = 1;
|
||||
private tasks = new Map<string, Task>();
|
||||
|
||||
constructor(listId?: string) {
|
||||
this.listId = listId;
|
||||
if (listId) {
|
||||
mkdirSync(TASKS_DIR, { recursive: true });
|
||||
this.filePath = join(TASKS_DIR, `${listId}.json`);
|
||||
this.lockPath = this.filePath + ".lock";
|
||||
this.load();
|
||||
}
|
||||
constructor(listIdOrPath?: string) {
|
||||
if (!listIdOrPath) return;
|
||||
const isAbsPath = isAbsolute(listIdOrPath);
|
||||
const filePath = isAbsPath ? listIdOrPath : join(TASKS_DIR, `${listIdOrPath}.json`);
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
this.filePath = filePath;
|
||||
this.lockPath = filePath + ".lock";
|
||||
this.load();
|
||||
}
|
||||
|
||||
/** Read store from disk (file-backed mode only). */
|
||||
@@ -83,12 +82,12 @@ export class TaskStore {
|
||||
} catch { /* corrupt file — start fresh */ }
|
||||
}
|
||||
|
||||
/** Write store to disk atomically (file-backed mode only). */
|
||||
/** Write store to disk atomically (file-backed mode only). Completed tasks are not persisted. */
|
||||
private save(): void {
|
||||
if (!this.filePath) return;
|
||||
const data: TaskStoreData = {
|
||||
nextId: this.nextId,
|
||||
tasks: Array.from(this.tasks.values()),
|
||||
tasks: Array.from(this.tasks.values()).filter(t => t.status !== "completed"),
|
||||
};
|
||||
const tmpPath = this.filePath + ".tmp";
|
||||
writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// <cwd>/.pi/tasks-config.json — persists extension settings across sessions
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
export interface TasksConfig {
|
||||
persistTasks?: boolean; // default: true
|
||||
autoCascade?: boolean; // default: false
|
||||
}
|
||||
|
||||
const CONFIG_PATH = join(process.cwd(), ".pi", "tasks-config.json");
|
||||
|
||||
export function loadTasksConfig(): TasksConfig {
|
||||
try {
|
||||
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
export function saveTasksConfig(config: TasksConfig): void {
|
||||
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
||||
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* settings-menu.ts — Polished settings panel for /tasks → Settings.
|
||||
*
|
||||
* Uses ui.custom() + SettingsList for native TUI rendering with keyboard
|
||||
* navigation, live toggle, and per-row descriptions — matching pi-coding-agent's
|
||||
* own settings panel style.
|
||||
*/
|
||||
|
||||
import { SettingsList, Container, Text, Spacer, type SettingItem } from "@mariozechner/pi-tui";
|
||||
import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
|
||||
import { saveTasksConfig, type TasksConfig } from "../tasks-config.js";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type SettingsUI = {
|
||||
custom<T>(
|
||||
factory: (tui: any, theme: any, keybindings: any, done: (result: T) => void) => any,
|
||||
options?: { overlay?: boolean; overlayOptions?: any },
|
||||
): Promise<T>;
|
||||
};
|
||||
|
||||
// ── Settings panel ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function openSettingsMenu(
|
||||
ui: SettingsUI,
|
||||
cfg: TasksConfig,
|
||||
onBack: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
await ui.custom((_tui, theme, _kb, done) => {
|
||||
const items: SettingItem[] = [
|
||||
{
|
||||
id: "autoCascade",
|
||||
label: "Auto-execute with agents",
|
||||
description:
|
||||
"When ON: pending agent tasks start automatically once their dependencies complete. " +
|
||||
"When OFF: use TaskExecute to launch them manually.",
|
||||
currentValue: (cfg.autoCascade ?? false) ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
},
|
||||
{
|
||||
id: "persist",
|
||||
label: "Persist tasks across sessions",
|
||||
description:
|
||||
"When ON: pending and in-progress tasks are saved to .pi/tasks/tasks.json so they " +
|
||||
"survive a restart. Completed tasks are never written to disk. " +
|
||||
"Toggle takes effect on next session start.",
|
||||
currentValue: (cfg.persistTasks ?? true) ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
},
|
||||
];
|
||||
|
||||
const list = new SettingsList(
|
||||
items,
|
||||
/* maxVisible */ 10,
|
||||
getSettingsListTheme(),
|
||||
/* onChange */ (id, newValue) => {
|
||||
if (id === "autoCascade") {
|
||||
cfg.autoCascade = newValue === "on";
|
||||
saveTasksConfig(cfg);
|
||||
}
|
||||
if (id === "persist") {
|
||||
cfg.persistTasks = newValue === "on";
|
||||
saveTasksConfig(cfg);
|
||||
}
|
||||
},
|
||||
/* onCancel */ () => done(undefined),
|
||||
);
|
||||
|
||||
// Container doesn't forward handleInput to children — subclass to fix.
|
||||
class SettingsPanel extends Container {
|
||||
handleInput(data: string) { list.handleInput(data); }
|
||||
}
|
||||
|
||||
const root = new SettingsPanel();
|
||||
root.addChild(new Text(theme.bold(theme.fg("accent", "⚙ Task Settings")), 0, 0));
|
||||
root.addChild(new Spacer(1));
|
||||
root.addChild(list);
|
||||
|
||||
return root;
|
||||
});
|
||||
|
||||
return onBack();
|
||||
}
|
||||
+106
-82
@@ -68,6 +68,10 @@ export class TaskWidget {
|
||||
private activeTaskIds = new Set<string>();
|
||||
/** Per-task runtime metrics keyed by task ID. */
|
||||
private metrics = new Map<string, TaskMetrics>();
|
||||
/** Cached TUI instance for requestRender() calls. */
|
||||
private tui: any | undefined;
|
||||
/** Whether the widget callback is currently registered. */
|
||||
private widgetRegistered = false;
|
||||
|
||||
constructor(private store: TaskStore) {}
|
||||
|
||||
@@ -108,13 +112,101 @@ export class TaskWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/** Build widget lines from current live state. Called from the render callback. */
|
||||
private renderWidget(tui: any, theme: Theme): string[] {
|
||||
const tasks = this.store.list();
|
||||
const w = tui.terminal.columns;
|
||||
const truncate = (line: string) => truncateToWidth(line, w);
|
||||
|
||||
if (tasks.length === 0) return [];
|
||||
|
||||
const completed = tasks.filter(t => t.status === "completed");
|
||||
const inProgress = tasks.filter(t => t.status === "in_progress");
|
||||
const pending = tasks.filter(t => t.status === "pending");
|
||||
|
||||
const parts: string[] = [];
|
||||
if (completed.length > 0) parts.push(`${completed.length} done`);
|
||||
if (inProgress.length > 0) parts.push(`${inProgress.length} in progress`);
|
||||
if (pending.length > 0) parts.push(`${pending.length} open`);
|
||||
const statusText = `${tasks.length} tasks (${parts.join(", ")})`;
|
||||
|
||||
const spinnerChar = SPINNER[this.widgetFrame % SPINNER.length];
|
||||
const lines: string[] = [truncate(theme.fg("accent", "●") + " " + theme.fg("accent", statusText))];
|
||||
|
||||
const visible = tasks.slice(0, MAX_VISIBLE_TASKS);
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const task = visible[i];
|
||||
const isActive = this.activeTaskIds.has(task.id) && task.status === "in_progress";
|
||||
|
||||
let icon: string;
|
||||
if (isActive) {
|
||||
icon = theme.fg("accent", spinnerChar);
|
||||
} else if (task.status === "completed") {
|
||||
icon = theme.fg("success", "✔");
|
||||
} else if (task.status === "in_progress") {
|
||||
icon = theme.fg("accent", "◼");
|
||||
} else {
|
||||
icon = "◻";
|
||||
}
|
||||
|
||||
let suffix = "";
|
||||
if (task.status === "pending" && task.blockedBy.length > 0) {
|
||||
const openBlockers = task.blockedBy.filter(bid => {
|
||||
const blocker = this.store.get(bid);
|
||||
return blocker && blocker.status !== "completed";
|
||||
});
|
||||
if (openBlockers.length > 0) {
|
||||
suffix = theme.fg("dim", ` › blocked by ${openBlockers.map(id => "#" + id).join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
let text: string;
|
||||
if (isActive) {
|
||||
const form = task.activeForm || task.subject;
|
||||
const agentId = task.metadata?.agentId;
|
||||
const agentLabel = agentId ? ` (agent ${agentId.slice(0, 5)})` : "";
|
||||
const m = this.metrics.get(task.id);
|
||||
let stats = "";
|
||||
if (m) {
|
||||
const elapsed = formatDuration(Date.now() - m.startedAt);
|
||||
const tokenParts: string[] = [];
|
||||
if (m.inputTokens > 0) tokenParts.push(`↑ ${formatTokens(m.inputTokens)}`);
|
||||
if (m.outputTokens > 0) tokenParts.push(`↓ ${formatTokens(m.outputTokens)}`);
|
||||
stats = tokenParts.length > 0
|
||||
? ` ${theme.fg("dim", `(${elapsed} · ${tokenParts.join(" ")})`)}`
|
||||
: ` ${theme.fg("dim", `(${elapsed})`)}`;
|
||||
}
|
||||
text = ` ${icon} ${theme.fg("accent", form + agentLabel + "…")}${stats}`;
|
||||
} else if (task.status === "completed") {
|
||||
text = ` ${icon} ${theme.fg("dim", theme.strikethrough(task.subject))}`;
|
||||
} else {
|
||||
const agentSuffix = task.status === "in_progress" && task.metadata?.agentId
|
||||
? theme.fg("dim", ` (agent ${task.metadata.agentId.slice(0, 5)})`)
|
||||
: "";
|
||||
text = ` ${icon} ${task.subject}${agentSuffix}`;
|
||||
}
|
||||
|
||||
lines.push(truncate(text + suffix));
|
||||
}
|
||||
|
||||
if (tasks.length > MAX_VISIBLE_TASKS) {
|
||||
lines.push(truncate(theme.fg("dim", ` … and ${tasks.length - MAX_VISIBLE_TASKS} more`)));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Force an immediate widget update. */
|
||||
update() {
|
||||
if (!this.uiCtx) return;
|
||||
const tasks = this.store.list();
|
||||
|
||||
// Transition: visible → hidden
|
||||
if (tasks.length === 0) {
|
||||
this.uiCtx.setWidget("tasks", undefined);
|
||||
if (this.widgetRegistered) {
|
||||
this.uiCtx.setWidget("tasks", undefined);
|
||||
this.widgetRegistered = false;
|
||||
}
|
||||
if (this.widgetInterval) {
|
||||
clearInterval(this.widgetInterval);
|
||||
this.widgetInterval = undefined;
|
||||
@@ -122,17 +214,6 @@ export class TaskWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
const completed = tasks.filter(t => t.status === "completed");
|
||||
const inProgress = tasks.filter(t => t.status === "in_progress");
|
||||
const pending = tasks.filter(t => t.status === "pending");
|
||||
|
||||
// Status summary (widget header only, not status bar)
|
||||
const parts: string[] = [];
|
||||
if (completed.length > 0) parts.push(`${completed.length} done`);
|
||||
if (inProgress.length > 0) parts.push(`${inProgress.length} in progress`);
|
||||
if (pending.length > 0) parts.push(`${pending.length} open`);
|
||||
const statusText = `${tasks.length} tasks (${parts.join(", ")})`;
|
||||
|
||||
// Prune stale active IDs (deleted or no longer in_progress)
|
||||
for (const id of this.activeTaskIds) {
|
||||
const t = this.store.get(id);
|
||||
@@ -152,77 +233,18 @@ export class TaskWidget {
|
||||
}
|
||||
|
||||
this.widgetFrame++;
|
||||
const spinnerChar = SPINNER[this.widgetFrame % SPINNER.length];
|
||||
|
||||
this.uiCtx.setWidget("tasks", (tui, theme) => {
|
||||
const w = tui.terminal.columns;
|
||||
const truncate = (line: string) => truncateToWidth(line, w);
|
||||
|
||||
const lines: string[] = [truncate(theme.fg("accent", "●") + " " + theme.fg("accent", statusText))];
|
||||
|
||||
const visible = tasks.slice(0, MAX_VISIBLE_TASKS);
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const task = visible[i];
|
||||
const isActive = this.activeTaskIds.has(task.id) && task.status === "in_progress";
|
||||
|
||||
let icon: string;
|
||||
if (isActive) {
|
||||
icon = theme.fg("accent", spinnerChar);
|
||||
} else if (task.status === "completed") {
|
||||
icon = theme.fg("success", "✔");
|
||||
} else if (task.status === "in_progress") {
|
||||
icon = theme.fg("accent", "◼");
|
||||
} else {
|
||||
icon = "◻";
|
||||
}
|
||||
|
||||
let suffix = "";
|
||||
// Show blocked-by info for pending tasks (only non-completed blockers)
|
||||
if (task.status === "pending" && task.blockedBy.length > 0) {
|
||||
const openBlockers = task.blockedBy.filter(bid => {
|
||||
const blocker = this.store.get(bid);
|
||||
return blocker && blocker.status !== "completed";
|
||||
});
|
||||
if (openBlockers.length > 0) {
|
||||
suffix = theme.fg("dim", ` › blocked by ${openBlockers.map(id => "#" + id).join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
let text: string;
|
||||
if (isActive) {
|
||||
const form = task.activeForm || task.subject;
|
||||
const agentId = task.metadata?.agentId;
|
||||
const agentLabel = agentId ? ` (agent ${agentId.slice(0, 5)})` : "";
|
||||
const m = this.metrics.get(task.id);
|
||||
let stats = "";
|
||||
if (m) {
|
||||
const elapsed = formatDuration(Date.now() - m.startedAt);
|
||||
const tokenParts: string[] = [];
|
||||
if (m.inputTokens > 0) tokenParts.push(`↑ ${formatTokens(m.inputTokens)}`);
|
||||
if (m.outputTokens > 0) tokenParts.push(`↓ ${formatTokens(m.outputTokens)}`);
|
||||
stats = tokenParts.length > 0
|
||||
? ` ${theme.fg("dim", `(${elapsed} · ${tokenParts.join(" ")})`)}`
|
||||
: ` ${theme.fg("dim", `(${elapsed})`)}`;
|
||||
}
|
||||
text = ` ${icon} ${theme.fg("accent", form + agentLabel + "…")}${stats}`;
|
||||
} else if (task.status === "completed") {
|
||||
text = ` ${icon} ${theme.fg("dim", theme.strikethrough(task.subject))}`;
|
||||
} else {
|
||||
const agentSuffix = task.status === "in_progress" && task.metadata?.agentId
|
||||
? theme.fg("dim", ` (agent ${task.metadata.agentId.slice(0, 5)})`)
|
||||
: "";
|
||||
text = ` ${icon} ${task.subject}${agentSuffix}`;
|
||||
}
|
||||
|
||||
lines.push(truncate(text + suffix));
|
||||
}
|
||||
|
||||
if (tasks.length > MAX_VISIBLE_TASKS) {
|
||||
lines.push(truncate(theme.fg("dim", ` … and ${tasks.length - MAX_VISIBLE_TASKS} more`)));
|
||||
}
|
||||
|
||||
return { render: () => lines, invalidate: () => {} };
|
||||
}, { placement: "aboveEditor" });
|
||||
// Transition: hidden → visible — register widget callback once
|
||||
if (!this.widgetRegistered) {
|
||||
this.uiCtx.setWidget("tasks", (tui, theme) => {
|
||||
this.tui = tui;
|
||||
return { render: () => this.renderWidget(tui, theme), invalidate: () => {} };
|
||||
}, { placement: "aboveEditor" });
|
||||
this.widgetRegistered = true;
|
||||
} else if (this.tui) {
|
||||
// Widget already registered — just request a re-render
|
||||
this.tui.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
@@ -233,5 +255,7 @@ export class TaskWidget {
|
||||
if (this.uiCtx) {
|
||||
this.uiCtx.setWidget("tasks", undefined);
|
||||
}
|
||||
this.widgetRegistered = false;
|
||||
this.tui = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ import { TaskStore } from "../src/task-store.js";
|
||||
import { TaskWidget, type UICtx, type Theme } from "../src/ui/task-widget.js";
|
||||
import initExtension from "../src/index.js";
|
||||
|
||||
// Force in-memory task store for all integration tests — prevents file-backed
|
||||
// store from loading stale tasks across test instances.
|
||||
beforeEach(() => { process.env.PI_TASKS = "off"; });
|
||||
afterEach(() => { delete process.env.PI_TASKS; });
|
||||
|
||||
// ---- Mock pi ----
|
||||
|
||||
/** Minimal mock of ExtensionAPI with events, tool capture, and event hooks. */
|
||||
@@ -428,80 +433,6 @@ describe("Auto-cascade", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("System prompt READY tags", () => {
|
||||
it("marks unblocked agent-typed pending tasks as READY", async () => {
|
||||
// Capture return values from lifecycle handlers
|
||||
const lifecycleHandlers: Array<(...args: any[]) => any> = [];
|
||||
const mock = mockPi();
|
||||
const origOn = mock.pi.on.bind(mock.pi);
|
||||
mock.pi.on = ((event: string, handler: any) => {
|
||||
origOn(event, handler);
|
||||
if (event === "before_agent_start") lifecycleHandlers.push(handler);
|
||||
}) as any;
|
||||
|
||||
initExtension(mock.pi as any);
|
||||
|
||||
await mock.executeTool("TaskCreate", {
|
||||
subject: "Ready task",
|
||||
description: "Desc",
|
||||
agentType: "general-purpose",
|
||||
});
|
||||
|
||||
// Call the before_agent_start handler directly to get its return value
|
||||
const result = await lifecycleHandlers[0]({ systemPrompt: "base" });
|
||||
expect(result.systemPrompt).toContain("[READY — use TaskExecute to start]");
|
||||
expect(result.systemPrompt).toContain("Ready task");
|
||||
});
|
||||
|
||||
it("does not mark blocked agent tasks as READY", async () => {
|
||||
const lifecycleHandlers: Array<(...args: any[]) => any> = [];
|
||||
const mock = mockPi();
|
||||
const origOn = mock.pi.on.bind(mock.pi);
|
||||
mock.pi.on = ((event: string, handler: any) => {
|
||||
origOn(event, handler);
|
||||
if (event === "before_agent_start") lifecycleHandlers.push(handler);
|
||||
}) as any;
|
||||
|
||||
initExtension(mock.pi as any);
|
||||
|
||||
await mock.executeTool("TaskCreate", {
|
||||
subject: "Blocker",
|
||||
description: "Desc",
|
||||
agentType: "general-purpose",
|
||||
});
|
||||
await mock.executeTool("TaskCreate", {
|
||||
subject: "Blocked task",
|
||||
description: "Desc",
|
||||
agentType: "general-purpose",
|
||||
});
|
||||
await mock.executeTool("TaskUpdate", { taskId: "2", addBlockedBy: ["1"] });
|
||||
|
||||
const result = await lifecycleHandlers[0]({ systemPrompt: "base" });
|
||||
// Task 1 should be READY, task 2 should NOT
|
||||
expect(result.systemPrompt).toContain("#1 [pending] Blocker [READY");
|
||||
expect(result.systemPrompt).not.toContain("#2 [pending] Blocked task [READY");
|
||||
});
|
||||
|
||||
it("does not mark tasks without agentType as READY", async () => {
|
||||
const lifecycleHandlers: Array<(...args: any[]) => any> = [];
|
||||
const mock = mockPi();
|
||||
const origOn = mock.pi.on.bind(mock.pi);
|
||||
mock.pi.on = ((event: string, handler: any) => {
|
||||
origOn(event, handler);
|
||||
if (event === "before_agent_start") lifecycleHandlers.push(handler);
|
||||
}) as any;
|
||||
|
||||
initExtension(mock.pi as any);
|
||||
|
||||
await mock.executeTool("TaskCreate", {
|
||||
subject: "Manual task",
|
||||
description: "Desc",
|
||||
});
|
||||
|
||||
const result = await lifecycleHandlers[0]({ systemPrompt: "base" });
|
||||
expect(result.systemPrompt).not.toContain("READY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Standalone operation (no subagents extension)", () => {
|
||||
let mock: ReturnType<typeof mockPi>;
|
||||
|
||||
+62
-3
@@ -1,8 +1,9 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { TaskStore } from "../src/task-store.js";
|
||||
import { existsSync, rmSync, mkdirSync } from "node:fs";
|
||||
import { existsSync, rmSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
describe("TaskStore (in-memory)", () => {
|
||||
let store: TaskStore;
|
||||
@@ -337,13 +338,41 @@ describe("TaskStore (file-backed)", () => {
|
||||
expect(tasks[0].subject).toBe("Persistent task");
|
||||
});
|
||||
|
||||
it("persists updates to disk", () => {
|
||||
it("persists in_progress updates to disk", () => {
|
||||
const store1 = new TaskStore(testListId);
|
||||
store1.create("Task", "Desc");
|
||||
store1.update("1", { status: "in_progress" });
|
||||
|
||||
const store2 = new TaskStore(testListId);
|
||||
expect(store2.get("1")!.status).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("does not persist completed tasks to disk", () => {
|
||||
const store1 = new TaskStore(testListId);
|
||||
store1.create("Done task", "Desc");
|
||||
store1.create("Pending task", "Desc");
|
||||
store1.update("1", { status: "completed" });
|
||||
|
||||
const store2 = new TaskStore(testListId);
|
||||
expect(store2.get("1")!.status).toBe("completed");
|
||||
expect(store2.get("1")).toBeUndefined();
|
||||
expect(store2.get("2")).toBeDefined();
|
||||
expect(store2.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("only restores pending and in_progress tasks across instances", () => {
|
||||
const store1 = new TaskStore(testListId);
|
||||
store1.create("Pending", "Desc");
|
||||
store1.create("In progress", "Desc");
|
||||
store1.create("Done", "Desc");
|
||||
store1.update("2", { status: "in_progress" });
|
||||
store1.update("3", { status: "completed" });
|
||||
|
||||
const store2 = new TaskStore(testListId);
|
||||
const tasks = store2.list();
|
||||
expect(tasks).toHaveLength(2);
|
||||
expect(tasks.map(t => t.id)).toContain("1");
|
||||
expect(tasks.map(t => t.id)).toContain("2");
|
||||
expect(tasks.map(t => t.id)).not.toContain("3");
|
||||
});
|
||||
|
||||
it("persists ID counter across instances", () => {
|
||||
@@ -356,3 +385,33 @@ describe("TaskStore (file-backed)", () => {
|
||||
expect(t3.id).toBe("3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskStore (absolute path)", () => {
|
||||
const absFilePath = join(tmpdir(), `pi-tasks-test-${Date.now()}.json`);
|
||||
|
||||
afterEach(() => {
|
||||
try { rmSync(absFilePath); } catch { /* */ }
|
||||
try { rmSync(absFilePath + ".lock"); } catch { /* */ }
|
||||
try { rmSync(absFilePath + ".tmp"); } catch { /* */ }
|
||||
});
|
||||
|
||||
it("accepts absolute path and persists tasks", () => {
|
||||
const store1 = new TaskStore(absFilePath);
|
||||
store1.create("Abs path task", "Desc");
|
||||
|
||||
const store2 = new TaskStore(absFilePath);
|
||||
expect(store2.list()).toHaveLength(1);
|
||||
expect(store2.list()[0].subject).toBe("Abs path task");
|
||||
});
|
||||
|
||||
it("does not persist completed tasks when using absolute path", () => {
|
||||
const store1 = new TaskStore(absFilePath);
|
||||
store1.create("Pending", "Desc");
|
||||
store1.create("Completed", "Desc");
|
||||
store1.update("2", { status: "completed" });
|
||||
|
||||
const raw = JSON.parse(readFileSync(absFilePath, "utf-8"));
|
||||
expect(raw.tasks).toHaveLength(1);
|
||||
expect(raw.tasks[0].id).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ function renderWidget(state: ReturnType<typeof mockUICtx>["state"]): string[] {
|
||||
const entry = state.widgets.get("tasks");
|
||||
if (!entry?.content) return [];
|
||||
const theme = mockTheme();
|
||||
const tui = { terminal: { columns: 200 } };
|
||||
const tui = { terminal: { columns: 200 }, requestRender() {} };
|
||||
const result = entry.content(tui, theme);
|
||||
return result.render();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user