pi-note 0.9.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.
@@ -123,6 +126,10 @@ user-level configuration only; project-local files are not read.
123
126
  ID argument. Updates merge fields, never replace the task text or tag. Fails if
124
127
  no task is selected or the selection changes during the operation. If another
125
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.
126
133
  - `pinote_tags`: list saved tag names.
127
134
  - `pinote_add`: create an active task. Optional `tag`. `select: true` starts and
128
135
  remembers it for this session only; use that only after the user agrees.
@@ -138,13 +145,14 @@ user asks to switch. Reuse a saved tag name when it fits.
138
145
 
139
146
  Agents are guided to keep notes to three short bullets total: relevant outcome,
140
147
  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.
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.
145
153
 
146
154
  Values are Markdown strings; no fields are required. `PR` enables the watcher below.
147
- `Bar` chooses extra footer fields:
155
+ `Bar` chooses extra footer fields unless configuration overrides it:
148
156
 
149
157
  ```markdown
150
158
  # Agent
@@ -163,31 +171,90 @@ sessions. Labels are case-sensitive (trimmed, Unicode-normalized), at most 64
163
171
  characters; values are at most 4096 characters. Maximum 64 fields / 32 KiB JSON
164
172
  per task. Use removal rather than empty values.
165
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
+
166
229
  ### Footer links
167
230
 
168
231
  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
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
171
235
  Markdown: the footer shows its text, and links in it are clickable for any scheme
172
236
  except `javascript:`, `data:`, and `vbscript:`. Credentials, control characters,
173
237
  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`.
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.
180
246
 
181
247
  ### PR merge watcher
182
248
 
183
249
  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.
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.
186
253
 
187
254
  Interactive Pi checks only the selected task's PR using authenticated `gh`
188
255
  (`gh auth login`). Polling defaults to 60 seconds; launch Pi with
189
256
  `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.
257
+ Allowed intervals are 10–86400 seconds. Configured links remain visible when polling is disabled.
191
258
  Network/authentication failures warn once until recovery and retry next interval.
192
259
  No polling runs in print/RPC mode or after Pi exits.
193
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,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 { 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.";
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.",
@@ -126,6 +128,7 @@ export default function (pi: ExtensionAPI) {
126
128
  let setupAbort: AbortController | undefined;
127
129
  let activeContext: ExtensionContext | undefined;
128
130
  let cliState: CLIAction | undefined;
131
+ let footerConfig = { ...defaultFooterConfig };
129
132
  const checkCLI = async (ctx: ExtensionContext, suggest = false) => {
130
133
  if (!ctx.hasUI || ctx.mode !== "tui" || setupAbort) return;
131
134
  const generation = epoch;
@@ -241,7 +244,7 @@ export default function (pi: ExtensionAPI) {
241
244
  try {
242
245
  const current = await selected(ctx);
243
246
  if (alive && generation === epoch && serial === refreshSerial) {
244
- 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);
245
248
  watcher.update(ctx, current);
246
249
  }
247
250
  } catch {
@@ -252,6 +255,7 @@ export default function (pi: ExtensionAPI) {
252
255
  };
253
256
  const watcher = createPRWatcher(pi, {
254
257
  selected,
258
+ footerConfig: () => footerConfig,
255
259
  get: async (id) => requiredTask(await run(["agent", "get", String(id)]), id),
256
260
  done: async (current, canAct) => {
257
261
  const result = requiredTask(await run([
@@ -275,6 +279,9 @@ export default function (pi: ExtensionAPI) {
275
279
  branchEpoch++;
276
280
  selectedId = readSelection(ctx);
277
281
  const generation = epoch;
282
+ footerConfig = loadFooterConfig((message) => {
283
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
284
+ });
278
285
  cliState = undefined;
279
286
  if (ctx.hasUI && ctx.mode === "tui") {
280
287
  if (offerConfigError) ctx.ui.notify(`Pinote task offers disabled: ${clean(offerConfigError)} Fix the configuration and /reload.`, "warning");
@@ -353,7 +360,7 @@ export default function (pi: ExtensionAPI) {
353
360
  registerInstallCommand("pi-note-upgrade");
354
361
 
355
362
  pi.registerCommand("pi-note", {
356
- description: "Continue, complete, or switch the selected pinote task",
363
+ description: "Continue, complete, switch tasks, or edit global footer settings (Tab)",
357
364
  handler: async (args, ctx) => {
358
365
  if (!ctx.hasUI || ctx.mode !== "tui") return;
359
366
  if (args.trim()) { ctx.ui.notify("Usage: /pi-note", "warning"); return; }
@@ -370,39 +377,59 @@ export default function (pi: ExtensionAPI) {
370
377
  try {
371
378
  const current = await selected(ctx);
372
379
  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) {
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();
378
425
  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");
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");
385
431
  return;
386
432
  }
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
433
  } catch (error) {
407
434
  if (currentSession()) ctx.ui.notify(`Pinote: ${clean(error instanceof Error ? error.message : String(error))}`, "error");
408
435
  } finally {
@@ -426,7 +453,7 @@ export default function (pi: ExtensionAPI) {
426
453
  name: "pinote_update_current",
427
454
  label: "Pinote update current",
428
455
  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.",
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.",
430
457
  parameters: Type.Object({
431
458
  expected_updated_at: Type.String({ minLength: 1 }),
432
459
  set: Type.Optional(Type.Record(Type.String(), Type.String())),
@@ -453,6 +480,18 @@ export default function (pi: ExtensionAPI) {
453
480
  return toolResult(updated);
454
481
  },
455
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
+ });
456
495
  pi.registerTool({
457
496
  name: "pinote_tags",
458
497
  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.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();