@vanillagreen/pi-qol 2.1.0 → 2.2.0

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
@@ -14,11 +14,11 @@ A Pi extension for session controls, prompt editing and notifications. Users can
14
14
  source = "kendex"
15
15
  ```
16
16
 
17
- Restart Pi after installation. Use `kendex update-pi --check` to preview the installation.
17
+ Requires Pi 0.86.0 or newer. Restart Pi after installation. Use `kendex update-pi --check` to preview the installation.
18
18
 
19
19
  ## Features
20
20
 
21
- - Show repository, model and context information beside the editor.
21
+ - Show repository, model and context information beside the editor, with the working spinner before the project name.
22
22
  - Name and search sessions.
23
23
  - Schedule prompts and prepare handoff drafts.
24
24
  - Ask before configured shell commands run.
@@ -36,7 +36,7 @@ The settings editor writes project values to `.pi/settings.json`. The default us
36
36
  Open `/extensions:settings`; settings appear under the **QOL** tab. Project settings in `.pi/settings.json` apply only after Pi marks the workspace trusted. `glyphStyle` picks `unicode` or `ascii` symbols, and `@vanillagreen/pi-tool-renderer`'s `globalGlyphStyleOverride` wins when set.
37
37
 
38
38
  - `enabled`: package toggle for everything below.
39
- - Statusline and editor: `statusline.enabled`, `statusline.showAccount`, `replaceFooter`, `compactPrompt`, `showSessionNameTitle`, `showSessionNameWindow`, `inputBottomPaddingLines`, `gitRefreshTimeoutMs`, `showDirtyMarker`, `newlineOnShiftEnter`, `newlineFallbackKey`, `pendingQueue.asciiGreen`, `showImageChips`, `showAttachmentCountInStatus`.
39
+ - Statusline and editor: `statusline.enabled`, `statusline.showProvider`, `statusline.showAccount`, `replaceFooter`, `compactPrompt`, `showSessionNameTitle`, `showSessionNameWindow`, `inputBottomPaddingLines`, `gitRefreshTimeoutMs`, `showDirtyMarker`, `newlineOnShiftEnter`, `newlineFallbackKey`, `pendingQueue.asciiGreen`, `showImageChips`, `showAttachmentCountInStatus`.
40
40
  - Commands: `enableSessionNameCommand`, `enableHandoffCommand`, `enableScheduleCommand`, `enableContextCommand`, `handoffReviewPrompt`.
41
41
  - Session naming: `sessionAutoRename.*` (model, fallback model, deterministic fallback, prefix, prompt, limits, notify, debug).
42
42
  - Session search: `sessionSearch.*` (shortcut, result and row limits, snippets, overlay width, cache TTL, summary model and limits).
@@ -44,6 +44,9 @@ Open `/extensions:settings`; settings appear under the **QOL** tab. Project sett
44
44
  - Permission gate: `permissionGate.enabled`, `permissionGate.commands` (comma-separated literal fragments or `/regex/flags`), `permissionGate.previewLines`, `permissionGate.previewChars`.
45
45
  - Notifications: `notification.*` (triggers, channels, tmux options, protocol, cooldown, title and body).
46
46
  - Compaction and budget guard: `compaction.*` (custom summaries, model, profile, remote endpoint, branch summaries, idle trigger, budget guard thresholds, chunk input cap, handoff artifact, transcript-risk budget).
47
+ - Pi's own `compaction.enabled` (the top-level `compaction` object in the same settings files, not a QOL key) turns off every automatic compaction: when it is `false`, neither the budget guard nor the idle trigger starts one, and `/qol` reports both as disabled by that key. A manual `/compact` still runs. While it is `true` or absent, both keep the defaults and thresholds above: the budget guard on at `compaction.budgetPercent` 85, the idle trigger off until `compaction.idleEnabled` is set.
47
48
  - Thinking: `thinkingLabel.text`, `thinkingTimer.enabled`, `workingIndicator.mode` (`static` if the animated indicator flashes).
48
49
 
50
+ **Show model provider** displays a readable provider name before the model, such as `Copilot / GPT 6 Astra`. It is on by default; changes apply on the next render without reloading. Disabling the QOL statusline leaves Pi's standalone working indicator in place.
51
+
49
52
  Maintainer notes are in [DEVELOPMENT.md](DEVELOPMENT.md).
@@ -27,7 +27,7 @@ import {
27
27
  type HandoffWriteResult,
28
28
  type QolBudgetHandoff,
29
29
  } from "./compaction-handoff.js";
30
- import { settingBoolean, settingNumber, settingString } from "./settings.js";
30
+ import { piCompactionEnabled, settingBoolean, settingNumber, settingString } from "./settings.js";
31
31
  import { stringifyError } from "./util.js";
32
32
 
33
33
  export type QolSummaryProfile = "concise" | "balanced" | "exhaustive";
@@ -373,6 +373,22 @@ export function compactionTriggerReason(ctx: ExtensionContext): string | undefin
373
373
  return undefined;
374
374
  }
375
375
 
376
+ /** Whether a QOL automatic compaction trigger may fire, and what turned it
377
+ * off. Pi core's `compaction.enabled` is read first, so that one key stops
378
+ * every automatic compaction in Pi; a manual /compact never passes here. */
379
+ export type AutoCompactionGate = "enabled" | "disabled" | "pi-compaction-disabled";
380
+
381
+ const AUTO_COMPACTION_SWITCHES = {
382
+ budgetGuard: { key: "compaction.budgetGuardEnabled", fallback: true },
383
+ idle: { key: "compaction.idleEnabled", fallback: false },
384
+ } as const;
385
+
386
+ export function autoCompactionGate(trigger: keyof typeof AUTO_COMPACTION_SWITCHES, cwd: string): AutoCompactionGate {
387
+ if (!piCompactionEnabled(cwd)) return "pi-compaction-disabled";
388
+ const { key, fallback } = AUTO_COMPACTION_SWITCHES[trigger];
389
+ return settingBoolean(key, fallback, cwd) ? "enabled" : "disabled";
390
+ }
391
+
376
392
  export type BudgetGuardTrigger = BudgetTrigger;
377
393
  export type TranscriptRiskState = TranscriptRiskResult;
378
394
 
@@ -383,7 +399,7 @@ export type TranscriptRiskState = TranscriptRiskResult;
383
399
  * repeated triggers while usage stays above the threshold.
384
400
  */
385
401
  export function budgetGuardTrigger(ctx: ExtensionContext): BudgetGuardTrigger | undefined {
386
- if (!settingBoolean("compaction.budgetGuardEnabled", true, ctx.cwd)) return undefined;
402
+ if (autoCompactionGate("budgetGuard", ctx.cwd) !== "enabled") return undefined;
387
403
  const usage = contextUsage(ctx);
388
404
  if (!usage) return undefined;
389
405
  return computeBudgetTrigger({
@@ -101,7 +101,25 @@ function syncQolEditorStatus(
101
101
  }
102
102
  }
103
103
 
104
- export class QolEditor extends CustomEditor {
104
+ type WorkingStatusIndicator = Parameters<CustomEditor["setWorkingStatusIndicator"]>[0];
105
+
106
+ // Pi owns the indicator's animation and lifetime. Opt into its embedded-status
107
+ // delivery, but draw in the statusline widget rather than the editor border.
108
+ class QolStatusEditor extends CustomEditor {
109
+ private statuslineIndicator: WorkingStatusIndicator;
110
+
111
+ override setWorkingStatusIndicator(indicator: WorkingStatusIndicator): void {
112
+ this.statuslineIndicator = indicator;
113
+ }
114
+
115
+ renderWorkingStatus(width: number): string {
116
+ const indicator = this.statuslineIndicator;
117
+ if (!indicator || !this.embedWorkingStatus) return "";
118
+ return indicator.kind === "working" ? indicator.renderSpinnerInBorder(width) : indicator.renderInBorder(width);
119
+ }
120
+ }
121
+
122
+ export class QolEditor extends QolStatusEditor {
105
123
  private readonly statusCache: { last?: string } = {};
106
124
  private readonly ctx: ExtensionContext;
107
125
 
@@ -111,7 +129,7 @@ export class QolEditor extends CustomEditor {
111
129
  keybindings: KeybindingsManager,
112
130
  ctx: ExtensionContext,
113
131
  ) {
114
- super(tui, editorTheme, keybindings);
132
+ super(tui, editorTheme, keybindings, { embedWorkingStatus: settingBoolean("statusline.enabled", true, ctx.cwd) });
115
133
  this.ctx = ctx;
116
134
  }
117
135
 
@@ -130,7 +148,7 @@ function isEditorBorderLine(line: string): boolean {
130
148
  return visible.length > 0 && /^[─━╭╮╰╯┌┐└┘]+$/.test(visible);
131
149
  }
132
150
 
133
- export class QolCompactPromptEditor extends CustomEditor {
151
+ export class QolCompactPromptEditor extends QolStatusEditor {
134
152
  private readonly statusCache: { last?: string } = {};
135
153
  private readonly inputBottomPaddingLines: number;
136
154
  private readonly ctx: ExtensionContext;
@@ -142,7 +160,7 @@ export class QolCompactPromptEditor extends CustomEditor {
142
160
  inputBottomPaddingLines: number,
143
161
  ctx: ExtensionContext,
144
162
  ) {
145
- super(tui, editorTheme, keybindings, { paddingX: 0 });
163
+ super(tui, editorTheme, keybindings, { paddingX: 0, embedWorkingStatus: settingBoolean("statusline.enabled", true, ctx.cwd) });
146
164
  this.inputBottomPaddingLines = inputBottomPaddingLines;
147
165
  this.ctx = ctx;
148
166
  }
@@ -69,21 +69,49 @@ export function piSettingsPaths(cwd = process.cwd()): string[] {
69
69
  return projectSettingsTrusted(project) ? [user, project] : [user];
70
70
  }
71
71
 
72
- export function readPackageConfig(packageId: string, cwd?: string): Record<string, unknown> {
73
- const merged: Record<string, unknown> = {};
72
+ function isRecord(value: unknown): value is Record<string, unknown> {
73
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
74
+ }
75
+
76
+ /** Each settings file `piSettingsPaths` names that exists and parses, later
77
+ * files overriding earlier ones. A malformed file is skipped, as Pi's own
78
+ * settings load treats one as empty. */
79
+ function readPiSettingsFiles(cwd?: string): Record<string, any>[] {
80
+ const files: Record<string, any>[] = [];
74
81
  for (const settingsPath of piSettingsPaths(cwd)) {
75
82
  if (!existsSync(settingsPath)) continue;
76
83
  try {
77
84
  const parsed = JSON.parse(readFileSync(settingsPath, "utf8"));
78
- const config = parsed?.kendex?.extensionManager?.config?.[packageId];
79
- if (config && typeof config === "object" && !Array.isArray(config)) Object.assign(merged, config);
85
+ if (isRecord(parsed)) files.push(parsed);
80
86
  } catch {
81
- // Ignore malformed optional manager config.
87
+ // Ignore a malformed settings file.
82
88
  }
83
89
  }
90
+ return files;
91
+ }
92
+
93
+ export function readPackageConfig(packageId: string, cwd?: string): Record<string, unknown> {
94
+ const merged: Record<string, unknown> = {};
95
+ for (const settings of readPiSettingsFiles(cwd)) {
96
+ const config = settings.kendex?.extensionManager?.config?.[packageId];
97
+ if (isRecord(config)) Object.assign(merged, config);
98
+ }
84
99
  return merged;
85
100
  }
86
101
 
102
+ /** Pi core's `compaction.enabled` over the files QOL reads its own config
103
+ * from, resolved as Pi's `SettingsManager.getCompactionEnabled` resolves it:
104
+ * the last file that sets the key decides, a key no file sets is Pi's default,
105
+ * true, and any falsy value turns compaction off. */
106
+ export function piCompactionEnabled(cwd?: string): boolean {
107
+ let enabled: unknown;
108
+ for (const settings of readPiSettingsFiles(cwd)) {
109
+ const compaction = settings.compaction;
110
+ if (isRecord(compaction) && compaction.enabled !== undefined) enabled = compaction.enabled;
111
+ }
112
+ return Boolean(enabled ?? true);
113
+ }
114
+
87
115
  export function readkendexConfig(cwd?: string): kendexConfig {
88
116
  return readPackageConfig(CONFIG_ID, cwd) as kendexConfig;
89
117
  }
@@ -1,7 +1,9 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { compactionProfile } from "./compaction.js";
2
+ import { autoCompactionGate, compactionProfile, type AutoCompactionGate } from "./compaction.js";
3
3
  import {
4
4
  DEFAULT_AUTO_RENAME_MODEL,
5
+ DEFAULT_BUDGET_GUARD_PERCENT,
6
+ DEFAULT_BUDGET_GUARD_TOKENS,
5
7
  DEFAULT_COMPACTION_MODEL,
6
8
  DEFAULT_IDLE_COMPACTION_SECONDS,
7
9
  DEFAULT_PERMISSION_GATE_PREVIEW_CHARS,
@@ -15,6 +17,21 @@ import { autoRenameEnabled } from "./session-rename.js";
15
17
  import { sessionSearchShortcut } from "./session-search/index.js";
16
18
  import { boundedSettingNumber, newlineFallbackKey, settingBoolean, settingNumber, settingString } from "./settings.js";
17
19
 
20
+ function autoCompactionStatus(gate: AutoCompactionGate, enabled: () => string): string {
21
+ switch (gate) {
22
+ case "enabled":
23
+ return enabled();
24
+ case "disabled":
25
+ return "disabled";
26
+ case "pi-compaction-disabled":
27
+ return "disabled by Pi compaction.enabled=false";
28
+ default: {
29
+ const unknownGate: never = gate;
30
+ throw new Error(`pi-qol: unknown auto-compaction gate ${String(unknownGate)}`);
31
+ }
32
+ }
33
+ }
34
+
18
35
  export function statusMessage(ctx: ExtensionContext): string {
19
36
  const labels = attachmentLabels(currentEditorText(ctx), ctx.cwd);
20
37
  const searchShortcut = sessionSearchShortcut(ctx.cwd);
@@ -37,7 +54,8 @@ export function statusMessage(ctx: ExtensionContext): string {
37
54
  `Handoff prompt review: ${settingBoolean("handoffReviewPrompt", true, ctx.cwd) ? "enabled" : "disabled"}`,
38
55
  `Session search: ${settingBoolean("sessionSearch.enabled", true, ctx.cwd) ? `enabled (/search${searchShortcut ? `, ${searchShortcut}` : ""})` : "disabled"}`,
39
56
  `Custom compaction: ${settingBoolean("compaction.customEnabled", false, ctx.cwd) ? `enabled (${settingString("compaction.model", DEFAULT_COMPACTION_MODEL, ctx.cwd)}, ${compactionProfile(ctx.cwd)})` : "disabled (Pi default)"}`,
40
- `Idle compaction: ${settingBoolean("compaction.idleEnabled", false, ctx.cwd) ? `enabled after ${Math.max(1, Math.floor(settingNumber("compaction.idleTimeoutSeconds", DEFAULT_IDLE_COMPACTION_SECONDS, ctx.cwd)))}s idle` : "disabled"}`,
57
+ `Budget guard: ${autoCompactionStatus(autoCompactionGate("budgetGuard", ctx.cwd), () => `enabled (budgetPercent=${settingNumber("compaction.budgetPercent", DEFAULT_BUDGET_GUARD_PERCENT, ctx.cwd)}, budgetTokens=${settingNumber("compaction.budgetTokens", DEFAULT_BUDGET_GUARD_TOKENS, ctx.cwd)})`)}`,
58
+ `Idle compaction: ${autoCompactionStatus(autoCompactionGate("idle", ctx.cwd), () => `enabled after ${Math.max(1, Math.floor(settingNumber("compaction.idleTimeoutSeconds", DEFAULT_IDLE_COMPACTION_SECONDS, ctx.cwd)))}s idle`)}`,
41
59
  `Branch summary override: ${settingBoolean("compaction.branchSummaryEnabled", false, ctx.cwd) ? "enabled" : "disabled"}`,
42
60
  `Notifications: ${settingBoolean("notification.enabled", true, ctx.cwd) ? `enabled (bell=${settingBoolean("notification.bell", true, ctx.cwd)}, muteBell=${settingBoolean("notification.muteBellSound", false, ctx.cwd)}, native=${settingBoolean("notification.native", true, ctx.cwd)}, tmuxClientTty=${settingBoolean("notification.tmuxNativeClientTty", true, ctx.cwd)}, tmuxMessage=${settingBoolean("notification.tmux", false, ctx.cwd)})` : "disabled"}`,
43
61
  `Permission gate: ${settingBoolean("permissionGate.enabled", false, ctx.cwd) ? `enabled (${permissionGateCommands(ctx.cwd).join(", ") || "none configured"}; preview ${boundedSettingNumber("permissionGate.previewLines", DEFAULT_PERMISSION_GATE_PREVIEW_LINES, 4, 40, ctx.cwd)} lines/${boundedSettingNumber("permissionGate.previewChars", DEFAULT_PERMISSION_GATE_PREVIEW_CHARS, 200, 5000, ctx.cwd)} chars)` : "disabled"}`,
@@ -39,6 +39,34 @@ function formatModelName(ctx: ExtensionContext): string {
39
39
  return name;
40
40
  }
41
41
 
42
+ const PROVIDER_NAMES: Record<string, string> = {
43
+ "github-copilot": "Copilot",
44
+ "openai": "OpenAI",
45
+ "openai-codex": "Codex",
46
+ "anthropic": "Anthropic",
47
+ "pi-claude": "Claude",
48
+ "google": "Google",
49
+ "google-gemini-cli": "Gemini CLI",
50
+ "google-antigravity": "Antigravity",
51
+ "google-vertex": "Vertex AI",
52
+ "amazon-bedrock": "Bedrock",
53
+ "azure-openai-responses": "Azure OpenAI",
54
+ "openrouter": "OpenRouter",
55
+ "vercel-ai-gateway": "Vercel AI Gateway",
56
+ "xai": "xAI",
57
+ "groq": "Groq",
58
+ "cerebras": "Cerebras",
59
+ "mistral": "Mistral",
60
+ "deepseek": "DeepSeek",
61
+ "zai": "Z.AI",
62
+ "ollama": "Ollama",
63
+ };
64
+
65
+ function formatProviderName(provider: string): string {
66
+ const name = stripAnsi(provider).replace(/[\x00-\x1f\x7f]/g, " ").trim();
67
+ return Object.hasOwn(PROVIDER_NAMES, name) ? PROVIDER_NAMES[name] : name.replace(/[-_]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()).replace(/\s+/g, " ").trim();
68
+ }
69
+
42
70
  type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
43
71
  type ThinkingThemeToken = "thinkingOff" | "thinkingMinimal" | "thinkingLow" | "thinkingMedium" | "thinkingHigh" | "thinkingXhigh" | "thinkingMax";
44
72
  const THINKING_TOKEN: Record<ThinkingLevel, ThinkingThemeToken> = {
@@ -204,24 +232,23 @@ function cavemanIconTone(mode: string, active: boolean): "muted" | "text" | "suc
204
232
  }
205
233
  }
206
234
 
207
- export function renderStatusLine(width: number, ctx: ExtensionContext, git: GitState, pi: ExtensionAPI, theme: Pick<Theme, "fg">): string {
235
+ export function renderStatusLine(width: number, ctx: ExtensionContext, git: GitState, pi: ExtensionAPI, theme: Pick<Theme, "fg">, workingIndicator = ""): string {
208
236
  const { label: contextLabel, percent } = statuslineContextInfo(ctx);
209
- const projectChunk = `${git.projectName}${gitBadge(git, settingBoolean("showDirtyMarker", true, ctx.cwd))} ${formatModelName(ctx)}`;
237
+ const provider = settingBoolean("statusline.showProvider", true, ctx.cwd) && ctx.model ? formatProviderName(ctx.model.provider) : "";
238
+ const projectChunk = `${git.projectName}${gitBadge(git, settingBoolean("showDirtyMarker", true, ctx.cwd))} ${provider ? `${provider} / ` : ""}${formatModelName(ctx)}`;
239
+ const workingPrefix = workingIndicator ? `${workingIndicator} ` : "";
210
240
  const statusSeparator = " / ";
211
241
  const thinkingLevel = normalizeThinkingLevel(pi.getThinkingLevel());
212
242
  const thinkingChunk = thinkingLevel;
213
243
  const contextChunk = ` ${contextLabel}`;
214
244
  const account = accountLabel(ctx);
215
- const accountSegment = account ? `${statusSeparator}${account}` : "";
216
245
  const cavemanBridge = readCavemanBridge();
217
246
  const cavemanVisible = !!cavemanBridge && (cavemanBridge.isStatusBadgeEnabled?.(ctx.cwd) ?? true);
218
247
  const caveman = cavemanVisible ? cavemanBridge : undefined;
219
248
  const cavemanActive = caveman?.isActive() ?? false;
220
249
  const cavemanGlyph = caveman ? (cavemanActive ? CAVEMAN_ICON_ACTIVE : CAVEMAN_ICON_INACTIVE) : "";
221
250
  const cavemanTone = cavemanIconTone(caveman?.getMode() ?? "off", cavemanActive);
222
- const cavemanSegment = caveman ? `${statusSeparator}${cavemanGlyph}` : "";
223
251
  const contextSeparator = caveman ? ` ${statusSeparator}` : "";
224
- const leftPlain = `${projectChunk}${statusSeparator}${thinkingChunk}${cavemanSegment}${contextSeparator}${contextChunk.trimStart()}${accountSegment}`;
225
252
  const percentPlain = percent === null ? `${glyphs(ctx.cwd).ellipsis}%` : `${percent}%`;
226
253
  const subagentMarker = subagentStatuslineMarker(ctx.cwd);
227
254
  const rightPlain = subagentMarker ? `${percentPlain} ${subagentMarker.plain}` : percentPlain;
@@ -234,9 +261,9 @@ export function renderStatusLine(width: number, ctx: ExtensionContext, git: GitS
234
261
  : `${theme.fg("accent", projectChunk)}${separatorColored}${theme.fg(thinkingToken, thinkingChunk)}${theme.fg("accent", contextChunk)}${accountColored}`;
235
262
  const right = subagentMarker ? `${theme.fg(percentColor, percentPlain)} ${subagentMarker.styled}` : theme.fg(percentColor, percentPlain);
236
263
  const minimumGap = 1;
237
- const gapWidth = Math.max(minimumGap, width - visibleWidth(leftPlain) - visibleWidth(rightPlain) - 2);
264
+ const gapWidth = Math.max(minimumGap, width - visibleWidth(workingPrefix + leftColored) - visibleWidth(rightPlain) - 2);
238
265
  const filled = percent === null ? 0 : Math.round(gapWidth * (percent / 100));
239
266
  const empty = Math.max(0, gapWidth - filled);
240
267
  const bar = " ".repeat(empty) + theme.fg("warning", glyphs(ctx.cwd).line.repeat(filled));
241
- return truncateToWidth(`${leftColored} ${bar} ${right}`, width, "");
268
+ return truncateToWidth(`${workingPrefix}${leftColored} ${bar} ${right}`, width, "");
242
269
  }
package/extensions/qol.ts CHANGED
@@ -5,6 +5,7 @@ import { getQuestionService, readCavemanBridge, type QuestionOpenedEventLike } f
5
5
  import { BudgetGuardDriver } from "./qol/budget-guard-runtime.js";
6
6
  import { ansiGreen } from "./qol/ansi.js";
7
7
  import {
8
+ autoCompactionGate,
8
9
  budgetGuardTrigger,
9
10
  compactionTriggerReason,
10
11
  handleQolBranchSummary,
@@ -120,6 +121,7 @@ export default function qol(pi: ExtensionAPI): void {
120
121
  let autoRenameInProgress = false;
121
122
  let autoRenameGeneration = 0;
122
123
  let latestSystemPromptOptions: unknown;
124
+ let statusEditor: QolEditor | QolCompactPromptEditor | undefined;
123
125
 
124
126
  const resetAutoRename = () => {
125
127
  autoRenameAttempted = false;
@@ -216,15 +218,9 @@ export default function qol(pi: ExtensionAPI): void {
216
218
  thinkingTimerStore.enabled = !!ctx?.hasUI && settingBoolean("thinkingTimer.enabled", true, ctx?.cwd);
217
219
  };
218
220
 
219
- // Working indicator mode.
220
- // The built-in pi-tui Loader ticks every 80ms during streaming. Each tick
221
- // mutates a line in statusContainer; once total rendered content exceeds the
222
- // terminal viewport (overlay content like /tree, or chat overflow), every tick
223
- // trips pi-tui's firstChanged < prevViewportTop branch and triggers a full
224
- // screen + scrollback clear (visible flash). This setting lets the user trade
225
- // the spinner animation away for a stable display in overflow scenarios.
226
- // Implementation note: Loader.restartAnimation() bails out when frames.length
227
- // is <= 1, so a single-frame indicator does NOT start the setInterval at all.
221
+ // Pi's Loader starts no animation timer for a single-frame indicator.
222
+ // Keep static mode available for terminals where frequent redraws flash,
223
+ // whether the indicator is embedded in QOL or in Pi's standalone row.
228
224
  const applyWorkingIndicatorMode = (ctx: ExtensionContext): void => {
229
225
  if (!ctx.hasUI) return;
230
226
  const mode = settingString("workingIndicator.mode", "animated", ctx.cwd);
@@ -334,7 +330,7 @@ export default function qol(pi: ExtensionAPI): void {
334
330
 
335
331
  const scheduleIdleCompaction = (ctx: ExtensionContext) => {
336
332
  clearIdleCompactionTimer();
337
- if (!settingBoolean("compaction.idleEnabled", false, ctx.cwd)) return;
333
+ if (autoCompactionGate("idle", ctx.cwd) !== "enabled") return;
338
334
  const reason = compactionTriggerReason(ctx);
339
335
  if (!reason) return;
340
336
  const delayMs = Math.max(1, Math.floor(settingNumber("compaction.idleTimeoutSeconds", DEFAULT_IDLE_COMPACTION_SECONDS, ctx.cwd))) * 1000;
@@ -546,6 +542,7 @@ export default function qol(pi: ExtensionAPI): void {
546
542
  ctx.ui.setHeader(undefined);
547
543
  ctx.ui.setFooter(undefined);
548
544
  activeTui = undefined;
545
+ statusEditor = undefined;
549
546
  };
550
547
 
551
548
  const maybeNotifyTaskCompletion = (_ctx: ExtensionContext, state: any) => {
@@ -614,9 +611,10 @@ export default function qol(pi: ExtensionAPI): void {
614
611
  installSessionTitleSync(ctx);
615
612
  ctx.ui.setEditorComponent((tui, theme, keybindings) => {
616
613
  activeTui = tui;
617
- return settingBoolean("compactPrompt", true, ctx.cwd)
614
+ statusEditor = settingBoolean("compactPrompt", true, ctx.cwd)
618
615
  ? new QolCompactPromptEditor(tui, theme, keybindings, Math.max(0, Math.floor(settingNumber("inputBottomPaddingLines", DEFAULT_INPUT_BOTTOM_PADDING_LINES, ctx.cwd))), ctx)
619
616
  : new QolEditor(tui, theme, keybindings, ctx);
617
+ return statusEditor;
620
618
  });
621
619
  if (showStatusline || scheduleEnabled || rateLimitAutoResumeEnabled) {
622
620
  const statusWidgetTimer = setTimeout(() => {
@@ -628,7 +626,7 @@ export default function qol(pi: ExtensionAPI): void {
628
626
  const lines = rateLimitAutoResumeController.enabled(ctx) ? rateLimitAutoResumeController.renderPreviewLines(width) : [];
629
627
  if (scheduleEnabled) lines.push(...scheduleController.renderPreviewLines(width));
630
628
  if (budgetGuardStatus) lines.push(truncateToWidth(ansiGreen(`┃ ${budgetGuardStatus}`), width, ""));
631
- if (showStatusline) lines.push(renderStatusLine(width, ctx, gitState ?? makeFallbackGitState(ctx.cwd), pi, theme));
629
+ if (showStatusline) lines.push(renderStatusLine(width, ctx, gitState ?? makeFallbackGitState(ctx.cwd), pi, theme, statusEditor?.renderWorkingStatus(width)));
632
630
  return lines;
633
631
  },
634
632
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-qol",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
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": [
@@ -56,6 +56,15 @@
56
56
  "apply": "reload",
57
57
  "requiresReload": true
58
58
  },
59
+ {
60
+ "key": "statusline.showProvider",
61
+ "label": "Show model provider",
62
+ "description": "Show a readable provider name before the model, for example Copilot / GPT 6 Astra.",
63
+ "type": "boolean",
64
+ "default": true,
65
+ "category": "Statusline",
66
+ "apply": "live"
67
+ },
59
68
  {
60
69
  "key": "statusline.showAccount",
61
70
  "label": "Show account email",
@@ -839,7 +848,7 @@
839
848
  {
840
849
  "key": "compaction.idleEnabled",
841
850
  "label": "Idle compaction trigger",
842
- "description": "Trigger Pi compaction after the session sits idle above the configured token threshold. Inspired by oh-my-pi idle compaction.",
851
+ "description": "Trigger Pi compaction after the session sits idle above the configured token threshold. Never fires while Pi's own compaction.enabled is false. Inspired by oh-my-pi idle compaction.",
843
852
  "type": "boolean",
844
853
  "default": false,
845
854
  "category": "Compaction Idle",
@@ -884,7 +893,7 @@
884
893
  {
885
894
  "key": "compaction.budgetGuardEnabled",
886
895
  "label": "Long-session budget guard",
887
- "description": "Start bounded compaction when an active session crosses the configured context budget, while giving Pi's built-in compaction priority. Designed for long autonomous runs so transcript growth cannot silently hit provider or buffer limits. Fires once per threshold crossing, not in a loop.",
896
+ "description": "Start bounded compaction when an active session crosses the configured context budget, while giving Pi's built-in compaction priority. Designed for long autonomous runs so transcript growth cannot silently hit provider or buffer limits. Fires once per threshold crossing, not in a loop. Never fires while Pi's own compaction.enabled is false.",
888
897
  "type": "boolean",
889
898
  "default": true,
890
899
  "category": "Compaction Budget Guard",
@@ -957,7 +966,7 @@
957
966
  {
958
967
  "key": "workingIndicator.mode",
959
968
  "label": "Working indicator mode",
960
- "description": "Control Pi's built-in streaming spinner. The default animated spinner ticks every 80ms; in terminals where rendered content overflows the viewport (chat scrolled past the top, /tree open, etc.) each tick can trigger a pi-tui full-screen redraw and visible flash. Use 'static' for a single non-animating dot that keeps the working row visible without the 80ms timer.",
969
+ "description": "Control Pi's built-in streaming spinner. The default animated spinner ticks every 80ms; in terminals where rendered content overflows the viewport (chat scrolled past the top, /tree open, etc.) each tick can trigger a pi-tui full-screen redraw and visible flash. Use 'static' for a single non-animating dot that keeps the working indicator visible without the 80ms timer. It appears before the project name when the QOL statusline is enabled, or in Pi's working row otherwise.",
961
970
  "type": "enum",
962
971
  "enumValues": [
963
972
  "animated",
@@ -974,7 +983,7 @@
974
983
  "peerDependencies": {
975
984
  "@earendil-works/pi-agent-core": "*",
976
985
  "@earendil-works/pi-ai": "*",
977
- "@earendil-works/pi-coding-agent": ">=0.80.4",
986
+ "@earendil-works/pi-coding-agent": ">=0.86.0",
978
987
  "@earendil-works/pi-tui": "*"
979
988
  },
980
989
  "files": [