@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -0
  3. package/README.md +437 -0
  4. package/agents/mf-plan.md +43 -0
  5. package/agents/moa-debater.md +37 -0
  6. package/agents/moa-explore.md +56 -0
  7. package/agents/moa-opinion.md +29 -0
  8. package/agents/moa-proposer.md +49 -0
  9. package/agents/moa-synthesizer.md +124 -0
  10. package/agents/moa-verifier.md +67 -0
  11. package/index.ts +3 -0
  12. package/package.json +61 -0
  13. package/src/activityMeter.ts +193 -0
  14. package/src/agents/authoritative.ts +91 -0
  15. package/src/agents/defaults.ts +123 -0
  16. package/src/agents/discovery.ts +119 -0
  17. package/src/config/modelCatalogue.ts +54 -0
  18. package/src/config/planName.ts +74 -0
  19. package/src/config/rosters.ts +118 -0
  20. package/src/config/settings.ts +161 -0
  21. package/src/debate/debateContract.ts +89 -0
  22. package/src/debate/debateFanout.ts +285 -0
  23. package/src/debate/debateFile.ts +38 -0
  24. package/src/debate/debateResults.ts +115 -0
  25. package/src/debate/debateRounds.ts +61 -0
  26. package/src/debate/runDebate.ts +143 -0
  27. package/src/index.ts +283 -0
  28. package/src/moa/conflictContract.ts +49 -0
  29. package/src/moa/conflicts.ts +153 -0
  30. package/src/moa/contextContract.ts +52 -0
  31. package/src/moa/fanout.ts +152 -0
  32. package/src/moa/fanoutWiring.ts +88 -0
  33. package/src/moa/implementationRetry.ts +292 -0
  34. package/src/moa/modelRuntime.ts +87 -0
  35. package/src/moa/orchestration.ts +105 -0
  36. package/src/moa/planInfo.ts +57 -0
  37. package/src/moa/planlessRetry.ts +72 -0
  38. package/src/moa/reviewLoop.ts +170 -0
  39. package/src/moa/runContext.ts +118 -0
  40. package/src/moa/synthesis.ts +420 -0
  41. package/src/moa/verdicts.ts +81 -0
  42. package/src/moa/verification.ts +791 -0
  43. package/src/moa/verificationCriteria.ts +127 -0
  44. package/src/moa/verifyGate.ts +137 -0
  45. package/src/opinion/opinionContract.ts +21 -0
  46. package/src/opinion/opinionFanout.ts +135 -0
  47. package/src/opinion/opinionFile.ts +38 -0
  48. package/src/opinion/opinionResults.ts +73 -0
  49. package/src/opinion/runOpinion.ts +156 -0
  50. package/src/planning/askUserQuestion.ts +83 -0
  51. package/src/planning/instructions.ts +146 -0
  52. package/src/planning/modeState.ts +61 -0
  53. package/src/planning/planFile.ts +273 -0
  54. package/src/planning/planMode.ts +673 -0
  55. package/src/planning/tools/enterPlanMode.ts +165 -0
  56. package/src/planning/tools/exitPlanMode.ts +159 -0
  57. package/src/planning/tools/mfPlanSubagent.ts +311 -0
  58. package/src/planning/tools/shared.ts +19 -0
  59. package/src/planning/tools/writePlan.ts +33 -0
  60. package/src/runtime/activityTracking.ts +141 -0
  61. package/src/runtime/cancelRun.ts +134 -0
  62. package/src/runtime/mutationTripwire.ts +251 -0
  63. package/src/runtime/processPool.ts +55 -0
  64. package/src/runtime/results.ts +103 -0
  65. package/src/runtime/runner.ts +538 -0
  66. package/src/runtime/wire.ts +177 -0
  67. package/src/shared/functionKeys.ts +30 -0
  68. package/src/shared/modelRefs.ts +91 -0
  69. package/src/ui/agentStatus.ts +84 -0
  70. package/src/ui/agentTranscript.ts +112 -0
  71. package/src/ui/cancelOverlay.ts +191 -0
  72. package/src/ui/chrome.ts +151 -0
  73. package/src/ui/conflictOverlay.ts +363 -0
  74. package/src/ui/debateModelPicker.ts +273 -0
  75. package/src/ui/menu.ts +679 -0
  76. package/src/ui/moaModelPicker.ts +900 -0
  77. package/src/ui/moaProgressWidget.ts +910 -0
  78. package/src/ui/moaSetupOverlay.ts +368 -0
  79. package/src/ui/modelLabel.ts +61 -0
  80. package/src/ui/observeOverlay.ts +206 -0
  81. package/src/ui/opinionModelPicker.ts +246 -0
  82. package/src/ui/planReviewOverlay.ts +315 -0
  83. package/src/ui/promptEditor.ts +87 -0
  84. package/src/ui/rosterEditor.ts +310 -0
  85. package/src/ui/shimmer.ts +77 -0
  86. package/src/ui/toolActivity.ts +35 -0
  87. package/src/ui/twoPaneModelThinking.ts +272 -0
  88. package/src/ui/verificationFindingsOverlay.ts +137 -0
@@ -0,0 +1,177 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type { Message } from "@earendil-works/pi-ai";
3
+ import type { JsonAgentSessionEvent } from "@earendil-works/pi-coding-agent";
4
+
5
+ export type WireAssistantMessageEvent = Extract<JsonAgentSessionEvent, { type: "message_update" }>["assistantMessageEvent"];
6
+
7
+ /** Bound content-indexed stream assembly so malformed children cannot allocate sparse arrays. */
8
+ export const MAX_STREAM_CONTENT_PARTS = 1024;
9
+
10
+ /**
11
+ * The wire union carries pi's custom message kinds — interactive `!cmd` bash,
12
+ * compaction and branch summaries — alongside plain LLM messages. None can
13
+ * occur in a non-interactive subagent, and nothing that consumes a subagent
14
+ * transcript knows how to render them.
15
+ */
16
+ export function isLlmMessage(message: AgentMessage): message is Message {
17
+ return message.role === "user" || message.role === "assistant" || message.role === "toolResult";
18
+ }
19
+
20
+ const USAGE_BEACON_PREFIX = "pi-usage-beacon/1 ";
21
+ const USAGE_BEACON_FAMILY_PREFIX = "pi-usage-beacon/";
22
+ const MAX_STDERR_BUFFER_CHARS = 65_536;
23
+
24
+ export function isValidContextTokens(value: unknown): value is number {
25
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
26
+ }
27
+
28
+ /** Parse a v1 beacon line; all malformed payloads are ordinary stderr diagnostics. */
29
+ function isRecord(value: unknown): value is Record<string, unknown> {
30
+ return !!value && typeof value === "object" && !Array.isArray(value);
31
+ }
32
+
33
+ export function isNonnegativeFiniteNumber(value: unknown): value is number {
34
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
35
+ }
36
+
37
+ export function isValidContentIndex(value: unknown): value is number {
38
+ return typeof value === "number"
39
+ && Number.isInteger(value)
40
+ && value >= 0
41
+ && value < MAX_STREAM_CONTENT_PARTS;
42
+ }
43
+
44
+ function isTextContent(value: unknown): boolean {
45
+ return isRecord(value) && value.type === "text" && typeof value.text === "string";
46
+ }
47
+
48
+ function isImageContent(value: unknown): boolean {
49
+ return isRecord(value)
50
+ && value.type === "image"
51
+ && typeof value.data === "string"
52
+ && typeof value.mimeType === "string";
53
+ }
54
+
55
+ function isThinkingContent(value: unknown): boolean {
56
+ return isRecord(value) && value.type === "thinking" && typeof value.thinking === "string";
57
+ }
58
+
59
+ function isToolCall(value: unknown): boolean {
60
+ return isRecord(value)
61
+ && value.type === "toolCall"
62
+ && typeof value.id === "string"
63
+ && typeof value.name === "string"
64
+ && isRecord(value.arguments);
65
+ }
66
+
67
+ function isWireMessage(value: unknown): boolean {
68
+ if (!isRecord(value) || typeof value.role !== "string") return false;
69
+ if (value.usage !== undefined && !isRecord(value.usage)) return false;
70
+ if (value.role === "user") {
71
+ return typeof value.content === "string"
72
+ || (Array.isArray(value.content) && value.content.every((part) => isTextContent(part) || isImageContent(part)));
73
+ }
74
+ if (value.role === "assistant") {
75
+ return Array.isArray(value.content)
76
+ && value.content.every((part) => isTextContent(part) || isThinkingContent(part) || isToolCall(part));
77
+ }
78
+ if (value.role === "toolResult") {
79
+ return Array.isArray(value.content)
80
+ && value.content.every((part) => isTextContent(part) || isImageContent(part))
81
+ && typeof value.toolCallId === "string"
82
+ && typeof value.toolName === "string"
83
+ && typeof value.isError === "boolean";
84
+ }
85
+ return false;
86
+ }
87
+
88
+ /** Parse and minimally validate one child JSON-protocol event. */
89
+ export function parseSessionEvent(line: string): JsonAgentSessionEvent | undefined {
90
+ try {
91
+ const parsed: unknown = JSON.parse(line);
92
+ if (!isRecord(parsed) || typeof parsed.type !== "string") return undefined;
93
+ if (parsed.type === "message_start" || parsed.type === "message_end") {
94
+ return isWireMessage(parsed.message) ? parsed as unknown as JsonAgentSessionEvent : undefined;
95
+ }
96
+ if (parsed.type === "message_update") {
97
+ const assistantEvent = parsed.assistantMessageEvent;
98
+ if (assistantEvent === undefined || assistantEvent === null) return parsed as unknown as JsonAgentSessionEvent;
99
+ if (!isRecord(assistantEvent) || typeof assistantEvent.type !== "string") return undefined;
100
+ if ("contentIndex" in assistantEvent && !isValidContentIndex(assistantEvent.contentIndex)) return undefined;
101
+ if (assistantEvent.type === "text_delta" || assistantEvent.type === "thinking_delta") {
102
+ if (typeof assistantEvent.delta !== "string" || !isValidContentIndex(assistantEvent.contentIndex)) return undefined;
103
+ }
104
+ if (assistantEvent.type === "toolcall_end") {
105
+ if (!isValidContentIndex(assistantEvent.contentIndex) || !isToolCall(assistantEvent.toolCall)) return undefined;
106
+ }
107
+ return parsed as unknown as JsonAgentSessionEvent;
108
+ }
109
+ if (parsed.type === "tool_execution_start" && typeof parsed.toolName !== "string") return undefined;
110
+ return parsed as unknown as JsonAgentSessionEvent;
111
+ } catch {
112
+ return undefined;
113
+ }
114
+ }
115
+
116
+ export function parseUsageBeacon(line: string): number | undefined {
117
+ const normalized = line.endsWith("\r") ? line.slice(0, -1) : line;
118
+ if (!normalized.startsWith(USAGE_BEACON_PREFIX)) return undefined;
119
+ try {
120
+ const payload: unknown = JSON.parse(normalized.slice(USAGE_BEACON_PREFIX.length));
121
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
122
+ const record = payload as Record<string, unknown>;
123
+ return isValidContextTokens(record.totalTokens) ? record.totalTokens : undefined;
124
+ } catch {
125
+ return undefined;
126
+ }
127
+ }
128
+
129
+ // Silently drop unsupported versions so a newer producer cannot flood failure diagnostics.
130
+ function isUnknownUsageBeacon(line: string): boolean {
131
+ const normalized = line.endsWith("\r") ? line.slice(0, -1) : line;
132
+ return normalized.startsWith(USAGE_BEACON_FAMILY_PREFIX) && !normalized.startsWith(USAGE_BEACON_PREFIX);
133
+ }
134
+
135
+ /** Splits stderr without letting recognized machine beacons grow diagnostic output. */
136
+ export class StderrBeaconReader {
137
+ #buffer = "";
138
+
139
+ push(chunk: string, flush = false): { beacons: number[]; diagnostics: string } {
140
+ this.#buffer += chunk;
141
+ const lines = this.#buffer.split("\n");
142
+ this.#buffer = (lines.pop() || "").slice(-MAX_STDERR_BUFFER_CHARS);
143
+ if (flush && this.#buffer) {
144
+ lines.push(this.#buffer);
145
+ this.#buffer = "";
146
+ }
147
+
148
+ const beacons: number[] = [];
149
+ let diagnostics = "";
150
+ for (let index = 0; index < lines.length; index++) {
151
+ const line = lines[index];
152
+ const beacon = parseUsageBeacon(line);
153
+ if (beacon !== undefined) {
154
+ beacons.push(beacon);
155
+ } else if (!isUnknownUsageBeacon(line)) {
156
+ const isFinalTail = flush && index === lines.length - 1 && !chunk.endsWith("\n");
157
+ diagnostics += line + (isFinalTail ? "" : "\n");
158
+ }
159
+ }
160
+ return { beacons, diagnostics };
161
+ }
162
+ }
163
+
164
+ export function reconcileContextTokens(
165
+ current: number | undefined,
166
+ authoritative: boolean,
167
+ totalTokens: number,
168
+ source: "beacon" | "message_end",
169
+ ): { contextTokens: number; authoritative: boolean; changed: boolean } {
170
+ if (source === "beacon") {
171
+ if (authoritative || (current !== undefined && totalTokens < current)) {
172
+ return { contextTokens: current ?? totalTokens, authoritative, changed: false };
173
+ }
174
+ return { contextTokens: totalTokens, authoritative: false, changed: current !== totalTokens };
175
+ }
176
+ return { contextTokens: totalTokens, authoritative: true, changed: current !== totalTokens };
177
+ }
@@ -0,0 +1,30 @@
1
+ import { isKeyRelease, isKeyRepeat, matchesKey, type KeyId } from "@earendil-works/pi-tui";
2
+
3
+ type KittyFunctionKeyTerminator = "P" | "Q" | "R" | "S";
4
+
5
+ export const FUNCTION_KEY_KITTY_TERMINATORS: Partial<Record<KeyId, KittyFunctionKeyTerminator>> = {
6
+ f1: "P",
7
+ f2: "Q",
8
+ f3: "R",
9
+ f4: "S",
10
+ } as const;
11
+
12
+ const KITTY_FUNCTION_KEY_SEQUENCE = /^\x1b\[1(?:;(\d+))?(?::(\d+))?([PQRS])$/;
13
+
14
+ export function matchesFunctionKeyPress(data: string, key: KeyId): boolean {
15
+ if (isKeyRelease(data) || isKeyRepeat(data)) return false;
16
+ if (/:[23][PQRS]$/.test(data)) return false;
17
+ if (matchesKey(data, key)) return true;
18
+
19
+ // Compatibility shim for pi-tui's Kitty F1-F4 gap; remove once upstream supports these aliases.
20
+ const terminator = FUNCTION_KEY_KITTY_TERMINATORS[key];
21
+ if (terminator === undefined) return false;
22
+
23
+ const match = KITTY_FUNCTION_KEY_SEQUENCE.exec(data);
24
+ return (
25
+ match !== null &&
26
+ match[3] === terminator &&
27
+ (match[1] === undefined || match[1] === "1") &&
28
+ (match[2] === undefined || match[2] === "1")
29
+ );
30
+ }
@@ -0,0 +1,91 @@
1
+ export interface ModelRef {
2
+ provider: string;
3
+ id: string;
4
+ }
5
+
6
+ export type SendUserMessageOptions = { deliverAs?: "steer" | "followUp"; triggerTurn?: boolean };
7
+ export const TRIGGER_TURN: SendUserMessageOptions = { triggerTurn: true };
8
+ export const FOLLOW_UP: SendUserMessageOptions = { deliverAs: "followUp" };
9
+
10
+ /**
11
+ * pi's thinking-level vocabulary, in pi's canonical order (see
12
+ * `pi.getThinkingLevel()` / `--thinking <level>`). `xhigh` and `max` are
13
+ * distinct levels a model may expose independently, so neither is folded
14
+ * into the other.
15
+ */
16
+ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
17
+ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
18
+
19
+ export function isThinkingLevel(value: string): value is ThinkingLevel {
20
+ return (THINKING_LEVELS as readonly string[]).includes(value);
21
+ }
22
+
23
+ export function isModelRef(value: unknown): value is ModelRef {
24
+ return !!value
25
+ && typeof value === "object"
26
+ && typeof (value as ModelRef).provider === "string"
27
+ && typeof (value as ModelRef).id === "string";
28
+ }
29
+
30
+ export function modelRefLabel(ref: ModelRef): string {
31
+ return `${ref.provider}/${ref.id}`;
32
+ }
33
+
34
+ export function parseRef(value: string): ModelRef {
35
+ const idx = value.indexOf("/");
36
+ if (idx === -1) return { provider: value, id: "" };
37
+ return { provider: value.slice(0, idx), id: value.slice(idx + 1) };
38
+ }
39
+
40
+ /**
41
+ * Anonymous slot label substituted for the real `modelRefLabel(ref)` in the
42
+ * synthesizer's input ONLY. Blinding prevents the synthesizer from favoring a
43
+ * proposal because of which model family/provider wrote it (self-preference
44
+ * bias), so proposals are judged purely on merit.
45
+ *
46
+ * The synthesizer's plan body refers to proposers as `Proposer 1`, `Proposer 2`,
47
+ * … which the user maps by index to the picker's `Proposer N` slot labels and
48
+ * the plan-review overlay's `P1`/`P2`/`P3` title-bar row (both render the real
49
+ * `ModelRef` from `MfPlanInfo`). If the picker's slot labeling ever changes,
50
+ * keep this in sync so the body↔overlay mapping stays correct by construction.
51
+ */
52
+ export function proposerBlindedLabel(slotIndex: number): string {
53
+ return `Proposer ${slotIndex + 1}`;
54
+ }
55
+
56
+ const MODEL_ATTR_SEGMENTS = new Set([
57
+ "pro", "plus", "max", "ultra", "mini", "flash", "turbo", "coder", "instruct",
58
+ "preview", "lite", "base", "sonnet", "opus", "haiku", "thinking", "reasoning",
59
+ ]);
60
+ const CLAUDE_FAMILY_SEGMENTS = new Set(["sonnet", "opus", "haiku"]);
61
+
62
+ function isVersionOrAttrSegment(segment: string): boolean {
63
+ const seg = segment.toLowerCase();
64
+ if (MODEL_ATTR_SEGMENTS.has(seg)) return true;
65
+ if (/^v\d/.test(seg)) return true;
66
+ if (/^k\d/.test(seg)) return true;
67
+ if (/^[a-z]\d+(\.\d+)*$/i.test(seg)) return true;
68
+ if (/^\d+(\.\d+)*$/.test(seg)) return true;
69
+ return false;
70
+ }
71
+
72
+ function modelIdBase(ref: ModelRef): string {
73
+ const slashIdx = ref.id.lastIndexOf("/");
74
+ return slashIdx >= 0 ? ref.id.slice(slashIdx + 1) : ref.id;
75
+ }
76
+
77
+ /** Short display name for MoA overlay (e.g. "claude-opus-4-6" → "opus", "deepseek-v4-pro" → "deepseek"). */
78
+ export function shortModelName(ref: ModelRef): string {
79
+ const base = modelIdBase(ref);
80
+ const segments = base.split("-").filter(Boolean);
81
+ // Claude's family is its useful compact identifier; "claude" only names
82
+ // the product and does not distinguish Opus, Sonnet, and Haiku.
83
+ if (segments[0]?.toLowerCase() === "claude") {
84
+ const family = segments.find((segment) => CLAUDE_FAMILY_SEGMENTS.has(segment.toLowerCase()));
85
+ if (family) return family.toLowerCase();
86
+ }
87
+ const kept = segments.filter((s) => !isVersionOrAttrSegment(s));
88
+ let name = kept.length > 0 ? kept.join("-") : base;
89
+ name = name.replace(/\d+(\.\d+)*$/, "");
90
+ return name.toLowerCase() || base;
91
+ }
@@ -0,0 +1,84 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+
3
+ const CONTEXT_BAR_WIDTH = 10;
4
+ const PERCENT_PAD_WIDTH = 6;
5
+ export const CTX_COL_WIDTH = 14;
6
+ const EIGHTH_BLOCKS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"] as const;
7
+ /** Badge an agent as looping once its current activity has recurred at least this many times in the window. */
8
+ export const LOOP_THRESHOLD = 3;
9
+
10
+ /**
11
+ * How many times the current activity recurs in the recent history window.
12
+ * At or above LOOP_THRESHOLD the agent is likely stuck looping on the same
13
+ * tool call. Read by the cancel overlay, which badges the looping agent.
14
+ */
15
+ export function activityLoopCount(activity: string | undefined, history: string[] | undefined): number {
16
+ if (!activity) return 0;
17
+ return (history ?? []).filter((a) => a === activity).length;
18
+ }
19
+
20
+ /**
21
+ * Render a live context-usage bar: how much of the model's context window the
22
+ * agent's latest turn is consuming (`contextTokens / contextWindow`). Shows a
23
+ * tenths-precision percentage padded to 6 chars, followed by a 10-cell bar
24
+ * using 1/8-precision Unicode block fill. Unknown window keeps the bracket
25
+ * aligned with a leading 6-space placeholder.
26
+ *
27
+ * When a `theme` is provided, fills use the accent color and unfilled cells
28
+ * use a dim foreground to visually distinguish the unfilled region.
29
+ */
30
+ export function usageBar(contextTokens: number | undefined, contextWindow: number | undefined, theme?: Theme): string {
31
+ if (contextTokens === undefined || !contextWindow || contextWindow <= 0) {
32
+ const bar = `${" ".repeat(PERCENT_PAD_WIDTH)}[${"?".repeat(CONTEXT_BAR_WIDTH)}]`;
33
+ return theme ? theme.fg("dim", bar) : bar;
34
+ }
35
+ const used = contextTokens && contextTokens > 0 ? contextTokens : 0;
36
+ const ratio = Math.max(0, Math.min(1, used / contextWindow));
37
+ const pctStr = `${(ratio * 100).toFixed(1)}%`;
38
+ const paddedPct = pctStr.padStart(PERCENT_PAD_WIDTH);
39
+ const filledEighths = Math.round(ratio * CONTEXT_BAR_WIDTH * 8);
40
+ const fullChars = Math.floor(filledEighths / 8);
41
+ const remainder = filledEighths % 8;
42
+ const empty = CONTEXT_BAR_WIDTH - fullChars - (remainder > 0 ? 1 : 0);
43
+ const filled = `${"█".repeat(fullChars)}${EIGHTH_BLOCKS[remainder]}`;
44
+ const unfilled = `${"░".repeat(empty)}`;
45
+ if (theme) {
46
+ return `${theme.fg("text", paddedPct)} [${theme.fg("accent", filled)}${theme.fg("dim", unfilled)}]`;
47
+ }
48
+ return `${paddedPct} [${filled}${unfilled}]`;
49
+ }
50
+
51
+ /** Format a token count compactly, using lowercase `k` and `m` suffixes. */
52
+ export function formatTokens(tokens: number): string {
53
+ const count = Number.isFinite(tokens) ? Math.max(0, Math.round(tokens)) : 0;
54
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}m`;
55
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
56
+ return String(count);
57
+ }
58
+
59
+ /** Format a context window in tokens as `X.XM` or `X.XK`, or raw if tiny. */
60
+ function formatWindowLabel(window: number): string {
61
+ if (window >= 1_000_000) return `${(window / 1_000_000).toFixed(1)}M`;
62
+ if (window >= 1_000) return `${(window / 1_000).toFixed(1)}K`;
63
+ return String(window);
64
+ }
65
+
66
+ /** Context usage as a right-aligned `${pct}/${windowLabel}`, or dashes when the window is unknown. */
67
+ export function contextPercent(contextTokens: number | undefined, contextWindow: number | undefined): string {
68
+ if (contextTokens === undefined || !contextWindow || contextWindow <= 0) return "—".padStart(CTX_COL_WIDTH);
69
+ const used = contextTokens && contextTokens > 0 ? contextTokens : 0;
70
+ const ratio = Math.max(0, Math.min(1, used / contextWindow));
71
+ return `${(ratio * 100).toFixed(1)}%/${formatWindowLabel(contextWindow)}`.padStart(CTX_COL_WIDTH);
72
+ }
73
+
74
+ /** Format cumulative model cost in the same precision as pi's usage footer. */
75
+ export function formatCost(costUsd: number | undefined): string {
76
+ if (costUsd === undefined || !Number.isFinite(costUsd)) return "—";
77
+ return `$${Math.max(0, costUsd).toFixed(3)}`;
78
+ }
79
+
80
+ /** Elapsed time as `M:SS`. Minutes keep counting past 60 rather than rolling into an hours field. */
81
+ export function formatElapsed(ms: number): string {
82
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
83
+ return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, "0")}`;
84
+ }
@@ -0,0 +1,112 @@
1
+ import type { Message } from "@earendil-works/pi-ai";
2
+ import { wrapWords } from "./chrome.ts";
3
+
4
+ export const CONTENT_SEPARATOR = "\n\n---\n\n";
5
+
6
+ const MAX_TOOL_RESULT_CHARS = 2_000;
7
+ const MAX_PREVIEW_MESSAGES = 6;
8
+ const MAX_PREVIEW_CHARS = 4_000;
9
+
10
+ /** Keep the end of text (most recent output) when over budget. */
11
+ export function truncateFromStart(text: string, max: number): string {
12
+ return text.length > max ? `…${text.slice(text.length - max + 1)}` : text;
13
+ }
14
+
15
+ /** Keep the start of text when over budget (e.g. short tool args). */
16
+ function truncateFromEnd(text: string, max: number): string {
17
+ return text.length > max ? `${text.slice(0, Math.max(0, max - 1))}…` : text;
18
+ }
19
+
20
+ /** Drop oldest message blocks first, then tail-truncate if one block alone exceeds budget. */
21
+ export function formatContentWithSlidingWindow(parts: string[], maxChars: number): string {
22
+ if (parts.length === 0) return "";
23
+
24
+ const suffixLengths = new Array<number>(parts.length);
25
+ for (let index = parts.length - 1; index >= 0; index--) {
26
+ suffixLengths[index] = parts[index].length
27
+ + (index < parts.length - 1 ? CONTENT_SEPARATOR.length + suffixLengths[index + 1] : 0);
28
+ }
29
+
30
+ let start = 0;
31
+ while (start < parts.length - 1) {
32
+ const prefixLength = start > 0 ? 1 + CONTENT_SEPARATOR.length : 0;
33
+ if (prefixLength + suffixLengths[start] <= maxChars) break;
34
+ start++;
35
+ }
36
+ let body = parts.slice(start).join(CONTENT_SEPARATOR);
37
+ if (start > 0) body = `…${CONTENT_SEPARATOR}${body}`;
38
+ return body.length <= maxChars ? body : truncateFromStart(body, maxChars);
39
+ }
40
+
41
+ function formatToolArgs(args: unknown): string {
42
+ if (args === undefined) return "";
43
+ try {
44
+ return truncateFromEnd(JSON.stringify(args), 300);
45
+ } catch {
46
+ return "";
47
+ }
48
+ }
49
+
50
+ export function formatMessage(message: Message): string {
51
+ if (message.role === "assistant") {
52
+ const parts: string[] = [];
53
+ for (const part of message.content) {
54
+ if (part.type === "text" && part.text) parts.push(part.text);
55
+ else if (part.type === "thinking" && part.thinking) parts.push(`#### Thinking\n\n_${part.thinking}_`);
56
+ else if (part.type === "toolCall") {
57
+ const args = formatToolArgs(part.arguments);
58
+ parts.push(`> 🔧 **${part.name}**${args ? ` ${args}` : ""}`);
59
+ }
60
+ }
61
+ return parts.join("\n\n");
62
+ }
63
+ if (message.role === "toolResult") {
64
+ const result = typeof message.content === "string"
65
+ ? message.content
66
+ : message.content.map((part) => part.type === "text" ? part.text : "").join("\n");
67
+ return `#### 📋 ${message.toolName}\n\n\`\`\`\n${truncateFromStart(result, MAX_TOOL_RESULT_CHARS)}\n\`\`\``;
68
+ }
69
+ return "";
70
+ }
71
+
72
+ export function transcriptBody(
73
+ messages: Message[],
74
+ partial: Message | undefined,
75
+ state: "working" | "done" | "error" | "cancelled",
76
+ maxChars: number,
77
+ ): string {
78
+ const contentParts = messages.map(formatMessage).filter(Boolean);
79
+ if (partial) {
80
+ const formattedPartial = formatMessage(partial);
81
+ if (formattedPartial) contentParts.push(`${formattedPartial}\n\n_(generating…)_`);
82
+ }
83
+ if (contentParts.length === 0) {
84
+ return state === "error" ? "_(agent failed — no output)_" : "_(waiting for first response…)_";
85
+ }
86
+ return formatContentWithSlidingWindow(contentParts, maxChars);
87
+ }
88
+
89
+ function stripTerminalControls(text: string): string {
90
+ return text
91
+ .replace(/\x1b\][^\x07]*(?:\x07|$)/g, "")
92
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
93
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
94
+ }
95
+
96
+ export function formatAgentPreview(
97
+ messages: Message[],
98
+ partial: Message | undefined,
99
+ width: number,
100
+ maxLines: number,
101
+ ): string[] {
102
+ if (width <= 0 || maxLines <= 0) return [];
103
+ const recentMessages = messages.slice(-MAX_PREVIEW_MESSAGES);
104
+ const body = stripTerminalControls(transcriptBody(recentMessages, partial, "working", MAX_PREVIEW_CHARS));
105
+ const generatingMarker = "_(generating…)_";
106
+ const generating = body.endsWith(generatingMarker);
107
+ const content = generating ? body.slice(0, -generatingMarker.length).trimEnd() : body;
108
+ const wrapped = wrapWords(content, width);
109
+ if (generating) wrapped.push("(generating…)");
110
+ const visible = wrapped.slice(-maxLines);
111
+ return [...Array<string>(Math.max(0, maxLines - visible.length)).fill(""), ...visible];
112
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Cancel overlay — interactive list of in-flight subagents.
3
+ *
4
+ * Opened by ESC during MoA orchestration (or F4 in either planning
5
+ * path). Lists each agent with its live state, tool activity, loop badge and
6
+ * context bar, plus a "Cancel ALL" entry. Enter cancels the selected agent
7
+ * (the overlay stays open so more can be cancelled / the row can be watched
8
+ * flipping to ✗); selecting Cancel ALL aborts the whole run and closes.
9
+ *
10
+ * The component renders directly from `session.run` on every frame (driven by
11
+ * the 100ms spinner interval), so it never shows stale rows and transparently
12
+ * survives the orchestrator swapping `session.run` between phases (fan-out →
13
+ * synthesis) while it is open.
14
+ */
15
+
16
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
17
+ import { Key, matchesKey, type Component, type TUI } from "@earendil-works/pi-tui";
18
+ import type { CancelSession } from "../runtime/cancelRun.ts";
19
+ import { LOOP_THRESHOLD, usageBar } from "./agentStatus.ts";
20
+ import {
21
+ BRAILLE_SPINNER_FRAMES,
22
+ ROUNDED_SINGLE_BOX,
23
+ SELECTOR,
24
+ UI_TICK_MS,
25
+ createFrame,
26
+ } from "./chrome.ts";
27
+ import { highlightActivity } from "./toolActivity.ts";
28
+
29
+ const OVERLAY_MARGIN = 1;
30
+
31
+ /** Show the cancel overlay for the session's live run. Resolves when closed. */
32
+ export async function showCancelOverlay(ctx: ExtensionContext, session: CancelSession): Promise<void> {
33
+ if (ctx.mode !== "tui") return;
34
+ await ctx.ui.custom<void>(
35
+ (tui, theme, _keybindings, done) => new CancelOverlayComponent(tui, theme, session, done),
36
+ {
37
+ overlay: true,
38
+ overlayOptions: {
39
+ anchor: "center",
40
+ width: "70%",
41
+ minWidth: 50,
42
+ maxHeight: "70%",
43
+ margin: OVERLAY_MARGIN,
44
+ },
45
+ },
46
+ );
47
+ }
48
+
49
+ class CancelOverlayComponent implements Component {
50
+ private sel = 0;
51
+ private spinFrame = 0;
52
+ private closed = false;
53
+ private readonly timer: ReturnType<typeof setInterval>;
54
+
55
+ constructor(
56
+ private readonly tui: TUI,
57
+ private readonly theme: Theme,
58
+ private readonly session: CancelSession,
59
+ private readonly done: (value: void) => void,
60
+ ) {
61
+ this.timer = setInterval(() => {
62
+ this.spinFrame = (this.spinFrame + 1) % BRAILLE_SPINNER_FRAMES.length;
63
+ tui.requestRender();
64
+ }, UI_TICK_MS);
65
+ session.closeOverlay = () => this.finish();
66
+ }
67
+
68
+ private finish(): void {
69
+ if (this.closed) return;
70
+ this.closed = true;
71
+ this.done(undefined);
72
+ }
73
+
74
+ /** Agent rows + the trailing "Cancel ALL" row. */
75
+ private rowCount(): number {
76
+ return (this.session.run?.agents.length ?? 0) + 1;
77
+ }
78
+
79
+ handleInput(data: string): void {
80
+ if (matchesKey(data, Key.escape) || data === "q" || data === "Q") {
81
+ this.finish();
82
+ return;
83
+ }
84
+ if (matchesKey(data, Key.up) || data === "k") {
85
+ this.sel = Math.max(0, this.sel - 1);
86
+ this.tui.requestRender();
87
+ return;
88
+ }
89
+ if (matchesKey(data, Key.down) || data === "j") {
90
+ this.sel = Math.min(this.rowCount() - 1, this.sel + 1);
91
+ this.tui.requestRender();
92
+ return;
93
+ }
94
+ if (matchesKey(data, Key.enter)) {
95
+ const run = this.session.run;
96
+ if (!run) {
97
+ this.finish();
98
+ return;
99
+ }
100
+ if (this.sel >= run.agents.length) {
101
+ run.cancelAll();
102
+ this.finish();
103
+ return;
104
+ }
105
+ // No-op unless the agent is still running, so an Enter racing an
106
+ // agent's completion is harmless. Stay open to cancel more.
107
+ run.cancel(this.sel);
108
+ this.tui.requestRender();
109
+ }
110
+ }
111
+
112
+ render(width: number): string[] {
113
+ const run = this.session.run;
114
+ const agents = run?.agents ?? [];
115
+ this.sel = Math.max(0, Math.min(this.sel, agents.length));
116
+
117
+ const innerWidth = Math.max(20, width - 4);
118
+ const th = this.theme;
119
+ const frame = createFrame(th, innerWidth, {
120
+ glyphs: ROUNDED_SINGLE_BOX,
121
+ horizontalPadding: 1,
122
+ truncationMark: "…",
123
+ padToWidth: true,
124
+ minimumBodyWidth: 10,
125
+ });
126
+ const top = frame.top();
127
+ const sep = frame.separator();
128
+ const bottom = frame.bottom();
129
+ const row = frame.row;
130
+ const spin = th.fg("accent", BRAILLE_SPINNER_FRAMES[this.spinFrame % BRAILLE_SPINNER_FRAMES.length]!);
131
+
132
+ const runningCount = agents.filter((a) => a.state === "running").length;
133
+ const lines: string[] = [
134
+ top,
135
+ row(th.fg("accent", "Cancel agents?") + th.fg("dim", ` ${this.session.title} · ${runningCount} running`)),
136
+ sep,
137
+ ];
138
+
139
+ if (agents.length === 0) {
140
+ lines.push(row(th.fg("dim", "No agents in flight.")));
141
+ }
142
+
143
+ for (let i = 0; i < agents.length; i++) {
144
+ const agent = agents[i];
145
+ const selected = i === this.sel;
146
+ const marker = selected ? th.fg("accent", SELECTOR) : " ";
147
+ const icon =
148
+ agent.state === "done" ? th.fg("success", "✓")
149
+ : agent.state === "error" || agent.state === "cancelled" ? th.fg("error", "✗")
150
+ : spin;
151
+ const stateText =
152
+ agent.state === "running" ? "running"
153
+ : agent.state === "cancelling" ? "cancelling…"
154
+ : agent.state;
155
+ const extras = this.session.getExtras?.(i);
156
+ const bar = extras && (extras.contextTokens !== undefined || extras.contextWindow !== undefined)
157
+ ? `${usageBar(extras.contextTokens, extras.contextWindow, this.theme)} `
158
+ : "";
159
+ const label = selected ? th.fg("text", agent.label) : agent.label;
160
+ lines.push(row(`${marker} ${icon} ${bar}${label} ${th.fg("dim", stateText)}`));
161
+
162
+ if (extras?.activity && (agent.state === "running" || agent.state === "cancelling")) {
163
+ const loop = (extras.loopCount ?? 0) >= LOOP_THRESHOLD
164
+ ? th.fg("warning", ` (↻ ${extras.loopCount}×)`)
165
+ : "";
166
+ const gutter = th.fg("dim", " ↳ ");
167
+ const activity = highlightActivity(th, extras.activity);
168
+ lines.push(row(`${gutter}${activity}${loop}`));
169
+ }
170
+ }
171
+
172
+ lines.push(sep);
173
+ const allSelected = this.sel === agents.length;
174
+ const allMarker = allSelected ? th.fg("accent", SELECTOR) : " ";
175
+ const allLabel = allSelected
176
+ ? th.fg("error", "Cancel ALL running agents")
177
+ : "Cancel ALL running agents";
178
+ lines.push(row(`${allMarker} ${allLabel}`));
179
+ lines.push(sep);
180
+ lines.push(row(th.fg("dim", "↑↓/jk select · enter cancel · esc close")));
181
+ lines.push(bottom);
182
+ return lines;
183
+ }
184
+
185
+ invalidate(): void {}
186
+
187
+ dispose(): void {
188
+ clearInterval(this.timer);
189
+ if (this.session.closeOverlay) this.session.closeOverlay = undefined;
190
+ }
191
+ }