@vanillagreen/pi-qol 1.6.0 → 1.7.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 +9 -5
- package/extensions/qol/budget-guard-runtime.ts +5 -0
- package/extensions/qol/notifications.ts +19 -5
- package/extensions/qol/schedule.ts +40 -16
- package/extensions/qol/session-search/cache.ts +7 -7
- package/extensions/qol/session-search/context.ts +5 -6
- package/extensions/qol/session-search/jsonl.ts +31 -0
- package/extensions/qol/status-message.ts +1 -1
- package/extensions/qol.ts +17 -1
- package/package.json +10 -1
package/README.md
CHANGED
|
@@ -13,9 +13,10 @@ Quality-of-life extension for Pi: compact statusline, multiline input, session n
|
|
|
13
13
|
- Newline-insert in the editor (multi-line drafts without auto-submit), with a fallback binding for terminals that can't distinguish the primary key.
|
|
14
14
|
- Sessions auto-name from your first prompt. `/rename` overrides anytime.
|
|
15
15
|
- `/search` browses previous sessions with snippet previews; the configured shortcut opens it instantly.
|
|
16
|
+
- Session search reads prompt snippets line-by-line, so very large session JSONL files do not have to be materialized just to browse, search, or import context.
|
|
16
17
|
- `/context` shows a Claude-style context-window breakdown.
|
|
17
18
|
- `/handoff <goal>` drafts a focused prompt for a new session.
|
|
18
|
-
- `/schedule 20m <message>` sends a delayed prompt without invoking the model until the timer fires.
|
|
19
|
+
- `/schedule 20m <message>` or `/schedule 1h45m <message>` sends a delayed prompt without invoking the model until the timer fires.
|
|
19
20
|
- Optional rate-limit auto-resume sends a configurable continuation after reset.
|
|
20
21
|
- Permission gate prompts before risky `bash` commands. Default match: `rm -Rf`.
|
|
21
22
|
- Notifications for ready, questions, blocked states, and task completion.
|
|
@@ -53,11 +54,11 @@ Restart Pi after installation.
|
|
|
53
54
|
| `/search [query]` | Open previous-session search. |
|
|
54
55
|
| `/search:refresh` | Refresh the session search cache. |
|
|
55
56
|
| `/handoff <goal>` | Draft a handoff prompt for a new session. |
|
|
56
|
-
| `/schedule <delay> <message>` | Send a user message after a timer without invoking the model now. Example: `/schedule
|
|
57
|
+
| `/schedule <delay> <message>` | Send a user message after a timer without invoking the model now. Example: `/schedule 1h45m retry the previous request`. |
|
|
57
58
|
|
|
58
59
|
Arguments support autocomplete.
|
|
59
60
|
|
|
60
|
-
`/schedule` accepts `ms`, `s`, `m`, `h`, and `d` units; bare numbers mean minutes. Pending prompts render above the statusline like steering/follow-up previews until they are sent or cancelled. Manage pending prompts with `/schedule list` and `/schedule cancel <id|all>`. Schedules are stored in the Pi session and re-armed on reload/resume; if Pi is not running at the due time, an overdue prompt sends when that session is next loaded.
|
|
61
|
+
`/schedule` accepts `ms`, `s`, `m`, `h`, and `d` units; bare numbers mean minutes. Compact composite durations are accepted in largest-to-smallest order, like `1h45m`, `45m10s`, or `1h45m30s`. Pending prompts render above the statusline like steering/follow-up previews until they are sent or cancelled. Manage pending prompts with `/schedule list` and `/schedule cancel <id|all>`. Schedules are stored in the Pi session and re-armed on reload/resume; if Pi is not running at the due time, an overdue prompt sends when that session is next loaded.
|
|
61
62
|
|
|
62
63
|
## Settings
|
|
63
64
|
|
|
@@ -138,7 +139,7 @@ Master toggle: **Enable notifications**.
|
|
|
138
139
|
|
|
139
140
|
Triggers (notify when): ready, direction needed, question popups, all tasks complete, critical/blocked.
|
|
140
141
|
|
|
141
|
-
Channels: terminal bell, native terminal notifications (OSC 777/99 or Windows toast), tmux `display-message`, tmux window marking, OSC passthrough, and an optional in-Pi UI notice.
|
|
142
|
+
Channels: terminal bell, **Mute bell sound**, native terminal notifications (OSC 777/99 or Windows toast), tmux `display-message`, tmux window marking, OSC passthrough, and an optional in-Pi UI notice.
|
|
142
143
|
|
|
143
144
|
Tuning: cooldown seconds, title, ready message, body length, tmux durations.
|
|
144
145
|
|
|
@@ -146,9 +147,10 @@ Notes:
|
|
|
146
147
|
|
|
147
148
|
- **Terminal notification protocol** picks between OSC 99 (Kitty) and OSC 777 automatically.
|
|
148
149
|
- **Bell when tmux window active** is off so you don't get bells while looking at Pi.
|
|
150
|
+
- **Mute bell sound** keeps notification routing enabled but suppresses QOL-emitted BEL bytes and uses ST terminators for OSC 777/99 where supported. Terminals or operating systems may still play their own sound for native notifications outside QOL control.
|
|
149
151
|
- **tmux native via client TTY** sends OSC notifications to attached tmux clients so notifications still appear when the Pi window is inactive.
|
|
150
152
|
|
|
151
|
-
Use `/qol notify-test` to verify your terminal/tmux setup.
|
|
153
|
+
Use `/qol notify-test` to verify your terminal/tmux setup, including silent behavior with **Mute bell sound** enabled.
|
|
152
154
|
|
|
153
155
|
### Permission gate
|
|
154
156
|
|
|
@@ -192,6 +194,8 @@ For autonomous/Flightdeck-style runs the agent never goes idle, so idle compacti
|
|
|
192
194
|
|
|
193
195
|
When the budget guard fires it injects a sentinel into the compaction request so the QOL bounded handler always runs — chunked summarizer + handoff artifact — even if **Custom compaction summaries** is off. Manual compactions (`/tree`, idle compaction, user-triggered) still only use the QOL handler when **Custom compaction summaries** is on; otherwise they fall through to Pi's default compaction with no handoff artifact and no chunking. If you want every compaction to use the QOL bounded path, turn **Custom compaction summaries** on.
|
|
194
196
|
|
|
197
|
+
While budget-guard compaction is running, QOL keeps a persistent status line above the prompt (and in the normal status footer when the compact statusline is disabled). After Pi prints the compacted-summary block, the line changes to `QOL budget guard finalizing compaction…` until `ctx.compact()` reports completion, so long reload/finalization gaps do not look frozen.
|
|
198
|
+
|
|
195
199
|
Recommended values for autonomous/Flightdeck runs:
|
|
196
200
|
|
|
197
201
|
- Keep **Long-session budget guard** on. Lower **Budget guard percent** to `75` if your provider buffers are tight.
|
|
@@ -17,6 +17,7 @@ export interface GuardDispatchInput {
|
|
|
17
17
|
trigger: BudgetTrigger | undefined;
|
|
18
18
|
compact: ((options: GuardCompactOptions) => void) | undefined;
|
|
19
19
|
notify: (message: string, level: GuardLevel) => void;
|
|
20
|
+
onStatus?: (message: string | undefined) => void;
|
|
20
21
|
staleCtx?: () => boolean;
|
|
21
22
|
}
|
|
22
23
|
|
|
@@ -70,6 +71,7 @@ export class BudgetGuardDriver {
|
|
|
70
71
|
return { kind: "no-compact-fn" };
|
|
71
72
|
}
|
|
72
73
|
input.notify(`QOL budget guard starting compaction: ${trigger.reason}`, "info");
|
|
74
|
+
input.onStatus?.(`QOL budget guard compacting session: ${trigger.reason}`);
|
|
73
75
|
this.inFlight = true;
|
|
74
76
|
this.lastKey = trigger.key;
|
|
75
77
|
const instructions = `${QOL_BUDGET_GUARD_SENTINEL} QOL budget guard triggered at agent_end because ${trigger.reason}. Bound the summary input, preserve current task state, decisions, files, blockers, and next steps.`;
|
|
@@ -78,6 +80,7 @@ export class BudgetGuardDriver {
|
|
|
78
80
|
customInstructions: instructions,
|
|
79
81
|
onComplete: () => {
|
|
80
82
|
this.inFlight = false;
|
|
83
|
+
input.onStatus?.(undefined);
|
|
81
84
|
input.notify("QOL budget guard compaction completed.", "info");
|
|
82
85
|
},
|
|
83
86
|
onError: (error: Error) => {
|
|
@@ -85,6 +88,7 @@ export class BudgetGuardDriver {
|
|
|
85
88
|
// Allow the next agent_end to retry rather than poisoning the
|
|
86
89
|
// crossing key after a transient failure.
|
|
87
90
|
this.lastKey = undefined;
|
|
91
|
+
input.onStatus?.(undefined);
|
|
88
92
|
input.notify(`QOL budget guard compaction failed: ${error.message}`, "error");
|
|
89
93
|
},
|
|
90
94
|
});
|
|
@@ -92,6 +96,7 @@ export class BudgetGuardDriver {
|
|
|
92
96
|
} catch (error) {
|
|
93
97
|
this.inFlight = false;
|
|
94
98
|
this.lastKey = undefined;
|
|
99
|
+
input.onStatus?.(undefined);
|
|
95
100
|
const message = error instanceof Error ? error.message : String(error);
|
|
96
101
|
input.notify(`QOL budget guard compaction failed to start: ${message}`, "error");
|
|
97
102
|
return { kind: "dispatch-threw", error: message, reason: trigger.reason };
|
|
@@ -52,6 +52,19 @@ function tmuxPassthrough(sequence: string): string {
|
|
|
52
52
|
return `\x1bPtmux;${sequence.replace(/\x1b/g, "\x1b\x1b")}\x1b\\`;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
export function terminalBellSequence(muteBellSound: boolean): string | undefined {
|
|
56
|
+
return muteBellSound ? undefined : "\x07";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function osc777NotificationSequence(title: string, body: string, muteBellSound = false): string {
|
|
60
|
+
const terminator = muteBellSound ? "\x1b\\" : "\x07";
|
|
61
|
+
return `\x1b]777;notify;${title};${body}${terminator}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function notificationBellMuted(cwd?: string): boolean {
|
|
65
|
+
return settingBoolean("notification.muteBellSound", false, cwd);
|
|
66
|
+
}
|
|
67
|
+
|
|
55
68
|
function sourcePaneTty(): string | undefined {
|
|
56
69
|
try {
|
|
57
70
|
if (!process.env.TMUX_PANE) return undefined;
|
|
@@ -127,21 +140,22 @@ function writeTerminalSequence(sequence: string, cwd?: string): void {
|
|
|
127
140
|
if (process.env.TMUX && settingBoolean("notification.tmuxNativeClientTty", true, cwd)) {
|
|
128
141
|
// Inactive tmux windows do not forward arbitrary OSC output to the terminal.
|
|
129
142
|
// Send native terminal notifications straight to attached tmux client TTYs,
|
|
130
|
-
// while
|
|
143
|
+
// while the explicit terminal bell still goes through the source pane when unmuted.
|
|
131
144
|
if (writeRawToPaths(tmuxClientTtys(), sequence)) return;
|
|
132
145
|
}
|
|
133
146
|
const output = process.env.TMUX && settingBoolean("notification.tmuxPassthrough", true, cwd) ? tmuxPassthrough(sequence) : sequence;
|
|
134
147
|
writeToTerminal(output);
|
|
135
148
|
}
|
|
136
149
|
|
|
137
|
-
function writeTerminalBell(): void {
|
|
150
|
+
function writeTerminalBell(cwd?: string): void {
|
|
138
151
|
// Match Claude-style hooks: resolve the source pane TTY and write raw BEL there.
|
|
139
152
|
// This lets tmux set window_bell_flag for the correct source window.
|
|
140
|
-
|
|
153
|
+
const sequence = terminalBellSequence(notificationBellMuted(cwd));
|
|
154
|
+
if (sequence) writeToTerminal(sequence);
|
|
141
155
|
}
|
|
142
156
|
|
|
143
157
|
function notifyOSC777(title: string, body: string, cwd?: string): void {
|
|
144
|
-
writeTerminalSequence(
|
|
158
|
+
writeTerminalSequence(osc777NotificationSequence(title, body, notificationBellMuted(cwd)), cwd);
|
|
145
159
|
}
|
|
146
160
|
|
|
147
161
|
function notifyOSC99(title: string, body: string, cwd?: string): void {
|
|
@@ -242,7 +256,7 @@ export function sendQolNotification(ctx: ExtensionContext | undefined, kind: Qol
|
|
|
242
256
|
const title = sanitizeNotificationPart(settingString("notification.title", DEFAULT_NOTIFICATION_TITLE, cwd), 80) || DEFAULT_NOTIFICATION_TITLE;
|
|
243
257
|
const text = sanitizeNotificationPart(body, Math.max(40, Math.floor(settingNumber("notification.bodyMaxChars", DEFAULT_NOTIFICATION_BODY_MAX_CHARS, cwd))));
|
|
244
258
|
const tmuxWindowActive = sourceTmuxWindowActive();
|
|
245
|
-
if (settingBoolean("notification.bell", true, cwd) && (!tmuxWindowActive || settingBoolean("notification.bellWhenActive", false, cwd))) writeTerminalBell();
|
|
259
|
+
if (settingBoolean("notification.bell", true, cwd) && (!tmuxWindowActive || settingBoolean("notification.bellWhenActive", false, cwd))) writeTerminalBell(cwd);
|
|
246
260
|
if (settingBoolean("notification.native", true, cwd)) notifyNativeTerminal(title, text, cwd);
|
|
247
261
|
if (!tmuxWindowActive) markTmuxWindow(cwd);
|
|
248
262
|
if (settingBoolean("notification.tmux", false, cwd)) notifyTmux(title, text, cwd);
|
|
@@ -56,6 +56,7 @@ const SCHEDULE_COMPLETIONS: AutocompleteItem[] = [
|
|
|
56
56
|
{ value: "list", label: "list", description: "Show pending scheduled messages" },
|
|
57
57
|
{ value: "cancel ", label: "cancel <id|all>", description: "Cancel one scheduled message or all pending messages" },
|
|
58
58
|
{ value: "20m ", label: "20m <message>", description: "Send a message after 20 minutes" },
|
|
59
|
+
{ value: "1h30m ", label: "1h30m <message>", description: "Send a message after 1 hour 30 minutes" },
|
|
59
60
|
{ value: "1h ", label: "1h <message>", description: "Send a message after 1 hour" },
|
|
60
61
|
];
|
|
61
62
|
|
|
@@ -66,25 +67,47 @@ export function getScheduleArgumentCompletions(prefix: string): AutocompleteItem
|
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
export function parseDurationMs(input: string): number | undefined {
|
|
69
|
-
const
|
|
70
|
-
if (!
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
70
|
+
const text = input.trim().toLowerCase();
|
|
71
|
+
if (!text) return undefined;
|
|
72
|
+
if (/^\d+(?:\.\d+)?$/.test(text)) return clampScheduleDelay(Number(text) * 60 * 1000);
|
|
73
|
+
|
|
74
|
+
let totalMs = 0;
|
|
75
|
+
let matched = false;
|
|
76
|
+
let previousRank = Number.POSITIVE_INFINITY;
|
|
77
|
+
const seenUnits = new Set<string>();
|
|
78
|
+
const re = /(\d+(?:\.\d+)?)(ms|seconds?|secs?|s|minutes?|mins?|min|m|hours?|hrs?|hr|h|days?|d)/gy;
|
|
79
|
+
|
|
80
|
+
while (re.lastIndex < text.length) {
|
|
81
|
+
const match = re.exec(text);
|
|
82
|
+
if (!match) return undefined;
|
|
83
|
+
const amount = Number(match[1]);
|
|
84
|
+
const unit = scheduleUnit(match[2]);
|
|
85
|
+
if (!Number.isFinite(amount) || amount < 0 || !unit) return undefined;
|
|
86
|
+
if (seenUnits.has(unit.name) || unit.rank >= previousRank) return undefined;
|
|
87
|
+
seenUnits.add(unit.name);
|
|
88
|
+
previousRank = unit.rank;
|
|
89
|
+
totalMs += amount * unit.multiplier;
|
|
90
|
+
matched = true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const delayMs = Math.round(totalMs);
|
|
94
|
+
return matched ? clampScheduleDelay(delayMs) : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function clampScheduleDelay(delayMs: number): number | undefined {
|
|
84
98
|
if (!Number.isFinite(delayMs) || delayMs <= 0 || delayMs > MAX_SCHEDULE_DELAY_MS) return undefined;
|
|
85
99
|
return delayMs;
|
|
86
100
|
}
|
|
87
101
|
|
|
102
|
+
function scheduleUnit(unit: string): { multiplier: number; name: string; rank: number } | undefined {
|
|
103
|
+
if (unit === "ms") return { multiplier: 1, name: "ms", rank: 0 };
|
|
104
|
+
if (["s", "sec", "secs", "second", "seconds"].includes(unit)) return { multiplier: 1000, name: "s", rank: 1 };
|
|
105
|
+
if (["m", "min", "mins", "minute", "minutes"].includes(unit)) return { multiplier: 60 * 1000, name: "m", rank: 2 };
|
|
106
|
+
if (["h", "hr", "hrs", "hour", "hours"].includes(unit)) return { multiplier: 60 * 60 * 1000, name: "h", rank: 3 };
|
|
107
|
+
if (["d", "day", "days"].includes(unit)) return { multiplier: 24 * 60 * 60 * 1000, name: "d", rank: 4 };
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
88
111
|
export function parseScheduleCommandArgs(args: string): ParsedScheduleCommand {
|
|
89
112
|
const trimmed = args.trim();
|
|
90
113
|
if (!trimmed) return { kind: "list" };
|
|
@@ -131,7 +154,8 @@ function usageText(): string {
|
|
|
131
154
|
return [
|
|
132
155
|
"Usage: /schedule <duration> <message>",
|
|
133
156
|
"Examples: /schedule 20m retry the previous request",
|
|
134
|
-
" /schedule
|
|
157
|
+
" /schedule 1h30m retry after the rate limit reset",
|
|
158
|
+
"Durations: 20, 20m, 90s, 500ms, 1h45m30s (bare numbers mean minutes)",
|
|
135
159
|
"Manage: /schedule list, /schedule cancel <id|all>",
|
|
136
160
|
].join("\n");
|
|
137
161
|
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
SESSION_SEARCH_STATUS_KEY,
|
|
11
11
|
} from "../constants.js";
|
|
12
12
|
import { expandHome, piSettingsPaths, settingBoolean, settingNumber, settingString, settingStringAllowEmpty } from "../settings.js";
|
|
13
|
+
import { forEachSessionJsonlLine } from "./jsonl.js";
|
|
13
14
|
import type {
|
|
14
15
|
QolSessionPaletteAction,
|
|
15
16
|
QolSessionSearchPendingMessage,
|
|
@@ -166,15 +167,14 @@ export function sessionUserMessages(sessionPath: string): QolSessionUserMessage[
|
|
|
166
167
|
if (cached) return cached;
|
|
167
168
|
const messages: QolSessionUserMessage[] = [];
|
|
168
169
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (!line.trim()) continue;
|
|
170
|
+
forEachSessionJsonlLine(sessionPath, (line) => {
|
|
171
|
+
if (!line.trim()) return;
|
|
172
172
|
let entry: any;
|
|
173
|
-
try { entry = JSON.parse(line); } catch {
|
|
173
|
+
try { entry = JSON.parse(line); } catch { return; }
|
|
174
174
|
const message = entry?.type === "message" ? entry.message : undefined;
|
|
175
|
-
if (!message || message.role !== "user")
|
|
175
|
+
if (!message || message.role !== "user") return;
|
|
176
176
|
const text = oneLine(messageContentText(message.content));
|
|
177
|
-
if (!text)
|
|
177
|
+
if (!text) return;
|
|
178
178
|
messages.push({
|
|
179
179
|
entryId: typeof entry.id === "string" ? entry.id : undefined,
|
|
180
180
|
index: messages.length + 1,
|
|
@@ -182,7 +182,7 @@ export function sessionUserMessages(sessionPath: string): QolSessionUserMessage[
|
|
|
182
182
|
text,
|
|
183
183
|
timestamp: sessionMessageTimestamp(entry, message),
|
|
184
184
|
});
|
|
185
|
-
}
|
|
185
|
+
});
|
|
186
186
|
} catch {
|
|
187
187
|
// Ignore unreadable sessions; callers fall back to SessionInfo.firstMessage.
|
|
188
188
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
1
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
3
2
|
import { SessionManager, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
4
3
|
import { Text } from "@earendil-works/pi-tui";
|
|
@@ -13,6 +12,7 @@ import {
|
|
|
13
12
|
import { settingNumber, settingString } from "../settings.js";
|
|
14
13
|
import { sessionDisplayName } from "./cache.js";
|
|
15
14
|
import { QolSessionSearchLoadingComponent } from "./component.js";
|
|
15
|
+
import { forEachSessionJsonlLine } from "./jsonl.js";
|
|
16
16
|
import type { QolSessionSearchPendingMessage, QolSessionSearchResult } from "./types.js";
|
|
17
17
|
|
|
18
18
|
export function trimSessionSummaryInput(text: string, cwd: string): string {
|
|
@@ -33,14 +33,13 @@ function messagesForSessionSummary(sessionPath: string): AgentMessage[] {
|
|
|
33
33
|
// Fall back to a direct JSONL parse below.
|
|
34
34
|
}
|
|
35
35
|
try {
|
|
36
|
-
const lines = readFileSync(sessionPath, "utf8").split(/\r?\n/);
|
|
37
36
|
const messages: AgentMessage[] = [];
|
|
38
|
-
|
|
39
|
-
if (!line.trim())
|
|
37
|
+
forEachSessionJsonlLine(sessionPath, (line) => {
|
|
38
|
+
if (!line.trim()) return;
|
|
40
39
|
let entry: any;
|
|
41
|
-
try { entry = JSON.parse(line); } catch {
|
|
40
|
+
try { entry = JSON.parse(line); } catch { return; }
|
|
42
41
|
if (entry?.type === "message" && entry.message) messages.push(entry.message);
|
|
43
|
-
}
|
|
42
|
+
});
|
|
44
43
|
return messages;
|
|
45
44
|
} catch {
|
|
46
45
|
return [];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
3
|
+
|
|
4
|
+
export function forEachSessionJsonlLine(sessionPath: string, onLine: (line: string) => void, chunkSize = 64 * 1024): void {
|
|
5
|
+
const fd = openSync(sessionPath, "r");
|
|
6
|
+
const buffer = Buffer.allocUnsafe(Math.max(1, Math.floor(chunkSize)));
|
|
7
|
+
const decoder = new StringDecoder("utf8");
|
|
8
|
+
let pending = "";
|
|
9
|
+
try {
|
|
10
|
+
for (;;) {
|
|
11
|
+
const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
|
|
12
|
+
if (bytesRead === 0) break;
|
|
13
|
+
pending += decoder.write(buffer.subarray(0, bytesRead));
|
|
14
|
+
let start = 0;
|
|
15
|
+
for (;;) {
|
|
16
|
+
const newline = pending.indexOf("\n", start);
|
|
17
|
+
if (newline < 0) {
|
|
18
|
+
pending = pending.slice(start);
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
const line = pending.slice(start, newline);
|
|
22
|
+
onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
23
|
+
start = newline + 1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
pending += decoder.end();
|
|
27
|
+
if (pending.length > 0) onLine(pending.endsWith("\r") ? pending.slice(0, -1) : pending);
|
|
28
|
+
} finally {
|
|
29
|
+
closeSync(fd);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -39,7 +39,7 @@ export function statusMessage(ctx: ExtensionContext): string {
|
|
|
39
39
|
`Custom compaction: ${settingBoolean("compaction.customEnabled", false, ctx.cwd) ? `enabled (${settingString("compaction.model", DEFAULT_COMPACTION_MODEL, ctx.cwd)}, ${compactionProfile(ctx.cwd)})` : "disabled (Pi default)"}`,
|
|
40
40
|
`Idle compaction: ${settingBoolean("compaction.idleEnabled", false, ctx.cwd) ? `enabled after ${Math.max(1, Math.floor(settingNumber("compaction.idleTimeoutSeconds", DEFAULT_IDLE_COMPACTION_SECONDS, ctx.cwd)))}s idle` : "disabled"}`,
|
|
41
41
|
`Branch summary override: ${settingBoolean("compaction.branchSummaryEnabled", false, ctx.cwd) ? "enabled" : "disabled"}`,
|
|
42
|
-
`Notifications: ${settingBoolean("notification.enabled", true, ctx.cwd) ? `enabled (bell=${settingBoolean("notification.bell", true, ctx.cwd)}, native=${settingBoolean("notification.native", true, ctx.cwd)}, tmuxClientTty=${settingBoolean("notification.tmuxNativeClientTty", true, ctx.cwd)}, tmuxMessage=${settingBoolean("notification.tmux", false, ctx.cwd)})` : "disabled"}`,
|
|
42
|
+
`Notifications: ${settingBoolean("notification.enabled", true, ctx.cwd) ? `enabled (bell=${settingBoolean("notification.bell", true, ctx.cwd)}, muteBell=${settingBoolean("notification.muteBellSound", false, ctx.cwd)}, native=${settingBoolean("notification.native", true, ctx.cwd)}, tmuxClientTty=${settingBoolean("notification.tmuxNativeClientTty", true, ctx.cwd)}, tmuxMessage=${settingBoolean("notification.tmux", false, ctx.cwd)})` : "disabled"}`,
|
|
43
43
|
`Permission gate: ${settingBoolean("permissionGate.enabled", false, ctx.cwd) ? `enabled (${permissionGateCommands(ctx.cwd).join(", ") || "none configured"}; preview ${boundedSettingNumber("permissionGate.previewLines", DEFAULT_PERMISSION_GATE_PREVIEW_LINES, 4, 40, ctx.cwd)} lines/${boundedSettingNumber("permissionGate.previewChars", DEFAULT_PERMISSION_GATE_PREVIEW_CHARS, 200, 5000, ctx.cwd)} chars)` : "disabled"}`,
|
|
44
44
|
`Thinking timer: ${settingBoolean("thinkingTimer.enabled", true, ctx.cwd) ? "enabled" : "disabled"}`,
|
|
45
45
|
"If shift+enter still submits, configure your terminal/tmux to send a distinct shift+enter sequence or use the fallback key.",
|
package/extensions/qol.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type
|
|
2
|
+
import { truncateToWidth, type TUI } from "@earendil-works/pi-tui";
|
|
3
3
|
import { criticalInfo, lastAssistantTextFromAgentEnd, needsDirection, taskStats } from "./qol/agent-end.js";
|
|
4
4
|
import { getQuestionService, readCavemanBridge, type QuestionOpenedEventLike } from "./qol/bridges.js";
|
|
5
5
|
import { BudgetGuardDriver } from "./qol/budget-guard-runtime.js";
|
|
6
|
+
import { ansiGreen } from "./qol/ansi.js";
|
|
6
7
|
import {
|
|
7
8
|
budgetGuardTrigger,
|
|
8
9
|
compactionTriggerReason,
|
|
@@ -262,9 +263,19 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
262
263
|
};
|
|
263
264
|
|
|
264
265
|
const budgetGuardDriver = new BudgetGuardDriver();
|
|
266
|
+
let budgetGuardStatus: string | undefined;
|
|
267
|
+
|
|
268
|
+
const setBudgetGuardStatus = (ctx: ExtensionContext, message: string | undefined) => {
|
|
269
|
+
budgetGuardStatus = message;
|
|
270
|
+
if (ctx.hasUI) {
|
|
271
|
+
ctx.ui.setStatus("qol-budget-guard", message ? ctx.ui.theme.fg("accent", message) : undefined);
|
|
272
|
+
requestRender();
|
|
273
|
+
}
|
|
274
|
+
};
|
|
265
275
|
|
|
266
276
|
const resetBudgetGuard = () => {
|
|
267
277
|
budgetGuardDriver.reset();
|
|
278
|
+
budgetGuardStatus = undefined;
|
|
268
279
|
};
|
|
269
280
|
|
|
270
281
|
const maybeFireBudgetGuard = (ctx: ExtensionContext) => {
|
|
@@ -286,6 +297,7 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
286
297
|
budgetGuardDriver.dispatch({
|
|
287
298
|
compact: typeof ctx.compact === "function" ? ctx.compact.bind(ctx) : undefined,
|
|
288
299
|
notify: notifySafely,
|
|
300
|
+
onStatus: (message) => setBudgetGuardStatus(ctx, message),
|
|
289
301
|
trigger,
|
|
290
302
|
});
|
|
291
303
|
};
|
|
@@ -584,6 +596,7 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
584
596
|
render(width: number): string[] {
|
|
585
597
|
const lines = rateLimitAutoResumeController.enabled(ctx) ? rateLimitAutoResumeController.renderPreviewLines(width) : [];
|
|
586
598
|
if (scheduleEnabled) lines.push(...scheduleController.renderPreviewLines(width));
|
|
599
|
+
if (budgetGuardStatus) lines.push(truncateToWidth(ansiGreen(`┃ ${budgetGuardStatus}`), width, ""));
|
|
587
600
|
if (showStatusline) lines.push(renderStatusLine(width, ctx, gitState ?? makeFallbackGitState(ctx.cwd), pi, theme));
|
|
588
601
|
return lines;
|
|
589
602
|
},
|
|
@@ -635,10 +648,12 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
635
648
|
questionUnsubscribe?.();
|
|
636
649
|
questionUnsubscribe = undefined;
|
|
637
650
|
currentCtx = undefined;
|
|
651
|
+
budgetGuardStatus = undefined;
|
|
638
652
|
const host = globalThis as unknown as Record<PropertyKey, unknown>;
|
|
639
653
|
if (host[QOL_NOTIFICATION_SERVICE_SYMBOL] === notificationService) delete host[QOL_NOTIFICATION_SERVICE_SYMBOL];
|
|
640
654
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
641
655
|
ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
|
|
656
|
+
ctx.ui.setStatus("qol-budget-guard", undefined);
|
|
642
657
|
restorePendingQueueThemePatch(ctx);
|
|
643
658
|
resetStatuslineUi(ctx);
|
|
644
659
|
ctx.ui.setEditorComponent(undefined);
|
|
@@ -721,6 +736,7 @@ export default function qol(pi: ExtensionAPI): void {
|
|
|
721
736
|
sendQolNotification(ctx, "ready", settingString("notification.readyMessage", "Ready for input", ctx.cwd), "info", "ready");
|
|
722
737
|
});
|
|
723
738
|
pi.on("session_compact", (_event, ctx) => {
|
|
739
|
+
if (budgetGuardStatus) setBudgetGuardStatus(ctx, "QOL budget guard finalizing compaction…");
|
|
724
740
|
// After a successful compaction usage drops below the budget, so reset the
|
|
725
741
|
// crossing key so the next threshold crossing re-fires the guard.
|
|
726
742
|
budgetGuardDriver.noteSessionCompacted();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vanillagreen/pi-qol",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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": [
|
|
@@ -582,6 +582,15 @@
|
|
|
582
582
|
"category": "Notifications Channels",
|
|
583
583
|
"apply": "live"
|
|
584
584
|
},
|
|
585
|
+
{
|
|
586
|
+
"key": "notification.muteBellSound",
|
|
587
|
+
"label": "Mute bell sound",
|
|
588
|
+
"description": "Keep notifications enabled while suppressing QOL-emitted BEL bytes; OSC terminal notifications use ST terminators where supported.",
|
|
589
|
+
"type": "boolean",
|
|
590
|
+
"default": false,
|
|
591
|
+
"category": "Notifications Channels",
|
|
592
|
+
"apply": "live"
|
|
593
|
+
},
|
|
585
594
|
{
|
|
586
595
|
"key": "notification.bellWhenActive",
|
|
587
596
|
"label": "Bell when tmux window active",
|