@monotykamary/pi-loop 0.1.12

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 (41) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +285 -0
  4. package/media/demo.mp4 +0 -0
  5. package/media/pi-loop.jpg +0 -0
  6. package/media/screenshot.png +0 -0
  7. package/package.json +89 -0
  8. package/src/core/analyzer.ts +51 -0
  9. package/src/core/content-extractor.ts +79 -0
  10. package/src/core/inference.ts +137 -0
  11. package/src/core/prompt-builder.ts +217 -0
  12. package/src/core/prompt-loader.ts +126 -0
  13. package/src/core/reframe.ts +30 -0
  14. package/src/core/snapshot-builder.ts +252 -0
  15. package/src/global-config.ts +38 -0
  16. package/src/index.ts +532 -0
  17. package/src/session/client.ts +47 -0
  18. package/src/session/loop-session.ts +102 -0
  19. package/src/session/response-parser.ts +37 -0
  20. package/src/state/manager.ts +164 -0
  21. package/src/state/patterns.ts +81 -0
  22. package/src/state/reframe.ts +33 -0
  23. package/src/subagent-detector.ts +94 -0
  24. package/src/types.ts +83 -0
  25. package/src/ui/animations.ts +70 -0
  26. package/src/ui/model-picker.ts +79 -0
  27. package/src/ui/renderer.ts +257 -0
  28. package/src/ui/status-widget.ts +30 -0
  29. package/src/ui/types.ts +48 -0
  30. package/tests/compaction.test.ts +754 -0
  31. package/tests/continue-action-regression.test.ts +456 -0
  32. package/tests/engine.test.ts +770 -0
  33. package/tests/ephemeral-supervision.test.ts +391 -0
  34. package/tests/full-fidelity-snapshot.test.ts +843 -0
  35. package/tests/parsing.test.ts +303 -0
  36. package/tests/state.test.ts +525 -0
  37. package/tests/status-widget.test.ts +703 -0
  38. package/tests/subagent-detector.test.ts +191 -0
  39. package/tests/supervise-command.test.ts +381 -0
  40. package/tsconfig.json +14 -0
  41. package/vitest.config.ts +15 -0
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Response parser for supervisor model decisions.
3
+ */
4
+
5
+ import type { SteeringDecision, InterventionASI } from '../types.js';
6
+
7
+ /**
8
+ * Parse a supervisor decision from text response.
9
+ * Handles JSON extraction from markdown code blocks or raw JSON.
10
+ */
11
+ export function parseDecision(text: string): SteeringDecision {
12
+ const jsonMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/) ?? text.match(/(\{[\s\S]*\})/);
13
+ const jsonStr = jsonMatch?.[1] ?? text.trim();
14
+
15
+ try {
16
+ const parsed = JSON.parse(jsonStr) as Partial<SteeringDecision>;
17
+ const action = parsed.action;
18
+ if (action !== 'continue' && action !== 'steer' && action !== 'done') {
19
+ return safeContinue('Invalid action in supervisor response');
20
+ }
21
+ return {
22
+ action,
23
+ message: typeof parsed.message === 'string' ? parsed.message.trim() : undefined,
24
+ reasoning: typeof parsed.reasoning === 'string' ? parsed.reasoning : '',
25
+ confidence: typeof parsed.confidence === 'number' ? parsed.confidence : 0.5,
26
+ asi:
27
+ parsed.asi && typeof parsed.asi === 'object' ? (parsed.asi as InterventionASI) : undefined,
28
+ };
29
+ } catch {
30
+ return safeContinue('Failed to parse supervisor JSON decision');
31
+ }
32
+ }
33
+
34
+ /** Create a safe "continue" decision with a reason. */
35
+ export function safeContinue(reason: string): SteeringDecision {
36
+ return { action: 'continue', reasoning: reason, confidence: 0 };
37
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * LoopStateManager — manages in-memory loop state and session persistence.
3
+ */
4
+
5
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
6
+ import type { LoopState, LoopIntervention, ConversationMessage, ReframeTier } from '../types.js';
7
+ import { detectIneffectivePattern, type IneffectivePattern } from './patterns.js';
8
+ import {
9
+ getReframeTier,
10
+ escalateReframeTier as escalateReframeTierInState,
11
+ resetReframeTier as resetReframeTierInState,
12
+ } from './reframe.js';
13
+
14
+ const ENTRY_TYPE = 'loop-state';
15
+
16
+ const DEFAULT_PROVIDER: string | null = null;
17
+ const DEFAULT_MODEL_ID: string | null = null;
18
+
19
+ export class LoopStateManager {
20
+ private state: LoopState | null = null;
21
+ private pi: ExtensionAPI;
22
+
23
+ constructor(pi: ExtensionAPI) {
24
+ this.pi = pi;
25
+ }
26
+
27
+ start(outcome: string, provider: string, modelId: string): void {
28
+ this.state = {
29
+ active: true,
30
+ outcome,
31
+ provider,
32
+ modelId,
33
+ interventions: [],
34
+ startedAt: Date.now(),
35
+ turnCount: 0,
36
+ snapshotBuffer: [],
37
+ lastAnalyzedTurn: -1,
38
+ justSteered: false,
39
+ reframeTier: 0,
40
+ lastSteerTurn: -1,
41
+ };
42
+ this.persist();
43
+ }
44
+
45
+ stop(): void {
46
+ if (!this.state) return;
47
+ this.state.active = false;
48
+ this.state.outcome = ''; // Clear the goal so /supervise starts fresh, not in append mode
49
+ this.persist();
50
+ }
51
+
52
+ isActive(): boolean {
53
+ return this.state?.active === true;
54
+ }
55
+
56
+ getState(): LoopState | null {
57
+ return this.state;
58
+ }
59
+
60
+ addIntervention(intervention: LoopIntervention): void {
61
+ if (!this.state) return;
62
+ this.state.interventions.push(intervention);
63
+ this.state.justSteered = true;
64
+ this.state.lastSteerTurn = intervention.turnCount;
65
+ this.persist();
66
+ }
67
+
68
+ clearJustSteered(): void {
69
+ if (!this.state) return;
70
+ this.state.justSteered = false;
71
+ }
72
+
73
+ incrementTurnCount(): void {
74
+ if (!this.state) return;
75
+ this.state.turnCount++;
76
+ }
77
+
78
+ setModel(provider: string, modelId: string): void {
79
+ if (!this.state) return;
80
+ this.state.provider = provider;
81
+ this.state.modelId = modelId;
82
+ this.persist();
83
+ }
84
+
85
+ updateOutcome(outcome: string): void {
86
+ if (!this.state) return;
87
+ this.state.outcome = outcome;
88
+ this.persist();
89
+ }
90
+
91
+ updateSnapshotBuffer(messages: ConversationMessage[]): void {
92
+ if (!this.state) return;
93
+ this.state.snapshotBuffer = messages;
94
+ this.state.lastAnalyzedTurn = this.state.turnCount;
95
+ }
96
+
97
+ getSnapshotBuffer(): ConversationMessage[] {
98
+ return this.state?.snapshotBuffer ?? [];
99
+ }
100
+
101
+ shouldAnalyzeMidRun(turnIndex: number): boolean {
102
+ if (!this.state) return false;
103
+ // Check if we just steered (verify it worked), or safety valve every 8th turn
104
+ if (this.state.justSteered) return true;
105
+ if (turnIndex > 0 && turnIndex % 8 === 0) return true;
106
+ return false;
107
+ }
108
+
109
+ // ---- Reframe tier management ----
110
+
111
+ getReframeTier(): ReframeTier {
112
+ return getReframeTier(this.state);
113
+ }
114
+
115
+ escalateReframeTier(): void {
116
+ if (!this.state) return;
117
+ if (escalateReframeTierInState(this.state)) {
118
+ this.persist();
119
+ }
120
+ }
121
+
122
+ resetReframeTier(): void {
123
+ if (!this.state) return;
124
+ resetReframeTierInState(this.state);
125
+ this.persist();
126
+ }
127
+
128
+ // ---- Pattern detection ----
129
+
130
+ detectIneffectivePattern(): IneffectivePattern {
131
+ if (!this.state) return { detected: false, similarCount: 0, turnsSinceLastSteer: 0 };
132
+ return detectIneffectivePattern(this.state);
133
+ }
134
+
135
+ // ---- Persistence ----
136
+
137
+ /** Restore state from session entries (finds the most recent loop-state entry). */
138
+ loadFromSession(ctx: ExtensionContext): void {
139
+ const entries = ctx.sessionManager.getBranch();
140
+ for (let i = entries.length - 1; i >= 0; i--) {
141
+ const entry = entries[i];
142
+ if (entry.type === 'custom' && (entry as any).customType === ENTRY_TYPE) {
143
+ const loaded = (entry as any).data as LoopState;
144
+ // Restore ephemeral fields
145
+ this.state = {
146
+ ...loaded,
147
+ snapshotBuffer: [],
148
+ lastAnalyzedTurn: -1,
149
+ justSteered: false,
150
+ };
151
+ return;
152
+ }
153
+ }
154
+ this.state = null;
155
+ }
156
+
157
+ /** Persist current state to session (public for compaction handler). */
158
+ persist(): void {
159
+ if (!this.state) return;
160
+ // Don't persist ephemeral fields that are runtime-only
161
+ const { snapshotBuffer, lastAnalyzedTurn, justSteered, ...toPersist } = this.state;
162
+ this.pi.appendEntry(ENTRY_TYPE, toPersist);
163
+ }
164
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Pattern detection for ineffective steering interventions.
3
+ */
4
+
5
+ import type { LoopIntervention, LoopState } from '../types.js';
6
+
7
+ export interface IneffectivePattern {
8
+ detected: boolean;
9
+ similarCount: number;
10
+ turnsSinceLastSteer: number;
11
+ }
12
+
13
+ /**
14
+ * Detect if recent interventions show a pattern of ineffectiveness.
15
+ * Returns similarity info if the last 2+ steering messages are similar.
16
+ */
17
+ export function detectIneffectivePattern(
18
+ state: Pick<LoopState, 'interventions' | 'turnCount' | 'lastSteerTurn'>
19
+ ): IneffectivePattern {
20
+ const turnsSinceLastSteer = state.turnCount - (state.lastSteerTurn ?? 0);
21
+
22
+ // Check stagnation: no progress after 3+ turns since last steer
23
+ const stagnating =
24
+ state.lastSteerTurn !== undefined && state.lastSteerTurn >= 0 && turnsSinceLastSteer >= 3;
25
+
26
+ const recent = state.interventions.slice(-3);
27
+ if (recent.length < 2) {
28
+ // Still detect stagnation even with fewer than 2 interventions
29
+ return { detected: stagnating, similarCount: recent.length, turnsSinceLastSteer };
30
+ }
31
+
32
+ // Simple similarity: check if messages share common keywords or have similar length
33
+ const messages = recent.map((iv) => iv.message.toLowerCase());
34
+ let similarCount = 1;
35
+
36
+ for (let i = 1; i < messages.length; i++) {
37
+ if (areMessagesSimilar(messages[i - 1], messages[i])) {
38
+ similarCount++;
39
+ }
40
+ }
41
+
42
+ // Detected if 2+ recent messages are similar OR stagnating (no progress after 3+ turns)
43
+ const detected = similarCount >= 2 || stagnating;
44
+
45
+ return { detected, similarCount, turnsSinceLastSteer };
46
+ }
47
+
48
+ function areMessagesSimilar(a: string, b: string): boolean {
49
+ // Simple similarity heuristics
50
+ const normalize = (s: string) => s.replace(/[^\w\s]/g, '').trim();
51
+ const normA = normalize(a);
52
+ const normB = normalize(b);
53
+
54
+ // Exact match after normalization
55
+ if (normA === normB) return true;
56
+
57
+ // Check for common directive keywords
58
+ const directiveWords = [
59
+ 'focus',
60
+ 'implement',
61
+ 'add',
62
+ 'fix',
63
+ 'create',
64
+ 'build',
65
+ 'need',
66
+ 'should',
67
+ 'must',
68
+ ];
69
+ const aDirectives = directiveWords.filter((w) => normA.includes(w));
70
+ const bDirectives = directiveWords.filter((w) => normB.includes(w));
71
+
72
+ // If they share 2+ directive words, likely similar
73
+ const commonDirectives = aDirectives.filter((w) => bDirectives.includes(w));
74
+ if (commonDirectives.length >= 2) return true;
75
+
76
+ // Length similarity (within 30%)
77
+ const lenRatio = Math.min(normA.length, normB.length) / Math.max(normA.length, normB.length);
78
+ if (lenRatio > 0.7 && commonDirectives.length >= 1) return true;
79
+
80
+ return false;
81
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Reframe tier state management.
3
+ */
4
+
5
+ import type { ReframeTier, LoopState } from '../types.js';
6
+
7
+ /** Maximum reframe tier value */
8
+ const MAX_REFRAME_TIER: ReframeTier = 4;
9
+
10
+ /** Get the current reframe tier from state */
11
+ export function getReframeTier(state: LoopState | null): ReframeTier {
12
+ return state?.reframeTier ?? 0;
13
+ }
14
+
15
+ /** Get next tier value */
16
+ function getNextTier(tier: ReframeTier): ReframeTier {
17
+ return Math.min(tier + 1, MAX_REFRAME_TIER) as ReframeTier;
18
+ }
19
+
20
+ /** Escalate reframe tier in state */
21
+ export function escalateReframeTier(state: LoopState): boolean {
22
+ const current = state.reframeTier ?? 0;
23
+ if (current < MAX_REFRAME_TIER) {
24
+ state.reframeTier = getNextTier(current);
25
+ return true;
26
+ }
27
+ return false;
28
+ }
29
+
30
+ /** Reset reframe tier to 0 */
31
+ export function resetReframeTier(state: LoopState): void {
32
+ state.reframeTier = 0;
33
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Subagent detection via process tree inspection.
3
+ *
4
+ * This is extension-agnostic: we don't care HOW subagents were created
5
+ * (pi-messenger, manual spawn, or other extensions). We just check if
6
+ * there are child 'pi' processes still running.
7
+ */
8
+
9
+ import { exec } from 'node:child_process';
10
+ import { promisify } from 'node:util';
11
+
12
+ const execAsync = promisify(exec);
13
+
14
+ interface SubagentStatus {
15
+ hasActiveSubagents: boolean;
16
+ count: number;
17
+ pids: number[];
18
+ }
19
+
20
+ /**
21
+ * Check for child pi processes spawned by the current process.
22
+ * Works on macOS and Linux via ps. Windows returns false (not implemented).
23
+ */
24
+ export async function checkChildPiProcesses(): Promise<SubagentStatus> {
25
+ const platform = process.platform;
26
+
27
+ if (platform === 'darwin' || platform === 'linux') {
28
+ return checkUnixChildProcesses();
29
+ }
30
+
31
+ // Windows: not implemented, assume no subagents
32
+ return { hasActiveSubagents: false, count: 0, pids: [] };
33
+ }
34
+
35
+ async function checkUnixChildProcesses(): Promise<SubagentStatus> {
36
+ try {
37
+ const ppid = process.pid;
38
+
39
+ // Get all pi processes with their parent PID
40
+ // Format: ppid pid command
41
+ const { stdout } = await execAsync(`ps -eo ppid,pid,comm | grep -E "\\bpi\\b" || true`);
42
+
43
+ const pids: number[] = [];
44
+
45
+ for (const line of stdout.trim().split('\n')) {
46
+ const parts = line.trim().split(/\s+/);
47
+ if (parts.length < 3) continue;
48
+
49
+ const childPpid = parseInt(parts[0], 10);
50
+ const childPid = parseInt(parts[1], 10);
51
+ const comm = parts[2];
52
+
53
+ // Check if this pi process is our direct child
54
+ // Also check for grandchildren (subagents spawning subagents)
55
+ if (childPpid === ppid && comm === 'pi') {
56
+ pids.push(childPid);
57
+ }
58
+ }
59
+
60
+ return {
61
+ hasActiveSubagents: pids.length > 0,
62
+ count: pids.length,
63
+ pids,
64
+ };
65
+ } catch {
66
+ return { hasActiveSubagents: false, count: 0, pids: [] };
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Poll until no child pi processes remain or timeout.
72
+ * Returns true if all subagents completed, false if timeout.
73
+ */
74
+ export async function waitForSubagents(
75
+ checkIntervalMs: number = 2000,
76
+ timeoutMs: number = 60000
77
+ ): Promise<{ completed: boolean; finalStatus: SubagentStatus }> {
78
+ const start = Date.now();
79
+
80
+ while (Date.now() - start < timeoutMs) {
81
+ const status = await checkChildPiProcesses();
82
+ if (!status.hasActiveSubagents) {
83
+ return { completed: true, finalStatus: status };
84
+ }
85
+ await sleep(checkIntervalMs);
86
+ }
87
+
88
+ const finalStatus = await checkChildPiProcesses();
89
+ return { completed: !finalStatus.hasActiveSubagents, finalStatus };
90
+ }
91
+
92
+ function sleep(ms: number): Promise<void> {
93
+ return new Promise((resolve) => setTimeout(resolve, ms));
94
+ }
package/src/types.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Core types for the pi-loop extension.
3
+ */
4
+
5
+ export type LoopAction = 'continue' | 'steer' | 'done';
6
+
7
+ /** A single intervention record */
8
+ export interface LoopIntervention {
9
+ turnCount: number;
10
+ message: string;
11
+ reasoning: string;
12
+ timestamp: number;
13
+ /** Self-generated actionable side information */
14
+ asi?: InterventionASI;
15
+ }
16
+
17
+ /** Reframe tier tracks escalation of intervention strategies */
18
+ export type ReframeTier = 0 | 1 | 2 | 3 | 4;
19
+
20
+ /** Actionable Side Information — free-form observations from interventions */
21
+ export interface InterventionASI {
22
+ /** Any observations worth remembering for future decisions */
23
+ [key: string]: unknown;
24
+ }
25
+
26
+ /** Full loop state — persisted to session */
27
+ export interface LoopState {
28
+ active: boolean;
29
+ outcome: string;
30
+ provider: string; // e.g. "anthropic"
31
+ modelId: string; // e.g. "claude-haiku-4-5-20251001"
32
+ interventions: LoopIntervention[];
33
+ startedAt: number;
34
+ turnCount: number;
35
+ // Incremental snapshot buffer (not persisted, rebuilt on load)
36
+ snapshotBuffer?: ConversationMessage[];
37
+ lastAnalyzedTurn?: number;
38
+ justSteered?: boolean; // flag to check if steer worked
39
+ // Reframe escalation tracking
40
+ reframeTier?: ReframeTier;
41
+ lastSteerTurn?: number; // track when we last steered to detect ineffectiveness
42
+ }
43
+
44
+ /** Decision returned by the observer LLM */
45
+ export interface SteeringDecision {
46
+ action: LoopAction;
47
+ message?: string;
48
+ reasoning: string;
49
+ confidence: number;
50
+ /** Self-generated actionable side information when steering */
51
+ asi?: InterventionASI;
52
+ }
53
+
54
+ /** Content block types for rich message capture */
55
+ export type ContentBlock =
56
+ | { type: 'text'; text: string }
57
+ | { type: 'image'; source: string; mimeType?: string }
58
+ | { type: 'tool_call'; id: string; name: string; input: Record<string, unknown> }
59
+ | {
60
+ type: 'tool_result';
61
+ toolCallId: string;
62
+ content: (ContentBlock | string)[];
63
+ isError?: boolean;
64
+ };
65
+
66
+ /** A simplified message for building the supervisor context - now with full tool support */
67
+ export interface ConversationMessage {
68
+ role: 'user' | 'assistant' | 'tool_results';
69
+ content: string;
70
+ /** Rich content blocks for full fidelity capture (images, tool calls, tool results) */
71
+ blocks?: ContentBlock[];
72
+ /** Raw tool results associated with this turn (for assistant messages) */
73
+ toolResults?: ToolResultEntry[];
74
+ }
75
+
76
+ /** Captured tool result entry */
77
+ export interface ToolResultEntry {
78
+ toolCallId: string;
79
+ toolName: string;
80
+ input: Record<string, unknown>;
81
+ content: ContentBlock[];
82
+ isError: boolean;
83
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Animation utilities for the supervisor widget.
3
+ */
4
+
5
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
6
+ import type { WidgetState, WidgetAction } from './types.js';
7
+ import type { LoopIntervention } from '../types.js';
8
+ import { WIDGET_ID, ANIMATION_STEP_MS } from './types.js';
9
+
10
+ /** Type for the render function callback */
11
+ export type RenderFn = (
12
+ ctx: ExtensionContext,
13
+ snap: { outcome: string; interventions: LoopIntervention[] },
14
+ action: WidgetAction,
15
+ lastThinking: string,
16
+ hideFromBottom: number
17
+ ) => void;
18
+
19
+ /** Start the line-by-line clear animation - hides lines from bottom to top */
20
+ export function startLineClearAnimation(
21
+ ctx: ExtensionContext,
22
+ state: WidgetState,
23
+ renderFn: RenderFn
24
+ ): void {
25
+ if (!state.lastActiveState || state.lastThinkingLines.length === 0) return;
26
+
27
+ const isSteering = state.lastActionType === 'steering';
28
+ const targetVisibleCount = 0;
29
+
30
+ const animateStep = () => {
31
+ const currentVisible = state.lastThinkingLines.length - state.hiddenFromBottomCount;
32
+
33
+ if (currentVisible <= targetVisibleCount) {
34
+ if (isSteering) {
35
+ state.lastThinkingLines = [];
36
+ state.hiddenFromBottomCount = 0;
37
+ const reframeTier =
38
+ state.storedAction?.type === 'steering' ? (state.storedAction.reframeTier ?? 0) : 0;
39
+ renderFn(
40
+ ctx,
41
+ state.lastActiveState!,
42
+ { type: 'steering', message: '', reframeTier },
43
+ '',
44
+ 0
45
+ );
46
+ return;
47
+ } else {
48
+ state.lastActiveState = null;
49
+ state.lastThinking = '';
50
+ state.lastThinkingLines = [];
51
+ state.hiddenFromBottomCount = 0;
52
+ state.storedAction = null;
53
+ ctx.ui.setWidget(WIDGET_ID, undefined);
54
+ return;
55
+ }
56
+ }
57
+
58
+ state.hiddenFromBottomCount++;
59
+ const reframeTier =
60
+ state.storedAction?.type === 'steering' ? (state.storedAction.reframeTier ?? 0) : 0;
61
+ const fallbackAction: WidgetAction = isSteering
62
+ ? { type: 'steering', message: '', reframeTier }
63
+ : { type: 'done', reframeTier: 0 };
64
+ renderFn(ctx, state.lastActiveState!, fallbackAction, '', state.hiddenFromBottomCount);
65
+
66
+ state.animationTimer = setTimeout(animateStep, ANIMATION_STEP_MS);
67
+ };
68
+
69
+ animateStep();
70
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * model-picker — wraps pi's internal ModelSelectorComponent for use in
3
+ * the /loop model command. Shows the same model selector the user
4
+ * sees when pressing Ctrl+P in pi, with search and API-key availability.
5
+ */
6
+
7
+ import {
8
+ ModelRuntime,
9
+ ModelSelectorComponent,
10
+ SettingsManager,
11
+ getAgentDir,
12
+ } from '@earendil-works/pi-coding-agent';
13
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
14
+ import type { Model } from '@earendil-works/pi-ai';
15
+
16
+ /**
17
+ * Open the interactive model picker.
18
+ * Returns the selected Model, or null if the user cancelled.
19
+ */
20
+ export async function pickModel(
21
+ ctx: ExtensionContext,
22
+ currentProvider?: string,
23
+ currentModelId?: string
24
+ ): Promise<Model<any> | null> {
25
+ if (ctx.mode !== 'tui') {
26
+ if (!ctx.hasUI) return null;
27
+ const all = ctx.modelRegistry.getAll();
28
+ if (all.length === 0) {
29
+ ctx.ui.notify('No models available.', 'info');
30
+ return null;
31
+ }
32
+ const items = all.map((m) => `${m.name ?? m.id} (${m.provider}/${m.id})`);
33
+ const pick = await ctx.ui.select('Pick a model', items);
34
+ if (pick === undefined) return null;
35
+ const idx = items.indexOf(pick);
36
+ if (idx < 0) return null;
37
+ return all[idx] ?? null;
38
+ }
39
+ // Resolve the currently-selected supervisor model (to pre-highlight it)
40
+ const currentModel =
41
+ currentProvider && currentModelId
42
+ ? ctx.modelRegistry.find(currentProvider, currentModelId)
43
+ : undefined;
44
+
45
+ // Minimal in-memory settings — we only need the selector, not persistence
46
+ const settingsManager = SettingsManager.inMemory();
47
+
48
+ // pi 0.80.8: ModelSelectorComponent takes the canonical ModelRuntime
49
+ // (previously the sync ModelRegistry facade). ExtensionContext only exposes
50
+ // modelRegistry, so build a runtime from the agent dir for the picker.
51
+ const modelRuntime = await ModelRuntime.create({
52
+ authPath: `${getAgentDir()}/auth.json`,
53
+ modelsPath: `${getAgentDir()}/models.json`,
54
+ });
55
+
56
+ return ctx.ui.custom<Model<any> | null>((tui, _theme, _kb, done) => {
57
+ const component = new ModelSelectorComponent(
58
+ tui,
59
+ currentModel,
60
+ settingsManager,
61
+ modelRuntime,
62
+ [], // no scoped-model cycling — we want the full model list
63
+ (model) => done(model),
64
+ () => done(null)
65
+ );
66
+
67
+ // Give focus so the search input is active immediately
68
+ component.focused = true;
69
+
70
+ return {
71
+ render: (width) => component.render(width),
72
+ invalidate: () => component.invalidate(),
73
+ handleInput: (data) => {
74
+ component.handleInput(data);
75
+ tui.requestRender();
76
+ },
77
+ };
78
+ });
79
+ }