pi-note 0.8.0 → 0.10.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 +108 -27
- package/footer-config.ts +114 -0
- package/footer-links.ts +45 -20
- package/footer-settings.ts +180 -0
- package/index.ts +110 -41
- 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 footer 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.
|
|
@@ -81,20 +84,34 @@ worktrees and pull requests. Task status refreshes at session start, before/afte
|
|
|
81
84
|
agent activity, and after commands/tools. The CLI's per-directory `agent selected`
|
|
82
85
|
command is not this session memory.
|
|
83
86
|
|
|
84
|
-
###
|
|
87
|
+
### Personal configuration
|
|
85
88
|
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
Configure `~/.pi/agent/pi-note.json` (or the agent directory set by
|
|
90
|
+
`PI_CODING_AGENT_DIR`):
|
|
88
91
|
|
|
89
92
|
```json
|
|
90
93
|
{
|
|
91
|
-
"handoffPrompt": "Read the current Pinote task. Summarize your understanding and proposed approach, but don’t start work yet."
|
|
94
|
+
"handoffPrompt": "Read the current Pinote task. Summarize your understanding and proposed approach, but don’t start work yet.",
|
|
95
|
+
"taskOfferPolicy": "github-remote"
|
|
92
96
|
}
|
|
93
97
|
```
|
|
94
98
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
99
|
+
`taskOfferPolicy` controls the agent's task-offer guidance:
|
|
100
|
+
|
|
101
|
+
- `always` (default): offer a task when given work with no task selected.
|
|
102
|
+
- `github-remote`: first verify with Git that the repository has an HTTPS or SSH
|
|
103
|
+
remote hosted on `github.com`; otherwise do not offer. Local paths and other
|
|
104
|
+
hosts do not qualify. This is agent guidance, not a network or PR-access check.
|
|
105
|
+
- `never`: do not offer tasks; explicit requests to create one remain allowed.
|
|
106
|
+
|
|
107
|
+
All policies preserve the existing creation tools, user-consent requirement,
|
|
108
|
+
and selected tasks. Run `/reload` after changing this setting. Invalid policy
|
|
109
|
+
values or unreadable/malformed configuration suppress offers; interactive startup
|
|
110
|
+
warns until you fix the file and reload. No project-local configuration is read.
|
|
111
|
+
|
|
112
|
+
Task selection and **Continue** reread `handoffPrompt` each time; changing that
|
|
113
|
+
field needs no `/reload`. Its value is literal text, not a prompt template; use
|
|
114
|
+
`\n` in JSON strings for multiple lines. It must be a nonblank string without control
|
|
98
115
|
characters other than tabs and newlines. A missing file or key uses the default.
|
|
99
116
|
Invalid/unreadable configuration reports an error without starting/switching a
|
|
100
117
|
task or changing the editor. **Done** and agent tools are unaffected. This is
|
|
@@ -109,6 +126,10 @@ user-level configuration only; project-local files are not read.
|
|
|
109
126
|
ID argument. Updates merge fields, never replace the task text or tag. Fails if
|
|
110
127
|
no task is selected or the selection changes during the operation. If another
|
|
111
128
|
process changed the task, read again before retrying.
|
|
129
|
+
- `pinote_fields`: read the user's global footer definitions (names, display labels,
|
|
130
|
+
links, formats, widths), even without a task. Agents should read this before
|
|
131
|
+
populating relevant values with `pinote_update_current`. Empty/missing values
|
|
132
|
+
stay hidden; this tool does not edit configuration.
|
|
112
133
|
- `pinote_tags`: list saved tag names.
|
|
113
134
|
- `pinote_add`: create an active task. Optional `tag`. `select: true` starts and
|
|
114
135
|
remembers it for this session only; use that only after the user agrees.
|
|
@@ -117,20 +138,21 @@ Version 0.7.0 replaces `pinote_get`/`pinote_update` with these current-task tool
|
|
|
117
138
|
and removes `pinote_tag`, without aliases. Agents cannot read/update tasks by ID
|
|
118
139
|
or retag existing tasks; use `/pi-note` to select an existing task.
|
|
119
140
|
|
|
120
|
-
When
|
|
121
|
-
|
|
141
|
+
When allowed by `taskOfferPolicy`, the agent proposes one note as `[tag] text`
|
|
142
|
+
and asks before creating it. No continues without a note. Yes calls
|
|
122
143
|
`pinote_add` with `select: true`. An existing selection is not replaced unless the
|
|
123
144
|
user asks to switch. Reuse a saved tag name when it fits.
|
|
124
145
|
|
|
125
146
|
Agents are guided to keep notes to three short bullets total: relevant outcome,
|
|
126
147
|
blocker, and next action. Replace stale notes; omit narration, repeated task text,
|
|
127
|
-
and routine test logs. Keep a GitHub pull request in `PR`.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
148
|
+
and routine test logs. Keep a GitHub pull request in `PR`. Set configured footer
|
|
149
|
+
fields with Markdown values; remove a field to hide it. Without a configured field
|
|
150
|
+
list, append its label to `Bar`, one label per line. Do not list `PR` in `Bar`.
|
|
151
|
+
Agents use `pinote_update_current`; there is no separate `add_to_bottom_bar` tool.
|
|
152
|
+
This is guidance, not truncation or a storage limit.
|
|
131
153
|
|
|
132
154
|
Values are Markdown strings; no fields are required. `PR` enables the watcher below.
|
|
133
|
-
`Bar` chooses extra footer fields:
|
|
155
|
+
`Bar` chooses extra footer fields unless configuration overrides it:
|
|
134
156
|
|
|
135
157
|
```markdown
|
|
136
158
|
# Agent
|
|
@@ -149,31 +171,90 @@ sessions. Labels are case-sensitive (trimmed, Unicode-normalized), at most 64
|
|
|
149
171
|
characters; values are at most 4096 characters. Maximum 64 fields / 32 KiB JSON
|
|
150
172
|
per task. Use removal rather than empty values.
|
|
151
173
|
|
|
174
|
+
### Footer configuration
|
|
175
|
+
|
|
176
|
+
Add `footer` to `~/.pi/agent/pi-note.json` (or `pi-note.json` under
|
|
177
|
+
`PI_CODING_AGENT_DIR`), preserving any existing `handoffPrompt`:
|
|
178
|
+
|
|
179
|
+
```json
|
|
180
|
+
{
|
|
181
|
+
"footer": {
|
|
182
|
+
"titleWidth": 40,
|
|
183
|
+
"fieldWidth": 30,
|
|
184
|
+
"maxFields": 4,
|
|
185
|
+
"fields": [
|
|
186
|
+
{ "name": "PR", "label": "", "link": true, "format": "#<number>" },
|
|
187
|
+
{ "name": "Dashboard", "label": "Dashboard", "link": true, "format": "<value>" },
|
|
188
|
+
{ "name": "Next", "label": "", "link": false, "format": "<value>", "width": 24 }
|
|
189
|
+
]
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Widths are terminal columns, including icons/tags or field labels, and must be
|
|
195
|
+
integers from 3 to 1000. Defaults: `titleWidth: 60`, `fieldWidth: 60`,
|
|
196
|
+
`maxFields: 4` (allowed 0–64). Each field can override `fieldWidth` with `width`.
|
|
197
|
+
`fields` selects task field names in order, regardless of a task's `Bar`.
|
|
198
|
+
Names are case-sensitive, trimmed and NFC-normalized, at most 64 characters;
|
|
199
|
+
`Bar` is reserved and duplicate names are rejected. Up to 64 definitions are
|
|
200
|
+
accepted. Missing/blank values do not display and do not consume the field limit.
|
|
201
|
+
`[]` or `maxFields: 0` hides configured fields, including PR.
|
|
202
|
+
|
|
203
|
+
In `/pi-note → Settings`, **Add field** adds a global display definition, not a
|
|
204
|
+
task value. Select any field to edit **Link**, **Label**, **Format**, and **Width**.
|
|
205
|
+
Label starts as the field name; clear it for no prefix. Link off displays plain
|
|
206
|
+
text without a clickable link. **Save** persists the draft; cancellation leaves
|
|
207
|
+
configuration untouched. Saves preserve unrelated settings (`handoffPrompt`,
|
|
208
|
+
`taskOfferPolicy`) and reject configuration changes made while the dialog was open.
|
|
209
|
+
Concurrent saves use `pi-note.json.lock`; remove stale locks only when no save is
|
|
210
|
+
running. Symlinked configuration must be edited manually; saves never replace the link.
|
|
211
|
+
|
|
212
|
+
Formats support `<value>` (Markdown display text), `<url>` (one safe link target),
|
|
213
|
+
and `<number>` (a GitHub PR number or numeric field value). With a blank label,
|
|
214
|
+
PR format `#<number>` displays `#123`; Link on makes it clickable. An unavailable
|
|
215
|
+
placeholder hides the field. The template is literal text, not executable code
|
|
216
|
+
or Markdown. Default format is `<value>`, or `#<number>` for PR.
|
|
217
|
+
String definitions and the older `{ "label": "Next", "width": 24 }` form still
|
|
218
|
+
work. Absent/`null` fields retain the legacy automatic PR link plus `Bar` fields;
|
|
219
|
+
the first Settings save turns this legacy list into explicit global definitions.
|
|
220
|
+
|
|
221
|
+
Settings saves apply immediately in this Pi session. Run `/reload` after manual
|
|
222
|
+
footer edits or in other running sessions; settings are also reread on session start.
|
|
223
|
+
Invalid footer configuration warns and uses footer defaults; missing files silently
|
|
224
|
+
use defaults. This does not validate or change `handoffPrompt`, which is read
|
|
225
|
+
separately on selection/Continue as described above.
|
|
226
|
+
This is user-level configuration, not task data; agents should not change it
|
|
227
|
+
without approval. Task values still come from `pinote_update_current`.
|
|
228
|
+
|
|
152
229
|
### Footer links
|
|
153
230
|
|
|
154
231
|
The selected active or in-progress task can show fields after its title.
|
|
155
|
-
|
|
156
|
-
|
|
232
|
+
Legacy configuration automatically shows a valid `PR` as **PR #123**.
|
|
233
|
+
With an explicit field list, PR presentation is configured like any other field.
|
|
234
|
+
Configured `fields`, or otherwise `Bar`, selects field names. Each listed value is
|
|
157
235
|
Markdown: the footer shows its text, and links in it are clickable for any scheme
|
|
158
236
|
except `javascript:`, `data:`, and `vbscript:`. Credentials, control characters,
|
|
159
237
|
and targets over 2048 characters after serialization are shown as text but not
|
|
160
|
-
linked.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
238
|
+
linked. `Bar` itself is never displayed.
|
|
239
|
+
By default, at most four extra fields are shown, each truncated to 60 columns.
|
|
240
|
+
Overflow ends with `...`, including cuts between Markdown/link segments. Removing
|
|
241
|
+
a listed field hides it even if configuration or `Bar` still names it. Terminal
|
|
242
|
+
OSC 8 support is required for clicking links. Pi joins footer statuses on one
|
|
243
|
+
line and truncates the combined line with `...` when the terminal is narrow;
|
|
244
|
+
this extension does not replace other footers. Other fields, such as `Jira` and
|
|
245
|
+
`CWD` in the example, stay off the footer unless selected.
|
|
166
246
|
|
|
167
247
|
### PR merge watcher
|
|
168
248
|
|
|
169
249
|
Set `PR` with `pinote_update_current` to one `https://github.com/owner/repo/pull/123` URL
|
|
170
|
-
or Markdown link.
|
|
171
|
-
|
|
250
|
+
or Markdown link. Legacy footer settings show a clickable **PR #123**; global
|
|
251
|
+
field settings can change or hide that presentation without disabling the watcher.
|
|
252
|
+
Other hosts, multiple links, and prose are not watched.
|
|
172
253
|
|
|
173
254
|
Interactive Pi checks only the selected task's PR using authenticated `gh`
|
|
174
255
|
(`gh auth login`). Polling defaults to 60 seconds; launch Pi with
|
|
175
256
|
`PINOTE_PR_POLL_SECONDS=120 pi` to change it, or `0` to disable polling.
|
|
176
|
-
Allowed intervals are 10–86400 seconds.
|
|
257
|
+
Allowed intervals are 10–86400 seconds. Configured links remain visible when polling is disabled.
|
|
177
258
|
Network/authentication failures warn once until recovery and retry next interval.
|
|
178
259
|
No polling runs in print/RPC mode or after Pi exits.
|
|
179
260
|
|
package/footer-config.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
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 type FooterField = { name: string; label: string; link: boolean; format: string; width?: number };
|
|
7
|
+
export type FooterConfig = { titleWidth: number; fieldWidth: number; maxFields: number; fields: FooterField[] | null };
|
|
8
|
+
export const defaultFooterConfig: FooterConfig = { titleWidth: 60, fieldWidth: 60, maxFields: 4, fields: null };
|
|
9
|
+
export const defaultFooterField = (name: string): FooterField => ({
|
|
10
|
+
name, label: name, link: true, format: name === "PR" ? "#<number>" : "<value>",
|
|
11
|
+
});
|
|
12
|
+
const object = (value: unknown): value is Record<string, unknown> =>
|
|
13
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
14
|
+
const width = (value: unknown): value is number => Number.isInteger(value) && Number(value) >= 3 && Number(value) <= 1000;
|
|
15
|
+
const safeText = (value: unknown): value is string => typeof value === "string" && !/[\x00-\x1f\x7f-\x9f]/u.test(value);
|
|
16
|
+
|
|
17
|
+
export function parseFooterConfig(value: unknown): FooterConfig {
|
|
18
|
+
if (!object(value)) throw new Error("expected an object");
|
|
19
|
+
if (value.footer === undefined) return { ...defaultFooterConfig };
|
|
20
|
+
if (!object(value.footer)) throw new Error("footer must be an object");
|
|
21
|
+
const footer = value.footer;
|
|
22
|
+
for (const key of ["titleWidth", "fieldWidth"] as const) {
|
|
23
|
+
if (footer[key] !== undefined && !width(footer[key])) throw new Error(`${key} must be an integer from 3 to 1000`);
|
|
24
|
+
}
|
|
25
|
+
if (footer.maxFields !== undefined &&
|
|
26
|
+
(!Number.isInteger(footer.maxFields) || Number(footer.maxFields) < 0 || Number(footer.maxFields) > 64)) {
|
|
27
|
+
throw new Error("maxFields must be an integer from 0 to 64");
|
|
28
|
+
}
|
|
29
|
+
let fields: FooterField[] | null = null;
|
|
30
|
+
if (footer.fields !== undefined && footer.fields !== null) {
|
|
31
|
+
if (!Array.isArray(footer.fields) || footer.fields.length > 64) throw new Error("fields must be null or an array of at most 64 fields");
|
|
32
|
+
const seen = new Set<string>();
|
|
33
|
+
fields = footer.fields.map((entry): FooterField => {
|
|
34
|
+
const field = typeof entry === "string" ? { name: entry } : entry;
|
|
35
|
+
if (!object(field)) throw new Error("each field needs a name");
|
|
36
|
+
// Accept the original {label: name, width} form as well as explicit names.
|
|
37
|
+
const source = field.name === undefined ? field.label : field.name;
|
|
38
|
+
if (!safeText(source)) throw new Error("invalid field name");
|
|
39
|
+
const name = source.normalize("NFC").trim();
|
|
40
|
+
if (!name || [...name].length > 64 || name === "Bar") throw new Error("invalid field name");
|
|
41
|
+
if (seen.has(name)) throw new Error("duplicate field name");
|
|
42
|
+
seen.add(name);
|
|
43
|
+
const label = field.name === undefined ? name : field.label === undefined ? name : field.label;
|
|
44
|
+
const format = field.format === undefined ? defaultFooterField(name).format : field.format;
|
|
45
|
+
if (!safeText(label) || [...label].length > 64) throw new Error("invalid display label");
|
|
46
|
+
if (!safeText(format) || format.length > 4096 || /<(?!value>|number>|url>)[^>]*>/u.test(format)) {
|
|
47
|
+
throw new Error("format supports only <value>, <number>, and <url>");
|
|
48
|
+
}
|
|
49
|
+
if (field.link !== undefined && typeof field.link !== "boolean") throw new Error("link must be a boolean");
|
|
50
|
+
if (field.width !== undefined && !width(field.width)) throw new Error("field width must be an integer from 3 to 1000");
|
|
51
|
+
return { name, label, link: field.link as boolean | undefined ?? true, format,
|
|
52
|
+
...(field.width === undefined ? {} : { width: field.width as number }) };
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
titleWidth: footer.titleWidth as number | undefined ?? defaultFooterConfig.titleWidth,
|
|
57
|
+
fieldWidth: footer.fieldWidth as number | undefined ?? defaultFooterConfig.fieldWidth,
|
|
58
|
+
maxFields: footer.maxFields as number | undefined ?? defaultFooterConfig.maxFields, fields,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readRaw(): string | null {
|
|
63
|
+
try { return readFileSync(join(getAgentDir(), "pi-note.json"), "utf8"); }
|
|
64
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; }
|
|
65
|
+
}
|
|
66
|
+
export function readFooterDocument() {
|
|
67
|
+
const raw = readRaw();
|
|
68
|
+
let value: unknown = {};
|
|
69
|
+
if (raw !== null) {
|
|
70
|
+
try { value = JSON.parse(raw); } catch { throw new Error("Invalid JSON in pi-note.json."); }
|
|
71
|
+
}
|
|
72
|
+
return { raw, config: parseFooterConfig(value) };
|
|
73
|
+
}
|
|
74
|
+
export function loadFooterConfig(warn: (message: string) => void): FooterConfig {
|
|
75
|
+
try { return readFooterDocument().config; }
|
|
76
|
+
catch {
|
|
77
|
+
warn("Cannot load pi-note.json; using default Pinote footer settings. Check the footer configuration in pi-note's README.");
|
|
78
|
+
return { ...defaultFooterConfig };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Synchronous compare-and-replace: reject edits made while the settings dialog was open,
|
|
83
|
+
// preserve unrelated keys (including handoffPrompt), and never leave partial JSON behind.
|
|
84
|
+
export function saveFooterConfig(config: FooterConfig, expectedRaw: string | null): FooterConfig {
|
|
85
|
+
const checked = parseFooterConfig({ footer: config });
|
|
86
|
+
const directory = getAgentDir();
|
|
87
|
+
mkdirSync(directory, { recursive: true });
|
|
88
|
+
const path = join(directory, "pi-note.json");
|
|
89
|
+
const lock = join(directory, "pi-note.json.lock");
|
|
90
|
+
let descriptor: number;
|
|
91
|
+
try { descriptor = openSync(lock, "wx", 0o600); }
|
|
92
|
+
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."); }
|
|
93
|
+
const temp = join(directory, `.pi-note-${randomUUID()}.tmp`);
|
|
94
|
+
try {
|
|
95
|
+
if (readRaw() !== expectedRaw) throw new Error("Pi-note configuration changed while settings were open. Reopen settings before saving.");
|
|
96
|
+
if (lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error("pi-note.json is a symlink. Edit its target manually rather than replacing the link.");
|
|
97
|
+
const root = expectedRaw === null ? {} : JSON.parse(expectedRaw);
|
|
98
|
+
if (!object(root)) throw new Error("Invalid pi-note configuration; expected an object.");
|
|
99
|
+
writeFileSync(temp, `${JSON.stringify({ ...root, footer: checked }, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
100
|
+
renameSync(temp, path);
|
|
101
|
+
} finally { rmSync(temp, { force: true }); closeSync(descriptor); rmSync(lock, { force: true }); }
|
|
102
|
+
return checked;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function effectiveFooterFields(config: FooterConfig, notes: Record<string, string> = {}): FooterField[] {
|
|
106
|
+
if (config.fields !== null) return config.fields;
|
|
107
|
+
const seen = new Set(["Bar"]);
|
|
108
|
+
return ["PR", ...(notes.Bar ?? "").split("\n")].flatMap((raw) => {
|
|
109
|
+
const name = raw.normalize("NFC").trim();
|
|
110
|
+
if (!name || seen.has(name)) return [];
|
|
111
|
+
seen.add(name);
|
|
112
|
+
return [defaultFooterField(name)];
|
|
113
|
+
});
|
|
114
|
+
}
|
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,180 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Input, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { defaultFooterField, parseFooterConfig, 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 knownFields: string[];
|
|
42
|
+
private theme: Theme;
|
|
43
|
+
private matches: Matches;
|
|
44
|
+
private done: (result: FooterConfig | "tasks" | undefined) => void;
|
|
45
|
+
private requestRender: () => void;
|
|
46
|
+
private index = 0;
|
|
47
|
+
private field?: FooterField;
|
|
48
|
+
private input?: { widget: Input; apply: (value: string) => void; heading: string };
|
|
49
|
+
private error = "";
|
|
50
|
+
private hasFocus = false;
|
|
51
|
+
|
|
52
|
+
constructor(config: FooterConfig, knownFields: string[], theme: Theme, matches: Matches,
|
|
53
|
+
done: (result: FooterConfig | "tasks" | undefined) => void, requestRender: () => void) {
|
|
54
|
+
this.draft = structuredClone(config);
|
|
55
|
+
this.draft.fields ??= [];
|
|
56
|
+
this.knownFields = [...new Set(knownFields)].filter((name) => name !== "Bar");
|
|
57
|
+
this.theme = theme; this.matches = matches; this.done = done; this.requestRender = requestRender;
|
|
58
|
+
}
|
|
59
|
+
get focused() { return this.hasFocus; }
|
|
60
|
+
set focused(value: boolean) { this.hasFocus = value; if (this.input) this.input.widget.focused = value; }
|
|
61
|
+
|
|
62
|
+
private ask(heading: string, value: string, apply: (value: string) => void) {
|
|
63
|
+
const widget = new Input();
|
|
64
|
+
widget.setValue(value); widget.focused = this.hasFocus;
|
|
65
|
+
this.input = { widget, heading, apply }; this.error = "";
|
|
66
|
+
}
|
|
67
|
+
private validate(field: FooterField): FooterField {
|
|
68
|
+
return parseFooterConfig({ footer: { fields: [field] } }).fields![0];
|
|
69
|
+
}
|
|
70
|
+
private edit(name: string) {
|
|
71
|
+
this.field = structuredClone(this.draft.fields!.find((field) => field.name === name) ?? defaultFooterField(name));
|
|
72
|
+
this.index = 0; this.error = "";
|
|
73
|
+
}
|
|
74
|
+
private rootRows(): Row[] {
|
|
75
|
+
const rows: Row[] = (["titleWidth", "fieldWidth", "maxFields"] as const).map((key) => ({
|
|
76
|
+
label: `${{ titleWidth: "Title width", fieldWidth: "Default field width", maxFields: "Max fields" }[key]}: ${this.draft[key]}`,
|
|
77
|
+
action: () => this.ask(key, String(this.draft[key]), (value) => {
|
|
78
|
+
const candidate = { ...this.draft, [key]: Number(value) };
|
|
79
|
+
if (!value.trim()) throw new Error("Enter a number.");
|
|
80
|
+
this.draft = parseFooterConfig({ footer: candidate });
|
|
81
|
+
}),
|
|
82
|
+
}));
|
|
83
|
+
const configured = this.draft.fields!.map((field) => field.name);
|
|
84
|
+
for (const name of [...configured, ...this.knownFields.filter((name) => !configured.includes(name))]) {
|
|
85
|
+
rows.push({ label: `Field: ${display(name)}${configured.includes(name) ? "" : " (not configured)"}`, action: () => this.edit(name) });
|
|
86
|
+
}
|
|
87
|
+
rows.push({ label: "+ Add field", action: () => this.ask("Field name", "", (value) => {
|
|
88
|
+
const field = this.validate(defaultFooterField(value));
|
|
89
|
+
this.edit(field.name);
|
|
90
|
+
}) }, { label: "Save settings", action: () => this.done(parseFooterConfig({ footer: this.draft })) },
|
|
91
|
+
{ label: "Return to tasks", action: () => this.done("tasks") });
|
|
92
|
+
return rows;
|
|
93
|
+
}
|
|
94
|
+
private fieldRows(): Row[] {
|
|
95
|
+
const field = this.field!;
|
|
96
|
+
const rows: Row[] = [
|
|
97
|
+
{ label: `Link: ${field.link ? "on" : "off (plain text)"}`, action: () => { field.link = !field.link; } },
|
|
98
|
+
{ label: `Label: ${display(field.label) || "(none)"}`, action: () => this.ask("Label (empty = no prefix)", field.label, (value) => {
|
|
99
|
+
this.field = this.validate({ ...field, label: value });
|
|
100
|
+
}) },
|
|
101
|
+
{ label: `Format: ${display(field.format) || "(empty)"}`, action: () => this.ask("Format: <value>, <url>, <number>", field.format, (value) => {
|
|
102
|
+
this.field = this.validate({ ...field, format: value });
|
|
103
|
+
}) },
|
|
104
|
+
{ label: `Width: ${field.width ?? `default (${this.draft.fieldWidth})`}`, action: () => this.ask("Width (empty = default)", field.width === undefined ? "" : String(field.width), (value) => {
|
|
105
|
+
const candidate = { ...field };
|
|
106
|
+
if (value.trim()) candidate.width = Number(value); else delete candidate.width;
|
|
107
|
+
this.field = this.validate(candidate);
|
|
108
|
+
}) },
|
|
109
|
+
{ label: "Save field", action: () => {
|
|
110
|
+
const candidate = this.validate(field);
|
|
111
|
+
const fields = this.draft.fields!.filter((entry) => entry.name !== candidate.name);
|
|
112
|
+
const index = this.draft.fields!.findIndex((entry) => entry.name === candidate.name);
|
|
113
|
+
fields.splice(index < 0 ? fields.length : index, 0, candidate);
|
|
114
|
+
this.draft = parseFooterConfig({ footer: { ...this.draft, fields } });
|
|
115
|
+
this.field = undefined; this.index = 0;
|
|
116
|
+
} },
|
|
117
|
+
];
|
|
118
|
+
if (this.draft.fields!.some((entry) => entry.name === field.name)) rows.push({ label: "Remove field", action: () => {
|
|
119
|
+
this.draft.fields = this.draft.fields!.filter((entry) => entry.name !== field.name);
|
|
120
|
+
this.field = undefined; this.index = 0;
|
|
121
|
+
} });
|
|
122
|
+
rows.push({ label: "Cancel field", action: () => { this.field = undefined; this.index = 0; } });
|
|
123
|
+
return rows;
|
|
124
|
+
}
|
|
125
|
+
handleInput(data: string) {
|
|
126
|
+
this.error = "";
|
|
127
|
+
try {
|
|
128
|
+
if (isTab(data)) this.done("tasks");
|
|
129
|
+
else if (this.input) {
|
|
130
|
+
if (this.matches(data, "tui.select.cancel")) this.input = undefined;
|
|
131
|
+
else if (this.matches(data, "tui.select.confirm")) {
|
|
132
|
+
const input = this.input;
|
|
133
|
+
input.apply(input.widget.getValue());
|
|
134
|
+
this.input = undefined;
|
|
135
|
+
} else this.input.widget.handleInput(data);
|
|
136
|
+
} else if (this.matches(data, "tui.select.cancel")) {
|
|
137
|
+
if (this.field) { this.field = undefined; this.index = 0; } else this.done(undefined);
|
|
138
|
+
} else {
|
|
139
|
+
const rows = this.field ? this.fieldRows() : this.rootRows();
|
|
140
|
+
this.index = Math.min(this.index, rows.length - 1);
|
|
141
|
+
if (this.matches(data, "tui.select.up")) this.index = (this.index + rows.length - 1) % rows.length;
|
|
142
|
+
else if (this.matches(data, "tui.select.down")) this.index = (this.index + 1) % rows.length;
|
|
143
|
+
else if (this.matches(data, "tui.select.confirm")) rows[this.index].action();
|
|
144
|
+
}
|
|
145
|
+
} catch (error) { this.error = error instanceof Error ? error.message : "Invalid settings."; }
|
|
146
|
+
this.requestRender();
|
|
147
|
+
}
|
|
148
|
+
render(width: number) {
|
|
149
|
+
const lines = [this.theme.fg("accent", "Pinote — Global Settings"), this.theme.fg("dim", "Tasks (Tab) · Settings · Fields")];
|
|
150
|
+
if (this.input) {
|
|
151
|
+
lines.push(this.theme.fg("accent", this.input.heading), ...this.input.widget.render(width),
|
|
152
|
+
this.theme.fg("dim", "Enter apply to draft · Esc cancel"));
|
|
153
|
+
} else {
|
|
154
|
+
if (this.field) lines.push(this.theme.fg("accent", `Field: ${display(this.field.name)}`));
|
|
155
|
+
const rows = this.field ? this.fieldRows() : this.rootRows();
|
|
156
|
+
this.index = Math.min(this.index, rows.length - 1);
|
|
157
|
+
const start = Math.max(0, Math.min(this.index - 3, rows.length - 8));
|
|
158
|
+
lines.push(...rows.slice(start, start + 8).map((row, offset) => offset + start === this.index
|
|
159
|
+
? this.theme.fg("accent", `> ${row.label}`) : ` ${row.label}`));
|
|
160
|
+
if (rows.length > 8) lines.push(this.theme.fg("dim", `${this.index + 1}/${rows.length}`));
|
|
161
|
+
lines.push(this.theme.fg("dim", "↑↓ navigate · Enter edit · Esc cancel"));
|
|
162
|
+
}
|
|
163
|
+
if (this.error) lines.push(this.theme.fg("error", this.error));
|
|
164
|
+
lines.push(this.theme.fg("dim", "Tab: tasks, discard draft · Save settings: persist globally"));
|
|
165
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
166
|
+
}
|
|
167
|
+
invalidate() { this.input?.widget.invalidate(); }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function withSettingsTab<T extends { handleInput?(data: string): void; render(width: number): string[]; invalidate(): void; focused?: boolean }>(
|
|
171
|
+
component: T, onSettings: () => void, theme: Theme, requestRender: () => void,
|
|
172
|
+
) {
|
|
173
|
+
return {
|
|
174
|
+
get focused() { return component.focused ?? false; },
|
|
175
|
+
set focused(value: boolean) { component.focused = value; },
|
|
176
|
+
handleInput(data: string) { if (isTab(data)) onSettings(); else component.handleInput?.(data); requestRender(); },
|
|
177
|
+
render(width: number) { return [truncateToWidth(theme.fg("accent", "Tasks · Settings (Tab)"), width), ...component.render(width)]; },
|
|
178
|
+
invalidate() { component.invalidate(); },
|
|
179
|
+
};
|
|
180
|
+
}
|
package/index.ts
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { 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, effectiveFooterFields, loadFooterConfig, readFooterDocument, saveFooterConfig, type FooterConfig } 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.
|
|
12
|
-
const
|
|
13
|
-
"When the user gives work and no pinote task is selected, propose one note as `[tag] text` and ask before creating it.
|
|
14
|
-
"
|
|
15
|
-
|
|
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.";
|
|
14
|
+
const offerGuidance = {
|
|
15
|
+
always: "When the user gives work and no pinote task is selected, propose one note as `[tag] text` and ask before creating it.",
|
|
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.",
|
|
17
|
+
never: "Do not offer to create a pinote task. Create one only when the user explicitly requests it.",
|
|
18
|
+
};
|
|
19
|
+
const consentGuidance = "On no, continue without a note. On yes, call pinote_add with select true so it becomes this session's active task. Never add or select without a yes. If a task is already selected, do not replace it unless the user asks to switch.";
|
|
20
|
+
const tagGuidance = "Reuse a pinote_tags name when it fits. One tag; case-sensitive, trimmed, at most 64 characters. Tags are set when creating tasks with pinote_add; agent tools cannot retag existing tasks. Do not use pinote_update_current for tags or task text.";
|
|
16
21
|
type Task = {
|
|
17
22
|
id: number;
|
|
18
23
|
text: string;
|
|
@@ -36,18 +41,43 @@ const firstLine = (task: Summary) => clean(task.text.split("\n", 1)[0]).replace(
|
|
|
36
41
|
const title = (task: Summary) => truncateToWidth(`#${task.id} ${firstLine(task)}`, 60);
|
|
37
42
|
|
|
38
43
|
const defaultHandoffPrompt = "Read the current Pinote task. Summarize your understanding, but don’t start work yet.";
|
|
39
|
-
function
|
|
44
|
+
function readConfig(): Record<string, unknown> {
|
|
40
45
|
const path = join(getAgentDir(), "pi-note.json");
|
|
41
46
|
let raw: string;
|
|
42
47
|
try {
|
|
43
48
|
raw = readFileSync(path, "utf8");
|
|
44
49
|
} catch (error) {
|
|
45
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return
|
|
50
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
46
51
|
throw new Error(`Cannot read ${path}. Check the pi-note configuration.`);
|
|
47
52
|
}
|
|
48
53
|
let config: unknown;
|
|
49
54
|
try { config = JSON.parse(raw); } catch { throw new Error(`Invalid JSON in ${path}.`); }
|
|
50
55
|
if (!record(config)) throw new Error(`Expected a JSON object in ${path}.`);
|
|
56
|
+
return config;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function creationGuidance(): { guidelines: string[]; error?: string } {
|
|
60
|
+
let policy: keyof typeof offerGuidance = "always";
|
|
61
|
+
let error: string | undefined;
|
|
62
|
+
try {
|
|
63
|
+
const config = readConfig();
|
|
64
|
+
if ("taskOfferPolicy" in config) {
|
|
65
|
+
const value = config.taskOfferPolicy;
|
|
66
|
+
if (value !== "always" && value !== "github-remote" && value !== "never") {
|
|
67
|
+
throw new Error(`taskOfferPolicy in ${join(getAgentDir(), "pi-note.json")} must be always, github-remote, or never.`);
|
|
68
|
+
}
|
|
69
|
+
policy = value;
|
|
70
|
+
}
|
|
71
|
+
} catch (cause) {
|
|
72
|
+
policy = "never";
|
|
73
|
+
error = cause instanceof Error ? cause.message : String(cause);
|
|
74
|
+
}
|
|
75
|
+
return { guidelines: [`${offerGuidance[policy]} ${consentGuidance}`, tagGuidance], error };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function handoffPrompt(): string {
|
|
79
|
+
const config = readConfig();
|
|
80
|
+
const path = join(getAgentDir(), "pi-note.json");
|
|
51
81
|
if (!("handoffPrompt" in config)) return defaultHandoffPrompt;
|
|
52
82
|
if (!text(config.handoffPrompt) || !config.handoffPrompt.trim()) {
|
|
53
83
|
throw new Error(`handoffPrompt in ${path} must be a nonblank string without control characters (except tabs/newlines).`);
|
|
@@ -90,6 +120,7 @@ function toolResult(value: Task | null) {
|
|
|
90
120
|
}
|
|
91
121
|
|
|
92
122
|
export default function (pi: ExtensionAPI) {
|
|
123
|
+
const { guidelines: createGuidance, error: offerConfigError } = creationGuidance();
|
|
93
124
|
let alive = true;
|
|
94
125
|
let epoch = 0;
|
|
95
126
|
let refreshSerial = 0;
|
|
@@ -97,6 +128,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
97
128
|
let setupAbort: AbortController | undefined;
|
|
98
129
|
let activeContext: ExtensionContext | undefined;
|
|
99
130
|
let cliState: CLIAction | undefined;
|
|
131
|
+
let footerConfig = { ...defaultFooterConfig };
|
|
100
132
|
const checkCLI = async (ctx: ExtensionContext, suggest = false) => {
|
|
101
133
|
if (!ctx.hasUI || ctx.mode !== "tui" || setupAbort) return;
|
|
102
134
|
const generation = epoch;
|
|
@@ -212,7 +244,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
212
244
|
try {
|
|
213
245
|
const current = await selected(ctx);
|
|
214
246
|
if (alive && generation === epoch && serial === refreshSerial) {
|
|
215
|
-
ctx.ui.setStatus("pinote", current ? truncateToWidth(`${noteIcon} ${taskTag(current)} ${firstLine(current)}`,
|
|
247
|
+
ctx.ui.setStatus("pinote", current ? truncateToWidth(`${noteIcon} ${taskTag(current)} ${firstLine(current)}`, footerConfig.titleWidth, "...") : undefined);
|
|
216
248
|
watcher.update(ctx, current);
|
|
217
249
|
}
|
|
218
250
|
} catch {
|
|
@@ -223,6 +255,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
223
255
|
};
|
|
224
256
|
const watcher = createPRWatcher(pi, {
|
|
225
257
|
selected,
|
|
258
|
+
footerConfig: () => footerConfig,
|
|
226
259
|
get: async (id) => requiredTask(await run(["agent", "get", String(id)]), id),
|
|
227
260
|
done: async (current, canAct) => {
|
|
228
261
|
const result = requiredTask(await run([
|
|
@@ -246,8 +279,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
246
279
|
branchEpoch++;
|
|
247
280
|
selectedId = readSelection(ctx);
|
|
248
281
|
const generation = epoch;
|
|
282
|
+
footerConfig = loadFooterConfig((message) => {
|
|
283
|
+
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
284
|
+
});
|
|
249
285
|
cliState = undefined;
|
|
250
286
|
if (ctx.hasUI && ctx.mode === "tui") {
|
|
287
|
+
if (offerConfigError) ctx.ui.notify(`Pinote task offers disabled: ${clean(offerConfigError)} Fix the configuration and /reload.`, "warning");
|
|
251
288
|
ctx.ui.addAutocompleteProvider((current) => cliMenu(current, () => setupAbort ? undefined : cliState));
|
|
252
289
|
}
|
|
253
290
|
// Keep the setup lock until its aborted subprocess has actually settled.
|
|
@@ -323,7 +360,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
323
360
|
registerInstallCommand("pi-note-upgrade");
|
|
324
361
|
|
|
325
362
|
pi.registerCommand("pi-note", {
|
|
326
|
-
description: "Continue, complete, or
|
|
363
|
+
description: "Continue, complete, switch tasks, or edit global footer settings (Tab)",
|
|
327
364
|
handler: async (args, ctx) => {
|
|
328
365
|
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
329
366
|
if (args.trim()) { ctx.ui.notify("Usage: /pi-note", "warning"); return; }
|
|
@@ -340,39 +377,59 @@ export default function (pi: ExtensionAPI) {
|
|
|
340
377
|
try {
|
|
341
378
|
const current = await selected(ctx);
|
|
342
379
|
if (!canAct()) return;
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
380
|
+
let settings = false;
|
|
381
|
+
while (canAct()) {
|
|
382
|
+
if (settings) {
|
|
383
|
+
const document = readFooterDocument();
|
|
384
|
+
const config = { ...document.config, fields: effectiveFooterFields(document.config, current?.agent_notes) };
|
|
385
|
+
const edited = await ctx.ui.custom<FooterConfig | "tasks" | undefined>((tui, theme, kb, done) =>
|
|
386
|
+
new FooterSettings(config, Object.keys(current?.agent_notes ?? {}).filter((name) => name !== "Bar"),
|
|
387
|
+
theme, (data, action) => kb.matches(data, action as Keybinding), done, () => tui.requestRender()));
|
|
388
|
+
if (!canAct() || edited === undefined) return;
|
|
389
|
+
if (edited === "tasks") { settings = false; continue; }
|
|
390
|
+
footerConfig = saveFooterConfig(edited, document.raw);
|
|
391
|
+
ctx.ui.notify("Global Pinote footer settings saved.", "info");
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const action = current
|
|
395
|
+
? await ctx.ui.custom<string | undefined>((tui, theme, kb, done) =>
|
|
396
|
+
new TaskMenu(`Pinote — ${taskState(current)} ${taskTag(current)} ${title(current)}`,
|
|
397
|
+
theme, (data, action) => kb.matches(data, action as Keybinding), done, () => tui.requestRender()))
|
|
398
|
+
: "Switch task";
|
|
399
|
+
if (!action || !canAct()) return;
|
|
400
|
+
if (action === "Settings") { settings = true; continue; }
|
|
401
|
+
if (action === "Done" && current) {
|
|
402
|
+
refreshSerial++;
|
|
403
|
+
const completed = requiredTask(await run([
|
|
404
|
+
"agent", "done", String(current.id), "--expected-updated-at", current.updated_at,
|
|
405
|
+
], undefined, canAct), current.id);
|
|
406
|
+
if (completed.state !== "done") throw new Error(compatible);
|
|
407
|
+
if (currentSession() && branch === branchEpoch) remember(null, branch);
|
|
408
|
+
if (currentSession()) ctx.ui.notify(`Pinote #${current.id} completed.`, "info");
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
let id: number;
|
|
412
|
+
if (action === "Continue" && current) {
|
|
413
|
+
id = current.id;
|
|
414
|
+
} else if (action === "Switch task") {
|
|
415
|
+
const tasks = taskList(await run(["list", "--json"]));
|
|
416
|
+
if (!canAct()) return;
|
|
417
|
+
const choice = await ctx.ui.custom<number | "Settings" | undefined>((tui, theme, kb, done) =>
|
|
418
|
+
withSettingsTab(new TaskPicker(tasks, theme, (data, action) => kb.matches(data, action),
|
|
419
|
+
done, () => tui.requestRender()), () => done("Settings"), theme, () => tui.requestRender()));
|
|
420
|
+
if (choice === undefined || !canAct()) return;
|
|
421
|
+
if (choice === "Settings") { settings = true; continue; }
|
|
422
|
+
id = choice;
|
|
423
|
+
} else return;
|
|
424
|
+
const handoff = handoffPrompt();
|
|
348
425
|
refreshSerial++;
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
if (currentSession()) ctx.ui.notify(`Pinote #${current.id} completed.`, "info");
|
|
426
|
+
const chosen = await startTask(id, canAct);
|
|
427
|
+
if (!chosen || !canAct()) return;
|
|
428
|
+
const draft = ctx.ui.getEditorText();
|
|
429
|
+
ctx.ui.setEditorText(draft ? `${draft}\n\n${handoff}` : handoff);
|
|
430
|
+
ctx.ui.notify(`Pinote #${chosen.id} is in progress. Task added to input.`, "info");
|
|
355
431
|
return;
|
|
356
432
|
}
|
|
357
|
-
const handoff = handoffPrompt();
|
|
358
|
-
let id: number;
|
|
359
|
-
if (action === "Continue" && current) {
|
|
360
|
-
id = current.id;
|
|
361
|
-
} else if (action === "Switch task") {
|
|
362
|
-
const tasks = taskList(await run(["list", "--json"]));
|
|
363
|
-
if (!canAct()) return;
|
|
364
|
-
if (!tasks.length) { ctx.ui.notify("No active pinote tasks.", "info"); return; }
|
|
365
|
-
const choice = await ctx.ui.custom<number | undefined>((tui, theme, kb, done) =>
|
|
366
|
-
new TaskPicker(tasks, theme, (data, action) => kb.matches(data, action), done, () => tui.requestRender()));
|
|
367
|
-
if (choice === undefined || !canAct()) return;
|
|
368
|
-
id = choice;
|
|
369
|
-
} else return;
|
|
370
|
-
refreshSerial++;
|
|
371
|
-
const chosen = await startTask(id, canAct);
|
|
372
|
-
if (!chosen || !canAct()) return;
|
|
373
|
-
const draft = ctx.ui.getEditorText();
|
|
374
|
-
ctx.ui.setEditorText(draft ? `${draft}\n\n${handoff}` : handoff);
|
|
375
|
-
ctx.ui.notify(`Pinote #${chosen.id} is in progress. Task added to input.`, "info");
|
|
376
433
|
} catch (error) {
|
|
377
434
|
if (currentSession()) ctx.ui.notify(`Pinote: ${clean(error instanceof Error ? error.message : String(error))}`, "error");
|
|
378
435
|
} finally {
|
|
@@ -396,7 +453,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
396
453
|
name: "pinote_update_current",
|
|
397
454
|
label: "Pinote update current",
|
|
398
455
|
promptGuidelines: [handoffGuidance],
|
|
399
|
-
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.
|
|
456
|
+
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.",
|
|
400
457
|
parameters: Type.Object({
|
|
401
458
|
expected_updated_at: Type.String({ minLength: 1 }),
|
|
402
459
|
set: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
@@ -423,6 +480,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
423
480
|
return toolResult(updated);
|
|
424
481
|
},
|
|
425
482
|
});
|
|
483
|
+
pi.registerTool({
|
|
484
|
+
name: "pinote_fields",
|
|
485
|
+
label: "Pinote footer fields",
|
|
486
|
+
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."],
|
|
487
|
+
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.",
|
|
488
|
+
parameters: Type.Object({}, { additionalProperties: false }),
|
|
489
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
490
|
+
const config = loadFooterConfig((message) => { if (ctx.hasUI) ctx.ui.notify(message, "warning"); });
|
|
491
|
+
const value = { ...config, fields: effectiveFooterFields(config), legacy_bar: config.fields === null };
|
|
492
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(value) }], details: value };
|
|
493
|
+
},
|
|
494
|
+
});
|
|
426
495
|
pi.registerTool({
|
|
427
496
|
name: "pinote_tags",
|
|
428
497
|
label: "Pinote tags",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-note",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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();
|