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 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**, and **Switch task**.
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`. To show another footer
142
- field, set its Markdown value and append its label to `Bar`, one label per line.
143
- Do not list `PR` in `Bar`. Remove the field and its `Bar` line to drop it. This is
144
- guidance, not truncation or a storage limit.
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
- `PR` is always shown when it matches the watcher format below, as **PR #123**.
170
- `Bar` is a newline-separated list of other field labels. Each listed value is
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. Missing labels and the `PR` and `Bar` labels themselves are skipped.
175
- At most four extra fields are shown, each truncated to 60 columns. Removing a listed
176
- field hides it even if `Bar` still names it. Terminal OSC 8 support is required
177
- for clicking links. Pi joins footer statuses on one line, so a narrow terminal can
178
- ellipsize later fields. Other fields, such as `Jira` and `CWD` in the example, stay
179
- off the footer unless named in `Bar`.
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. The footer adds a clickable, link-coloured **PR #123**, without
185
- status text. Other hosts, multiple links, and prose are not watched.
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. The selected task's link remains visible when polling is disabled.
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
 
@@ -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 used = 0;
171
+ let room = width - 3;
172
172
  for (const segment of segments) {
173
- if (used >= width) break;
174
- const room = width - used;
175
- const text = visibleWidth(segment.text) <= room ? segment.text : clip(segment.text, room);
176
- if (!text) break;
177
- kept.push(segment.url ? { text, url: segment.url } : { text });
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
- export function customFooterChips(notes: Record<string, string> | undefined): FooterChip[] {
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
- for (const raw of (notes.Bar ?? "").split("\n")) {
188
- if (chips.length >= maxCustomChips) break;
189
- const name = raw.normalize("NFC").trim();
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 label = clip(name, maxLabelWidth);
193
- const body = renderMarkdown(ownText(notes, name));
194
- if (!label || !body.length) continue;
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. To show another footer field, set its Markdown value and append its label to Bar, one label per line. Links in that text are clickable. Do not list PR in Bar. Remove the field and its Bar line to drop it. Notes stay off the footer unless named in Bar.";
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
- const path = join(getAgentDir(), "pi-note.json");
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)}`, 60) : undefined);
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 switch the selected pinote task",
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
- const action = current
374
- ? await ctx.ui.select(`Pinote — ${taskState(current)} ${taskTag(current)} ${title(current)}`, ["Continue", "Done", "Switch task"])
375
- : "Switch task";
376
- if (!action || !canAct()) return;
377
- if (action === "Done" && current) {
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 completed = requiredTask(await run([
380
- "agent", "done", String(current.id), "--expected-updated-at", current.updated_at,
381
- ], undefined, canAct), current.id);
382
- if (completed.state !== "done") throw new Error(compatible);
383
- if (currentSession() && branch === branchEpoch) remember(null, branch);
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. 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.",
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.9.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();