@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 +5 -3
- package/extensions/qol/constants.ts +2 -0
- package/extensions/qol/editor.ts +22 -4
- package/extensions/qol/notifications.ts +158 -96
- package/extensions/qol/pending-queue.ts +49 -4
- package/extensions/qol/statusline.ts +34 -7
- package/extensions/qol.ts +23 -24
- package/package.json +12 -3
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;
|
package/extensions/qol/editor.ts
CHANGED
|
@@ -101,7 +101,25 @@ function syncQolEditorStatus(
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
|
|
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
|
|
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
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
return
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
235
|
+
void tmux(pi, args);
|
|
192
236
|
}
|
|
193
237
|
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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 || !
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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"
|
|
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
|
|
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(
|
|
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
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
986
|
+
"@earendil-works/pi-coding-agent": ">=0.86.0",
|
|
978
987
|
"@earendil-works/pi-tui": "*"
|
|
979
988
|
},
|
|
980
989
|
"files": [
|