@underactive/pi-topping-moa-fusion 0.1.0 → 0.2.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/CHANGELOG.md +34 -0
- package/README.md +31 -8
- package/agents/moa-synthesizer.md +9 -6
- package/package.json +5 -5
- package/src/config/planName.ts +29 -24
- package/src/config/settings.ts +38 -1
- package/src/debate/debateFanout.ts +14 -4
- package/src/debate/runDebate.ts +13 -4
- package/src/index.ts +1 -1
- package/src/moa/conflictContract.ts +2 -1
- package/src/moa/contextContract.ts +1 -1
- package/src/moa/fanout.ts +115 -4
- package/src/moa/fanoutWiring.ts +11 -1
- package/src/moa/implementationRetry.ts +2 -2
- package/src/moa/orchestration.ts +3 -0
- package/src/moa/panelDependence.ts +87 -0
- package/src/moa/reviewLoop.ts +2 -2
- package/src/moa/synthesis.ts +12 -8
- package/src/moa/verification.ts +114 -17
- package/src/moa/verificationCriteria.ts +1 -1
- package/src/opinion/opinionFanout.ts +5 -4
- package/src/opinion/runOpinion.ts +14 -13
- package/src/planning/modeState.ts +17 -0
- package/src/planning/planFile.ts +2 -1
- package/src/planning/planMode.ts +46 -5
- package/src/planning/tools/exitPlanMode.ts +7 -2
- package/src/planning/tools/mfPlanSubagent.ts +3 -0
- package/src/runtime/activityTracking.ts +28 -12
- package/src/runtime/processPool.ts +2 -0
- package/src/runtime/runner.ts +45 -9
- package/src/runtime/wire.ts +11 -0
- package/src/ui/cancelOverlay.ts +1 -1
- package/src/ui/moaModelPicker.ts +11 -1
- package/src/ui/moaProgressWidget.ts +59 -8
- package/src/ui/moaSetupOverlay.ts +42 -4
- package/src/ui/workingOverlay.ts +265 -0
|
@@ -297,12 +297,20 @@ export class MoaProgressWidget implements MoaProgressView {
|
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
/**
|
|
300
|
-
* Start the fan-out phase
|
|
300
|
+
* Start the fan-out phase, marking only the slots that fit the process-pool
|
|
301
|
+
* limit as working. The runner promotes queued slots when they acquire one.
|
|
301
302
|
* `thinking[i]` tints proposer `i`'s meter; an absent entry falls to accent.
|
|
302
303
|
*/
|
|
303
|
-
startFanout(refs: ModelRef[], thinking: (ThinkingLevel | undefined)[] = []): void {
|
|
304
|
+
startFanout(refs: ModelRef[], thinking: (ThinkingLevel | undefined)[] = [], maxConcurrency = refs.length): void {
|
|
304
305
|
const startedAt = Date.now();
|
|
305
|
-
|
|
306
|
+
const activeCount = Number.isFinite(maxConcurrency) ? Math.max(1, Math.floor(maxConcurrency)) : refs.length;
|
|
307
|
+
this.statuses = refs.map((ref, index) => ({
|
|
308
|
+
ref,
|
|
309
|
+
phase: "Plan" as const,
|
|
310
|
+
state: index < activeCount ? "working" as const : "queued" as const,
|
|
311
|
+
startedAt: index < activeCount ? startedAt : undefined,
|
|
312
|
+
thinking: thinking[index],
|
|
313
|
+
}));
|
|
306
314
|
this.active = "Plan";
|
|
307
315
|
this.mountTable();
|
|
308
316
|
}
|
|
@@ -341,14 +349,17 @@ export class MoaProgressWidget implements MoaProgressView {
|
|
|
341
349
|
const wasSettled = s.state === "done" || s.state === "error" || s.state === "cancelled";
|
|
342
350
|
s.state = state;
|
|
343
351
|
s.detail = detail;
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
if (state === "working" && wasSettled) {
|
|
352
|
+
// A settled proposer entering a queued retry/round must clear its preview
|
|
353
|
+
// before it can be promoted to working, so it never shows stale output.
|
|
354
|
+
if ((state === "working" || state === "queued") && wasSettled) {
|
|
347
355
|
s.transcript = undefined;
|
|
348
356
|
s.transcriptRevision = (s.transcriptRevision ?? 0) + 1;
|
|
349
357
|
}
|
|
350
358
|
if (!isActive(state)) s.endedAt ??= Date.now();
|
|
351
|
-
else
|
|
359
|
+
else {
|
|
360
|
+
s.startedAt ??= Date.now();
|
|
361
|
+
s.endedAt = undefined;
|
|
362
|
+
}
|
|
352
363
|
}
|
|
353
364
|
|
|
354
365
|
/** Update one proposer's live context, turn, tool-call, and cost readings (by index). */
|
|
@@ -386,6 +397,46 @@ export class MoaProgressWidget implements MoaProgressView {
|
|
|
386
397
|
s.transcriptRevision = (s.transcriptRevision ?? 0) + 1;
|
|
387
398
|
}
|
|
388
399
|
|
|
400
|
+
/**
|
|
401
|
+
* Point a settled proposer row at a replacement model and clear every reading
|
|
402
|
+
* from the abandoned attempt, so the row reads as freshly queued. The elapsed
|
|
403
|
+
* clock is left unstamped: `update(index, "working")` starts it when the slot
|
|
404
|
+
* actually runs. Remounts the table, which the replacement prompt stopped —
|
|
405
|
+
* `update()` alone clears only the transcript, leaving stale turns/tools/cost/
|
|
406
|
+
* context behind, and `stopWidget()` (needed so the select/picker are not
|
|
407
|
+
* fighting the sticky widget) unmounts it, so only `mountTable()` remounts.
|
|
408
|
+
*/
|
|
409
|
+
replaceProposerModel(index: number, ref: ModelRef, thinking?: ThinkingLevel): void {
|
|
410
|
+
const s = this.statuses[index];
|
|
411
|
+
if (!s) return;
|
|
412
|
+
s.ref = ref;
|
|
413
|
+
s.phase = "Plan";
|
|
414
|
+
s.thinking = thinking;
|
|
415
|
+
s.state = "queued";
|
|
416
|
+
s.detail = undefined;
|
|
417
|
+
// Clear every reading from the abandoned attempt, matching the wasSettled
|
|
418
|
+
// branch of update(): the row must read as freshly queued, and the
|
|
419
|
+
// transcriptRevision bump invalidates the preview cache.
|
|
420
|
+
s.contextTokens = undefined;
|
|
421
|
+
s.activity = undefined;
|
|
422
|
+
s.activityHistory = [];
|
|
423
|
+
s.outputTokens = undefined;
|
|
424
|
+
s.outputRevision = undefined;
|
|
425
|
+
s.turns = undefined;
|
|
426
|
+
s.toolCalls = undefined;
|
|
427
|
+
s.costUsd = undefined;
|
|
428
|
+
s.transcript = undefined;
|
|
429
|
+
s.startedAt = undefined;
|
|
430
|
+
s.endedAt = undefined;
|
|
431
|
+
s.transcriptRevision = (s.transcriptRevision ?? 0) + 1;
|
|
432
|
+
this.mountTable();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Re-show the table after a prompt stopped it, without changing any row. Idempotent. */
|
|
436
|
+
resumeTable(): void {
|
|
437
|
+
this.mountTable();
|
|
438
|
+
}
|
|
439
|
+
|
|
389
440
|
/**
|
|
390
441
|
* Move a role row into `working`, starting (or keeping) its elapsed clock.
|
|
391
442
|
* Each activation replaces the row's thinking level (with `undefined` when the
|
|
@@ -539,7 +590,7 @@ export class MoaProgressWidget implements MoaProgressView {
|
|
|
539
590
|
private toRow(s: ProposerStatus, phase: MoaPhase, workingText: string, now: number, firstOfPhase: boolean): ProgressRow {
|
|
540
591
|
const statusText =
|
|
541
592
|
s.state === "working" ? (s.detail ?? workingText)
|
|
542
|
-
: s.state === "cancelling" ? "cancelling
|
|
593
|
+
: s.state === "cancelling" ? "cancelling"
|
|
543
594
|
: s.state === "cancelled" ? "cancelled"
|
|
544
595
|
: s.state === "error" ? s.detail ?? "error"
|
|
545
596
|
: s.state === "queued" ? "queued"
|
|
@@ -17,6 +17,10 @@ import { type Component, type TUI } from "@earendil-works/pi-tui";
|
|
|
17
17
|
import {
|
|
18
18
|
defaultThinkingForModel,
|
|
19
19
|
loadMoaConfig,
|
|
20
|
+
MAX_CONCURRENT_AGENTS,
|
|
21
|
+
MAX_VERIFICATION_REPAIRS_LIMIT,
|
|
22
|
+
MIN_CONCURRENT_AGENTS,
|
|
23
|
+
MIN_VERIFICATION_REPAIRS,
|
|
20
24
|
saveMoaConfig,
|
|
21
25
|
type MoaConfig,
|
|
22
26
|
} from "../config/settings.ts";
|
|
@@ -49,8 +53,6 @@ interface SetupSlot {
|
|
|
49
53
|
description: string;
|
|
50
54
|
/** Whether the overview row appends the selected thinking level. */
|
|
51
55
|
showThinkingValue: boolean;
|
|
52
|
-
/** Where a confirmed selection is persisted. */
|
|
53
|
-
target: { kind: "agent"; agentName: string } | { kind: "role" };
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
const SLOTS: readonly SetupSlot[] = [
|
|
@@ -59,14 +61,12 @@ const SLOTS: readonly SetupSlot[] = [
|
|
|
59
61
|
title: agent.title,
|
|
60
62
|
description: agent.description,
|
|
61
63
|
showThinkingValue: true,
|
|
62
|
-
target: { kind: "agent", agentName: agent.name },
|
|
63
64
|
})),
|
|
64
65
|
{
|
|
65
66
|
menuLabel: "cheap/fast agent",
|
|
66
67
|
title: "Cheap / fast agent — plan-file naming",
|
|
67
68
|
description: "Only summarizes a plan prompt into a short file name. Always called with thinking off and falls back safely if it cannot respond, so pick the cheapest model available.",
|
|
68
69
|
showThinkingValue: false,
|
|
69
|
-
target: { kind: "role" },
|
|
70
70
|
},
|
|
71
71
|
];
|
|
72
72
|
|
|
@@ -77,6 +77,8 @@ interface SetupDraft {
|
|
|
77
77
|
thinkingOverrides: Record<string, ThinkingLevel>;
|
|
78
78
|
autoResolveConflicts: boolean;
|
|
79
79
|
useSummaryName: boolean;
|
|
80
|
+
maxConcurrentAgents: number;
|
|
81
|
+
maxVerificationRepairs: number;
|
|
80
82
|
rosters: PlanRoster[];
|
|
81
83
|
}
|
|
82
84
|
|
|
@@ -90,6 +92,8 @@ class MoaSetupComponent implements Component {
|
|
|
90
92
|
private readonly menu: MenuComponent;
|
|
91
93
|
private autoResolveConflicts: boolean;
|
|
92
94
|
private useSummaryName: boolean;
|
|
95
|
+
private maxConcurrentAgents: number;
|
|
96
|
+
private maxVerificationRepairs: number;
|
|
93
97
|
|
|
94
98
|
constructor(
|
|
95
99
|
private readonly tui: TUI,
|
|
@@ -105,6 +109,8 @@ class MoaSetupComponent implements Component {
|
|
|
105
109
|
this.twoPane = new TwoPaneModelThinking(tui, theme, availableRefs, config, currentThinking, ctx);
|
|
106
110
|
this.autoResolveConflicts = draft.autoResolveConflicts;
|
|
107
111
|
this.useSummaryName = draft.useSummaryName;
|
|
112
|
+
this.maxConcurrentAgents = draft.maxConcurrentAgents;
|
|
113
|
+
this.maxVerificationRepairs = draft.maxVerificationRepairs;
|
|
108
114
|
const slotItem = (slotIndex: number): MenuItem => ({
|
|
109
115
|
id: `role-${slotIndex}`,
|
|
110
116
|
label: SLOTS[slotIndex]!.menuLabel,
|
|
@@ -130,6 +136,28 @@ class MoaSetupComponent implements Component {
|
|
|
130
136
|
{ title: "options", items: [
|
|
131
137
|
{ id: "auto-resolve", label: "auto resolve conflicts", value: this.autoResolveConflicts, onChange: (value: boolean) => { this.autoResolveConflicts = value; } },
|
|
132
138
|
{ id: "use-summary-name", label: "summarize plan names", value: this.useSummaryName, onChange: (value: boolean) => { this.useSummaryName = value; } },
|
|
139
|
+
{
|
|
140
|
+
id: "max-concurrent-agents",
|
|
141
|
+
label: "max concurrent agents",
|
|
142
|
+
values: Array.from(
|
|
143
|
+
{ length: MAX_CONCURRENT_AGENTS - MIN_CONCURRENT_AGENTS + 1 },
|
|
144
|
+
(_unused, index) => String(MIN_CONCURRENT_AGENTS + index),
|
|
145
|
+
),
|
|
146
|
+
valueIndex: this.maxConcurrentAgents - MIN_CONCURRENT_AGENTS,
|
|
147
|
+
description: "Child agents running at once in MoA, opinion, debate and plan subagent fan-outs. Keep at 1 for local agents.",
|
|
148
|
+
onChange: (valueIndex: number) => { this.maxConcurrentAgents = MIN_CONCURRENT_AGENTS + valueIndex; },
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: "max-verification-repairs",
|
|
152
|
+
label: "max verification repairs",
|
|
153
|
+
values: Array.from(
|
|
154
|
+
{ length: MAX_VERIFICATION_REPAIRS_LIMIT - MIN_VERIFICATION_REPAIRS + 1 },
|
|
155
|
+
(_unused, index) => String(MIN_VERIFICATION_REPAIRS + index),
|
|
156
|
+
),
|
|
157
|
+
valueIndex: this.maxVerificationRepairs - MIN_VERIFICATION_REPAIRS,
|
|
158
|
+
description: "Verifier-driven repair rounds after implementation (0 disables automatic repairs).",
|
|
159
|
+
onChange: (valueIndex: number) => { this.maxVerificationRepairs = MIN_VERIFICATION_REPAIRS + valueIndex; },
|
|
160
|
+
},
|
|
133
161
|
] },
|
|
134
162
|
],
|
|
135
163
|
buttons: [
|
|
@@ -168,6 +196,8 @@ class MoaSetupComponent implements Component {
|
|
|
168
196
|
thinkingOverrides: { ...this.draft.thinkingOverrides },
|
|
169
197
|
autoResolveConflicts: this.autoResolveConflicts,
|
|
170
198
|
useSummaryName: this.useSummaryName,
|
|
199
|
+
maxConcurrentAgents: this.maxConcurrentAgents,
|
|
200
|
+
maxVerificationRepairs: this.maxVerificationRepairs,
|
|
171
201
|
rosters: [...this.draft.rosters],
|
|
172
202
|
};
|
|
173
203
|
}
|
|
@@ -299,6 +329,8 @@ export async function showMoaSetup(
|
|
|
299
329
|
thinkingOverrides: {},
|
|
300
330
|
autoResolveConflicts: saved.autoResolveConflicts,
|
|
301
331
|
useSummaryName: saved.useSummaryName,
|
|
332
|
+
maxConcurrentAgents: saved.maxConcurrentAgents,
|
|
333
|
+
maxVerificationRepairs: saved.maxVerificationRepairs,
|
|
302
334
|
rosters: [...saved.rosters],
|
|
303
335
|
};
|
|
304
336
|
|
|
@@ -325,6 +357,10 @@ export async function showMoaSetup(
|
|
|
325
357
|
);
|
|
326
358
|
if (result === undefined) return false;
|
|
327
359
|
if (result.action === "rosters") {
|
|
360
|
+
draft.autoResolveConflicts = result.draft.autoResolveConflicts;
|
|
361
|
+
draft.useSummaryName = result.draft.useSummaryName;
|
|
362
|
+
draft.maxConcurrentAgents = result.draft.maxConcurrentAgents;
|
|
363
|
+
draft.maxVerificationRepairs = result.draft.maxVerificationRepairs;
|
|
328
364
|
draft.rosters = await showRosterManager(ctx, draft.rosters, { currentThinking });
|
|
329
365
|
continue;
|
|
330
366
|
}
|
|
@@ -360,6 +396,8 @@ export async function showMoaSetup(
|
|
|
360
396
|
agentDefaultsConfigured: true,
|
|
361
397
|
autoResolveConflicts: finalDraft.autoResolveConflicts,
|
|
362
398
|
useSummaryName: finalDraft.useSummaryName,
|
|
399
|
+
maxConcurrentAgents: finalDraft.maxConcurrentAgents,
|
|
400
|
+
maxVerificationRepairs: finalDraft.maxVerificationRepairs,
|
|
363
401
|
cheap: finalDraft.selections[AGENT_SLOT_COUNT],
|
|
364
402
|
rosters: finalDraft.rosters,
|
|
365
403
|
thinkingOverrides: { ...current.thinkingOverrides, ...finalDraft.thinkingOverrides },
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working overlay — a small "please wait" card for blocking, non-agent work
|
|
3
|
+
* that has nowhere else to draw while it runs (e.g. summarizing a plan
|
|
4
|
+
* prompt into a name between the prompt editor closing and the next picker
|
|
5
|
+
* mounting). This is not the cancel-run surface: there is no agent table
|
|
6
|
+
* and no subagents to cancel. Esc abandons the wait rather than the flow
|
|
7
|
+
* that is waiting on it — the calling flow owns the overlay's lifetime
|
|
8
|
+
* through a session bridge, mirroring `CancelSession.closeOverlay`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { isKeyRelease, Key, matchesKey, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
13
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
14
|
+
import { formatElapsed } from "./agentStatus.ts";
|
|
15
|
+
import { BRAILLE_SPINNER_FRAMES, ROUNDED_SINGLE_BOX, UI_TICK_MS, createFrame } from "./chrome.ts";
|
|
16
|
+
import { shimmerString, type ShimmerTheme } from "./shimmer.ts";
|
|
17
|
+
|
|
18
|
+
const OVERLAY_MARGIN = 1;
|
|
19
|
+
/** Grace before mounting: work that settles inside this never flashes a card. */
|
|
20
|
+
const SHOW_DELAY_MS = 200;
|
|
21
|
+
/** Once shown, stay up at least this long so the card is readable, not a blink. */
|
|
22
|
+
const MIN_VISIBLE_MS = 500;
|
|
23
|
+
|
|
24
|
+
export interface WorkingOverlayOptions<T> {
|
|
25
|
+
/** Accent title row, e.g. "Naming the plan". */
|
|
26
|
+
title: string;
|
|
27
|
+
/** Dim, shimmering body line naming the work. */
|
|
28
|
+
detail: string;
|
|
29
|
+
/** Footer hint. Defaults to a plain wait line when `skip` is omitted. */
|
|
30
|
+
hint?: string;
|
|
31
|
+
/** Value to resolve with when the user presses esc. Omit to make the wait un-skippable. */
|
|
32
|
+
skip?: () => T;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Bridge an outside owner uses to close an overlay it does not render. Mirrors `CancelSession.closeOverlay`. */
|
|
36
|
+
export interface WorkingOverlaySession {
|
|
37
|
+
closeOverlay?: () => void;
|
|
38
|
+
/** Set when close was requested before the component mounted. */
|
|
39
|
+
closeRequested?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Ask a (possibly not-yet-mounted) overlay to close. Safe to call before `showWorkingOverlay`'s factory has run. */
|
|
43
|
+
export function requestWorkingOverlayClose(session: WorkingOverlaySession): void {
|
|
44
|
+
session.closeRequested = true;
|
|
45
|
+
session.closeOverlay?.();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface WorkingOverlayRenderOptions {
|
|
49
|
+
title: string;
|
|
50
|
+
detail: string;
|
|
51
|
+
hint?: string;
|
|
52
|
+
onSkip?: () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Show the working overlay. Resolves when closed. Outside TUI mode there is no overlay surface. */
|
|
56
|
+
export async function showWorkingOverlay(
|
|
57
|
+
ctx: ExtensionContext,
|
|
58
|
+
options: WorkingOverlayRenderOptions,
|
|
59
|
+
session: WorkingOverlaySession,
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
if (ctx.mode !== "tui") return;
|
|
62
|
+
await ctx.ui.custom<void>(
|
|
63
|
+
(tui, theme, _keybindings, done) => new WorkingOverlayComponent(tui, theme, options, session, done),
|
|
64
|
+
{
|
|
65
|
+
overlay: true,
|
|
66
|
+
overlayOptions: {
|
|
67
|
+
anchor: "center",
|
|
68
|
+
width: "50%",
|
|
69
|
+
minWidth: 40,
|
|
70
|
+
margin: OVERLAY_MARGIN,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type Settled<T> = { ok: true; value: T } | { ok: false; error: unknown };
|
|
77
|
+
|
|
78
|
+
function unwrap<T>(settled: Settled<T>): T {
|
|
79
|
+
if (settled.ok) return settled.value;
|
|
80
|
+
throw settled.error;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Run `task` behind the working overlay. The overlay only mounts if `task`
|
|
85
|
+
* is still pending after `SHOW_DELAY_MS`, and once mounted stays up at
|
|
86
|
+
* least `MIN_VISIBLE_MS` so it never reads as a flicker. There is no
|
|
87
|
+
* automatic cutoff: a task that never settles keeps the card up until the
|
|
88
|
+
* user presses Esc (when `options.skip` is set) — deliberate, not an
|
|
89
|
+
* oversight, since the hint row is what turns that into a user choice
|
|
90
|
+
* rather than a hang.
|
|
91
|
+
*/
|
|
92
|
+
export async function withWorkingOverlay<T>(
|
|
93
|
+
ctx: ExtensionContext,
|
|
94
|
+
options: WorkingOverlayOptions<T>,
|
|
95
|
+
task: (signal: AbortSignal) => Promise<T>,
|
|
96
|
+
): Promise<T> {
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
const work = task(controller.signal);
|
|
99
|
+
// No terminal surface: the caller still waits, but there is nothing to draw on.
|
|
100
|
+
if (ctx.mode !== "tui") return work;
|
|
101
|
+
|
|
102
|
+
// Reflect instead of letting `work` reject on its own timeline: it is raced
|
|
103
|
+
// against the grace timer and abandoned outright when the user skips, and an
|
|
104
|
+
// abandoned rejecting promise would otherwise surface as an unhandled rejection.
|
|
105
|
+
let pending = true;
|
|
106
|
+
const settled: Promise<Settled<T>> = work.then(
|
|
107
|
+
(value): Settled<T> => ({ ok: true, value }),
|
|
108
|
+
(error): Settled<T> => ({ ok: false, error }),
|
|
109
|
+
);
|
|
110
|
+
void settled.then(() => {
|
|
111
|
+
pending = false;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await Promise.race([settled, sleep(SHOW_DELAY_MS)]);
|
|
115
|
+
if (!pending) return unwrap(await settled);
|
|
116
|
+
|
|
117
|
+
const session: WorkingOverlaySession = {};
|
|
118
|
+
const shownAt = Date.now();
|
|
119
|
+
let skipped: { value: T } | undefined;
|
|
120
|
+
void settled.then(async () => {
|
|
121
|
+
if (skipped) return;
|
|
122
|
+
await sleep(Math.max(0, MIN_VISIBLE_MS - (Date.now() - shownAt)));
|
|
123
|
+
requestWorkingOverlayClose(session);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const skip = options.skip;
|
|
127
|
+
try {
|
|
128
|
+
await showWorkingOverlay(
|
|
129
|
+
ctx,
|
|
130
|
+
{
|
|
131
|
+
title: options.title,
|
|
132
|
+
detail: options.detail,
|
|
133
|
+
hint: options.hint,
|
|
134
|
+
// Esc skips: abort what we can, then leave the rest behind. The flow
|
|
135
|
+
// must move on even when a provider ignores the abort signal.
|
|
136
|
+
onSkip: skip
|
|
137
|
+
? () => {
|
|
138
|
+
skipped = { value: skip() };
|
|
139
|
+
controller.abort();
|
|
140
|
+
}
|
|
141
|
+
: undefined,
|
|
142
|
+
},
|
|
143
|
+
session,
|
|
144
|
+
);
|
|
145
|
+
} catch {
|
|
146
|
+
// A stale ctx (e.g. after /reload) must not break the flow that is waiting.
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (skipped) return skipped.value;
|
|
150
|
+
return unwrap(await settled);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Copy for the plan/opinion/debate naming wait. No trailing ellipses — see CHANGELOG 0.2.0. */
|
|
154
|
+
export function planNamingOverlay(subject: "plan" | "opinion" | "debate", skip: () => string): WorkingOverlayOptions<string> {
|
|
155
|
+
return {
|
|
156
|
+
title: `Naming the ${subject}`,
|
|
157
|
+
detail: `summarizing your prompt into a short ${subject} name`,
|
|
158
|
+
hint: "esc skip naming · a name from your prompt is used instead",
|
|
159
|
+
skip,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The slice of pi's `Theme` this overlay needs. Structural so tests can stub it without `getFgAnsi`. */
|
|
164
|
+
interface WorkingOverlayTheme {
|
|
165
|
+
fg(color: ThemeColor, text: string): string;
|
|
166
|
+
bg(color: Parameters<Theme["bg"]>[0], text: string): string;
|
|
167
|
+
/** 24-bit ANSI escape for a color. Absent on test stubs, so the detail line falls back to flat text. */
|
|
168
|
+
getFgAnsi?(color: ThemeColor): string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
class WorkingOverlayComponent implements Component {
|
|
172
|
+
private readonly tui: TUI;
|
|
173
|
+
private readonly theme: WorkingOverlayTheme;
|
|
174
|
+
private readonly options: WorkingOverlayRenderOptions;
|
|
175
|
+
private readonly session: WorkingOverlaySession;
|
|
176
|
+
private readonly done: (value: void) => void;
|
|
177
|
+
private readonly startedAt: number;
|
|
178
|
+
private readonly timer: ReturnType<typeof setInterval>;
|
|
179
|
+
private spinFrame = 0;
|
|
180
|
+
private closed = false;
|
|
181
|
+
|
|
182
|
+
constructor(
|
|
183
|
+
tui: TUI,
|
|
184
|
+
theme: WorkingOverlayTheme,
|
|
185
|
+
options: WorkingOverlayRenderOptions,
|
|
186
|
+
session: WorkingOverlaySession,
|
|
187
|
+
done: (value: void) => void,
|
|
188
|
+
) {
|
|
189
|
+
this.tui = tui;
|
|
190
|
+
this.theme = theme;
|
|
191
|
+
this.options = options;
|
|
192
|
+
this.session = session;
|
|
193
|
+
this.done = done;
|
|
194
|
+
this.startedAt = Date.now();
|
|
195
|
+
this.timer = setInterval(() => {
|
|
196
|
+
this.spinFrame = (this.spinFrame + 1) % BRAILLE_SPINNER_FRAMES.length;
|
|
197
|
+
tui.requestRender();
|
|
198
|
+
}, UI_TICK_MS);
|
|
199
|
+
this.timer.unref();
|
|
200
|
+
|
|
201
|
+
session.closeOverlay = () => this.finish();
|
|
202
|
+
// The task can settle before `ctx.ui.custom` invokes this factory (only
|
|
203
|
+
// mounting triggers the factory). Defer so `done` is never called
|
|
204
|
+
// synchronously from inside the factory that constructs this component.
|
|
205
|
+
if (session.closeRequested) setTimeout(() => this.finish(), 0);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private finish(): void {
|
|
209
|
+
if (this.closed) return;
|
|
210
|
+
this.closed = true;
|
|
211
|
+
this.done(undefined);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
handleInput(data: string): void {
|
|
215
|
+
// Under the Kitty keyboard protocol, the *release* of the key that
|
|
216
|
+
// closed the previous surface (e.g. Enter submitting the prompt editor)
|
|
217
|
+
// can land on this freshly mounted overlay a tick later. Only a press
|
|
218
|
+
// may skip. Same bug class as test/cancel-overlay-escape-release.test.mjs.
|
|
219
|
+
if (isKeyRelease(data)) return;
|
|
220
|
+
if (matchesKey(data, Key.escape)) {
|
|
221
|
+
if (this.options.onSkip) {
|
|
222
|
+
this.options.onSkip();
|
|
223
|
+
this.finish();
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// Everything else is swallowed: this is a blocking wait, not an
|
|
228
|
+
// interactive list, and stray keystrokes must not queue into whatever
|
|
229
|
+
// mounts next.
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
render(width: number): string[] {
|
|
233
|
+
const th = this.theme;
|
|
234
|
+
const innerWidth = Math.max(20, width - 4);
|
|
235
|
+
const frame = createFrame(th, innerWidth, {
|
|
236
|
+
glyphs: ROUNDED_SINGLE_BOX,
|
|
237
|
+
horizontalPadding: 1,
|
|
238
|
+
truncationMark: "…",
|
|
239
|
+
padToWidth: true,
|
|
240
|
+
minimumBodyWidth: 10,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const elapsed = formatElapsed(Date.now() - this.startedAt);
|
|
244
|
+
const titlePad = Math.max(1, frame.bodyWidth - visibleWidth(this.options.title) - visibleWidth(elapsed));
|
|
245
|
+
const titleRow = frame.row(`${th.fg("accent", this.options.title)}${" ".repeat(titlePad)}${th.fg("dim", elapsed)}`);
|
|
246
|
+
|
|
247
|
+
const spin = th.fg("accent", BRAILLE_SPINNER_FRAMES[this.spinFrame % BRAILLE_SPINNER_FRAMES.length]!);
|
|
248
|
+
const detailText = th.getFgAnsi
|
|
249
|
+
? shimmerString(this.options.detail, Date.now() - this.startedAt, th as ShimmerTheme, "ltr", "normal")
|
|
250
|
+
: th.fg("dim", this.options.detail);
|
|
251
|
+
const detailRow = frame.row(`${spin} ${detailText}`);
|
|
252
|
+
|
|
253
|
+
const hint = this.options.hint ?? (this.options.onSkip ? "esc skip" : "please wait");
|
|
254
|
+
const hintRow = frame.row(th.fg("dim", hint));
|
|
255
|
+
|
|
256
|
+
return [frame.top(), titleRow, frame.separator(), detailRow, frame.separator(), hintRow, frame.bottom()];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
invalidate(): void {}
|
|
260
|
+
|
|
261
|
+
dispose(): void {
|
|
262
|
+
clearInterval(this.timer);
|
|
263
|
+
if (this.session.closeOverlay) this.session.closeOverlay = undefined;
|
|
264
|
+
}
|
|
265
|
+
}
|