@underactive/pi-topping-moa-fusion 0.1.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 +5 -0
- package/LICENSE +21 -0
- package/README.md +437 -0
- package/agents/mf-plan.md +43 -0
- package/agents/moa-debater.md +37 -0
- package/agents/moa-explore.md +56 -0
- package/agents/moa-opinion.md +29 -0
- package/agents/moa-proposer.md +49 -0
- package/agents/moa-synthesizer.md +124 -0
- package/agents/moa-verifier.md +67 -0
- package/index.ts +3 -0
- package/package.json +61 -0
- package/src/activityMeter.ts +193 -0
- package/src/agents/authoritative.ts +91 -0
- package/src/agents/defaults.ts +123 -0
- package/src/agents/discovery.ts +119 -0
- package/src/config/modelCatalogue.ts +54 -0
- package/src/config/planName.ts +74 -0
- package/src/config/rosters.ts +118 -0
- package/src/config/settings.ts +161 -0
- package/src/debate/debateContract.ts +89 -0
- package/src/debate/debateFanout.ts +285 -0
- package/src/debate/debateFile.ts +38 -0
- package/src/debate/debateResults.ts +115 -0
- package/src/debate/debateRounds.ts +61 -0
- package/src/debate/runDebate.ts +143 -0
- package/src/index.ts +283 -0
- package/src/moa/conflictContract.ts +49 -0
- package/src/moa/conflicts.ts +153 -0
- package/src/moa/contextContract.ts +52 -0
- package/src/moa/fanout.ts +152 -0
- package/src/moa/fanoutWiring.ts +88 -0
- package/src/moa/implementationRetry.ts +292 -0
- package/src/moa/modelRuntime.ts +87 -0
- package/src/moa/orchestration.ts +105 -0
- package/src/moa/planInfo.ts +57 -0
- package/src/moa/planlessRetry.ts +72 -0
- package/src/moa/reviewLoop.ts +170 -0
- package/src/moa/runContext.ts +118 -0
- package/src/moa/synthesis.ts +420 -0
- package/src/moa/verdicts.ts +81 -0
- package/src/moa/verification.ts +791 -0
- package/src/moa/verificationCriteria.ts +127 -0
- package/src/moa/verifyGate.ts +137 -0
- package/src/opinion/opinionContract.ts +21 -0
- package/src/opinion/opinionFanout.ts +135 -0
- package/src/opinion/opinionFile.ts +38 -0
- package/src/opinion/opinionResults.ts +73 -0
- package/src/opinion/runOpinion.ts +156 -0
- package/src/planning/askUserQuestion.ts +83 -0
- package/src/planning/instructions.ts +146 -0
- package/src/planning/modeState.ts +61 -0
- package/src/planning/planFile.ts +273 -0
- package/src/planning/planMode.ts +673 -0
- package/src/planning/tools/enterPlanMode.ts +165 -0
- package/src/planning/tools/exitPlanMode.ts +159 -0
- package/src/planning/tools/mfPlanSubagent.ts +311 -0
- package/src/planning/tools/shared.ts +19 -0
- package/src/planning/tools/writePlan.ts +33 -0
- package/src/runtime/activityTracking.ts +141 -0
- package/src/runtime/cancelRun.ts +134 -0
- package/src/runtime/mutationTripwire.ts +251 -0
- package/src/runtime/processPool.ts +55 -0
- package/src/runtime/results.ts +103 -0
- package/src/runtime/runner.ts +538 -0
- package/src/runtime/wire.ts +177 -0
- package/src/shared/functionKeys.ts +30 -0
- package/src/shared/modelRefs.ts +91 -0
- package/src/ui/agentStatus.ts +84 -0
- package/src/ui/agentTranscript.ts +112 -0
- package/src/ui/cancelOverlay.ts +191 -0
- package/src/ui/chrome.ts +151 -0
- package/src/ui/conflictOverlay.ts +363 -0
- package/src/ui/debateModelPicker.ts +273 -0
- package/src/ui/menu.ts +679 -0
- package/src/ui/moaModelPicker.ts +900 -0
- package/src/ui/moaProgressWidget.ts +910 -0
- package/src/ui/moaSetupOverlay.ts +368 -0
- package/src/ui/modelLabel.ts +61 -0
- package/src/ui/observeOverlay.ts +206 -0
- package/src/ui/opinionModelPicker.ts +246 -0
- package/src/ui/planReviewOverlay.ts +315 -0
- package/src/ui/promptEditor.ts +87 -0
- package/src/ui/rosterEditor.ts +310 -0
- package/src/ui/shimmer.ts +77 -0
- package/src/ui/toolActivity.ts +35 -0
- package/src/ui/twoPaneModelThinking.ts +272 -0
- package/src/ui/verificationFindingsOverlay.ts +137 -0
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MoA progress surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Fan-out, synthesis, implementation and verification render into ONE sticky
|
|
5
|
+
* widget above the editor, under a single key: a full-width agent table
|
|
6
|
+
* carrying elapsed time, turn count, tool-call count, model cost, context
|
|
7
|
+
* usage and a pi-topping style generated-output activity meter per agent.
|
|
8
|
+
* Rows are grouped under `── Plan / Synthesize / Implement / Verify` headings,
|
|
9
|
+
* with a shimmering four-phase chevron band above the header tracing the
|
|
10
|
+
* active phase. The table stays mounted across the whole run — orchestration
|
|
11
|
+
* hands it to the plan-mode controller on approval, which keeps it alive
|
|
12
|
+
* through in-session implementation and verification.
|
|
13
|
+
*
|
|
14
|
+
* Each row's MONITOR activity meter is tinted with the thinking level that row
|
|
15
|
+
* runs under, using that level's native theme color (`thinkingOff` through
|
|
16
|
+
* `thinkingMax`). The level is stored per row — not per model — so identical
|
|
17
|
+
* models in different slots can carry different hues, and every activation
|
|
18
|
+
* (retry, model swap, verifier fallback, resumed handoff) refreshes it. A row
|
|
19
|
+
* whose level is unknown falls back to the neutral `accent`, and idle cells
|
|
20
|
+
* plus settled traces keep their existing dimming under whichever hue applies.
|
|
21
|
+
*
|
|
22
|
+
* Widgets never take keyboard focus, so the default editor keeps it and pi
|
|
23
|
+
* dispatches f2/f3/f4 through the extension shortcuts registered in index.ts.
|
|
24
|
+
* ESC likewise stays with index.ts's raw terminal-input hook. Nothing here
|
|
25
|
+
* handles input.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
29
|
+
import type { ExtensionContext, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import { visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
31
|
+
import { ACTIVITY_METER_WIDTH, ActivityMeter, rateToLevel, TokRateTracker } from "../activityMeter.ts";
|
|
32
|
+
import { modelRefLabel, shortModelName, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
|
|
33
|
+
import { contextPercent, CTX_COL_WIDTH, formatCost, formatElapsed, formatTokens } from "./agentStatus.ts";
|
|
34
|
+
import { formatAgentPreview } from "./agentTranscript.ts";
|
|
35
|
+
import { QUADRANT_SPINNER_FRAMES, UI_TICK_MS, fitVisible, ratioViewport } from "./chrome.ts";
|
|
36
|
+
import { shimmerString, type ShimmerTheme } from "./shimmer.ts";
|
|
37
|
+
import { highlightActivity } from "./toolActivity.ts";
|
|
38
|
+
|
|
39
|
+
const WIDGET_KEY = "mf-plan-moa-status";
|
|
40
|
+
|
|
41
|
+
/** Visible phase groups, in render order. */
|
|
42
|
+
export const MOA_PHASES = ["Plan", "Synthesize", "Implement", "Verify"] as const;
|
|
43
|
+
export type MoaPhase = (typeof MOA_PHASES)[number];
|
|
44
|
+
|
|
45
|
+
/** The model (or, for the fan-out, models) assigned to each phase, for the band. */
|
|
46
|
+
export type PhaseModels = Partial<Record<MoaPhase, ModelRef | ModelRef[]>>;
|
|
47
|
+
|
|
48
|
+
export type ProposerState = "queued" | "working" | "done" | "error" | "cancelling" | "cancelled";
|
|
49
|
+
|
|
50
|
+
export interface ProposerStatus {
|
|
51
|
+
ref: ModelRef;
|
|
52
|
+
phase: MoaPhase;
|
|
53
|
+
state: ProposerState;
|
|
54
|
+
detail?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Thinking level the model runs this activation under, tinting the MONITOR
|
|
57
|
+
* meter. Per row, not per model: the same model can occupy different slots at
|
|
58
|
+
* different levels. Undefined when the level is unknown (meter falls to accent).
|
|
59
|
+
*/
|
|
60
|
+
thinking?: ThinkingLevel;
|
|
61
|
+
/** Latest reported context size for this agent's most recent turn (usage.totalTokens). */
|
|
62
|
+
contextTokens?: number;
|
|
63
|
+
/** Live one-line description of the agent's most recent tool call. */
|
|
64
|
+
activity?: string;
|
|
65
|
+
/** Recent activity strings (bounded) for simple loop detection. */
|
|
66
|
+
activityHistory?: string[];
|
|
67
|
+
/** Cumulative generated-output tokens, driving the activity meter. */
|
|
68
|
+
outputTokens?: number;
|
|
69
|
+
/** Bumped when exact usage supersedes an estimate, so the meter can reset. */
|
|
70
|
+
outputRevision?: number;
|
|
71
|
+
/** Bounded completed and streaming output shown beneath a working row. */
|
|
72
|
+
transcript?: { messages: Message[]; partial?: Message };
|
|
73
|
+
/** Bumped on every transcript snapshot, including in-place stream updates. */
|
|
74
|
+
transcriptRevision?: number;
|
|
75
|
+
/** Assistant turns completed so far. */
|
|
76
|
+
turns?: number;
|
|
77
|
+
/** Tool calls started so far. */
|
|
78
|
+
toolCalls?: number;
|
|
79
|
+
/** Cumulative model cost in USD, calculated from registry rates. */
|
|
80
|
+
costUsd?: number;
|
|
81
|
+
startedAt?: number;
|
|
82
|
+
/** Set once the agent settles, freezing its elapsed reading. */
|
|
83
|
+
endedAt?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type ContextWindowResolver = (ref: ModelRef) => number | undefined;
|
|
87
|
+
|
|
88
|
+
/** How many recent activity entries to retain per agent for loop detection. */
|
|
89
|
+
const ACTIVITY_WINDOW = 8;
|
|
90
|
+
|
|
91
|
+
const TABLE_TITLE = "MoA Fusion";
|
|
92
|
+
const TABLE_FOOTER = "esc cancel · f2 toggle preview · f3 observe";
|
|
93
|
+
/** Shortest rule run allowed between the title and the plan name before the name is dropped. */
|
|
94
|
+
const MIN_TITLE_NAME_GAP = 2;
|
|
95
|
+
/**
|
|
96
|
+
* Share of the terminal the table may claim. Lower than an overlay's would be:
|
|
97
|
+
* this sits above the editor for the whole run and cannot be dismissed, so it
|
|
98
|
+
* has to leave the transcript readable.
|
|
99
|
+
*/
|
|
100
|
+
const TABLE_HEIGHT_RATIO = 0.65;
|
|
101
|
+
/** Border, header, separator, footer and bottom border — the rows a table always costs. */
|
|
102
|
+
const TABLE_CHROME_ROWS = 5;
|
|
103
|
+
/** Two band text lines plus their separator rule — the extra rows the phase/model band costs when shown. */
|
|
104
|
+
const PHASE_BAND_ROWS = 3;
|
|
105
|
+
/** Narrowest a band column may get before its centered label becomes unreadable; below this the band is dropped. */
|
|
106
|
+
const PHASE_BAND_MIN_COL = 12;
|
|
107
|
+
/**
|
|
108
|
+
* The two halves of the tall right-chevron drawn between phase columns: a "\"
|
|
109
|
+
* powerline diagonal (U+E0B9) on the name row stacked over a "/" (U+E0BB) on
|
|
110
|
+
* the model row. Needs a Powerline/Nerd Font to render; plainer fonts show tofu.
|
|
111
|
+
*/
|
|
112
|
+
const PHASE_SEP_TOP = "\u{E0B9}";
|
|
113
|
+
const PHASE_SEP_BOTTOM = "\u{E0BB}";
|
|
114
|
+
|
|
115
|
+
const COLUMN_GAP = 2;
|
|
116
|
+
const STATUS_COL_WIDTH = 2;
|
|
117
|
+
const ELAPSED_COL_WIDTH = 6;
|
|
118
|
+
const TURNS_COL_WIDTH = 5;
|
|
119
|
+
const TOOLS_COL_WIDTH = 5;
|
|
120
|
+
const COST_COL_WIDTH = 8;
|
|
121
|
+
const AGENT_COL_MIN = 8;
|
|
122
|
+
/** Agent width below which a model ref stops being distinguishable from its siblings. */
|
|
123
|
+
const AGENT_COL_READABLE = 24;
|
|
124
|
+
const ACTIVITY_COL_MIN = 10;
|
|
125
|
+
const PREVIEW_LINES = 4;
|
|
126
|
+
const PREVIEW_INDENT = STATUS_COL_WIDTH + 2;
|
|
127
|
+
const PREVIEW_GUTTER = "│ ";
|
|
128
|
+
const PREVIEW_MIN_WIDTH = 16;
|
|
129
|
+
const TRANSCRIPT_MESSAGE_LIMIT = 40;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* MONITOR meter hue per selected thinking level: each row's activity meter is
|
|
133
|
+
* tinted with its level's native theme color (`thinkingOff` … `thinkingMax`),
|
|
134
|
+
* so the table reads the thinking effort at a glance. Rows whose level is
|
|
135
|
+
* unknown fall back to `accent` (see `meterColorFor`). Exhaustive over
|
|
136
|
+
* `ThinkingLevel` so a new level cannot silently render as accent.
|
|
137
|
+
*/
|
|
138
|
+
const THINKING_METER_COLORS: Record<ThinkingLevel, ThemeColor> = {
|
|
139
|
+
off: "thinkingOff",
|
|
140
|
+
minimal: "thinkingMinimal",
|
|
141
|
+
low: "thinkingLow",
|
|
142
|
+
medium: "thinkingMedium",
|
|
143
|
+
high: "thinkingHigh",
|
|
144
|
+
xhigh: "thinkingXhigh",
|
|
145
|
+
max: "thinkingMax",
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** Resolve a row's meter hue, defaulting an unknown level to the neutral accent. */
|
|
149
|
+
function meterColorFor(thinking: ThinkingLevel | undefined): ThemeColor {
|
|
150
|
+
return thinking ? THINKING_METER_COLORS[thinking] : "accent";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Push an activity onto a bounded history buffer (most-recent-last). */
|
|
154
|
+
function pushActivity(history: string[], activity: string): void {
|
|
155
|
+
history.push(activity);
|
|
156
|
+
while (history.length > ACTIVITY_WINDOW) history.shift();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Working agents animate, render bright, and may carry a tool-activity sub-row. */
|
|
160
|
+
function isActive(state: ProposerState): boolean {
|
|
161
|
+
return state === "working" || state === "cancelling";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** One agent line in the fan-out/synthesis/implementation/verification table. */
|
|
165
|
+
export interface ProgressRow {
|
|
166
|
+
phase: MoaPhase;
|
|
167
|
+
label: string;
|
|
168
|
+
state: ProposerState;
|
|
169
|
+
statusText: string;
|
|
170
|
+
contextTokens?: number;
|
|
171
|
+
contextWindow?: number;
|
|
172
|
+
activity?: string;
|
|
173
|
+
elapsedMs: number;
|
|
174
|
+
turns: number;
|
|
175
|
+
toolCalls: number;
|
|
176
|
+
costUsd?: number;
|
|
177
|
+
outputTokens: number;
|
|
178
|
+
outputRevision: number;
|
|
179
|
+
transcript?: { messages: Message[]; partial?: Message };
|
|
180
|
+
transcriptRevision: number;
|
|
181
|
+
/** Thinking level of this activation, colouring the MONITOR meter. */
|
|
182
|
+
thinking?: ThinkingLevel;
|
|
183
|
+
/** First row of its phase group; used by the render-only phase heading. */
|
|
184
|
+
firstOfPhase: boolean;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The slice of pi's `Theme` the table needs. Structural so tests can stub it without `getFgAnsi`. */
|
|
188
|
+
export interface ProgressTheme {
|
|
189
|
+
fg(color: ThemeColor, text: string): string;
|
|
190
|
+
bold(text: string): string;
|
|
191
|
+
/** 24-bit ANSI escape for a color. Absent on test stubs, so the band falls back to flat text. */
|
|
192
|
+
getFgAnsi?(color: ThemeColor): string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** What the table component reads from. */
|
|
196
|
+
export interface MoaProgressView {
|
|
197
|
+
progressRows(): ProgressRow[];
|
|
198
|
+
/** Title shown in the table's top border. */
|
|
199
|
+
readonly title: string;
|
|
200
|
+
/** Summarized plan name, shown right-aligned in the title bar. */
|
|
201
|
+
readonly planName: string | undefined;
|
|
202
|
+
/** Whether live transcript previews are currently shown beneath active rows. */
|
|
203
|
+
readonly previewVisible: boolean;
|
|
204
|
+
/** Display label for a phase without changing its internal identity. */
|
|
205
|
+
phaseLabel(phase: MoaPhase): string;
|
|
206
|
+
/** Model assigned to each phase, for the band above the table header. */
|
|
207
|
+
phaseModels(): PhaseModels;
|
|
208
|
+
/** The phase currently highlighted in the band; undefined highlights none. */
|
|
209
|
+
activePhase(): MoaPhase | undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface MoaProgressCallbacks {
|
|
213
|
+
/**
|
|
214
|
+
* Close the cancel/observe overlays. Both describe the run this table is
|
|
215
|
+
* reporting on, so they must not outlive it.
|
|
216
|
+
*/
|
|
217
|
+
closeStacked?: () => void;
|
|
218
|
+
/** Display-only overrides used by sibling fan-out flows. */
|
|
219
|
+
title?: string;
|
|
220
|
+
phaseLabels?: Partial<Record<MoaPhase, string>>;
|
|
221
|
+
fanoutWorkingText?: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
interface RowMeter {
|
|
225
|
+
meter: ActivityMeter;
|
|
226
|
+
tracker: TokRateTracker;
|
|
227
|
+
revision: number;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** A post-fan-out workload (synthesizer, implementer, verifier), keyed by phase. */
|
|
231
|
+
interface RoleRow {
|
|
232
|
+
ref: ModelRef;
|
|
233
|
+
status: ProposerStatus;
|
|
234
|
+
workingText: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export class MoaProgressWidget implements MoaProgressView {
|
|
238
|
+
private readonly ctx: ExtensionContext;
|
|
239
|
+
private readonly resolveContextWindow: ContextWindowResolver;
|
|
240
|
+
/** Context window is immutable per model, so a resolved value is cached by ref label across ticks. */
|
|
241
|
+
private readonly contextWindowCache = new Map<string, number | undefined>();
|
|
242
|
+
private readonly callbacks: MoaProgressCallbacks;
|
|
243
|
+
readonly title: string;
|
|
244
|
+
readonly planName: string | undefined;
|
|
245
|
+
|
|
246
|
+
private statuses: ProposerStatus[] = [];
|
|
247
|
+
private roleRows = new Map<MoaPhase, RoleRow>();
|
|
248
|
+
private models: PhaseModels = {};
|
|
249
|
+
private active: MoaPhase | undefined;
|
|
250
|
+
private tableMounted = false;
|
|
251
|
+
private showPreview = true;
|
|
252
|
+
private requestRender: (() => void) | undefined;
|
|
253
|
+
|
|
254
|
+
constructor(
|
|
255
|
+
ctx: ExtensionContext,
|
|
256
|
+
resolveContextWindow: ContextWindowResolver = () => undefined,
|
|
257
|
+
callbacks: MoaProgressCallbacks = {},
|
|
258
|
+
planName?: string,
|
|
259
|
+
) {
|
|
260
|
+
this.ctx = ctx;
|
|
261
|
+
this.resolveContextWindow = resolveContextWindow;
|
|
262
|
+
this.callbacks = callbacks;
|
|
263
|
+
this.title = callbacks.title ?? TABLE_TITLE;
|
|
264
|
+
this.planName = planName;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private get ui(): ExtensionContext["ui"] {
|
|
268
|
+
return this.ctx.ui;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
get previewVisible(): boolean {
|
|
272
|
+
return this.showPreview;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
togglePreview(): boolean {
|
|
276
|
+
this.showPreview = !this.showPreview;
|
|
277
|
+
this.requestRender?.();
|
|
278
|
+
return this.showPreview;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private mountTable(): void {
|
|
282
|
+
if (this.tableMounted) return;
|
|
283
|
+
this.tableMounted = true;
|
|
284
|
+
this.ui.setWidget(WIDGET_KEY, (tui, theme) => {
|
|
285
|
+
this.requestRender = () => tui.requestRender();
|
|
286
|
+
return new MoaProgressTableComponent(tui, theme, this);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private unmount(): void {
|
|
291
|
+
if (this.tableMounted) {
|
|
292
|
+
this.tableMounted = false;
|
|
293
|
+
this.callbacks.closeStacked?.();
|
|
294
|
+
}
|
|
295
|
+
this.requestRender = undefined;
|
|
296
|
+
this.ui.setWidget(WIDGET_KEY, undefined);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Start the fan-out phase with the initial (working) proposer statuses.
|
|
301
|
+
* `thinking[i]` tints proposer `i`'s meter; an absent entry falls to accent.
|
|
302
|
+
*/
|
|
303
|
+
startFanout(refs: ModelRef[], thinking: (ThinkingLevel | undefined)[] = []): void {
|
|
304
|
+
const startedAt = Date.now();
|
|
305
|
+
this.statuses = refs.map((ref, index) => ({ ref, phase: "Plan" as const, state: "working" as const, startedAt, thinking: thinking[index] }));
|
|
306
|
+
this.active = "Plan";
|
|
307
|
+
this.mountTable();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Assign the model shown in the phase/model band for each phase. */
|
|
311
|
+
setPhaseModels(models: PhaseModels): void {
|
|
312
|
+
this.models = models;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
phaseModels(): PhaseModels {
|
|
316
|
+
return this.models;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Mark which phase is highlighted in the band. Undefined highlights none. */
|
|
320
|
+
setActivePhase(phase: MoaPhase | undefined): void {
|
|
321
|
+
this.active = phase;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
activePhase(): MoaPhase | undefined {
|
|
325
|
+
return this.active;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
phaseLabel(phase: MoaPhase): string {
|
|
329
|
+
return this.callbacks.phaseLabels?.[phase] ?? phase;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Register a not-yet-started role row so its phase heading renders from the start. */
|
|
333
|
+
queueRoleRow(phase: MoaPhase, ref: ModelRef, thinking?: ThinkingLevel): void {
|
|
334
|
+
this.roleRows.set(phase, { ref, status: { ref, phase, state: "queued", thinking }, workingText: "" });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Update one proposer's status (by index, matching the order passed to startFanout). */
|
|
338
|
+
update(index: number, state: ProposerState, detail?: string): void {
|
|
339
|
+
const s = this.statuses[index];
|
|
340
|
+
if (!s) return;
|
|
341
|
+
const wasSettled = s.state === "done" || s.state === "error" || s.state === "cancelled";
|
|
342
|
+
s.state = state;
|
|
343
|
+
s.detail = detail;
|
|
344
|
+
// Re-activation (a settled proposer being retried) un-freezes the clock
|
|
345
|
+
// and must not show output from the failed attempt.
|
|
346
|
+
if (state === "working" && wasSettled) {
|
|
347
|
+
s.transcript = undefined;
|
|
348
|
+
s.transcriptRevision = (s.transcriptRevision ?? 0) + 1;
|
|
349
|
+
}
|
|
350
|
+
if (!isActive(state)) s.endedAt ??= Date.now();
|
|
351
|
+
else s.endedAt = undefined;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Update one proposer's live context, turn, tool-call, and cost readings (by index). */
|
|
355
|
+
updateUsage(index: number, contextTokens: number | undefined, turns: number, toolCalls = 0, costUsd?: number): void {
|
|
356
|
+
const s = this.statuses[index];
|
|
357
|
+
if (!s) return;
|
|
358
|
+
s.contextTokens = contextTokens;
|
|
359
|
+
s.turns = turns;
|
|
360
|
+
s.toolCalls = toolCalls;
|
|
361
|
+
s.costUsd = costUsd;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Update one proposer's live tool-activity line (by index). */
|
|
365
|
+
updateActivity(index: number, activity: string): void {
|
|
366
|
+
const s = this.statuses[index];
|
|
367
|
+
if (!s || !activity) return;
|
|
368
|
+
s.activity = activity;
|
|
369
|
+
s.activityHistory = s.activityHistory ?? [];
|
|
370
|
+
pushActivity(s.activityHistory, activity);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Update one proposer's generated-output reading (by index). */
|
|
374
|
+
updateOutput(index: number, tokens: number, revision: number): void {
|
|
375
|
+
const s = this.statuses[index];
|
|
376
|
+
if (!s) return;
|
|
377
|
+
s.outputTokens = tokens;
|
|
378
|
+
s.outputRevision = revision;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Update one proposer's bounded completed and streaming transcript snapshot. */
|
|
382
|
+
updateTranscript(index: number, messages: Message[], partial?: Message): void {
|
|
383
|
+
const s = this.statuses[index];
|
|
384
|
+
if (!s) return;
|
|
385
|
+
s.transcript = { messages: messages.slice(-TRANSCRIPT_MESSAGE_LIMIT), partial };
|
|
386
|
+
s.transcriptRevision = (s.transcriptRevision ?? 0) + 1;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Move a role row into `working`, starting (or keeping) its elapsed clock.
|
|
391
|
+
* Each activation replaces the row's thinking level (with `undefined` when the
|
|
392
|
+
* caller cannot supply one), so a stale hue never outlives a model/level swap.
|
|
393
|
+
*/
|
|
394
|
+
private setRoleWorking(phase: MoaPhase, ref: ModelRef, workingText: string, thinking?: ThinkingLevel): void {
|
|
395
|
+
const existing = this.roleRows.get(phase);
|
|
396
|
+
const status: ProposerStatus = existing?.status ?? { ref, phase, state: "queued", activityHistory: [] };
|
|
397
|
+
const wasWorking = status.state === "working";
|
|
398
|
+
const wasSettled = status.state === "done" || status.state === "error" || status.state === "cancelled";
|
|
399
|
+
status.ref = ref;
|
|
400
|
+
status.phase = phase;
|
|
401
|
+
status.state = "working";
|
|
402
|
+
status.detail = undefined;
|
|
403
|
+
status.thinking = thinking;
|
|
404
|
+
if (wasSettled) {
|
|
405
|
+
status.startedAt = Date.now();
|
|
406
|
+
status.contextTokens = undefined;
|
|
407
|
+
status.activity = undefined;
|
|
408
|
+
status.activityHistory = [];
|
|
409
|
+
status.outputTokens = undefined;
|
|
410
|
+
status.outputRevision = undefined;
|
|
411
|
+
status.turns = undefined;
|
|
412
|
+
status.toolCalls = undefined;
|
|
413
|
+
status.costUsd = undefined;
|
|
414
|
+
status.transcript = undefined;
|
|
415
|
+
status.transcriptRevision = (status.transcriptRevision ?? 0) + 1;
|
|
416
|
+
} else {
|
|
417
|
+
status.startedAt ??= Date.now();
|
|
418
|
+
}
|
|
419
|
+
status.endedAt = undefined;
|
|
420
|
+
this.roleRows.set(phase, { ref, status, workingText });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Switch to the synthesis phase, appending the synthesizer row below the
|
|
425
|
+
* (now frozen) proposer rows. Reuses the table when it is still up;
|
|
426
|
+
* remounts when a user prompt or conflict review stopped it in between.
|
|
427
|
+
*/
|
|
428
|
+
switchToSynthesizing(synthesizerRef: ModelRef, status: string, thinking?: ThinkingLevel): void {
|
|
429
|
+
this.setRoleWorking("Synthesize", synthesizerRef, status, thinking);
|
|
430
|
+
this.active = "Synthesize";
|
|
431
|
+
this.mountTable();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Activate the in-session implementer row. */
|
|
435
|
+
switchToImplementing(implementerRef: ModelRef, status: string, thinking?: ThinkingLevel): void {
|
|
436
|
+
this.setRoleWorking("Implement", implementerRef, status, thinking);
|
|
437
|
+
this.active = "Implement";
|
|
438
|
+
this.mountTable();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Activate the verifier row. */
|
|
442
|
+
switchToVerifying(verifierRef: ModelRef, status: string, thinking?: ThinkingLevel): void {
|
|
443
|
+
this.setRoleWorking("Verify", verifierRef, status, thinking);
|
|
444
|
+
this.active = "Verify";
|
|
445
|
+
this.mountTable();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Settle a role row, freezing its elapsed reading. */
|
|
449
|
+
settleRoleRow(phase: MoaPhase, state: "done" | "error" | "cancelled", status?: string): void {
|
|
450
|
+
const existing = this.roleRows.get(phase);
|
|
451
|
+
if (!existing) return;
|
|
452
|
+
const s = existing.status;
|
|
453
|
+
s.state = state;
|
|
454
|
+
s.detail = status;
|
|
455
|
+
s.activity = undefined;
|
|
456
|
+
s.endedAt ??= Date.now();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Update a role row's live context, turn, tool-call, and cost readings. */
|
|
460
|
+
updateRoleUsage(phase: MoaPhase, contextTokens: number | undefined, turns: number, toolCalls = 0, costUsd?: number): void {
|
|
461
|
+
const s = this.roleRows.get(phase)?.status;
|
|
462
|
+
if (!s) return;
|
|
463
|
+
s.contextTokens = contextTokens;
|
|
464
|
+
s.turns = turns;
|
|
465
|
+
s.toolCalls = toolCalls;
|
|
466
|
+
s.costUsd = costUsd;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Update a role row's live tool-activity line. */
|
|
470
|
+
updateRoleActivity(phase: MoaPhase, activity: string): void {
|
|
471
|
+
const s = this.roleRows.get(phase)?.status;
|
|
472
|
+
if (!s || !activity) return;
|
|
473
|
+
s.activity = activity;
|
|
474
|
+
s.activityHistory = s.activityHistory ?? [];
|
|
475
|
+
pushActivity(s.activityHistory, activity);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Update a role row's generated-output reading. */
|
|
479
|
+
updateRoleOutput(phase: MoaPhase, tokens: number, revision: number): void {
|
|
480
|
+
const s = this.roleRows.get(phase)?.status;
|
|
481
|
+
if (!s) return;
|
|
482
|
+
s.outputTokens = tokens;
|
|
483
|
+
s.outputRevision = revision;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Update a role row's bounded completed and streaming transcript snapshot. */
|
|
487
|
+
updateRoleTranscript(phase: MoaPhase, messages: Message[], partial?: Message): void {
|
|
488
|
+
const s = this.roleRows.get(phase)?.status;
|
|
489
|
+
if (!s) return;
|
|
490
|
+
s.transcript = { messages: messages.slice(-TRANSCRIPT_MESSAGE_LIMIT), partial };
|
|
491
|
+
s.transcriptRevision = (s.transcriptRevision ?? 0) + 1;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Tear down the progress surface. Safe to call multiple times. */
|
|
495
|
+
stopWidget(): void {
|
|
496
|
+
this.unmount();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Live status snapshot for one proposer row (read by the cancel overlay). */
|
|
500
|
+
getStatus(index: number): ProposerStatus | undefined {
|
|
501
|
+
return this.statuses[index];
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Live status snapshot for a role row (read by the cancel overlay). */
|
|
505
|
+
getRoleStatus(phase: MoaPhase): { ref: ModelRef | undefined; contextTokens: number | undefined; activity: string | undefined; activityHistory: string[] } {
|
|
506
|
+
const entry = this.roleRows.get(phase);
|
|
507
|
+
return {
|
|
508
|
+
ref: entry?.ref,
|
|
509
|
+
contextTokens: entry?.status.contextTokens,
|
|
510
|
+
activity: entry?.status.activity,
|
|
511
|
+
activityHistory: entry?.status.activityHistory ?? [],
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Table rows: every proposer, plus role rows in phase order. */
|
|
516
|
+
progressRows(): ProgressRow[] {
|
|
517
|
+
const now = Date.now();
|
|
518
|
+
const ordered: { phase: MoaPhase; status: ProposerStatus; workingText: string }[] = [
|
|
519
|
+
...this.statuses.map((status) => ({
|
|
520
|
+
phase: "Plan" as MoaPhase,
|
|
521
|
+
status,
|
|
522
|
+
workingText: this.callbacks.fanoutWorkingText ?? "exploring & planning",
|
|
523
|
+
})),
|
|
524
|
+
...[...this.roleRows.entries()].map(([phase, role]) => ({ phase, status: role.status, workingText: role.workingText })),
|
|
525
|
+
].sort((a, b) => MOA_PHASES.indexOf(a.phase) - MOA_PHASES.indexOf(b.phase));
|
|
526
|
+
return ordered.map((entry, index) =>
|
|
527
|
+
this.toRow(entry.status, entry.phase, entry.workingText, now, ordered[index - 1]?.phase !== entry.phase),
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private cachedContextWindow(ref: ModelRef): number | undefined {
|
|
532
|
+
const key = modelRefLabel(ref);
|
|
533
|
+
if (this.contextWindowCache.has(key)) return this.contextWindowCache.get(key);
|
|
534
|
+
const value = this.resolveContextWindow(ref);
|
|
535
|
+
this.contextWindowCache.set(key, value);
|
|
536
|
+
return value;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private toRow(s: ProposerStatus, phase: MoaPhase, workingText: string, now: number, firstOfPhase: boolean): ProgressRow {
|
|
540
|
+
const statusText =
|
|
541
|
+
s.state === "working" ? (s.detail ?? workingText)
|
|
542
|
+
: s.state === "cancelling" ? "cancelling…"
|
|
543
|
+
: s.state === "cancelled" ? "cancelled"
|
|
544
|
+
: s.state === "error" ? s.detail ?? "error"
|
|
545
|
+
: s.state === "queued" ? "queued"
|
|
546
|
+
: `done (${formatTokens(s.outputTokens ?? 0)} tokens)`;
|
|
547
|
+
return {
|
|
548
|
+
phase,
|
|
549
|
+
label: modelRefLabel(s.ref),
|
|
550
|
+
state: s.state,
|
|
551
|
+
statusText,
|
|
552
|
+
contextTokens: s.contextTokens,
|
|
553
|
+
contextWindow: this.cachedContextWindow(s.ref),
|
|
554
|
+
activity: s.activity,
|
|
555
|
+
elapsedMs: s.startedAt === undefined ? 0 : (s.endedAt ?? now) - s.startedAt,
|
|
556
|
+
turns: s.turns ?? 0,
|
|
557
|
+
toolCalls: s.toolCalls ?? 0,
|
|
558
|
+
costUsd: s.costUsd,
|
|
559
|
+
outputTokens: s.outputTokens ?? 0,
|
|
560
|
+
outputRevision: s.outputRevision ?? 0,
|
|
561
|
+
transcript: s.transcript,
|
|
562
|
+
transcriptRevision: s.transcriptRevision ?? 0,
|
|
563
|
+
thinking: s.thinking,
|
|
564
|
+
firstOfPhase,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Column widths for the agent table, shedding columns as the terminal narrows. */
|
|
570
|
+
export function tableColumns(bodyWidth: number, labels: string[]): { agent: number; activity: number; stats: boolean } {
|
|
571
|
+
const fixed = STATUS_COL_WIDTH + COLUMN_GAP + CTX_COL_WIDTH + COLUMN_GAP + ACTIVITY_METER_WIDTH + COLUMN_GAP;
|
|
572
|
+
const statsWidth = ELAPSED_COL_WIDTH + COLUMN_GAP + COST_COL_WIDTH + COLUMN_GAP + TOOLS_COL_WIDTH + COLUMN_GAP + TURNS_COL_WIDTH + COLUMN_GAP;
|
|
573
|
+
// Elapsed, turns, tool calls, and cost go first on a narrow terminal: they are ambient readings,
|
|
574
|
+
// and are not worth truncating the agent name down to an unreadable stub.
|
|
575
|
+
const stats = bodyWidth - fixed - statsWidth >= AGENT_COL_READABLE + ACTIVITY_COL_MIN;
|
|
576
|
+
const available = bodyWidth - fixed - (stats ? statsWidth : 0);
|
|
577
|
+
if (available < AGENT_COL_MIN) return { agent: Math.max(1, available), activity: 0, stats };
|
|
578
|
+
|
|
579
|
+
const widest = labels.reduce((max, label) => Math.max(max, visibleWidth(label)), 0);
|
|
580
|
+
const agent = Math.min(Math.max(AGENT_COL_MIN, widest), Math.max(AGENT_COL_MIN, available - ACTIVITY_COL_MIN));
|
|
581
|
+
return { agent, activity: available - agent, stats };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** Pad or truncate a possibly-ANSI-colored cell to exactly `width` columns. */
|
|
585
|
+
function cell(text: string, width: number, align: "left" | "right" | "center" = "left"): string {
|
|
586
|
+
if (width <= 0) return "";
|
|
587
|
+
const shown = visibleWidth(text) > width
|
|
588
|
+
? fitVisible(text, width, { truncationMark: "…", padToWidth: false })
|
|
589
|
+
: text;
|
|
590
|
+
const padding = Math.max(0, width - visibleWidth(shown));
|
|
591
|
+
if (align === "right") return " ".repeat(padding) + shown;
|
|
592
|
+
if (align === "center") {
|
|
593
|
+
const left = Math.floor(padding / 2);
|
|
594
|
+
return " ".repeat(left) + shown + " ".repeat(padding - left);
|
|
595
|
+
}
|
|
596
|
+
return shown + " ".repeat(padding);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Count the render-only heading and blank separator rows needed for the phase groups in `rows`. */
|
|
600
|
+
function phaseSectionRows(rows: ProgressRow[]): number {
|
|
601
|
+
const headingCount = rows.reduce(
|
|
602
|
+
(count, row, index) => count + (index === 0 || rows[index - 1]?.phase !== row.phase ? 1 : 0),
|
|
603
|
+
0,
|
|
604
|
+
);
|
|
605
|
+
return headingCount + Math.max(0, headingCount - 1);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export class MoaProgressTableComponent implements Component {
|
|
609
|
+
private readonly tui: TUI;
|
|
610
|
+
private readonly theme: ProgressTheme;
|
|
611
|
+
private readonly view: MoaProgressView;
|
|
612
|
+
private readonly timer: ReturnType<typeof setInterval>;
|
|
613
|
+
private readonly meters: RowMeter[] = [];
|
|
614
|
+
private readonly previewCache = new Map<number, { revision: number; width: number; lines: string[] }>();
|
|
615
|
+
private readonly createdAt = Date.now();
|
|
616
|
+
private spinFrame = 0;
|
|
617
|
+
|
|
618
|
+
constructor(tui: TUI, theme: ProgressTheme, view: MoaProgressView) {
|
|
619
|
+
this.tui = tui;
|
|
620
|
+
this.theme = theme;
|
|
621
|
+
this.view = view;
|
|
622
|
+
// One timer drives spinner animation, meter sampling and repaint, so the
|
|
623
|
+
// meter's 100ms cadence matches pi-topping's.
|
|
624
|
+
this.timer = setInterval(() => {
|
|
625
|
+
this.spinFrame = (this.spinFrame + 1) % QUADRANT_SPINNER_FRAMES.length;
|
|
626
|
+
this.sampleMeters(Date.now());
|
|
627
|
+
this.tui.requestRender();
|
|
628
|
+
}, UI_TICK_MS);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private sampleMeters(now: number): void {
|
|
632
|
+
const rows = this.view.progressRows();
|
|
633
|
+
this.meters.length = rows.length;
|
|
634
|
+
for (let i = 0; i < rows.length; i++) {
|
|
635
|
+
const row = rows[i];
|
|
636
|
+
let entry = this.meters[i];
|
|
637
|
+
if (!entry) {
|
|
638
|
+
entry = { meter: new ActivityMeter("rtl"), tracker: new TokRateTracker(), revision: row.outputRevision };
|
|
639
|
+
this.meters[i] = entry;
|
|
640
|
+
}
|
|
641
|
+
if (entry.revision !== row.outputRevision) {
|
|
642
|
+
entry.revision = row.outputRevision;
|
|
643
|
+
entry.tracker.reset();
|
|
644
|
+
}
|
|
645
|
+
// Settled agents keep their final trace instead of decaying to idle.
|
|
646
|
+
if (!isActive(row.state)) continue;
|
|
647
|
+
entry.meter.push(rateToLevel(entry.tracker.sample(row.outputTokens, now)));
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private renderMeter(index: number, active: boolean, thinking: ThinkingLevel | undefined): string {
|
|
652
|
+
const meter = this.meters[index]?.meter;
|
|
653
|
+
if (!meter) return this.theme.fg("dim", "⢀".repeat(ACTIVITY_METER_WIDTH));
|
|
654
|
+
// Each row's meter carries its selected thinking level's native theme hue;
|
|
655
|
+
// an unknown level falls back to accent. IDLE cells and settled traces keep
|
|
656
|
+
// their existing dimming inside colorizeCell regardless of the hue.
|
|
657
|
+
const color = meterColorFor(thinking);
|
|
658
|
+
return meter.render((level, char) =>
|
|
659
|
+
ActivityMeter.colorizeCell(level, char, this.theme, color, !active),
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** How many lines the table may use, so it never crowds out the editor below it. */
|
|
664
|
+
private rowBudget(): number {
|
|
665
|
+
const rows = this.tui?.terminal?.rows ?? 0;
|
|
666
|
+
if (rows <= 0) return Number.POSITIVE_INFINITY;
|
|
667
|
+
return ratioViewport(rows, {
|
|
668
|
+
fallbackRows: rows,
|
|
669
|
+
ratio: TABLE_HEIGHT_RATIO,
|
|
670
|
+
minimum: TABLE_CHROME_ROWS,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Compact band label for one phase's model(s), truncated by the Plan cell's width budget. */
|
|
675
|
+
private bandLabel(models: ModelRef | ModelRef[] | undefined, colWidth: number): string {
|
|
676
|
+
if (!models) return "—";
|
|
677
|
+
if (Array.isArray(models)) {
|
|
678
|
+
const names = models.map(shortModelName).join(" · ");
|
|
679
|
+
return visibleWidth(names) <= colWidth ? names : `${models.length} proposers`;
|
|
680
|
+
}
|
|
681
|
+
return shortModelName(models);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Two centered rows — phase names, then their assigned model ids — shown
|
|
686
|
+
* above the table header, with a two-row powerline chevron standing between
|
|
687
|
+
* adjacent columns to trace the phase flow. Self-suppresses when no phase has
|
|
688
|
+
* a model, or the terminal is too narrow to keep each column readable. The
|
|
689
|
+
* active phase (and the chevrons touching it) shimmers in place of its flat
|
|
690
|
+
* highlighted tone when the theme can supply truecolor ANSI.
|
|
691
|
+
*/
|
|
692
|
+
private phaseModelBand(bodyWidth: number, now: number): string[] {
|
|
693
|
+
const models = this.view.phaseModels();
|
|
694
|
+
if (!MOA_PHASES.some((phase) => models[phase])) return [];
|
|
695
|
+
const sepCount = MOA_PHASES.length - 1;
|
|
696
|
+
const colWidth = Math.floor((bodyWidth - sepCount) / MOA_PHASES.length);
|
|
697
|
+
if (colWidth < PHASE_BAND_MIN_COL) return [];
|
|
698
|
+
|
|
699
|
+
const th = this.theme;
|
|
700
|
+
const active = this.view.activePhase();
|
|
701
|
+
const paint = (lit: boolean, text: string): string => {
|
|
702
|
+
if (!lit) return th.fg("dim", text);
|
|
703
|
+
return th.getFgAnsi
|
|
704
|
+
? shimmerString(text, now - this.createdAt, th as ShimmerTheme, "ltr", "normal", true)
|
|
705
|
+
: th.fg("text", text);
|
|
706
|
+
};
|
|
707
|
+
// Each boundary carries a two-row powerline chevron: the "\" half on the name
|
|
708
|
+
// row stacks over the "/" half on the model row. Both rows share one column
|
|
709
|
+
// layout, so the halves land in the same terminal column and read as a
|
|
710
|
+
// single tall chevron. A separator lights with either phase it divides, so
|
|
711
|
+
// the active highlight flows along the pipeline.
|
|
712
|
+
const bandRow = (sep: string, textFor: (phase: MoaPhase) => string): string => {
|
|
713
|
+
const parts: string[] = [];
|
|
714
|
+
MOA_PHASES.forEach((phase, i) => {
|
|
715
|
+
parts.push(paint(phase === active, cell(textFor(phase), colWidth, "center")));
|
|
716
|
+
const next = MOA_PHASES[i + 1];
|
|
717
|
+
if (next !== undefined) parts.push(paint(phase === active || next === active, sep));
|
|
718
|
+
});
|
|
719
|
+
return parts.join("");
|
|
720
|
+
};
|
|
721
|
+
const nameRow = bandRow(PHASE_SEP_TOP, (phase) => this.view.phaseLabel(phase));
|
|
722
|
+
const modelRow = bandRow(PHASE_SEP_BOTTOM, (phase) => this.bandLabel(models[phase], colWidth));
|
|
723
|
+
return [nameRow, modelRow];
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
render(width: number): string[] {
|
|
727
|
+
const th = this.theme;
|
|
728
|
+
const innerWidth = Math.max(20, width);
|
|
729
|
+
const bodyWidth = Math.max(10, innerWidth - 4);
|
|
730
|
+
const border = (s: string) => th.fg("border", s);
|
|
731
|
+
const row = (s: string) => ` ${fitVisible(s, bodyWidth, { truncationMark: "…", padToWidth: true })} `;
|
|
732
|
+
const now = Date.now();
|
|
733
|
+
const band = this.phaseModelBand(bodyWidth, now);
|
|
734
|
+
const rows = this.view.progressRows();
|
|
735
|
+
const cols = tableColumns(bodyWidth, rows.map((r) => r.label));
|
|
736
|
+
// Transcript previews stop after ACTIVITY, leaving the right-aligned stats
|
|
737
|
+
// columns (TURNS, TOOLS, COST, TIME) visually clear.
|
|
738
|
+
const leftTableWidth = Math.min(bodyWidth,
|
|
739
|
+
STATUS_COL_WIDTH + cols.agent
|
|
740
|
+
+ COLUMN_GAP + CTX_COL_WIDTH
|
|
741
|
+
+ COLUMN_GAP + ACTIVITY_METER_WIDTH
|
|
742
|
+
+ (cols.activity > 0 ? COLUMN_GAP + cols.activity : 0),
|
|
743
|
+
);
|
|
744
|
+
const spin = QUADRANT_SPINNER_FRAMES[this.spinFrame % QUADRANT_SPINNER_FRAMES.length]!;
|
|
745
|
+
|
|
746
|
+
const gap = " ".repeat(COLUMN_GAP);
|
|
747
|
+
// Turns, tool calls, cost, and elapsed are pinned to the right edge so the
|
|
748
|
+
// activity column, whose values are by far the longest, keeps every other column from squeezing it.
|
|
749
|
+
const line = (icon: string, label: string, ctx: string, meter: string, activity: string, turns: string, toolCalls: string, cost: string, elapsed: string) => {
|
|
750
|
+
const parts = [`${cell(icon, STATUS_COL_WIDTH)}${cell(label, cols.agent)}`, cell(ctx, CTX_COL_WIDTH, "right"), meter];
|
|
751
|
+
if (cols.activity > 0) parts.push(cell(activity, cols.activity));
|
|
752
|
+
if (cols.stats) parts.push(
|
|
753
|
+
cell(turns, TURNS_COL_WIDTH, "right"),
|
|
754
|
+
cell(toolCalls, TOOLS_COL_WIDTH, "right"),
|
|
755
|
+
cell(cost, COST_COL_WIDTH, "right"),
|
|
756
|
+
cell(elapsed, ELAPSED_COL_WIDTH, "right"),
|
|
757
|
+
);
|
|
758
|
+
return row(parts.join(gap));
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
const dim = (s: string) => th.fg("dim", s);
|
|
762
|
+
const lines: string[] = [this.topBorder(innerWidth, border)];
|
|
763
|
+
for (const bandLine of band) lines.push(row(bandLine));
|
|
764
|
+
if (band.length > 0) lines.push(border("─".repeat(innerWidth)));
|
|
765
|
+
lines.push(line("", dim("MODEL"), dim("CTX"), dim(cell("MONITOR", ACTIVITY_METER_WIDTH)), dim("ACTIVITY"), dim("TURNS"), dim("TOOLS"), dim("COST"), dim("TIME")));
|
|
766
|
+
|
|
767
|
+
const free = Math.max(
|
|
768
|
+
0,
|
|
769
|
+
this.rowBudget() - TABLE_CHROME_ROWS - rows.length - phaseSectionRows(rows) - (band.length > 0 ? PHASE_BAND_ROWS : 0),
|
|
770
|
+
);
|
|
771
|
+
const activeIndices = rows.flatMap((r, index) => isActive(r.state) ? [index] : []);
|
|
772
|
+
const activityIndices = activeIndices.filter((index) => rows[index]?.activity);
|
|
773
|
+
const renderedActivityIndices = new Set(activityIndices.slice(0, free));
|
|
774
|
+
let remaining = Math.max(0, free - renderedActivityIndices.size);
|
|
775
|
+
|
|
776
|
+
const previewWidth = Math.max(0, leftTableWidth - PREVIEW_INDENT - visibleWidth(PREVIEW_GUTTER));
|
|
777
|
+
const previewIndices = this.view.previewVisible && previewWidth >= PREVIEW_MIN_WIDTH
|
|
778
|
+
? activeIndices.filter((index) => rows[index]?.transcript)
|
|
779
|
+
: [];
|
|
780
|
+
const previewAllocations = new Map<number, number>();
|
|
781
|
+
while (remaining > 0) {
|
|
782
|
+
let allocated = false;
|
|
783
|
+
for (const index of previewIndices) {
|
|
784
|
+
const count = previewAllocations.get(index) ?? 0;
|
|
785
|
+
if (count >= PREVIEW_LINES || remaining <= 0) continue;
|
|
786
|
+
previewAllocations.set(index, count + 1);
|
|
787
|
+
remaining--;
|
|
788
|
+
allocated = true;
|
|
789
|
+
}
|
|
790
|
+
if (!allocated) break;
|
|
791
|
+
}
|
|
792
|
+
// A blank separator is cosmetic: allocate it only after transcript content,
|
|
793
|
+
// so short terminals shed the blank before they shed the preview itself.
|
|
794
|
+
const previewSeparators = new Set<number>();
|
|
795
|
+
for (const index of previewIndices) {
|
|
796
|
+
if (remaining <= 0) break;
|
|
797
|
+
if ((previewAllocations.get(index) ?? 0) > 0) {
|
|
798
|
+
previewSeparators.add(index);
|
|
799
|
+
remaining--;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
// Preserve the old empty activity reservation only after actual activity and
|
|
803
|
+
// transcript previews have claimed their higher-priority rows.
|
|
804
|
+
const reservedActivityIndices = new Set<number>();
|
|
805
|
+
for (const index of activeIndices) {
|
|
806
|
+
if (remaining <= 0) break;
|
|
807
|
+
if (!rows[index]?.activity && !previewSeparators.has(index)) {
|
|
808
|
+
reservedActivityIndices.add(index);
|
|
809
|
+
remaining--;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
let renderedPhase = false;
|
|
813
|
+
|
|
814
|
+
for (let i = 0; i < rows.length; i++) {
|
|
815
|
+
const r = rows[i];
|
|
816
|
+
const active = isActive(r.state);
|
|
817
|
+
if (r.firstOfPhase) {
|
|
818
|
+
if (renderedPhase) lines.push(row(""));
|
|
819
|
+
const heading = `── ${this.view.phaseLabel(r.phase)} `;
|
|
820
|
+
lines.push(row(dim(heading + "─".repeat(Math.max(0, bodyWidth - visibleWidth(heading))))));
|
|
821
|
+
renderedPhase = true;
|
|
822
|
+
}
|
|
823
|
+
const icon =
|
|
824
|
+
r.state === "done" ? th.fg("success", "✓")
|
|
825
|
+
: r.state === "error" || r.state === "cancelled" ? th.fg("error", "✗")
|
|
826
|
+
: r.state === "queued" ? th.fg("dim", "○")
|
|
827
|
+
: th.fg("accent", spin);
|
|
828
|
+
const label = active ? th.fg("text", r.label) : th.fg("dim", r.label);
|
|
829
|
+
const status =
|
|
830
|
+
r.state === "error" ? th.fg("error", r.statusText)
|
|
831
|
+
: isActive(r.state) ? th.fg("text", r.statusText)
|
|
832
|
+
: th.fg("dim", r.statusText);
|
|
833
|
+
lines.push(
|
|
834
|
+
line(
|
|
835
|
+
icon,
|
|
836
|
+
label,
|
|
837
|
+
contextPercent(r.contextTokens, r.contextWindow),
|
|
838
|
+
this.renderMeter(i, active, r.thinking),
|
|
839
|
+
status,
|
|
840
|
+
String(r.turns),
|
|
841
|
+
String(r.toolCalls),
|
|
842
|
+
formatCost(r.costUsd),
|
|
843
|
+
formatElapsed(r.elapsedMs),
|
|
844
|
+
),
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
if (renderedActivityIndices.has(i)) lines.push(row(this.activitySubRow(r, bodyWidth)));
|
|
848
|
+
else if (reservedActivityIndices.has(i)) lines.push(row(""));
|
|
849
|
+
|
|
850
|
+
const previewLineCount = previewAllocations.get(i) ?? 0;
|
|
851
|
+
if (previewLineCount > 0) {
|
|
852
|
+
for (const text of this.previewLines(i, r, previewWidth).slice(-previewLineCount)) {
|
|
853
|
+
lines.push(row(this.previewRow(text, leftTableWidth)));
|
|
854
|
+
}
|
|
855
|
+
if (previewSeparators.has(i)) lines.push(row(""));
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const totalCost = rows.reduce((sum, r) => sum + (r.costUsd ?? 0), 0);
|
|
860
|
+
const totals = `total ${formatCost(totalCost)} · ${formatElapsed(now - this.createdAt)}`;
|
|
861
|
+
lines.push(border("─".repeat(innerWidth)));
|
|
862
|
+
lines.push(row(`${th.fg("dim", TABLE_FOOTER)}${" ".repeat(Math.max(1, bodyWidth - visibleWidth(TABLE_FOOTER) - visibleWidth(totals)))}${th.fg("dim", totals)}`));
|
|
863
|
+
lines.push(border("═".repeat(innerWidth)));
|
|
864
|
+
return lines;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
private previewLines(index: number, row: ProgressRow, width: number): string[] {
|
|
868
|
+
const cached = this.previewCache.get(index);
|
|
869
|
+
if (cached && cached.revision === row.transcriptRevision && cached.width === width) return cached.lines;
|
|
870
|
+
const transcript = row.transcript;
|
|
871
|
+
const lines = transcript ? formatAgentPreview(transcript.messages, transcript.partial, width, PREVIEW_LINES) : [];
|
|
872
|
+
this.previewCache.set(index, { revision: row.transcriptRevision, width, lines });
|
|
873
|
+
return lines;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
private previewRow(text: string, leftTableWidth: number): string {
|
|
877
|
+
const contentWidth = Math.max(0, leftTableWidth - PREVIEW_INDENT - visibleWidth(PREVIEW_GUTTER));
|
|
878
|
+
return `${" ".repeat(PREVIEW_INDENT)}${this.theme.fg("muted", `${PREVIEW_GUTTER}${cell(text, contentWidth)}`)}`;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** Merged-cell tool activity line, spanning everything right of the status icon. */
|
|
882
|
+
private activitySubRow(r: ProgressRow, bodyWidth: number): string {
|
|
883
|
+
const room = Math.max(0, bodyWidth - STATUS_COL_WIDTH);
|
|
884
|
+
const gutter = this.theme.fg("dim", "↳ ");
|
|
885
|
+
const activity = highlightActivity(this.theme, r.activity ?? "");
|
|
886
|
+
return `${" ".repeat(STATUS_COL_WIDTH)}${cell(`${gutter}${activity}`, room)}`;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
private topBorder(innerWidth: number, border: (s: string) => string): string {
|
|
890
|
+
const title = ` ${this.view.title} `;
|
|
891
|
+
const head = `${border("══")}${this.theme.fg("accent", title)}`;
|
|
892
|
+
const name = this.view.planName ? ` ${this.view.planName} ` : "";
|
|
893
|
+
const nameFill = innerWidth - 4 - visibleWidth(title) - visibleWidth(name);
|
|
894
|
+
// A narrow terminal drops the plan name rather than truncating it: a cut-off
|
|
895
|
+
// slug reads as a different plan, and the full name is on the review overlay.
|
|
896
|
+
if (name && nameFill >= MIN_TITLE_NAME_GAP) {
|
|
897
|
+
return `${head}${border("═".repeat(nameFill))}${this.theme.fg("dim", name)}${border("══")}`;
|
|
898
|
+
}
|
|
899
|
+
const fill = innerWidth - 2 - visibleWidth(title);
|
|
900
|
+
if (fill < 0) return border("═".repeat(innerWidth));
|
|
901
|
+
return `${head}${border("═".repeat(fill))}`;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
invalidate(): void {}
|
|
905
|
+
|
|
906
|
+
dispose(): void {
|
|
907
|
+
clearInterval(this.timer);
|
|
908
|
+
this.previewCache.clear();
|
|
909
|
+
}
|
|
910
|
+
}
|