pi-note 0.9.0 → 0.11.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 +91 -18
- package/footer-config.ts +124 -0
- package/footer-links.ts +45 -20
- package/footer-settings.ts +206 -0
- package/index.ts +85 -42
- package/package.json +2 -2
- package/pr-watch.ts +3 -1
package/README.md
CHANGED
|
@@ -61,12 +61,15 @@ standalone `settings/pi/extensions/pinote.ts` entry if installed; load only one
|
|
|
61
61
|
The agent fetches current task text and saved handoff fields when you submit;
|
|
62
62
|
they are not copied into the draft. The default prompt asks for a summary, not implementation.
|
|
63
63
|
Existing drafts are preserved; nothing is submitted automatically.
|
|
64
|
-
With a selected task it offers **Continue**, **Done**,
|
|
64
|
+
With a selected task it offers **Continue**, **Done**, **Switch task**, and **Settings**.
|
|
65
|
+
Press **Tab** from the task menu or picker to open global settings; Tab
|
|
66
|
+
returns to tasks without saving. Settings also lists fields found on the current
|
|
67
|
+
task, so existing values can be configured without creating another task.
|
|
65
68
|
Type in the task picker to filter by text, ID, tag, or state (all words must match).
|
|
66
69
|
Use ↑/↓ and Enter to select, or Esc to cancel. Rows show **●** for in progress or
|
|
67
70
|
**○** for active, followed by the tag (`[Untagged]` when absent).
|
|
68
71
|
The normal Pi status area shows `📌 [tag] Task title` without replacing other footers.
|
|
69
|
-
The footer omits the task ID and state and shows only the first line, truncated to 60 terminal columns including the pin and tag.
|
|
72
|
+
The footer omits the task ID and state and shows only the first line, truncated to the configured width (default 60 terminal columns including the pin and tag). Overflow ends with `...`.
|
|
70
73
|
The pin glyph is bundled in `icons/note.txt`; it uses the terminal's emoji font, not a Nerd Font or icon theme.
|
|
71
74
|
|
|
72
75
|
Each Pi session remembers its own task, including several sessions in one folder.
|
|
@@ -106,6 +109,12 @@ and selected tasks. Run `/reload` after changing this setting. Invalid policy
|
|
|
106
109
|
values or unreadable/malformed configuration suppress offers; interactive startup
|
|
107
110
|
warns until you fix the file and reload. No project-local configuration is read.
|
|
108
111
|
|
|
112
|
+
In `/pi-note → Settings`, select **Task prompt** to edit the text inserted after
|
|
113
|
+
selection or **Continue**. **Shift+Enter** or **Ctrl+J** adds a newline; **Enter** applies it to
|
|
114
|
+
the settings draft, **Ctrl+C** clears it, and **Esc** cancels that edit. Select **Save settings** to
|
|
115
|
+
persist it globally; leaving Settings with **Esc** or **Tab** discards the draft.
|
|
116
|
+
Saving never changes existing editor input or submits anything.
|
|
117
|
+
|
|
109
118
|
Task selection and **Continue** reread `handoffPrompt` each time; changing that
|
|
110
119
|
field needs no `/reload`. Its value is literal text, not a prompt template; use
|
|
111
120
|
`\n` in JSON strings for multiple lines. It must be a nonblank string without control
|
|
@@ -123,6 +132,10 @@ user-level configuration only; project-local files are not read.
|
|
|
123
132
|
ID argument. Updates merge fields, never replace the task text or tag. Fails if
|
|
124
133
|
no task is selected or the selection changes during the operation. If another
|
|
125
134
|
process changed the task, read again before retrying.
|
|
135
|
+
- `pinote_fields`: read the user's global footer definitions (names, display labels,
|
|
136
|
+
links, formats, widths), even without a task. Agents should read this before
|
|
137
|
+
populating relevant values with `pinote_update_current`. Empty/missing values
|
|
138
|
+
stay hidden; this tool does not edit configuration.
|
|
126
139
|
- `pinote_tags`: list saved tag names.
|
|
127
140
|
- `pinote_add`: create an active task. Optional `tag`. `select: true` starts and
|
|
128
141
|
remembers it for this session only; use that only after the user agrees.
|
|
@@ -138,13 +151,14 @@ user asks to switch. Reuse a saved tag name when it fits.
|
|
|
138
151
|
|
|
139
152
|
Agents are guided to keep notes to three short bullets total: relevant outcome,
|
|
140
153
|
blocker, and next action. Replace stale notes; omit narration, repeated task text,
|
|
141
|
-
and routine test logs. Keep a GitHub pull request in `PR`.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
154
|
+
and routine test logs. Keep a GitHub pull request in `PR`. Set configured footer
|
|
155
|
+
fields with Markdown values; remove a field to hide it. Without a configured field
|
|
156
|
+
list, append its label to `Bar`, one label per line. Do not list `PR` in `Bar`.
|
|
157
|
+
Agents use `pinote_update_current`; there is no separate `add_to_bottom_bar` tool.
|
|
158
|
+
This is guidance, not truncation or a storage limit.
|
|
145
159
|
|
|
146
160
|
Values are Markdown strings; no fields are required. `PR` enables the watcher below.
|
|
147
|
-
`Bar` chooses extra footer fields:
|
|
161
|
+
`Bar` chooses extra footer fields unless configuration overrides it:
|
|
148
162
|
|
|
149
163
|
```markdown
|
|
150
164
|
# Agent
|
|
@@ -163,31 +177,90 @@ sessions. Labels are case-sensitive (trimmed, Unicode-normalized), at most 64
|
|
|
163
177
|
characters; values are at most 4096 characters. Maximum 64 fields / 32 KiB JSON
|
|
164
178
|
per task. Use removal rather than empty values.
|
|
165
179
|
|
|
180
|
+
### Footer configuration
|
|
181
|
+
|
|
182
|
+
Add `footer` to `~/.pi/agent/pi-note.json` (or `pi-note.json` under
|
|
183
|
+
`PI_CODING_AGENT_DIR`), preserving any existing `handoffPrompt`:
|
|
184
|
+
|
|
185
|
+
```json
|
|
186
|
+
{
|
|
187
|
+
"footer": {
|
|
188
|
+
"titleWidth": 40,
|
|
189
|
+
"fieldWidth": 30,
|
|
190
|
+
"maxFields": 4,
|
|
191
|
+
"fields": [
|
|
192
|
+
{ "name": "PR", "label": "", "link": true, "format": "#<number>" },
|
|
193
|
+
{ "name": "Dashboard", "label": "Dashboard", "link": true, "format": "<value>" },
|
|
194
|
+
{ "name": "Next", "label": "", "link": false, "format": "<value>", "width": 24 }
|
|
195
|
+
]
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Widths are terminal columns, including icons/tags or field labels, and must be
|
|
201
|
+
integers from 3 to 1000. Defaults: `titleWidth: 60`, `fieldWidth: 60`,
|
|
202
|
+
`maxFields: 4` (allowed 0–64). Each field can override `fieldWidth` with `width`.
|
|
203
|
+
`fields` selects task field names in order, regardless of a task's `Bar`.
|
|
204
|
+
Names are case-sensitive, trimmed and NFC-normalized, at most 64 characters;
|
|
205
|
+
`Bar` is reserved and duplicate names are rejected. Up to 64 definitions are
|
|
206
|
+
accepted. Missing/blank values do not display and do not consume the field limit.
|
|
207
|
+
`[]` or `maxFields: 0` hides configured fields, including PR.
|
|
208
|
+
|
|
209
|
+
In `/pi-note → Settings`, **Add field** adds a global display definition, not a
|
|
210
|
+
task value. Select any field to edit **Link**, **Label**, **Format**, and **Width**.
|
|
211
|
+
Label starts as the field name; clear it for no prefix. Link off displays plain
|
|
212
|
+
text without a clickable link. **Save** persists the draft; cancellation leaves
|
|
213
|
+
configuration untouched. Saves include the task prompt and preserve unrelated
|
|
214
|
+
settings (such as `taskOfferPolicy`) and reject configuration changes made while the dialog was open.
|
|
215
|
+
Concurrent saves use `pi-note.json.lock`; remove stale locks only when no save is
|
|
216
|
+
running. Symlinked configuration must be edited manually; saves never replace the link.
|
|
217
|
+
|
|
218
|
+
Formats support `<value>` (Markdown display text), `<url>` (one safe link target),
|
|
219
|
+
and `<number>` (a GitHub PR number or numeric field value). With a blank label,
|
|
220
|
+
PR format `#<number>` displays `#123`; Link on makes it clickable. An unavailable
|
|
221
|
+
placeholder hides the field. The template is literal text, not executable code
|
|
222
|
+
or Markdown. Default format is `<value>`, or `#<number>` for PR.
|
|
223
|
+
String definitions and the older `{ "label": "Next", "width": 24 }` form still
|
|
224
|
+
work. Absent/`null` fields retain the legacy automatic PR link plus `Bar` fields;
|
|
225
|
+
the first Settings save turns this legacy list into explicit global definitions.
|
|
226
|
+
|
|
227
|
+
Settings saves apply immediately in this Pi session. Run `/reload` after manual
|
|
228
|
+
footer edits or in other running sessions; settings are also reread on session start.
|
|
229
|
+
Invalid footer configuration warns and uses footer defaults; missing files silently
|
|
230
|
+
use defaults. The task prompt is also read separately on selection/Continue as
|
|
231
|
+
described above; prompt changes need no reload in any running session.
|
|
232
|
+
This is user-level configuration, not task data; agents should not change it
|
|
233
|
+
without approval. Task values still come from `pinote_update_current`.
|
|
234
|
+
|
|
166
235
|
### Footer links
|
|
167
236
|
|
|
168
237
|
The selected active or in-progress task can show fields after its title.
|
|
169
|
-
|
|
170
|
-
|
|
238
|
+
Legacy configuration automatically shows a valid `PR` as **PR #123**.
|
|
239
|
+
With an explicit field list, PR presentation is configured like any other field.
|
|
240
|
+
Configured `fields`, or otherwise `Bar`, selects field names. Each listed value is
|
|
171
241
|
Markdown: the footer shows its text, and links in it are clickable for any scheme
|
|
172
242
|
except `javascript:`, `data:`, and `vbscript:`. Credentials, control characters,
|
|
173
243
|
and targets over 2048 characters after serialization are shown as text but not
|
|
174
|
-
linked.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
244
|
+
linked. `Bar` itself is never displayed.
|
|
245
|
+
By default, at most four extra fields are shown, each truncated to 60 columns.
|
|
246
|
+
Overflow ends with `...`, including cuts between Markdown/link segments. Removing
|
|
247
|
+
a listed field hides it even if configuration or `Bar` still names it. Terminal
|
|
248
|
+
OSC 8 support is required for clicking links. Pi joins footer statuses on one
|
|
249
|
+
line and truncates the combined line with `...` when the terminal is narrow;
|
|
250
|
+
this extension does not replace other footers. Other fields, such as `Jira` and
|
|
251
|
+
`CWD` in the example, stay off the footer unless selected.
|
|
180
252
|
|
|
181
253
|
### PR merge watcher
|
|
182
254
|
|
|
183
255
|
Set `PR` with `pinote_update_current` to one `https://github.com/owner/repo/pull/123` URL
|
|
184
|
-
or Markdown link.
|
|
185
|
-
|
|
256
|
+
or Markdown link. Legacy footer settings show a clickable **PR #123**; global
|
|
257
|
+
field settings can change or hide that presentation without disabling the watcher.
|
|
258
|
+
Other hosts, multiple links, and prose are not watched.
|
|
186
259
|
|
|
187
260
|
Interactive Pi checks only the selected task's PR using authenticated `gh`
|
|
188
261
|
(`gh auth login`). Polling defaults to 60 seconds; launch Pi with
|
|
189
262
|
`PINOTE_PR_POLL_SECONDS=120 pi` to change it, or `0` to disable polling.
|
|
190
|
-
Allowed intervals are 10–86400 seconds.
|
|
263
|
+
Allowed intervals are 10–86400 seconds. Configured links remain visible when polling is disabled.
|
|
191
264
|
Network/authentication failures warn once until recovery and retry next interval.
|
|
192
265
|
No polling runs in print/RPC mode or after Pi exits.
|
|
193
266
|
|
package/footer-config.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
export const defaultHandoffPrompt = "Read the current Pinote task. Summarize your understanding, but don’t start work yet.";
|
|
7
|
+
export function parseHandoffPrompt(value: unknown): string {
|
|
8
|
+
if (typeof value !== "string" || !value.trim() || /[\x00-\x08\x0b-\x1f\x7f-\x9f]/u.test(value)) {
|
|
9
|
+
throw new Error("handoffPrompt in pi-note.json must be a nonblank string without control characters (except tabs/newlines).");
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
export type PinoteSettings = { footer: FooterConfig; handoffPrompt: string };
|
|
14
|
+
|
|
15
|
+
export type FooterField = { name: string; label: string; link: boolean; format: string; width?: number };
|
|
16
|
+
export type FooterConfig = { titleWidth: number; fieldWidth: number; maxFields: number; fields: FooterField[] | null };
|
|
17
|
+
export const defaultFooterConfig: FooterConfig = { titleWidth: 60, fieldWidth: 60, maxFields: 4, fields: null };
|
|
18
|
+
export const defaultFooterField = (name: string): FooterField => ({
|
|
19
|
+
name, label: name, link: true, format: name === "PR" ? "#<number>" : "<value>",
|
|
20
|
+
});
|
|
21
|
+
const object = (value: unknown): value is Record<string, unknown> =>
|
|
22
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
23
|
+
const width = (value: unknown): value is number => Number.isInteger(value) && Number(value) >= 3 && Number(value) <= 1000;
|
|
24
|
+
const safeText = (value: unknown): value is string => typeof value === "string" && !/[\x00-\x1f\x7f-\x9f]/u.test(value);
|
|
25
|
+
|
|
26
|
+
export function parseFooterConfig(value: unknown): FooterConfig {
|
|
27
|
+
if (!object(value)) throw new Error("expected an object");
|
|
28
|
+
if (value.footer === undefined) return { ...defaultFooterConfig };
|
|
29
|
+
if (!object(value.footer)) throw new Error("footer must be an object");
|
|
30
|
+
const footer = value.footer;
|
|
31
|
+
for (const key of ["titleWidth", "fieldWidth"] as const) {
|
|
32
|
+
if (footer[key] !== undefined && !width(footer[key])) throw new Error(`${key} must be an integer from 3 to 1000`);
|
|
33
|
+
}
|
|
34
|
+
if (footer.maxFields !== undefined &&
|
|
35
|
+
(!Number.isInteger(footer.maxFields) || Number(footer.maxFields) < 0 || Number(footer.maxFields) > 64)) {
|
|
36
|
+
throw new Error("maxFields must be an integer from 0 to 64");
|
|
37
|
+
}
|
|
38
|
+
let fields: FooterField[] | null = null;
|
|
39
|
+
if (footer.fields !== undefined && footer.fields !== null) {
|
|
40
|
+
if (!Array.isArray(footer.fields) || footer.fields.length > 64) throw new Error("fields must be null or an array of at most 64 fields");
|
|
41
|
+
const seen = new Set<string>();
|
|
42
|
+
fields = footer.fields.map((entry): FooterField => {
|
|
43
|
+
const field = typeof entry === "string" ? { name: entry } : entry;
|
|
44
|
+
if (!object(field)) throw new Error("each field needs a name");
|
|
45
|
+
// Accept the original {label: name, width} form as well as explicit names.
|
|
46
|
+
const source = field.name === undefined ? field.label : field.name;
|
|
47
|
+
if (!safeText(source)) throw new Error("invalid field name");
|
|
48
|
+
const name = source.normalize("NFC").trim();
|
|
49
|
+
if (!name || [...name].length > 64 || name === "Bar") throw new Error("invalid field name");
|
|
50
|
+
if (seen.has(name)) throw new Error("duplicate field name");
|
|
51
|
+
seen.add(name);
|
|
52
|
+
const label = field.name === undefined ? name : field.label === undefined ? name : field.label;
|
|
53
|
+
const format = field.format === undefined ? defaultFooterField(name).format : field.format;
|
|
54
|
+
if (!safeText(label) || [...label].length > 64) throw new Error("invalid display label");
|
|
55
|
+
if (!safeText(format) || format.length > 4096 || /<(?!value>|number>|url>)[^>]*>/u.test(format)) {
|
|
56
|
+
throw new Error("format supports only <value>, <number>, and <url>");
|
|
57
|
+
}
|
|
58
|
+
if (field.link !== undefined && typeof field.link !== "boolean") throw new Error("link must be a boolean");
|
|
59
|
+
if (field.width !== undefined && !width(field.width)) throw new Error("field width must be an integer from 3 to 1000");
|
|
60
|
+
return { name, label, link: field.link as boolean | undefined ?? true, format,
|
|
61
|
+
...(field.width === undefined ? {} : { width: field.width as number }) };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
titleWidth: footer.titleWidth as number | undefined ?? defaultFooterConfig.titleWidth,
|
|
66
|
+
fieldWidth: footer.fieldWidth as number | undefined ?? defaultFooterConfig.fieldWidth,
|
|
67
|
+
maxFields: footer.maxFields as number | undefined ?? defaultFooterConfig.maxFields, fields,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readRaw(): string | null {
|
|
72
|
+
try { return readFileSync(join(getAgentDir(), "pi-note.json"), "utf8"); }
|
|
73
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; }
|
|
74
|
+
}
|
|
75
|
+
export function readFooterDocument() {
|
|
76
|
+
const raw = readRaw();
|
|
77
|
+
let value: unknown = {};
|
|
78
|
+
if (raw !== null) {
|
|
79
|
+
try { value = JSON.parse(raw); } catch { throw new Error("Invalid JSON in pi-note.json."); }
|
|
80
|
+
}
|
|
81
|
+
return { raw, config: parseFooterConfig(value) };
|
|
82
|
+
}
|
|
83
|
+
export function loadFooterConfig(warn: (message: string) => void): FooterConfig {
|
|
84
|
+
try { return readFooterDocument().config; }
|
|
85
|
+
catch {
|
|
86
|
+
warn("Cannot load pi-note.json; using default Pinote footer settings. Check the footer configuration in pi-note's README.");
|
|
87
|
+
return { ...defaultFooterConfig };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Synchronous compare-and-replace: reject edits made while the settings dialog was open,
|
|
92
|
+
// preserve unrelated keys, and never leave partial JSON behind.
|
|
93
|
+
export function saveFooterConfig(config: FooterConfig, expectedRaw: string | null, handoffPrompt?: string): FooterConfig {
|
|
94
|
+
const checked = parseFooterConfig({ footer: config });
|
|
95
|
+
const prompt = handoffPrompt === undefined ? {} : { handoffPrompt: parseHandoffPrompt(handoffPrompt) };
|
|
96
|
+
const directory = getAgentDir();
|
|
97
|
+
mkdirSync(directory, { recursive: true });
|
|
98
|
+
const path = join(directory, "pi-note.json");
|
|
99
|
+
const lock = join(directory, "pi-note.json.lock");
|
|
100
|
+
let descriptor: number;
|
|
101
|
+
try { descriptor = openSync(lock, "wx", 0o600); }
|
|
102
|
+
catch { throw new Error("Pinote settings are locked. Retry after another save finishes; remove a stale pi-note.json.lock only when no save is running."); }
|
|
103
|
+
const temp = join(directory, `.pi-note-${randomUUID()}.tmp`);
|
|
104
|
+
try {
|
|
105
|
+
if (readRaw() !== expectedRaw) throw new Error("Pi-note configuration changed while settings were open. Reopen settings before saving.");
|
|
106
|
+
if (lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error("pi-note.json is a symlink. Edit its target manually rather than replacing the link.");
|
|
107
|
+
const root = expectedRaw === null ? {} : JSON.parse(expectedRaw);
|
|
108
|
+
if (!object(root)) throw new Error("Invalid pi-note configuration; expected an object.");
|
|
109
|
+
writeFileSync(temp, `${JSON.stringify({ ...root, ...prompt, footer: checked }, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
110
|
+
renameSync(temp, path);
|
|
111
|
+
} finally { rmSync(temp, { force: true }); closeSync(descriptor); rmSync(lock, { force: true }); }
|
|
112
|
+
return checked;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function effectiveFooterFields(config: FooterConfig, notes: Record<string, string> = {}): FooterField[] {
|
|
116
|
+
if (config.fields !== null) return config.fields;
|
|
117
|
+
const seen = new Set(["Bar"]);
|
|
118
|
+
return ["PR", ...(notes.Bar ?? "").split("\n")].flatMap((raw) => {
|
|
119
|
+
const name = raw.normalize("NFC").trim();
|
|
120
|
+
if (!name || seen.has(name)) return [];
|
|
121
|
+
seen.add(name);
|
|
122
|
+
return [defaultFooterField(name)];
|
|
123
|
+
});
|
|
124
|
+
}
|
package/footer-links.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
2
|
import { stripVTControlCharacters } from "node:util";
|
|
3
|
+
import { defaultFooterConfig, defaultFooterField, type FooterConfig, type FooterField } from "./footer-config.ts";
|
|
3
4
|
|
|
4
5
|
export const footerStatusKey = "pinote-links";
|
|
5
6
|
export const legacyFooterStatusKey = "pinote-pr";
|
|
6
|
-
const maxCustomChips = 4;
|
|
7
7
|
const maxLabelWidth = 24;
|
|
8
|
-
const maxChipWidth = 60;
|
|
9
8
|
const maxUrlLength = 2048;
|
|
10
9
|
const blockedProtocols = new Set(["javascript:", "data:", "vbscript:"]);
|
|
11
10
|
const control = /[\u0000-\u001f\u007f-\u009f]/u;
|
|
@@ -167,32 +166,57 @@ export function renderMarkdown(value: string | undefined): FooterSegment[] {
|
|
|
167
166
|
}
|
|
168
167
|
|
|
169
168
|
function truncateSegments(segments: readonly FooterSegment[], width: number): FooterSegment[] {
|
|
169
|
+
if (segments.reduce((sum, segment) => sum + visibleWidth(segment.text), 0) <= width) return [...segments];
|
|
170
170
|
const kept: FooterSegment[] = [];
|
|
171
|
-
let
|
|
171
|
+
let room = width - 3;
|
|
172
172
|
for (const segment of segments) {
|
|
173
|
-
if (
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
used += visibleWidth(text);
|
|
173
|
+
if (room <= 0) break;
|
|
174
|
+
const text = stripVTControlCharacters(truncateToWidth(segment.text, room, ""));
|
|
175
|
+
if (text) kept.push(segment.url ? { text, url: segment.url } : { text });
|
|
176
|
+
room -= visibleWidth(text);
|
|
177
|
+
if (text !== segment.text) break;
|
|
179
178
|
}
|
|
179
|
+
// Keep the ellipsis outside hyperlinks, including cuts at segment boundaries.
|
|
180
|
+
kept.push({ text: "..." });
|
|
180
181
|
return kept;
|
|
181
182
|
}
|
|
182
183
|
|
|
183
|
-
|
|
184
|
+
function fieldSegments(field: FooterField, value: string | undefined): FooterSegment[] {
|
|
185
|
+
if (!value?.trim()) return [];
|
|
186
|
+
const original = renderMarkdown(value);
|
|
187
|
+
const urls = [...new Set(original.flatMap((part) => part.url ? [part.url] : []))];
|
|
188
|
+
const url = urls.length === 1 ? urls[0] : undefined;
|
|
189
|
+
const number = url && /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/([1-9][0-9]*)\/?$/u.exec(url)?.[1]
|
|
190
|
+
|| (/^[0-9]+$/u.test(value.trim()) ? value.trim() : undefined);
|
|
191
|
+
if (field.format.includes("<number>") && !number || field.format.includes("<url>") && !url) return [];
|
|
192
|
+
let body: FooterSegment[];
|
|
193
|
+
if (field.format === "<value>") body = original;
|
|
194
|
+
else {
|
|
195
|
+
const shown = plain(field.format.replace(/<(value|number|url)>/gu, (_match, key: string) =>
|
|
196
|
+
({ value: original.map((part) => part.text).join(""), number: number ?? "", url: url ?? "" })[key as "value" | "number" | "url"]));
|
|
197
|
+
body = shown ? [url ? { text: shown, url } : { text: shown }] : [];
|
|
198
|
+
}
|
|
199
|
+
if (!field.link) body = body.map((part) => ({ text: part.text }));
|
|
200
|
+
if (!body.length) return [];
|
|
201
|
+
const label = clip(field.label, maxLabelWidth);
|
|
202
|
+
return [...(label ? [{ text: `${label}: ` }] : []), ...body];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function customFooterChips(
|
|
206
|
+
notes: Record<string, string> | undefined, config: FooterConfig = defaultFooterConfig,
|
|
207
|
+
): FooterChip[] {
|
|
184
208
|
if (!notes) return [];
|
|
185
209
|
const chips: FooterChip[] = [];
|
|
186
|
-
const seen = new Set(["PR", "Bar"]);
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
210
|
+
const seen = new Set(config.fields === null ? ["PR", "Bar"] : ["Bar"]);
|
|
211
|
+
const fields: FooterField[] = config.fields ?? (notes.Bar ?? "").split("\n").map(defaultFooterField);
|
|
212
|
+
for (const field of fields) {
|
|
213
|
+
if (chips.length >= config.maxFields) break;
|
|
214
|
+
const name = field.name.normalize("NFC").trim();
|
|
190
215
|
if (!name || seen.has(name)) continue;
|
|
191
216
|
seen.add(name);
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
chips.push({ segments: truncateSegments([{ text: `${label}: ` }, ...body], maxChipWidth) });
|
|
217
|
+
const segments = fieldSegments(field, ownText(notes, name));
|
|
218
|
+
if (!segments.length) continue;
|
|
219
|
+
chips.push({ segments: truncateSegments(segments, field.width ?? config.fieldWidth) });
|
|
196
220
|
}
|
|
197
221
|
return chips;
|
|
198
222
|
}
|
|
@@ -200,11 +224,12 @@ export function customFooterChips(notes: Record<string, string> | undefined): Fo
|
|
|
200
224
|
export function footerChips(
|
|
201
225
|
task: { state: string; agent_notes: Record<string, string> } | null,
|
|
202
226
|
pr: { url: string; number: string } | undefined,
|
|
227
|
+
config: FooterConfig = defaultFooterConfig,
|
|
203
228
|
): FooterChip[] {
|
|
204
229
|
if (!task || !["active", "in_progress"].includes(task.state)) return [];
|
|
205
230
|
return [
|
|
206
|
-
...(pr ? [{ segments: [{ text: `PR #${pr.number}`, url: pr.url }] }] : []),
|
|
207
|
-
...customFooterChips(task.agent_notes),
|
|
231
|
+
...(config.fields === null && pr ? [{ segments: [{ text: `PR #${pr.number}`, url: pr.url }] }] : []),
|
|
232
|
+
...customFooterChips(task.agent_notes, config),
|
|
208
233
|
];
|
|
209
234
|
}
|
|
210
235
|
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Editor, Input, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { defaultFooterField, parseFooterConfig, parseHandoffPrompt, type PinoteSettings, type FooterConfig, type FooterField } from "./footer-config.ts";
|
|
4
|
+
|
|
5
|
+
type Matches = (data: string, action: string) => boolean;
|
|
6
|
+
type Row = { label: string; action: () => void };
|
|
7
|
+
const isTab = (data: string) => matchesKey(data, "tab") || matchesKey(data, "shift+tab");
|
|
8
|
+
const display = (value: string) => value.replace(/[\x00-\x1f\x7f-\x9f]/gu, " ");
|
|
9
|
+
const actions = ["Continue", "Done", "Switch task", "Settings"];
|
|
10
|
+
|
|
11
|
+
export class TaskMenu {
|
|
12
|
+
private index = 0;
|
|
13
|
+
private title: string;
|
|
14
|
+
private theme: Theme;
|
|
15
|
+
private matches: Matches;
|
|
16
|
+
private done: (action: string | undefined) => void;
|
|
17
|
+
private requestRender: () => void;
|
|
18
|
+
constructor(title: string, theme: Theme, matches: Matches,
|
|
19
|
+
done: (action: string | undefined) => void, requestRender: () => void) {
|
|
20
|
+
this.title = title; this.theme = theme; this.matches = matches;
|
|
21
|
+
this.done = done; this.requestRender = requestRender;
|
|
22
|
+
}
|
|
23
|
+
handleInput(data: string) {
|
|
24
|
+
if (isTab(data)) this.done("Settings");
|
|
25
|
+
else if (this.matches(data, "tui.select.up")) this.index = (this.index + actions.length - 1) % actions.length;
|
|
26
|
+
else if (this.matches(data, "tui.select.down")) this.index = (this.index + 1) % actions.length;
|
|
27
|
+
else if (this.matches(data, "tui.select.confirm")) this.done(actions[this.index]);
|
|
28
|
+
else if (this.matches(data, "tui.select.cancel")) this.done(undefined);
|
|
29
|
+
this.requestRender();
|
|
30
|
+
}
|
|
31
|
+
render(width: number) {
|
|
32
|
+
return [this.theme.fg("accent", this.title), this.theme.fg("dim", "Tasks · Settings (Tab)"),
|
|
33
|
+
...actions.map((action, index) => index === this.index ? this.theme.fg("accent", `> ${action}`) : ` ${action}`),
|
|
34
|
+
this.theme.fg("dim", "↑↓ navigate · Enter select · Esc cancel")].map((line) => truncateToWidth(line, width));
|
|
35
|
+
}
|
|
36
|
+
invalidate() {}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class FooterSettings {
|
|
40
|
+
private draft: FooterConfig;
|
|
41
|
+
private prompt: string;
|
|
42
|
+
private editor?: Editor;
|
|
43
|
+
private createEditor: () => Editor;
|
|
44
|
+
private knownFields: string[];
|
|
45
|
+
private theme: Theme;
|
|
46
|
+
private matches: Matches;
|
|
47
|
+
private done: (result: PinoteSettings | "tasks" | undefined) => void;
|
|
48
|
+
private requestRender: () => void;
|
|
49
|
+
private index = 0;
|
|
50
|
+
private field?: FooterField;
|
|
51
|
+
private input?: { widget: Input; apply: (value: string) => void; heading: string };
|
|
52
|
+
private error = "";
|
|
53
|
+
private hasFocus = false;
|
|
54
|
+
|
|
55
|
+
constructor(config: PinoteSettings, knownFields: string[], theme: Theme, matches: Matches,
|
|
56
|
+
done: (result: PinoteSettings | "tasks" | undefined) => void, requestRender: () => void, createEditor: () => Editor) {
|
|
57
|
+
this.draft = structuredClone(config.footer);
|
|
58
|
+
this.prompt = config.handoffPrompt;
|
|
59
|
+
this.createEditor = createEditor;
|
|
60
|
+
this.draft.fields ??= [];
|
|
61
|
+
this.knownFields = [...new Set(knownFields)].filter((name) => name !== "Bar");
|
|
62
|
+
this.theme = theme; this.matches = matches; this.done = done; this.requestRender = requestRender;
|
|
63
|
+
}
|
|
64
|
+
get focused() { return this.hasFocus; }
|
|
65
|
+
set focused(value: boolean) {
|
|
66
|
+
this.hasFocus = value;
|
|
67
|
+
if (this.input) this.input.widget.focused = value;
|
|
68
|
+
if (this.editor) this.editor.focused = value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private ask(heading: string, value: string, apply: (value: string) => void) {
|
|
72
|
+
const widget = new Input();
|
|
73
|
+
widget.setValue(value); widget.focused = this.hasFocus;
|
|
74
|
+
this.input = { widget, heading, apply }; this.error = "";
|
|
75
|
+
}
|
|
76
|
+
private validate(field: FooterField): FooterField {
|
|
77
|
+
return parseFooterConfig({ footer: { fields: [field] } }).fields![0];
|
|
78
|
+
}
|
|
79
|
+
private edit(name: string) {
|
|
80
|
+
this.field = structuredClone(this.draft.fields!.find((field) => field.name === name) ?? defaultFooterField(name));
|
|
81
|
+
this.index = 0; this.error = "";
|
|
82
|
+
}
|
|
83
|
+
private rootRows(): Row[] {
|
|
84
|
+
const rows: Row[] = (["titleWidth", "fieldWidth", "maxFields"] as const).map((key) => ({
|
|
85
|
+
label: `${{ titleWidth: "Title width", fieldWidth: "Default field width", maxFields: "Max fields" }[key]}: ${this.draft[key]}`,
|
|
86
|
+
action: () => this.ask(key, String(this.draft[key]), (value) => {
|
|
87
|
+
const candidate = { ...this.draft, [key]: Number(value) };
|
|
88
|
+
if (!value.trim()) throw new Error("Enter a number.");
|
|
89
|
+
this.draft = parseFooterConfig({ footer: candidate });
|
|
90
|
+
}),
|
|
91
|
+
}));
|
|
92
|
+
rows.push({ label: `Task prompt: ${display(this.prompt)}`, action: () => {
|
|
93
|
+
this.editor = this.createEditor();
|
|
94
|
+
this.editor.disableSubmit = true;
|
|
95
|
+
this.editor.setText(this.prompt);
|
|
96
|
+
this.editor.focused = this.hasFocus;
|
|
97
|
+
} });
|
|
98
|
+
const configured = this.draft.fields!.map((field) => field.name);
|
|
99
|
+
for (const name of [...configured, ...this.knownFields.filter((name) => !configured.includes(name))]) {
|
|
100
|
+
rows.push({ label: `Field: ${display(name)}${configured.includes(name) ? "" : " (not configured)"}`, action: () => this.edit(name) });
|
|
101
|
+
}
|
|
102
|
+
rows.push({ label: "+ Add field", action: () => this.ask("Field name", "", (value) => {
|
|
103
|
+
const field = this.validate(defaultFooterField(value));
|
|
104
|
+
this.edit(field.name);
|
|
105
|
+
}) }, { label: "Save settings", action: () => this.done({ footer: parseFooterConfig({ footer: this.draft }), handoffPrompt: parseHandoffPrompt(this.prompt) }) },
|
|
106
|
+
{ label: "Return to tasks", action: () => this.done("tasks") });
|
|
107
|
+
return rows;
|
|
108
|
+
}
|
|
109
|
+
private fieldRows(): Row[] {
|
|
110
|
+
const field = this.field!;
|
|
111
|
+
const rows: Row[] = [
|
|
112
|
+
{ label: `Link: ${field.link ? "on" : "off (plain text)"}`, action: () => { field.link = !field.link; } },
|
|
113
|
+
{ label: `Label: ${display(field.label) || "(none)"}`, action: () => this.ask("Label (empty = no prefix)", field.label, (value) => {
|
|
114
|
+
this.field = this.validate({ ...field, label: value });
|
|
115
|
+
}) },
|
|
116
|
+
{ label: `Format: ${display(field.format) || "(empty)"}`, action: () => this.ask("Format: <value>, <url>, <number>", field.format, (value) => {
|
|
117
|
+
this.field = this.validate({ ...field, format: value });
|
|
118
|
+
}) },
|
|
119
|
+
{ label: `Width: ${field.width ?? `default (${this.draft.fieldWidth})`}`, action: () => this.ask("Width (empty = default)", field.width === undefined ? "" : String(field.width), (value) => {
|
|
120
|
+
const candidate = { ...field };
|
|
121
|
+
if (value.trim()) candidate.width = Number(value); else delete candidate.width;
|
|
122
|
+
this.field = this.validate(candidate);
|
|
123
|
+
}) },
|
|
124
|
+
{ label: "Save field", action: () => {
|
|
125
|
+
const candidate = this.validate(field);
|
|
126
|
+
const fields = this.draft.fields!.filter((entry) => entry.name !== candidate.name);
|
|
127
|
+
const index = this.draft.fields!.findIndex((entry) => entry.name === candidate.name);
|
|
128
|
+
fields.splice(index < 0 ? fields.length : index, 0, candidate);
|
|
129
|
+
this.draft = parseFooterConfig({ footer: { ...this.draft, fields } });
|
|
130
|
+
this.field = undefined; this.index = 0;
|
|
131
|
+
} },
|
|
132
|
+
];
|
|
133
|
+
if (this.draft.fields!.some((entry) => entry.name === field.name)) rows.push({ label: "Remove field", action: () => {
|
|
134
|
+
this.draft.fields = this.draft.fields!.filter((entry) => entry.name !== field.name);
|
|
135
|
+
this.field = undefined; this.index = 0;
|
|
136
|
+
} });
|
|
137
|
+
rows.push({ label: "Cancel field", action: () => { this.field = undefined; this.index = 0; } });
|
|
138
|
+
return rows;
|
|
139
|
+
}
|
|
140
|
+
handleInput(data: string) {
|
|
141
|
+
this.error = "";
|
|
142
|
+
try {
|
|
143
|
+
if (isTab(data)) this.done("tasks");
|
|
144
|
+
else if (this.editor) {
|
|
145
|
+
if (matchesKey(data, "ctrl+c")) this.editor.setText("");
|
|
146
|
+
else if (this.matches(data, "tui.select.cancel")) this.editor = undefined;
|
|
147
|
+
else if (this.matches(data, "tui.input.newLine")) this.editor.handleInput(data);
|
|
148
|
+
else if (this.matches(data, "tui.select.confirm")) {
|
|
149
|
+
this.prompt = parseHandoffPrompt(this.editor.getExpandedText());
|
|
150
|
+
this.editor = undefined;
|
|
151
|
+
} else this.editor.handleInput(data);
|
|
152
|
+
} else if (this.input) {
|
|
153
|
+
if (this.matches(data, "tui.select.cancel")) this.input = undefined;
|
|
154
|
+
else if (this.matches(data, "tui.select.confirm")) {
|
|
155
|
+
const input = this.input;
|
|
156
|
+
input.apply(input.widget.getValue());
|
|
157
|
+
this.input = undefined;
|
|
158
|
+
} else this.input.widget.handleInput(data);
|
|
159
|
+
} else if (this.matches(data, "tui.select.cancel")) {
|
|
160
|
+
if (this.field) { this.field = undefined; this.index = 0; } else this.done(undefined);
|
|
161
|
+
} else {
|
|
162
|
+
const rows = this.field ? this.fieldRows() : this.rootRows();
|
|
163
|
+
this.index = Math.min(this.index, rows.length - 1);
|
|
164
|
+
if (this.matches(data, "tui.select.up")) this.index = (this.index + rows.length - 1) % rows.length;
|
|
165
|
+
else if (this.matches(data, "tui.select.down")) this.index = (this.index + 1) % rows.length;
|
|
166
|
+
else if (this.matches(data, "tui.select.confirm")) rows[this.index].action();
|
|
167
|
+
}
|
|
168
|
+
} catch (error) { this.error = error instanceof Error ? error.message : "Invalid settings."; }
|
|
169
|
+
this.requestRender();
|
|
170
|
+
}
|
|
171
|
+
render(width: number) {
|
|
172
|
+
const lines = [this.theme.fg("accent", "Pinote — Global Settings"), this.theme.fg("dim", "Tasks (Tab) · Settings · Fields")];
|
|
173
|
+
if (this.editor) {
|
|
174
|
+
lines.push(this.theme.fg("accent", "Task prompt"), ...this.editor.render(width),
|
|
175
|
+
this.theme.fg("dim", "Enter apply · Shift+Enter/Ctrl+J newline · Ctrl+C clear · Esc cancel"));
|
|
176
|
+
} else if (this.input) {
|
|
177
|
+
lines.push(this.theme.fg("accent", this.input.heading), ...this.input.widget.render(width),
|
|
178
|
+
this.theme.fg("dim", "Enter apply to draft · Esc cancel"));
|
|
179
|
+
} else {
|
|
180
|
+
if (this.field) lines.push(this.theme.fg("accent", `Field: ${display(this.field.name)}`));
|
|
181
|
+
const rows = this.field ? this.fieldRows() : this.rootRows();
|
|
182
|
+
this.index = Math.min(this.index, rows.length - 1);
|
|
183
|
+
const start = Math.max(0, Math.min(this.index - 3, rows.length - 8));
|
|
184
|
+
lines.push(...rows.slice(start, start + 8).map((row, offset) => offset + start === this.index
|
|
185
|
+
? this.theme.fg("accent", `> ${row.label}`) : ` ${row.label}`));
|
|
186
|
+
if (rows.length > 8) lines.push(this.theme.fg("dim", `${this.index + 1}/${rows.length}`));
|
|
187
|
+
lines.push(this.theme.fg("dim", "↑↓ navigate · Enter edit · Esc cancel"));
|
|
188
|
+
}
|
|
189
|
+
if (this.error) lines.push(this.theme.fg("error", this.error));
|
|
190
|
+
lines.push(this.theme.fg("dim", "Tab: tasks, discard draft · Save settings: persist globally"));
|
|
191
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
192
|
+
}
|
|
193
|
+
invalidate() { this.input?.widget.invalidate(); this.editor?.invalidate(); }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function withSettingsTab<T extends { handleInput?(data: string): void; render(width: number): string[]; invalidate(): void; focused?: boolean }>(
|
|
197
|
+
component: T, onSettings: () => void, theme: Theme, requestRender: () => void,
|
|
198
|
+
) {
|
|
199
|
+
return {
|
|
200
|
+
get focused() { return component.focused ?? false; },
|
|
201
|
+
set focused(value: boolean) { component.focused = value; },
|
|
202
|
+
handleInput(data: string) { if (isTab(data)) onSettings(); else component.handleInput?.(data); requestRender(); },
|
|
203
|
+
render(width: number) { return [truncateToWidth(theme.fg("accent", "Tasks · Settings (Tab)"), width), ...component.render(width)]; },
|
|
204
|
+
invalidate() { component.invalidate(); },
|
|
205
|
+
};
|
|
206
|
+
}
|
package/index.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { Editor, truncateToWidth, type Keybinding } from "@earendil-works/pi-tui";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { stripVTControlCharacters } from "node:util";
|
|
6
6
|
import { Type } from "typebox";
|
|
7
7
|
import { createPRWatcher } from "./pr-watch.ts";
|
|
8
|
+
import { defaultFooterConfig, defaultHandoffPrompt, effectiveFooterFields, loadFooterConfig, parseHandoffPrompt, readFooterDocument, saveFooterConfig, type FooterConfig, type PinoteSettings } from "./footer-config.ts";
|
|
9
|
+
import { FooterSettings, TaskMenu, withSettingsTab } from "./footer-settings.ts";
|
|
8
10
|
import { TaskPicker, taskState, taskTag } from "./task-picker.ts";
|
|
9
11
|
import { bundledCLIVersion, cliMenu, detectCLI, setupCLI, setupHint, versionAtLeast, type CLIAction } from "./setup.ts";
|
|
10
12
|
|
|
11
|
-
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 a GitHub pull request in the PR field.
|
|
13
|
+
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 a GitHub pull request in the PR field. Read pinote_fields to learn the user's globally configured footer field names and presentation. Populate those fields with meaningful task values when relevant using pinote_update_current; fields without values stay hidden. Footer fields and widths are chosen by the user's pi-note.json config. Set configured fields with Markdown values; links are clickable. When footer.fields is unset, Bar selects labels, one per line. Do not list PR in Bar. Remove a field to hide it. Do not modify config without user approval.";
|
|
12
14
|
const offerGuidance = {
|
|
13
15
|
always: "When the user gives work and no pinote task is selected, propose one note as `[tag] text` and ask before creating it.",
|
|
14
16
|
"github-remote": "When the user gives work and no pinote task is selected, first verify with Git that the current repository has a remote whose URL host is github.com (HTTPS or SSH). Only then propose one note as `[tag] text` and ask before creating it. Local paths, other hosts, and GitHub-looking URL paths do not qualify. If no GitHub remote is verified, do not offer a task; explicit user requests to create one remain allowed.",
|
|
@@ -38,7 +40,6 @@ const clean = (value: string) => stripVTControlCharacters(value).replace(/[\x00-
|
|
|
38
40
|
const firstLine = (task: Summary) => clean(task.text.split("\n", 1)[0]).replace(/\s+/gu, " ").trim();
|
|
39
41
|
const title = (task: Summary) => truncateToWidth(`#${task.id} ${firstLine(task)}`, 60);
|
|
40
42
|
|
|
41
|
-
const defaultHandoffPrompt = "Read the current Pinote task. Summarize your understanding, but don’t start work yet.";
|
|
42
43
|
function readConfig(): Record<string, unknown> {
|
|
43
44
|
const path = join(getAgentDir(), "pi-note.json");
|
|
44
45
|
let raw: string;
|
|
@@ -75,12 +76,7 @@ function creationGuidance(): { guidelines: string[]; error?: string } {
|
|
|
75
76
|
|
|
76
77
|
function handoffPrompt(): string {
|
|
77
78
|
const config = readConfig();
|
|
78
|
-
|
|
79
|
-
if (!("handoffPrompt" in config)) return defaultHandoffPrompt;
|
|
80
|
-
if (!text(config.handoffPrompt) || !config.handoffPrompt.trim()) {
|
|
81
|
-
throw new Error(`handoffPrompt in ${path} must be a nonblank string without control characters (except tabs/newlines).`);
|
|
82
|
-
}
|
|
83
|
-
return config.handoffPrompt;
|
|
79
|
+
return "handoffPrompt" in config ? parseHandoffPrompt(config.handoffPrompt) : defaultHandoffPrompt;
|
|
84
80
|
}
|
|
85
81
|
|
|
86
82
|
function parse(raw: string): unknown {
|
|
@@ -126,6 +122,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
126
122
|
let setupAbort: AbortController | undefined;
|
|
127
123
|
let activeContext: ExtensionContext | undefined;
|
|
128
124
|
let cliState: CLIAction | undefined;
|
|
125
|
+
let footerConfig = { ...defaultFooterConfig };
|
|
129
126
|
const checkCLI = async (ctx: ExtensionContext, suggest = false) => {
|
|
130
127
|
if (!ctx.hasUI || ctx.mode !== "tui" || setupAbort) return;
|
|
131
128
|
const generation = epoch;
|
|
@@ -241,7 +238,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
241
238
|
try {
|
|
242
239
|
const current = await selected(ctx);
|
|
243
240
|
if (alive && generation === epoch && serial === refreshSerial) {
|
|
244
|
-
ctx.ui.setStatus("pinote", current ? truncateToWidth(`${noteIcon} ${taskTag(current)} ${firstLine(current)}`,
|
|
241
|
+
ctx.ui.setStatus("pinote", current ? truncateToWidth(`${noteIcon} ${taskTag(current)} ${firstLine(current)}`, footerConfig.titleWidth, "...") : undefined);
|
|
245
242
|
watcher.update(ctx, current);
|
|
246
243
|
}
|
|
247
244
|
} catch {
|
|
@@ -252,6 +249,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
252
249
|
};
|
|
253
250
|
const watcher = createPRWatcher(pi, {
|
|
254
251
|
selected,
|
|
252
|
+
footerConfig: () => footerConfig,
|
|
255
253
|
get: async (id) => requiredTask(await run(["agent", "get", String(id)]), id),
|
|
256
254
|
done: async (current, canAct) => {
|
|
257
255
|
const result = requiredTask(await run([
|
|
@@ -275,6 +273,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
275
273
|
branchEpoch++;
|
|
276
274
|
selectedId = readSelection(ctx);
|
|
277
275
|
const generation = epoch;
|
|
276
|
+
footerConfig = loadFooterConfig((message) => {
|
|
277
|
+
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
278
|
+
});
|
|
278
279
|
cliState = undefined;
|
|
279
280
|
if (ctx.hasUI && ctx.mode === "tui") {
|
|
280
281
|
if (offerConfigError) ctx.ui.notify(`Pinote task offers disabled: ${clean(offerConfigError)} Fix the configuration and /reload.`, "warning");
|
|
@@ -353,7 +354,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
353
354
|
registerInstallCommand("pi-note-upgrade");
|
|
354
355
|
|
|
355
356
|
pi.registerCommand("pi-note", {
|
|
356
|
-
description: "Continue, complete, or
|
|
357
|
+
description: "Continue, complete, switch tasks, or edit global settings (Tab)",
|
|
357
358
|
handler: async (args, ctx) => {
|
|
358
359
|
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
359
360
|
if (args.trim()) { ctx.ui.notify("Usage: /pi-note", "warning"); return; }
|
|
@@ -370,39 +371,69 @@ export default function (pi: ExtensionAPI) {
|
|
|
370
371
|
try {
|
|
371
372
|
const current = await selected(ctx);
|
|
372
373
|
if (!canAct()) return;
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
374
|
+
let settings = false;
|
|
375
|
+
while (canAct()) {
|
|
376
|
+
if (settings) {
|
|
377
|
+
const document = readFooterDocument();
|
|
378
|
+
const root = document.raw === null ? {} : JSON.parse(document.raw);
|
|
379
|
+
const config: PinoteSettings = {
|
|
380
|
+
footer: { ...document.config, fields: effectiveFooterFields(document.config, current?.agent_notes) },
|
|
381
|
+
handoffPrompt: "handoffPrompt" in root ? parseHandoffPrompt(root.handoffPrompt) : defaultHandoffPrompt,
|
|
382
|
+
};
|
|
383
|
+
const edited = await ctx.ui.custom<PinoteSettings | "tasks" | undefined>((tui, theme, kb, done) =>
|
|
384
|
+
new FooterSettings(config, Object.keys(current?.agent_notes ?? {}).filter((name) => name !== "Bar"),
|
|
385
|
+
theme, (data, action) => kb.matches(data, action as Keybinding), done, () => tui.requestRender(),
|
|
386
|
+
() => new Editor(tui, {
|
|
387
|
+
borderColor: (line) => theme.fg("accent", line),
|
|
388
|
+
selectList: { selectedPrefix: (line) => theme.fg("accent", line), selectedText: (line) => theme.fg("accent", line),
|
|
389
|
+
description: (line) => theme.fg("muted", line), scrollInfo: (line) => theme.fg("dim", line),
|
|
390
|
+
noMatch: (line) => theme.fg("warning", line) },
|
|
391
|
+
})));
|
|
392
|
+
if (!canAct() || edited === undefined) return;
|
|
393
|
+
if (edited === "tasks") { settings = false; continue; }
|
|
394
|
+
footerConfig = saveFooterConfig(edited.footer, document.raw, edited.handoffPrompt);
|
|
395
|
+
ctx.ui.notify("Global Pinote settings saved.", "info");
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const action = current
|
|
399
|
+
? await ctx.ui.custom<string | undefined>((tui, theme, kb, done) =>
|
|
400
|
+
new TaskMenu(`Pinote — ${taskState(current)} ${taskTag(current)} ${title(current)}`,
|
|
401
|
+
theme, (data, action) => kb.matches(data, action as Keybinding), done, () => tui.requestRender()))
|
|
402
|
+
: "Switch task";
|
|
403
|
+
if (!action || !canAct()) return;
|
|
404
|
+
if (action === "Settings") { settings = true; continue; }
|
|
405
|
+
if (action === "Done" && current) {
|
|
406
|
+
refreshSerial++;
|
|
407
|
+
const completed = requiredTask(await run([
|
|
408
|
+
"agent", "done", String(current.id), "--expected-updated-at", current.updated_at,
|
|
409
|
+
], undefined, canAct), current.id);
|
|
410
|
+
if (completed.state !== "done") throw new Error(compatible);
|
|
411
|
+
if (currentSession() && branch === branchEpoch) remember(null, branch);
|
|
412
|
+
if (currentSession()) ctx.ui.notify(`Pinote #${current.id} completed.`, "info");
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
let id: number;
|
|
416
|
+
if (action === "Continue" && current) {
|
|
417
|
+
id = current.id;
|
|
418
|
+
} else if (action === "Switch task") {
|
|
419
|
+
const tasks = taskList(await run(["list", "--json"]));
|
|
420
|
+
if (!canAct()) return;
|
|
421
|
+
const choice = await ctx.ui.custom<number | "Settings" | undefined>((tui, theme, kb, done) =>
|
|
422
|
+
withSettingsTab(new TaskPicker(tasks, theme, (data, action) => kb.matches(data, action),
|
|
423
|
+
done, () => tui.requestRender()), () => done("Settings"), theme, () => tui.requestRender()));
|
|
424
|
+
if (choice === undefined || !canAct()) return;
|
|
425
|
+
if (choice === "Settings") { settings = true; continue; }
|
|
426
|
+
id = choice;
|
|
427
|
+
} else return;
|
|
428
|
+
const handoff = handoffPrompt();
|
|
378
429
|
refreshSerial++;
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
if (currentSession()) ctx.ui.notify(`Pinote #${current.id} completed.`, "info");
|
|
430
|
+
const chosen = await startTask(id, canAct);
|
|
431
|
+
if (!chosen || !canAct()) return;
|
|
432
|
+
const draft = ctx.ui.getEditorText();
|
|
433
|
+
ctx.ui.setEditorText(draft ? `${draft}\n\n${handoff}` : handoff);
|
|
434
|
+
ctx.ui.notify(`Pinote #${chosen.id} is in progress. Task added to input.`, "info");
|
|
385
435
|
return;
|
|
386
436
|
}
|
|
387
|
-
const handoff = handoffPrompt();
|
|
388
|
-
let id: number;
|
|
389
|
-
if (action === "Continue" && current) {
|
|
390
|
-
id = current.id;
|
|
391
|
-
} else if (action === "Switch task") {
|
|
392
|
-
const tasks = taskList(await run(["list", "--json"]));
|
|
393
|
-
if (!canAct()) return;
|
|
394
|
-
if (!tasks.length) { ctx.ui.notify("No active pinote tasks.", "info"); return; }
|
|
395
|
-
const choice = await ctx.ui.custom<number | undefined>((tui, theme, kb, done) =>
|
|
396
|
-
new TaskPicker(tasks, theme, (data, action) => kb.matches(data, action), done, () => tui.requestRender()));
|
|
397
|
-
if (choice === undefined || !canAct()) return;
|
|
398
|
-
id = choice;
|
|
399
|
-
} else return;
|
|
400
|
-
refreshSerial++;
|
|
401
|
-
const chosen = await startTask(id, canAct);
|
|
402
|
-
if (!chosen || !canAct()) return;
|
|
403
|
-
const draft = ctx.ui.getEditorText();
|
|
404
|
-
ctx.ui.setEditorText(draft ? `${draft}\n\n${handoff}` : handoff);
|
|
405
|
-
ctx.ui.notify(`Pinote #${chosen.id} is in progress. Task added to input.`, "info");
|
|
406
437
|
} catch (error) {
|
|
407
438
|
if (currentSession()) ctx.ui.notify(`Pinote: ${clean(error instanceof Error ? error.message : String(error))}`, "error");
|
|
408
439
|
} finally {
|
|
@@ -426,7 +457,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
426
457
|
name: "pinote_update_current",
|
|
427
458
|
label: "Pinote update current",
|
|
428
459
|
promptGuidelines: [handoffGuidance],
|
|
429
|
-
description: "Patch arbitrary agent handoff fields on this session's current pinote task. No ID argument. Read first with pinote_get_current; pass its updated_at as expected_updated_at. Fails when no task is selected or the selection changes during the operation. 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 PR #N in the footer and watch for merge confirmation in interactive Pi.
|
|
460
|
+
description: "Patch arbitrary agent handoff fields on this session's current pinote task. No ID argument. Read first with pinote_get_current; pass its updated_at as expected_updated_at. Fails when no task is selected or the selection changes during the operation. 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 PR #N in the footer and watch for merge confirmation in interactive Pi. Read pinote_fields for the user's global field names, labels, link switches, formats, and widths; set those field names when their values are relevant. Fields without values stay hidden. When footer.fields is unset, set Bar to newline-separated field labels to show those Markdown fields in the footer; links in the text are clickable. remove deletes named fields. A stale revision fails; read again before retrying. Does not complete the task or change its tag.",
|
|
430
461
|
parameters: Type.Object({
|
|
431
462
|
expected_updated_at: Type.String({ minLength: 1 }),
|
|
432
463
|
set: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
@@ -453,6 +484,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
453
484
|
return toolResult(updated);
|
|
454
485
|
},
|
|
455
486
|
});
|
|
487
|
+
pi.registerTool({
|
|
488
|
+
name: "pinote_fields",
|
|
489
|
+
label: "Pinote footer fields",
|
|
490
|
+
promptGuidelines: ["Read pinote_fields before updating handoff fields to learn which global fields the user wants populated. Use pinote_update_current for meaningful values; omit empty or irrelevant fields. Do not edit global configuration without user approval."],
|
|
491
|
+
description: "Read the user's global Pinote footer field definitions, including source names, display labels, link switches, formats, and widths. Use these names in pinote_update_current set to populate relevant task values. Fields without values are not displayed. Works without a selected task; does not modify configuration or task data.",
|
|
492
|
+
parameters: Type.Object({}, { additionalProperties: false }),
|
|
493
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
494
|
+
const config = loadFooterConfig((message) => { if (ctx.hasUI) ctx.ui.notify(message, "warning"); });
|
|
495
|
+
const value = { ...config, fields: effectiveFooterFields(config), legacy_bar: config.fields === null };
|
|
496
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(value) }], details: value };
|
|
497
|
+
},
|
|
498
|
+
});
|
|
456
499
|
pi.registerTool({
|
|
457
500
|
name: "pinote_tags",
|
|
458
501
|
label: "Pinote tags",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-note",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"homepage": "https://github.com/kvidzibo/pinote/tree/main/pi#readme",
|
|
15
15
|
"bugs": { "url": "https://github.com/kvidzibo/pinote/issues" },
|
|
16
16
|
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
|
|
17
|
-
"files": ["index.ts", "pr-watch.ts", "footer-links.ts", "task-picker.ts", "setup.ts", "icons", "README.md", "LICENSE"],
|
|
17
|
+
"files": ["index.ts", "pr-watch.ts", "footer-links.ts", "footer-config.ts", "footer-settings.ts", "task-picker.ts", "setup.ts", "icons", "README.md", "LICENSE"],
|
|
18
18
|
"pi": { "extensions": ["./index.ts"] },
|
|
19
19
|
"scripts": {
|
|
20
20
|
"check": "tsc --noEmit",
|
package/pr-watch.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { footerChips, footerStatusKey, legacyFooterStatusKey, renderFooterLinks, type FooterChip } from "./footer-links.ts";
|
|
3
|
+
import type { FooterConfig } from "./footer-config.ts";
|
|
3
4
|
|
|
4
5
|
export type WatchedTask = {
|
|
5
6
|
id: number;
|
|
@@ -14,6 +15,7 @@ type Dependencies = {
|
|
|
14
15
|
done: (task: WatchedTask, canAct: () => boolean) => Promise<void>;
|
|
15
16
|
claim: () => (() => void) | undefined;
|
|
16
17
|
refresh: (ctx: ExtensionContext) => Promise<void>;
|
|
18
|
+
footerConfig?: () => FooterConfig;
|
|
17
19
|
};
|
|
18
20
|
const entryType = "pinote-pr-acknowledged";
|
|
19
21
|
const watchEntryType = "pinote-pr-watched";
|
|
@@ -99,7 +101,7 @@ export function createPRWatcher(pi: ExtensionAPI, deps: Dependencies) {
|
|
|
99
101
|
revision++;
|
|
100
102
|
// Display only the active selection, independently of the retained merge watch.
|
|
101
103
|
visiblePR = current && ["active", "in_progress"].includes(current.state) ? taskPR(current) : undefined;
|
|
102
|
-
visibleLinks = footerChips(current, visiblePR);
|
|
104
|
+
visibleLinks = footerChips(current, visiblePR, deps.footerConfig?.());
|
|
103
105
|
// Completion clears selection. Keep the last task until poll verifies its state.
|
|
104
106
|
const changed = current ? remember(taskPR(current) ? current : null) : false;
|
|
105
107
|
paint();
|