@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,257 @@
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 { LoopIntervention, LoopState } 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: LoopState | 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
+ if (state.clearTimer) {
30
+ clearTimeout(state.clearTimer);
31
+ state.clearTimer = null;
32
+ }
33
+ if (state.animationTimer) {
34
+ clearTimeout(state.animationTimer);
35
+ state.animationTimer = null;
36
+ }
37
+
38
+ // Reset animation state for new thinking (streaming replacement)
39
+ if (hasNewThinking) {
40
+ state.hiddenFromBottomCount = 0;
41
+ state.lastThinkingLines = [];
42
+ }
43
+
44
+ // Always update last state first
45
+ if (supervisorState?.active) {
46
+ state.lastActiveState = {
47
+ outcome: supervisorState.outcome,
48
+ interventions: [...supervisorState.interventions],
49
+ };
50
+ state.lastActionType = action.type;
51
+ if (action.type === 'analyzing' && action.thinking) {
52
+ state.lastThinking = action.thinking;
53
+ }
54
+ }
55
+
56
+ // Handle inferring specially
57
+ if (action.type === 'inferring') {
58
+ if (state.widgetVisible) {
59
+ const inferState = { outcome: '', interventions: state.lastActiveState?.interventions ?? [] };
60
+ renderWithState(ctx, state, inferState, action, '', 0);
61
+ }
62
+ return;
63
+ }
64
+
65
+ const shouldAnimate = !supervisorState || !supervisorState.active || action.type === 'steering';
66
+
67
+ if (shouldAnimate && state.lastActiveState && state.lastThinkingLines.length > 0) {
68
+ const fallbackAction: WidgetAction =
69
+ action.type === 'steering'
70
+ ? { type: 'steering', message: '', reframeTier: action.reframeTier }
71
+ : { type: 'done', reframeTier: 0 };
72
+ state.lastActionType = fallbackAction.type;
73
+ state.storedAction = fallbackAction;
74
+
75
+ // Ensure lastThinkingLines is populated before the animation timer fires.
76
+ // renderWithState returns early when hideFromBottom > 0 (during animation),
77
+ // so we need to compute the lines here to ensure the animation can run.
78
+ if (state.lastThinking && state.lastThinkingLines.length === 0) {
79
+ state.lastThinkingLines = state.lastThinking.split('\n');
80
+ }
81
+
82
+ state.clearTimer = setTimeout(() => {
83
+ const boundRender: RenderFn = (ctx, snap, action, thinking, hideFromBottom) => {
84
+ renderWithState(ctx, state, snap, action, thinking, hideFromBottom);
85
+ };
86
+ startLineClearAnimation(ctx, state, boundRender);
87
+ }, CLEAR_DELAY_MS);
88
+ renderWithState(
89
+ ctx,
90
+ state,
91
+ state.lastActiveState,
92
+ fallbackAction,
93
+ state.lastThinking,
94
+ state.hiddenFromBottomCount
95
+ );
96
+ return;
97
+ }
98
+
99
+ if (!supervisorState || !supervisorState.active) {
100
+ state.lastThinkingLines = [];
101
+ ctx.ui.setWidget(WIDGET_ID, undefined);
102
+ return;
103
+ }
104
+
105
+ if (!state.widgetVisible) {
106
+ ctx.ui.setWidget(WIDGET_ID, undefined);
107
+ return;
108
+ }
109
+
110
+ renderWithState(
111
+ ctx,
112
+ state,
113
+ state.lastActiveState!,
114
+ action,
115
+ state.lastThinking,
116
+ state.hiddenFromBottomCount
117
+ );
118
+ }
119
+
120
+ /** Main render function - creates the widget content */
121
+ function renderWithState(
122
+ ctx: ExtensionContext,
123
+ widgetState: WidgetState,
124
+ snap: { outcome: string; interventions: LoopIntervention[] },
125
+ action: WidgetAction,
126
+ lastThinking: string,
127
+ hideFromBottom: number = 0
128
+ ): void {
129
+ ctx.ui.setWidget(WIDGET_ID, (tui, theme) => {
130
+ let actionStr: string;
131
+ let thinking = lastThinking;
132
+
133
+ switch (action.type) {
134
+ case 'watching':
135
+ actionStr = theme.fg('dim', 'watching');
136
+ break;
137
+ case 'analyzing':
138
+ actionStr = theme.fg('warning', `⟳ turn ${action.turn}`);
139
+ thinking = action.thinking ?? lastThinking;
140
+ break;
141
+ case 'steering':
142
+ actionStr = theme.fg('warning', 'steering');
143
+ break;
144
+ case 'done':
145
+ actionStr = theme.fg('accent', '✓ done');
146
+ break;
147
+ case 'waiting':
148
+ actionStr = theme.fg('warning', `⏳ ${action.message}`);
149
+ break;
150
+ case 'inferring':
151
+ actionStr = theme.fg('dim', 'scanning');
152
+ break;
153
+ }
154
+
155
+ const sep = theme.fg('dim', ' · ');
156
+ let headerText: string;
157
+ if (action.type === 'done') headerText = 'Closed';
158
+ else if (action.type === 'inferring') headerText = 'Inferring';
159
+ else headerText = 'Loop';
160
+ const header = `${theme.fg('accent', '◉')} ${theme.fg('accent', headerText)}`;
161
+ const hasGoal = snap.outcome.length > 0;
162
+ const goalLabel = hasGoal ? `${theme.fg('dim', 'Goal:')} ` : '';
163
+ const goalQuoteOpen = hasGoal ? theme.fg('muted', '"') : '';
164
+ const goalQuoteClose = hasGoal ? theme.fg('muted', '"') : '';
165
+
166
+ const steerCount = snap.interventions.length;
167
+ const steers = steerCount > 0 ? theme.fg('dim', `↗ ${steerCount}`) : '';
168
+ const reframeTier = 'reframeTier' in action ? (action.reframeTier ?? 0) : 0;
169
+ const reframeStr = reframeTier > 0 ? theme.fg('error', `↻${reframeTier}`) : '';
170
+ const suffixParts = [steers, reframeStr, actionStr].filter(Boolean);
171
+
172
+ const thinkingPrefix = theme.fg('dim', ' ');
173
+ const rawThinking = thinking;
174
+
175
+ return {
176
+ render: (width: number) => {
177
+ const paddedWidth = Math.max(0, width - 1);
178
+ widgetState.lastRenderedWidth = paddedWidth;
179
+ const suffix = suffixParts.length > 0 ? sep + suffixParts.join(sep) : '';
180
+
181
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '');
182
+
183
+ let line: string;
184
+ if (hasGoal) {
185
+ const prefix = header + sep + goalLabel + goalQuoteOpen;
186
+ const prefixWidth = stripAnsi(prefix).length;
187
+ const suffixWidth = stripAnsi(suffix).length;
188
+ const closeQuoteWidth = stripAnsi(goalQuoteClose).length;
189
+ const availableForGoal = Math.max(
190
+ 0,
191
+ paddedWidth - prefixWidth - suffixWidth - closeQuoteWidth
192
+ );
193
+ const rawGoal = snap.outcome;
194
+ const truncatedGoal = truncateToWidth(rawGoal, availableForGoal);
195
+ const goalText = theme.fg('muted', truncatedGoal);
196
+ line = prefix + goalText + goalQuoteClose + suffix;
197
+ } else {
198
+ const parts = [header, ...suffixParts].filter(Boolean);
199
+ line = parts.join(sep);
200
+ }
201
+ const l1 = truncateToWidth(line, paddedWidth);
202
+
203
+ // During animation, hide lines from the bottom
204
+ if (hideFromBottom > 0 && widgetState.lastThinkingLines.length > 0) {
205
+ const visibleCount = Math.max(0, widgetState.lastThinkingLines.length - hideFromBottom);
206
+ const visibleLines = widgetState.lastThinkingLines
207
+ .slice(0, visibleCount)
208
+ .map((ln) => theme.fg('dim', ln));
209
+ return [l1, ...visibleLines];
210
+ }
211
+
212
+ // For non-analyzing states (steering/done/waiting), preserve existing line breaks.
213
+ // This prevents animation jumps caused by width changes from different headers.
214
+ const isAnalyzing = action.type === 'analyzing';
215
+ if (!isAnalyzing && widgetState.lastThinkingLines.length > 0) {
216
+ const visibleLines = widgetState.lastThinkingLines.map((ln) => theme.fg('dim', ln));
217
+ return [l1, ...visibleLines];
218
+ }
219
+
220
+ // No thinking to display
221
+ if (!rawThinking) {
222
+ return [l1];
223
+ }
224
+
225
+ const thinkingIndent = stripAnsi(thinkingPrefix).length;
226
+ const thinkingWords = rawThinking.split(' ');
227
+ const thinkingLines: string[] = [];
228
+ const plainLines: string[] = [];
229
+ let currentThinkingLine = '';
230
+ let currentPlainLine = '';
231
+
232
+ for (const word of thinkingWords) {
233
+ const testLine = currentPlainLine ? `${currentPlainLine} ${word}` : word;
234
+ if (testLine.length <= paddedWidth - thinkingIndent) {
235
+ currentPlainLine = testLine;
236
+ currentThinkingLine = currentThinkingLine ? `${currentThinkingLine} ${word}` : word;
237
+ } else {
238
+ if (currentThinkingLine) {
239
+ thinkingLines.push(thinkingPrefix + theme.fg('dim', currentThinkingLine));
240
+ plainLines.push(' ' + currentPlainLine);
241
+ }
242
+ currentPlainLine = word;
243
+ currentThinkingLine = word;
244
+ }
245
+ }
246
+ if (currentThinkingLine) {
247
+ thinkingLines.push(thinkingPrefix + theme.fg('dim', currentThinkingLine));
248
+ plainLines.push(' ' + currentPlainLine);
249
+ }
250
+
251
+ widgetState.lastThinkingLines = plainLines;
252
+ return [l1, ...thinkingLines];
253
+ },
254
+ invalidate: () => {},
255
+ };
256
+ });
257
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Loop UI - status widget.
3
+ *
4
+ * Widget line 1: ◉ Loop · Goal: "..." · steers · action
5
+ * Widget line 2: dim thinking text while analyzing (temporary)
6
+ *
7
+ * Toggle visibility with toggleWidget().
8
+ */
9
+
10
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
11
+ import type { LoopState } from '../types.js';
12
+ import { createInitialState, type WidgetAction, WIDGET_ID } from './types.js';
13
+ import { toggleWidget as toggleWidgetImpl, updateUI as updateUIImpl } from './renderer.js';
14
+
15
+ // Module-level state instance
16
+ const state = createInitialState();
17
+
18
+ /** Toggle the widget on/off. Returns the new visibility state. */
19
+ export function toggleWidget(): boolean {
20
+ return toggleWidgetImpl(state);
21
+ }
22
+
23
+ /** Update footer + widget. Call this every time state or action changes. */
24
+ export function updateUI(
25
+ ctx: ExtensionContext,
26
+ supervisorState: LoopState | null,
27
+ action: WidgetAction = { type: 'watching' }
28
+ ): void {
29
+ return updateUIImpl(ctx, state, supervisorState, action);
30
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * UI types and state for the supervisor widget.
3
+ */
4
+
5
+ import type { LoopIntervention } from '../types.js';
6
+
7
+ export type WidgetAction =
8
+ | { type: 'watching'; reframeTier?: number }
9
+ | { type: 'analyzing'; turn: number; reframeTier?: number; thinking?: string }
10
+ | { type: 'steering'; message: string; reframeTier?: number }
11
+ | { type: 'done'; reframeTier?: number }
12
+ | { type: 'waiting'; message: string; turn: number; 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: LoopIntervention[] } | 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 = 'loop';
47
+ export const CLEAR_DELAY_MS = 15000;
48
+ export const ANIMATION_STEP_MS = 500;