@underactive/pi-topping-moa-fusion 0.4.4 → 0.5.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.
@@ -5,6 +5,7 @@ import type {
5
5
  AgentBeforeSettleEvent,
6
6
  AgentSettledEvent,
7
7
  AgentStartEvent,
8
+ BeforeAgentStartEvent,
8
9
  ExtensionAPI,
9
10
  ExtensionContext,
10
11
  MessageEndEvent,
@@ -14,6 +15,8 @@ import type {
14
15
  ThemeColor,
15
16
  ToolExecutionStartEvent,
16
17
  } from "@earendil-works/pi-coding-agent";
18
+ import { OutputTokenAccount } from "@underactive/pi-topping-primitives/activity";
19
+ import { formatToolActivity } from "@underactive/pi-topping-primitives/activity-tui";
17
20
 
18
21
  import { installShippedAgents } from "../agents/authoritative.ts";
19
22
  import { summarizePlanPromptName } from "../config/planName.ts";
@@ -28,7 +31,7 @@ import { runImplementationVerification } from "../moa/verification.ts";
28
31
  import { applyImplementingSelection, resolveContextWindow, resolveModelCost } from "../moa/modelRuntime.ts";
29
32
  import { validateMfPlanInfo, type MfPlanInfo } from "../moa/planInfo.ts";
30
33
  import type { MoaRunHost } from "../moa/runContext.ts";
31
- import { formatToolActivity, OutputActivityTracker, PartialAssistantAssembler } from "../runtime/activityTracking.ts";
34
+ import { PartialAssistantAssembler } from "../runtime/activityTracking.ts";
32
35
  import type { CancelSession } from "../runtime/cancelRun.ts";
33
36
  import { cleanupTrackedProcesses } from "../runtime/processPool.ts";
34
37
  import type { UsageStats } from "../runtime/results.ts";
@@ -140,7 +143,7 @@ export function createPlanModeController(pi: ExtensionAPI) {
140
143
  // synthesis and review still own the table.
141
144
  let runningProgressWidget: MoaProgressWidget | undefined;
142
145
  // In-session implementer telemetry, reset each time implementation starts.
143
- let implementationOutputTracker: OutputActivityTracker | undefined;
146
+ let implementationOutputTracker: OutputTokenAccount | undefined;
144
147
  let implementationMessages: Message[] = [];
145
148
  const implementationPartial = new PartialAssistantAssembler();
146
149
  let implementationUsage: UsageStats | undefined;
@@ -469,7 +472,7 @@ export function createPlanModeController(pi: ExtensionAPI) {
469
472
  markImplementationPending: (ctx) => {
470
473
  footerError = undefined;
471
474
  implementationPending = true;
472
- implementationOutputTracker = new OutputActivityTracker();
475
+ implementationOutputTracker = new OutputTokenAccount();
473
476
  resetImplementationCounters();
474
477
  // Reconstruct the table for a resumed run that never adopted one (e.g.
475
478
  // /mf-plan-implement after a restart). In-run paths already hold the
@@ -739,7 +742,15 @@ export function createPlanModeController(pi: ExtensionAPI) {
739
742
  return !isPlanModeInstructionText(msg.content);
740
743
  }) };
741
744
  },
742
- onBeforeAgentStart: async () => {
745
+ onBeforeAgentStart: async (event?: Partial<Pick<BeforeAgentStartEvent, "messageType">>) => {
746
+ // pi >= 1.2 also fires this for runs another extension starts with
747
+ // sendMessage(..., { triggerTurn: true }). Those must neither receive plan
748
+ // instructions nor consume the one-shot exit reminder meant for the user's
749
+ // next prompt. Older hosts omit messageType and only fire for user runs.
750
+ if (event?.messageType === "custom") {
751
+ if (!planModeEnabled) deactivatePlanOnlyTools();
752
+ return;
753
+ }
743
754
  if (needsExitReminder) {
744
755
  needsExitReminder = false;
745
756
  // The reminder turn is also the first normal-mode turn: repair a
@@ -3,7 +3,7 @@ import { isKeyRelease, Key, matchesKey } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
4
 
5
5
  import { installShippedAgents } from "../../agents/authoritative.ts";
6
- import { loadMoaConfig, saveMoaConfig } from "../../config/settings.ts";
6
+ import { loadMoaConfig, moaSaveFailureMessage, updateMoaConfig } from "../../config/settings.ts";
7
7
  import { runMoaOrchestration } from "../../moa/orchestration.ts";
8
8
  import type { MfPlanInfo } from "../../moa/planInfo.ts";
9
9
  import type { MoaRunHost } from "../../moa/runContext.ts";
@@ -102,9 +102,9 @@ export async function runInteractivePlanMode(
102
102
  pi.sendUserMessage(trimmedPrompt, TRIGGER_TURN);
103
103
  return;
104
104
  }
105
- const settings = loadMoaConfig();
106
105
  const { proposers, synthesizer, implementer, verifier, proposerThinking, synthesizerThinking, implementerThinking, verifierThinking, thinkingSelections } = pickerResult;
107
- saveMoaConfig({ ...settings, mode: "moa", proposers, synthesizer, implementer, verifier, thinkingOverrides: { ...settings.thinkingOverrides, ...thinkingSelections } });
106
+ const saved = updateMoaConfig((settings) => ({ ...settings, mode: "moa", proposers, synthesizer, implementer, verifier, thinkingOverrides: { ...settings.thinkingOverrides, ...thinkingSelections } }));
107
+ if (!saved.ok) ctx.ui.notify(moaSaveFailureMessage(saved), "warning");
108
108
  host.setActiveRunMoaInfo({ proposers, synthesizer });
109
109
  const outcome = await runMoaOrchestration(host.moaRunHost, ctx, trimmedPrompt, proposers, synthesizer, proposerThinking, synthesizerThinking, session, { implementer, implementerThinking, verifier, verifierThinking });
110
110
  if (outcome === "cancelled") { session.closeOverlay?.(); continue; }
@@ -8,10 +8,10 @@ import * as path from "node:path";
8
8
  import { installShippedAgents, shippedAgentsDir, withAuthoritativeMoaAgents } from "../../agents/authoritative.ts";
9
9
  import { loadMoaConfig } from "../../config/settings.ts";
10
10
  import { discoverAgents, parseAgentFile } from "../../agents/discovery.ts";
11
- import { modelExtensionOptions } from "../../moa/modelRuntime.ts";
11
+ import { modelExtensionOptions, qualifyBareModelId } from "../../moa/modelRuntime.ts";
12
12
  import { CancelRun, type CancelRowExtras, type CancelSession } from "../../runtime/cancelRun.ts";
13
13
  import { MutationTripwire, formatMutationWarning } from "../../runtime/mutationTripwire.ts";
14
- import { getFinalOutput, getResultOutput, isFailedResult, truncateOutput } from "../../runtime/results.ts";
14
+ import { getResultOutput, isFailedResult, truncateOutput } from "../../runtime/results.ts";
15
15
  import { runParallelAgents, runSingleAgent } from "../../runtime/runner.ts";
16
16
  import { parseRef as parseModelRefLabel, type ThinkingLevel } from "../../shared/modelRefs.ts";
17
17
  import { activityLoopCount } from "../../ui/agentStatus.ts";
@@ -141,7 +141,16 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
141
141
  const planOverride = sessionModel
142
142
  ? { model: `${sessionModel.provider}/${sessionModel.id}`, thinking: host.currentThinkingLevel() }
143
143
  : undefined;
144
- const overrideFor = (agentName: string) => (agentName === "mf-plan" ? planOverride : undefined);
144
+ // Shipped and hand-edited frontmatter can carry a bare id, which pi >= 1.3.0
145
+ // rejects as ambiguous when several authenticated providers offer it. Qualify
146
+ // it here; thinking stays undefined so the frontmatter level still applies.
147
+ const frontmatterOverrideFor = (agentName: string): { model: string; thinking?: ThinkingLevel } | undefined => {
148
+ const model = agents.find((agent) => agent.name === agentName)?.model;
149
+ if (!model) return undefined;
150
+ const qualified = qualifyBareModelId(ctx.modelRegistry, model);
151
+ return qualified === model ? undefined : { model: qualified };
152
+ };
153
+ const overrideFor = (agentName: string) => (agentName === "mf-plan" && planOverride ? planOverride : frontmatterOverrideFor(agentName));
145
154
 
146
155
  // An agent whose effective model belongs to an extension-registered
147
156
  // provider (claude-bridge, cursor-bridge) or is an extension-registered virtual model
@@ -319,7 +328,7 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
319
328
  }
320
329
  return {
321
330
  details: undefined,
322
- content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
331
+ content: [{ type: "text", text: getResultOutput(result) }],
323
332
  structuredContent,
324
333
  };
325
334
  } finally {
@@ -1,96 +1,6 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
2
  import type { AssistantMessage, Message, ToolCall } from "@earendil-works/pi-ai";
3
- import { stripTerminalSequences } from "@earendil-works/pi-tui";
4
- import { StreamingWordCounter } from "../activityMeter.ts";
5
- import { isNonnegativeFiniteNumber, isValidContentIndex } from "./wire.ts";
6
-
7
- /** Max length of a one-line tool-activity description surfaced for progress UIs. */
8
- const ACTIVITY_MAX = 80;
9
-
10
- /** Collapse all whitespace/newlines to single spaces and trim. */
11
- function collapseWhitespace(text: string): string {
12
- return text.replace(/\s+/g, " ").trim();
13
- }
14
-
15
- /**
16
- * Pick the most informative string argument for a one-line activity label.
17
- * Prefers well-known tool arg names (command, pattern, path, …); falls back to
18
- * the first non-empty string value.
19
- */
20
- function summarizeToolArgs(args: unknown): string {
21
- if (!args || typeof args !== "object") return "";
22
- const record = args as Record<string, unknown>;
23
- const preferred = ["command", "pattern", "query", "path", "file", "glob", "dir", "url", "name"];
24
- for (const key of preferred) {
25
- const value = record[key];
26
- if (typeof value === "string" && value.trim()) return collapseWhitespace(value);
27
- }
28
- for (const value of Object.values(record)) {
29
- if (typeof value === "string" && value.trim()) return collapseWhitespace(value);
30
- }
31
- return "";
32
- }
33
-
34
- /** Format a `tool_execution_start` event into a single-line "toolName arg" label. */
35
- export function formatToolActivity(toolName: string, args: unknown): string {
36
- const detail = summarizeToolArgs(args);
37
- const label = detail ? `${toolName} ${detail}` : String(toolName);
38
- return stripTerminalSequences(collapseWhitespace(label).slice(0, ACTIVITY_MAX));
39
- }
40
-
41
- /** Cumulative generated-output reading for the fan-out activity monitor. */
42
- export interface OutputActivity {
43
- /** Tokens generated so far this run: confirmed turns plus the in-flight turn. */
44
- tokens: number;
45
- /**
46
- * Bumped whenever exact provider usage replaces a differing estimate.
47
- * Consumers reset their rate tracker on a change so the correction doesn't
48
- * register as a burst of generation that never happened.
49
- */
50
- revision: number;
51
- }
52
-
53
- export class OutputActivityTracker {
54
- #counter = new StreamingWordCounter();
55
- #confirmed = 0;
56
- #liveWords = 0;
57
- #revision = 0;
58
-
59
- /** Begin a new assistant turn, discarding only the previous turn's live estimate. */
60
- messageStart(message: { role?: string } | undefined): void {
61
- if (message?.role !== "assistant") return;
62
- this.#resetTurn();
63
- }
64
-
65
- messageUpdate(assistantEvent: { type?: string; delta?: string } | undefined): void {
66
- if (assistantEvent?.type !== "text_delta" && assistantEvent?.type !== "thinking_delta") return;
67
- if (typeof assistantEvent.delta !== "string") return;
68
- // Counted per stream kind so a word split across deltas isn't double
69
- // counted, and interleaved thinking/text streams don't corrupt each other.
70
- this.#liveWords += this.#counter.count(assistantEvent.delta, assistantEvent.type);
71
- }
72
-
73
- messageEnd(message: { role?: string; usage?: { output?: unknown } } | undefined): void {
74
- if (message?.role !== "assistant") return;
75
- const exact = message.usage?.output;
76
- if (isNonnegativeFiniteNumber(exact)) {
77
- this.#confirmed += exact;
78
- if (exact !== this.#liveWords) this.#revision++;
79
- } else {
80
- this.#confirmed += this.#liveWords;
81
- }
82
- this.#resetTurn();
83
- }
84
-
85
- snapshot(): OutputActivity {
86
- return { tokens: this.#confirmed + this.#liveWords, revision: this.#revision };
87
- }
88
-
89
- #resetTurn(): void {
90
- this.#liveWords = 0;
91
- this.#counter.reset();
92
- }
93
- }
3
+ import { isValidContentIndex } from "./wire.ts";
94
4
 
95
5
  type AssistantContentPart = AssistantMessage["content"][number];
96
6
 
@@ -2,34 +2,23 @@
2
2
  * Capability probing for the pi executable a child process will actually run.
3
3
  * The local `@earendil-works/pi-coding-agent` VERSION export describes the
4
4
  * package this extension was built against, not the host (or `pi` on PATH)
5
- * that `getPiInvocation()` spawns, so flag support is probed on the executable.
5
+ * that `resolvePiInvocation()` spawns, so flag support is probed on the executable.
6
6
  */
7
7
 
8
8
  import { execFile } from "node:child_process";
9
+ import {
10
+ type PiInvocation,
11
+ parsePiVersion,
12
+ versionAtLeast,
13
+ } from "@underactive/pi-topping-primitives/process";
9
14
 
10
- export interface PiInvocation {
11
- command: string;
12
- args: string[];
13
- }
15
+ export { parsePiVersion, versionAtLeast };
16
+ export type { PiInvocation };
14
17
 
15
18
  /** `--no-mcp` first ships in pi 1.0.4; 1.0.3 rejects it as an unknown option. */
16
19
  const NO_MCP_MIN_VERSION = [1, 0, 4] as const;
17
20
  const VERSION_PROBE_TIMEOUT_MS = 10_000;
18
21
 
19
- export function parsePiVersion(output: string): [number, number, number] | undefined {
20
- const match = /(\d+)\.(\d+)\.(\d+)/.exec(output);
21
- if (!match) return undefined;
22
- return [Number(match[1]), Number(match[2]), Number(match[3])];
23
- }
24
-
25
- export function versionAtLeast(version: readonly number[], minimum: readonly number[]): boolean {
26
- for (let i = 0; i < minimum.length; i++) {
27
- const diff = (version[i] ?? 0) - minimum[i];
28
- if (diff !== 0) return diff > 0;
29
- }
30
- return true;
31
- }
32
-
33
22
  export function versionSupportsNoMcp(output: string): boolean {
34
23
  const version = parsePiVersion(output);
35
24
  return version !== undefined && versionAtLeast(version, NO_MCP_MIN_VERSION);
@@ -1,57 +1,19 @@
1
- import { type ChildProcess } from "node:child_process";
1
+ import {
2
+ createProcessTracker,
3
+ escalateKill,
4
+ mapWithConcurrencyLimit,
5
+ } from "@underactive/pi-topping-primitives/process";
2
6
 
3
- /** Globally tracked spawned processes so /reload can kill them. */
4
- export const trackedProcesses: Set<ChildProcess> = new Set();
7
+ export { escalateKill, mapWithConcurrencyLimit };
5
8
 
6
- /** Send SIGTERM, then force a still-live child down after the grace period. */
7
- export function escalateKill(proc: ChildProcess, termTimeoutMs = 5000): void {
8
- if (proc.exitCode !== null || proc.signalCode !== null) return;
9
- try { proc.kill("SIGTERM"); } catch { return; }
10
- setTimeout(() => {
11
- if (proc.exitCode !== null || proc.signalCode !== null) return;
12
- try { proc.kill("SIGKILL"); } catch { /* ignore */ }
13
- }, termTimeoutMs).unref();
14
- }
9
+ /** This extension's spawned processes, so /reload kills only what moa-fusion started. */
10
+ export const trackedProcesses = createProcessTracker();
15
11
 
16
- /** Kill every tracked process. Safe to call multiple times. */
12
+ /**
13
+ * Kill every tracked process. Safe to call multiple times. The snapshot-then-clear ordering
14
+ * (the delayed SIGKILL pass still reaches the processes that were just signalled) lives in the
15
+ * package's tracker.
16
+ */
17
17
  export function cleanupTrackedProcesses(termTimeoutMs = 5000): void {
18
- // Snapshot targets before clearing so the delayed SIGKILL pass still
19
- // reaches the same processes that were just signalled.
20
- const processes = [...trackedProcesses];
21
- for (const proc of processes) {
22
- if (proc.exitCode !== null || proc.signalCode !== null) continue;
23
- try { proc.kill("SIGTERM"); } catch { /* ignore */ }
24
- }
25
- trackedProcesses.clear();
26
-
27
- // Give SIGTERM a moment, then SIGKILL any process that has not exited.
28
- setTimeout(() => {
29
- for (const proc of processes) {
30
- if (proc.exitCode !== null || proc.signalCode !== null) continue;
31
- try { proc.kill("SIGKILL"); } catch { /* ignore */ }
32
- }
33
- }, termTimeoutMs).unref();
34
- }
35
-
36
-
37
- export async function mapWithConcurrencyLimit<TIn, TOut>(
38
- items: TIn[],
39
- concurrency: number,
40
- fn: (item: TIn, index: number) => Promise<TOut>,
41
- onStart?: (index: number) => void,
42
- ): Promise<TOut[]> {
43
- if (items.length === 0) return [];
44
- const limit = Math.max(1, Math.min(concurrency, items.length));
45
- const results: TOut[] = new Array(items.length);
46
- let nextIndex = 0;
47
- const workers = new Array(limit).fill(null).map(async () => {
48
- while (true) {
49
- const current = nextIndex++;
50
- if (current >= items.length) return;
51
- onStart?.(current);
52
- results[current] = await fn(items[current], current);
53
- }
54
- });
55
- await Promise.all(workers);
56
- return results;
18
+ trackedProcesses.cleanup(termTimeoutMs);
57
19
  }
@@ -1,5 +1,5 @@
1
1
  import type { Message } from "@earendil-works/pi-ai";
2
- import type { OutputActivity } from "./activityTracking.ts";
2
+ import type { OutputTokenSnapshot } from "@underactive/pi-topping-primitives/activity";
3
3
 
4
4
  export interface UsageStats {
5
5
  input: number;
@@ -41,7 +41,7 @@ export interface SingleResult {
41
41
  * Distinct from `usage.output`, which only advances at turn boundaries and
42
42
  * stays at 0 for providers that never report usage.
43
43
  */
44
- outputActivity?: OutputActivity;
44
+ outputActivity?: OutputTokenSnapshot;
45
45
  /**
46
46
  * True when this agent was aborted (per-agent/user cancellation) and the
47
47
  * caller opted into `resolveOnAbort`. Cancelled results still count as
@@ -85,11 +85,21 @@ export function isFailedResult(result: { exitCode: number; stopReason?: string;
85
85
  );
86
86
  }
87
87
 
88
+ /**
89
+ * Prefixed to a child's output when its final response stopped at the model's
90
+ * output-token limit. Children run in `--mode json`, where pi exits 0 on a
91
+ * `length` stop (only text mode exits 1), so the reply counts as a success and
92
+ * this is the only sign that it was cut off. It leads rather than trails the
93
+ * output so `truncateOutput` cannot cut it away.
94
+ */
95
+ export const RESPONSE_TRUNCATED_WARNING = "[Response was truncated: the model reached its output token limit.]";
96
+
88
97
  export function getResultOutput(result: { exitCode: number; stopReason?: string; errorMessage?: string; stderr?: string; messages: Message[] }): string {
89
98
  if (isFailedResult(result)) {
90
99
  return result.errorMessage || result.stderr || getFinalOutput(result.messages) || "No agent messages were emitted.";
91
100
  }
92
- return getFinalOutput(result.messages) || "(no output)";
101
+ const output = getFinalOutput(result.messages) || "(no output)";
102
+ return result.stopReason === "length" ? `${RESPONSE_TRUNCATED_WARNING}\n\n${output}` : output;
93
103
  }
94
104
 
95
105
  /**
@@ -10,10 +10,13 @@ import * as os from "node:os";
10
10
  import * as path from "node:path";
11
11
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
12
12
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
13
+ import { OutputTokenAccount } from "@underactive/pi-topping-primitives/activity";
14
+ import { formatToolActivity } from "@underactive/pi-topping-primitives/activity-tui";
15
+ import { resolvePiInvocation } from "@underactive/pi-topping-primitives/process";
13
16
  import type { AgentConfig } from "../agents/discovery.ts";
14
17
  import { normalizeMaxConcurrentAgents } from "../config/settings.ts";
15
18
  import type { ThinkingLevel } from "../shared/modelRefs.ts";
16
- import { OutputActivityTracker, PartialAssistantAssembler, formatToolActivity } from "./activityTracking.ts";
19
+ import { PartialAssistantAssembler } from "./activityTracking.ts";
17
20
  import { piSupportsNoMcp, type PiInvocation } from "./piCapabilities.ts";
18
21
  import { escalateKill, mapWithConcurrencyLimit, trackedProcesses } from "./processPool.ts";
19
22
  import { getFinalOutput, type SingleResult } from "./results.ts";
@@ -71,22 +74,6 @@ export function getReadOnlyAgentTools(configuredTools?: string[]): string[] {
71
74
  return configuredTools.filter((tool) => READ_ONLY_AGENT_TOOL_SET.has(tool));
72
75
  }
73
76
 
74
- function getPiInvocation(args: string[]): PiInvocation {
75
- const currentScript = process.argv[1];
76
- const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
77
- if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
78
- return { command: process.execPath, args: [currentScript, ...args] };
79
- }
80
-
81
- const execName = path.basename(process.execPath).toLowerCase();
82
- const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
83
- if (!isGenericRuntime) {
84
- return { command: process.execPath, args };
85
- }
86
-
87
- return { command: "pi", args };
88
- }
89
-
90
77
  export interface RunSingleAgentOptions {
91
78
  /**
92
79
  * Load all configured pi extensions in the child process. Retained as a
@@ -201,7 +188,7 @@ export async function runSingleAgent(
201
188
  return currentResult;
202
189
  }
203
190
 
204
- const outputActivity = new OutputActivityTracker();
191
+ const outputActivity = new OutputTokenAccount();
205
192
  const partialMessage = new PartialAssistantAssembler();
206
193
 
207
194
  const emitUpdate = () => {
@@ -230,7 +217,7 @@ export async function runSingleAgent(
230
217
  // will run knows the flag. The default and isolated paths already pass
231
218
  // --no-extensions, which excludes built-in MCP.
232
219
  if (options.loadExtensions && !options.extensionPath) {
233
- const probe = options.piInvocation ?? getPiInvocation([]);
220
+ const probe = options.piInvocation ?? resolvePiInvocation([]);
234
221
  if (await piSupportsNoMcp(probe)) args.push("--no-mcp");
235
222
  }
236
223
 
@@ -240,7 +227,7 @@ export async function runSingleAgent(
240
227
  const exitCode = await new Promise<number>((resolve) => {
241
228
  const invocation = options.piInvocation
242
229
  ? { command: options.piInvocation.command, args: [...options.piInvocation.args, ...args] }
243
- : getPiInvocation(args);
230
+ : resolvePiInvocation(args);
244
231
 
245
232
  const appendDiagnostics = (diagnostics: string) => {
246
233
  currentResult.stderr = (currentResult.stderr + diagnostics).slice(-MAX_DIAGNOSTIC_CHARS);
@@ -1,42 +1,18 @@
1
- export interface ModelRef {
2
- provider: string;
3
- id: string;
4
- }
1
+ export {
2
+ isModelRef,
3
+ isThinkingLevel,
4
+ modelRefLabel,
5
+ parseRef,
6
+ shortModelName,
7
+ THINKING_LEVELS,
8
+ type ModelRef,
9
+ type ThinkingLevel,
10
+ } from "@underactive/pi-topping-primitives/models";
5
11
 
6
12
  export type SendUserMessageOptions = { deliverAs?: "steer" | "followUp"; triggerTurn?: boolean };
7
13
  export const TRIGGER_TURN: SendUserMessageOptions = { triggerTurn: true };
8
14
  export const FOLLOW_UP: SendUserMessageOptions = { deliverAs: "followUp" };
9
15
 
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
16
  /**
41
17
  * Anonymous slot label substituted for the real `modelRefLabel(ref)` in the
42
18
  * synthesizer's input ONLY. Blinding prevents the synthesizer from favoring a
@@ -52,40 +28,3 @@ export function parseRef(value: string): ModelRef {
52
28
  export function proposerBlindedLabel(slotIndex: number): string {
53
29
  return `Proposer ${slotIndex + 1}`;
54
30
  }
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
- }
@@ -71,14 +71,8 @@ export function contextPercent(contextTokens: number | undefined, contextWindow:
71
71
  return `${(ratio * 100).toFixed(1)}%/${formatWindowLabel(contextWindow)}`.padStart(CTX_COL_WIDTH);
72
72
  }
73
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
- }
74
+ /** Cumulative model cost in the same precision as pi's usage footer; `—` when unknown. Shared from the primitives package. */
75
+ export { formatCostUsd as formatCost } from "@underactive/pi-topping-primitives/text";
79
76
 
80
77
  /** 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
- }
78
+ export { formatElapsedClock as formatElapsed } from "@underactive/pi-topping-primitives/activity";
@@ -15,6 +15,7 @@
15
15
 
16
16
  import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
17
17
  import { Key, matchesKey, type Component, type TUI } from "@earendil-works/pi-tui";
18
+ import { highlightActivity } from "@underactive/pi-topping-primitives/activity-tui";
18
19
  import type { CancelSession } from "../runtime/cancelRun.ts";
19
20
  import { LOOP_THRESHOLD, usageBar } from "./agentStatus.ts";
20
21
  import {
@@ -24,7 +25,6 @@ import {
24
25
  UI_TICK_MS,
25
26
  createFrame,
26
27
  } from "./chrome.ts";
27
- import { highlightActivity } from "./toolActivity.ts";
28
28
 
29
29
  const OVERLAY_MARGIN = 1;
30
30
 
package/src/ui/chrome.ts CHANGED
@@ -3,8 +3,7 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
3
 
4
4
  export const UI_TICK_MS = 100;
5
5
 
6
- export const BRAILLE_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
7
- export const QUADRANT_SPINNER_FRAMES = ["◐", "◓", "◑", "◒"] as const;
6
+ export { SPINNER_FRAMES as BRAILLE_SPINNER_FRAMES, QUADRANT_SPINNER_FRAMES } from "@underactive/pi-topping-primitives/glyphs";
8
7
 
9
8
  export interface BoxGlyphs {
10
9
  topLeft: string;
@@ -10,8 +10,8 @@ import {
10
10
  ratioViewport,
11
11
  } from "./chrome.ts";
12
12
  import { getAvailableModelRefs } from "./moaModelPicker.ts";
13
- import { smartTruncateModelLabel } from "./modelLabel.ts";
14
- import { TwoPaneModelThinking } from "./twoPaneModelThinking.ts";
13
+ import { smartTruncateModelLabel } from "@underactive/pi-topping-primitives/models";
14
+ import { createMoaModelPicker, type TwoPaneModelThinking } from "./twoPaneModelThinking.ts";
15
15
 
16
16
  const HINT_BASE = "type filters models • ↑↓ navigate pane • tab panes/buttons • ←→ switch/select • enter select";
17
17
 
@@ -60,7 +60,7 @@ export class DebateModelPickerComponent implements Component {
60
60
  this.defaults = defaults;
61
61
  this.done = done;
62
62
  this.rounds = Math.min(MAX_DEBATE_ROUNDS, Math.max(MIN_DEBATE_ROUNDS, config.debateRounds));
63
- this.twoPane = new TwoPaneModelThinking(tui, theme, availableRefs, config, currentThinking, ctx);
63
+ this.twoPane = createMoaModelPicker(tui, theme, availableRefs, config, currentThinking, ctx);
64
64
  }
65
65
 
66
66
  private assignedCount(): number {
@@ -26,9 +26,9 @@
26
26
  * while that slot is still empty — they never count as assignments, so
27
27
  * readiness always reflects explicit selections.
28
28
  *
29
- * Models and thinking levels both come from the model registry (see
30
- * modelCatalogue.ts), so a confirmed selection is usable by construction and
31
- * nothing verifies it after the picker closes. Whether a model actually answers
29
+ * Models and thinking levels both come from the shared registry catalogue,
30
+ * so a confirmed selection is usable by construction and nothing verifies
31
+ * it after the picker closes. Whether a model actually answers
32
32
  * is decided when it runs.
33
33
  */
34
34
 
@@ -43,10 +43,9 @@ import {
43
43
  wrapWords,
44
44
  } from "./chrome.ts";
45
45
  import { PLAN_OVERLAY_OPTIONS } from "./menu.ts";
46
- import { TwoPaneModelThinking } from "./twoPaneModelThinking.ts";
47
- import { smartTruncateModelLabel } from "./modelLabel.ts";
46
+ import { createMoaModelPicker, type TwoPaneModelThinking } from "./twoPaneModelThinking.ts";
47
+ import { getModelCatalogue, smartTruncateModelLabel } from "@underactive/pi-topping-primitives/models";
48
48
  import { proposerDiversityWarning } from "../moa/panelDependence.ts";
49
- import { getModelCatalogue } from "../config/modelCatalogue.ts";
50
49
  import {
51
50
  loadMoaConfig,
52
51
  moaSettingsExist,
@@ -171,7 +170,7 @@ class MoaModelPickerComponent implements Component {
171
170
  ].filter((slot) => this.availableLabels.has(modelRefLabel(slot.ref))).length,
172
171
  }))
173
172
  .sort((a, b) => a.roster.name.localeCompare(b.roster.name));
174
- this.twoPane = new TwoPaneModelThinking(tui, theme, availableRefs, config, currentThinking, ctx);
173
+ this.twoPane = createMoaModelPicker(tui, theme, availableRefs, config, currentThinking, ctx);
175
174
  }
176
175
 
177
176
  /** The committed model/thinking pair for a slot picker screen, if assigned. */
@@ -811,7 +810,7 @@ class ImplementingModelPickerComponent implements Component {
811
810
  private readonly title: string,
812
811
  private readonly done: (result: { ref: ModelRef; thinking: ThinkingLevel } | undefined) => void,
813
812
  ) {
814
- this.twoPane = new TwoPaneModelThinking(tui, theme, availableRefs, config, currentThinking, ctx);
813
+ this.twoPane = createMoaModelPicker(tui, theme, availableRefs, config, currentThinking, ctx);
815
814
  this.twoPane.reset(defaultRef, defaultThinking);
816
815
  }
817
816