@vanillagreen/pi-qol 2.1.1 → 2.2.1

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).
@@ -47,4 +47,6 @@ Open `/extensions:settings`; settings appear under the **QOL** tab. Project sett
47
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.
48
48
  - Thinking: `thinkingLabel.text`, `thinkingTimer.enabled`, `workingIndicator.mode` (`static` if the animated indicator flashes).
49
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
+
50
52
  Maintainer notes are in [DEVELOPMENT.md](DEVELOPMENT.md).
@@ -33,6 +33,8 @@ export const DEFAULT_NOTIFICATION_TITLE = "Pi";
33
33
  export const DEFAULT_NOTIFICATION_COOLDOWN_SECONDS = 8;
34
34
  export const DEFAULT_NOTIFICATION_BODY_MAX_CHARS = 240;
35
35
  export const DEFAULT_TMUX_MESSAGE_DURATION_MS = 5000;
36
+ /** Deadline for each tmux call a notification makes; a stalled server is skipped, not waited on. */
37
+ export const TMUX_COMMAND_TIMEOUT_MS = 1000;
36
38
 
37
39
  export const DEFAULT_COMPACTION_MODEL = "current";
38
40
  export const DEFAULT_COMPACTION_MAX_TOKENS = 8192;
@@ -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
  }
@@ -1,18 +1,22 @@
1
- import { execFile, execFileSync } from "node:child_process";
2
- import { writeFileSync } from "node:fs";
3
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { execFile } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { open } from "node:fs/promises";
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
5
  import {
5
6
  DEFAULT_NOTIFICATION_BODY_MAX_CHARS,
6
7
  DEFAULT_NOTIFICATION_COOLDOWN_SECONDS,
7
8
  DEFAULT_NOTIFICATION_TITLE,
8
9
  DEFAULT_TMUX_MESSAGE_DURATION_MS,
9
10
  QUESTION_NOTIFY_DEDUP_MS,
11
+ TMUX_COMMAND_TIMEOUT_MS,
10
12
  } from "./constants.js";
11
13
  import { settingBoolean, settingNumber, settingString } from "./settings.js";
12
14
  import type { QuestionOpenedEventLike, QuestionRequestLike } from "./bridges.js";
13
15
 
14
16
  export type QolNotificationKind = "ready" | "direction" | "question" | "task-complete" | "critical" | "test";
15
17
  export type QolNotificationLevel = "info" | "warning" | "error";
18
+ /** The one Pi call notifications need: `pi.exec`, whose `timeout` kills a stalled tmux. */
19
+ export type QolNotificationExec = Pick<ExtensionAPI, "exec">;
16
20
 
17
21
  export interface QolNotificationService {
18
22
  notifyQuestionOpened(ctx: ExtensionContext | undefined, event: QuestionOpenedEventLike): boolean;
@@ -65,54 +69,93 @@ function notificationBellMuted(cwd?: string): boolean {
65
69
  return settingBoolean("notification.muteBellSound", false, cwd);
66
70
  }
67
71
 
68
- function sourcePaneTty(): string | undefined {
69
- try {
70
- if (!process.env.TMUX_PANE) return undefined;
71
- const tty = execFileSync("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE, "#{pane_tty}"], { encoding: "utf8" }).trim();
72
- return tty || undefined;
73
- } catch {
74
- return undefined;
75
- }
72
+ /** The source pane as one tmux query reports it when a notification fires. */
73
+ interface TmuxPaneState {
74
+ paneTty: string;
75
+ windowActive: boolean;
76
+ sessionId: string;
77
+ windowId: string;
78
+ windowName: string;
76
79
  }
77
80
 
78
- function sourceTmuxSession(): string | undefined {
81
+ /** `#W` is last: a window name may hold a tab, so every field after the fourth is the name. */
82
+ const PANE_STATE_FORMAT = "#{pane_tty}\t#{window_active}\t#{session_id}\t#{window_id}\t#W";
83
+
84
+ /**
85
+ * Every terminal write joins this chain and runs alone, in queue order. Each
86
+ * write opens its tty non-blocking, so a full terminal fails the write rather
87
+ * than holding it, and the chain always advances.
88
+ */
89
+ let terminalWrites: Promise<void> = Promise.resolve();
90
+ /** Bumped by `clearTmuxWindowMark`; a notification decided before the bump does not mark. */
91
+ let tmuxMarkGeneration = 0;
92
+
93
+ /**
94
+ * Runs one tmux command under `TMUX_COMMAND_TIMEOUT_MS`. Undefined means no
95
+ * answer: a non-zero exit, a timeout, or a Pi runtime that went stale after
96
+ * session replacement (`pi.exec` then throws). Pi reports a command its
97
+ * timeout killed with `killed` and, when the signal left no exit status,
98
+ * with code 0, so `killed` is read first.
99
+ */
100
+ async function tmux(pi: QolNotificationExec, args: string[]): Promise<string | undefined> {
79
101
  try {
80
- if (!process.env.TMUX_PANE) return undefined;
81
- const session = execFileSync("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE, "#{session_name}"], { encoding: "utf8" }).trim();
82
- return session || undefined;
102
+ const result = await pi.exec("tmux", args, { timeout: TMUX_COMMAND_TIMEOUT_MS });
103
+ if (result.killed || result.code !== 0) return undefined;
104
+ return result.stdout;
83
105
  } catch {
84
106
  return undefined;
85
107
  }
86
108
  }
87
109
 
88
- export function sourceTmuxWindowActive(): boolean {
89
- try {
90
- if (!process.env.TMUX_PANE) return false;
91
- const active = execFileSync("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE, "#{window_active}"], { encoding: "utf8" }).trim();
92
- return active === "1";
93
- } catch {
94
- return false;
95
- }
110
+ /**
111
+ * Reads the source pane once per notification. The session and window are
112
+ * read fresh each time: tmux `break-pane`, `join-pane` and `move-window` move
113
+ * a pane to another window or session and keep its id and tty.
114
+ */
115
+ async function sourceTmuxPaneState(pi: QolNotificationExec): Promise<TmuxPaneState | undefined> {
116
+ const pane = process.env.TMUX_PANE;
117
+ if (!pane) return undefined;
118
+ const stdout = await tmux(pi, ["display-message", "-p", "-t", pane, PANE_STATE_FORMAT]);
119
+ const [paneTty = "", windowActive = "", sessionId = "", windowId = "", ...name] = (stdout ?? "").replace(/\r?\n$/, "").split("\t");
120
+ if (!paneTty || !sessionId || !windowId) return undefined;
121
+ return { paneTty, windowActive: windowActive === "1", sessionId, windowId, windowName: name.join("\t") };
122
+ }
123
+
124
+ async function tmuxClientTtys(pi: QolNotificationExec, pane: TmuxPaneState | undefined): Promise<string[]> {
125
+ if (!process.env.TMUX) return [];
126
+ const args = ["list-clients", "-F", "#{client_tty}"];
127
+ if (pane) args.splice(1, 0, "-t", pane.sessionId);
128
+ const output = await tmux(pi, args);
129
+ if (output === undefined) return [];
130
+ return [...new Set(output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))];
96
131
  }
97
132
 
98
- function tmuxClientTtys(): string[] {
133
+ function queueTerminalWrite(write: () => Promise<void>): Promise<void> {
134
+ const queued = terminalWrites.then(write);
135
+ terminalWrites = queued.catch(() => undefined);
136
+ return queued;
137
+ }
138
+
139
+ /**
140
+ * Writes to a terminal without waiting on it. A tmux client whose SSH link
141
+ * dropped stays attached with a full tty buffer, and a blocking write to it
142
+ * would hang until the link times out; non-blocking, it fails with `EAGAIN`.
143
+ * `O_NOCTTY` keeps the open from making the tty Pi's controlling terminal.
144
+ */
145
+ async function writeTty(path: string, output: string): Promise<void> {
146
+ const handle = await open(path, constants.O_WRONLY | constants.O_NONBLOCK | constants.O_NOCTTY);
99
147
  try {
100
- if (!process.env.TMUX) return [];
101
- const session = sourceTmuxSession();
102
- const args = ["list-clients", "-F", "#{client_tty}"];
103
- if (session) args.splice(1, 0, "-t", session);
104
- const output = execFileSync("tmux", args, { encoding: "utf8" });
105
- return [...new Set(output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))];
106
- } catch {
107
- return [];
148
+ await handle.writeFile(output, "utf8");
149
+ } finally {
150
+ await handle.close();
108
151
  }
109
152
  }
110
153
 
111
- function writeRawToPaths(paths: string[], output: string): boolean {
154
+ async function writeRawToPaths(paths: string[], output: string): Promise<boolean> {
112
155
  let wrote = false;
113
156
  for (const path of paths) {
114
157
  try {
115
- writeFileSync(path, output, "utf8");
158
+ await writeTty(path, output);
116
159
  wrote = true;
117
160
  } catch {
118
161
  // Try remaining paths.
@@ -121,10 +164,9 @@ function writeRawToPaths(paths: string[], output: string): boolean {
121
164
  return wrote;
122
165
  }
123
166
 
124
- function writeToTerminal(output: string): void {
125
- const tty = sourcePaneTty();
167
+ async function writeToTerminal(pane: TmuxPaneState | undefined, output: string): Promise<void> {
126
168
  try {
127
- writeFileSync(tty ?? "/dev/tty", output, "utf8");
169
+ await writeTty(pane?.paneTty ?? "/dev/tty", output);
128
170
  return;
129
171
  } catch {
130
172
  // Fall through to stdout best-effort.
@@ -136,38 +178,40 @@ function writeToTerminal(output: string): void {
136
178
  }
137
179
  }
138
180
 
139
- function writeTerminalSequence(sequence: string, cwd?: string): void {
140
- if (process.env.TMUX && settingBoolean("notification.tmuxNativeClientTty", true, cwd)) {
141
- // Inactive tmux windows do not forward arbitrary OSC output to the terminal.
142
- // Send native terminal notifications straight to attached tmux client TTYs,
143
- // while the explicit terminal bell still goes through the source pane when unmuted.
144
- if (writeRawToPaths(tmuxClientTtys(), sequence)) return;
145
- }
181
+ async function writeTerminalSequence(pi: QolNotificationExec, pane: TmuxPaneState | undefined, sequence: string, cwd?: string): Promise<void> {
182
+ // Inactive tmux windows do not forward arbitrary OSC output to the terminal.
183
+ // Send native terminal notifications straight to attached tmux client TTYs,
184
+ // while the explicit terminal bell still goes through the source pane when unmuted.
185
+ const clientTtys = process.env.TMUX && settingBoolean("notification.tmuxNativeClientTty", true, cwd) ? await tmuxClientTtys(pi, pane) : [];
146
186
  const output = process.env.TMUX && settingBoolean("notification.tmuxPassthrough", true, cwd) ? tmuxPassthrough(sequence) : sequence;
147
- writeToTerminal(output);
187
+ await queueTerminalWrite(async () => {
188
+ if (await writeRawToPaths(clientTtys, sequence)) return;
189
+ await writeToTerminal(pane, output);
190
+ });
148
191
  }
149
192
 
150
- function writeTerminalBell(cwd?: string): void {
193
+ function writeTerminalBell(pane: TmuxPaneState | undefined, cwd?: string): Promise<void> {
151
194
  // Match Claude-style hooks: resolve the source pane TTY and write raw BEL there.
152
195
  // This lets tmux set window_bell_flag for the correct source window.
153
196
  const sequence = terminalBellSequence(notificationBellMuted(cwd));
154
- if (sequence) writeToTerminal(sequence);
197
+ if (!sequence) return Promise.resolve();
198
+ return queueTerminalWrite(() => writeToTerminal(pane, sequence));
155
199
  }
156
200
 
157
- function notifyOSC777(title: string, body: string, cwd?: string): void {
158
- writeTerminalSequence(osc777NotificationSequence(title, body, notificationBellMuted(cwd)), cwd);
201
+ function notifyOSC777(pi: QolNotificationExec, pane: TmuxPaneState | undefined, title: string, body: string, cwd?: string): Promise<void> {
202
+ return writeTerminalSequence(pi, pane, osc777NotificationSequence(title, body, notificationBellMuted(cwd)), cwd);
159
203
  }
160
204
 
161
- function notifyOSC99(title: string, body: string, cwd?: string): void {
162
- writeTerminalSequence(`\x1b]99;i=1:d=0;${title}\x1b\\`, cwd);
163
- writeTerminalSequence(`\x1b]99;i=1:p=body;${body}\x1b\\`, cwd);
205
+ async function notifyOSC99(pi: QolNotificationExec, pane: TmuxPaneState | undefined, title: string, body: string, cwd?: string): Promise<void> {
206
+ await writeTerminalSequence(pi, pane, `\x1b]99;i=1:d=0;${title}\x1b\\`, cwd);
207
+ await writeTerminalSequence(pi, pane, `\x1b]99;i=1:p=body;${body}\x1b\\`, cwd);
164
208
  }
165
209
 
166
210
  function notifyWindows(title: string, body: string): void {
167
211
  execFile("powershell.exe", ["-NoProfile", "-Command", windowsToastScript(title, body)], () => undefined);
168
212
  }
169
213
 
170
- function notifyNativeTerminal(title: string, body: string, cwd?: string): void {
214
+ async function notifyNativeTerminal(pi: QolNotificationExec, pane: TmuxPaneState | undefined, title: string, body: string, cwd?: string): Promise<void> {
171
215
  const protocol = settingString("notification.oscProtocol", "auto", cwd);
172
216
  if (process.env.WT_SESSION) {
173
217
  notifyWindows(title, body);
@@ -175,60 +219,61 @@ function notifyNativeTerminal(title: string, body: string, cwd?: string): void {
175
219
  }
176
220
  if (protocol === "off") return;
177
221
  if (protocol === "osc99" || (protocol === "auto" && process.env.KITTY_WINDOW_ID)) {
178
- notifyOSC99(title, body, cwd);
222
+ await notifyOSC99(pi, pane, title, body, cwd);
179
223
  return;
180
224
  }
181
- notifyOSC777(title, body, cwd);
225
+ await notifyOSC777(pi, pane, title, body, cwd);
182
226
  }
183
227
 
184
- function notifyTmux(title: string, body: string, cwd?: string): void {
228
+ function notifyTmux(pi: QolNotificationExec, title: string, body: string, cwd?: string): void {
185
229
  if (!process.env.TMUX && !process.env.TMUX_PANE) return;
186
230
  const duration = Math.max(500, Math.floor(settingNumber("notification.tmuxMessageDurationMs", DEFAULT_TMUX_MESSAGE_DURATION_MS, cwd)));
187
231
  const message = `${title}: ${body}`;
188
232
  const args = ["display-message", "-d", String(duration)];
189
233
  if (process.env.TMUX_PANE) args.push("-t", process.env.TMUX_PANE);
190
234
  args.push(message);
191
- execFile("tmux", args, () => undefined);
235
+ void tmux(pi, args);
192
236
  }
193
237
 
194
- export function clearTmuxWindowMark(): void {
195
- if (tmuxWindowMarkTimer) clearTimeout(tmuxWindowMarkTimer);
196
- tmuxWindowMarkTimer = undefined;
238
+ /** Renames the marked window back, if one is marked. */
239
+ function unmarkTmuxWindow(pi: QolNotificationExec): void {
197
240
  const target = tmuxMarkedTarget;
198
241
  const original = tmuxOriginalWindowName;
199
242
  tmuxMarkedTarget = undefined;
200
243
  tmuxOriginalWindowName = undefined;
201
244
  if (!target || !original) return;
202
- execFile("tmux", ["rename-window", "-t", target, original], () => undefined);
245
+ void tmux(pi, ["rename-window", "-t", target, original]);
246
+ }
247
+
248
+ /** Removes the window mark, and keeps a notification still in flight from setting one. */
249
+ export function clearTmuxWindowMark(pi: QolNotificationExec): void {
250
+ tmuxMarkGeneration += 1;
251
+ if (tmuxWindowMarkTimer) clearTimeout(tmuxWindowMarkTimer);
252
+ tmuxWindowMarkTimer = undefined;
253
+ unmarkTmuxWindow(pi);
203
254
  }
204
255
 
205
- function markTmuxWindow(cwd?: string): void {
256
+ function markTmuxWindow(pi: QolNotificationExec, pane: TmuxPaneState | undefined, generation: number, cwd?: string): void {
206
257
  if (!settingBoolean("notification.tmuxWindowMark", false, cwd)) return;
207
- if (!process.env.TMUX || !process.env.TMUX_PANE) return;
258
+ if (!process.env.TMUX || !pane || generation !== tmuxMarkGeneration) return;
208
259
  const mark = sanitizeNotificationPart(settingString("notification.tmuxWindowMarkText", "!", cwd), 12) || "!";
209
260
  const prefix = `${mark} `;
210
- execFile("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE, "#{session_name}:#{window_index}"], (targetError, targetStdout) => {
211
- if (targetError) return;
212
- const target = targetStdout.trim();
213
- if (!target) return;
214
- execFile("tmux", ["display-message", "-p", "-t", process.env.TMUX_PANE!, "#W"], (nameError, nameStdout) => {
215
- if (nameError) return;
216
- const current = nameStdout.replace(/\r?\n$/, "");
217
- if (!current) return;
218
- const original = current.startsWith(prefix) ? current.slice(prefix.length) : current;
219
- if (!tmuxMarkedTarget || tmuxMarkedTarget !== target) {
220
- tmuxMarkedTarget = target;
221
- tmuxOriginalWindowName = original;
222
- }
223
- if (!current.startsWith(prefix)) execFile("tmux", ["rename-window", "-t", target, `${prefix}${current}`], () => undefined);
224
- const duration = Math.max(0, Math.floor(settingNumber("notification.tmuxWindowMarkDurationMs", 0, cwd)));
225
- if (tmuxWindowMarkTimer) clearTimeout(tmuxWindowMarkTimer);
226
- if (duration > 0) {
227
- tmuxWindowMarkTimer = setTimeout(clearTmuxWindowMark, duration);
228
- tmuxWindowMarkTimer.unref?.();
229
- }
230
- });
231
- });
261
+ const target = pane.windowId;
262
+ const current = pane.windowName;
263
+ if (!current) return;
264
+ // The pane moved to another window since the last mark: that window gets its name back.
265
+ if (tmuxMarkedTarget && tmuxMarkedTarget !== target) unmarkTmuxWindow(pi);
266
+ if (!tmuxMarkedTarget) {
267
+ tmuxMarkedTarget = target;
268
+ tmuxOriginalWindowName = current.startsWith(prefix) ? current.slice(prefix.length) : current;
269
+ }
270
+ if (!current.startsWith(prefix)) void tmux(pi, ["rename-window", "-t", target, `${prefix}${current}`]);
271
+ const duration = Math.max(0, Math.floor(settingNumber("notification.tmuxWindowMarkDurationMs", 0, cwd)));
272
+ if (tmuxWindowMarkTimer) clearTimeout(tmuxWindowMarkTimer);
273
+ if (duration > 0) {
274
+ tmuxWindowMarkTimer = setTimeout(() => clearTmuxWindowMark(pi), duration);
275
+ tmuxWindowMarkTimer.unref?.();
276
+ }
232
277
  }
233
278
 
234
279
  function notificationEnabledFor(kind: QolNotificationKind, cwd?: string): boolean {
@@ -243,24 +288,41 @@ function notificationEnabledFor(kind: QolNotificationKind, cwd?: string): boolea
243
288
  }
244
289
  }
245
290
 
246
- export function sendQolNotification(ctx: ExtensionContext | undefined, kind: QolNotificationKind, body: string, level: QolNotificationLevel = "info", key: string = kind): void {
291
+ /**
292
+ * The window mark and the tmux message never touch the terminal, so they start
293
+ * beside the terminal writes rather than behind them.
294
+ */
295
+ async function deliverTerminalNotification(pi: QolNotificationExec, title: string, text: string, markGeneration: number, cwd?: string): Promise<void> {
296
+ const pane = await sourceTmuxPaneState(pi);
297
+ const windowActive = pane?.windowActive ?? false;
298
+ if (!windowActive) markTmuxWindow(pi, pane, markGeneration, cwd);
299
+ if (settingBoolean("notification.tmux", false, cwd)) notifyTmux(pi, title, text, cwd);
300
+ if (settingBoolean("notification.bell", true, cwd) && (!windowActive || settingBoolean("notification.bellWhenActive", false, cwd))) await writeTerminalBell(pane, cwd);
301
+ if (settingBoolean("notification.native", true, cwd)) await notifyNativeTerminal(pi, pane, title, text, cwd);
302
+ }
303
+
304
+ /**
305
+ * Decides and records the notification synchronously, then delivers the
306
+ * terminal and tmux channels in the background: the returned promise settles
307
+ * when delivery is done, and no caller on Pi's event path awaits it, so a
308
+ * stalled tmux server or terminal never holds up a turn end.
309
+ */
310
+ export function sendQolNotification(pi: QolNotificationExec, ctx: ExtensionContext | undefined, kind: QolNotificationKind, body: string, level: QolNotificationLevel = "info", key: string = kind): Promise<void> {
247
311
  const cwd = ctx?.cwd;
248
- if (ctx && !ctx.hasUI) return;
249
- if (!notificationEnabledFor(kind, cwd)) return;
312
+ if (ctx && !ctx.hasUI) return Promise.resolve();
313
+ if (!notificationEnabledFor(kind, cwd)) return Promise.resolve();
250
314
  const cooldownMs = Math.max(0, settingNumber("notification.cooldownSeconds", DEFAULT_NOTIFICATION_COOLDOWN_SECONDS, cwd) * 1000);
251
315
  const now = Date.now();
252
316
  const last = lastNotificationAt.get(key) ?? 0;
253
- if (cooldownMs > 0 && now - last < cooldownMs) return;
317
+ if (cooldownMs > 0 && now - last < cooldownMs) return Promise.resolve();
254
318
  lastNotificationAt.set(key, now);
255
319
 
256
320
  const title = sanitizeNotificationPart(settingString("notification.title", DEFAULT_NOTIFICATION_TITLE, cwd), 80) || DEFAULT_NOTIFICATION_TITLE;
257
321
  const text = sanitizeNotificationPart(body, Math.max(40, Math.floor(settingNumber("notification.bodyMaxChars", DEFAULT_NOTIFICATION_BODY_MAX_CHARS, cwd))));
258
- const tmuxWindowActive = sourceTmuxWindowActive();
259
- if (settingBoolean("notification.bell", true, cwd) && (!tmuxWindowActive || settingBoolean("notification.bellWhenActive", false, cwd))) writeTerminalBell(cwd);
260
- if (settingBoolean("notification.native", true, cwd)) notifyNativeTerminal(title, text, cwd);
261
- if (!tmuxWindowActive) markTmuxWindow(cwd);
262
- if (settingBoolean("notification.tmux", false, cwd)) notifyTmux(title, text, cwd);
263
322
  if (ctx?.hasUI && settingBoolean("notification.piUi", false, cwd)) ctx.ui.notify(text, level);
323
+ // Every channel already absorbs its own failure; this catch keeps an
324
+ // unexpected throw from becoming an unhandled rejection, which ends Pi.
325
+ return deliverTerminalNotification(pi, title, text, tmuxMarkGeneration, cwd).catch(() => undefined);
264
326
  }
265
327
 
266
328
  function questionNotificationTitle(request?: QuestionRequestLike): string {
@@ -269,7 +331,7 @@ function questionNotificationTitle(request?: QuestionRequestLike): string {
269
331
  return "Question";
270
332
  }
271
333
 
272
- export function notifyQuestionOpened(ctx: ExtensionContext | undefined, event: QuestionOpenedEventLike, keyPrefix = "question"): void {
334
+ export function notifyQuestionOpened(pi: QolNotificationExec, ctx: ExtensionContext | undefined, event: QuestionOpenedEventLike, keyPrefix = "question"): void {
273
335
  const title = questionNotificationTitle(event.request);
274
336
  const key = `${keyPrefix}:${event.requestId ?? title}`;
275
337
  const now = Date.now();
@@ -279,5 +341,5 @@ export function notifyQuestionOpened(ctx: ExtensionContext | undefined, event: Q
279
341
  for (const [storedKey, timestamp] of lastQuestionNotificationAt) {
280
342
  if (now - timestamp > 60_000) lastQuestionNotificationAt.delete(storedKey);
281
343
  }
282
- sendQolNotification(ctx, "question", `Input required: ${title}`, "warning", key);
344
+ void sendQolNotification(pi, ctx, "question", `Input required: ${title}`, "warning", key);
283
345
  }
@@ -9,6 +9,19 @@ interface PendingQueueThemePatch {
9
9
  cwd?: string;
10
10
  }
11
11
 
12
+ interface StatusTextAlignmentPatch {
13
+ originalRender: (this: unknown, width: number) => string[];
14
+ }
15
+
16
+ function isStatusTextAlignmentPatch(value: unknown): value is StatusTextAlignmentPatch {
17
+ return typeof value === "object" && value !== null && typeof (value as Partial<StatusTextAlignmentPatch>).originalRender === "function";
18
+ }
19
+
20
+ interface StatusTextClassification {
21
+ text: string;
22
+ status: boolean;
23
+ }
24
+
12
25
  function isPendingQueuePreviewText(text: string): boolean {
13
26
  const plain = stripAnsi(text);
14
27
  return plain.startsWith("Steering: ") || plain.startsWith("Follow-up: ");
@@ -28,15 +41,33 @@ function isQueuedMessageStatusText(text: string): boolean {
28
41
  return /^Restored \d+ queued messages? to editor$/.test(plain) || plain === "No queued messages to restore";
29
42
  }
30
43
 
31
- export function installStatusTextAlignmentPatch(): void {
44
+ /**
45
+ * Pads Pi's dequeue status line ("Restored N queued messages to editor") flush
46
+ * with the pending-queue preview. The patch wraps every pi-tui `Text`, so the
47
+ * classification is cached per instance and recomputed only when its text
48
+ * changes: an unchanged `Text`, which pi-tui answers from its own render
49
+ * cache, pays one string comparison, not a scan of its whole text.
50
+ */
51
+ export function installStatusTextAlignmentPatch(ctx: ExtensionContext): void {
52
+ if (!ctx.hasUI) return;
32
53
  const proto = Text.prototype as unknown as Record<PropertyKey, any>;
33
- if (proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL]) return;
54
+ // Any marker already here keeps its wrapper, including pi-qol 2.2.0's `true`.
55
+ if (proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL] !== undefined) return;
34
56
  const originalRender = proto.render;
35
57
  if (typeof originalRender !== "function") return;
36
- proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL] = true;
58
+ const classifications = new WeakMap<object, StatusTextClassification>();
59
+ const isStatusText = (component: object, text: string): boolean => {
60
+ const cached = classifications.get(component);
61
+ if (cached && cached.text === text) return cached.status;
62
+ const status = isQueuedMessageStatusText(text);
63
+ classifications.set(component, { text, status });
64
+ return status;
65
+ };
66
+ const patch: StatusTextAlignmentPatch = { originalRender };
67
+ proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL] = patch;
37
68
  proto.render = function patchedQolStatusTextRender(this: any, width: number): string[] {
38
69
  const text = typeof this?.text === "string" ? this.text : "";
39
- if (!isQueuedMessageStatusText(text)) return originalRender.call(this, width);
70
+ if (!isStatusText(this, text)) return originalRender.call(this, width);
40
71
  const originalPaddingX = this.paddingX;
41
72
  try {
42
73
  this.paddingX = 0;
@@ -49,6 +80,20 @@ export function installStatusTextAlignmentPatch(): void {
49
80
  };
50
81
  }
51
82
 
83
+ /**
84
+ * Removes the patch, but only one this module's install wrote. Any other
85
+ * marker keeps its wrapper: pi-qol 2.2.0 marked the prototype with `true` and
86
+ * kept no original render, and Pi's `/reload` carries that marker into this
87
+ * module because pi-tui's prototype outlives the reload.
88
+ */
89
+ export function restoreStatusTextAlignmentPatch(): void {
90
+ const proto = Text.prototype as unknown as Record<PropertyKey, any>;
91
+ const patch: unknown = proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL];
92
+ if (!isStatusTextAlignmentPatch(patch)) return;
93
+ proto.render = patch.originalRender;
94
+ delete proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL];
95
+ }
96
+
52
97
  export function installPendingQueueThemePatch(ctx: ExtensionContext): void {
53
98
  if (!ctx.hasUI) return;
54
99
  const proto = Theme.prototype as unknown as Record<PropertyKey, unknown>;
@@ -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
@@ -46,6 +46,7 @@ import {
46
46
  installPendingQueueThemePatch,
47
47
  installStatusTextAlignmentPatch,
48
48
  restorePendingQueueThemePatch,
49
+ restoreStatusTextAlignmentPatch,
49
50
  } from "./qol/pending-queue.js";
50
51
  import { permissionGateMatch, permissionGatePrompt } from "./qol/permission-gate.js";
51
52
  import { createRateLimitAutoResumeController, RATE_LIMIT_AUTO_RESUME_EVENT } from "./qol/rate-limit-auto-resume.js";
@@ -102,7 +103,6 @@ export default function qol(pi: ExtensionAPI): void {
102
103
  if (!settingBoolean("enabled", true)) return;
103
104
 
104
105
  installThinkingTimerPatch();
105
- installStatusTextAlignmentPatch();
106
106
  const thinkingTimerStore: ThinkingTimerStore = {
107
107
  enabled: false,
108
108
  starts: new Map(),
@@ -121,6 +121,7 @@ export default function qol(pi: ExtensionAPI): void {
121
121
  let autoRenameInProgress = false;
122
122
  let autoRenameGeneration = 0;
123
123
  let latestSystemPromptOptions: unknown;
124
+ let statusEditor: QolEditor | QolCompactPromptEditor | undefined;
124
125
 
125
126
  const resetAutoRename = () => {
126
127
  autoRenameAttempted = false;
@@ -217,15 +218,9 @@ export default function qol(pi: ExtensionAPI): void {
217
218
  thinkingTimerStore.enabled = !!ctx?.hasUI && settingBoolean("thinkingTimer.enabled", true, ctx?.cwd);
218
219
  };
219
220
 
220
- // Working indicator mode.
221
- // The built-in pi-tui Loader ticks every 80ms during streaming. Each tick
222
- // mutates a line in statusContainer; once total rendered content exceeds the
223
- // terminal viewport (overlay content like /tree, or chat overflow), every tick
224
- // trips pi-tui's firstChanged < prevViewportTop branch and triggers a full
225
- // screen + scrollback clear (visible flash). This setting lets the user trade
226
- // the spinner animation away for a stable display in overflow scenarios.
227
- // Implementation note: Loader.restartAnimation() bails out when frames.length
228
- // 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.
229
224
  const applyWorkingIndicatorMode = (ctx: ExtensionContext): void => {
230
225
  if (!ctx.hasUI) return;
231
226
  const mode = settingString("workingIndicator.mode", "animated", ctx.cwd);
@@ -386,7 +381,7 @@ export default function qol(pi: ExtensionAPI): void {
386
381
  if (!service) return false;
387
382
  questionUnsubscribe = service.subscribe((event: any) => {
388
383
  if (event?.action !== "opened") return;
389
- notifyQuestionOpened(ctx, { requestId: event.requestId, request: event.request, source: event.source }, "question");
384
+ notifyQuestionOpened(pi, ctx, { requestId: event.requestId, request: event.request, source: event.source }, "question");
390
385
  });
391
386
  return true;
392
387
  };
@@ -547,6 +542,7 @@ export default function qol(pi: ExtensionAPI): void {
547
542
  ctx.ui.setHeader(undefined);
548
543
  ctx.ui.setFooter(undefined);
549
544
  activeTui = undefined;
545
+ statusEditor = undefined;
550
546
  };
551
547
 
552
548
  const maybeNotifyTaskCompletion = (_ctx: ExtensionContext, state: any) => {
@@ -574,7 +570,7 @@ export default function qol(pi: ExtensionAPI): void {
574
570
  };
575
571
  const notificationService: QolNotificationService = {
576
572
  notifyQuestionOpened(ctx, event) {
577
- notifyQuestionOpened(ctx ?? currentCtx, event, "question");
573
+ notifyQuestionOpened(pi, ctx ?? currentCtx, event, "question");
578
574
  return true;
579
575
  },
580
576
  };
@@ -583,7 +579,7 @@ export default function qol(pi: ExtensionAPI): void {
583
579
  pi.events.on(QUESTION_OPENED_EVENT, (data: unknown) => {
584
580
  if (!data || typeof data !== "object") return;
585
581
  const event = data as QuestionOpenedEventLike;
586
- notifyQuestionOpened(currentCtx, event, "question");
582
+ notifyQuestionOpened(pi, currentCtx, event, "question");
587
583
  });
588
584
  pi.events.on(RATE_LIMIT_AUTO_RESUME_EVENT, (payload: unknown) => {
589
585
  if (!currentCtx) return;
@@ -605,6 +601,7 @@ export default function qol(pi: ExtensionAPI): void {
605
601
  void consumePendingSessionSearchContext(pi, ctx, event.reason);
606
602
  installAutocompleteHintStyling(ctx);
607
603
  installPendingQueueThemePatch(ctx);
604
+ installStatusTextAlignmentPatch(ctx);
608
605
  if (ctx.hasUI) {
609
606
  ctx.ui.setHiddenThinkingLabel(hiddenThinkingLabel(ctx.ui.theme, ctx.cwd));
610
607
  applyWorkingIndicatorMode(ctx);
@@ -615,9 +612,10 @@ export default function qol(pi: ExtensionAPI): void {
615
612
  installSessionTitleSync(ctx);
616
613
  ctx.ui.setEditorComponent((tui, theme, keybindings) => {
617
614
  activeTui = tui;
618
- return settingBoolean("compactPrompt", true, ctx.cwd)
615
+ statusEditor = settingBoolean("compactPrompt", true, ctx.cwd)
619
616
  ? new QolCompactPromptEditor(tui, theme, keybindings, Math.max(0, Math.floor(settingNumber("inputBottomPaddingLines", DEFAULT_INPUT_BOTTOM_PADDING_LINES, ctx.cwd))), ctx)
620
617
  : new QolEditor(tui, theme, keybindings, ctx);
618
+ return statusEditor;
621
619
  });
622
620
  if (showStatusline || scheduleEnabled || rateLimitAutoResumeEnabled) {
623
621
  const statusWidgetTimer = setTimeout(() => {
@@ -629,7 +627,7 @@ export default function qol(pi: ExtensionAPI): void {
629
627
  const lines = rateLimitAutoResumeController.enabled(ctx) ? rateLimitAutoResumeController.renderPreviewLines(width) : [];
630
628
  if (scheduleEnabled) lines.push(...scheduleController.renderPreviewLines(width));
631
629
  if (budgetGuardStatus) lines.push(truncateToWidth(ansiGreen(`┃ ${budgetGuardStatus}`), width, ""));
632
- if (showStatusline) lines.push(renderStatusLine(width, ctx, gitState ?? makeFallbackGitState(ctx.cwd), pi, theme));
630
+ if (showStatusline) lines.push(renderStatusLine(width, ctx, gitState ?? makeFallbackGitState(ctx.cwd), pi, theme, statusEditor?.renderWorkingStatus(width)));
633
631
  return lines;
634
632
  },
635
633
  };
@@ -676,7 +674,7 @@ export default function qol(pi: ExtensionAPI): void {
676
674
  if (sessionSearchWarmupTimer) clearTimeout(sessionSearchWarmupTimer);
677
675
  sessionSearchWarmupTimer = undefined;
678
676
  resetThinkingTimer(undefined);
679
- clearTmuxWindowMark();
677
+ clearTmuxWindowMark(pi);
680
678
  questionUnsubscribe?.();
681
679
  questionUnsubscribe = undefined;
682
680
  currentCtx = undefined;
@@ -686,6 +684,7 @@ export default function qol(pi: ExtensionAPI): void {
686
684
  ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
687
685
  ctx.ui.setStatus("qol-budget-guard", undefined);
688
686
  restorePendingQueueThemePatch(ctx);
687
+ restoreStatusTextAlignmentPatch();
689
688
  resetStatuslineUi(ctx);
690
689
  ctx.ui.setEditorComponent(undefined);
691
690
  });
@@ -707,7 +706,7 @@ export default function qol(pi: ExtensionAPI): void {
707
706
  });
708
707
  pi.on("agent_start", (_event, ctx) => {
709
708
  clearIdleCompactionTimer();
710
- clearTmuxWindowMark();
709
+ clearTmuxWindowMark(pi);
711
710
  rateLimitAutoResumeController.noteAgentStart(ctx);
712
711
  if (ctx.hasUI) {
713
712
  void refreshStatusline(ctx);
@@ -757,21 +756,21 @@ export default function qol(pi: ExtensionAPI): void {
757
756
  const critical = criticalInfo(text);
758
757
  if (critical) {
759
758
  pendingTaskCompleteNotification = undefined;
760
- sendQolNotification(ctx, "critical", `Critical: ${critical}`, "error", `critical:${critical.slice(0, 80)}`);
759
+ void sendQolNotification(pi, ctx, "critical", `Critical: ${critical}`, "error", `critical:${critical.slice(0, 80)}`);
761
760
  return;
762
761
  }
763
762
  if (pendingTaskCompleteNotification) {
764
763
  const body = pendingTaskCompleteNotification;
765
764
  pendingTaskCompleteNotification = undefined;
766
- sendQolNotification(ctx, "task-complete", body, "info", "task-complete");
765
+ void sendQolNotification(pi, ctx, "task-complete", body, "info", "task-complete");
767
766
  return;
768
767
  }
769
768
  if (ctx.hasPendingMessages?.()) return;
770
769
  if (needsDirection(text)) {
771
- sendQolNotification(ctx, "direction", "Pi is awaiting your direction.", "warning", "direction");
770
+ void sendQolNotification(pi, ctx, "direction", "Pi is awaiting your direction.", "warning", "direction");
772
771
  return;
773
772
  }
774
- sendQolNotification(ctx, "ready", settingString("notification.readyMessage", "Ready for input", ctx.cwd), "info", "ready");
773
+ void sendQolNotification(pi, ctx, "ready", settingString("notification.readyMessage", "Ready for input", ctx.cwd), "info", "ready");
775
774
  });
776
775
  pi.on("agent_settled", async (_event, ctx) => {
777
776
  await fireStagedBudgetGuard(ctx);
@@ -799,7 +798,7 @@ export default function qol(pi: ExtensionAPI): void {
799
798
  pi.on("session_before_tree", (event, ctx) => handleQolBranchSummary(event, ctx));
800
799
  pi.on("tool_call", async (event: any, ctx) => {
801
800
  if (event?.toolName === "question") {
802
- notifyQuestionOpened(ctx, { requestId: event.input?.id ?? event.toolCallId, request: event.input, source: "tool_call" }, "question");
801
+ notifyQuestionOpened(pi, ctx, { requestId: event.input?.id ?? event.toolCallId, request: event.input, source: "tool_call" }, "question");
803
802
  return undefined;
804
803
  }
805
804
  if (!settingBoolean("permissionGate.enabled", false, ctx.cwd)) return undefined;
@@ -818,7 +817,7 @@ export default function qol(pi: ExtensionAPI): void {
818
817
  });
819
818
 
820
819
  pi.on("input", async (event) => {
821
- clearTmuxWindowMark();
820
+ clearTmuxWindowMark(pi);
822
821
  if (event.source === "extension") return { action: "continue" };
823
822
  const text = event.text ?? "";
824
823
  const paths = currentCtx?.cwd ? resolveSubmittedImagePaths(text, currentCtx.cwd) : [];
@@ -955,7 +954,7 @@ export default function qol(pi: ExtensionAPI): void {
955
954
  return;
956
955
  }
957
956
  if (sub === "notify-test") {
958
- sendQolNotification(ctx, "test", "QOL notification test", "info", `test:${Date.now()}`);
957
+ void sendQolNotification(pi, ctx, "test", "QOL notification test", "info", `test:${Date.now()}`);
959
958
  ctx.ui.notify("Sent QOL notification test.", "info");
960
959
  return;
961
960
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-qol",
3
- "version": "2.1.1",
3
+ "version": "2.2.1",
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",
@@ -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": [