@bastani/atomic 0.8.20-0 → 0.8.21-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.
Files changed (127) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/builtin/intercom/package.json +1 -1
  3. package/dist/builtin/mcp/CHANGELOG.md +5 -0
  4. package/dist/builtin/mcp/package.json +1 -1
  5. package/dist/builtin/subagents/CHANGELOG.md +5 -0
  6. package/dist/builtin/subagents/agents/code-simplifier.md +78 -22
  7. package/dist/builtin/subagents/agents/debugger.md +4 -3
  8. package/dist/builtin/subagents/package.json +1 -1
  9. package/dist/builtin/web-access/CHANGELOG.md +5 -0
  10. package/dist/builtin/web-access/package.json +1 -1
  11. package/dist/builtin/workflows/CHANGELOG.md +25 -0
  12. package/dist/builtin/workflows/package.json +1 -1
  13. package/dist/builtin/workflows/skills/create-spec/SKILL.md +169 -125
  14. package/dist/builtin/workflows/skills/impeccable/SKILL.md +89 -80
  15. package/dist/builtin/workflows/skills/impeccable/agents/impeccable_asset_producer.toml +92 -0
  16. package/dist/builtin/workflows/skills/impeccable/agents/impeccable_manual_edit_applier.toml +95 -0
  17. package/dist/builtin/workflows/skills/impeccable/agents/openai.yaml +4 -0
  18. package/dist/builtin/workflows/skills/impeccable/reference/adapt.md +122 -1
  19. package/dist/builtin/workflows/skills/impeccable/reference/animate.md +38 -12
  20. package/dist/builtin/workflows/skills/impeccable/reference/audit.md +5 -5
  21. package/dist/builtin/workflows/skills/impeccable/reference/bolder.md +7 -7
  22. package/dist/builtin/workflows/skills/impeccable/reference/brand.md +4 -14
  23. package/dist/builtin/workflows/skills/impeccable/reference/clarify.md +115 -1
  24. package/dist/builtin/workflows/skills/impeccable/reference/codex.md +3 -3
  25. package/dist/builtin/workflows/skills/impeccable/reference/colorize.md +109 -6
  26. package/dist/builtin/workflows/skills/impeccable/reference/craft.md +7 -7
  27. package/dist/builtin/workflows/skills/impeccable/reference/critique.md +623 -94
  28. package/dist/builtin/workflows/skills/impeccable/reference/delight.md +2 -2
  29. package/dist/builtin/workflows/skills/impeccable/reference/distill.md +2 -2
  30. package/dist/builtin/workflows/skills/impeccable/reference/document.md +16 -14
  31. package/dist/builtin/workflows/skills/impeccable/reference/extract.md +1 -1
  32. package/dist/builtin/workflows/skills/impeccable/reference/harden.md +1 -1
  33. package/dist/builtin/workflows/skills/impeccable/reference/init.md +172 -0
  34. package/dist/builtin/workflows/skills/impeccable/reference/interaction-design.md +0 -6
  35. package/dist/builtin/workflows/skills/impeccable/reference/layout.md +33 -13
  36. package/dist/builtin/workflows/skills/impeccable/reference/live.md +96 -19
  37. package/dist/builtin/workflows/skills/impeccable/reference/onboard.md +1 -1
  38. package/dist/builtin/workflows/skills/impeccable/reference/optimize.md +1 -1
  39. package/dist/builtin/workflows/skills/impeccable/reference/overdrive.md +1 -1
  40. package/dist/builtin/workflows/skills/impeccable/reference/polish.md +3 -4
  41. package/dist/builtin/workflows/skills/impeccable/reference/product.md +1 -3
  42. package/dist/builtin/workflows/skills/impeccable/reference/quieter.md +2 -2
  43. package/dist/builtin/workflows/skills/impeccable/reference/shape.md +5 -5
  44. package/dist/builtin/workflows/skills/impeccable/reference/typeset.md +158 -3
  45. package/dist/builtin/workflows/skills/impeccable/scripts/cleanup-deprecated.mjs +1 -1
  46. package/dist/builtin/workflows/skills/impeccable/scripts/command-metadata.json +2 -2
  47. package/dist/builtin/workflows/skills/impeccable/scripts/context-signals.mjs +225 -0
  48. package/dist/builtin/workflows/skills/impeccable/scripts/context.mjs +266 -0
  49. package/dist/builtin/workflows/skills/impeccable/scripts/critique-storage.mjs +17 -1
  50. package/dist/builtin/workflows/skills/impeccable/scripts/design-parser.mjs +16 -1
  51. package/dist/builtin/workflows/skills/impeccable/scripts/detect.mjs +21 -0
  52. package/dist/builtin/workflows/skills/impeccable/scripts/detector/browser/injected/index.mjs +1725 -0
  53. package/dist/builtin/workflows/skills/impeccable/scripts/detector/cli/main.mjs +244 -0
  54. package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +4543 -0
  55. package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns.mjs +43 -0
  56. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs +252 -0
  57. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +535 -0
  58. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +986 -0
  59. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +208 -0
  60. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/visual/screenshot-contrast.mjs +189 -0
  61. package/dist/builtin/workflows/skills/impeccable/scripts/detector/findings.mjs +12 -0
  62. package/dist/builtin/workflows/skills/impeccable/scripts/detector/node/file-system.mjs +198 -0
  63. package/dist/builtin/workflows/skills/impeccable/scripts/detector/profile/profiler.mjs +166 -0
  64. package/dist/builtin/workflows/skills/impeccable/scripts/detector/registry/antipatterns.mjs +419 -0
  65. package/dist/builtin/workflows/skills/impeccable/scripts/detector/rules/checks.mjs +2316 -0
  66. package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/color.mjs +124 -0
  67. package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/constants.mjs +101 -0
  68. package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/page.mjs +7 -0
  69. package/dist/builtin/workflows/skills/impeccable/scripts/impeccable-paths.mjs +17 -1
  70. package/dist/builtin/workflows/skills/impeccable/scripts/is-generated.mjs +2 -2
  71. package/dist/builtin/workflows/skills/impeccable/scripts/live-accept.mjs +139 -96
  72. package/dist/builtin/workflows/skills/impeccable/scripts/live-browser.js +4491 -526
  73. package/dist/builtin/workflows/skills/impeccable/scripts/live-commit-manual-edits.mjs +1241 -0
  74. package/dist/builtin/workflows/skills/impeccable/scripts/live-copy-edit-agent.mjs +683 -0
  75. package/dist/builtin/workflows/skills/impeccable/scripts/live-discard-manual-edits.mjs +51 -0
  76. package/dist/builtin/workflows/skills/impeccable/scripts/live-event-validation.mjs +136 -0
  77. package/dist/builtin/workflows/skills/impeccable/scripts/live-inject.mjs +22 -9
  78. package/dist/builtin/workflows/skills/impeccable/scripts/live-insert-ui.mjs +458 -0
  79. package/dist/builtin/workflows/skills/impeccable/scripts/live-insert.mjs +232 -0
  80. package/dist/builtin/workflows/skills/impeccable/scripts/live-manual-edit-evidence.mjs +363 -0
  81. package/dist/builtin/workflows/skills/impeccable/scripts/live-manual-edits-buffer.mjs +152 -0
  82. package/dist/builtin/workflows/skills/impeccable/scripts/live-poll.mjs +288 -110
  83. package/dist/builtin/workflows/skills/impeccable/scripts/live-resume.mjs +47 -1
  84. package/dist/builtin/workflows/skills/impeccable/scripts/live-server.mjs +1443 -100
  85. package/dist/builtin/workflows/skills/impeccable/scripts/live-session-store.mjs +17 -0
  86. package/dist/builtin/workflows/skills/impeccable/scripts/live-status.mjs +17 -3
  87. package/dist/builtin/workflows/skills/impeccable/scripts/live-wrap.mjs +216 -6
  88. package/dist/builtin/workflows/skills/impeccable/scripts/live.mjs +2 -3
  89. package/dist/builtin/workflows/skills/impeccable/scripts/palette.mjs +633 -0
  90. package/dist/builtin/workflows/skills/impeccable/scripts/pin.mjs +1 -1
  91. package/dist/builtin/workflows/src/extension/index.ts +67 -3
  92. package/dist/builtin/workflows/src/extension/render-result.ts +26 -1
  93. package/dist/builtin/workflows/src/runs/foreground/executor.ts +227 -3
  94. package/dist/builtin/workflows/src/runs/foreground/stage-runner.ts +94 -7
  95. package/dist/builtin/workflows/src/shared/stage-prompt.ts +326 -0
  96. package/dist/builtin/workflows/src/shared/stage-ui-broker.ts +62 -7
  97. package/dist/builtin/workflows/src/shared/store-types.ts +43 -0
  98. package/dist/builtin/workflows/src/shared/store.ts +37 -0
  99. package/dist/builtin/workflows/src/tui/chat-surface-message.ts +22 -4
  100. package/dist/builtin/workflows/src/tui/graph-view.ts +47 -0
  101. package/dist/builtin/workflows/src/tui/overlay-adapter.ts +43 -1
  102. package/dist/builtin/workflows/src/tui/run-detail.ts +10 -4
  103. package/dist/builtin/workflows/src/tui/stage-chat-view.ts +117 -15
  104. package/dist/builtin/workflows/src/tui/workflow-attach-pane.ts +9 -0
  105. package/dist/core/skills.d.ts.map +1 -1
  106. package/dist/core/skills.js +2 -5
  107. package/dist/core/skills.js.map +1 -1
  108. package/dist/core/system-prompt.d.ts.map +1 -1
  109. package/dist/core/system-prompt.js +11 -29
  110. package/dist/core/system-prompt.js.map +1 -1
  111. package/dist/index.d.ts +1 -0
  112. package/dist/index.d.ts.map +1 -1
  113. package/dist/index.js +3 -0
  114. package/dist/index.js.map +1 -1
  115. package/docs/quickstart.md +1 -2
  116. package/package.json +4 -4
  117. package/dist/builtin/workflows/skills/impeccable/reference/cognitive-load.md +0 -106
  118. package/dist/builtin/workflows/skills/impeccable/reference/color-and-contrast.md +0 -105
  119. package/dist/builtin/workflows/skills/impeccable/reference/heuristics-scoring.md +0 -234
  120. package/dist/builtin/workflows/skills/impeccable/reference/motion-design.md +0 -109
  121. package/dist/builtin/workflows/skills/impeccable/reference/personas.md +0 -179
  122. package/dist/builtin/workflows/skills/impeccable/reference/responsive-design.md +0 -114
  123. package/dist/builtin/workflows/skills/impeccable/reference/spatial-design.md +0 -100
  124. package/dist/builtin/workflows/skills/impeccable/reference/teach.md +0 -156
  125. package/dist/builtin/workflows/skills/impeccable/reference/typography.md +0 -159
  126. package/dist/builtin/workflows/skills/impeccable/reference/ux-writing.md +0 -107
  127. package/dist/builtin/workflows/skills/impeccable/scripts/load-context.mjs +0 -141
@@ -7,6 +7,7 @@ import type {
7
7
  PendingPrompt,
8
8
  PromptKind,
9
9
  RunSnapshot,
10
+ StageInputRequest,
10
11
  StageSnapshot,
11
12
  StageNotice,
12
13
  StoreSnapshot,
@@ -177,6 +178,16 @@ export interface Store {
177
178
  * or restore it to running after the tool resolves.
178
179
  */
179
180
  recordStageAwaitingInput(runId: string, stageId: string, awaiting: boolean, ts?: number): boolean;
181
+ /**
182
+ * Record the serializable descriptor of a brokered structured prompt
183
+ * (`ask_user_question` / readiness gate) awaiting an answer on a stage.
184
+ * Surfaces the questions/options on the snapshot so `workflow send` and
185
+ * status inspection can answer the prompt headlessly. Resolution itself lives
186
+ * in `StageUiBroker`. Returns `true` when the descriptor changed.
187
+ */
188
+ recordStageInputRequest(runId: string, stageId: string, request: StageInputRequest): boolean;
189
+ /** Clear a stage's brokered structured-prompt descriptor. Returns `true` when one was present. */
190
+ clearStageInputRequest(runId: string, stageId: string): boolean;
180
191
  /**
181
192
  * Mark a stage as `paused` and record `pausedAt`. Returns `true` when
182
193
  * the stage transitioned (was not already paused, blocked, or terminal).
@@ -373,6 +384,7 @@ export function createStore(): Store {
373
384
  if (stage.replayedFromStageId !== undefined) existing.replayedFromStageId = stage.replayedFromStageId;
374
385
  if (stage.replayed !== undefined) existing.replayed = stage.replayed;
375
386
  delete existing.awaitingInputSince;
387
+ delete existing.inputRequest;
376
388
  rejectStagePrompt(existing, `pi-workflows: stage ${stage.id} ended before prompt resolved`);
377
389
  _version++;
378
390
  notify();
@@ -697,6 +709,31 @@ export function createStore(): Store {
697
709
  return true;
698
710
  },
699
711
 
712
+ recordStageInputRequest(runId: string, stageId: string, request: StageInputRequest): boolean {
713
+ const run = findRun(runId);
714
+ if (!run) return false;
715
+ if (TERMINAL_STATUSES.has(run.status)) return false;
716
+ const stage = findStage(run, stageId);
717
+ if (!stage) return false;
718
+ if (isTerminalStageStatus(stage.status)) return false;
719
+ if (stage.inputRequest?.id === request.id) return false;
720
+ stage.inputRequest = { ...request };
721
+ _version++;
722
+ notify();
723
+ return true;
724
+ },
725
+
726
+ clearStageInputRequest(runId: string, stageId: string): boolean {
727
+ const run = findRun(runId);
728
+ if (!run) return false;
729
+ const stage = findStage(run, stageId);
730
+ if (!stage || stage.inputRequest === undefined) return false;
731
+ delete stage.inputRequest;
732
+ _version++;
733
+ notify();
734
+ return true;
735
+ },
736
+
700
737
  recordStageBlocked(runId: string, stageId: string, blockedBy: string): boolean {
701
738
  const run = findRun(runId);
702
739
  if (!run) return false;
@@ -60,7 +60,9 @@ export interface DispatchPayload {
60
60
  /**
61
61
  * Status list after `/workflow status`. The snapshot is captured (and
62
62
  * `--all`-filtered) at emit time — scrollback entries don't live-update
63
- * (the orchestrator widget owns live state).
63
+ * (the orchestrator widget owns live state). The wall-clock used for the
64
+ * `elapsed` / `running` labels is likewise frozen once per chat entry; see
65
+ * {@link makeComponent}.
64
66
  */
65
67
  export interface StatusPayload {
66
68
  kind: "status";
@@ -186,12 +188,27 @@ function makeComponent(
186
188
  payload: ChatSurfacePayload,
187
189
  theme: GraphTheme,
188
190
  ): CardComponent {
191
+ // Capture wall-clock ONCE, when the chat entry's component is created. The
192
+ // render() lambda below re-runs on every TUI frame: pi-tui's Container.render
193
+ // fans out to every child on each doRender, and the workflow/subagent live
194
+ // widgets call requestRender() ~12x/sec while runs are active. Without a
195
+ // frozen `now`, renderStatusList / renderRunDetail fall through to Date.now()
196
+ // on each frame, ticking the `elapsed` / `running` labels. Once the entry has
197
+ // scrolled above the viewport fold, that off-screen change pushes pi-tui's
198
+ // doRender() into the full-redraw branch (CSI 2J + CSI H + CSI 3J), which
199
+ // reads as a whole-screen flicker on terminals without synchronized-output
200
+ // support (notably mosh). Freezing here is also semantically right: these are
201
+ // point-in-time scrollback snapshots — the orchestrator widget owns live
202
+ // state. requestRender() never invalidates, so makeComponent runs once per
203
+ // entry; this mirrors the tool-result renderResult slot fix in
204
+ // src/extension/index.ts (capture `now` once, reuse it across renders).
205
+ const capturedNow = Date.now();
189
206
  return {
190
207
  render(width: number): string[] {
191
208
  // pi passes the real chat content width; thread it down to every
192
209
  // primitive so band fillers and card gaps land exactly on the
193
210
  // right-edge cell. No `process.stdout.columns` heuristic needed.
194
- return renderPayload(payload, theme, width).split("\n");
211
+ return renderPayload(payload, theme, width, capturedNow).split("\n");
195
212
  },
196
213
  invalidate() {
197
214
  /* renders are pure of stored state; nothing to drop. */
@@ -203,6 +220,7 @@ function renderPayload(
203
220
  payload: ChatSurfacePayload,
204
221
  theme: GraphTheme,
205
222
  width: number,
223
+ now: number,
206
224
  ): string {
207
225
  switch (payload.kind) {
208
226
  case "dispatch":
@@ -214,11 +232,11 @@ function renderPayload(
214
232
  width,
215
233
  });
216
234
  case "status":
217
- return renderStatusList(payload.runs, { theme, width });
235
+ return renderStatusList(payload.runs, { theme, width, now });
218
236
  case "list":
219
237
  return renderWorkflowList(payload.entries, { theme, width });
220
238
  case "detail":
221
- return renderRunDetail(payload.detail, { theme, width });
239
+ return renderRunDetail(payload.detail, { theme, width, now });
222
240
  case "killed":
223
241
  return renderWorkflowKilledNotice({
224
242
  width,
@@ -191,6 +191,7 @@ export class GraphView implements Component {
191
191
  private graphScrollOffset = 0;
192
192
  private graphScrollColOffset = 0;
193
193
  private pendingEnsureFocusedVisible = true;
194
+ private lastAutoFocusedAwaitingInputKey: string | null = null;
194
195
 
195
196
  private _intervalId: ReturnType<typeof setInterval> | null = null;
196
197
  private _lastGTime: number | null = null;
@@ -278,6 +279,17 @@ export class GraphView implements Component {
278
279
  }
279
280
  }
280
281
 
282
+ const awaitingTarget = this._awaitingInputFocusTarget();
283
+ if (awaitingTarget) {
284
+ if (awaitingTarget.key !== this.lastAutoFocusedAwaitingInputKey) {
285
+ this.focusedIndex = awaitingTarget.index;
286
+ focusNeedsReveal = true;
287
+ this.lastAutoFocusedAwaitingInputKey = awaitingTarget.key;
288
+ }
289
+ } else {
290
+ this.lastAutoFocusedAwaitingInputKey = null;
291
+ }
292
+
281
293
  if (this.cachedLayout.length === 0) {
282
294
  this.focusedIndex = 0;
283
295
  this.graphScrollOffset = 0;
@@ -290,6 +302,41 @@ export class GraphView implements Component {
290
302
  this._syncPromptState(run.pendingPrompt);
291
303
  }
292
304
 
305
+ private _awaitingInputFocusTarget(): { index: number; key: string } | null {
306
+ let newest: { index: number; key: string; createdAt: number } | null = null;
307
+ for (let index = 0; index < this.cachedLayout.length; index++) {
308
+ const stage = this.cachedLayout[index]!.stage;
309
+ const target = this._awaitingInputKey(stage);
310
+ if (!target) continue;
311
+ if (!newest || target.createdAt >= newest.createdAt) {
312
+ newest = { index, key: target.key, createdAt: target.createdAt };
313
+ }
314
+ }
315
+ return newest ? { index: newest.index, key: newest.key } : null;
316
+ }
317
+
318
+ private _awaitingInputKey(stage: StageSnapshot): { key: string; createdAt: number } | null {
319
+ if (stage.pendingPrompt) {
320
+ return {
321
+ key: `prompt:${stage.id}:${stage.pendingPrompt.id}`,
322
+ createdAt: stage.pendingPrompt.createdAt,
323
+ };
324
+ }
325
+ if (stage.inputRequest) {
326
+ return {
327
+ key: `input-request:${stage.id}:${stage.inputRequest.id}`,
328
+ createdAt: stage.inputRequest.createdAt,
329
+ };
330
+ }
331
+ if (stage.status === "awaiting_input") {
332
+ return {
333
+ key: `awaiting:${stage.id}:${stage.awaitingInputSince ?? "active"}`,
334
+ createdAt: stage.awaitingInputSince ?? stage.startedAt ?? 0,
335
+ };
336
+ }
337
+ return null;
338
+ }
339
+
293
340
  private _graphStages(run: RunSnapshot): StageSnapshot[] {
294
341
  const hasStagePrompt = run.stages.some((stage) => stage.pendingPrompt !== undefined);
295
342
  if (!hasStagePrompt) return [...run.stages];
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { Store } from "../shared/store.js";
19
+ import type { StoreSnapshot } from "../shared/store-types.js";
19
20
  import type { ChatMessageRenderOptions, ReadonlyFooterDataProvider } from "@bastani/atomic";
20
21
  import { WorkflowAttachPane } from "./workflow-attach-pane.js";
21
22
  import { deriveGraphThemeFromPiTheme } from "./graph-theme.js";
@@ -176,12 +177,31 @@ export function buildGraphOverlayAdapter(
176
177
  }
177
178
  }
178
179
 
180
+ function snapshotHasAwaitingInput(snapshot: StoreSnapshot): boolean {
181
+ return snapshot.runs.some(
182
+ (run) => run.pendingPrompt !== undefined || run.stages.some(
183
+ (stage) => stage.status === "awaiting_input"
184
+ || stage.pendingPrompt !== undefined
185
+ || stage.inputRequest !== undefined,
186
+ ),
187
+ );
188
+ }
189
+
190
+ function refocusVisibleOverlayForAwaitingInput(snapshot: StoreSnapshot): void {
191
+ if (!snapshotHasAwaitingInput(snapshot)) return;
192
+ if (currentHandle === null) return;
193
+ if (currentHandle.isHidden()) return;
194
+ if (currentHandle.isFocused()) return;
195
+ currentHandle.focus();
196
+ }
197
+
179
198
  function makeComponent(
180
199
  view: WorkflowAttachPane,
181
200
  tui: PiCustomOverlayFactoryTui,
182
201
  ): PiCustomComponent {
183
- const onStoreUpdate = (): void => {
202
+ const onStoreUpdate = (snapshot: StoreSnapshot): void => {
184
203
  view.invalidate();
204
+ refocusVisibleOverlayForAwaitingInput(snapshot);
185
205
  tui.requestRender?.();
186
206
  };
187
207
  const unsubscribe = store.subscribe(onStoreUpdate);
@@ -217,6 +237,13 @@ export function buildGraphOverlayAdapter(
217
237
  if (mounted) {
218
238
  currentView?.retarget(runId, stageId);
219
239
  setMouseScrollTracking(currentView?.wantsMouseScrollTracking() ?? true);
240
+ // Restore keyboard focus to the visible overlay after retargeting.
241
+ // pi-tui dispatches key events only to the focused component, so a
242
+ // mounted-but-visible overlay that is retargeted (e.g. to a stage-scoped
243
+ // HIL prompt / readiness gate) would otherwise appear frozen — arrows,
244
+ // Enter, Ctrl+D and `q` all dead — if focus stayed on an underlying or
245
+ // previously-focused pane (issue #1120).
246
+ currentHandle?.focus();
220
247
  return;
221
248
  }
222
249
 
@@ -275,6 +302,21 @@ export function buildGraphOverlayAdapter(
275
302
  if (currentHandle?.isHidden() === true) return;
276
303
  tui.requestRender?.();
277
304
  },
305
+ // Re-assert overlay keyboard focus on demand. The attached stage chat
306
+ // calls this when it shows a broker custom UI (e.g. the readiness gate)
307
+ // so the gate receives input even if focus drifted off the overlay
308
+ // while the agent's turn was streaming (#1120).
309
+ requestFocus: () => {
310
+ if (currentHandle?.isHidden() === true) return;
311
+ // Idempotent: only grab focus if the overlay does not already own it.
312
+ // A redundant focus() while already focused re-runs pi-tui's focus
313
+ // transition mid-stream and stalls the agent's continuation (#1120,
314
+ // the "ac" freeze). Skipping the no-op case lets callers ask for focus
315
+ // freely — e.g. when showing a mid-turn ask_user_question — without a
316
+ // fragile "only when not streaming" guard at every call site.
317
+ if (currentHandle?.isFocused() === true) return;
318
+ currentHandle?.focus();
319
+ },
278
320
  setMouseScrollTracking,
279
321
  } as ConstructorParameters<typeof WorkflowAttachPane>[0] & {
280
322
  piTui?: PiCustomOverlayFactoryTui;
@@ -202,7 +202,7 @@ function artifactRowsFor(detail: RunDetail): Array<[string, string]> {
202
202
  function stageLinePlain(stage: StageSnapshot, now: number, width: number): string {
203
203
  const icon = statusIcon(stage.status);
204
204
  const dur = stageDurationString(stage, now);
205
- const activity = stageActivityString(stage);
205
+ const activity = stageActivityString(stage, now);
206
206
  const name = truncateToWidth(`${icon} ${stage.name}`, STAGE_NAME_COL + 2, "…");
207
207
  const activityText = activity ? truncateToWidth(activity, 16, "…") : undefined;
208
208
  const parts = [
@@ -222,7 +222,7 @@ function stageLineThemed(stage: StageSnapshot, now: number, theme: GraphTheme, w
222
222
  const dim = hexToAnsi(theme.dim);
223
223
  const stateFg = hexToAnsi(statusColor(stage.status, theme));
224
224
 
225
- const activity = stageActivityString(stage);
225
+ const activity = stageActivityString(stage, now);
226
226
  const dur = stageDurationString(stage, now);
227
227
 
228
228
  const nameText = truncateToWidth(stage.name, STAGE_NAME_COL, "…");
@@ -268,7 +268,13 @@ function stageDurationString(stage: StageSnapshot, now: number): string | undefi
268
268
  return elapsed === undefined ? undefined : fmtDuration(elapsed);
269
269
  }
270
270
 
271
- function stageActivityString(stage: StageSnapshot): string | undefined {
271
+ // `now` is the stable, capture-once clock threaded down from renderRunDetail
272
+ // (opts.now). Using it — not a fresh Date.now() — keeps a running stage's active
273
+ // tool-activity label (e.g. `bash · 6s`) byte-stable across host re-renders so a
274
+ // scrollback run-detail card that has scrolled above the viewport fold does not
275
+ // retrigger pi-tui's full-screen redraw (CSI 2J/H/3J) every render tick. The
276
+ // companion below-editor widget owns the live, ticking view.
277
+ function stageActivityString(stage: StageSnapshot, now: number): string | undefined {
272
278
  if (stage.status !== "running") return undefined;
273
279
  const last = stage.toolEvents.at(-1);
274
280
  if (!last) return undefined;
@@ -276,7 +282,7 @@ function stageActivityString(stage: StageSnapshot): string | undefined {
276
282
  return `${last.name} · ${fmtDuration(last.endedAt - last.startedAt)}`;
277
283
  }
278
284
  if (last.startedAt !== undefined) {
279
- return `${last.name} · ${fmtDuration(Date.now() - last.startedAt)}`;
285
+ return `${last.name} · ${fmtDuration(now - last.startedAt)}`;
280
286
  }
281
287
  return last.name;
282
288
  }
@@ -109,6 +109,12 @@ export interface StageChatViewOpts {
109
109
  onClose: () => void;
110
110
  /** Request a host TUI repaint after SDK events mutate local chat state. */
111
111
  requestRender?: () => void;
112
+ /**
113
+ * Re-assert overlay keyboard focus. Showing a stage custom UI (e.g. the
114
+ * readiness gate) must make the overlay the focused pi-tui component again,
115
+ * otherwise key events keep going elsewhere and the UI looks frozen (#1120).
116
+ */
117
+ requestFocus?: () => void;
112
118
  /** Live pi-tui host objects. When present, stage input uses pi's editor UI. */
113
119
  piTui?: TUI;
114
120
  piTheme?: unknown;
@@ -190,6 +196,8 @@ export class StageChatView implements Component, Focusable {
190
196
  private onDetach: () => void;
191
197
  private onClose: () => void;
192
198
  private requestRender: (() => void) | undefined;
199
+ private requestFocus: (() => void) | undefined;
200
+ private focusHoldTimer: ReturnType<typeof setInterval> | undefined;
193
201
  private getViewportRows?: () => number | undefined;
194
202
  private piTui?: TUI;
195
203
  private piTheme?: unknown;
@@ -198,6 +206,7 @@ export class StageChatView implements Component, Focusable {
198
206
  private chatHost: ChatSessionHost<NoticeEntry>;
199
207
  private stageUiBroker: StageUiBroker;
200
208
  private mountedCustomUi: MountedStageCustomUi | null = null;
209
+ private mountingRequestId: string | null = null;
201
210
  private promptState: PromptCardState | null = null;
202
211
  private promptEditor: EditorComponent | null = null;
203
212
  private promptEditorPromptId: string | null = null;
@@ -227,6 +236,25 @@ export class StageChatView implements Component, Focusable {
227
236
  this.onDetach = opts.onDetach;
228
237
  this.onClose = opts.onClose;
229
238
  this.requestRender = opts.requestRender;
239
+ this.requestFocus = opts.requestFocus;
240
+ // Hold overlay keyboard focus against host focus-steals. pi-tui overlays
241
+ // capture focus on show, but any tui.setFocus() elsewhere (the host editor
242
+ // during background workflow activity) steals it, leaving the gate/composer
243
+ // input-dead. Re-claim it on a short interval — only while NOT streaming, so
244
+ // a mid-turn ask_user_question never refocuses during the agent's
245
+ // continuation (which would stall the stream).
246
+ if (opts.requestFocus) {
247
+ this.focusHoldTimer = setInterval(() => {
248
+ // Hold focus on the overlay whenever there is something to interact
249
+ // with: a mounted custom UI (ask_user_question / readiness gate) must
250
+ // stay answerable even mid-turn, and an idle composer should keep focus.
251
+ // During a pure streaming continuation (no custom UI mounted) we leave
252
+ // focus alone so we never reclaim it out from under the agent's live
253
+ // output. requestFocus is idempotent, so this is a no-op whenever the
254
+ // overlay already owns focus.
255
+ if (this.mountedCustomUi !== null || !this._isStreaming()) this.requestFocus?.();
256
+ }, 150);
257
+ }
230
258
  this.getViewportRows = opts.getViewportRows;
231
259
  this.piTui = opts.piTui;
232
260
  this.piTheme = opts.piTheme;
@@ -367,7 +395,15 @@ export class StageChatView implements Component, Focusable {
367
395
  private async _showCustomUi(request: StageCustomUiRequest): Promise<void> {
368
396
  this.mountedCustomUi?.component.dispose?.();
369
397
  this.mountedCustomUi = null;
398
+ // Track the request currently being mounted. `mountStageCustomUi` is async,
399
+ // so the broker can resolve/reject/abort the request (clearing it via
400
+ // `_hideMountedCustomUi`) before we finish awaiting. Without this guard the
401
+ // post-await assignment below would strand a settled gate as a permanent
402
+ // `mountedCustomUi`, hiding the transcript and crashing on the next
403
+ // keystroke routed into the dead component (readiness gate #1099).
404
+ this.mountingRequestId = request.id;
370
405
  if (!this.piTui || this.piTheme === undefined || this.piKeybindings === undefined) {
406
+ this.mountingRequestId = null;
371
407
  this.stageUiBroker.reject(
372
408
  request,
373
409
  new Error("pi-workflows: stage custom UI cannot mount without attached TUI host"),
@@ -375,7 +411,7 @@ export class StageChatView implements Component, Focusable {
375
411
  return;
376
412
  }
377
413
  try {
378
- this.mountedCustomUi = await mountStageCustomUi(
414
+ const mounted = await mountStageCustomUi(
379
415
  request,
380
416
  this.piTui,
381
417
  this.piTheme,
@@ -390,8 +426,25 @@ export class StageChatView implements Component, Focusable {
390
426
  this.requestRender?.();
391
427
  },
392
428
  );
429
+ // Settled or superseded while mounting: drop the freshly-built component
430
+ // instead of showing a gate the broker has already torn down.
431
+ if (this.mountingRequestId !== request.id) {
432
+ mounted.component.dispose?.();
433
+ return;
434
+ }
435
+ this.mountingRequestId = null;
436
+ this.mountedCustomUi = mounted;
437
+ // A freshly-shown custom UI (ask_user_question / readiness gate) must own
438
+ // keyboard focus to be answerable — including a question mounted mid-turn
439
+ // while the agent is "streaming" (it is blocked on this very question, and
440
+ // host focus may have drifted off the overlay during the turn, e.g. after a
441
+ // stay-loop composer submit). requestFocus is idempotent (a no-op when the
442
+ // overlay already owns focus), so this never re-runs a redundant focus
443
+ // transition that would stall the stream (#1120).
444
+ this.requestFocus?.();
393
445
  this.requestRender?.();
394
446
  } catch (error) {
447
+ if (this.mountingRequestId === request.id) this.mountingRequestId = null;
395
448
  this.stageUiBroker.reject(request, error);
396
449
  }
397
450
  }
@@ -579,6 +632,13 @@ export class StageChatView implements Component, Focusable {
579
632
  this._syncPromptState(stage?.pendingPrompt);
580
633
  const promptActive = !customUiActive && this.promptState !== null;
581
634
  const readOnlyArchive = this._isReadOnlyArchive(stage);
635
+
636
+ // ask_user_question / readiness-gate custom UI renders as a bottom panel
637
+ // (in the high-priority composer slot) so the live transcript stays visible
638
+ // and scrollable above it — matching the standalone ask_user_question tool.
639
+ // Structured prompt nodes and read-only archives keep their full-body
640
+ // treatment below.
641
+ const customUiLines = customUiActive ? this._renderCustomUi(w) : [];
582
642
  const chatChromeHidden = customUiActive || promptActive || readOnlyArchive;
583
643
  const pendingLines = chatChromeHidden ? [] : this.chatHost.renderPendingMessages(w);
584
644
  const workingLines = chatChromeHidden ? [] : this.chatHost.renderWorkingStatus(w);
@@ -594,27 +654,32 @@ export class StageChatView implements Component, Focusable {
594
654
  pendingRows: pendingLines.length,
595
655
  workingRows: workingLines.length,
596
656
  usageRows: usageLines.length,
597
- editorRows: editorLines.length,
657
+ // The custom UI question takes the reserved bottom (composer) slot so the
658
+ // transcript above keeps as much room as possible and the question never
659
+ // clips below the overlay boundary.
660
+ editorRows: customUiActive ? customUiLines.length : editorLines.length,
598
661
  footerRows: footerLines.length,
599
662
  });
600
663
  const visiblePendingLines = pendingLines.slice(0, plan.pendingRows);
601
664
  const visibleWorkingLines = workingLines.slice(0, plan.workingRows);
602
665
  const visibleUsageLines = usageLines.slice(0, plan.usageRows);
603
- const visibleEditorLines = editorLines.slice(0, plan.editorRows);
666
+ const visibleEditorLines = customUiActive
667
+ ? customUiLines.slice(0, plan.editorRows)
668
+ : editorLines.slice(0, plan.editorRows);
604
669
  const visibleFooterLines = footerLines.slice(0, plan.footerRows);
605
670
  const bodyBudget = plan.bodyRows;
606
671
  if (blocked) this.chatHost.scrollToBottom();
607
672
 
608
673
  let bodyLines: string[];
609
- if (customUiActive) {
610
- bodyLines = this._renderCustomUiBody(w, bodyBudget);
611
- } else if (promptActive) {
674
+ if (promptActive) {
612
675
  bodyLines = this._renderPromptBody(w, bodyBudget);
613
676
  } else if (blocked) {
614
677
  bodyLines = this._renderBlockedBody(w, bodyBudget, stage);
615
678
  } else if (readOnlyArchive) {
616
679
  bodyLines = this._renderReadOnlyArchiveBody(w, bodyBudget, stage);
617
680
  } else {
681
+ // Live transcript. When a custom UI question is active it renders in the
682
+ // composer slot above; the transcript here stays visible and scrollable.
618
683
  bodyLines = this.chatHost.renderBody(w, bodyBudget);
619
684
  }
620
685
 
@@ -844,11 +909,13 @@ export class StageChatView implements Component, Focusable {
844
909
  return lines;
845
910
  }
846
911
 
847
- private _renderCustomUiBody(width: number, budget: number): string[] {
912
+ // Natural-height render of the mounted custom UI (no body padding): it is
913
+ // placed in the composer slot so the transcript stays scrollable above it.
914
+ private _renderCustomUi(width: number): string[] {
848
915
  const component = this.mountedCustomUi?.component;
849
- if (component) setComponentFocused(component, this.focused);
850
- const lines = component ? component.render(width) : [];
851
- return this._fitBodyLines(lines, width, budget);
916
+ if (!component) return [];
917
+ setComponentFocused(component, this.focused);
918
+ return component.render(width);
852
919
  }
853
920
 
854
921
  private _renderPromptBody(width: number, budget: number): string[] {
@@ -1041,16 +1108,30 @@ export class StageChatView implements Component, Focusable {
1041
1108
  handleInput(data: string): boolean {
1042
1109
  if (this.mountedCustomUi) {
1043
1110
  if (matchesKey(data, Key.ctrl("d"))) {
1044
- this._rejectMountedCustomUi("stage custom UI detached");
1111
+ // Detach stops *viewing* the stage; it does not cancel a pending
1112
+ // human-input request. Release the local display only — the request
1113
+ // stays pending (the stage remains awaiting_input) and is re-displayed
1114
+ // when the user re-attaches.
1115
+ this._releaseMountedCustomUi();
1045
1116
  if (this._isPaused()) this.onClose();
1046
1117
  else this.onDetach();
1047
1118
  return true;
1048
1119
  }
1049
1120
  if (matchesKey(data, Key.ctrl("c"))) {
1050
- this._rejectMountedCustomUi("stage custom UI closed");
1121
+ // Close hides the overlay; the background run — and its pending
1122
+ // human-input request — keep living. Release the local display only.
1123
+ this._releaseMountedCustomUi();
1051
1124
  this.onClose();
1052
1125
  return true;
1053
1126
  }
1127
+ // Let scroll input (mouse wheel / pageUp / pageDown / home / end) reach
1128
+ // the transcript so history stays scrollable while the question is shown,
1129
+ // matching the standalone ask_user_question tool. Navigation keys
1130
+ // (arrows / enter / typing) fall through to the question component.
1131
+ if (this.chatHost.handleScrollInput(data)) {
1132
+ this.requestRender?.();
1133
+ return true;
1134
+ }
1054
1135
  setComponentFocused(this.mountedCustomUi.component, this.focused);
1055
1136
  this.mountedCustomUi.component.handleInput?.(data);
1056
1137
  this.requestRender?.();
@@ -1165,11 +1246,15 @@ export class StageChatView implements Component, Focusable {
1165
1246
  }
1166
1247
 
1167
1248
  dispose(): void {
1249
+ if (this.focusHoldTimer !== undefined) {
1250
+ clearInterval(this.focusHoldTimer);
1251
+ this.focusHoldTimer = undefined;
1252
+ }
1168
1253
  this._unsubscribeStore?.();
1169
1254
  this._unsubscribeStore = null;
1170
1255
  this._unsubscribeHandle?.();
1171
1256
  this._unsubscribeHandle = null;
1172
- this._rejectMountedCustomUi("stage chat view disposed");
1257
+ this._releaseMountedCustomUi();
1173
1258
  this._disposePromptEditor();
1174
1259
  this._unregisterStageUiHost?.();
1175
1260
  this._unregisterStageUiHost = null;
@@ -1177,20 +1262,37 @@ export class StageChatView implements Component, Focusable {
1177
1262
  }
1178
1263
 
1179
1264
  private _hideMountedCustomUi(request: StageCustomUiRequest): void {
1265
+ // Signal any in-flight `_showCustomUi` mount for this request to drop its
1266
+ // component when it finishes — the broker is already tearing it down.
1267
+ if (this.mountingRequestId === request.id) this.mountingRequestId = null;
1180
1268
  const mounted = this.mountedCustomUi;
1181
1269
  if (!mounted || mounted.request.id !== request.id) return;
1182
1270
  this.mountedCustomUi = null;
1183
1271
  mounted.component.dispose?.();
1184
1272
  this.chatHost.focused = this.focused;
1185
1273
  this.chatHost.scrollToBottom();
1274
+ // Returning to the composer after a custom UI resolves (e.g. the readiness
1275
+ // gate -> "stay") must re-assert overlay focus so the composer accepts
1276
+ // input. Guarded for streaming so an answered mid-turn ask_user_question
1277
+ // does not refocus during the agent's continuation (would stall it).
1278
+ if (!this._isStreaming()) this.requestFocus?.();
1186
1279
  this.requestRender?.();
1187
1280
  }
1188
1281
 
1189
- private _rejectMountedCustomUi(message: string): void {
1282
+ /**
1283
+ * Stop displaying the mounted stage custom UI locally, WITHOUT settling its
1284
+ * broker request. Detaching / closing / disposing the attached chat stops
1285
+ * viewing the stage; it never cancels a pending human-input request. The
1286
+ * request stays pending (the stage remains awaiting_input) so re-attaching
1287
+ * re-displays it. The request is settled only by the user answering (broker
1288
+ * resolve) or the run aborting (its AbortSignal -> broker reject) — those are
1289
+ * the single chokepoints for ending a human-input request.
1290
+ */
1291
+ private _releaseMountedCustomUi(): void {
1292
+ this.mountingRequestId = null;
1190
1293
  const mounted = this.mountedCustomUi;
1191
1294
  if (!mounted) return;
1192
1295
  this.mountedCustomUi = null;
1193
- this.stageUiBroker.reject(mounted.request, new Error(`pi-workflows: ${message}`));
1194
1296
  mounted.component.dispose?.();
1195
1297
  }
1196
1298
 
@@ -100,6 +100,12 @@ export interface WorkflowAttachPaneOpts {
100
100
  * visibility so a hidden pane stays cheap.
101
101
  */
102
102
  requestRender?: () => void;
103
+ /**
104
+ * Host hook to re-assert overlay keyboard focus. Threaded into the attached
105
+ * stage chat so showing a broker custom UI (e.g. the readiness gate) refocuses
106
+ * the overlay and the UI is not left input-dead (#1120).
107
+ */
108
+ requestFocus?: () => void;
103
109
  /**
104
110
  * Host hook for terminal mouse reporting. Graph mode uses wheel input
105
111
  * for canvas scrolling; stage-chat mode uses it for transcript history
@@ -125,6 +131,7 @@ export class WorkflowAttachPane implements Component {
125
131
  private onPromptResolve?: (runId: string, promptId: string, response: unknown) => void;
126
132
  private getViewportRows?: () => number | undefined;
127
133
  private hostRequestRender?: () => void;
134
+ private hostRequestFocus?: () => void;
128
135
  private setMouseScrollTracking?: (enabled: boolean) => void;
129
136
  private piTui?: TUI;
130
137
  private piTheme?: unknown;
@@ -152,6 +159,7 @@ export class WorkflowAttachPane implements Component {
152
159
  this.onPromptResolve = opts.onPromptResolve;
153
160
  this.getViewportRows = opts.getViewportRows;
154
161
  this.hostRequestRender = opts.requestRender;
162
+ this.hostRequestFocus = opts.requestFocus;
155
163
  this.setMouseScrollTracking = opts.setMouseScrollTracking;
156
164
  this.piTui = opts.piTui;
157
165
  this.piTheme = opts.piTheme;
@@ -233,6 +241,7 @@ export class WorkflowAttachPane implements Component {
233
241
  onDetach: () => this._detachFromStage(),
234
242
  onClose: this.onClose,
235
243
  requestRender: this.hostRequestRender,
244
+ requestFocus: this.hostRequestFocus,
236
245
  piTui: this.piTui,
237
246
  piTheme: this.piTheme,
238
247
  piKeybindings: this.piKeybindings,