pi-better-goal 0.1.20 → 0.1.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/goal-clock.ts CHANGED
@@ -62,7 +62,7 @@ export function formatGoalClock(
62
62
  ): string {
63
63
  const timing = goalTiming(goal, now);
64
64
  return (
65
- `Goal [${goal.status}]: ${goal.objective}` +
65
+ `Goal [${goal.status}]: ${flattenObjectiveForRail(goal.objective)}` +
66
66
  ` | active ${formatClockDuration(timing.activeSeconds)}` +
67
67
  ` | elapsed ${formatClockDuration(timing.elapsedSeconds)}`
68
68
  );
@@ -85,10 +85,26 @@ export function renderGoalClockLine(
85
85
  if (fixedWidth >= width) {
86
86
  return truncateToWidth(`${heading} ${active}`, width);
87
87
  }
88
- const objective = truncateToWidth(goal.objective, width - fixedWidth);
88
+ const objective = truncateToWidth(
89
+ flattenObjectiveForRail(goal.objective),
90
+ width - fixedWidth,
91
+ );
89
92
  return styleGoalRailLine(heading, `${active} ${objective}`, fg);
90
93
  }
91
94
 
95
+ /**
96
+ * Goal rail / clock lines must stay one terminal row. Raw objectives can keep
97
+ * newlines for prompts and `/goal` inspection, but embedding them here makes
98
+ * the widget taller than the dock budget and desyncs main-screen differential
99
+ * paints (Working... / Elapsed stacking into scrollback).
100
+ */
101
+ export function flattenObjectiveForRail(objective: string): string {
102
+ return objective
103
+ .replace(/[\r\n\t]+/g, " ")
104
+ .replace(/ {2,}/g, " ")
105
+ .trim();
106
+ }
107
+
92
108
  function styleGoalRailLine(
93
109
  heading: string,
94
110
  tail: string,
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  import { continuationEvidence, type ContinuationEvidence } from "./continuation.js";
25
25
  import { observeGoalStall } from "./stall.js";
26
26
  import {
27
+ flattenObjectiveForRail,
27
28
  goalClockRefreshDelayMs,
28
29
  goalTiming,
29
30
  isGoalClockVisible,
@@ -164,7 +165,7 @@ export default function (pi: ExtensionAPI): void {
164
165
  let continuationQueuedFor: string | null = null;
165
166
  let idleContinuationTimer: ReturnType<typeof setTimeout> | undefined;
166
167
  let idleContinuationSignature = "";
167
- let refreshGoalWidget: (() => void) | undefined;
168
+ let refreshGoalWidget: ((force?: boolean) => void) | undefined;
168
169
  let lastAgentEvidence: ContinuationEvidence | null = null;
169
170
 
170
171
  const getGoal = (ctx: ExtensionContext): GoalSnapshot | null => currentGoalSnapshot(ctx);
@@ -180,21 +181,53 @@ export default function (pi: ExtensionAPI): void {
180
181
  appendContinuationState(createContinuationState(goal.goalId));
181
182
  };
182
183
 
184
+ const isForegroundBusy = (ctx: ExtensionContext): boolean =>
185
+ foregroundRunning || !ctx.isIdle();
186
+
187
+ /**
188
+ * Chat `notify` appends lines above the Working/bash status region. While the
189
+ * agent is streaming, that height change desyncs Pi main-screen differential
190
+ * rendering and stacks "Working..." / "Elapsed Xs" into scrollback. Keep
191
+ * mid-stream feedback out of the transcript; use the footer status instead.
192
+ */
193
+ const notifyGoal = (
194
+ ctx: ExtensionContext,
195
+ message: string,
196
+ type: "info" | "warning" | "error" = "info",
197
+ ): void => {
198
+ if (!ctx.hasUI) {
199
+ return;
200
+ }
201
+ if (isForegroundBusy(ctx) && type !== "error") {
202
+ try {
203
+ ctx.ui.setStatus(EXTENSION_NAME, message);
204
+ } catch {
205
+ // Footer status is best-effort only.
206
+ }
207
+ return;
208
+ }
209
+ ctx.ui.notify(message, type);
210
+ };
211
+
183
212
  const setGoal = (goal: GoalSnapshot, ctx: ExtensionContext, source: GoalEntrySource): void => {
213
+ const previous = getGoal(ctx);
214
+ const wasVisible = previous !== null && isGoalClockVisible(previous);
184
215
  pi.appendEntry(EXTENSION_NAME, goalSetEntry(goal, source));
185
216
  continuationQueuedFor = null;
186
217
  backgroundDrainTracker = null;
187
218
  clearIdleContinuation();
188
- refreshGoalWidget?.();
219
+ // Force a full redraw when the dock height changes (absent ↔ visible clock).
220
+ refreshGoalWidget?.(!wasVisible || !isGoalClockVisible(goal));
189
221
  };
190
222
 
191
223
  const clearGoal = (ctx: ExtensionContext, source: GoalEntrySource): void => {
192
224
  const current = getGoal(ctx);
225
+ const wasVisible = current !== null && isGoalClockVisible(current);
193
226
  pi.appendEntry(EXTENSION_NAME, goalClearEntry(current?.goalId ?? null, source));
194
227
  continuationQueuedFor = null;
195
228
  backgroundDrainTracker = null;
196
229
  clearIdleContinuation();
197
- refreshGoalWidget?.();
230
+ refreshGoalWidget?.(wasVisible);
198
231
  };
199
232
 
200
233
  const queueGoalContinuation = (goal: GoalSnapshot): void => {
@@ -395,8 +428,24 @@ export default function (pi: ExtensionAPI): void {
395
428
  ctx.ui.setWidget(
396
429
  EXTENSION_NAME,
397
430
  (tui, theme) => {
398
- const scheduler = createRenderScheduler(() => tui.requestRender());
399
- const refresh = (): void => scheduler.request();
431
+ const requestRender = (force = false): void => {
432
+ try {
433
+ tui.requestRender(force);
434
+ } catch {
435
+ // UI paint is best-effort only.
436
+ }
437
+ };
438
+ const scheduler = createRenderScheduler(() => requestRender(false));
439
+ const refresh = (force = false): void => {
440
+ if (force) {
441
+ scheduler.cancel();
442
+ // Height transitions must reset main-screen differential state so
443
+ // Working/Elapsed lines keep rewriting in place instead of stacking.
444
+ requestRender(true);
445
+ return;
446
+ }
447
+ scheduler.request();
448
+ };
400
449
  refreshGoalWidget = refresh;
401
450
 
402
451
  return {
@@ -444,52 +493,56 @@ export default function (pi: ExtensionAPI): void {
444
493
 
445
494
  if (!trimmed) {
446
495
  const continuation = current ? currentContinuationState(ctx, current.goalId) : null;
496
+ // Inspection is always explicit user intent; show the full summary even while busy.
447
497
  ctx.ui.notify(formatGoal(current, continuation, observeGoalStall(current, continuation, { foregroundRunning })), "info");
448
498
  return;
449
499
  }
450
500
 
451
501
  if (trimmed === "pause") {
452
502
  if (!current || current.status !== "active") {
453
- ctx.ui.notify("Only active goals can be paused.", "warning");
503
+ notifyGoal(ctx, "Only active goals can be paused.", "warning");
454
504
  return;
455
505
  }
456
506
  setGoal(goalWithStatus(current, "paused"), ctx, "command");
457
- ctx.ui.notify("Goal paused.", "info");
507
+ notifyGoal(ctx, "Goal paused.");
458
508
  return;
459
509
  }
460
510
 
461
511
  if (trimmed === "resume") {
462
512
  if (!current || current.status !== "paused") {
463
- ctx.ui.notify("Only paused goals can be resumed.", "warning");
513
+ notifyGoal(ctx, "Only paused goals can be resumed.", "warning");
464
514
  return;
465
515
  }
466
516
  const goal = goalWithStatus(current, "active");
467
517
  setGoal(goal, ctx, "command");
468
518
  queueGoalContinuation(goal);
469
- ctx.ui.notify("Goal resumed.", "info");
519
+ notifyGoal(ctx, "Goal resumed.");
470
520
  return;
471
521
  }
472
522
 
473
523
  if (trimmed === "clear") {
474
524
  clearGoal(ctx, "command");
475
- ctx.ui.notify("Goal cleared.", "info");
525
+ notifyGoal(ctx, "Goal cleared.");
476
526
  return;
477
527
  }
478
528
 
479
529
  if (trimmed === "complete") {
480
530
  if (!current) {
481
- ctx.ui.notify("No goal is set.", "warning");
531
+ notifyGoal(ctx, "No goal is set.", "warning");
482
532
  return;
483
533
  }
484
534
  setGoal(goalWithStatus(current, "complete"), ctx, "command");
485
- ctx.ui.notify("Goal marked complete.", "info");
535
+ notifyGoal(ctx, "Goal marked complete.");
486
536
  return;
487
537
  }
488
538
 
489
- if (current && current.status !== "complete" && ctx.hasUI) {
539
+ // Confirm dialogs swap the editor and reflow the dock. While the agent is
540
+ // streaming that desyncs main-screen differential paints, so replace
541
+ // silently mid-turn and only confirm when idle.
542
+ if (current && current.status !== "complete" && ctx.hasUI && !isForegroundBusy(ctx)) {
490
543
  const replace = await ctx.ui.confirm(
491
544
  "Replace active goal?",
492
- `Current goal: ${current.objective}`,
545
+ `Current goal: ${flattenObjectiveForRail(current.objective)}`,
493
546
  );
494
547
  if (!replace) {
495
548
  return;
@@ -498,9 +551,9 @@ export default function (pi: ExtensionAPI): void {
498
551
 
499
552
  try {
500
553
  const goal = startOrReplaceGoal(trimmed, null, ctx, "command");
501
- ctx.ui.notify(`Goal set: ${goal.objective}`, "info");
554
+ notifyGoal(ctx, `Goal set: ${flattenObjectiveForRail(goal.objective)}`);
502
555
  } catch (error) {
503
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
556
+ notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
504
557
  }
505
558
  },
506
559
  });