@henryqw/pi-pr 3.1.9 → 3.1.10

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
@@ -45,10 +45,12 @@ Add optional instructions to guide a creation, branch-update, CI-fix, or feedbac
45
45
  | --- | --- | --- |
46
46
  | `/pr [instructions]` | command | Run the current pull request's next safe route. |
47
47
  | Footer | ui | Show a linked `PR #number` and one plain-language status. |
48
- | Widget | ui | Show one actionable icon-prefixed `Run /pr to …` hint. |
48
+ | Widget | ui | Show one action hint or transient routing status. |
49
49
 
50
50
  The footer already shows the pull request and status. Actionable widgets omit duplicate identity and status. Each uses one semantic status icon, a space, and a plain `Run /pr to …` route. `✗` marks errors, `!` warnings, `✓` success, and `●` accent or neutral routes. In TUI, only the icon uses a theme color. RPC and non-TUI output use the same plain text without ANSI.
51
51
 
52
+ The widget switches to `⠋ Checking pull request…` as soon as `/pr` starts discovery. The braille spinner animates in TUI mode. RPC receives one plain static line. The footer stays unchanged. The routing widget clears after route selection and before any prompt, notification, mutation, or workflow dispatch.
53
+
52
54
  ## Flow
53
55
 
54
56
  Each footer entry is one linked `PR #number` plus one plain-language status: `N unresolved`, `draft`, `open`, `approved`, `CI running`, `CI failed`, `changes requested`, `base update required`, `merge conflict`, `merge-ready`, `merged`, or `closed`. Colors support the text; they do not carry meaning alone.
@@ -115,7 +117,7 @@ The footer and widget load at session start. A directory outside a Git worktree
115
117
 
116
118
  They refresh after local commits, PR creation, pushes, and each dispatched workflow settles. During creation, intermediate refreshes wait until the workflow settles. They also refresh after any successful delegated task settles. Active Git worktrees poll every 30 seconds. Polling updates presentation only and may be stale.
117
119
 
118
- The create widget stays hidden until the local branch has a commit beyond its creation point. Any displayed widget clears as soon as `/pr` starts. A dispatched workflow keeps it hidden until the agent settles. A direct merge, no-action route, or failed command refreshes the widget when the handler finishes.
120
+ The create widget stays hidden until the local branch has a commit beyond its creation point. `/pr` replaces any hint with routing feedback while it selects a route. The feedback clears before route interaction. A dispatched workflow keeps the widget hidden until the agent settles. Direct and no-action routes refresh it after completion. A failed command restores the prior hint and schedules a refresh.
119
121
 
120
122
  Presentation uses route priority, so draft appears before running CI. `/pr` reads fresh state before routing or merging. The command is authoritative for actions.
121
123
 
@@ -27,7 +27,11 @@ const WORKFLOWS: Record<WorkflowNextStep, string> = {
27
27
  };
28
28
 
29
29
  type PrCommandPi = Pick<ExtensionAPI, "exec" | "getCommands" | "sendUserMessage">;
30
- export type PrCommandHandler = (args: string, ctx: ExtensionCommandContext) => Promise<NextStep>;
30
+ export type PrCommandHandler = (
31
+ args: string,
32
+ ctx: ExtensionCommandContext,
33
+ onRouteResolved?: (nextStep: NextStep) => void,
34
+ ) => Promise<NextStep>;
31
35
 
32
36
  type PrCommandDependencies = {
33
37
  loadCurrentPullRequest?: typeof loadCurrentPullRequest;
@@ -175,10 +179,11 @@ export function createPrCommandHandler(
175
179
  ): PrCommandHandler {
176
180
  const load = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest;
177
181
  const link = dependencies.linkInferredPullRequest ?? linkInferredPullRequest;
178
- return async (args, ctx) => {
182
+ return async (args, ctx, onRouteResolved) => {
179
183
  const instructions = args.trim();
180
184
  const discovery = await load(pi, ctx);
181
185
  const nextStep = deriveNextStep(discovery);
186
+ onRouteResolved?.(nextStep);
182
187
  if (instructions && !(nextStep in WORKFLOWS)) {
183
188
  throw new Error("The current /pr route does not accept instructions");
184
189
  }
package/extensions/pr.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  type ExtensionAPI,
4
4
  type ExtensionContext,
5
5
  } from "@earendil-works/pi-coding-agent";
6
+ import { truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
6
7
  import { createHerdrClient } from "@henryqw/pi-herdr";
7
8
  import { createPrCommandHandler } from "./pr-command.ts";
8
9
  import {
@@ -24,6 +25,9 @@ import {
24
25
  } from "./pr-ui.ts";
25
26
 
26
27
  const POLL_INTERVAL_MS = 30_000;
28
+ const ROUTING_SPINNER_INTERVAL_MS = 80;
29
+ const ROUTING_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
30
+ const ROUTING_WIDGET_TEXT = "Checking pull request…";
27
31
  const HERDR_TIMEOUT_MS = 10_000;
28
32
  const UI_KEY = "pi-pr";
29
33
  const OBSERVATION_ENTRY = "pi-pr-observation";
@@ -130,9 +134,23 @@ export default function pullRequestExtension(
130
134
  let mergeCompleted = false;
131
135
  let displayedWidget: PrDisplay | undefined;
132
136
  let commandGeneration = 0;
133
- const activeInvocations = new Map<number, "routing" | "create-workflow" | "workflow">();
137
+ const activeInvocations = new Map<number, "routing" | "resolved" | "create-workflow" | "workflow">();
138
+ let widgetKind: "presentation" | "routing" = "presentation";
139
+ let routingSpinnerFrame = 0;
140
+ let routingSpinnerTimer: ReturnType<typeof setInterval> | undefined;
141
+ let routingSpinnerTui: TUI | undefined;
142
+ let routingSpinnerComponent: (Component & { dispose(): void }) | undefined;
143
+
144
+ const stopRoutingSpinner = (): void => {
145
+ if (routingSpinnerTimer !== undefined) clearInterval(routingSpinnerTimer);
146
+ routingSpinnerTimer = undefined;
147
+ routingSpinnerTui = undefined;
148
+ routingSpinnerComponent = undefined;
149
+ };
134
150
 
135
151
  const setWidget = (ctx: ExtensionContext, display: PrDisplay | undefined): void => {
152
+ stopRoutingSpinner();
153
+ widgetKind = "presentation";
136
154
  if (display?.widget === undefined) {
137
155
  ctx.ui.setWidget(UI_KEY, undefined);
138
156
  return;
@@ -147,6 +165,57 @@ export default function pullRequestExtension(
147
165
  ctx.ui.setWidget(UI_KEY, formatPrWidget(display));
148
166
  };
149
167
 
168
+ const setRoutingWidget = (ctx: ExtensionContext): void => {
169
+ if (widgetKind === "routing") return;
170
+ stopRoutingSpinner();
171
+ widgetKind = "routing";
172
+ routingSpinnerFrame = 0;
173
+ if (ctx.mode !== "tui") {
174
+ ctx.ui.setWidget(UI_KEY, [`${ROUTING_SPINNER_FRAMES[0]} ${ROUTING_WIDGET_TEXT}`]);
175
+ return;
176
+ }
177
+
178
+ ctx.ui.setWidget(UI_KEY, (tui, theme) => {
179
+ let cachedWidth: number | undefined;
180
+ let cachedLines: string[] | undefined;
181
+ const component: Component & { dispose(): void } = {
182
+ invalidate() {
183
+ cachedWidth = undefined;
184
+ cachedLines = undefined;
185
+ },
186
+ render(width) {
187
+ if (width <= 0) return [];
188
+ if (cachedLines !== undefined && cachedWidth === width) return cachedLines;
189
+ const line = `${theme.fg("accent", ROUTING_SPINNER_FRAMES[routingSpinnerFrame]!)} ${ROUTING_WIDGET_TEXT}`;
190
+ cachedWidth = width;
191
+ cachedLines = [truncateToWidth(line, Math.max(1, width))];
192
+ return cachedLines;
193
+ },
194
+ dispose() {
195
+ if (routingSpinnerComponent !== component) return;
196
+ stopRoutingSpinner();
197
+ widgetKind = "presentation";
198
+ },
199
+ };
200
+ routingSpinnerTui = tui;
201
+ routingSpinnerComponent = component;
202
+ return component;
203
+ });
204
+ routingSpinnerTimer = setInterval(() => {
205
+ routingSpinnerFrame = (routingSpinnerFrame + 1) % ROUTING_SPINNER_FRAMES.length;
206
+ routingSpinnerComponent?.invalidate();
207
+ routingSpinnerTui?.requestRender();
208
+ }, ROUTING_SPINNER_INTERVAL_MS);
209
+ };
210
+
211
+ const reconcileWidget = (ctx: ExtensionContext): void => {
212
+ if ([...activeInvocations.values()].includes("routing")) {
213
+ setRoutingWidget(ctx);
214
+ return;
215
+ }
216
+ setWidget(ctx, activeInvocations.size > 0 ? undefined : displayedWidget);
217
+ };
218
+
150
219
  const render = (
151
220
  ctx: ExtensionContext,
152
221
  discovery: Awaited<ReturnType<typeof loadCurrentPullRequest>>,
@@ -159,7 +228,7 @@ export default function pullRequestExtension(
159
228
  lastDiscovery = "inactive";
160
229
  displayedWidget = undefined;
161
230
  ctx.ui.setStatus(UI_KEY, undefined);
162
- setWidget(ctx, undefined);
231
+ reconcileWidget(ctx);
163
232
  return;
164
233
  }
165
234
  const display = projectPrDisplay(discovery, localCommit);
@@ -168,9 +237,8 @@ export default function pullRequestExtension(
168
237
  throw new Error("Pull request display is missing a footer");
169
238
  }
170
239
  displayedWidget = display.widget === undefined ? undefined : display;
171
- const widget = activeInvocations.size > 0 ? undefined : displayedWidget;
172
240
  ctx.ui.setStatus(UI_KEY, footer);
173
- setWidget(ctx, widget);
241
+ reconcileWidget(ctx);
174
242
  if (discovery.kind === "blocked") {
175
243
  const key = discoveryIssueKey(discovery.issue);
176
244
  if (lastBlockedIssueKey !== key) {
@@ -203,6 +271,8 @@ export default function pullRequestExtension(
203
271
  displayedWidget = undefined;
204
272
  commandGeneration = 0;
205
273
  activeInvocations.clear();
274
+ stopRoutingSpinner();
275
+ widgetKind = "presentation";
206
276
  if (timer !== undefined) clearInterval(timer);
207
277
  timer = undefined;
208
278
  active?.abort();
@@ -255,8 +325,9 @@ export default function pullRequestExtension(
255
325
  if (!controller.signal.aborted && sessionGeneration === generation) {
256
326
  if (!displayEstablished) {
257
327
  const unavailable = unavailablePrDisplay();
328
+ displayedWidget = undefined;
258
329
  ctx.ui.setStatus(UI_KEY, formatPrFooter(unavailable, ctx.ui.theme));
259
- setWidget(ctx, undefined);
330
+ reconcileWidget(ctx);
260
331
  displayEstablished = true;
261
332
  }
262
333
  reportRefreshFailure();
@@ -352,17 +423,21 @@ export default function pullRequestExtension(
352
423
  const generation = sessionGeneration;
353
424
  const invocation = ++commandGeneration;
354
425
  activeInvocations.set(invocation, "routing");
355
- if (displayedWidget !== undefined) setWidget(ctx, undefined);
426
+ reconcileWidget(ctx);
427
+ const routeResolved = (): void => {
428
+ if (sessionGeneration !== generation || activeInvocations.get(invocation) !== "routing") return;
429
+ activeInvocations.set(invocation, "resolved");
430
+ reconcileWidget(ctx);
431
+ };
356
432
  let nextStep: Awaited<ReturnType<typeof commandHandler>>;
357
433
  try {
358
- nextStep = await commandHandler(args, ctx);
434
+ nextStep = await commandHandler(args, ctx, routeResolved);
435
+ routeResolved();
359
436
  } catch (error) {
360
437
  if (sessionGeneration === generation) {
361
438
  cancelRefresh();
362
439
  activeInvocations.delete(invocation);
363
- if (!activeInvocations.size) {
364
- setWidget(ctx, displayedWidget);
365
- }
440
+ reconcileWidget(ctx);
366
441
  refreshInBackground();
367
442
  }
368
443
  throw error;
@@ -372,7 +447,7 @@ export default function pullRequestExtension(
372
447
  if (WORKFLOW_ROUTES.has(nextStep)) {
373
448
  activeInvocations.set(invocation, nextStep === "create" ? "create-workflow" : "workflow");
374
449
  if (nextStep === "create") ctx.ui.setStatus(UI_KEY, undefined);
375
- setWidget(ctx, undefined);
450
+ reconcileWidget(ctx);
376
451
  } else {
377
452
  if (nextStep === "merge") mergeCompleted = true;
378
453
  activeInvocations.delete(invocation);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "3.1.9",
3
+ "version": "3.1.10",
4
4
  "description": "Run /pr to safely discover or link the current pull request, then create, update, address feedback, fix CI, or merge when ready.",
5
5
  "keywords": [
6
6
  "pi-package",