@oh-my-pi/pi-coding-agent 15.1.7 → 15.1.8

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 CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.1.8] - 2026-05-20
6
+
7
+ ### Fixed
8
+
9
+ - Fixed streaming edit previews for `apply_patch` and `hashline` jittering as the model typed `+added` lines. Two root causes addressed: (1) the trailing partial line of the streaming text input is now trimmed at each tick so a half-typed `+added` line no longer flickers; (2) the preview is rendered in the model's input order during streaming instead of re-deriving a unified diff via `Diff.structuredPatch`, whose coalescing previously reshuffled existing `+added` lines downward each time a new `-removed` line arrived. Existing additions now stay put and the preview only grows at the bottom while streaming. A residual trailing `-removed`/hunk-header block whose matching `+added` companion has not yet arrived is also suppressed until the additions land.
10
+ - Fixed Perplexity web search appearing "logged out" roughly an hour after `omp auth login perplexity`. The search provider's `findOAuthToken` was honoring the bogus `expires = login_time + 1h` written by older logins (Perplexity JWTs typically omit `exp` because sessions are server-side) and silently dropping the credential. The loader now decodes the JWT's `exp` claim directly and only skips when the JWT itself is expired; tokens without an `exp` claim are treated as non-expiring.
11
+
5
12
  ## [15.1.7] - 2026-05-19
6
13
 
7
14
  ### Fixed
@@ -26,6 +26,13 @@ export interface StreamingDiffContext {
26
26
  fuzzyThreshold?: number;
27
27
  allowFuzzy?: boolean;
28
28
  hashlineAutoDropPureInsertDuplicates?: boolean;
29
+ /**
30
+ * True while the tool's arguments are still streaming in. Strategies that
31
+ * accept free-form text input (apply_patch, hashline) trim the trailing
32
+ * partial line so per-character growth of an in-flight `+added` line does
33
+ * not flicker in the preview.
34
+ */
35
+ isStreaming?: boolean;
29
36
  }
30
37
  export interface EditStreamingStrategy<Args = unknown> {
31
38
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "15.1.7",
4
+ "version": "15.1.8",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -47,12 +47,12 @@
47
47
  "@agentclientprotocol/sdk": "0.21.0",
48
48
  "@babel/parser": "^7.29.3",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/omp-stats": "15.1.7",
51
- "@oh-my-pi/pi-agent-core": "15.1.7",
52
- "@oh-my-pi/pi-ai": "15.1.7",
53
- "@oh-my-pi/pi-natives": "15.1.7",
54
- "@oh-my-pi/pi-tui": "15.1.7",
55
- "@oh-my-pi/pi-utils": "15.1.7",
50
+ "@oh-my-pi/omp-stats": "15.1.8",
51
+ "@oh-my-pi/pi-agent-core": "15.1.8",
52
+ "@oh-my-pi/pi-ai": "15.1.8",
53
+ "@oh-my-pi/pi-natives": "15.1.8",
54
+ "@oh-my-pi/pi-tui": "15.1.8",
55
+ "@oh-my-pi/pi-utils": "15.1.8",
56
56
  "@puppeteer/browsers": "^2.13.0",
57
57
  "@types/turndown": "5.0.6",
58
58
  "@xterm/headless": "^6.0.0",
@@ -45,6 +45,13 @@ export interface StreamingDiffContext {
45
45
  fuzzyThreshold?: number;
46
46
  allowFuzzy?: boolean;
47
47
  hashlineAutoDropPureInsertDuplicates?: boolean;
48
+ /**
49
+ * True while the tool's arguments are still streaming in. Strategies that
50
+ * accept free-form text input (apply_patch, hashline) trim the trailing
51
+ * partial line so per-character growth of an in-flight `+added` line does
52
+ * not flicker in the preview.
53
+ */
54
+ isStreaming?: boolean;
48
55
  }
49
56
 
50
57
  export interface EditStreamingStrategy<Args = unknown> {
@@ -274,21 +281,146 @@ interface HashlineArgs {
274
281
  __partialJson?: string;
275
282
  }
276
283
 
284
+ /**
285
+ * While streaming a free-form text payload (apply_patch envelope, hashline
286
+ * input), trim the trailing partial line so per-character growth of an
287
+ * in-flight `+added` line does not cause the diff preview to flicker. The
288
+ * full line will show on the next streaming tick once its `\n` arrives.
289
+ * Returns `text` unchanged when not streaming or when no newline is present.
290
+ */
291
+ function trimTrailingPartialLine(text: string, isStreaming: boolean | undefined): string {
292
+ if (!isStreaming) return text;
293
+ const idx = text.lastIndexOf("\n");
294
+ if (idx === -1) return "";
295
+ return text.slice(0, idx + 1);
296
+ }
297
+
298
+ /**
299
+ * Build a per-file diff preview directly from a partial `apply_patch`
300
+ * envelope by emitting its body lines in *input order*. This bypasses the
301
+ * file-state re-diff (`computePatchDiff` → `Diff.structuredPatch`) whose
302
+ * coalescing reorders the model's `-old +new -old +new` stream into
303
+ * `-old -old +new +new` and visibly shifts existing `+added` lines
304
+ * downward each time a new `-` arrives. The preview therefore grows
305
+ * monotonically at the bottom while streaming and only becomes a real
306
+ * unified diff once the args are complete.
307
+ */
308
+ function buildApplyPatchNaturalOrderPreviews(input: string): PerFileDiffPreview[] | null {
309
+ const lines = input.split("\n");
310
+ const groups = new Map<string, string[]>();
311
+ let currentPath: string | undefined;
312
+ const ensure = (path: string): string[] => {
313
+ let bucket = groups.get(path);
314
+ if (!bucket) {
315
+ bucket = [];
316
+ groups.set(path, bucket);
317
+ }
318
+ return bucket;
319
+ };
320
+ for (const raw of lines) {
321
+ const trimmedEnd = raw.trimEnd();
322
+ if (trimmedEnd === BEGIN_PATCH_MARKER || trimmedEnd === END_PATCH_MARKER || trimmedEnd === ABORT_MARKER) {
323
+ continue;
324
+ }
325
+ if (trimmedEnd.startsWith("*** Add File: ")) {
326
+ currentPath = trimmedEnd.slice("*** Add File: ".length);
327
+ ensure(currentPath);
328
+ continue;
329
+ }
330
+ if (trimmedEnd.startsWith("*** Delete File: ")) {
331
+ currentPath = trimmedEnd.slice("*** Delete File: ".length);
332
+ ensure(currentPath);
333
+ continue;
334
+ }
335
+ if (trimmedEnd.startsWith("*** Update File: ")) {
336
+ currentPath = trimmedEnd.slice("*** Update File: ".length);
337
+ ensure(currentPath);
338
+ continue;
339
+ }
340
+ if (trimmedEnd.startsWith("*** Move to:") || trimmedEnd.startsWith("*** End of File")) {
341
+ continue;
342
+ }
343
+ if (!currentPath) continue;
344
+ // Diff body: keep `-/+/space`-prefixed lines and `@@` hunk headers in
345
+ // input order. parseDiffLine accepts the no-line-number legacy form so
346
+ // the renderer styles them as additions/removals/context naturally.
347
+ if (raw.startsWith("+") || raw.startsWith("-") || raw.startsWith(" ") || raw.startsWith("@@")) {
348
+ ensure(currentPath).push(raw);
349
+ }
350
+ }
351
+ if (groups.size === 0) return null;
352
+ const previews: PerFileDiffPreview[] = [];
353
+ for (const [path, body] of groups) {
354
+ if (body.length === 0) continue;
355
+ previews.push({ path, diff: body.join("\n") });
356
+ }
357
+ return previews.length > 0 ? previews : null;
358
+ }
359
+
360
+ /**
361
+ * Hashline equivalent: emit each section's `~payload` lines as `+added`
362
+ * lines in the order the model typed them. We deliberately omit op headers
363
+ * and removal targets from the streaming preview because their content
364
+ * lives in the file and would require a costly re-apply per tick; the
365
+ * complete unified diff is shown once streaming finishes.
366
+ */
367
+ function buildHashlineNaturalOrderPreviews(
368
+ input: string,
369
+ defaultPath: string | undefined,
370
+ ): PerFileDiffPreview[] | null {
371
+ const lines = input.split("\n");
372
+ const groups = new Map<string, string[]>();
373
+ let currentPath = defaultPath ?? "";
374
+ const ensure = (path: string): string[] => {
375
+ let bucket = groups.get(path);
376
+ if (!bucket) {
377
+ bucket = [];
378
+ groups.set(path, bucket);
379
+ }
380
+ return bucket;
381
+ };
382
+ for (const raw of lines) {
383
+ if (isHashlineEnvelopeMarkerLine(raw)) continue;
384
+ if (isHashlineHeaderLine(raw)) {
385
+ currentPath = raw.trimEnd().slice(1).trim();
386
+ if (currentPath) ensure(currentPath);
387
+ continue;
388
+ }
389
+ if (raw.startsWith("~")) {
390
+ ensure(currentPath).push(`+${raw.slice(1)}`);
391
+ }
392
+ }
393
+ if (groups.size === 0) return null;
394
+ const previews: PerFileDiffPreview[] = [];
395
+ for (const [path, body] of groups) {
396
+ if (body.length === 0) continue;
397
+ previews.push({ path, diff: body.join("\n") });
398
+ }
399
+ return previews.length > 0 ? previews : null;
400
+ }
401
+
277
402
  const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
278
403
  extractCompleteEdits(args) {
279
404
  return args;
280
405
  },
281
406
  async computeDiffPreview(args, ctx) {
282
407
  if (typeof args.input !== "string" || args.input.length === 0) return null;
408
+ const input = trimTrailingPartialLine(args.input, ctx.isStreaming);
409
+ if (input.length === 0) return null;
410
+ if (ctx.isStreaming) {
411
+ // Skip the costly per-tick re-apply and avoid `Diff.structuredPatch`
412
+ // reordering by showing the model's `~payload` lines in input order.
413
+ return buildHashlineNaturalOrderPreviews(input, args.path);
414
+ }
283
415
  ctx.signal.throwIfAborted();
284
416
 
285
417
  let sections: HashlineInputSection[];
286
418
  try {
287
- sections = splitHashlineInputs(args.input, { cwd: ctx.cwd, path: args.path });
419
+ sections = splitHashlineInputs(input, { cwd: ctx.cwd, path: args.path });
288
420
  } catch {
289
421
  // Single-section fallback keeps the original error rendering for the
290
422
  // "haven't typed `@@ PATH` yet" case.
291
- const result = await computeHashlineDiff({ input: args.input, path: args.path }, ctx.cwd, {
423
+ const result = await computeHashlineDiff({ input, path: args.path }, ctx.cwd, {
292
424
  autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
293
425
  });
294
426
  ctx.signal.throwIfAborted();
@@ -340,12 +472,21 @@ const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
340
472
  },
341
473
  async computeDiffPreview(args, ctx) {
342
474
  if (typeof args.input !== "string" || args.input.length === 0) return null;
475
+ const input = trimTrailingPartialLine(args.input, ctx.isStreaming);
476
+ if (input.length === 0) return null;
477
+ if (ctx.isStreaming) {
478
+ // Render the envelope's diff body in input order so newly streamed
479
+ // `+added` lines append at the bottom instead of being shuffled
480
+ // upward as later `-removed` lines arrive and reorder the unified
481
+ // diff that `Diff.structuredPatch` would otherwise produce.
482
+ return buildApplyPatchNaturalOrderPreviews(input);
483
+ }
343
484
  let entries: ApplyPatchEntry[];
344
485
  try {
345
- entries = expandApplyPatchToEntries({ input: args.input });
486
+ entries = expandApplyPatchToEntries({ input });
346
487
  } catch {
347
488
  try {
348
- entries = expandApplyPatchToPreviewEntries({ input: args.input });
489
+ entries = expandApplyPatchToPreviewEntries({ input });
349
490
  } catch (err) {
350
491
  return [{ path: "", error: err instanceof Error ? err.message : String(err) }];
351
492
  }
@@ -51,6 +51,49 @@ function cloneToolArgs<T>(args: T): T {
51
51
  }
52
52
  }
53
53
 
54
+ /**
55
+ * Drop trailing removal/hunk-header lines that appear in a streaming diff
56
+ * before the matching `+added` lines have arrived. Without this, a partial
57
+ * apply_patch / hashline preview shows `-old` first and then visibly grows
58
+ * the `+new` block beneath it — the "removals first, additions catching up"
59
+ * jitter. Once the next streaming tick brings the additions in, the trailing
60
+ * block reappears alongside them.
61
+ */
62
+ function stripTrailingUnbalancedRemoval(diff: string | undefined): string | undefined {
63
+ if (!diff) return diff;
64
+ const lines = diff.split("\n");
65
+ let lastAddIdx = -1;
66
+ for (let i = lines.length - 1; i >= 0; i--) {
67
+ if (lines[i].startsWith("+")) {
68
+ lastAddIdx = i;
69
+ break;
70
+ }
71
+ }
72
+ let hasTrailingUnbalanced = false;
73
+ for (let i = lastAddIdx + 1; i < lines.length; i++) {
74
+ const line = lines[i];
75
+ if (line.startsWith("-") || line.startsWith("@@")) {
76
+ hasTrailingUnbalanced = true;
77
+ break;
78
+ }
79
+ }
80
+ if (!hasTrailingUnbalanced) return diff;
81
+ if (lastAddIdx === -1) return "";
82
+ return lines.slice(0, lastAddIdx + 1).join("\n");
83
+ }
84
+
85
+ function stabilizeStreamingPreviews(previews: PerFileDiffPreview[]): PerFileDiffPreview[] {
86
+ let changed = false;
87
+ const next = previews.map(preview => {
88
+ if (!preview.diff) return preview;
89
+ const trimmed = stripTrailingUnbalancedRemoval(preview.diff);
90
+ if (trimmed === preview.diff) return preview;
91
+ changed = true;
92
+ return { ...preview, diff: trimmed ?? "" };
93
+ });
94
+ return changed ? next : previews;
95
+ }
96
+
54
97
  function isEditLikeToolName(toolName: string): boolean {
55
98
  return toolName === "edit" || toolName === "apply_patch";
56
99
  }
@@ -222,16 +265,18 @@ export class ToolExecutionComponent extends Container {
222
265
  this.#editDiffAbort = controller;
223
266
 
224
267
  try {
268
+ const isStreaming = !this.#argsComplete;
225
269
  const previews = await strategy.computeDiffPreview(effectiveArgs, {
226
270
  cwd: this.#cwd,
227
271
  signal: controller.signal,
228
272
  fuzzyThreshold: this.#editFuzzyThreshold,
229
273
  allowFuzzy: this.#editAllowFuzzy,
230
274
  hashlineAutoDropPureInsertDuplicates: this.#hashlineAutoDropPureInsertDuplicates,
275
+ isStreaming,
231
276
  });
232
277
  if (controller.signal.aborted) return;
233
278
  if (previews) {
234
- this.#editDiffPreview = previews;
279
+ this.#editDiffPreview = isStreaming ? stabilizeStreamingPreviews(previews) : previews;
235
280
  this.#updateDisplay();
236
281
  this.#ui.requestRender();
237
282
  }
@@ -584,11 +584,17 @@ export class InteractiveMode implements InteractiveModeContext {
584
584
  if (!this.loopModeEnabled || !this.loopPrompt) return;
585
585
  const prompt = this.loopPrompt;
586
586
  const loopAction = settings.get("loop.mode");
587
+ this.#deferLoopAutoSubmit(() => {
588
+ void this.#runLoopIteration(loopAction, prompt);
589
+ });
590
+ }
591
+
592
+ #deferLoopAutoSubmit(callback: () => void): void {
587
593
  // Brief delay so the user has a chance to press Esc between iterations.
588
594
  this.#loopAutoSubmitTimer = setTimeout(() => {
589
595
  this.#loopAutoSubmitTimer = undefined;
590
596
  if (!this.loopModeEnabled || !this.onInputCallback) return;
591
- void this.#runLoopIteration(loopAction, prompt);
597
+ callback();
592
598
  }, 800);
593
599
  }
594
600
 
@@ -641,7 +647,32 @@ export class InteractiveMode implements InteractiveModeContext {
641
647
  }
642
648
  }
643
649
 
650
+ #isLoopAutoSubmitBlocked(): boolean {
651
+ return this.session.isStreaming || this.session.isCompacting;
652
+ }
653
+
654
+ #submitLoopPromptWhenReady(prompt: string): void {
655
+ if (!this.loopModeEnabled || this.loopPrompt !== prompt || !this.onInputCallback) return;
656
+ if (isLoopDurationExpired(this.loopLimit)) {
657
+ this.disableLoopMode("Loop time limit reached. Loop mode disabled.");
658
+ return;
659
+ }
660
+ if (this.#isLoopAutoSubmitBlocked()) {
661
+ this.#deferLoopAutoSubmit(() => this.#submitLoopPromptWhenReady(prompt));
662
+ return;
663
+ }
664
+ this.onInputCallback(this.startPendingSubmission({ text: prompt }));
665
+ }
666
+
644
667
  async #runLoopIteration(action: "prompt" | "compact" | "reset", prompt: string): Promise<void> {
668
+ if (!this.loopModeEnabled || this.loopPrompt !== prompt || !this.onInputCallback) return;
669
+ if (this.#isLoopAutoSubmitBlocked()) {
670
+ this.#deferLoopAutoSubmit(() => {
671
+ void this.#runLoopIteration(action, prompt);
672
+ });
673
+ return;
674
+ }
675
+
645
676
  if (!consumeLoopLimitIteration(this.loopLimit)) {
646
677
  this.disableLoopMode("Loop limit reached. Loop mode disabled.");
647
678
  return;
@@ -652,12 +683,7 @@ export class InteractiveMode implements InteractiveModeContext {
652
683
  } else if (action === "reset") {
653
684
  await this.handleClearCommand();
654
685
  }
655
- if (!this.loopModeEnabled || !this.onInputCallback) return;
656
- if (isLoopDurationExpired(this.loopLimit)) {
657
- this.disableLoopMode("Loop time limit reached. Loop mode disabled.");
658
- return;
659
- }
660
- this.onInputCallback(this.startPendingSubmission({ text: prompt }));
686
+ this.#submitLoopPromptWhenReady(prompt);
661
687
  }
662
688
 
663
689
  disableLoopMode(message = "Loop mode disabled."): void {
@@ -174,6 +174,25 @@ export function findApiKey(): string | null {
174
174
  return getEnvApiKey("perplexity") ?? null;
175
175
  }
176
176
 
177
+ /**
178
+ * Decode a Perplexity JWT's `exp` claim, in ms. Returns `undefined` when the
179
+ * token has no `exp` (which is the common case — Perplexity sessions are
180
+ * server-side and effectively non-expiring from the client's POV).
181
+ */
182
+ function jwtExpiryMs(token: string): number | undefined {
183
+ const parts = token.split(".");
184
+ if (parts.length !== 3) return undefined;
185
+ const payload = parts[1];
186
+ if (!payload) return undefined;
187
+ try {
188
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { exp?: unknown };
189
+ if (typeof decoded.exp !== "number" || !Number.isFinite(decoded.exp)) return undefined;
190
+ return decoded.exp * 1000;
191
+ } catch {
192
+ return undefined;
193
+ }
194
+ }
195
+
177
196
  async function findOAuthToken(): Promise<string | null> {
178
197
  const now = Date.now();
179
198
  try {
@@ -183,7 +202,11 @@ async function findOAuthToken(): Promise<string | null> {
183
202
  if (record.credential.type !== "oauth") continue;
184
203
  const credential = record.credential as PerplexityOAuthCredential;
185
204
  if (!credential.access) continue;
186
- if (credential.expires <= now + OAUTH_EXPIRY_BUFFER_MS) continue;
205
+ // Trust the JWT's own `exp` claim if it has one; otherwise treat as
206
+ // non-expiring. The stored `expires` field is unreliable: older logins
207
+ // wrote `loginTime + 1h` even though Perplexity JWTs typically lack `exp`.
208
+ const jwtExpiry = jwtExpiryMs(credential.access);
209
+ if (jwtExpiry !== undefined && jwtExpiry <= now + OAUTH_EXPIRY_BUFFER_MS) continue;
187
210
  return credential.access;
188
211
  }
189
212
  } catch {