mirror of
https://github.com/wassname/pi-lgtm.git
synced 2026-06-27 16:46:17 +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/),
|
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).
|
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
|
## [0.3.0] - 2026-03-14
|
||||||
|
|
||||||
### Changed
|
### 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).
|
- **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.
|
- **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.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.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
|
[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
|
- **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
|
- **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)
|
- **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)
|
||||||
- **Task state persistence** — current task state injected into system prompt on every agent loop, surviving context compaction
|
- **Prompt guidelines** — workflow contract encoded in tool descriptions, nudging the LLM at the point of tool use
|
||||||
- **Prompt guidelines** — system prompt guidelines nudge the LLM to use task tools for complex work
|
|
||||||
- **Dependency management** — bidirectional `blocks`/`blockedBy` relationships with warnings for cycles, self-deps, and dangling references
|
- **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
|
- **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
|
- **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
|
- **Raw data preserved:** `TaskGet` shows ALL edges, including completed blockers
|
||||||
- **Cleanup on deletion:** removing a task cleans up all edges pointing to it
|
- **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
|
```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).
|
**Shared team list** (`.envrc`):
|
||||||
|
```bash
|
||||||
Without the env var, tasks are session-scoped (in-memory only).
|
export PI_TASKS=my-project
|
||||||
|
```
|
||||||
|
|
||||||
## `/tasks` Command
|
## `/tasks` Command
|
||||||
|
|
||||||
@@ -203,7 +218,7 @@ Tasks
|
|||||||
|
|
||||||
- **View all tasks** — select a task to see details and take actions (start, complete, delete)
|
- **View all tasks** — select a task to see details and take actions (start, complete, delete)
|
||||||
- **Create task** — input prompts for subject and description
|
- **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
|
- **Clear completed** — remove all completed tasks
|
||||||
|
|
||||||
## Cross-extension Communication with [`@tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents)
|
## 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/
|
src/
|
||||||
├── index.ts # Extension entry: 7 tools + /tasks command + widget + subagent integration
|
├── 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
|
├── 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
|
├── process-tracker.ts # Background process output buffering and stop
|
||||||
└── ui/
|
└── 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
|
## Future Work
|
||||||
@@ -277,7 +294,7 @@ src/
|
|||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run typecheck # TypeScript validation
|
npm run typecheck # TypeScript validation
|
||||||
npm test # Run unit tests (102 tests)
|
npm test # Run unit tests (116 tests)
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@tintinweb/pi-tasks",
|
"name": "@tintinweb/pi-tasks",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@tintinweb/pi-tasks",
|
"name": "@tintinweb/pi-tasks",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mariozechner/pi-coding-agent": "^0.57.1",
|
"@mariozechner/pi-coding-agent": "^0.57.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@tintinweb/pi-tasks",
|
"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.",
|
"description": "A pi extension that brings Claude Code-style task tracking and coordination to pi.",
|
||||||
"author": "tintinweb",
|
"author": "tintinweb",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
+22
-54
@@ -19,7 +19,10 @@ import { Type } from "@sinclair/typebox";
|
|||||||
import { TaskStore } from "./task-store.js";
|
import { TaskStore } from "./task-store.js";
|
||||||
import { ProcessTracker } from "./process-tracker.js";
|
import { ProcessTracker } from "./process-tracker.js";
|
||||||
import { TaskWidget, type UICtx } from "./ui/task-widget.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 { randomUUID } from "node:crypto";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Helpers ----
|
||||||
|
|
||||||
@@ -38,14 +41,22 @@ The task tools haven't been used recently. If you're working on tasks that would
|
|||||||
</system-reminder>`;
|
</system-reminder>`;
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
export default function (pi: ExtensionAPI) {
|
||||||
// Initialize store: use PI_TASK_LIST_ID for shared/file-backed mode
|
// Initialize store and config
|
||||||
const listId = process.env.PI_TASK_LIST_ID;
|
const cfg = loadTasksConfig();
|
||||||
const store = new TaskStore(listId);
|
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 tracker = new ProcessTracker();
|
||||||
const widget = new TaskWidget(store);
|
const widget = new TaskWidget(store);
|
||||||
|
|
||||||
// ── Subagent integration state ──
|
// ── Subagent integration state ──
|
||||||
let autoCascadeEnabled = false;
|
|
||||||
/** Latest ExtensionContext — refreshed on every tool execution so cascade always has a valid one. */
|
/** Latest ExtensionContext — refreshed on every tool execution so cascade always has a valid one. */
|
||||||
let latestCtx: ExtensionContext | undefined;
|
let latestCtx: ExtensionContext | undefined;
|
||||||
/** Cascade config — set by TaskExecute, consumed by completion listener. */
|
/** Cascade config — set by TaskExecute, consumed by completion listener. */
|
||||||
@@ -111,7 +122,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
widget.setActiveTask(task.id, false);
|
widget.setActiveTask(task.id, false);
|
||||||
|
|
||||||
// Auto-cascade: find unblocked dependents with agentType
|
// Auto-cascade: find unblocked dependents with agentType
|
||||||
if (autoCascadeEnabled && cascadeConfig && latestCtx) {
|
if ((cfg.autoCascade ?? false) && cascadeConfig && latestCtx) {
|
||||||
const unblocked = store.list().filter(t =>
|
const unblocked = store.list().filter(t =>
|
||||||
t.status === "pending" &&
|
t.status === "pending" &&
|
||||||
t.metadata?.agentType &&
|
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
|
// Grab UI + extension context from every tool execution
|
||||||
pi.on("tool_execution_start", async (_event, ctx) => {
|
pi.on("tool_execution_start", async (_event, ctx) => {
|
||||||
latestCtx = ctx;
|
latestCtx = ctx;
|
||||||
@@ -450,6 +423,10 @@ Returns full task details:
|
|||||||
|
|
||||||
## When to Use This Tool
|
## 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:**
|
**Mark tasks as resolved:**
|
||||||
- When you have completed the work described in a task
|
- When you have completed the work described in a task
|
||||||
- When a task is no longer needed or has been superseded
|
- When a task is no longer needed or has been superseded
|
||||||
@@ -842,17 +819,8 @@ Set up task dependencies:
|
|||||||
return viewTasks();
|
return viewTasks();
|
||||||
};
|
};
|
||||||
|
|
||||||
const settingsMenu = async (): Promise<void> => {
|
const settingsMenu = (): Promise<void> =>
|
||||||
const cascadeLabel = `Auto-execute tasks with agents: ${autoCascadeEnabled ? "ON" : "OFF"}`;
|
openSettingsMenu(ui, cfg, mainMenu);
|
||||||
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 createTask = async (): Promise<void> => {
|
const createTask = async (): Promise<void> => {
|
||||||
const subject = await ui.input("Task subject");
|
const subject = await ui.input("Task subject");
|
||||||
|
|||||||
+11
-12
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "node:fs";
|
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 { homedir } from "node:os";
|
||||||
import type { Task, TaskStatus, TaskStoreData } from "./types.js";
|
import type { Task, TaskStatus, TaskStoreData } from "./types.js";
|
||||||
|
|
||||||
@@ -51,7 +51,6 @@ function isProcessRunning(pid: number): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class TaskStore {
|
export class TaskStore {
|
||||||
private listId: string | undefined;
|
|
||||||
private filePath: string | undefined;
|
private filePath: string | undefined;
|
||||||
private lockPath: string | undefined;
|
private lockPath: string | undefined;
|
||||||
|
|
||||||
@@ -59,14 +58,14 @@ export class TaskStore {
|
|||||||
private nextId = 1;
|
private nextId = 1;
|
||||||
private tasks = new Map<string, Task>();
|
private tasks = new Map<string, Task>();
|
||||||
|
|
||||||
constructor(listId?: string) {
|
constructor(listIdOrPath?: string) {
|
||||||
this.listId = listId;
|
if (!listIdOrPath) return;
|
||||||
if (listId) {
|
const isAbsPath = isAbsolute(listIdOrPath);
|
||||||
mkdirSync(TASKS_DIR, { recursive: true });
|
const filePath = isAbsPath ? listIdOrPath : join(TASKS_DIR, `${listIdOrPath}.json`);
|
||||||
this.filePath = join(TASKS_DIR, `${listId}.json`);
|
mkdirSync(dirname(filePath), { recursive: true });
|
||||||
this.lockPath = this.filePath + ".lock";
|
this.filePath = filePath;
|
||||||
this.load();
|
this.lockPath = filePath + ".lock";
|
||||||
}
|
this.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read store from disk (file-backed mode only). */
|
/** Read store from disk (file-backed mode only). */
|
||||||
@@ -83,12 +82,12 @@ export class TaskStore {
|
|||||||
} catch { /* corrupt file — start fresh */ }
|
} 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 {
|
private save(): void {
|
||||||
if (!this.filePath) return;
|
if (!this.filePath) return;
|
||||||
const data: TaskStoreData = {
|
const data: TaskStoreData = {
|
||||||
nextId: this.nextId,
|
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";
|
const tmpPath = this.filePath + ".tmp";
|
||||||
writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
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>();
|
private activeTaskIds = new Set<string>();
|
||||||
/** Per-task runtime metrics keyed by task ID. */
|
/** Per-task runtime metrics keyed by task ID. */
|
||||||
private metrics = new Map<string, TaskMetrics>();
|
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) {}
|
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. */
|
/** Force an immediate widget update. */
|
||||||
update() {
|
update() {
|
||||||
if (!this.uiCtx) return;
|
if (!this.uiCtx) return;
|
||||||
const tasks = this.store.list();
|
const tasks = this.store.list();
|
||||||
|
|
||||||
|
// Transition: visible → hidden
|
||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
this.uiCtx.setWidget("tasks", undefined);
|
if (this.widgetRegistered) {
|
||||||
|
this.uiCtx.setWidget("tasks", undefined);
|
||||||
|
this.widgetRegistered = false;
|
||||||
|
}
|
||||||
if (this.widgetInterval) {
|
if (this.widgetInterval) {
|
||||||
clearInterval(this.widgetInterval);
|
clearInterval(this.widgetInterval);
|
||||||
this.widgetInterval = undefined;
|
this.widgetInterval = undefined;
|
||||||
@@ -122,17 +214,6 @@ export class TaskWidget {
|
|||||||
return;
|
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)
|
// Prune stale active IDs (deleted or no longer in_progress)
|
||||||
for (const id of this.activeTaskIds) {
|
for (const id of this.activeTaskIds) {
|
||||||
const t = this.store.get(id);
|
const t = this.store.get(id);
|
||||||
@@ -152,77 +233,18 @@ export class TaskWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.widgetFrame++;
|
this.widgetFrame++;
|
||||||
const spinnerChar = SPINNER[this.widgetFrame % SPINNER.length];
|
|
||||||
|
|
||||||
this.uiCtx.setWidget("tasks", (tui, theme) => {
|
// Transition: hidden → visible — register widget callback once
|
||||||
const w = tui.terminal.columns;
|
if (!this.widgetRegistered) {
|
||||||
const truncate = (line: string) => truncateToWidth(line, w);
|
this.uiCtx.setWidget("tasks", (tui, theme) => {
|
||||||
|
this.tui = tui;
|
||||||
const lines: string[] = [truncate(theme.fg("accent", "●") + " " + theme.fg("accent", statusText))];
|
return { render: () => this.renderWidget(tui, theme), invalidate: () => {} };
|
||||||
|
}, { placement: "aboveEditor" });
|
||||||
const visible = tasks.slice(0, MAX_VISIBLE_TASKS);
|
this.widgetRegistered = true;
|
||||||
for (let i = 0; i < visible.length; i++) {
|
} else if (this.tui) {
|
||||||
const task = visible[i];
|
// Widget already registered — just request a re-render
|
||||||
const isActive = this.activeTaskIds.has(task.id) && task.status === "in_progress";
|
this.tui.requestRender();
|
||||||
|
}
|
||||||
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" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
@@ -233,5 +255,7 @@ export class TaskWidget {
|
|||||||
if (this.uiCtx) {
|
if (this.uiCtx) {
|
||||||
this.uiCtx.setWidget("tasks", undefined);
|
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 { TaskWidget, type UICtx, type Theme } from "../src/ui/task-widget.js";
|
||||||
import initExtension from "../src/index.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 ----
|
// ---- Mock pi ----
|
||||||
|
|
||||||
/** Minimal mock of ExtensionAPI with events, tool capture, and event hooks. */
|
/** 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)", () => {
|
describe("Standalone operation (no subagents extension)", () => {
|
||||||
let mock: ReturnType<typeof mockPi>;
|
let mock: ReturnType<typeof mockPi>;
|
||||||
|
|||||||
+62
-3
@@ -1,8 +1,9 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { TaskStore } from "../src/task-store.js";
|
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 { join } from "node:path";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
describe("TaskStore (in-memory)", () => {
|
describe("TaskStore (in-memory)", () => {
|
||||||
let store: TaskStore;
|
let store: TaskStore;
|
||||||
@@ -337,13 +338,41 @@ describe("TaskStore (file-backed)", () => {
|
|||||||
expect(tasks[0].subject).toBe("Persistent task");
|
expect(tasks[0].subject).toBe("Persistent task");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists updates to disk", () => {
|
it("persists in_progress updates to disk", () => {
|
||||||
const store1 = new TaskStore(testListId);
|
const store1 = new TaskStore(testListId);
|
||||||
store1.create("Task", "Desc");
|
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" });
|
store1.update("1", { status: "completed" });
|
||||||
|
|
||||||
const store2 = new TaskStore(testListId);
|
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", () => {
|
it("persists ID counter across instances", () => {
|
||||||
@@ -356,3 +385,33 @@ describe("TaskStore (file-backed)", () => {
|
|||||||
expect(t3.id).toBe("3");
|
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");
|
const entry = state.widgets.get("tasks");
|
||||||
if (!entry?.content) return [];
|
if (!entry?.content) return [];
|
||||||
const theme = mockTheme();
|
const theme = mockTheme();
|
||||||
const tui = { terminal: { columns: 200 } };
|
const tui = { terminal: { columns: 200 }, requestRender() {} };
|
||||||
const result = entry.content(tui, theme);
|
const result = entry.content(tui, theme);
|
||||||
return result.render();
|
return result.render();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user