pi-note 0.5.1 → 0.6.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 +32 -9
- package/footer-links.ts +222 -0
- package/index.ts +2 -2
- package/package.json +2 -2
- package/pr-watch.ts +38 -7
package/README.md
CHANGED
|
@@ -100,17 +100,23 @@ user asks to switch. Reuse a saved tag name when it fits.
|
|
|
100
100
|
|
|
101
101
|
Agents are guided to keep notes to three short bullets total: relevant outcome,
|
|
102
102
|
blocker, and next action. Replace stale notes; omit narration, repeated task text,
|
|
103
|
-
and routine test logs. Keep
|
|
104
|
-
|
|
103
|
+
and routine test logs. Keep a GitHub pull request in `PR`. To show another footer
|
|
104
|
+
field, set its Markdown value and append its label to `Bar`, one label per line.
|
|
105
|
+
Do not list `PR` in `Bar`. Remove the field and its `Bar` line to drop it. This is
|
|
106
|
+
guidance, not truncation or a storage limit.
|
|
105
107
|
|
|
106
|
-
Values are Markdown strings; no fields are required. `PR` enables the watcher below
|
|
108
|
+
Values are Markdown strings; no fields are required. `PR` enables the watcher below.
|
|
109
|
+
`Bar` chooses extra footer fields:
|
|
107
110
|
|
|
108
111
|
```markdown
|
|
109
112
|
# Agent
|
|
110
113
|
PR: [Task selection #42](https://github.com/org/repo/pull/42)
|
|
114
|
+
Dashboard: [Metrics](http://127.0.0.1:3000/d/app)
|
|
115
|
+
Next: Address review comments
|
|
116
|
+
Bar: Dashboard
|
|
117
|
+
Next
|
|
111
118
|
Jira: [PROJ-123](https://example.atlassian.net/browse/PROJ-123)
|
|
112
119
|
CWD: `/home/me/project`
|
|
113
|
-
Next: Address review comments
|
|
114
120
|
```
|
|
115
121
|
|
|
116
122
|
The GTK preview renders this section using its existing safe Markdown renderer.
|
|
@@ -119,12 +125,26 @@ sessions. Labels are case-sensitive (trimmed, Unicode-normalized), at most 64
|
|
|
119
125
|
characters; values are at most 4096 characters. Maximum 64 fields / 32 KiB JSON
|
|
120
126
|
per task. Use removal rather than empty values.
|
|
121
127
|
|
|
128
|
+
### Footer links
|
|
129
|
+
|
|
130
|
+
The selected active or in-progress task can show fields after its title.
|
|
131
|
+
`PR` is always shown when it matches the watcher format below, as **PR #123**.
|
|
132
|
+
`Bar` is a newline-separated list of other field labels. Each listed value is
|
|
133
|
+
Markdown: the footer shows its text, and links in it are clickable for any scheme
|
|
134
|
+
except `javascript:`, `data:`, and `vbscript:`. Credentials, control characters,
|
|
135
|
+
and targets over 2048 characters after serialization are shown as text but not
|
|
136
|
+
linked. Missing labels and the `PR` and `Bar` labels themselves are skipped.
|
|
137
|
+
At most four extra fields are shown, each truncated to 60 columns. Removing a listed
|
|
138
|
+
field hides it even if `Bar` still names it. Terminal OSC 8 support is required
|
|
139
|
+
for clicking links. Pi joins footer statuses on one line, so a narrow terminal can
|
|
140
|
+
ellipsize later fields. Other fields, such as `Jira` and `CWD` in the example, stay
|
|
141
|
+
off the footer unless named in `Bar`.
|
|
142
|
+
|
|
122
143
|
### PR merge watcher
|
|
123
144
|
|
|
124
145
|
Set `PR` with `pinote_update` to one `https://github.com/owner/repo/pull/123` URL
|
|
125
146
|
or Markdown link. The footer adds a clickable, link-coloured **PR #123**, without
|
|
126
|
-
status text. Other hosts, multiple links, and prose are not watched.
|
|
127
|
-
OSC 8 support is required for clicking links.
|
|
147
|
+
status text. Other hosts, multiple links, and prose are not watched.
|
|
128
148
|
|
|
129
149
|
Interactive Pi checks only the selected task's PR using authenticated `gh`
|
|
130
150
|
(`gh auth login`). Polling defaults to 60 seconds; launch Pi with
|
|
@@ -133,9 +153,12 @@ Allowed intervals are 10–86400 seconds. The selected task's link remains visib
|
|
|
133
153
|
Network/authentication failures warn once until recovery and retry next interval.
|
|
134
154
|
No polling runs in print/RPC mode or after Pi exits.
|
|
135
155
|
|
|
136
|
-
On merge, Pi waits until idle and asks **Mark this task completed?**
|
|
137
|
-
|
|
138
|
-
|
|
156
|
+
On merge, Pi waits until idle and asks **Mark this task completed?** In Kitty,
|
|
157
|
+
indeterminate progress animates the tab while the prompt awaits input (with Kitty's
|
|
158
|
+
default progress-aware tab title or a working/ready renderer). Progress clears on
|
|
159
|
+
response, cancellation, or shutdown; other terminals and redirected output are untouched.
|
|
160
|
+
Confirmation uses the same guarded CLI Done operation as `/pi-note`; declining leaves
|
|
161
|
+
the task unchanged. If it is already done, Pi says **PR #123 was merged and the task is
|
|
139
162
|
already completed**, without completing it again. Completion clears the selection
|
|
140
163
|
and hides the footer link, even with polling disabled. The background watch remains
|
|
141
164
|
until another task is selected, the link is removed, or Pi exits. Removed/scheduled tasks are no longer watched.
|
package/footer-links.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { stripVTControlCharacters } from "node:util";
|
|
3
|
+
|
|
4
|
+
export const footerStatusKey = "pinote-links";
|
|
5
|
+
export const legacyFooterStatusKey = "pinote-pr";
|
|
6
|
+
const maxCustomChips = 4;
|
|
7
|
+
const maxLabelWidth = 24;
|
|
8
|
+
const maxChipWidth = 60;
|
|
9
|
+
const maxUrlLength = 2048;
|
|
10
|
+
const blockedProtocols = new Set(["javascript:", "data:", "vbscript:"]);
|
|
11
|
+
const control = /[\u0000-\u001f\u007f-\u009f]/u;
|
|
12
|
+
|
|
13
|
+
export type FooterSegment = { text: string; url?: string };
|
|
14
|
+
export type FooterChip = { segments: FooterSegment[] };
|
|
15
|
+
|
|
16
|
+
function display(value: string): string {
|
|
17
|
+
// Keep whitespace so newlines stay word boundaries. Drop other C0/C1 controls;
|
|
18
|
+
// U+009D survives stripVTControlCharacters and can start OSC.
|
|
19
|
+
return stripVTControlCharacters(value)
|
|
20
|
+
.replace(new RegExp(control, "gu"), (char) => /\s/u.test(char) ? " " : "")
|
|
21
|
+
.replace(/\s+/gu, " ");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function plain(value: string): string {
|
|
25
|
+
return display(value).trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function clip(value: string, width: number): string {
|
|
29
|
+
return stripVTControlCharacters(truncateToWidth(plain(value), width));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ownText(notes: Record<string, string>, name: string): string | undefined {
|
|
33
|
+
if (!Object.hasOwn(notes, name)) return;
|
|
34
|
+
const value = notes[name];
|
|
35
|
+
return typeof value === "string" ? value : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Any scheme except scriptable or credentialed targets. Reject terminal escapes and oversized serialization.
|
|
39
|
+
function linkTarget(value: string): string | undefined {
|
|
40
|
+
// Do not trim or strip first: that can turn a dirty destination into a different URL.
|
|
41
|
+
if (!value || value.length > maxUrlLength || /[\s\\<>"`]/u.test(value) || control.test(value)) return;
|
|
42
|
+
const candidate = value;
|
|
43
|
+
let parsed: URL;
|
|
44
|
+
try {
|
|
45
|
+
parsed = new URL(candidate);
|
|
46
|
+
} catch {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (blockedProtocols.has(parsed.protocol) || parsed.username || parsed.password || !parsed.protocol) return;
|
|
50
|
+
if (parsed.href.length > maxUrlLength || /[\\<>"`]/u.test(parsed.href) || control.test(parsed.href)) return;
|
|
51
|
+
return parsed.href;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function pushText(segments: FooterSegment[], text: string) {
|
|
55
|
+
const cleaned = display(text).replace(/\*\*|__/gu, "");
|
|
56
|
+
if (!cleaned) return;
|
|
57
|
+
const last = segments.at(-1);
|
|
58
|
+
if (last && !last.url) last.text += cleaned;
|
|
59
|
+
else segments.push({ text: cleaned });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readCode(source: string, index: number): { text: string; end: number } | undefined {
|
|
63
|
+
if (source[index] !== "`") return;
|
|
64
|
+
const end = source.indexOf("`", index + 1);
|
|
65
|
+
if (end < 0) return;
|
|
66
|
+
return { text: source.slice(index + 1, end), end: end + 1 };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readDestination(source: string, start: number): { dest: string; end: number } | undefined {
|
|
70
|
+
if (source[start] === "<") {
|
|
71
|
+
const close = source.indexOf(">", start + 1);
|
|
72
|
+
if (close < 0) return;
|
|
73
|
+
return { dest: source.slice(start + 1, close), end: close + 1 };
|
|
74
|
+
}
|
|
75
|
+
let depth = 0;
|
|
76
|
+
let end = start;
|
|
77
|
+
for (; end < source.length; end++) {
|
|
78
|
+
const char = source[end];
|
|
79
|
+
if (char === "(") depth++;
|
|
80
|
+
else if (char === ")") {
|
|
81
|
+
if (depth === 0) break;
|
|
82
|
+
depth--;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (end === start || depth !== 0) return;
|
|
86
|
+
return { dest: source.slice(start, end), end };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readLink(source: string, index: number): { text: string; dest: string; end: number } | undefined {
|
|
90
|
+
let start = index;
|
|
91
|
+
if (source[start] === "!") start++;
|
|
92
|
+
if (source[start] !== "[") return;
|
|
93
|
+
const labelEnd = source.indexOf("]", start + 1);
|
|
94
|
+
if (labelEnd < 0 || source[labelEnd + 1] !== "(") return;
|
|
95
|
+
const dest = readDestination(source, labelEnd + 2);
|
|
96
|
+
if (!dest || source[dest.end] !== ")") return;
|
|
97
|
+
return { text: source.slice(start + 1, labelEnd), dest: dest.dest, end: dest.end + 1 };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readAuto(source: string, index: number): { text: string; dest: string; end: number } | undefined {
|
|
101
|
+
if (source[index] !== "<") return;
|
|
102
|
+
const close = source.indexOf(">", index + 1);
|
|
103
|
+
if (close < 0) return;
|
|
104
|
+
const dest = source.slice(index + 1, close);
|
|
105
|
+
if (!/^[a-z][a-z0-9+.-]*:/iu.test(dest)) return;
|
|
106
|
+
return { text: dest, dest, end: close + 1 };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readBare(source: string, index: number): { text: string; dest: string; end: number } | undefined {
|
|
110
|
+
if (index > 0 && /[A-Za-z0-9]/u.test(source[index - 1])) return;
|
|
111
|
+
const scheme = /^[a-z][a-z0-9+.-]*:\/\//iu.exec(source.slice(index));
|
|
112
|
+
if (!scheme) return;
|
|
113
|
+
let end = index + scheme[0].length;
|
|
114
|
+
let depth = 0;
|
|
115
|
+
for (; end < source.length; end++) {
|
|
116
|
+
const char = source[end];
|
|
117
|
+
if (/[\s<>\]*]/u.test(char) || control.test(char)) break;
|
|
118
|
+
if (char === "(") depth++;
|
|
119
|
+
else if (char === ")") {
|
|
120
|
+
if (depth === 0) break;
|
|
121
|
+
depth--;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const raw = source.slice(index, end).replace(/[.,;:!?]+$/u, "").replace(/__$/u, "");
|
|
125
|
+
if (!raw || depth !== 0) return;
|
|
126
|
+
return { text: raw, dest: raw, end: index + raw.length };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function emitLink(segments: FooterSegment[], label: string, dest: string) {
|
|
130
|
+
const text = plain(label).replace(/\*\*|__/gu, "") || plain(dest);
|
|
131
|
+
if (!text) return;
|
|
132
|
+
const url = linkTarget(dest);
|
|
133
|
+
segments.push(url ? { text, url } : { text });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function renderMarkdown(value: string | undefined): FooterSegment[] {
|
|
137
|
+
const source = value ?? "";
|
|
138
|
+
if (!plain(source)) return [];
|
|
139
|
+
const segments: FooterSegment[] = [];
|
|
140
|
+
let text = "";
|
|
141
|
+
for (let index = 0; index < source.length;) {
|
|
142
|
+
const code = readCode(source, index);
|
|
143
|
+
const link = code ? undefined : readLink(source, index);
|
|
144
|
+
const auto = code || link ? undefined : readAuto(source, index);
|
|
145
|
+
const bare = code || link || auto ? undefined : readBare(source, index);
|
|
146
|
+
const token = link ?? auto ?? bare;
|
|
147
|
+
if (code) {
|
|
148
|
+
pushText(segments, text);
|
|
149
|
+
text = "";
|
|
150
|
+
pushText(segments, code.text);
|
|
151
|
+
index = code.end;
|
|
152
|
+
} else if (token) {
|
|
153
|
+
pushText(segments, text);
|
|
154
|
+
text = "";
|
|
155
|
+
emitLink(segments, token.text, token.dest);
|
|
156
|
+
index = token.end;
|
|
157
|
+
} else {
|
|
158
|
+
text += source[index];
|
|
159
|
+
index++;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
pushText(segments, text);
|
|
163
|
+
if (segments[0] && !segments[0].url) segments[0].text = segments[0].text.trimStart();
|
|
164
|
+
const last = segments.at(-1);
|
|
165
|
+
if (last && !last.url) last.text = last.text.trimEnd();
|
|
166
|
+
return segments.filter((segment) => segment.text);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function truncateSegments(segments: readonly FooterSegment[], width: number): FooterSegment[] {
|
|
170
|
+
const kept: FooterSegment[] = [];
|
|
171
|
+
let used = 0;
|
|
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);
|
|
179
|
+
}
|
|
180
|
+
return kept;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function customFooterChips(notes: Record<string, string> | undefined): FooterChip[] {
|
|
184
|
+
if (!notes) return [];
|
|
185
|
+
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();
|
|
190
|
+
if (!name || seen.has(name)) continue;
|
|
191
|
+
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) });
|
|
196
|
+
}
|
|
197
|
+
return chips;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function footerChips(
|
|
201
|
+
task: { state: string; agent_notes: Record<string, string> } | null,
|
|
202
|
+
pr: { url: string; number: string } | undefined,
|
|
203
|
+
): FooterChip[] {
|
|
204
|
+
if (!task || !["active", "in_progress"].includes(task.state)) return [];
|
|
205
|
+
return [
|
|
206
|
+
...(pr ? [{ segments: [{ text: `PR #${pr.number}`, url: pr.url }] }] : []),
|
|
207
|
+
...customFooterChips(task.agent_notes),
|
|
208
|
+
];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function renderFooterLinks(
|
|
212
|
+
chips: readonly FooterChip[],
|
|
213
|
+
colorLink: (text: string) => string,
|
|
214
|
+
): string | undefined {
|
|
215
|
+
const rendered = chips.flatMap((chip) => {
|
|
216
|
+
const text = chip.segments.map((segment) => segment.url
|
|
217
|
+
? colorLink(`\x1b]8;;${segment.url}\x1b\\${segment.text}\x1b]8;;\x1b\\`)
|
|
218
|
+
: segment.text).join("");
|
|
219
|
+
return text.trim() ? [text] : [];
|
|
220
|
+
});
|
|
221
|
+
return rendered.length ? rendered.join(" · ") : undefined;
|
|
222
|
+
}
|
package/index.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { createPRWatcher } from "./pr-watch.ts";
|
|
|
7
7
|
import { TaskPicker, taskState, taskTag } from "./task-picker.ts";
|
|
8
8
|
import { bundledCLIVersion, cliMenu, detectCLI, setupCLI, setupHint, versionAtLeast, type CLIAction } from "./setup.ts";
|
|
9
9
|
|
|
10
|
-
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
|
|
10
|
+
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.";
|
|
11
11
|
const createGuidance = [
|
|
12
12
|
"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.",
|
|
13
13
|
"Reuse a pinote_tags name when it fits. One tag; case-sensitive, trimmed, at most 64 characters. pinote_tag changes an existing active or in-progress task after pinote_get; pass updated_at. Do not use pinote_update for tags or task text.",
|
|
@@ -378,7 +378,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
378
378
|
name: "pinote_update",
|
|
379
379
|
label: "Pinote update",
|
|
380
380
|
promptGuidelines: [handoffGuidance],
|
|
381
|
-
description: "Patch arbitrary agent handoff fields on an explicit pinote task. Read first with pinote_get; pass its updated_at as expected_updated_at. 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
|
|
381
|
+
description: "Patch arbitrary agent handoff fields on an explicit pinote task. Read first with pinote_get; pass its updated_at as expected_updated_at. 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.",
|
|
382
382
|
parameters: Type.Object({
|
|
383
383
|
id: idSchema,
|
|
384
384
|
expected_updated_at: Type.String({ minLength: 1 }),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-note",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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", "task-picker.ts", "setup.ts", "icons", "README.md", "LICENSE"],
|
|
17
|
+
"files": ["index.ts", "pr-watch.ts", "footer-links.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,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { footerChips, footerStatusKey, legacyFooterStatusKey, renderFooterLinks, type FooterChip } from "./footer-links.ts";
|
|
2
3
|
|
|
3
4
|
export type WatchedTask = {
|
|
4
5
|
id: number;
|
|
@@ -32,10 +33,35 @@ const watchKey = (task: WatchedTask | null) => {
|
|
|
32
33
|
return task && pr ? keyFor(task, pr) : undefined;
|
|
33
34
|
};
|
|
34
35
|
|
|
36
|
+
async function confirmCompletion(ctx: ExtensionContext, pr: PR, signal: AbortSignal) {
|
|
37
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
38
|
+
const report = () => process.stdout.write("\x1b]9;4;3\x1b\\");
|
|
39
|
+
const stop = () => {
|
|
40
|
+
if (timer === undefined) return;
|
|
41
|
+
clearInterval(timer);
|
|
42
|
+
timer = undefined;
|
|
43
|
+
process.stdout.write("\x1b]9;4;0\x1b\\");
|
|
44
|
+
};
|
|
45
|
+
if (process.stdout.isTTY && process.env.KITTY_WINDOW_ID && !signal.aborted) {
|
|
46
|
+
report();
|
|
47
|
+
// Refresh Kitty's progress timeout and redraw the tab while input is pending.
|
|
48
|
+
timer = setInterval(report, 125);
|
|
49
|
+
timer.unref();
|
|
50
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
return await ctx.ui.confirm(`PR #${pr.number} was merged`, "Mark this task completed?", { signal });
|
|
54
|
+
} finally {
|
|
55
|
+
signal.removeEventListener("abort", stop);
|
|
56
|
+
stop();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
35
60
|
export function createPRWatcher(pi: ExtensionAPI, deps: Dependencies) {
|
|
36
61
|
let ctx: ExtensionContext | undefined;
|
|
37
62
|
let watched: WatchedTask | null = null;
|
|
38
63
|
let visiblePR: PR | undefined;
|
|
64
|
+
let visibleLinks: FooterChip[] = [];
|
|
39
65
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
40
66
|
let controller = new AbortController();
|
|
41
67
|
let generation = 0;
|
|
@@ -54,11 +80,13 @@ export function createPRWatcher(pi: ExtensionAPI, deps: Dependencies) {
|
|
|
54
80
|
return changed;
|
|
55
81
|
};
|
|
56
82
|
const paint = () => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
83
|
+
const current = ctx;
|
|
84
|
+
if (!current) return;
|
|
85
|
+
// Clear the old key first so a reload cannot show PR twice.
|
|
86
|
+
current.ui.setStatus(legacyFooterStatusKey, undefined);
|
|
87
|
+
current.ui.setStatus(footerStatusKey, renderFooterLinks(
|
|
88
|
+
visibleLinks, (value) => current.ui.theme.fg("mdLink", value),
|
|
89
|
+
));
|
|
62
90
|
};
|
|
63
91
|
const schedule = (delay = interval) => {
|
|
64
92
|
if (!ctx || !interval || running) return;
|
|
@@ -71,6 +99,7 @@ export function createPRWatcher(pi: ExtensionAPI, deps: Dependencies) {
|
|
|
71
99
|
revision++;
|
|
72
100
|
// Display only the active selection, independently of the retained merge watch.
|
|
73
101
|
visiblePR = current && ["active", "in_progress"].includes(current.state) ? taskPR(current) : undefined;
|
|
102
|
+
visibleLinks = footerChips(current, visiblePR);
|
|
74
103
|
// Completion clears selection. Keep the last task until poll verifies its state.
|
|
75
104
|
const changed = current ? remember(taskPR(current) ? current : null) : false;
|
|
76
105
|
paint();
|
|
@@ -129,7 +158,7 @@ export function createPRWatcher(pi: ExtensionAPI, deps: Dependencies) {
|
|
|
129
158
|
};
|
|
130
159
|
if (current.state === "done") { alreadyDone(); return; }
|
|
131
160
|
if (!["active", "in_progress"].includes(current.state)) return;
|
|
132
|
-
const confirmed = await context
|
|
161
|
+
const confirmed = await confirmCompletion(context, pr, signal);
|
|
133
162
|
if (!same() || !context.isIdle()) return;
|
|
134
163
|
// Re-read selection, link, and revision after the dialog; never complete a changed task.
|
|
135
164
|
const fresh = await latest(context, current);
|
|
@@ -192,10 +221,12 @@ export function createPRWatcher(pi: ExtensionAPI, deps: Dependencies) {
|
|
|
192
221
|
controller.abort();
|
|
193
222
|
controller = new AbortController();
|
|
194
223
|
clearTimeout(timer);
|
|
195
|
-
ctx?.ui.setStatus(
|
|
224
|
+
ctx?.ui.setStatus(legacyFooterStatusKey, undefined);
|
|
225
|
+
ctx?.ui.setStatus(footerStatusKey, undefined);
|
|
196
226
|
ctx = undefined;
|
|
197
227
|
watched = null;
|
|
198
228
|
visiblePR = undefined;
|
|
229
|
+
visibleLinks = [];
|
|
199
230
|
running = false;
|
|
200
231
|
warned = false;
|
|
201
232
|
},
|