@vanillagreen/pi-qol 1.7.2 → 1.7.4

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/README.md CHANGED
@@ -76,7 +76,7 @@ Glyph style: each package exposes `glyphStyle` (`unicode` default, `ascii` for t
76
76
  | Show compact statusline | Render or disable the QOL statusline row. |
77
77
  | Replace built-in footer | Hide Pi's default footer while the QOL statusline is enabled. |
78
78
  | Use π prompt editor | Use the compact prompt editor. |
79
- | Show session name title | Show the session name above the prompt and in the tmux pane title. |
79
+ | Show session name title | Show the session name above the prompt and in the tmux pane title; refreshes as soon as Pi reports a session metadata change. |
80
80
  | Sync session name to tmux window name | Rename the tmux window to `π <session>`. |
81
81
  | Input bottom padding | Blank lines below the prompt. |
82
82
  | Show dirty marker | Append `*` to the branch when the worktree is dirty. |
@@ -85,8 +85,8 @@ Glyph style: each package exposes `glyphStyle` (`unicode` default, `ascii` for t
85
85
 
86
86
  | Setting | What it does |
87
87
  | --- | --- |
88
- | Newline-insert binding | Insert a newline instead of submitting. |
89
- | Fallback newline binding | Alternate binding for terminals that can't send the primary one. |
88
+ | shift+enter inserts newline | Insert a newline instead of submitting. |
89
+ | Fallback newline key | Alternate binding for terminals that can't send the primary one. |
90
90
  | Style pending queue preview | Highlight Pi's pending-queue preview with a green left bar. |
91
91
  | Style image chips | Render `[Image #N]` placeholders as distinct chips. |
92
92
  | Show attachment count | Show a status badge when the draft has image placeholders. |
@@ -129,7 +129,7 @@ Advanced: input cap, title length, output tokens, timeout, custom prompt templat
129
129
  | Enable session search | Register `/search` and the overlay. |
130
130
  | Session search shortcut | Configurable; set to `none` to disable. |
131
131
  | Result limit | Max matching prompts returned. |
132
- | Visible session rows | Rows shown before scrolling. |
132
+ | Visible session rows | Rows shown before scrolling; defaults to `8`. |
133
133
  | Preview snippets | Matching snippets shown on the preview screen. |
134
134
  | Session cache TTL | Seconds before the session list refreshes; `0` keeps it until you run `/search:refresh`. |
135
135
 
@@ -169,8 +169,8 @@ Off by default. When enabled, non-interactive matches are blocked.
169
169
 
170
170
  | Setting | What it does |
171
171
  | --- | --- |
172
- | Custom compaction summaries | Use QOL summaries instead of Pi's default. |
173
- | Compaction model | Summarizer model. Defaults to `current`, meaning Pi's active model; set a provider/model when you want a dedicated larger-context summarizer. |
172
+ | Custom compaction summaries | Use QOL summaries instead of Pi's default, with Pi-standard transient provider retries when available. |
173
+ | Compaction model | Summarizer model. Defaults to `current`, meaning Pi's active model; set a provider/model when you want a dedicated larger-context summarizer. Thinking suffixes through `:max` are accepted, and Pi-resolved header/environment authentication is forwarded. |
174
174
  | Compaction detail profile | `concise`, `balanced`, or `exhaustive`. |
175
175
  | Include previous summary | Pass the previous summary for iterative continuity. |
176
176
  | Fallback to Pi default compaction | Run Pi's default compaction if QOL's fails. |
@@ -1,5 +1,6 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
- import { complete, type Message } from "@earendil-works/pi-ai";
2
+ import type { Message } from "@earendil-works/pi-ai";
3
+ import { complete, retryAssistantCallCompat } from "./pi-ai-compat.js";
3
4
  import { convertToLlm, serializeConversation, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
5
  import {
5
6
  DEFAULT_BUDGET_GUARD_PERCENT,
@@ -115,7 +116,7 @@ async function summarizeWithRemote(endpoint: string, systemPrompt: string, promp
115
116
 
116
117
  export function resolveConfiguredModel(ctx: ExtensionContext, configured: string): any | undefined {
117
118
  if (!configured || configured.trim().toLowerCase() === "current") return ctx.model;
118
- const withoutThinking = configured.replace(/:(off|minimal|low|medium|high|xhigh)$/i, "");
119
+ const withoutThinking = configured.replace(/:(off|minimal|low|medium|high|xhigh|max)$/i, "");
119
120
  const slash = withoutThinking.indexOf("/");
120
121
  if (slash > 0) return ctx.modelRegistry.find(withoutThinking.slice(0, slash), withoutThinking.slice(slash + 1));
121
122
  const providers = [ctx.model?.provider, "google", "openai", "anthropic", "mistral", "moonshot", "cloudflare-ai-gateway", "cloudflare-workers-ai"].filter((value): value is string => typeof value === "string");
@@ -163,23 +164,34 @@ async function singleShotSummary(ctx: ExtensionContext, request: SummarizeReques
163
164
  if (!model) throw new Error(`Summary model not found: ${configuredModel}`);
164
165
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
165
166
  if (!auth.ok) throw new Error(auth.error);
166
- if (!auth.apiKey) throw new Error(`No API key for ${model.provider}`);
167
167
 
168
168
  const message: Message = {
169
169
  content: [{ text: promptText, type: "text" }],
170
170
  role: "user",
171
171
  timestamp: Date.now(),
172
172
  };
173
- const response = await complete(
174
- model,
175
- { messages: [message], systemPrompt: QOL_COMPACTION_SYSTEM_PROMPT },
176
- { apiKey: auth.apiKey, headers: auth.headers, maxTokens: options.maxTokens, signal: options.signal },
173
+ const response = await retryAssistantCallCompat(
174
+ () => complete(
175
+ model,
176
+ { messages: [message], systemPrompt: QOL_COMPACTION_SYSTEM_PROMPT },
177
+ { apiKey: auth.apiKey, env: auth.env, headers: auth.headers, maxTokens: options.maxTokens, signal: options.signal },
178
+ ),
179
+ options.signal,
180
+ {
181
+ onRetryScheduled: (attempt, maxAttempts, delayMs, errorMessage) => {
182
+ compactionNotify(ctx, `QOL compaction retry ${attempt}/${maxAttempts} in ${Math.ceil(delayMs / 1000)}s: ${errorMessage}`, "warning");
183
+ },
184
+ },
177
185
  );
186
+ if (response.stopReason === "error" || response.stopReason === "aborted") {
187
+ throw new Error(response.errorMessage || `Summary request stopped: ${response.stopReason}`);
188
+ }
178
189
  const summary = response.content
179
190
  .filter((content): content is { type: "text"; text: string } => content.type === "text")
180
191
  .map((content) => content.text)
181
192
  .join("\n")
182
193
  .trim();
194
+ if (!summary) throw new Error("Summary model returned no text");
183
195
  return { model: modelLabel(model), summary, via: "model" };
184
196
  }
185
197
 
@@ -1,5 +1,6 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
- import { complete, type Message } from "@earendil-works/pi-ai";
2
+ import type { Message } from "@earendil-works/pi-ai";
3
+ import { complete } from "./pi-ai-compat.js";
3
4
  import { BorderedLoader, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
5
  import { acquireVstackModalLock } from "./bridges.js";
5
6
  import { serializeMessagesForSummary } from "./compaction.js";
@@ -49,9 +50,7 @@ export async function runHandoff(args: string, ctx: ExtensionCommandContext): Pr
49
50
 
50
51
  const doGenerate = async () => {
51
52
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model!);
52
- if (!auth.ok || !auth.apiKey) {
53
- throw new Error(auth.ok ? `No API key for ${ctx.model!.provider}` : auth.error);
54
- }
53
+ if (!auth.ok) throw new Error(auth.error);
55
54
 
56
55
  const userMessage: Message = {
57
56
  role: "user",
@@ -67,7 +66,7 @@ export async function runHandoff(args: string, ctx: ExtensionCommandContext): Pr
67
66
  const response = await complete(
68
67
  ctx.model!,
69
68
  { systemPrompt: HANDOFF_SYSTEM_PROMPT, messages: [userMessage] },
70
- { apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
69
+ { apiKey: auth.apiKey, env: auth.env, headers: auth.headers, signal: loader.signal },
71
70
  );
72
71
 
73
72
  if (response.stopReason === "aborted") return null;
@@ -0,0 +1,37 @@
1
+ import * as piAi from "@earendil-works/pi-ai";
2
+
3
+ const dynamicImport = (specifier: string) => import(specifier);
4
+
5
+ export interface PiAiCompatDeps {
6
+ root?: any;
7
+ loadCompat?: () => Promise<any>;
8
+ }
9
+
10
+ type RetryAssistantCall = (
11
+ produce: () => Promise<any>,
12
+ policy: { enabled: boolean; maxRetries: number; baseDelayMs: number },
13
+ signal: AbortSignal | undefined,
14
+ callbacks?: {
15
+ onRetryScheduled?: (attempt: number, maxAttempts: number, delayMs: number, errorMessage: string) => void | Promise<void>;
16
+ onRetryAttemptStart?: () => void | Promise<void>;
17
+ onRetryFinished?: (success: boolean, attempt: number, finalError?: string) => void | Promise<void>;
18
+ },
19
+ ) => Promise<any>;
20
+
21
+ export async function complete(model: unknown, context: unknown, options?: unknown, deps: PiAiCompatDeps = {}): Promise<any> {
22
+ const rootComplete = (deps.root ?? piAi as any).complete;
23
+ if (typeof rootComplete === "function") return await rootComplete(model, context, options);
24
+ const compat = deps.loadCompat ? await deps.loadCompat() : await dynamicImport("@earendil-works/pi-ai/compat") as any;
25
+ return await compat.complete(model, context, options);
26
+ }
27
+
28
+ export async function retryAssistantCallCompat(
29
+ produce: () => Promise<any>,
30
+ signal: AbortSignal | undefined,
31
+ callbacks?: Parameters<RetryAssistantCall>[3],
32
+ deps: { root?: any } = {},
33
+ ): Promise<any> {
34
+ const candidate = (deps.root ?? piAi as any).retryAssistantCall as RetryAssistantCall | undefined;
35
+ if (typeof candidate !== "function") return produce();
36
+ return candidate(produce, { enabled: true, maxRetries: 3, baseDelayMs: 2000 }, signal, callbacks);
37
+ }
@@ -1,4 +1,5 @@
1
- import { complete, type Message } from "@earendil-works/pi-ai";
1
+ import type { Message } from "@earendil-works/pi-ai";
2
+ import { complete } from "./pi-ai-compat.js";
2
3
  import type { ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
3
4
  import { textFromContent } from "./agent-end.js";
4
5
  import { oneLine } from "./ansi.js";
@@ -20,6 +21,7 @@ type AutoRenameFallbackMode = "none" | "truncate" | "words";
20
21
 
21
22
  interface AutoRenameAuth {
22
23
  apiKey?: string;
24
+ env?: Record<string, string>;
23
25
  headers?: Record<string, string>;
24
26
  label: string;
25
27
  model: any;
@@ -262,7 +264,7 @@ async function cheapestAvailableAutoRenameModel(ctx: ExtensionContext): Promise<
262
264
  try {
263
265
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
264
266
  const headers = headerRecord(auth.headers);
265
- if (auth.ok && (auth.apiKey || headers)) return { apiKey: auth.apiKey, headers, label: modelLabel(model), model, source: "cheapest" };
267
+ if (auth.ok) return { apiKey: auth.apiKey, env: auth.env, headers, label: modelLabel(model), model, source: "cheapest" };
266
268
  } catch (error) {
267
269
  if (isStaleCtxError(error)) return undefined;
268
270
  // Try the next available model.
@@ -286,11 +288,7 @@ async function resolveAutoRenameModel(ctx: ExtensionContext, configured: string)
286
288
  autoRenameDebug(ctx, `auth unavailable for ${modelLabel(model)}: ${auth.error}`, "warning");
287
289
  return undefined;
288
290
  }
289
- if (!auth.apiKey && !headers) {
290
- autoRenameDebug(ctx, `no auth for ${modelLabel(model)}; use /login or models.json`, "warning");
291
- return undefined;
292
- }
293
- return { apiKey: auth.apiKey, headers, label: modelLabel(model), model, source: configured };
291
+ return { apiKey: auth.apiKey, env: auth.env, headers, label: modelLabel(model), model, source: configured };
294
292
  } catch (error) {
295
293
  if (isStaleCtxError(error)) return undefined;
296
294
  autoRenameDebug(ctx, `auth failed for ${modelLabel(model)}: ${stringifyError(error)}`, "warning");
@@ -330,7 +328,7 @@ export async function generateAutoRenameName(query: string, ctx: ExtensionContex
330
328
  const response = await complete(
331
329
  resolved.model,
332
330
  { messages: [message], systemPrompt: AUTO_RENAME_SYSTEM_PROMPT },
333
- { apiKey: resolved.apiKey, headers: resolved.headers, maxTokens, signal: controller.signal },
331
+ { apiKey: resolved.apiKey, env: resolved.env, headers: resolved.headers, maxTokens, signal: controller.signal },
334
332
  );
335
333
  if (response.stopReason === "error") {
336
334
  autoRenameDebug(ctx, `${resolved.label} failed: ${response.errorMessage ?? "unknown error"}`, "warning");
@@ -39,17 +39,19 @@ function formatModelName(ctx: ExtensionContext): string {
39
39
  return name;
40
40
  }
41
41
 
42
- type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
43
- const THINKING_TOKEN: Record<ThinkingLevel, "thinkingOff" | "thinkingMinimal" | "thinkingLow" | "thinkingMedium" | "thinkingHigh" | "thinkingXhigh"> = {
42
+ type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
43
+ type ThinkingThemeToken = "thinkingOff" | "thinkingMinimal" | "thinkingLow" | "thinkingMedium" | "thinkingHigh" | "thinkingXhigh" | "thinkingMax";
44
+ const THINKING_TOKEN: Record<ThinkingLevel, ThinkingThemeToken> = {
44
45
  off: "thinkingOff",
45
46
  minimal: "thinkingMinimal",
46
47
  low: "thinkingLow",
47
48
  medium: "thinkingMedium",
48
49
  high: "thinkingHigh",
49
50
  xhigh: "thinkingXhigh",
51
+ max: "thinkingMax",
50
52
  };
51
53
 
52
- function normalizeThinkingLevel(value: string | undefined): ThinkingLevel {
54
+ export function normalizeThinkingLevel(value: string | undefined): ThinkingLevel {
53
55
  switch ((value ?? "").toLowerCase()) {
54
56
  case "off": return "off";
55
57
  case "minimal": return "minimal";
@@ -57,10 +59,15 @@ function normalizeThinkingLevel(value: string | undefined): ThinkingLevel {
57
59
  case "medium": return "medium";
58
60
  case "high": return "high";
59
61
  case "xhigh": return "xhigh";
62
+ case "max": return "max";
60
63
  default: return "off";
61
64
  }
62
65
  }
63
66
 
67
+ export function thinkingThemeToken(value: string | undefined): ThinkingThemeToken {
68
+ return THINKING_TOKEN[normalizeThinkingLevel(value)];
69
+ }
70
+
64
71
  function formatWindow(tokens: number | undefined): string {
65
72
  if (!tokens || tokens <= 0) return "?";
66
73
  if (tokens >= 1_000_000) {
@@ -207,9 +214,10 @@ export function renderStatusLine(width: number, ctx: ExtensionContext, git: GitS
207
214
  const rightPlain = subagentMarker ? `${percentPlain} ${subagentMarker.plain}` : percentPlain;
208
215
  const percentColor = percent === null ? "muted" : percent <= 15 ? "error" : percent <= 30 ? "warning" : "success";
209
216
  const separatorColored = theme.fg("muted", statusSeparator);
217
+ const thinkingToken = thinkingThemeToken(thinkingLevel) as Parameters<Theme["fg"]>[0];
210
218
  const leftColored = caveman
211
- ? `${theme.fg("accent", projectChunk)}${separatorColored}${theme.fg(THINKING_TOKEN[thinkingLevel], thinkingChunk)}${separatorColored}${theme.fg(cavemanTone, cavemanGlyph)}${theme.fg("muted", contextSeparator)}${theme.fg("accent", contextChunk.trimStart())}`
212
- : `${theme.fg("accent", projectChunk)}${separatorColored}${theme.fg(THINKING_TOKEN[thinkingLevel], thinkingChunk)}${theme.fg("accent", contextChunk)}`;
219
+ ? `${theme.fg("accent", projectChunk)}${separatorColored}${theme.fg(thinkingToken, thinkingChunk)}${separatorColored}${theme.fg(cavemanTone, cavemanGlyph)}${theme.fg("muted", contextSeparator)}${theme.fg("accent", contextChunk.trimStart())}`
220
+ : `${theme.fg("accent", projectChunk)}${separatorColored}${theme.fg(thinkingToken, thinkingChunk)}${theme.fg("accent", contextChunk)}`;
213
221
  const right = subagentMarker ? `${theme.fg(percentColor, percentPlain)} ${subagentMarker.styled}` : theme.fg(percentColor, percentPlain);
214
222
  const minimumGap = 1;
215
223
  const gapWidth = Math.max(minimumGap, width - visibleWidth(leftPlain) - visibleWidth(rightPlain) - 2);
package/extensions/qol.ts CHANGED
@@ -668,6 +668,13 @@ export default function qol(pi: ExtensionAPI): void {
668
668
  pi.on("thinking_level_select", (_event, ctx) => {
669
669
  if (ctx.hasUI) requestRender();
670
670
  });
671
+ // Renames land immediately instead of waiting for the SESSION_TITLE_SYNC_INTERVAL_MS
672
+ // poll installed by installSessionTitleSync; the poll stays as the backstop for
673
+ // name changes Pi does not announce.
674
+ pi.on("session_info_changed", (_event, ctx) => {
675
+ if (!ctx.hasUI) return;
676
+ syncSessionTitle(ctx);
677
+ });
671
678
  pi.on("agent_start", (_event, ctx) => {
672
679
  clearIdleCompactionTimer();
673
680
  clearTmuxWindowMark();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-qol",
3
- "version": "1.7.2",
3
+ "version": "1.7.4",
4
4
  "description": "Pi quality-of-life extension: compact statusline/π prompt, reliable multiline input, styled pasted-image chips, session naming/search/context import, scheduled prompts, handoff, permission prompts, notifications, custom compaction, and a collapsed-thinking timer.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -79,7 +79,7 @@
79
79
  {
80
80
  "key": "showSessionNameTitle",
81
81
  "label": "Show session name title",
82
- "description": "Show the named session above the prompt. In tmux, update the current pane title to \"π <session>\" and enable a pane border title padded with non-breaking spaces so tmux does not trim it.",
82
+ "description": "Show the named session above the prompt. In tmux, update the current pane title to \"π <session>\" and enable a pane border title padded with non-breaking spaces so tmux does not trim it. The title refreshes as soon as Pi reports a session metadata change.",
83
83
  "type": "boolean",
84
84
  "default": true,
85
85
  "category": "Statusline",
@@ -452,7 +452,7 @@
452
452
  "label": "Visible session rows",
453
453
  "description": "Maximum number of sessions shown at once in the overlay before scrolling.",
454
454
  "type": "number",
455
- "default": 10,
455
+ "default": 8,
456
456
  "category": "Session Search",
457
457
  "apply": "live"
458
458
  },