@underactive/pi-topping-moa-fusion 0.4.5 → 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.
@@ -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
 
@@ -31,13 +31,19 @@
31
31
  import type { Message } from "@earendil-works/pi-ai";
32
32
  import type { ExtensionContext, ThemeColor } from "@earendil-works/pi-coding-agent";
33
33
  import { visibleWidth, wrapTextWithAnsi, type Component, type TUI } from "@earendil-works/pi-tui";
34
- import { ACTIVITY_METER_WIDTH, ActivityMeter, rateToLevel, TokRateTracker } from "../activityMeter.ts";
34
+ import { ACTIVITY_METER_WIDTH, ActivityMeter, rateToLevel, TokRateTracker } from "@underactive/pi-topping-primitives/activity";
35
+ import {
36
+ type AnsiTheme,
37
+ highlightActivity,
38
+ renderIdleMeter,
39
+ renderMeter,
40
+ shimmerString,
41
+ thinkingLevelThemeColor,
42
+ } from "@underactive/pi-topping-primitives/activity-tui";
35
43
  import { modelRefLabel, shortModelName, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
36
44
  import { contextPercent, CTX_COL_WIDTH, formatCost, formatElapsed, formatTokens } from "./agentStatus.ts";
37
45
  import { formatAgentPreview } from "./agentTranscript.ts";
38
46
  import { QUADRANT_SPINNER_FRAMES, UI_TICK_MS, fitVisible, ratioViewport } from "./chrome.ts";
39
- import { shimmerString, type ShimmerTheme } from "./shimmer.ts";
40
- import { highlightActivity } from "./toolActivity.ts";
41
47
 
42
48
  const WIDGET_KEY = "mf-plan-moa-status";
43
49
 
@@ -140,28 +146,6 @@ const PREVIEW_GUTTER = "│ ";
140
146
  const PREVIEW_MIN_WIDTH = 16;
141
147
  const TRANSCRIPT_MESSAGE_LIMIT = 40;
142
148
 
143
- /**
144
- * MONITOR meter hue per selected thinking level: each row's activity meter is
145
- * tinted with its level's native theme color (`thinkingOff` … `thinkingMax`),
146
- * so the table reads the thinking effort at a glance. Rows whose level is
147
- * unknown fall back to `accent` (see `meterColorFor`). Exhaustive over
148
- * `ThinkingLevel` so a new level cannot silently render as accent.
149
- */
150
- const THINKING_METER_COLORS: Record<ThinkingLevel, ThemeColor> = {
151
- off: "thinkingOff",
152
- minimal: "thinkingMinimal",
153
- low: "thinkingLow",
154
- medium: "thinkingMedium",
155
- high: "thinkingHigh",
156
- xhigh: "thinkingXhigh",
157
- max: "thinkingMax",
158
- };
159
-
160
- /** Resolve a row's meter hue, defaulting an unknown level to the neutral accent. */
161
- function meterColorFor(thinking: ThinkingLevel | undefined): ThemeColor {
162
- return thinking ? THINKING_METER_COLORS[thinking] : "accent";
163
- }
164
-
165
149
  /** Push an activity onto a bounded history buffer (most-recent-last). */
166
150
  function pushActivity(history: string[], activity: string): void {
167
151
  history.push(activity);
@@ -747,7 +731,7 @@ export class MoaProgressTableComponent implements Component {
747
731
  const row = rows[i];
748
732
  let entry = this.meters[i];
749
733
  if (!entry) {
750
- entry = { meter: new ActivityMeter("rtl"), tracker: new TokRateTracker(), revision: row.outputRevision };
734
+ entry = { meter: new ActivityMeter({ direction: "rtl" }), tracker: new TokRateTracker(), revision: row.outputRevision };
751
735
  this.meters[i] = entry;
752
736
  }
753
737
  if (entry.revision !== row.outputRevision) {
@@ -762,14 +746,11 @@ export class MoaProgressTableComponent implements Component {
762
746
 
763
747
  private renderMeter(index: number, active: boolean, thinking: ThinkingLevel | undefined): string {
764
748
  const meter = this.meters[index]?.meter;
765
- if (!meter) return this.theme.fg("dim", "⢀".repeat(ACTIVITY_METER_WIDTH));
749
+ if (!meter) return renderIdleMeter(this.theme);
766
750
  // Each row's meter carries its selected thinking level's native theme hue;
767
751
  // an unknown level falls back to accent. IDLE cells and settled traces keep
768
- // their existing dimming inside colorizeCell regardless of the hue.
769
- const color = meterColorFor(thinking);
770
- return meter.render((level, char) =>
771
- ActivityMeter.colorizeCell(level, char, this.theme, color, !active),
772
- );
752
+ // their existing dimming inside renderMeter regardless of the hue.
753
+ return renderMeter(meter, this.theme, thinkingLevelThemeColor(thinking), !active);
773
754
  }
774
755
 
775
756
  /** How many lines the table may use, so it never crowds out the editor below it. */
@@ -798,7 +779,7 @@ export class MoaProgressTableComponent implements Component {
798
779
  const th = this.theme;
799
780
  if (!lit) return th.fg("dim", text);
800
781
  return th.getFgAnsi
801
- ? shimmerString(text, now - this.animationStartedAt, th as ShimmerTheme, "ltr", "normal", true)
782
+ ? shimmerString(text, now - this.animationStartedAt, th as AnsiTheme, "ltr", "normal", true)
802
783
  : th.fg("text", text);
803
784
  }
804
785