@monotykamary/pi-supervisor 0.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +120 -0
- package/LICENSE +21 -0
- package/README.md +341 -0
- package/media/demo.mp4 +0 -0
- package/media/screenshot.png +0 -0
- package/package.json +87 -0
- package/src/compaction/brief.ts +841 -0
- package/src/compaction/build-sections.ts +340 -0
- package/src/compaction/causal-keys.ts +138 -0
- package/src/compaction/content.ts +68 -0
- package/src/compaction/extract/commits.ts +78 -0
- package/src/compaction/extract/goals.ts +79 -0
- package/src/compaction/extract/preferences.ts +52 -0
- package/src/compaction/extract/shared-symbols.ts +376 -0
- package/src/compaction/filter-noise.ts +47 -0
- package/src/compaction/format.ts +89 -0
- package/src/compaction/index.ts +38 -0
- package/src/compaction/normalize.ts +73 -0
- package/src/compaction/sanitize.ts +5 -0
- package/src/compaction/sections.ts +19 -0
- package/src/compaction/skill-collapse.ts +35 -0
- package/src/compaction/tool-args.ts +14 -0
- package/src/compaction/types.ts +26 -0
- package/src/core/analyzer.ts +58 -0
- package/src/core/index.ts +8 -0
- package/src/core/inference.ts +77 -0
- package/src/core/prompt-builder.ts +137 -0
- package/src/core/prompt-loader.ts +125 -0
- package/src/core/reframe.ts +27 -0
- package/src/fabric-provider.ts +115 -0
- package/src/global-config.ts +65 -0
- package/src/index.ts +514 -0
- package/src/session/client.ts +46 -0
- package/src/session/response-parser.ts +37 -0
- package/src/session/supervisor-session.ts +102 -0
- package/src/state/manager.ts +133 -0
- package/src/state/mid-run-signals.ts +103 -0
- package/src/state/patterns.ts +82 -0
- package/src/state/reframe.ts +27 -0
- package/src/subagent-detector.ts +94 -0
- package/src/types.ts +42 -0
- package/src/ui/animations.ts +95 -0
- package/src/ui/model-picker.ts +72 -0
- package/src/ui/model-settings-selector.ts +440 -0
- package/src/ui/model-sort.ts +101 -0
- package/src/ui/renderer.ts +314 -0
- package/src/ui/types.ts +48 -0
- package/tests/compaction.test.ts +507 -0
- package/tests/engine.test.ts +622 -0
- package/tests/ephemeral-supervision.test.ts +347 -0
- package/tests/fabric-provider.test.ts +55 -0
- package/tests/full-fidelity-snapshot.test.ts +250 -0
- package/tests/global-config.test.ts +74 -0
- package/tests/model-sort.test.ts +157 -0
- package/tests/parsing.test.ts +303 -0
- package/tests/state.test.ts +474 -0
- package/tests/status-widget.test.ts +539 -0
- package/tests/subagent-detector.test.ts +191 -0
- package/tests/supervise-command.test.ts +363 -0
- package/tests/supervise-model-command.test.ts +184 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-sort.ts — integration with the pi-model-sort extension.
|
|
3
|
+
*
|
|
4
|
+
* The supervisor's model picker copies pi-core's /model selector. To "work
|
|
5
|
+
* together with" pi-model-sort (which monkey-patches pi-core's selector to
|
|
6
|
+
* sort by last usage), we read pi-model-sort's persisted timestamps and
|
|
7
|
+
* re-apply the identical sort order here, so the supervisor picker lists
|
|
8
|
+
* models in the user's actual usage order — matching what they see in pi's
|
|
9
|
+
* own /model selector.
|
|
10
|
+
*
|
|
11
|
+
* Mirrors pi-model-sort's sortByLastUsed algorithm exactly:
|
|
12
|
+
* 1. Current model first (if currentModelKey is provided)
|
|
13
|
+
* 2. Most recently used (highest timestamp) first
|
|
14
|
+
* 3. Provider name alphabetically
|
|
15
|
+
* 4. Model id alphabetically
|
|
16
|
+
*
|
|
17
|
+
* When pi-model-sort isn't installed or has no recorded usage, callers
|
|
18
|
+
* should fall back to pi-core's default provider sort.
|
|
19
|
+
*
|
|
20
|
+
* Config path: ~/.pi/agent/extensions/pi-model-sort.json (same file
|
|
21
|
+
* pi-model-sort reads/writes — sharing it keeps both pickers in sync).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
27
|
+
|
|
28
|
+
const MODEL_SORT_CONFIG_PATH = join(getAgentDir(), 'extensions', 'pi-model-sort.json');
|
|
29
|
+
|
|
30
|
+
export interface LastUsedMap {
|
|
31
|
+
/** Map of "provider/modelId" → last-used Unix timestamp (ms). */
|
|
32
|
+
[providerModelKey: string]: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Build a stable model key from provider and model id (matches pi-model-sort). */
|
|
36
|
+
export function buildModelKey(provider: string, id: string): string {
|
|
37
|
+
return `${provider}/${id}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read pi-model-sort's last-used timestamps from disk.
|
|
42
|
+
* Returns null when pi-model-sort isn't installed, has no config, or the
|
|
43
|
+
* config is unreadable — callers then fall back to the default sort.
|
|
44
|
+
*/
|
|
45
|
+
export function readModelSortLastUsed(): LastUsedMap | null {
|
|
46
|
+
if (!existsSync(MODEL_SORT_CONFIG_PATH)) return null;
|
|
47
|
+
try {
|
|
48
|
+
const raw = readFileSync(MODEL_SORT_CONFIG_PATH, 'utf-8');
|
|
49
|
+
const parsed = JSON.parse(raw) as { lastUsed?: LastUsedMap };
|
|
50
|
+
if (!parsed.lastUsed || typeof parsed.lastUsed !== 'object') return null;
|
|
51
|
+
return parsed.lastUsed;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether enough usage data exists to sort by last-used (vs. falling back to
|
|
59
|
+
* the default provider sort). Exposed for tests and the selector.
|
|
60
|
+
*/
|
|
61
|
+
export function hasUsageData(lastUsed: LastUsedMap | null): lastUsed is LastUsedMap {
|
|
62
|
+
return !!lastUsed && Object.keys(lastUsed).length > 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Sort models by last-used recency — a faithful copy of pi-model-sort's
|
|
67
|
+
* sortByLastUsed. Non-mutating.
|
|
68
|
+
*
|
|
69
|
+
* Sort order:
|
|
70
|
+
* 1. Current model first (if currentModelKey is provided)
|
|
71
|
+
* 2. Most recently used (highest timestamp) first
|
|
72
|
+
* 3. Provider name alphabetically
|
|
73
|
+
* 4. Model id alphabetically
|
|
74
|
+
*
|
|
75
|
+
* Models with no recorded usage get timestamp 0 (sorted last).
|
|
76
|
+
*/
|
|
77
|
+
export function sortByLastUsed<T extends { provider: string; id: string }>(
|
|
78
|
+
items: T[],
|
|
79
|
+
lastUsed: LastUsedMap,
|
|
80
|
+
currentModelKey: string | null
|
|
81
|
+
): T[] {
|
|
82
|
+
const sorted = [...items];
|
|
83
|
+
sorted.sort((a, b) => {
|
|
84
|
+
const aKey = buildModelKey(a.provider, a.id);
|
|
85
|
+
const bKey = buildModelKey(b.provider, b.id);
|
|
86
|
+
|
|
87
|
+
if (currentModelKey !== null) {
|
|
88
|
+
const aIsCurrent = aKey === currentModelKey;
|
|
89
|
+
const bIsCurrent = bKey === currentModelKey;
|
|
90
|
+
if (aIsCurrent && !bIsCurrent) return -1;
|
|
91
|
+
if (!aIsCurrent && bIsCurrent) return 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const aLast = lastUsed[aKey] ?? 0;
|
|
95
|
+
const bLast = lastUsed[bKey] ?? 0;
|
|
96
|
+
if (aLast !== bLast) return bLast - aLast;
|
|
97
|
+
|
|
98
|
+
return a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id);
|
|
99
|
+
});
|
|
100
|
+
return sorted;
|
|
101
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget renderer - handles the visual rendering of the supervisor status widget.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import { truncateToWidth } from '@earendil-works/pi-tui';
|
|
7
|
+
import type { SupervisorIntervention, SupervisorState } from '../types.js';
|
|
8
|
+
import type { WidgetAction, WidgetState } from './types.js';
|
|
9
|
+
import { WIDGET_ID, CLEAR_DELAY_MS } from './types.js';
|
|
10
|
+
import { startLineClearAnimation, type RenderFn } from './animations.js';
|
|
11
|
+
|
|
12
|
+
/** Toggle the widget on/off. Returns the new visibility state. */
|
|
13
|
+
export function toggleWidget(state: WidgetState): boolean {
|
|
14
|
+
state.widgetVisible = !state.widgetVisible;
|
|
15
|
+
return state.widgetVisible;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Update footer + widget. Call this every time state or action changes. */
|
|
19
|
+
export function updateUI(
|
|
20
|
+
ctx: ExtensionContext,
|
|
21
|
+
state: WidgetState,
|
|
22
|
+
supervisorState: SupervisorState | null,
|
|
23
|
+
action: WidgetAction = { type: 'watching' }
|
|
24
|
+
): void {
|
|
25
|
+
// Check if we're receiving new thinking content
|
|
26
|
+
const hasNewThinking =
|
|
27
|
+
action.type === 'analyzing' && action.thinking && action.thinking !== state.lastThinking;
|
|
28
|
+
|
|
29
|
+
// Detect when leaving analyzing mode (for clear animation)
|
|
30
|
+
const wasAnalyzing = state.lastActionType === 'analyzing';
|
|
31
|
+
const isNowAnalyzing = action.type === 'analyzing';
|
|
32
|
+
const leavingAnalyzing = wasAnalyzing && !isNowAnalyzing;
|
|
33
|
+
|
|
34
|
+
if (state.clearTimer) {
|
|
35
|
+
clearTimeout(state.clearTimer);
|
|
36
|
+
state.clearTimer = null;
|
|
37
|
+
}
|
|
38
|
+
if (state.animationTimer) {
|
|
39
|
+
clearTimeout(state.animationTimer);
|
|
40
|
+
state.animationTimer = null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Reset animation state for new thinking (streaming replacement)
|
|
44
|
+
if (hasNewThinking) {
|
|
45
|
+
state.hiddenFromBottomCount = 0;
|
|
46
|
+
state.lastThinkingLines = [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// When leaving analyzing, keep the thinking visible so it can animate away.
|
|
50
|
+
// For done: shown before delayed clear animation.
|
|
51
|
+
// For steering/watching: animated away immediately (no delay).
|
|
52
|
+
// Clear lastThinking when leaving to a non-done action so the render falls back
|
|
53
|
+
// to preserved lines (for animation) or shows no thinking (no lines to animate).
|
|
54
|
+
if (leavingAnalyzing && action.type !== 'done') {
|
|
55
|
+
state.lastThinking = '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Always update last state first
|
|
59
|
+
// For 'done', supervisorState may already be inactive (stopped before this call),
|
|
60
|
+
// so we also capture state on that transition.
|
|
61
|
+
if (supervisorState?.active || action.type === 'done') {
|
|
62
|
+
if (supervisorState) {
|
|
63
|
+
state.lastActiveState = {
|
|
64
|
+
outcome: supervisorState.outcome,
|
|
65
|
+
interventions: [...supervisorState.interventions],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
state.lastActionType = action.type;
|
|
69
|
+
if (action.type === 'analyzing' && action.thinking) {
|
|
70
|
+
state.lastThinking = action.thinking;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Handle inferring specially — but only if there's no thinking to animate away first.
|
|
75
|
+
// When transitioning from analyzing (with thinking lines visible) to inferring,
|
|
76
|
+
// we animate the thinking down before showing the inferring state.
|
|
77
|
+
if (action.type === 'inferring' && !(leavingAnalyzing && state.lastThinkingLines.length > 0)) {
|
|
78
|
+
// Clear any stale thinking when inferring from a non-analyzing state
|
|
79
|
+
if (leavingAnalyzing) {
|
|
80
|
+
state.lastThinking = '';
|
|
81
|
+
}
|
|
82
|
+
if (state.widgetVisible) {
|
|
83
|
+
const inferState = { outcome: '', interventions: state.lastActiveState?.interventions ?? [] };
|
|
84
|
+
renderWithState(ctx, state, inferState, action, '', 0);
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// We need the clear animation when:
|
|
90
|
+
// 1. Supervisor is inactive and thinking lines are still visible (stop), or
|
|
91
|
+
// 2. Leaving analyzing with thinking lines to a non-analyzing action (steering/watching).
|
|
92
|
+
// This animates the thinking text down instead of vanishing it instantly.
|
|
93
|
+
// 3. Done action — always show "✓ done" briefly before clearing, even without thinking.
|
|
94
|
+
const hasThinkingToAnimate = state.lastActiveState && state.lastThinkingLines.length > 0;
|
|
95
|
+
const isDoneTransition = action.type === 'done' && state.lastActiveState;
|
|
96
|
+
const needsClearAnimation =
|
|
97
|
+
(hasThinkingToAnimate && (!supervisorState?.active || leavingAnalyzing)) || isDoneTransition;
|
|
98
|
+
|
|
99
|
+
// Leaving analyzing to a non-done, non-analyzing action — animate the thinking
|
|
100
|
+
// text away immediately (no delay), so it doesn't vanish instantly.
|
|
101
|
+
// For 'done', use the delayed clear path instead so "✓ done" stays visible briefly.
|
|
102
|
+
if (needsClearAnimation && leavingAnalyzing && !isDoneTransition) {
|
|
103
|
+
const boundRender: RenderFn = (ctx, snap, action, thinking, hideFromBottom) => {
|
|
104
|
+
renderWithState(ctx, state, snap, action, thinking, hideFromBottom);
|
|
105
|
+
};
|
|
106
|
+
let fallbackAction: WidgetAction;
|
|
107
|
+
if (action.type === 'steering') {
|
|
108
|
+
fallbackAction = { type: 'steering', message: '', reframeTier: action.reframeTier };
|
|
109
|
+
} else if (action.type === 'waiting') {
|
|
110
|
+
fallbackAction = {
|
|
111
|
+
type: 'waiting',
|
|
112
|
+
message: action.message,
|
|
113
|
+
reframeTier: action.reframeTier,
|
|
114
|
+
};
|
|
115
|
+
} else if (action.type === 'inferring') {
|
|
116
|
+
fallbackAction = { type: 'inferring' };
|
|
117
|
+
} else {
|
|
118
|
+
const reframeTier = 'reframeTier' in action ? (action.reframeTier ?? 0) : 0;
|
|
119
|
+
fallbackAction = { type: action.type, reframeTier } as WidgetAction;
|
|
120
|
+
}
|
|
121
|
+
state.lastActionType = fallbackAction.type;
|
|
122
|
+
state.storedAction = fallbackAction;
|
|
123
|
+
startLineClearAnimation(ctx, state, boundRender);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// When supervisor becomes inactive (after stop), start the clear animation.
|
|
128
|
+
// This handles the 'done' transition: the widget shows all thinking lines,
|
|
129
|
+
// then after CLEAR_DELAY_MS they animate away.
|
|
130
|
+
if (needsClearAnimation) {
|
|
131
|
+
state.clearTimer = setTimeout(() => {
|
|
132
|
+
const boundRender: RenderFn = (ctx, snap, action, thinking, hideFromBottom) => {
|
|
133
|
+
renderWithState(ctx, state, snap, action, thinking, hideFromBottom);
|
|
134
|
+
};
|
|
135
|
+
startLineClearAnimation(ctx, state, boundRender);
|
|
136
|
+
}, CLEAR_DELAY_MS);
|
|
137
|
+
const fallbackAction: WidgetAction =
|
|
138
|
+
action.type === 'steering'
|
|
139
|
+
? { type: 'steering', message: '', reframeTier: action.reframeTier }
|
|
140
|
+
: { type: 'done', reframeTier: 0 };
|
|
141
|
+
state.lastActionType = fallbackAction.type;
|
|
142
|
+
state.storedAction = fallbackAction;
|
|
143
|
+
// Render with lastThinkingLines content when lastThinking is empty
|
|
144
|
+
renderWithState(
|
|
145
|
+
ctx,
|
|
146
|
+
state,
|
|
147
|
+
state.lastActiveState!,
|
|
148
|
+
fallbackAction,
|
|
149
|
+
state.lastThinking,
|
|
150
|
+
state.hiddenFromBottomCount
|
|
151
|
+
);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!supervisorState || !supervisorState.active) {
|
|
156
|
+
// Don't clear if a done clear animation is scheduled — it handles the teardown.
|
|
157
|
+
if (state.clearTimer || state.animationTimer) return;
|
|
158
|
+
state.lastThinkingLines = [];
|
|
159
|
+
ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!state.widgetVisible) {
|
|
164
|
+
ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
renderWithState(
|
|
169
|
+
ctx,
|
|
170
|
+
state,
|
|
171
|
+
state.lastActiveState!,
|
|
172
|
+
action,
|
|
173
|
+
state.lastThinking,
|
|
174
|
+
state.hiddenFromBottomCount
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Main render function - creates the widget content */
|
|
179
|
+
function renderWithState(
|
|
180
|
+
ctx: ExtensionContext,
|
|
181
|
+
widgetState: WidgetState,
|
|
182
|
+
snap: { outcome: string; interventions: SupervisorIntervention[] },
|
|
183
|
+
action: WidgetAction,
|
|
184
|
+
lastThinking: string,
|
|
185
|
+
hideFromBottom: number = 0
|
|
186
|
+
): void {
|
|
187
|
+
ctx.ui.setWidget(WIDGET_ID, (tui, theme) => {
|
|
188
|
+
let actionStr: string;
|
|
189
|
+
let thinking = lastThinking;
|
|
190
|
+
|
|
191
|
+
switch (action.type) {
|
|
192
|
+
case 'watching':
|
|
193
|
+
actionStr = theme.fg('dim', 'watching');
|
|
194
|
+
break;
|
|
195
|
+
case 'analyzing':
|
|
196
|
+
actionStr = theme.fg('warning', '⟳ analyzing');
|
|
197
|
+
thinking = action.thinking ?? lastThinking;
|
|
198
|
+
break;
|
|
199
|
+
case 'steering':
|
|
200
|
+
actionStr = theme.fg('warning', 'steering');
|
|
201
|
+
break;
|
|
202
|
+
case 'done':
|
|
203
|
+
actionStr = theme.fg('accent', '✓ done');
|
|
204
|
+
break;
|
|
205
|
+
case 'waiting':
|
|
206
|
+
actionStr = theme.fg('warning', `⏳ ${action.message}`);
|
|
207
|
+
break;
|
|
208
|
+
break;
|
|
209
|
+
case 'inferring':
|
|
210
|
+
actionStr = theme.fg('dim', 'scanning');
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const sep = theme.fg('dim', ' · ');
|
|
215
|
+
let headerText: string;
|
|
216
|
+
if (action.type === 'done') headerText = 'Supervised';
|
|
217
|
+
else if (action.type === 'inferring') headerText = 'Inferring';
|
|
218
|
+
else headerText = 'Supervising';
|
|
219
|
+
const header = `${theme.fg('accent', '◉')} ${theme.fg('accent', headerText)}`;
|
|
220
|
+
const hasGoal = snap.outcome.length > 0;
|
|
221
|
+
const goalLabel = hasGoal ? `${theme.fg('dim', 'Goal:')} ` : '';
|
|
222
|
+
const goalQuoteOpen = hasGoal ? theme.fg('muted', '"') : '';
|
|
223
|
+
const goalQuoteClose = hasGoal ? theme.fg('muted', '"') : '';
|
|
224
|
+
|
|
225
|
+
const steerCount = snap.interventions.length;
|
|
226
|
+
const steers = steerCount > 0 ? theme.fg('dim', `↗ ${steerCount}`) : '';
|
|
227
|
+
const reframeTier = 'reframeTier' in action ? (action.reframeTier ?? 0) : 0;
|
|
228
|
+
const reframeStr = reframeTier > 0 ? theme.fg('error', `↻${reframeTier}`) : '';
|
|
229
|
+
const suffixParts = [steers, reframeStr, actionStr].filter(Boolean);
|
|
230
|
+
|
|
231
|
+
const thinkingPrefix = theme.fg('dim', ' ');
|
|
232
|
+
const rawThinking = thinking;
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
render: (width: number) => {
|
|
236
|
+
const paddedWidth = Math.max(0, width - 1);
|
|
237
|
+
widgetState.lastRenderedWidth = paddedWidth;
|
|
238
|
+
const suffix = suffixParts.length > 0 ? sep + suffixParts.join(sep) : '';
|
|
239
|
+
|
|
240
|
+
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
241
|
+
|
|
242
|
+
let line: string;
|
|
243
|
+
if (hasGoal) {
|
|
244
|
+
const prefix = header + sep + goalLabel + goalQuoteOpen;
|
|
245
|
+
const prefixWidth = stripAnsi(prefix).length;
|
|
246
|
+
const suffixWidth = stripAnsi(suffix).length;
|
|
247
|
+
const closeQuoteWidth = stripAnsi(goalQuoteClose).length;
|
|
248
|
+
const availableForGoal = Math.max(
|
|
249
|
+
0,
|
|
250
|
+
paddedWidth - prefixWidth - suffixWidth - closeQuoteWidth
|
|
251
|
+
);
|
|
252
|
+
const rawGoal = snap.outcome.replace(/\r?\n/g, ' ');
|
|
253
|
+
const truncatedGoal = truncateToWidth(rawGoal, availableForGoal);
|
|
254
|
+
const goalText = theme.fg('muted', truncatedGoal);
|
|
255
|
+
line = prefix + goalText + goalQuoteClose + suffix;
|
|
256
|
+
} else {
|
|
257
|
+
const parts = [header, ...suffixParts].filter(Boolean);
|
|
258
|
+
line = parts.join(sep);
|
|
259
|
+
}
|
|
260
|
+
const l1 = truncateToWidth(line, paddedWidth);
|
|
261
|
+
|
|
262
|
+
if (!rawThinking) {
|
|
263
|
+
// No live thinking text — render from preserved lines for display/animation
|
|
264
|
+
// (e.g. after the supervisor is done, lastThinking is empty but lastThinkingLines
|
|
265
|
+
// holds the previously rendered lines for display/animation).
|
|
266
|
+
if (widgetState.lastThinkingLines.length > 0) {
|
|
267
|
+
const visibleCount = Math.max(0, widgetState.lastThinkingLines.length - hideFromBottom);
|
|
268
|
+
const visibleLines = widgetState.lastThinkingLines
|
|
269
|
+
.slice(0, visibleCount)
|
|
270
|
+
.map((ln) => truncateToWidth(theme.fg('dim', ln), paddedWidth));
|
|
271
|
+
if (visibleCount === 0) return [l1];
|
|
272
|
+
return [l1, ...visibleLines];
|
|
273
|
+
}
|
|
274
|
+
return [l1];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const thinkingIndent = stripAnsi(thinkingPrefix).length;
|
|
278
|
+
const maxContentWidth = Math.max(0, paddedWidth - thinkingIndent);
|
|
279
|
+
const thinkingWords = rawThinking.replace(/[\r\n]+/g, ' ').split(' ');
|
|
280
|
+
const thinkingLines: string[] = [];
|
|
281
|
+
const plainLines: string[] = [];
|
|
282
|
+
let currentThinkingLine = '';
|
|
283
|
+
let currentPlainLine = '';
|
|
284
|
+
|
|
285
|
+
for (const word of thinkingWords) {
|
|
286
|
+
const testLine = currentPlainLine ? `${currentPlainLine} ${word}` : word;
|
|
287
|
+
if (testLine.length <= maxContentWidth) {
|
|
288
|
+
currentPlainLine = testLine;
|
|
289
|
+
currentThinkingLine = currentThinkingLine ? `${currentThinkingLine} ${word}` : word;
|
|
290
|
+
} else {
|
|
291
|
+
if (currentThinkingLine) {
|
|
292
|
+
thinkingLines.push(
|
|
293
|
+
truncateToWidth(thinkingPrefix + theme.fg('dim', currentThinkingLine), paddedWidth)
|
|
294
|
+
);
|
|
295
|
+
plainLines.push(' ' + currentPlainLine);
|
|
296
|
+
}
|
|
297
|
+
currentPlainLine = word;
|
|
298
|
+
currentThinkingLine = word;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (currentThinkingLine) {
|
|
302
|
+
thinkingLines.push(
|
|
303
|
+
truncateToWidth(thinkingPrefix + theme.fg('dim', currentThinkingLine), paddedWidth)
|
|
304
|
+
);
|
|
305
|
+
plainLines.push(' ' + currentPlainLine);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
widgetState.lastThinkingLines = plainLines;
|
|
309
|
+
return [l1, ...thinkingLines];
|
|
310
|
+
},
|
|
311
|
+
invalidate: () => {},
|
|
312
|
+
};
|
|
313
|
+
});
|
|
314
|
+
}
|
package/src/ui/types.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI types and state for the supervisor widget.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { SupervisorIntervention } from '../types.js';
|
|
6
|
+
|
|
7
|
+
export type WidgetAction =
|
|
8
|
+
| { type: 'watching'; reframeTier?: number }
|
|
9
|
+
| { type: 'analyzing'; reframeTier?: number; thinking?: string }
|
|
10
|
+
| { type: 'steering'; message: string; reframeTier?: number }
|
|
11
|
+
| { type: 'done'; reframeTier?: number }
|
|
12
|
+
| { type: 'waiting'; message: string; reframeTier?: number }
|
|
13
|
+
| { type: 'inferring' };
|
|
14
|
+
|
|
15
|
+
/** Internal UI state for the widget */
|
|
16
|
+
export interface WidgetState {
|
|
17
|
+
widgetVisible: boolean;
|
|
18
|
+
lastActiveState: { outcome: string; interventions: SupervisorIntervention[] } | null;
|
|
19
|
+
lastThinking: string;
|
|
20
|
+
lastActionType: WidgetAction['type'];
|
|
21
|
+
storedAction: WidgetAction | null;
|
|
22
|
+
lastRenderedWidth: number;
|
|
23
|
+
lastThinkingLines: string[];
|
|
24
|
+
hiddenFromBottomCount: number;
|
|
25
|
+
clearTimer: ReturnType<typeof setTimeout> | null;
|
|
26
|
+
animationTimer: ReturnType<typeof setTimeout> | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Create initial widget state */
|
|
30
|
+
export function createInitialState(): WidgetState {
|
|
31
|
+
return {
|
|
32
|
+
widgetVisible: true,
|
|
33
|
+
lastActiveState: null,
|
|
34
|
+
lastThinking: '',
|
|
35
|
+
lastActionType: 'watching',
|
|
36
|
+
storedAction: null,
|
|
37
|
+
lastRenderedWidth: 80,
|
|
38
|
+
lastThinkingLines: [],
|
|
39
|
+
hiddenFromBottomCount: 0,
|
|
40
|
+
clearTimer: null,
|
|
41
|
+
animationTimer: null,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Constants for widget behavior */
|
|
46
|
+
export const WIDGET_ID = 'supervisor';
|
|
47
|
+
export const CLEAR_DELAY_MS = 15000;
|
|
48
|
+
export const ANIMATION_STEP_MS = 500;
|