pi-note 0.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kvidzibo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # pi-note
2
+
3
+ Persistent [pinote](https://github.com/kvidzibo/pinote) tasks and Markdown handoffs
4
+ in Pi: selected-task footer status, `/pi-note`, and agent read/update tools.
5
+ The extension and Python app share a repository but install separately.
6
+
7
+ ## Install
8
+
9
+ Requires Node.js 22.19+ and **pinote 0.3.0+** (`note` on PATH).
10
+ From this checkout, install the extension with `pi install ./pi`, then run
11
+ `/reload` in interactive Pi. Use `/pi-note-setup` if the CLI is missing, or the
12
+ suggested `/pi-note-upgrade` if it is older than the bundled CLI. The planned npm package name is
13
+ `pi-note`; after its first release use `pi install npm:pi-note` instead.
14
+ It is not published yet. npm installation does not run Python installers.
15
+
16
+ ### CLI setup and upgrades
17
+
18
+ Install [uv](https://docs.astral.sh/uv/getting-started/installation/) first and
19
+ restart Pi with `uv` on PATH. At startup the extension checks `note --version`
20
+ against its bundled CLI version (currently 0.3.0), without network requests:
21
+
22
+ - Missing/unrecognized CLI: show `/pi-note-setup` in the slash-command menu.
23
+ - Older CLI: show and suggest `/pi-note-upgrade`.
24
+ - Equal/newer CLI: hide both commands; no upgrade notification.
25
+
26
+ Both commands ask before installing into uv's isolated tool environment.
27
+ The menu refreshes after setup/upgrade; restart Pi or `/reload` after external
28
+ CLI changes. Manually typing either command rechecks the version and never
29
+ reinstalls an equal/newer CLI or intentionally downgrades it.
30
+ Python 3.11+ is required; uv can download a suitable interpreter when missing.
31
+ The command downloads a pinned GitHub source archive and build dependencies,
32
+ not a similarly named PyPI package. It does not install GTK dependencies, edit
33
+ shell configuration, or change tasks. This checks the Python CLI bundled with
34
+ the installed extension, not the latest npm/GitHub release. Update the extension
35
+ first to receive a newer bundled CLI. `/pi-note-setup --upgrade` is replaced by
36
+ `/pi-note-upgrade`.
37
+
38
+ Back up your database before upgrading and update an optional GUI separately.
39
+ For checkout development, use `uv tool install --reinstall .` at the repo root
40
+ instead, so local Python changes are installed.
41
+
42
+ If setup reports a PATH problem, run `uv tool dir --bin` in your terminal,
43
+ put that directory before older `note` executables on PATH, and restart Pi.
44
+ Check `note --version`, then restart Pi or `/reload`. Network/build failures and
45
+ conflicting executables are reported without forcibly overwriting another
46
+ installer's commands. Fix the reported cause and retry; installs time out after
47
+ three minutes. Setup/upgrade commands are hidden while installation runs.
48
+ Missing uv is reported with its installation link, not installed
49
+ automatically. The [GTK desktop app](../README.md#optional-gtk-checklist) is optional
50
+ and still requires separate system dependencies.
51
+
52
+ The extension probes `note --version` before task commands: older CLIs interpret
53
+ unknown commands as note text and must be rejected before use. Remove the old
54
+ standalone `settings/pi/extensions/pinote.ts` entry if installed; load only one copy.
55
+
56
+ ## Use
57
+
58
+ `/pi-note` picks a task, starts it, and puts its full text and saved handoff fields
59
+ in the editor. Existing drafts are preserved; nothing is submitted automatically.
60
+ With a selected task it offers **Continue**, **Done**, and **Switch task**.
61
+ Type in the task picker to filter by text, ID, tag, or state (all words must match).
62
+ Use โ†‘/โ†“ and Enter to select, or Esc to cancel. Rows show **โ—** for in progress or
63
+ **โ—‹** for active, followed by the tag (`[Untagged]` when absent).
64
+ The normal Pi status area shows `๐Ÿ“Œ [tag] Task title` without replacing other footers.
65
+ The footer omits the task ID and state and shows only the first line, truncated to 60 terminal columns including the pin and tag.
66
+ The pin glyph is bundled in `icons/note.txt`; it uses the terminal's emoji font, not a Nerd Font or icon theme.
67
+
68
+ Selection is stored per canonical working directory in pinote's database. A new
69
+ Pi session shows that selection; use **Continue** to load its current contents.
70
+ Completing, removing, or scheduling a task clears its selections. Switching does
71
+ not complete or reset the previous task. Task status refreshes at session start,
72
+ before/after agent activity, and after commands/tools.
73
+
74
+ Agent tools:
75
+
76
+ - `pinote_get`: read the selected task, or an explicit `id`, including its revision.
77
+ - `pinote_update`: supply `id`, `expected_updated_at` from that read, and arbitrary
78
+ `set` label/value pairs or `remove` labels. Updates merge fields, never replace
79
+ the task text. If another process changed the task, read again before retrying.
80
+
81
+ Agents are guided to keep notes to three short bullets total: relevant outcome,
82
+ blocker, and next action. Replace stale notes; omit narration, repeated task text,
83
+ and routine test logs. Keep PR links in `PR`. This is guidance, not truncation or
84
+ a storage limit.
85
+
86
+ Values are Markdown strings; no fields are required. `PR` enables the watcher below:
87
+
88
+ ```markdown
89
+ # Agent
90
+ PR: [Task selection #42](https://github.com/org/repo/pull/42)
91
+ Jira: [PROJ-123](https://example.atlassian.net/browse/PROJ-123)
92
+ CWD: `/home/me/project`
93
+ Next: Address review comments
94
+ ```
95
+
96
+ The GTK preview renders this section using its existing safe Markdown renderer.
97
+ Fields are stored separately from task text, retained in history, and survive
98
+ sessions. Labels are case-sensitive (trimmed, Unicode-normalized), at most 64
99
+ characters; values are at most 4096 characters. Maximum 64 fields / 32 KiB JSON
100
+ per task. Use removal rather than empty values.
101
+
102
+ ### PR merge watcher
103
+
104
+ Set `PR` with `pinote_update` to one `https://github.com/owner/repo/pull/123` URL
105
+ or Markdown link. The footer adds a clickable, link-coloured **PR #123**, without
106
+ status text. Other hosts, multiple links, and prose are not watched. Terminal
107
+ OSC 8 support is required for clicking links.
108
+
109
+ Interactive Pi checks only the selected task's PR using authenticated `gh`
110
+ (`gh auth login`). Polling defaults to 60 seconds; launch Pi with
111
+ `PINOTE_PR_POLL_SECONDS=120 pi` to change it, or `0` to disable polling.
112
+ Allowed intervals are 10โ€“86400 seconds. The selected task's link remains visible when polling is disabled.
113
+ Network/authentication failures warn once until recovery and retry next interval.
114
+ No polling runs in print/RPC mode or after Pi exits.
115
+
116
+ On merge, Pi waits until idle and asks **Mark this task completed?** Confirmation
117
+ uses the same guarded CLI Done operation as `/pi-note`; declining leaves the task
118
+ unchanged. If it is already done, Pi says **PR #123 was merged and the task is
119
+ already completed**, without completing it again. Completion clears the selection
120
+ and hides the footer link, even with polling disabled. The background watch remains
121
+ until another task is selected, the link is removed, or Pi exits. Removed/scheduled tasks are no longer watched.
122
+
123
+ The watched task ID/link and merge acknowledgements are saved in the Pi session,
124
+ so `/reload` and session resume retain completed-task watches without repeating
125
+ acknowledged prompts. A new session can notify again for a
126
+ selected task. Task or PR changes during confirmation cannot complete a different
127
+ task. No completion-hook system is added; this uses Pinote's existing Done flow.
128
+ A separate branch-based PR-status extension may show a duplicate link; disable it
129
+ if you only want task-linked PRs.
130
+
131
+ Task data stays local except for GitHub status requests. Tools work without a TUI,
132
+ but `/pi-note` needs an idle TUI.
133
+ This package does not synchronize databases or paths between machines. Note text
134
+ and fields loaded into Pi are sent to the configured model when used as context;
135
+ avoid secrets. Agent commands do not send desktop notifications.
136
+
137
+ ## Development and releases
138
+
139
+ From the repository root:
140
+
141
+ ```sh
142
+ uv sync --locked
143
+ npm --prefix pi ci --ignore-scripts
144
+ npm --prefix pi test
145
+ (cd pi && npm pack --dry-run)
146
+ ```
147
+
148
+ The load test uses the real Pi loader and checkout's `.venv/bin/note` with temporary
149
+ data. Run visual checks under Xvfb/private D-Bus; never use personal task data.
150
+
151
+ `.github/workflows/publish.yml` follows the other Pi packages: every push to
152
+ `main` runs the full Python/GTK/Pi tests, then publishes a new npm version using
153
+ OIDC trusted publishing and provenance. Existing versions are skipped; registry
154
+ failures fail the workflow. No npm token is stored in GitHub.
155
+
156
+ One-time maintainer setup (not performed by installing this package):
157
+
158
+ 1. From `pi/`, run `npm login`, then `npm publish --ignore-scripts --access public`
159
+ to bootstrap `pi-note` after checking name availability.
160
+ 2. In npm's package settings configure the GitHub trusted publisher: owner
161
+ `kvidzibo`, repository `pinote`, workflow `publish.yml`, no environment.
162
+ 3. Add verified npm and Pi package-directory links here after publication.
163
+
164
+ The CLI source pin in `setup.ts` must point to a verified immutable commit with a
165
+ compatible Python package. Update it and the bundled-version setup text together
166
+ when releasing Python changes; verify installation in a temporary uv tool directory.
167
+
168
+ Subsequent releases: run `npm version patch|minor|major --no-git-tag-version`
169
+ inside `pi/`, commit both version files in a PR, and merge through the normal
170
+ review/check process. Python and npm versions are independent; update the stated
171
+ minimum CLI version whenever the integration contract changes.
package/icons/note.txt ADDED
@@ -0,0 +1 @@
1
+ ๐Ÿ“Œ
package/index.ts ADDED
@@ -0,0 +1,324 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { readFileSync } from "node:fs";
4
+ import { stripVTControlCharacters } from "node:util";
5
+ import { Type } from "typebox";
6
+ import { createPRWatcher } from "./pr-watch.ts";
7
+ import { TaskPicker, taskState, taskTag } from "./task-picker.ts";
8
+ import { bundledCLIVersion, cliMenu, detectCLI, compatibleVersion, setupCLI, setupHint, type CLIAction } from "./setup.ts";
9
+
10
+ const handoffGuidance = "Keep agent notes to at most three short bullets total: outcome, blocker, next action, only when relevant. Replace stale notes; omit narration, repeated task text, and routine test logs. Keep PR links in the separate PR field.";
11
+ type Task = {
12
+ id: number;
13
+ text: string;
14
+ state: "active" | "in_progress" | "done" | "removed" | "scheduled";
15
+ tag: string | null;
16
+ updated_at: string;
17
+ agent_notes: Record<string, string>;
18
+ markdown: string;
19
+ };
20
+ type Summary = Pick<Task, "id" | "text" | "state" | "tag">;
21
+ // The standard Pi footer accepts text, so ship a portable terminal glyph, not a theme icon.
22
+ const noteIcon = readFileSync(new URL("./icons/note.txt", import.meta.url), "utf8").trim();
23
+ const idSchema = Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
24
+ const compatible = "Incompatible note CLI response. Install pinote 0.3.0+ and check note on PATH.";
25
+ const validId = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) > 0;
26
+ const record = (value: unknown): value is Record<string, unknown> =>
27
+ value !== null && typeof value === "object" && !Array.isArray(value);
28
+ const text = (value: unknown): value is string =>
29
+ typeof value === "string" && !/[\x00-\x08\x0b-\x1f\x7f-\x9f]/u.test(value);
30
+ const clean = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/gu, "๏ฟฝ");
31
+ const firstLine = (task: Summary) => clean(task.text.split("\n", 1)[0]).replace(/\s+/gu, " ").trim();
32
+ const title = (task: Summary) => truncateToWidth(`#${task.id} ${firstLine(task)}`, 60);
33
+
34
+ function parse(raw: string): unknown {
35
+ try { return JSON.parse(raw); } catch { throw new Error(compatible); }
36
+ }
37
+ function task(raw: string): Task | null {
38
+ const value = parse(raw);
39
+ if (value === null) return null;
40
+ if (!record(value) || !validId(value.id) || !text(value.text) || !value.text.trim() ||
41
+ typeof value.state !== "string" || !["active", "in_progress", "done", "removed", "scheduled"].includes(value.state) ||
42
+ !(value.tag === null || text(value.tag)) || !text(value.updated_at) || !value.updated_at.trim() ||
43
+ !record(value.agent_notes) || !Object.entries(value.agent_notes).every(([key, v]) => text(key) && text(v)) ||
44
+ !text(value.markdown)) throw new Error(compatible);
45
+ return value as Task;
46
+ }
47
+ function requiredTask(raw: string, id?: number): Task {
48
+ const value = task(raw);
49
+ if (!value) throw new Error("No selected pinote task. Select one with /pi-note first.");
50
+ if (id !== undefined && value.id !== id) throw new Error(compatible);
51
+ return value;
52
+ }
53
+ function taskList(raw: string): Summary[] {
54
+ const value = parse(raw);
55
+ if (!Array.isArray(value) || !value.every((v) => record(v) && validId(v.id) && text(v.text) &&
56
+ v.text.trim() && (v.tag === null || text(v.tag)) &&
57
+ typeof v.state === "string" && ["active", "in_progress"].includes(v.state))) throw new Error(compatible);
58
+ return value.sort((a, b) => b.id - a.id);
59
+ }
60
+
61
+ function toolResult(value: Task) {
62
+ // Keep the model payload under 50 KiB at the CLI's limits; don't send the same fields twice.
63
+ const { markdown: _markdown, ...data } = value;
64
+ return { content: [{ type: "text" as const, text: JSON.stringify(data) }], details: value };
65
+ }
66
+
67
+ export default function (pi: ExtensionAPI) {
68
+ let alive = true;
69
+ let epoch = 0;
70
+ let refreshSerial = 0;
71
+ let pending: symbol | undefined;
72
+ let setupAbort: AbortController | undefined;
73
+ let activeContext: ExtensionContext | undefined;
74
+ let cliState: CLIAction | undefined;
75
+ const checkCLI = async (ctx: ExtensionContext, suggest = false) => {
76
+ if (!ctx.hasUI || ctx.mode !== "tui" || setupAbort) return;
77
+ const generation = epoch;
78
+ const action = await detectCLI(pi);
79
+ if (!alive || generation !== epoch || setupAbort) return;
80
+ cliState = action;
81
+ if (suggest && action === "upgrade") {
82
+ ctx.ui.notify(`Pinote CLI ${bundledCLIVersion} is bundled with this extension. Run /pi-note-upgrade to update your older CLI.`, "info");
83
+ }
84
+ };
85
+
86
+ const invoke = async (argv: string[], signal?: AbortSignal) => {
87
+ signal?.throwIfAborted();
88
+ let result;
89
+ try {
90
+ result = await pi.exec("note", argv, { timeout: 5000, signal });
91
+ } catch (error) {
92
+ throw new Error(`Pinote CLI unavailable: ${clean(String(error))}. ${setupHint}`);
93
+ }
94
+ if (result.killed || result.code !== 0) {
95
+ const detail = clean((result.stderr || result.stdout || "note failed or timed out").trim());
96
+ throw new Error(`${detail}${argv[0] === "--version" ? ` ${setupHint}` : result.killed ? " Read the task before retrying; a write may have committed." : ""}`);
97
+ }
98
+ return result.stdout;
99
+ };
100
+ const run = async (argv: string[], signal?: AbortSignal, canRun = () => true) => {
101
+ if (setupAbort) throw new Error("Pinote CLI setup is still running. Retry after it finishes.");
102
+ // Old pinote treats unknown commands as note text. Probe safely before every call,
103
+ // including tools in headless sessions, and do not cache across CLI upgrades/downgrades.
104
+ const version = (await invoke(["--version"], signal)).trim();
105
+ if (!compatibleVersion(version)) {
106
+ throw new Error(`pi-note requires pinote 0.3.0+; found ${clean(version) || "an unknown CLI"}. ${setupHint}`);
107
+ }
108
+ if (!canRun()) throw new Error("Pinote operation cancelled: the session changed.");
109
+ return invoke(argv, signal);
110
+ };
111
+ const selected = async (ctx: ExtensionContext, signal?: AbortSignal) =>
112
+ task(await run(["agent", "selected", "--cwd", ctx.cwd], signal));
113
+
114
+ const refresh = async (ctx: ExtensionContext) => {
115
+ if (!alive || setupAbort || !ctx.hasUI || ctx.mode !== "tui") return;
116
+ const generation = epoch;
117
+ const serial = ++refreshSerial;
118
+ try {
119
+ const current = await selected(ctx);
120
+ if (alive && generation === epoch && serial === refreshSerial) {
121
+ ctx.ui.setStatus("pinote", current ? truncateToWidth(`${noteIcon} ${taskTag(current)} ${firstLine(current)}`, 60) : undefined);
122
+ watcher.update(ctx, current);
123
+ }
124
+ } catch {
125
+ if (alive && generation === epoch && serial === refreshSerial) {
126
+ ctx.ui.setStatus("pinote", `${noteIcon} Pinote unavailable ยท ${cliState === "upgrade" ? "/pi-note-upgrade" : cliState === "setup" ? "/pi-note-setup" : "/pi-note"}`);
127
+ }
128
+ }
129
+ };
130
+ const watcher = createPRWatcher(pi, {
131
+ selected,
132
+ get: async (id) => requiredTask(await run(["agent", "get", String(id)]), id),
133
+ done: async (current, canAct) => {
134
+ const result = requiredTask(await run([
135
+ "agent", "done", String(current.id), "--expected-updated-at", current.updated_at,
136
+ ], undefined, canAct), current.id);
137
+ if (result.state !== "done") throw new Error(compatible);
138
+ },
139
+ claim: () => {
140
+ if (pending) return;
141
+ const operation = Symbol();
142
+ pending = operation;
143
+ return () => { if (pending === operation) pending = undefined; };
144
+ },
145
+ refresh,
146
+ });
147
+ pi.on("session_start", async (_event, ctx) => {
148
+ setupAbort?.abort();
149
+ alive = true;
150
+ activeContext = ctx;
151
+ epoch++;
152
+ const generation = epoch;
153
+ cliState = undefined;
154
+ if (ctx.hasUI && ctx.mode === "tui") {
155
+ ctx.ui.addAutocompleteProvider((current) => cliMenu(current, () => setupAbort ? undefined : cliState));
156
+ }
157
+ // Keep the setup lock until its aborted subprocess has actually settled.
158
+ if (!setupAbort) pending = undefined;
159
+ await checkCLI(ctx, true);
160
+ if (!alive || generation !== epoch) return;
161
+ if (!setupAbort) watcher.start(ctx);
162
+ await refresh(ctx);
163
+ });
164
+ pi.on("before_agent_start", async (_event, ctx) => { await refresh(ctx); });
165
+ pi.on("agent_end", async (_event, ctx) => { await refresh(ctx); });
166
+ pi.on("session_shutdown", (_event, ctx) => {
167
+ setupAbort?.abort();
168
+ alive = false;
169
+ activeContext = undefined;
170
+ epoch++;
171
+ refreshSerial++;
172
+ watcher.stop();
173
+ if (ctx.hasUI && ctx.mode === "tui") ctx.ui.setStatus("pinote", undefined);
174
+ });
175
+
176
+ const registerInstallCommand = (name: "pi-note-setup" | "pi-note-upgrade") => pi.registerCommand(name, {
177
+ description: name === "pi-note-setup" ? "Install the missing Python CLI with uv" : "Upgrade an older Python CLI to the bundled version",
178
+ handler: async (args, ctx) => {
179
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
180
+ if (args.trim()) {
181
+ ctx.ui.notify(`Usage: /${name}`, "warning");
182
+ return;
183
+ }
184
+ if (pending || !ctx.isIdle()) {
185
+ ctx.ui.notify("Wait until Pi is idle and the pinote operation has finished.", "warning");
186
+ return;
187
+ }
188
+ const operation = Symbol();
189
+ pending = operation;
190
+ const generation = epoch;
191
+ const controller = new AbortController();
192
+ setupAbort = controller;
193
+ watcher.stop();
194
+ const currentSession = () => alive && generation === epoch;
195
+ try {
196
+ await setupCLI(pi, ctx, name === "pi-note-upgrade",
197
+ () => currentSession() && ctx.isIdle(), controller.signal);
198
+ } catch (error) {
199
+ if (currentSession()) ctx.ui.notify(`Pinote setup: ${clean(String(error))}`, "error");
200
+ } finally {
201
+ if (pending === operation) pending = undefined;
202
+ if (setupAbort === controller) setupAbort = undefined;
203
+ const latestContext = activeContext ?? (currentSession() ? ctx : undefined);
204
+ if (alive && latestContext) {
205
+ const generation = epoch;
206
+ await checkCLI(latestContext, !currentSession());
207
+ if (!alive || generation !== epoch || setupAbort) return;
208
+ watcher.start(latestContext);
209
+ await refresh(latestContext);
210
+ }
211
+ }
212
+ },
213
+ });
214
+ registerInstallCommand("pi-note-setup");
215
+ registerInstallCommand("pi-note-upgrade");
216
+
217
+ pi.registerCommand("pi-note", {
218
+ description: "Continue, complete, or switch the selected pinote task",
219
+ handler: async (args, ctx) => {
220
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
221
+ if (args.trim()) { ctx.ui.notify("Usage: /pi-note", "warning"); return; }
222
+ if (pending || !ctx.isIdle()) {
223
+ ctx.ui.notify("Wait until Pi is idle and the pinote operation has finished.", "warning");
224
+ return;
225
+ }
226
+ const operation = Symbol();
227
+ pending = operation;
228
+ const generation = epoch;
229
+ const currentSession = () => alive && generation === epoch;
230
+ const canAct = () => currentSession() && ctx.isIdle();
231
+ try {
232
+ const current = await selected(ctx);
233
+ if (!canAct()) return;
234
+ const action = current
235
+ ? await ctx.ui.select(`Pinote โ€” ${taskState(current)} ${taskTag(current)} ${title(current)}`, ["Continue", "Done", "Switch task"])
236
+ : "Switch task";
237
+ if (!action || !canAct()) return;
238
+ if (action === "Done" && current) {
239
+ refreshSerial++;
240
+ const completed = requiredTask(await run([
241
+ "agent", "done", String(current.id), "--expected-updated-at", current.updated_at,
242
+ ], undefined, canAct), current.id);
243
+ if (completed.state !== "done") throw new Error(compatible);
244
+ if (currentSession()) ctx.ui.notify(`Pinote #${current.id} completed.`, "info");
245
+ return;
246
+ }
247
+ let id: number;
248
+ if (action === "Continue" && current) {
249
+ id = current.id;
250
+ } else if (action === "Switch task") {
251
+ const tasks = taskList(await run(["list", "--json"]));
252
+ if (!canAct()) return;
253
+ if (!tasks.length) { ctx.ui.notify("No active pinote tasks.", "info"); return; }
254
+ const choice = await ctx.ui.custom<number | undefined>((tui, theme, kb, done) =>
255
+ new TaskPicker(tasks, theme, (data, action) => kb.matches(data, action), done, () => tui.requestRender()));
256
+ if (choice === undefined || !canAct()) return;
257
+ id = choice;
258
+ } else return;
259
+ // Validate/start the explicit task and return its latest handoff in one transaction.
260
+ refreshSerial++;
261
+ const chosen = requiredTask(await run(["agent", "select", String(id), "--cwd", ctx.cwd], undefined, canAct), id);
262
+ if (!canAct()) return;
263
+ const draft = ctx.ui.getEditorText();
264
+ const handoff = `Pinote task #${chosen.id} (revision ${chosen.updated_at}).\n` +
265
+ "Use pinote_get to read current task data and pinote_update to save agent handoff fields.\n" +
266
+ handoffGuidance + "\n\n" + chosen.markdown;
267
+ ctx.ui.setEditorText(draft ? `${draft}\n\n${handoff}` : handoff);
268
+ ctx.ui.notify(`Pinote #${chosen.id} is in progress. Task added to input.`, "info");
269
+ } catch (error) {
270
+ if (currentSession()) ctx.ui.notify(`Pinote: ${clean(error instanceof Error ? error.message : String(error))}`, "error");
271
+ } finally {
272
+ if (pending === operation) pending = undefined;
273
+ if (currentSession()) await refresh(ctx);
274
+ }
275
+ },
276
+ });
277
+
278
+ pi.registerTool({
279
+ name: "pinote_get",
280
+ label: "Pinote get",
281
+ description: "Read a pinote task and its Markdown agent fields/revision. Omit id to read this project's selected task; supply id to read another task. Use the returned updated_at for pinote_update.",
282
+ parameters: Type.Object({ id: Type.Optional(idSchema) }),
283
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
284
+ if (params.id !== undefined && !validId(params.id)) throw new Error("id must be a positive safe integer.");
285
+ const argv = params.id === undefined
286
+ ? ["agent", "selected", "--cwd", ctx.cwd]
287
+ : ["agent", "get", String(params.id)];
288
+ const result = requiredTask(await run(argv, signal), params.id);
289
+ return toolResult(result);
290
+ },
291
+ });
292
+ pi.registerTool({
293
+ name: "pinote_update",
294
+ label: "Pinote update",
295
+ promptGuidelines: [handoffGuidance],
296
+ description: "Patch arbitrary agent handoff fields on an explicit pinote task. Read first with pinote_get; pass its updated_at as expected_updated_at. set merges label/value pairs without replacing other fields or task text; values are Markdown, e.g. PR: [Fix #42](https://github.com/org/repo/pull/42). Set PR to one GitHub pull-request URL or Markdown link to show it in the footer and watch for merge confirmation in interactive Pi. remove deletes named fields. A stale revision fails; read again before retrying. Does not complete the task.",
297
+ parameters: Type.Object({
298
+ id: idSchema,
299
+ expected_updated_at: Type.String({ minLength: 1 }),
300
+ set: Type.Optional(Type.Record(Type.String(), Type.String())),
301
+ remove: Type.Optional(Type.Array(Type.String())),
302
+ }),
303
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
304
+ if (!validId(params.id) || !text(params.expected_updated_at) || !params.expected_updated_at.trim()) {
305
+ throw new Error("id and expected_updated_at are required; read the task first.");
306
+ }
307
+ if (pending) throw new Error("A pinote operation is already open. Retry after it finishes.");
308
+ const operation = Symbol();
309
+ pending = operation;
310
+ const generation = epoch;
311
+ refreshSerial++;
312
+ try {
313
+ const argv = ["agent", "update", String(params.id), "--expected-updated-at", params.expected_updated_at,
314
+ "--set-json", JSON.stringify(params.set ?? {})];
315
+ for (const label of params.remove ?? []) argv.push(`--remove=${label}`);
316
+ const result = requiredTask(await run(argv, signal, () => alive && generation === epoch), params.id);
317
+ return toolResult(result);
318
+ } finally {
319
+ if (pending === operation) pending = undefined;
320
+ if (alive && generation === epoch) await refresh(ctx);
321
+ }
322
+ },
323
+ });
324
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "pi-note",
3
+ "version": "0.4.0",
4
+ "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "keywords": ["pi-package", "tasks", "notes"],
8
+ "engines": { "node": ">=22.19.0" },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/kvidzibo/pinote.git",
12
+ "directory": "pi"
13
+ },
14
+ "homepage": "https://github.com/kvidzibo/pinote/tree/main/pi#readme",
15
+ "bugs": { "url": "https://github.com/kvidzibo/pinote/issues" },
16
+ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
17
+ "files": ["index.ts", "pr-watch.ts", "task-picker.ts", "setup.ts", "icons", "README.md", "LICENSE"],
18
+ "pi": { "extensions": ["./index.ts"] },
19
+ "scripts": {
20
+ "check": "tsc --noEmit",
21
+ "test:unit": "node --test --experimental-strip-types tests/*.test.ts",
22
+ "test:load": "node --test --experimental-strip-types test-load.ts",
23
+ "test": "npm run check && npm run test:unit && npm run test:load"
24
+ },
25
+ "peerDependencies": {
26
+ "@earendil-works/pi-coding-agent": "*",
27
+ "@earendil-works/pi-tui": "*",
28
+ "typebox": "*"
29
+ },
30
+ "devDependencies": {
31
+ "@earendil-works/pi-coding-agent": "0.99.1",
32
+ "@earendil-works/pi-tui": "0.99.1",
33
+ "@types/node": "22.20.3",
34
+ "typebox": "1.3.7",
35
+ "typescript": "5.9.3"
36
+ }
37
+ }
package/pr-watch.ts ADDED
@@ -0,0 +1,203 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type WatchedTask = {
4
+ id: number;
5
+ state: string;
6
+ updated_at: string;
7
+ agent_notes: Record<string, string>;
8
+ };
9
+ type PR = { url: string; number: string };
10
+ type Dependencies = {
11
+ selected: (ctx: ExtensionContext) => Promise<WatchedTask | null>;
12
+ get: (id: number) => Promise<WatchedTask>;
13
+ done: (task: WatchedTask, canAct: () => boolean) => Promise<void>;
14
+ claim: () => (() => void) | undefined;
15
+ refresh: (ctx: ExtensionContext) => Promise<void>;
16
+ };
17
+ const entryType = "pinote-pr-acknowledged";
18
+ const watchEntryType = "pinote-pr-watched";
19
+
20
+ // Accept one bare URL or Markdown link, never arbitrary hosts or terminal escapes.
21
+ export function taskPR(task: WatchedTask | null): PR | undefined {
22
+ const field = task?.agent_notes.PR?.trim();
23
+ if (!field) return;
24
+ const url = /^\[[^\r\n]*\]\(([^\s]+)\)$/u.exec(field)?.[1] ?? field;
25
+ const match = /^https:\/\/github\.com\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_.-]+)\/pull\/([1-9][0-9]*)\/?$/u.exec(url);
26
+ if (!match || [".", ".."].includes(match[2]) || !Number.isSafeInteger(Number(match[3]))) return;
27
+ return { url: `https://github.com/${match[1]}/${match[2]}/pull/${match[3]}`, number: match[3] };
28
+ }
29
+ const keyFor = (task: WatchedTask, pr: PR) => JSON.stringify([task.id, pr.url.toLowerCase()]);
30
+ const watchKey = (task: WatchedTask | null) => {
31
+ const pr = taskPR(task);
32
+ return task && pr ? keyFor(task, pr) : undefined;
33
+ };
34
+
35
+ export function createPRWatcher(pi: ExtensionAPI, deps: Dependencies) {
36
+ let ctx: ExtensionContext | undefined;
37
+ let watched: WatchedTask | null = null;
38
+ let visiblePR: PR | undefined;
39
+ let timer: ReturnType<typeof setTimeout> | undefined;
40
+ let controller = new AbortController();
41
+ let generation = 0;
42
+ let revision = 0;
43
+ let running = false;
44
+ let interval = 60_000;
45
+ let warned = false;
46
+ let acknowledged = new Set<string>();
47
+
48
+ const remember = (current: WatchedTask | null) => {
49
+ const changed = watchKey(current) !== watchKey(watched);
50
+ watched = current;
51
+ if (changed && ctx) pi.appendEntry(watchEntryType, {
52
+ cwd: ctx.cwd, task: current ? { id: current.id, url: taskPR(current)!.url } : null,
53
+ });
54
+ return changed;
55
+ };
56
+ const paint = () => {
57
+ if (!ctx) return;
58
+ const pr = visiblePR;
59
+ ctx.ui.setStatus("pinote-pr", pr
60
+ ? ctx.ui.theme.fg("mdLink", `\x1b]8;;${pr.url}\x1b\\PR #${pr.number}\x1b]8;;\x1b\\`)
61
+ : undefined);
62
+ };
63
+ const schedule = (delay = interval) => {
64
+ if (!ctx || !interval || running) return;
65
+ clearTimeout(timer);
66
+ timer = setTimeout(() => { void poll(); }, delay);
67
+ timer.unref();
68
+ };
69
+ const update = (context: ExtensionContext, current: WatchedTask | null) => {
70
+ if (!ctx || ctx.cwd !== context.cwd) return;
71
+ revision++;
72
+ // Display only the active selection, independently of the retained merge watch.
73
+ visiblePR = current && ["active", "in_progress"].includes(current.state) ? taskPR(current) : undefined;
74
+ // Completion clears selection. Keep the last task until poll verifies its state.
75
+ const changed = current ? remember(taskPR(current) ? current : null) : false;
76
+ paint();
77
+ if (changed) schedule(0);
78
+ };
79
+ const latest = async (context: ExtensionContext, previous: WatchedTask | null) => {
80
+ const current = await deps.selected(context);
81
+ if (current) return taskPR(current) ? current : null;
82
+ if (!previous) return null;
83
+ const old = await deps.get(previous.id);
84
+ return old.state === "done" && taskPR(old) ? old : null;
85
+ };
86
+ async function poll() {
87
+ if (!ctx || running) return;
88
+ const context = ctx;
89
+ const epoch = generation;
90
+ const signal = controller.signal;
91
+ const valid = () => ctx === context && generation === epoch && !signal.aborted;
92
+ running = true;
93
+ try {
94
+ await deps.refresh(context);
95
+ if (!valid()) return;
96
+ const snapshot = revision;
97
+ const candidate = await latest(context, watched);
98
+ if (!valid() || revision !== snapshot) return;
99
+ remember(candidate);
100
+ paint();
101
+ const pr = taskPR(candidate);
102
+ if (!candidate || !pr) return;
103
+ const key = keyFor(candidate, pr);
104
+ const same = () => valid() && watched !== null && taskPR(watched)?.url === pr.url && watched.id === candidate.id;
105
+ if (acknowledged.has(key)) return;
106
+ const result = await pi.exec("gh", ["pr", "view", pr.url, "--json", "state,url"], {
107
+ cwd: context.cwd, timeout: 10_000, signal,
108
+ });
109
+ if (!same()) return;
110
+ if (result.code !== 0 || result.killed) throw new Error("GitHub CLI failed");
111
+ const data = JSON.parse(result.stdout);
112
+ // GitHub canonicalizes owner/repository casing in its response URL.
113
+ if (typeof data.url !== "string" || data.url.toLowerCase() !== pr.url.toLowerCase() ||
114
+ !["OPEN", "CLOSED", "MERGED"].includes(data.state)) throw new Error("Invalid PR response");
115
+ warned = false;
116
+ if (data.state !== "MERGED" || !context.isIdle()) return;
117
+ const release = deps.claim();
118
+ if (!release) return;
119
+ try {
120
+ const current = await latest(context, candidate);
121
+ if (!same() || !current || current.id !== candidate.id || taskPR(current)?.url !== pr.url || !context.isIdle()) return;
122
+ const acknowledge = () => {
123
+ pi.appendEntry(entryType, { cwd: context.cwd, key });
124
+ acknowledged.add(key);
125
+ };
126
+ const alreadyDone = () => {
127
+ context.ui.notify(`PR #${pr.number} was merged and the task is already completed.`, "info");
128
+ acknowledge();
129
+ };
130
+ if (current.state === "done") { alreadyDone(); return; }
131
+ if (!["active", "in_progress"].includes(current.state)) return;
132
+ const confirmed = await context.ui.confirm(`PR #${pr.number} was merged`, "Mark this task completed?", { signal });
133
+ if (!same() || !context.isIdle()) return;
134
+ // Re-read selection, link, and revision after the dialog; never complete a changed task.
135
+ const fresh = await latest(context, current);
136
+ if (!same() || !fresh || fresh.id !== current.id || taskPR(fresh)?.url !== pr.url) return;
137
+ if (fresh.state === "done") { alreadyDone(); return; }
138
+ if (fresh.updated_at !== current.updated_at) {
139
+ context.ui.notify("Pinote task changed while confirming. The merge will be checked again.", "warning");
140
+ return;
141
+ }
142
+ if (confirmed) {
143
+ await deps.done(fresh, () => same() && context.isIdle());
144
+ if (!valid()) return;
145
+ context.ui.notify(`Pinote #${fresh.id} completed.`, "info");
146
+ }
147
+ acknowledge();
148
+ } finally {
149
+ release();
150
+ if (valid()) await deps.refresh(context);
151
+ }
152
+ } catch {
153
+ if (valid() && !warned) {
154
+ warned = true;
155
+ context.ui.notify("Pinote PR check failed. Check gh authentication and note availability; polling will retry.", "warning");
156
+ }
157
+ } finally {
158
+ // A previous session's request must not affect a replacement watcher's timer.
159
+ if (valid()) { running = false; schedule(); }
160
+ }
161
+ }
162
+ return {
163
+ update,
164
+ start(context: ExtensionContext) {
165
+ this.stop();
166
+ if (!context.hasUI || context.mode !== "tui") return;
167
+ const seconds = Number(process.env.PINOTE_PR_POLL_SECONDS ?? "60");
168
+ interval = Number.isInteger(seconds) && (seconds === 0 || seconds >= 10 && seconds <= 86400)
169
+ ? seconds * 1000 : 60_000;
170
+ if (interval !== seconds * 1000) context.ui.notify("Invalid PINOTE_PR_POLL_SECONDS; using 60 seconds (allowed: 0 or 10โ€“86400).", "warning");
171
+ ctx = context;
172
+ const branch = context.sessionManager.getBranch();
173
+ // Persist only the identity; latest() must verify the real task state before polling.
174
+ for (const entry of branch) {
175
+ if (entry.type !== "custom" || entry.customType !== watchEntryType) continue;
176
+ const data = entry.data as { cwd?: unknown; task?: { id?: unknown; url?: unknown } | null } | undefined;
177
+ if (data?.cwd !== context.cwd) continue;
178
+ if (data.task === null) { watched = null; continue; }
179
+ if (!data.task || !Number.isSafeInteger(data.task.id) || Number(data.task.id) < 1 || typeof data.task.url !== "string") continue;
180
+ const restored = { id: Number(data.task.id), state: "unknown", updated_at: "", agent_notes: { PR: data.task.url } };
181
+ if (taskPR(restored)) watched = restored;
182
+ }
183
+ acknowledged = new Set(branch.flatMap((entry) => {
184
+ if (entry.type !== "custom" || entry.customType !== entryType) return [];
185
+ const data = entry.data as { cwd?: unknown; key?: unknown } | undefined;
186
+ return data?.cwd === context.cwd && typeof data.key === "string" ? [data.key] : [];
187
+ }));
188
+ schedule(0);
189
+ },
190
+ stop() {
191
+ generation++;
192
+ controller.abort();
193
+ controller = new AbortController();
194
+ clearTimeout(timer);
195
+ ctx?.ui.setStatus("pinote-pr", undefined);
196
+ ctx = undefined;
197
+ watched = null;
198
+ visiblePR = undefined;
199
+ running = false;
200
+ warned = false;
201
+ },
202
+ };
203
+ }
package/setup.ts ADDED
@@ -0,0 +1,103 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { stripVTControlCharacters } from "node:util";
3
+ import type { AutocompleteProvider } from "@earendil-works/pi-tui";
4
+
5
+ // Immutable source: the Python app is not yet published to PyPI.
6
+ export const cliSource = "https://github.com/kvidzibo/pinote/archive/9c6795f655e6b9d2c51c735fc84b6485fb663bb1.tar.gz";
7
+ export const bundledCLIVersion = "0.3.0";
8
+ export const setupHint = "Use /pi-note-setup for a missing CLI or /pi-note-upgrade for an older CLI; note must be on PATH.";
9
+ export type CLIAction = "setup" | "upgrade" | "ready";
10
+
11
+ export function cliAction(version: string): CLIAction {
12
+ const match = /^pinote (\d+)\.(\d+)\.(\d+)$/u.exec(version.trim());
13
+ if (!match) return "setup";
14
+ const installed = match.slice(1).map(BigInt);
15
+ const bundled = bundledCLIVersion.split(".").map(BigInt);
16
+ for (let i = 0; i < 3; i++) {
17
+ if (installed[i] < bundled[i]) return "upgrade";
18
+ if (installed[i] > bundled[i]) return "ready";
19
+ }
20
+ return "ready";
21
+ }
22
+
23
+ export async function detectCLI(pi: ExtensionAPI, signal?: AbortSignal): Promise<CLIAction> {
24
+ try {
25
+ const result = await pi.exec("note", ["--version"], { timeout: 5000, signal });
26
+ return !result.killed && result.code === 0 ? cliAction(result.stdout) : "setup";
27
+ } catch { return "setup"; }
28
+ }
29
+
30
+ export function cliMenu(current: AutocompleteProvider, action: () => CLIAction | undefined): AutocompleteProvider {
31
+ return {
32
+ triggerCharacters: current.triggerCharacters,
33
+ async getSuggestions(...args) {
34
+ const suggestions = await current.getSuggestions(...args);
35
+ if (!suggestions || !/^\/\S*$/u.test(suggestions.prefix)) return suggestions;
36
+ const items = suggestions.items.filter(({ value }) => {
37
+ const name = value.replace(/^\//u, "");
38
+ return name === "pi-note-setup" ? action() === "setup"
39
+ : name === "pi-note-upgrade" ? action() === "upgrade" : true;
40
+ });
41
+ return items.length ? { ...suggestions, items } : null;
42
+ },
43
+ applyCompletion: (...args) => current.applyCompletion(...args),
44
+ shouldTriggerFileCompletion: (...args) => current.shouldTriggerFileCompletion?.(...args) ?? true,
45
+ };
46
+ }
47
+ export function compatibleVersion(version: string): boolean {
48
+ const match = /^pinote (\d+)\.(\d+)\.(\d+)$/u.exec(version.trim());
49
+ return !!match && (Number(match[1]) > 0 || Number(match[2]) >= 3);
50
+ }
51
+ const clean = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f-\x9f]/gu, " ").slice(0, 1500);
52
+
53
+ export async function setupCLI(
54
+ pi: ExtensionAPI, ctx: ExtensionContext, upgrade: boolean,
55
+ canAct: () => boolean, signal: AbortSignal,
56
+ ): Promise<void> {
57
+ const exec = (command: string, args: string[], timeout = 5000) =>
58
+ pi.exec(command, args, { timeout, signal });
59
+ const action = await detectCLI(pi, signal);
60
+ if (!canAct() || signal.aborted) return;
61
+ if (action === "ready") {
62
+ ctx.ui.notify(`Pinote CLI is ready (bundled version ${bundledCLIVersion} or newer). Use /pi-note.`, "info");
63
+ return;
64
+ }
65
+ const command = action === "upgrade" ? "/pi-note-upgrade" : "/pi-note-setup";
66
+ if (upgrade !== (action === "upgrade")) {
67
+ ctx.ui.notify(`Use ${command} ${action === "upgrade" ? "to upgrade the older CLI" : "to install the missing CLI"}.`, "info");
68
+ return;
69
+ }
70
+ try {
71
+ const uv = await exec("uv", ["--version"]);
72
+ if (uv.killed || uv.code !== 0) throw new Error("uv unavailable");
73
+ } catch {
74
+ if (canAct()) ctx.ui.notify(`Install uv from https://docs.astral.sh/uv/getting-started/installation/ and restart Pi with uv on PATH, then run ${command}. Python 3.11+ is required; uv can download it.`, "error");
75
+ return;
76
+ }
77
+ if (!canAct() || signal.aborted) return;
78
+ const approved = await ctx.ui.confirm("Install Pinote CLI?",
79
+ `Install bundled pinote ${bundledCLIVersion} in uv's isolated tool environment (may replace an existing uv installation). ` +
80
+ "Downloads source and build dependencies; uv may download Python 3.11+. No GTK, shell configuration, or task changes. " +
81
+ `Source: ${cliSource}`);
82
+ if (!approved || !canAct() || signal.aborted) return;
83
+ const confirmedAction = await detectCLI(pi, signal);
84
+ if (!canAct() || signal.aborted) return;
85
+ if (confirmedAction !== action) {
86
+ ctx.ui.notify("Pinote CLI changed while confirmation was open; installation skipped.", "info");
87
+ return;
88
+ }
89
+ ctx.ui.notify("Installing Pinote CLI with uvโ€ฆ (up to 3 minutes)", "info");
90
+ const installed = await exec("uv", ["--no-config", "tool", "install", "--reinstall", "--python", ">=3.11", `pinote @ ${cliSource}`], 180000);
91
+ if (signal.aborted) return;
92
+ if (installed.killed || installed.code !== 0) {
93
+ throw new Error(`CLI installation failed or timed out: ${clean(installed.stderr || installed.stdout)}. Check uv/network access and retry ${command}. Conflicting executables are not overwritten; resolve them manually.`);
94
+ }
95
+ if (!canAct()) return;
96
+ try {
97
+ const version = await exec("note", ["--version"]);
98
+ if (version.killed || version.code !== 0 || cliAction(version.stdout) !== "ready") throw new Error("note unavailable or older than bundled CLI");
99
+ if (canAct()) ctx.ui.notify(`${clean(version.stdout.trim())} is ready. Use /pi-note. GTK is optional and installed separately.`, "info");
100
+ } catch {
101
+ if (canAct()) ctx.ui.notify("Pinote installed, but note on PATH is missing or older than the bundled CLI. Run uv tool dir --bin in your terminal, add that directory to PATH before older note executables, then restart Pi.", "warning");
102
+ }
103
+ }
package/task-picker.ts ADDED
@@ -0,0 +1,81 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Input, SelectList, truncateToWidth } from "@earendil-works/pi-tui";
3
+
4
+ export type TaskSummary = {
5
+ id: number;
6
+ text: string;
7
+ state: string;
8
+ tag: string | null;
9
+ };
10
+ const singleLine = (value: string) => value.replace(/\s+/gu, " ").trim();
11
+ export const taskState = (task: TaskSummary) => task.state === "in_progress" ? "โ—" : "โ—‹";
12
+ export const taskTag = (task: TaskSummary) => task.tag ? `[${singleLine(task.tag)}]` : "[Untagged]";
13
+
14
+ const selectionActions = ["tui.select.up", "tui.select.down", "tui.select.confirm", "tui.select.cancel"] as const;
15
+ type Matches = (data: string, action: typeof selectionActions[number]) => boolean;
16
+
17
+ export class TaskPicker {
18
+ private input = new Input({ prompt: "Search: ", placeholder: "task text, #id, tag, or state" });
19
+ private list!: SelectList;
20
+ private count = 0;
21
+
22
+ private tasks: TaskSummary[];
23
+ private theme: Theme;
24
+ private matches: Matches;
25
+ private done: (id: number | undefined) => void;
26
+ private requestRender: () => void;
27
+
28
+ constructor(tasks: TaskSummary[], theme: Theme, matches: Matches,
29
+ done: (id: number | undefined) => void, requestRender: () => void) {
30
+ this.tasks = tasks;
31
+ this.theme = theme;
32
+ this.matches = matches;
33
+ this.done = done;
34
+ this.requestRender = requestRender;
35
+ this.filter();
36
+ }
37
+
38
+ get focused() { return this.input.focused; }
39
+ set focused(value: boolean) { this.input.focused = value; }
40
+
41
+ private filter() {
42
+ const terms = this.input.getValue().toLocaleLowerCase().trim().split(/\s+/u).filter(Boolean);
43
+ const tasks = this.tasks.filter((task) => {
44
+ const search = `${task.text} #${task.id} ${taskTag(task)} ${task.state.replaceAll("_", " ")}`.toLocaleLowerCase();
45
+ return terms.every((term) => search.includes(term));
46
+ });
47
+ this.count = tasks.length;
48
+ this.list = new SelectList(tasks.map((task) => ({
49
+ value: String(task.id),
50
+ label: `${taskState(task)} ${taskTag(task)} #${task.id} ${singleLine(task.text)}`,
51
+ })), 8, {
52
+ selectedPrefix: (s) => this.theme.fg("accent", s),
53
+ selectedText: (s) => this.theme.fg("accent", s),
54
+ description: (s) => this.theme.fg("muted", s),
55
+ scrollInfo: (s) => this.theme.fg("dim", s),
56
+ noMatch: (s) => this.theme.fg("warning", s),
57
+ });
58
+ this.list.onSelect = (item) => this.done(Number(item.value));
59
+ this.list.onCancel = () => this.done(undefined);
60
+ }
61
+
62
+ handleInput(data: string) {
63
+ if (selectionActions.some((action) => this.matches(data, action))) this.list.handleInput(data);
64
+ else {
65
+ const before = this.input.getValue();
66
+ this.input.handleInput(data);
67
+ if (this.input.getValue() !== before) this.filter();
68
+ }
69
+ this.requestRender();
70
+ }
71
+
72
+ render(width: number) {
73
+ return [
74
+ truncateToWidth(this.theme.fg("accent", `Pinote โ€” select a task (${this.count}/${this.tasks.length})`), width),
75
+ ...this.input.render(width),
76
+ ...this.list.render(width),
77
+ truncateToWidth(this.theme.fg("dim", "Type to filter ยท โ†‘โ†“ navigate ยท Enter select ยท Esc cancel"), width),
78
+ ];
79
+ }
80
+ invalidate() { this.input.invalidate(); this.list.invalidate(); }
81
+ }