@monotykamary/pi-loop 0.1.12 → 0.1.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-loop",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "A pi extension that closes the verification loop on task completion",
5
5
  "author": "monotykamary",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -85,6 +85,16 @@ export default function (pi: ExtensionAPI) {
85
85
  const widgetState = createInitialState();
86
86
  let currentCtx: ExtensionContext | undefined;
87
87
  let idleSteers = 0; // consecutive agent_end steers; reset on done/stop/new loop
88
+ let userInputEpoch = 0;
89
+
90
+ pi.on('input', (event) => {
91
+ if (event.source === 'interactive' || event.source === 'rpc') {
92
+ userInputEpoch++;
93
+ }
94
+ });
95
+ pi.on('before_agent_start', () => {
96
+ userInputEpoch++;
97
+ });
88
98
 
89
99
  // ---- Session lifecycle: restore state ----
90
100
 
@@ -212,6 +222,7 @@ export default function (pi: ExtensionAPI) {
212
222
  pi.on('agent_settled', async (_event, ctx) => {
213
223
  currentCtx = ctx;
214
224
  if (!state.isActive()) return;
225
+ const inputEpochAtStart = userInputEpoch;
215
226
 
216
227
  state.incrementTurnCount();
217
228
  const s = state.getState()!;
@@ -261,7 +272,7 @@ export default function (pi: ExtensionAPI) {
261
272
  const decision = await analyze(
262
273
  ctx,
263
274
  s,
264
- true /* always idle at agent_end */,
275
+ true /* fully settled checkpoint */,
265
276
  ineffectivePattern,
266
277
  undefined,
267
278
  (accumulated) => {
@@ -275,6 +286,16 @@ export default function (pi: ExtensionAPI) {
275
286
  }
276
287
  );
277
288
 
289
+ // A real user prompt supersedes a decision computed from the preceding
290
+ // settled snapshot. Do not race that prompt or steer from stale context.
291
+ if (userInputEpoch !== inputEpochAtStart) {
292
+ updateUI(ctx, widgetState, state.getState(), {
293
+ type: 'watching',
294
+ reframeTier: state.getReframeTier(),
295
+ });
296
+ return;
297
+ }
298
+
278
299
  if (decision.action === 'steer' && decision.message) {
279
300
  idleSteers++;
280
301
  state.addIntervention({
@@ -289,7 +310,7 @@ export default function (pi: ExtensionAPI) {
289
310
  message: decision.message,
290
311
  reframeTier: state.getReframeTier(),
291
312
  });
292
- pi.sendUserMessage(decision.message);
313
+ pi.sendUserMessage(decision.message, { deliverAs: 'followUp' });
293
314
  } else if (decision.action === 'done') {
294
315
  idleSteers = 0;
295
316
  state.resetReframeTier();
@@ -317,7 +338,7 @@ export default function (pi: ExtensionAPI) {
317
338
  message: steerMessage,
318
339
  reframeTier: state.getReframeTier(),
319
340
  });
320
- pi.sendUserMessage(steerMessage);
341
+ pi.sendUserMessage(steerMessage, { deliverAs: 'followUp' });
321
342
  } else {
322
343
  updateUI(ctx, widgetState, state.getState(), {
323
344
  type: 'watching',
@@ -175,6 +175,8 @@ function renderWithState(
175
175
  return {
176
176
  render: (width: number) => {
177
177
  const paddedWidth = Math.max(0, width - 1);
178
+ const fitLines = (lines: string[]) =>
179
+ lines.map((line) => truncateToWidth(line, paddedWidth, ''));
178
180
  widgetState.lastRenderedWidth = paddedWidth;
179
181
  const suffix = suffixParts.length > 0 ? sep + suffixParts.join(sep) : '';
180
182
 
@@ -206,7 +208,7 @@ function renderWithState(
206
208
  const visibleLines = widgetState.lastThinkingLines
207
209
  .slice(0, visibleCount)
208
210
  .map((ln) => theme.fg('dim', ln));
209
- return [l1, ...visibleLines];
211
+ return fitLines([l1, ...visibleLines]);
210
212
  }
211
213
 
212
214
  // For non-analyzing states (steering/done/waiting), preserve existing line breaks.
@@ -214,12 +216,12 @@ function renderWithState(
214
216
  const isAnalyzing = action.type === 'analyzing';
215
217
  if (!isAnalyzing && widgetState.lastThinkingLines.length > 0) {
216
218
  const visibleLines = widgetState.lastThinkingLines.map((ln) => theme.fg('dim', ln));
217
- return [l1, ...visibleLines];
219
+ return fitLines([l1, ...visibleLines]);
218
220
  }
219
221
 
220
222
  // No thinking to display
221
223
  if (!rawThinking) {
222
- return [l1];
224
+ return fitLines([l1]);
223
225
  }
224
226
 
225
227
  const thinkingIndent = stripAnsi(thinkingPrefix).length;
@@ -249,7 +251,7 @@ function renderWithState(
249
251
  }
250
252
 
251
253
  widgetState.lastThinkingLines = plainLines;
252
- return [l1, ...thinkingLines];
254
+ return fitLines([l1, ...thinkingLines]);
253
255
  },
254
256
  invalidate: () => {},
255
257
  };
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { updateUI } from '../src/ui/status-widget.js';
3
+
4
+ vi.mock('@earendil-works/pi-tui', () => ({
5
+ truncateToWidth: (text: string, width: number) => text.slice(0, Math.max(0, width)),
6
+ }));
7
+
8
+ const theme = { fg: (_color: string, text: string) => text };
9
+
10
+ describe('loop widget width', () => {
11
+ it('bounds long words and cached lines after a terminal shrink', () => {
12
+ const setWidget = vi.fn();
13
+ const ctx = {
14
+ ui: { setWidget, setStatus: vi.fn(), notify: vi.fn(), select: vi.fn() },
15
+ sessionManager: { getBranch: vi.fn(() => []) },
16
+ model: { provider: 'test', id: 'model' },
17
+ } as any;
18
+ const state = {
19
+ active: true,
20
+ outcome: 'goal',
21
+ interventions: [],
22
+ turnCount: 1,
23
+ justSteered: false,
24
+ lastAnalyzedTurn: 0,
25
+ snapshotBuffer: [],
26
+ reframeTier: 0,
27
+ lastSteerTurn: -1,
28
+ } as any;
29
+
30
+ updateUI(ctx, state, { type: 'analyzing', turn: 1, thinking: 'x'.repeat(200) });
31
+ let factory = setWidget.mock.calls.at(-1)[1];
32
+ factory(null, theme).render(100);
33
+
34
+ updateUI(ctx, state, { type: 'done' });
35
+ factory = setWidget.mock.calls.at(-1)[1];
36
+ const width = 20;
37
+ const lines = factory(null, theme).render(width);
38
+
39
+ expect(lines.every((line: string) => line.length <= width - 1)).toBe(true);
40
+ });
41
+ });