@danypops/papyrus 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -32
- package/extension/src/active-task-continuation.ts +9 -0
- package/extension/src/domain-tools.ts +35 -12
- package/extension/src/index.ts +39 -14
- package/extension/src/skills.ts +1 -0
- package/extension/src/task-widget.ts +4 -2
- package/extension/src/tasks.ts +62 -29
- package/package.json +1 -1
- package/src/adapters/sqlite-artifact-store.ts +6 -1
- package/src/adapters/sqlite-task-focus-store.ts +27 -14
- package/src/adapters/sqlite-task-scope-store.ts +59 -0
- package/src/cli.ts +100 -47
- package/src/constants.ts +9 -14
- package/src/daemon.ts +2 -18
- package/src/db.ts +49 -2
- package/src/domain/artifact.ts +6 -0
- package/src/domain/task-event.ts +6 -2
- package/src/domain/task-scope.ts +39 -0
- package/src/ops.ts +17 -1
- package/src/ports/artifact-store.ts +2 -0
- package/src/ports/task-focus-store.ts +30 -8
- package/src/ports/task-scope-store.ts +40 -0
- package/src/service.ts +79 -27
- package/src/skill-execution.ts +16 -10
- package/src/task-context.ts +4 -2
- package/src/task-service.ts +197 -30
- package/src/task-automation.ts +0 -188
package/README.md
CHANGED
|
@@ -32,13 +32,14 @@ bun src/cli.ts service install # install, enable, and start user service
|
|
|
32
32
|
bun src/cli.ts service status
|
|
33
33
|
bun src/cli.ts service restart
|
|
34
34
|
|
|
35
|
-
# Authenticated daemon-backed
|
|
35
|
+
# Authenticated daemon-backed Task operations (add --json for machine output)
|
|
36
36
|
papyrus tasks plan
|
|
37
37
|
papyrus tasks depend <task-id> <prerequisite-id>
|
|
38
|
-
papyrus tasks
|
|
38
|
+
papyrus tasks update <task-id> --title "Revised task"
|
|
39
|
+
papyrus tasks focus <task-id>
|
|
40
|
+
papyrus tasks pause
|
|
41
|
+
papyrus tasks unpause
|
|
39
42
|
papyrus tasks complete <task-id>
|
|
40
|
-
papyrus tasks automate <task-id> <on|off>
|
|
41
|
-
papyrus automation status
|
|
42
43
|
```
|
|
43
44
|
|
|
44
45
|
For repository work, install the versioned ownership guard once:
|
|
@@ -92,7 +93,7 @@ The `papyrus_*` tools are the low-level graph-store API:
|
|
|
92
93
|
|
|
93
94
|
Agent-facing domain tools own lifecycle invariants and sit above this store API:
|
|
94
95
|
|
|
95
|
-
- **`tasks`** — create/list/show/plan, manage the singleton active focus, replace evidence-bearing checklists, hierarchy/dependencies, lifecycle transitions, non-blocking gates, and review completion that focuses one deterministic ready successor without claiming effort
|
|
96
|
+
- **`tasks`** — create/update/list/show/plan, manage the singleton active focus, replace evidence-bearing checklists, hierarchy/dependencies, lifecycle transitions, non-blocking gates, and review completion that focuses one deterministic ready successor without claiming effort
|
|
96
97
|
- **`docs`** — create/list/show, activate/archive/reopen, and document-safe graph links
|
|
97
98
|
- **`rules`** — create/list/show/preview, enable/disable, and attach governance gates to tasks
|
|
98
99
|
- **`skills`** — create/list/show/invoke/run, enable/disable, create compatibility templates, and atomically instantiate parameterized workflow runs
|
|
@@ -103,7 +104,7 @@ Internally, application services depend on the `ArtifactStore` and `GateRunner`
|
|
|
103
104
|
|
|
104
105
|
## Interactive frontends
|
|
105
106
|
|
|
106
|
-
- `/tasks` — task lifecycle, append-only history, gates, dependencies, and nested metadata
|
|
107
|
+
- `/tasks` — project/focused-graph scope, task lifecycle, append-only history, gates, dependencies, and nested metadata
|
|
107
108
|
- `/docs` — searchable documents, lifecycle, details, and graph links
|
|
108
109
|
- `/rules` — severity/condition rows, exact injection preview, enable/disable, and task gating
|
|
109
110
|
- `/skills` — trigger/tools rows, invocation into the editor, and artifact templates
|
|
@@ -114,56 +115,49 @@ All four use daemon-backed domain operations; none opens SQLite from the Pi proc
|
|
|
114
115
|
|
|
115
116
|
Run `/tasks` for the interactive task panel:
|
|
116
117
|
|
|
117
|
-
- `/` filters; arrow keys navigate; Enter opens task actions
|
|
118
|
+
- `/` filters; arrow keys navigate; Enter opens task actions; `s` switches among the persisted current-project, focused-root graph, and explicit all-projects views
|
|
118
119
|
- `g` opens the programmatic Unicode graph; Tab switches dependency/composition views and arrow keys pan
|
|
119
120
|
- routed graph layouts are bounded to 48 nodes/96 edges; larger graphs use a deterministic, box-drawn line fallback, and renderer failures are contained inside the viewport rather than escaping Pi
|
|
120
121
|
- advance the `todo → in-progress → review → done` lifecycle; failed review becomes `rejected`, retry returns to `in-progress`, and `canceled` is terminal
|
|
121
|
-
- use **
|
|
122
|
+
- use **focus** as the independent singleton Task selection that automatic continuation follows; focusing, pausing, or resuming never changes lifecycle
|
|
122
123
|
- starting nested effort moves todo ancestors to in-progress; submitting enters review; completing review checks both typed checklist proofs and executable gates
|
|
123
124
|
- passing review marks only that task done and focuses one deterministic ready successor while leaving the successor todo until effort starts
|
|
124
125
|
- successors are never auto-completed; fan-in, fan-out, diamonds, and disconnected DAGs remain explicit
|
|
125
126
|
- inspect deterministic execution layers, readiness, a box-drawn nested hierarchy, composition, dependencies, evidence-bearing checklists, and verification gates
|
|
126
127
|
- lifecycle colors are semantic and redundant with text/glyphs: To-Do grey, in-progress yellow, review blue, rejected orange, done green, and canceled red; `▶` marks active focus
|
|
127
128
|
- Show details keeps Checklist and Validation gates separate from incidental Metadata, renders bounded post-migration lifecycle history with actor/source/reason and gate evidence, then renders relationships as a Unicode box-drawing graph footer; `↑/↓` scrolls and `←/→` pans wide graphs
|
|
128
|
-
- the compact persistent widget shows bounded open work in containment order and always retains
|
|
129
|
+
- the compact persistent widget shows the current scope label plus bounded open work in containment order and always retains active focus when it belongs to that scope
|
|
129
130
|
|
|
130
131
|
Authenticated CLI parity covers the changed lifecycle and focus operations:
|
|
131
132
|
|
|
132
133
|
```bash
|
|
133
134
|
papyrus tasks graph --json
|
|
135
|
+
papyrus tasks scope --json
|
|
136
|
+
papyrus tasks scope project --json
|
|
137
|
+
papyrus tasks scope graph <root-id> --json
|
|
138
|
+
papyrus tasks scope all --json
|
|
139
|
+
papyrus tasks assign-project <task-id> [project-root] --json
|
|
134
140
|
papyrus tasks active --json
|
|
135
141
|
papyrus tasks history <id> --json
|
|
136
142
|
papyrus tasks focus <id> --json
|
|
143
|
+
papyrus tasks focused --json
|
|
144
|
+
papyrus tasks pause --json
|
|
145
|
+
papyrus tasks unpause --json
|
|
146
|
+
papyrus tasks clear-focus --json
|
|
147
|
+
papyrus tasks update <id> --title "Revised title" --body "Revised body" --json
|
|
137
148
|
papyrus tasks start <id> --json
|
|
138
149
|
papyrus tasks submit <id> --json
|
|
139
150
|
papyrus tasks complete <id> --json
|
|
140
151
|
papyrus tasks reject <id> --json
|
|
141
152
|
papyrus tasks retry <id> --json
|
|
142
153
|
papyrus tasks cancel <id> --json
|
|
143
|
-
papyrus tasks automate <id> <on|off> --json
|
|
144
|
-
papyrus automation status --json
|
|
145
|
-
papyrus automation run --json
|
|
146
154
|
```
|
|
147
155
|
|
|
148
|
-
###
|
|
156
|
+
### Focus-driven automatic continuation
|
|
149
157
|
|
|
150
|
-
|
|
158
|
+
Automatic continuation is a property of the singleton Task focus, not a per-Task automation flag. An active focus continues at Pi's public `agent_settled` boundary when Pi is idle and has no queued messages. `tasks pause` preserves the focused Task while stopping continuation; `tasks unpause` resumes it; `tasks clear-focus` removes it. Replacing focus selects an existing Task rather than creating or canceling one.
|
|
151
159
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
```ini
|
|
155
|
-
[Service]
|
|
156
|
-
Environment=PAPYRUS_AUTOMATION_ENABLED=1
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
```bash
|
|
160
|
-
systemctl --user edit papyrus.service
|
|
161
|
-
systemctl --user restart papyrus.service
|
|
162
|
-
papyrus tasks automate <task-id> on
|
|
163
|
-
papyrus automation status
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
Secure defaults are a 60-second interval, 10 Task transitions per sweep, gate concurrency 1, and a 120-second sweep deadline. Optional environment settings are `PAPYRUS_AUTOMATION_INTERVAL_MS` (10 seconds–1 hour), `PAPYRUS_AUTOMATION_MAX_TASKS` (1–100), `PAPYRUS_AUTOMATION_GATE_CONCURRENCY` (1–4), and `PAPYRUS_AUTOMATION_MAX_RUNTIME_MS` (1 ms–10 minutes). Candidate scans are capped at 1,000 review Tasks, sweeps are single-flight, subprocess gates inherit the sweep deadline, result arrays are bounded by the Task limit, and logs contain counts rather than gate output. `papyrus automation run` uses the same policy and refuses to reconcile while global automation is disabled.
|
|
160
|
+
Continuation is single-flight and bounded to 20 automatic turns or 6 unchanged Task snapshots. Reaching either bound persists a paused focus and records the reason in append-only Task history. Human input resumes only these automatically paused focuses; an explicit user pause remains paused.
|
|
167
161
|
|
|
168
162
|
Checklist criteria are an item-to-proof map. Every new item requires one or more typed references to inspectable evidence; proof presence does not imply that the evidence passed an executable gate:
|
|
169
163
|
|
|
@@ -182,7 +176,7 @@ Proof types are `file`, `symbol`, `code`, `test`, `command`, `artifact`, and `ur
|
|
|
182
176
|
|
|
183
177
|
Papyrus also injects an Alef-style reconciliation block on every agent turn while work remains: `Current`, `Desired`, `Verify`, and `Next`. The agent is explicitly instructed to ask **“Did we accomplish this task?”** and run review before marking it done. The injection disappears when every task is done or canceled.
|
|
184
178
|
|
|
185
|
-
|
|
179
|
+
Task edits mutate the existing Papyrus-owned Task identity and append an `updated` event; title, body, and labels can be revised without canceling the Task or creating a replacement. Lifecycle, relationships, gates, checklist metadata, scope, and focus remain intact.
|
|
186
180
|
|
|
187
181
|
## Why
|
|
188
182
|
|
|
@@ -197,10 +191,10 @@ packed install npm:@danypops/papyrus
|
|
|
197
191
|
~/.pi/agent/npm/node_modules/.bin/papyrus service install
|
|
198
192
|
```
|
|
199
193
|
|
|
200
|
-
Existing databases are never migrated on daemon boot. After upgrading to
|
|
194
|
+
Existing databases are never migrated on daemon boot. After upgrading to focus-driven Task continuation, run the authenticated CLI migration explicitly. Older databases receive prerequisite schemas in one transaction. Existing Tasks are deliberately marked **unscoped**: Papyrus does not guess ownership from titles, labels, historical cwd, or repository names. They remain visible in **All projects** until explicitly assigned with `papyrus tasks assign-project <task-id> [project-root]`:
|
|
201
195
|
|
|
202
196
|
```bash
|
|
203
|
-
~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-
|
|
197
|
+
~/.pi/agent/npm/node_modules/.bin/papyrus migrate task-focus
|
|
204
198
|
```
|
|
205
199
|
|
|
206
200
|
Until that command succeeds, health reports `migrationRequired` and normal domain operations are rejected with actionable guidance. Migration is not exposed as a Pi tool or MCP action. New empty databases bootstrap directly at the current schema.
|
|
@@ -23,6 +23,15 @@ export interface ActiveTaskContinuationDecision {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
const TITLE_LIMIT = 120;
|
|
26
|
+
const AUTOMATIC_PAUSE_PREFIX = "automatic continuation paused:";
|
|
27
|
+
|
|
28
|
+
export function automaticPauseReason(reason: string): string {
|
|
29
|
+
return `${AUTOMATIC_PAUSE_PREFIX} ${reason}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function shouldResumeFocusOnHumanInput(status: string, pauseReason?: string): boolean {
|
|
33
|
+
return status === "paused" && pauseReason?.startsWith(AUTOMATIC_PAUSE_PREFIX) === true;
|
|
34
|
+
}
|
|
26
35
|
|
|
27
36
|
function fingerprint(task: ActiveTaskMarker): string {
|
|
28
37
|
return `${task.id}:${task.updated_at}`;
|
|
@@ -31,7 +31,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
31
31
|
pi.registerTool({
|
|
32
32
|
name: "tasks",
|
|
33
33
|
label: "Tasks",
|
|
34
|
-
description: "Task domain tool. ACTIONS: create, list, show, history, graph, plan, active, focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist,
|
|
34
|
+
description: "Task domain tool. ACTIONS: create, update, list, show, history, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, run_gates, set_checklist, depend, contain. Lifecycle is todo → in-progress → review → done, with review failure → rejected and retry → in-progress; canceled is terminal. Active focus is independent and identifies the one task auto-drive continues. Completion runs gates and checklist-proof review, then focuses one deterministic ready successor without claiming effort. Dependency cycles are rejected. Prefer this over low-level papyrus_* tools for task work.",
|
|
35
35
|
parameters: Type.Object({
|
|
36
36
|
action: Type.String(),
|
|
37
37
|
id: Type.Optional(Type.String()),
|
|
@@ -44,7 +44,6 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
44
44
|
direction: Type.Optional(Type.Union([Type.Literal("asc"), Type.Literal("desc")])),
|
|
45
45
|
reason: Type.Optional(Type.String()),
|
|
46
46
|
session_id: Type.Optional(Type.String()),
|
|
47
|
-
enabled: Type.Optional(Type.Boolean()),
|
|
48
47
|
labels: Type.Optional(Type.Array(Type.String())),
|
|
49
48
|
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
50
49
|
gates: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Unknown()))),
|
|
@@ -54,17 +53,20 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
54
53
|
child_id: Type.Optional(Type.String()),
|
|
55
54
|
dependency_id: Type.Optional(Type.String()),
|
|
56
55
|
depends_on: Type.Optional(Type.Array(Type.String())),
|
|
56
|
+
project_root: Type.Optional(Type.String()),
|
|
57
|
+
scope: Type.Optional(Type.Union([Type.Literal("project"), Type.Literal("graph"), Type.Literal("all")])),
|
|
58
|
+
root_task_id: Type.Optional(Type.String()),
|
|
57
59
|
}),
|
|
58
|
-
async execute(_id, params) {
|
|
60
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
59
61
|
try {
|
|
60
62
|
const action = params.action;
|
|
61
|
-
const request = { ...params, actor: "agent", source: "pi-tool" };
|
|
63
|
+
const request = { ...params, project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool" };
|
|
62
64
|
if (action === "create") {
|
|
63
65
|
const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
|
|
64
66
|
return text(`Created task ${artifactLine(artifact)}`, { artifact });
|
|
65
67
|
}
|
|
66
68
|
if (action === "list") {
|
|
67
|
-
const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list",
|
|
69
|
+
const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list", request);
|
|
68
70
|
return text(rows.length ? rows.map(artifactLine).join("\n") : "No tasks found.", { rows });
|
|
69
71
|
}
|
|
70
72
|
if (action === "show") {
|
|
@@ -76,18 +78,35 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
76
78
|
const lines = page.events.map((event) => `${event.occurredAt} ${event.type} ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"} · ${event.actor}/${event.source}${event.reason ? ` · ${event.reason}` : ""}`);
|
|
77
79
|
return text(lines.join("\n") || "No recorded history for this task.", { page });
|
|
78
80
|
}
|
|
81
|
+
if (action === "scope") {
|
|
82
|
+
const selection = await callService<Record<string, unknown>, import("../../src/domain/task-scope.ts").TaskViewSelection>("tasks.scope", request);
|
|
83
|
+
return text(`Task scope: ${selection.label}`, { selection });
|
|
84
|
+
}
|
|
79
85
|
if (action === "active") {
|
|
80
|
-
const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active",
|
|
86
|
+
const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", request);
|
|
81
87
|
return text(artifact ? `Active: ${artifactLine(artifact)}` : "No active task.", { artifact });
|
|
82
88
|
}
|
|
89
|
+
if (action === "focused") {
|
|
90
|
+
const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string } | null>("tasks.focused", request);
|
|
91
|
+
return text(focus ? `Focused (${focus.status}): ${artifactLine(focus.artifact)}` : "No focused task.", { focus });
|
|
92
|
+
}
|
|
93
|
+
if (action === "pause" || action === "unpause") {
|
|
94
|
+
const operation = action === "pause" ? "tasks.pause" : "tasks.unpause";
|
|
95
|
+
const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>(operation, request);
|
|
96
|
+
return text(`Focused (${focus.status}): ${artifactLine(focus.artifact)}`, { focus });
|
|
97
|
+
}
|
|
98
|
+
if (action === "clear_focus") {
|
|
99
|
+
const result = await callService<Record<string, unknown>, { cleared: boolean }>("tasks.clear_focus", request);
|
|
100
|
+
return text(result.cleared ? "Task focus cleared." : "No focused task.", result);
|
|
101
|
+
}
|
|
83
102
|
if (action === "graph") {
|
|
84
|
-
const graph = await callService<Record<string, unknown>, TaskGraph>("tasks.graph",
|
|
103
|
+
const graph = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", request);
|
|
85
104
|
const dependencies = graph.nodes.reduce((count, node) => count + node.dependencyIds.length, 0);
|
|
86
105
|
const containment = graph.nodes.reduce((count, node) => count + node.childIds.length, 0);
|
|
87
106
|
return text(`Task graph: ${graph.nodes.length} nodes, ${graph.rootIds.length} roots, ${dependencies} dependencies, ${containment} containment edges.`, { graph });
|
|
88
107
|
}
|
|
89
108
|
if (action === "plan") {
|
|
90
|
-
const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan",
|
|
109
|
+
const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan", request);
|
|
91
110
|
const byId = new Map(plan.nodes.map((node) => [node.id, node]));
|
|
92
111
|
const lines = plan.layers.flatMap((layer, index) => [
|
|
93
112
|
`Layer ${index + 1}`,
|
|
@@ -124,7 +143,9 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
124
143
|
reject: "tasks.reject",
|
|
125
144
|
retry: "tasks.retry",
|
|
126
145
|
cancel: "tasks.cancel",
|
|
127
|
-
|
|
146
|
+
update: "tasks.update",
|
|
147
|
+
set_scope: "tasks.set_scope",
|
|
148
|
+
assign_project: "tasks.assign_project",
|
|
128
149
|
depend: "tasks.depend",
|
|
129
150
|
contain: "tasks.contain",
|
|
130
151
|
} as const;
|
|
@@ -234,10 +255,12 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
234
255
|
text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), template_id: Type.Optional(Type.String()),
|
|
235
256
|
target_kind: Type.Optional(Type.String()), defaults: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
236
257
|
required: Type.Optional(Type.Array(Type.String())), kind: Type.Optional(Type.String()), subtype: Type.Optional(Type.String()),
|
|
258
|
+
project_root: Type.Optional(Type.String()),
|
|
237
259
|
}),
|
|
238
|
-
async execute(_id, params) {
|
|
260
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
239
261
|
try {
|
|
240
262
|
const action = params.action;
|
|
263
|
+
const request = { ...params, project_root: params.project_root ?? ctx.cwd };
|
|
241
264
|
if (action === "create" || action === "create_template") {
|
|
242
265
|
const operation = action === "create" ? "skills.create" : "skills.create_template";
|
|
243
266
|
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
@@ -252,7 +275,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
252
275
|
return text(invocation, { invocation });
|
|
253
276
|
}
|
|
254
277
|
if (action === "run") {
|
|
255
|
-
const run = await callService<Record<string, unknown>, SkillWorkflowRunResult>("skills.run",
|
|
278
|
+
const run = await callService<Record<string, unknown>, SkillWorkflowRunResult>("skills.run", request);
|
|
256
279
|
const execution = run.execution.nodes.map((node) => ` [${node.state}] ${node.id} ${node.title}`).join("\n");
|
|
257
280
|
return text([
|
|
258
281
|
`Created Skill run ${run.runId}: ${run.created.tasks.length} tasks, ${run.created.rules.length} rules, ${run.created.docs.length} docs.`,
|
|
@@ -265,7 +288,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
265
288
|
const operations = { show: "skills.show", enable: "skills.enable", disable: "skills.disable", instantiate: "skills.instantiate" } as const;
|
|
266
289
|
const operation = operations[action as keyof typeof operations];
|
|
267
290
|
if (!operation) return text(`Unknown skills action: ${action}`);
|
|
268
|
-
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
291
|
+
const artifact = await callService<Record<string, unknown>, Artifact>(operation, action === "instantiate" ? request : params);
|
|
269
292
|
return text(`${artifactLine(artifact)}${action === "show" ? `\n\n${artifact.body}` : ""}`, { artifact });
|
|
270
293
|
} catch (error) {
|
|
271
294
|
return text(`skills failed: ${error instanceof Error ? error.message : error}`);
|
package/extension/src/index.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { formatMetadata } from "./artifact-format.ts";
|
|
|
20
20
|
import { callService } from "./service-client.ts";
|
|
21
21
|
import { registerDomainTools } from "./domain-tools.ts";
|
|
22
22
|
import type { TaskGraph, TaskStatus } from "../../src/task-service.ts";
|
|
23
|
-
import { ActiveTaskContinuation, type ActiveTaskMarker } from "./active-task-continuation.ts";
|
|
23
|
+
import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanInput, type ActiveTaskMarker } from "./active-task-continuation.ts";
|
|
24
24
|
import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
|
|
25
25
|
import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
|
|
26
26
|
|
|
@@ -36,12 +36,12 @@ const WIDGET_KEY = "pi-papyrus";
|
|
|
36
36
|
|
|
37
37
|
export function renderTaskWidgetLines(theme: Theme, projection: TaskWidgetProjection, width: number): string[] {
|
|
38
38
|
if (projection.openTotal === 0) return [];
|
|
39
|
-
const lines: string[] = [];
|
|
39
|
+
const lines: string[] = [theme.fg("muted", `Tasks · ${projection.scopeLabel}`)];
|
|
40
40
|
for (let index = 0; index < projection.rows.length; index++) {
|
|
41
41
|
const row = projection.rows[index]!;
|
|
42
42
|
const laterSibling = projection.rows.slice(index + 1).some((candidate) => candidate.depth === row.depth);
|
|
43
43
|
const hierarchy = taskTreeConnector({ depth: row.depth, hasChildren: row.hasOpenChildren, hasLaterSibling: laterSibling });
|
|
44
|
-
const focus = row.active ? theme.fg("accent", "▶") : " ";
|
|
44
|
+
const focus = row.active ? theme.fg("accent", row.focusStatus === "paused" ? "Ⅱ" : "▶") : " ";
|
|
45
45
|
const presentation = TASK_STATUS_PRESENTATION[row.task.status as TaskStatus];
|
|
46
46
|
const glyph = presentation ? theme.fg(presentation.color, presentation.glyph) : theme.fg("muted", "?");
|
|
47
47
|
lines.push(truncateToWidth(`${focus} ${hierarchy} ${glyph} ${row.task.title}`, width, "…"));
|
|
@@ -54,6 +54,7 @@ class TaskOverlay {
|
|
|
54
54
|
private registered = false;
|
|
55
55
|
private tui: any | undefined;
|
|
56
56
|
private snapshot: TaskGraph = { nodes: [], rootIds: [] };
|
|
57
|
+
private projectRoot: string | undefined;
|
|
57
58
|
|
|
58
59
|
setUI(ctx: ExtensionUIContext): void {
|
|
59
60
|
if (ctx !== this.uiCtx) {
|
|
@@ -63,9 +64,12 @@ class TaskOverlay {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
setProjectRoot(projectRoot: string): void { this.projectRoot = projectRoot; }
|
|
68
|
+
|
|
66
69
|
async refresh(): Promise<void> {
|
|
70
|
+
if (!this.projectRoot) return;
|
|
67
71
|
try {
|
|
68
|
-
this.snapshot = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", { limit: 500 });
|
|
72
|
+
this.snapshot = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", { limit: 500, project_root: this.projectRoot });
|
|
69
73
|
} catch {
|
|
70
74
|
this.snapshot = { nodes: [], rootIds: [] };
|
|
71
75
|
}
|
|
@@ -116,6 +120,7 @@ class TaskOverlay {
|
|
|
116
120
|
this.registered = false;
|
|
117
121
|
this.tui = undefined;
|
|
118
122
|
this.uiCtx = undefined;
|
|
123
|
+
this.projectRoot = undefined;
|
|
119
124
|
}
|
|
120
125
|
}
|
|
121
126
|
|
|
@@ -133,7 +138,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
133
138
|
const driveActiveTasks = async (ctx: ExtensionContext): Promise<void> => {
|
|
134
139
|
if (ctx.mode !== "tui" && ctx.mode !== "rpc") return;
|
|
135
140
|
try {
|
|
136
|
-
const active = await callService<Record<string,
|
|
141
|
+
const active = await callService<Record<string, unknown>, ActiveTaskMarker | null>("tasks.active", { project_root: ctx.cwd });
|
|
137
142
|
const decision = taskContinuation.evaluate(active, {
|
|
138
143
|
idle: ctx.isIdle(),
|
|
139
144
|
pendingMessages: ctx.hasPendingMessages(),
|
|
@@ -144,8 +149,13 @@ export default async function (pi: ExtensionAPI) {
|
|
|
144
149
|
content: decision.prompt,
|
|
145
150
|
display: false,
|
|
146
151
|
}, { triggerTurn: true, deliverAs: "nextTurn" });
|
|
147
|
-
} else if (decision.action === "pause"
|
|
148
|
-
|
|
152
|
+
} else if (decision.action === "pause") {
|
|
153
|
+
await callService("tasks.pause", {
|
|
154
|
+
actor: "system",
|
|
155
|
+
source: "task-continuation",
|
|
156
|
+
reason: automaticPauseReason(decision.reason),
|
|
157
|
+
});
|
|
158
|
+
if (ctx.hasUI) ctx.ui.notify(`Papyrus task driving paused: ${decision.reason}. Human input resumes it automatically.`, "warning");
|
|
149
159
|
}
|
|
150
160
|
} catch {
|
|
151
161
|
// The daemon may be unavailable during startup, reload, or shutdown.
|
|
@@ -174,10 +184,14 @@ export default async function (pi: ExtensionAPI) {
|
|
|
174
184
|
labels: Type.Optional(Type.Array(Type.String())),
|
|
175
185
|
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
176
186
|
template_id: Type.Optional(Type.String({ description: "skill/artifact-template id whose defaults and requirements apply" })),
|
|
187
|
+
project_root: Type.Optional(Type.String({ description: "required for Tasks; defaults to Pi cwd" })),
|
|
177
188
|
}),
|
|
178
|
-
async execute(_id, params, _signal, _onUpdate,
|
|
189
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
179
190
|
try {
|
|
180
|
-
const a = await callService<Record<string, unknown>, Artifact>("artifact.create",
|
|
191
|
+
const a = await callService<Record<string, unknown>, Artifact>("artifact.create", {
|
|
192
|
+
...params,
|
|
193
|
+
...(params.kind === "task" ? { project_root: params.project_root ?? ctx.cwd } : {}),
|
|
194
|
+
});
|
|
181
195
|
return text(`Created ${a.id} [${a.kind}|${a.status}] ${a.title}`, { id: a.id });
|
|
182
196
|
} catch (e) {
|
|
183
197
|
return text(`papyrus_create failed: ${e instanceof Error ? e.message : e}`);
|
|
@@ -309,6 +323,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
309
323
|
pi.registerCommand("tasks", {
|
|
310
324
|
description: "Browse and manage Papyrus tasks (interactive)",
|
|
311
325
|
handler: async (_args, ctx) => {
|
|
326
|
+
overlay?.setProjectRoot(ctx.cwd);
|
|
312
327
|
await tasksModule.showTasks(ctx);
|
|
313
328
|
await overlay?.refresh();
|
|
314
329
|
},
|
|
@@ -332,6 +347,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
332
347
|
if (!ctx.hasUI) return;
|
|
333
348
|
overlay ??= new TaskOverlay();
|
|
334
349
|
overlay.setUI(ctx.ui);
|
|
350
|
+
overlay.setProjectRoot(ctx.cwd);
|
|
335
351
|
await overlay.refresh();
|
|
336
352
|
});
|
|
337
353
|
|
|
@@ -350,8 +366,17 @@ export default async function (pi: ExtensionAPI) {
|
|
|
350
366
|
// agent_settled is intentionally later than agent_end: Pi guarantees that
|
|
351
367
|
// retry, compaction retry, and queued follow-up processing have finished.
|
|
352
368
|
|
|
353
|
-
pi.on("input", (event) => {
|
|
354
|
-
if (event.source
|
|
369
|
+
pi.on("input", async (event) => {
|
|
370
|
+
if (event.source === "extension") return;
|
|
371
|
+
taskContinuation.onHumanInput();
|
|
372
|
+
try {
|
|
373
|
+
const focus = await callService<Record<string, never>, { status: string; pauseReason?: string } | null>("tasks.focused", {});
|
|
374
|
+
if (focus && shouldResumeFocusOnHumanInput(focus.status, focus.pauseReason)) {
|
|
375
|
+
await callService("tasks.unpause", { actor: "system", source: "task-continuation", reason: "human input resumed automatic task continuation" });
|
|
376
|
+
}
|
|
377
|
+
} catch {
|
|
378
|
+
// The daemon may be unavailable during startup, reload, or shutdown.
|
|
379
|
+
}
|
|
355
380
|
});
|
|
356
381
|
pi.on("agent_start", () => { taskContinuation.onAgentStart(); });
|
|
357
382
|
pi.on("agent_settled", async (_event, ctx) => { await driveActiveTasks(ctx); });
|
|
@@ -360,11 +385,11 @@ export default async function (pi: ExtensionAPI) {
|
|
|
360
385
|
// The agent sees its open work items every turn. If there are rejected
|
|
361
386
|
// tasks, they're explicitly called out — the agent should address them.
|
|
362
387
|
|
|
363
|
-
pi.on("before_agent_start", async (event,
|
|
388
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
364
389
|
try {
|
|
365
390
|
const [rules, summary] = await Promise.all([
|
|
366
|
-
callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "body" | "extra">>>("rules.injectable", {}),
|
|
367
|
-
callService<Record<string, unknown>, string | null>("tasks.context", {}),
|
|
391
|
+
callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd }),
|
|
392
|
+
callService<Record<string, unknown>, string | null>("tasks.context", { project_root: ctx.cwd }),
|
|
368
393
|
]);
|
|
369
394
|
let prompt = event.systemPrompt ?? "";
|
|
370
395
|
if (rules.length > 0) {
|
package/extension/src/skills.ts
CHANGED
|
@@ -93,6 +93,7 @@ export async function showSkills(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
93
93
|
const run = await callService<Record<string, unknown>, SkillWorkflowRunResult>("skills.run", {
|
|
94
94
|
id: skill.id,
|
|
95
95
|
arguments: arguments_ as Record<string, unknown>,
|
|
96
|
+
project_root: commandCtx.cwd,
|
|
96
97
|
});
|
|
97
98
|
commandCtx.ui.notify([
|
|
98
99
|
`Created ${run.runId} · ${run.created.tasks.length} tasks · ${run.rootTaskIds.length} ready roots`,
|
|
@@ -7,12 +7,14 @@ export interface TaskWidgetRow {
|
|
|
7
7
|
depth: number;
|
|
8
8
|
hasOpenChildren: boolean;
|
|
9
9
|
active: boolean;
|
|
10
|
+
focusStatus?: "active" | "paused";
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export interface TaskWidgetProjection {
|
|
13
14
|
rows: TaskWidgetRow[];
|
|
14
15
|
openTotal: number;
|
|
15
16
|
total: number;
|
|
17
|
+
scopeLabel: string;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
function isOpen(task: Artifact): boolean {
|
|
@@ -34,7 +36,7 @@ export function buildTaskWidgetProjection(
|
|
|
34
36
|
if (!node) return;
|
|
35
37
|
visited.add(id);
|
|
36
38
|
const open = isOpen(node.task);
|
|
37
|
-
if (open) ordered.push({ task: node.task, depth: openDepth, hasOpenChildren: false, active: node.active === true });
|
|
39
|
+
if (open) ordered.push({ task: node.task, depth: openDepth, hasOpenChildren: false, active: node.active === true, focusStatus: node.focusStatus });
|
|
38
40
|
const childDepth = open ? openDepth + 1 : openDepth;
|
|
39
41
|
for (const childId of node.childIds) visit(childId, childDepth);
|
|
40
42
|
};
|
|
@@ -52,5 +54,5 @@ export function buildTaskWidgetProjection(
|
|
|
52
54
|
rows = [...rows.slice(0, Math.max(0, limit - 1)), active]
|
|
53
55
|
.sort((left, right) => ordered.indexOf(left) - ordered.indexOf(right));
|
|
54
56
|
}
|
|
55
|
-
return { rows, openTotal: ordered.length, total: graph.nodes.length };
|
|
57
|
+
return { rows, openTotal: ordered.length, total: graph.nodes.length, scopeLabel: graph.scope?.label ?? "All projects" };
|
|
56
58
|
}
|
package/extension/src/tasks.ts
CHANGED
|
@@ -30,12 +30,6 @@ const STATUS_ACTIONS: Record<string, string[]> = {
|
|
|
30
30
|
|
|
31
31
|
type TaskRow = Artifact;
|
|
32
32
|
|
|
33
|
-
function taskAutomationEnabled(task: Artifact): boolean {
|
|
34
|
-
const automation = task.extra["automation"];
|
|
35
|
-
return typeof automation === "object" && automation !== null && !Array.isArray(automation)
|
|
36
|
-
&& (automation as Record<string, unknown>)["enabled"] === true;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
33
|
export interface TaskHierarchyRow {
|
|
40
34
|
task: TaskRow;
|
|
41
35
|
depth: number;
|
|
@@ -62,8 +56,13 @@ export function buildTaskHierarchy(graph: TaskGraph): TaskHierarchyRow[] {
|
|
|
62
56
|
return result;
|
|
63
57
|
}
|
|
64
58
|
|
|
65
|
-
async function loadTaskGraph(): Promise<TaskGraph> {
|
|
66
|
-
return callService<Record<string, unknown>, TaskGraph>("tasks.graph", {
|
|
59
|
+
async function loadTaskGraph(projectRoot: string, scope?: "project" | "graph" | "all", rootTaskId?: string): Promise<TaskGraph> {
|
|
60
|
+
return callService<Record<string, unknown>, TaskGraph>("tasks.graph", {
|
|
61
|
+
limit: 200,
|
|
62
|
+
project_root: projectRoot,
|
|
63
|
+
...(scope ? { scope } : {}),
|
|
64
|
+
...(rootTaskId ? { root_task_id: rootTaskId } : {}),
|
|
65
|
+
});
|
|
67
66
|
}
|
|
68
67
|
|
|
69
68
|
export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
@@ -71,14 +70,14 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
71
70
|
ctx.ui.notify("/tasks requires interactive mode", "warning");
|
|
72
71
|
return;
|
|
73
72
|
}
|
|
74
|
-
let graph = await loadTaskGraph();
|
|
73
|
+
let graph = await loadTaskGraph(ctx.cwd);
|
|
75
74
|
if (graph.nodes.length === 0) {
|
|
76
75
|
const create = await ctx.ui.select("No tasks yet", ["Create a task", "Cancel"]);
|
|
77
76
|
if (create === "Create a task") {
|
|
78
77
|
const title = await ctx.ui.input("Task title:", "");
|
|
79
78
|
if (title) {
|
|
80
|
-
await callService("tasks.create", { title, actor: "user", source: "tasks-tui" });
|
|
81
|
-
graph = await loadTaskGraph();
|
|
79
|
+
await callService("tasks.create", { title, project_root: ctx.cwd, actor: "user", source: "tasks-tui" });
|
|
80
|
+
graph = await loadTaskGraph(ctx.cwd);
|
|
82
81
|
}
|
|
83
82
|
}
|
|
84
83
|
if (graph.nodes.length === 0) return;
|
|
@@ -87,16 +86,34 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
87
86
|
for (;;) {
|
|
88
87
|
const action = await renderPanel(ctx, graph);
|
|
89
88
|
if (!action) return;
|
|
90
|
-
if (action.type === "refresh") { graph = await loadTaskGraph(); continue; }
|
|
89
|
+
if (action.type === "refresh") { graph = await loadTaskGraph(ctx.cwd); continue; }
|
|
90
|
+
if (action.type === "scope") {
|
|
91
|
+
const choice = await ctx.ui.select("Task scope", ["Current project", "Focused graph", "All projects"]);
|
|
92
|
+
if (!choice) continue;
|
|
93
|
+
const scope: "project" | "graph" | "all" = choice === "Current project" ? "project" : choice === "All projects" ? "all" : "graph";
|
|
94
|
+
let rootTaskId: string | undefined;
|
|
95
|
+
if (scope === "graph") {
|
|
96
|
+
const projectGraph = await loadTaskGraph(ctx.cwd, "project");
|
|
97
|
+
const roots = projectGraph.rootIds.map((id) => projectGraph.nodes.find((node) => node.task.id === id)?.task).filter((task): task is Artifact => task !== undefined);
|
|
98
|
+
const selected = await ctx.ui.select("Focused root or epic", roots.map((task) => `${task.title} · ${task.id}`));
|
|
99
|
+
if (!selected) continue;
|
|
100
|
+
rootTaskId = roots.find((task) => `${task.title} · ${task.id}` === selected)?.id;
|
|
101
|
+
if (!rootTaskId) continue;
|
|
102
|
+
}
|
|
103
|
+
await callService("tasks.set_scope", { project_root: ctx.cwd, scope, ...(rootTaskId ? { root_task_id: rootTaskId } : {}) });
|
|
104
|
+
graph = await loadTaskGraph(ctx.cwd);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
91
107
|
if (action.type === "graph") { await showTaskGraph(ctx, graph); continue; }
|
|
92
108
|
if (action.type !== "action" || !action.row) continue;
|
|
93
109
|
|
|
94
110
|
const active = graph.nodes.find((node) => node.task.id === action.row!.id)?.active === true;
|
|
95
|
-
const
|
|
111
|
+
const focusStatus = graph.nodes.find((node) => node.task.id === action.row!.id)?.focusStatus;
|
|
96
112
|
const choices = [
|
|
97
113
|
"Show details",
|
|
114
|
+
"Edit task",
|
|
98
115
|
...(!active && action.row.status !== "done" && action.row.status !== "canceled" ? ["Make active"] : []),
|
|
99
|
-
...(
|
|
116
|
+
...(active ? [focusStatus === "paused" ? "Resume focus" : "Pause focus", "Clear focus"] : []),
|
|
100
117
|
...(action.row.status === "review" ? ["Run gates"] : []),
|
|
101
118
|
...(STATUS_ACTIONS[action.row.status] ?? []),
|
|
102
119
|
];
|
|
@@ -108,26 +125,39 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
108
125
|
if (!art) { ctx.ui.notify("Not found", "error"); continue; }
|
|
109
126
|
const history = await callService<Record<string, unknown>, TaskHistoryPage>("tasks.history", { id: art.id, direction: "desc" });
|
|
110
127
|
await showTaskDetails(ctx, art, graph, undefined, [...history.events].reverse());
|
|
128
|
+
} else if (choice === "Edit task") {
|
|
129
|
+
const title = await ctx.ui.input("Task title:", action.row.title);
|
|
130
|
+
if (title === undefined) continue;
|
|
131
|
+
const body = await ctx.ui.input("Task body:", action.row.body);
|
|
132
|
+
if (body === undefined) continue;
|
|
133
|
+
try {
|
|
134
|
+
const updated = await callService<Record<string, unknown>, Artifact>("tasks.update", {
|
|
135
|
+
id: action.row.id,
|
|
136
|
+
title,
|
|
137
|
+
body,
|
|
138
|
+
actor: "user",
|
|
139
|
+
source: "tasks-tui",
|
|
140
|
+
});
|
|
141
|
+
action.row.title = updated.title;
|
|
142
|
+
action.row.body = updated.body;
|
|
143
|
+
ctx.ui.notify(`Updated: ${updated.title}`, "info");
|
|
144
|
+
} catch (error) {
|
|
145
|
+
ctx.ui.notify(`Task update failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
146
|
+
}
|
|
111
147
|
} else if (choice === "Make active") {
|
|
112
148
|
try {
|
|
113
|
-
await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id });
|
|
149
|
+
await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id, actor: "user", source: "tasks-tui" });
|
|
114
150
|
ctx.ui.notify(`Active: ${action.row.title}`, "info");
|
|
115
151
|
} catch (error) {
|
|
116
152
|
ctx.ui.notify(`Focus failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
117
153
|
}
|
|
118
|
-
} else if (choice === "
|
|
154
|
+
} else if (choice === "Pause focus" || choice === "Resume focus" || choice === "Clear focus") {
|
|
119
155
|
try {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
enabled,
|
|
124
|
-
actor: "user",
|
|
125
|
-
source: "tasks-tui",
|
|
126
|
-
});
|
|
127
|
-
action.row.extra = updated.extra;
|
|
128
|
-
ctx.ui.notify(`Automation ${enabled ? "enabled" : "disabled"}: ${action.row.title}`, enabled ? "warning" : "info");
|
|
156
|
+
const operation = choice === "Pause focus" ? "tasks.pause" : choice === "Resume focus" ? "tasks.unpause" : "tasks.clear_focus";
|
|
157
|
+
await callService(operation, { actor: "user", source: "tasks-tui" });
|
|
158
|
+
ctx.ui.notify(choice === "Clear focus" ? "Task focus cleared" : choice, "info");
|
|
129
159
|
} catch (error) {
|
|
130
|
-
ctx.ui.notify(`
|
|
160
|
+
ctx.ui.notify(`Focus action failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
131
161
|
}
|
|
132
162
|
} else if (choice === "Run gates") {
|
|
133
163
|
try {
|
|
@@ -173,12 +203,12 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
173
203
|
ctx.ui.notify(`Task action failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
174
204
|
}
|
|
175
205
|
}
|
|
176
|
-
graph = await loadTaskGraph();
|
|
206
|
+
graph = await loadTaskGraph(ctx.cwd);
|
|
177
207
|
}
|
|
178
208
|
}
|
|
179
209
|
|
|
180
210
|
interface PanelAction {
|
|
181
|
-
type: "action" | "refresh" | "graph";
|
|
211
|
+
type: "action" | "refresh" | "graph" | "scope";
|
|
182
212
|
row?: TaskRow;
|
|
183
213
|
}
|
|
184
214
|
|
|
@@ -218,7 +248,7 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
|
|
|
218
248
|
const header = {
|
|
219
249
|
invalidate() {},
|
|
220
250
|
render(width: number): string[] {
|
|
221
|
-
const title = theme.bold(
|
|
251
|
+
const title = theme.bold(`Tasks · ${graph.scope?.label ?? "scope unavailable"}`);
|
|
222
252
|
const hint = searchActive
|
|
223
253
|
? rawKeyHint("esc", "clear")
|
|
224
254
|
: rawKeyHint("↑/↓", "navigate") +
|
|
@@ -229,6 +259,8 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
|
|
|
229
259
|
theme.fg("muted", " · ") +
|
|
230
260
|
rawKeyHint("g", "graph") +
|
|
231
261
|
theme.fg("muted", " · ") +
|
|
262
|
+
rawKeyHint("s", "scope") +
|
|
263
|
+
theme.fg("muted", " · ") +
|
|
232
264
|
rawKeyHint("r", "refresh") +
|
|
233
265
|
theme.fg("muted", " · ") +
|
|
234
266
|
rawKeyHint("esc", "close");
|
|
@@ -322,6 +354,7 @@ function renderPanel(ctx: ExtensionCommandContext, graph: TaskGraph): Promise<Pa
|
|
|
322
354
|
else if (matchesKey(data, "down")) selectedIndex = (selectedIndex + 1) % Math.max(filtered.length, 1);
|
|
323
355
|
else if (data === "/") searchActive = true;
|
|
324
356
|
else if (data === "g") { done({ type: "graph" }); return; }
|
|
357
|
+
else if (data === "s") { done({ type: "scope" }); return; }
|
|
325
358
|
else if (data === "r") { done({ type: "refresh" }); return; }
|
|
326
359
|
else if (matchesKey(data, "enter")) {
|
|
327
360
|
const entry = filtered[selectedIndex];
|