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 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 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
- ### Handoff prompt configuration
87
+ ### Personal configuration
85
88
 
86
- Set `handoffPrompt` in `~/.pi/agent/pi-note.json` (or in the agent directory set
87
- by `PI_CODING_AGENT_DIR`):
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
- Both task selection and **Continue** read this file each time; no `/reload` is
96
- needed after edits. The value is literal text, not a prompt template; use `\n`
97
- in JSON strings for multiple lines. It must be a nonblank string without control
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 the user gives work and no task is selected, the agent proposes one note as
121
- `[tag] text` and asks before creating it. No continues without a note. Yes calls
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`. To show another footer
128
- field, set its Markdown value and append its label to `Bar`, one label per line.
129
- Do not list `PR` in `Bar`. Remove the field and its `Bar` line to drop it. This is
130
- guidance, not truncation or a storage limit.
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
- `PR` is always shown when it matches the watcher format below, as **PR #123**.
156
- `Bar` is a newline-separated list of other field labels. Each listed value is
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. Missing labels and the `PR` and `Bar` labels themselves are skipped.
161
- At most four extra fields are shown, each truncated to 60 columns. Removing a listed
162
- field hides it even if `Bar` still names it. Terminal OSC 8 support is required
163
- for clicking links. Pi joins footer statuses on one line, so a narrow terminal can
164
- ellipsize later fields. Other fields, such as `Jira` and `CWD` in the example, stay
165
- off the footer unless named in `Bar`.
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. The footer adds a clickable, link-coloured **PR #123**, without
171
- status text. Other hosts, multiple links, and prose are not watched.
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. The selected task's link remains visible when polling is disabled.
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
 
@@ -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 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,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. 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.";
12
- const createGuidance = [
13
- "When the user gives work and no pinote task is selected, propose one note as `[tag] text` and ask before creating it. 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.",
14
- "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.",
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 handoffPrompt(): string {
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 defaultHandoffPrompt;
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)}`, 60) : undefined);
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 switch the selected pinote task",
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
- const action = current
344
- ? await ctx.ui.select(`Pinote — ${taskState(current)} ${taskTag(current)} ${title(current)}`, ["Continue", "Done", "Switch task"])
345
- : "Switch task";
346
- if (!action || !canAct()) return;
347
- if (action === "Done" && current) {
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 completed = requiredTask(await run([
350
- "agent", "done", String(current.id), "--expected-updated-at", current.updated_at,
351
- ], undefined, canAct), current.id);
352
- if (completed.state !== "done") throw new Error(compatible);
353
- if (currentSession() && branch === branchEpoch) remember(null, branch);
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. 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.",
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.8.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();