@vanillagreen/pi-qol 1.3.0 → 1.4.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
@@ -5,7 +5,21 @@
5
5
  ![Session search popup](https://raw.githubusercontent.com/vanillagreencom/vstack/main/pi-extensions/pi-qol/assets/session-search.gif)
6
6
  ![/context usage breakdown](https://raw.githubusercontent.com/vanillagreencom/vstack/main/pi-extensions/pi-qol/assets/context-usage.png)
7
7
 
8
- Quality-of-life extension for Pi.
8
+ Quality-of-life extension for Pi: compact statusline, multiline input, session naming and search, notifications, and a permission gate.
9
+
10
+ ## Highlights
11
+
12
+ - Compact statusline with repo, branch, model, thinking level, and context percent.
13
+ - Shift+Enter inserts a newline. Ctrl+J fallback for terminals that can't distinguish it.
14
+ - Sessions auto-name from your first prompt. `/rename` overrides anytime.
15
+ - `/search` browses previous sessions with snippet previews. F2 opens it instantly.
16
+ - `/context` shows a Claude-style context-window breakdown.
17
+ - `/handoff <goal>` drafts a focused prompt for a new session.
18
+ - Permission gate prompts before risky `bash` commands. Default match: `rm -Rf`.
19
+ - Notifications for ready, questions, blocked states, and task completion.
20
+ - Thinking timer next to collapsed `Thinking...` labels.
21
+ - Caveman badge and Alt+C mode cycling when `pi-caveman` is loaded.
22
+ - Subagent-name badge in `pi-agents-tmux` child panes.
9
23
 
10
24
  ## Install
11
25
 
@@ -24,108 +38,137 @@ vstack add vanillagreencom/vstack --pi-extension pi-qol --harness pi -y
24
38
 
25
39
  Restart Pi after installation.
26
40
 
27
- ## What it provides
28
-
29
- - Compact statusline and `π` prompt editor: shows repo/project, branch/dirty state, model, colorized thinking level, optional Caveman badge, context window size, remaining context percent, and (inside `pi-agents-tmux` child panes) an ANSI-background subagent-name badge while replacing Pi's default footer/editor chrome.
30
- - Reliable multiline input: `Shift+Enter` / `Shift+Return` inserts a newline when the terminal reports it distinctly; `ctrl+j` is the default fallback newline key. `Alt+Enter` is reserved for Pi follow-up messages. QOL can also style Pi's built-in steering/follow-up pending queue preview with an ANSI green left bar while leaving the restore hint muted. When `pi-caveman` is loaded, `Alt+C` cycles Caveman modes.
31
- - Compact image placeholders: existing pasted image paths can collapse to `[Image #N]` aliases and are attached on submit.
32
- - Session naming: `/rename [name]` sets or shows the friendly session name; automatic first-prompt naming is enabled by default.
33
- - Context usage: `/context` prints an inline Claude-style context-window visualization with estimated Pi/model category breakdowns.
34
- - Previous-session search: `/search` and optional `F2` overlay search prior sessions, preview snippets, resume, inject summarized context, or start a new session with summarized context.
35
- - Handoff: `/handoff <goal>` drafts a focused prompt for a new session, preserving the latest compaction summary plus retained branch entries.
36
- - Optional permission gate: when enabled, prompts before configured `bash` tool command fragments run; default match is `rm -Rf`.
37
- - Notifications: terminal/tmux/native notifications for ready-for-input, questions, direction needed, task completion, and critical/blocked states.
38
- - Optional custom compaction and idle compaction; disabled by default so Pi's compaction behavior is unchanged until enabled.
39
- - Thinking timer next to collapsed `Thinking...` labels; enabled by default and falls back to Pi defaults if internals change.
40
-
41
41
  ## Commands
42
42
 
43
43
  | Command | Action |
44
44
  | --- | --- |
45
- | `/qol` | Open the extension-manager settings popup (falls back to inline status when the manager is not installed). |
45
+ | `/qol` | Open settings (or print status if extension-manager isn't installed). |
46
46
  | `/qol notify-test` | Send a test notification. |
47
- | `/qol:rename` | Regenerate the current session name from the first user prompt. |
48
- | `/qol:rename:full` | Regenerate the session name from the full conversation. |
49
- | `/rename [name]` | Set or show the current session's friendly name. |
50
- | `/context` | Show inline context-window usage, model/context limit, and estimated category breakdowns. |
51
- | `/search [query]` | Open previous-session search, optionally prefilled with a query. |
47
+ | `/rename [name]` | Set or show the current session's name. |
48
+ | `/qol:rename` | Regenerate the session name from the first prompt. |
49
+ | `/qol:rename:full` | Regenerate from the full conversation. |
50
+ | `/context` | Show context-window usage with category breakdown. |
51
+ | `/search [query]` | Open previous-session search. |
52
52
  | `/search:refresh` | Refresh the session search cache. |
53
- | `/search:resume-pending <id>` | Internal recovery command inserted into the editor when a resume/fork action must be confirmed by pressing Enter. |
54
- | `/handoff <goal>` | Draft a focused handoff prompt for a new session. |
53
+ | `/handoff <goal>` | Draft a handoff prompt for a new session. |
55
54
 
56
- `/qol` and `/search` arguments support autocomplete.
55
+ Arguments support autocomplete.
57
56
 
58
57
  ## Settings
59
58
 
60
- Settings are exposed through `pi-extension-manager` under **QOL**.
59
+ All settings live in the extension manager under **QOL**. Names below match the labels shown there.
61
60
 
62
- ### Statusline and prompt
61
+ ### Statusline
63
62
 
64
- - `replaceFooter`: hide Pi's default footer while QOL's compact statusline is active; default on.
65
- - `compactPrompt`: use the compact `π` prompt editor; default on.
66
- - `showSessionNameTitle`: show the named session above the prompt, or in tmux's pane title/border; default on.
67
- - `showSessionNameWindow`: also rename the current tmux window to `π <session>` so it appears in the tmux status line. Disables `automatic-rename` for the window while active and restores it on shutdown. Requires `showSessionNameTitle`; default on.
68
- - `inputBottomPaddingLines`: blank lines below the compact prompt; default `0`.
69
- - Git display knobs: `gitRefreshTimeoutMs`, `showDirtyMarker`.
70
- - If `pi-caveman` is loaded, QOL reads its bridge to show a Caveman icon in the statusline and to make `Alt+C` cycle Caveman modes. The Caveman package's `showStatusBadge` setting also controls this QOL badge.
71
- - If `pi-agents-tmux` is loaded inside a child pane, QOL reads its bridge/env metadata to show a trailing subagent-name badge after the context percent. The badge uses the agent `color:` frontmatter (`red`, `green`, `yellow`, `blue`, `magenta`, `cyan`; aliases `orange`, `purple`/`violet`, `teal`) or falls back to automatic color cycling.
63
+ | Setting | What it does |
64
+ | --- | --- |
65
+ | Enable QOL editor helpers | Master toggle for QOL statusline, commands, notifications, search, compaction, and editor helpers. |
66
+ | Replace built-in footer | Hide Pi's default footer while the QOL statusline is active. |
67
+ | Use π prompt editor | Use the compact prompt editor. |
68
+ | Show session name title | Show the session name above the prompt and in the tmux pane title. |
69
+ | Sync session name to tmux window name | Rename the tmux window to `π <session>`. |
70
+ | Input bottom padding | Blank lines below the prompt. |
71
+ | Show dirty marker | Append `*` to the branch when the worktree is dirty. |
72
72
 
73
73
  ### Input
74
74
 
75
- - `newlineOnShiftEnter`: intercept distinguishable `Shift+Enter` / `Shift+Return` and insert a newline; default on.
76
- - `newlineFallbackKey`: alternate newline key for terminals that cannot distinguish shifted enter; default `ctrl+j`, or `none` to disable.
77
- - `pendingQueue.asciiGreen`: style Pi's built-in steering/follow-up pending queue preview with an ANSI green heavy left bar while keeping the Alt+Up restore hint in the theme hint color with a two-space indent; default on.
75
+ | Setting | What it does |
76
+ | --- | --- |
77
+ | Shift+Enter inserts newline | Insert a newline instead of submitting on Shift+Enter. |
78
+ | Fallback newline key | Alternate newline key for terminals that can't send Shift+Enter. |
79
+ | Style pending queue preview | Highlight Pi's pending-queue preview with a green left bar. |
80
+ | Style image chips | Render `[Image #N]` placeholders as distinct chips. |
81
+ | Show attachment count | Show a status badge when the draft has image placeholders. |
78
82
 
79
- ### Session auto-rename
83
+ ### Session naming
80
84
 
81
- - `sessionAutoRename.enabled`: automatically name unnamed sessions after the first prompt/agent turn; default on.
82
- - `sessionAutoRename.model`: naming model (`provider/model`, `current`, or `cheapest`); default `openai-codex/gpt-5.4-mini`.
83
- - `sessionAutoRename.fallbackModel`: fallback model; default `current`, or `none`/`off` to skip.
84
- - `sessionAutoRename.fallback`: deterministic fallback when model naming fails: `words`, `truncate`, or `none`.
85
- - Advanced knobs: `prefix`, `maxInputChars`, `maxNameChars`, `maxTokens`, `timeoutMs`, `prompt`, `notify`, and `debug`.
85
+ | Setting | What it does |
86
+ | --- | --- |
87
+ | Enable /rename command | Register the `/rename` command. |
88
+ | Auto-name new sessions | Generate a friendly session name from the first prompt. |
89
+ | Auto-rename model | Model used for title generation. |
90
+ | Auto-rename fallback model | Model tried when the primary fails. |
91
+ | Deterministic fallback | Title-case words, truncated prompt, or none if all model calls fail. |
92
+ | Auto-rename prefix | Optional static prefix on every generated name. |
93
+ | Notify on auto-rename | Show a notification when auto-renaming. |
86
94
 
87
- ### Session search
95
+ Advanced: input cap, title length, output tokens, timeout, custom prompt template, and debug logging.
88
96
 
89
- - `sessionSearch.enabled`: register `/search` and the overlay.
90
- - `sessionSearch.shortcutKey`: shortcut to open search; default `f2`, set `none` to disable.
91
- - Search ordering: empty search shows prompts by recency; typed search sorts by match quality, then prompt recency.
92
- - Result/layout knobs: `resultLimit`, `maxVisible`, `messageMaxVisible`, `previewSnippets`, `overlayWidth`, `cacheTtlSeconds`.
93
- - Summary knobs: `summaryModel`, `summaryMaxTokens`, `summaryInputMaxChars`.
97
+ ### Handoff
94
98
 
95
- Search rows use Pi's `/resume`-style title: explicit session name, otherwise first user prompt, otherwise filename. The session cache is warmed on session start/first use and, by default, kept until `/search:refresh`; set `cacheTtlSeconds` above `0` if you want automatic time-based refreshes.
99
+ | Setting | What it does |
100
+ | --- | --- |
101
+ | Enable /handoff command | Register the `/handoff` command. |
102
+ | Review handoff prompt | Open an editor to edit the generated prompt before creating the session. |
103
+
104
+ ### Context window
105
+
106
+ | Setting | What it does |
107
+ | --- | --- |
108
+ | Enable /context command | Register `/context`. |
96
109
 
97
- ### Context usage
110
+ ### Session search
98
111
 
99
- - `enableContextCommand`: register `/context`; default on. The display uses Pi's `ctx.getContextUsage()` for total tokens/context window, then estimates the category split from the current system prompt, active tool definitions, session messages, compact summaries, context files, skills, and custom agents when Pi exposes that structured data.
112
+ | Setting | What it does |
113
+ | --- | --- |
114
+ | Enable session search | Register `/search` and the overlay. |
115
+ | Session search shortcut | Shortcut to open search. Default `f2`; set to `none` to disable. |
116
+ | Result limit | Max matching prompts returned. |
117
+ | Visible session rows | Rows shown before scrolling. |
118
+ | Preview snippets | Matching snippets shown on the preview screen. |
119
+ | Session cache TTL | Seconds before the session list refreshes; `0` keeps it until you run `/search:refresh`. |
120
+
121
+ Summary settings (model, max tokens, input cap) tune the summarizer when you import context from a previous session.
100
122
 
101
123
  ### Notifications
102
124
 
103
- - Master and trigger toggles: `notification.enabled`, `onAgentReady`, `onDirectionNeeded`, `onQuestion`, `onTaskComplete`, `onCritical`.
104
- - Channels: BEL, native terminal notifications, tmux client TTY writes, tmux messages, optional tmux window marking, and Pi UI notifications.
105
- - `notification.oscProtocol`: `auto`, `osc777`, `osc99`, or `off`; `auto` uses Kitty OSC 99 when available, otherwise OSC 777.
106
- - Tuning: `cooldownSeconds`, `title`, `readyMessage`, `bodyMaxChars`, `tmuxMessageDurationMs`, and tmux mark text/duration.
125
+ Master toggle: **Enable notifications**.
126
+
127
+ Triggers (notify when): ready, direction needed, question popups, all tasks complete, critical/blocked.
128
+
129
+ 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.
130
+
131
+ Tuning: cooldown seconds, title, ready message, body length, tmux durations.
132
+
133
+ Notes:
134
+
135
+ - **Terminal notification protocol** picks between OSC 99 (Kitty) and OSC 777 automatically.
136
+ - **Bell when tmux window active** is off so you don't get bells while looking at Pi.
137
+ - **tmux native via client TTY** sends OSC notifications to attached tmux clients so notifications still appear when the Pi window is inactive.
107
138
 
108
- Use `/qol notify-test` to verify your terminal/tmux notification path.
139
+ Use `/qol notify-test` to verify your terminal/tmux setup.
109
140
 
110
141
  ### Permission gate
111
142
 
112
- - `permissionGate.enabled`: ask before matching `bash` tool calls; default off. When enabled, non-interactive matches are blocked.
113
- - `permissionGate.commands`: comma-separated literal fragments or `/pattern/flags` regexes; default `rm -Rf`.
114
- - `permissionGate.previewLines` / `permissionGate.previewChars`: cap the approval prompt preview; long commands show a compact head/tail preview while the full command still runs only if approved.
143
+ | Setting | What it does |
144
+ | --- | --- |
145
+ | Prompt before risky bash commands | Ask before bash commands matching the command list. |
146
+ | Commands to prompt for | Comma-separated literal fragments or `/regex/flags`. |
147
+ | Approval preview lines | Cap the approval-prompt preview height. |
148
+ | Approval preview characters | Cap the approval-prompt preview width. |
115
149
 
116
- ### Compaction
150
+ Off by default. When enabled, non-interactive matches are blocked.
117
151
 
118
- - `compaction.customEnabled`: use QOL custom summaries for Pi compaction events; default off.
119
- - `compaction.model`: summarizer model; default `google/gemini-2.5-flash`.
120
- - `compaction.profile`: `concise`, `balanced`, or `exhaustive`.
121
- - `compaction.remoteEnabled` / `compaction.remoteEndpoint`: call a remote summarizer with `{ systemPrompt, prompt, maxTokens }` and expect `{ summary }`.
122
- - `compaction.branchSummaryEnabled`: use the same summarizer for requested `/tree` branch summaries.
123
- - `compaction.idleEnabled` and related thresholds: extension-managed idle compaction trigger.
152
+ ### Compaction
124
153
 
125
- ### Thinking timer
154
+ | Setting | What it does |
155
+ | --- | --- |
156
+ | Custom compaction summaries | Use QOL summaries instead of Pi's default. |
157
+ | Compaction model | Summarizer model. |
158
+ | Compaction detail profile | `concise`, `balanced`, or `exhaustive`. |
159
+ | Include previous summary | Pass the previous summary for iterative continuity. |
160
+ | Fallback to Pi default compaction | Run Pi's default compaction if QOL's fails. |
161
+ | Show compaction notifications | Notify on compaction start/fail/complete. |
162
+ | Custom branch summaries | Use the QOL summarizer for `/tree` branch summaries. |
163
+ | Remote compaction endpoint | Call a remote HTTP summarizer instead of a model. |
164
+ | Idle compaction trigger | Auto-compact after the session sits idle above a token threshold. |
126
165
 
127
- - `thinkingTimer.enabled`: show elapsed time next to collapsed `Thinking...` labels when the model emits thinking blocks.
166
+ Idle thresholds (token threshold, idle delay, fixed token limit, percent limit) tune when idle compaction fires.
128
167
 
129
- ## Notes
168
+ ### Thinking
130
169
 
131
- Pi owns native pending image attachment state and does not expose it to extensions. QOL can attach image paths it collapses itself; native Pi paste/drag attachments remain Pi-owned.
170
+ | Setting | What it does |
171
+ | --- | --- |
172
+ | Hidden thinking label | Label shown when thinking blocks are hidden. |
173
+ | Show thinking timer | Show elapsed time next to collapsed `Thinking...` labels. |
174
+ | Working indicator mode | `animated` ticks every 80ms; switch to `static` if you see flashes when the chat overflows. |
@@ -6,6 +6,11 @@ import { serializeMessagesForSummary } from "./compaction.js";
6
6
  import { HANDOFF_SYSTEM_PROMPT } from "./constants.js";
7
7
  import { settingBoolean } from "./settings.js";
8
8
 
9
+ function errorMessage(error: unknown): string {
10
+ const message = error instanceof Error ? error.message : String(error);
11
+ return message.length > 180 ? `${message.slice(0, 179)}…` : message;
12
+ }
13
+
9
14
  export async function runHandoff(args: string, ctx: ExtensionCommandContext): Promise<void> {
10
15
  if (!ctx.hasUI) {
11
16
  ctx.ui.notify("handoff requires interactive mode", "error");
@@ -36,56 +41,58 @@ export async function runHandoff(args: string, ctx: ExtensionCommandContext): Pr
36
41
 
37
42
  const releaseModalLock = acquireVstackModalLock();
38
43
  let result: string | null = null;
44
+ let generationError: unknown;
39
45
  try {
40
- result = await ctx.ui.custom<string | null>((tui: any, theme: any, _kb: any, done: (value: string | null) => void) => {
41
- const loader = new BorderedLoader(tui, theme, "Generating handoff prompt...");
42
- loader.onAbort = () => done(null);
43
-
44
- const doGenerate = async () => {
45
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model!);
46
- if (!auth.ok || !auth.apiKey) {
47
- throw new Error(auth.ok ? `No API key for ${ctx.model!.provider}` : auth.error);
48
- }
49
-
50
- const userMessage: Message = {
51
- role: "user",
52
- content: [
53
- {
54
- type: "text",
55
- text: `## Conversation History\n\n${conversationText}\n\n## User's Goal for New Thread\n\n${goal}`,
56
- },
57
- ],
58
- timestamp: Date.now(),
46
+ result = await ctx.ui.custom<string | null>((tui: any, theme: any, _kb: any, done: (value: string | null) => void) => {
47
+ const loader = new BorderedLoader(tui, theme, "Generating handoff prompt...");
48
+ loader.onAbort = () => done(null);
49
+
50
+ const doGenerate = async () => {
51
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model!);
52
+ if (!auth.ok || !auth.apiKey) {
53
+ throw new Error(auth.ok ? `No API key for ${ctx.model!.provider}` : auth.error);
54
+ }
55
+
56
+ const userMessage: Message = {
57
+ role: "user",
58
+ content: [
59
+ {
60
+ type: "text",
61
+ text: `## Conversation History\n\n${conversationText}\n\n## User's Goal for New Thread\n\n${goal}`,
62
+ },
63
+ ],
64
+ timestamp: Date.now(),
65
+ };
66
+
67
+ const response = await complete(
68
+ ctx.model!,
69
+ { systemPrompt: HANDOFF_SYSTEM_PROMPT, messages: [userMessage] },
70
+ { apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
71
+ );
72
+
73
+ if (response.stopReason === "aborted") return null;
74
+ return response.content
75
+ .filter((content): content is { type: "text"; text: string } => content.type === "text")
76
+ .map((content) => content.text)
77
+ .join("\n");
59
78
  };
60
79
 
61
- const response = await complete(
62
- ctx.model!,
63
- { systemPrompt: HANDOFF_SYSTEM_PROMPT, messages: [userMessage] },
64
- { apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
65
- );
66
-
67
- if (response.stopReason === "aborted") return null;
68
- return response.content
69
- .filter((content): content is { type: "text"; text: string } => content.type === "text")
70
- .map((content) => content.text)
71
- .join("\n");
72
- };
73
-
74
- doGenerate()
75
- .then(done)
76
- .catch((error) => {
77
- console.error("Handoff generation failed:", error);
78
- done(null);
79
- });
80
-
81
- return loader;
82
- });
80
+ doGenerate()
81
+ .then(done)
82
+ .catch((error) => {
83
+ generationError = error;
84
+ done(null);
85
+ });
86
+
87
+ return loader;
88
+ });
83
89
  } finally {
84
90
  releaseModalLock();
85
91
  }
86
92
 
87
93
  if (result === null) {
88
- ctx.ui.notify("Cancelled", "info");
94
+ if (generationError) ctx.ui.notify(`Handoff generation failed: ${errorMessage(generationError)}`, "error");
95
+ else ctx.ui.notify("Cancelled", "info");
89
96
  return;
90
97
  }
91
98
 
@@ -30,6 +30,14 @@ export function autoRenameEnabled(cwd?: string): boolean {
30
30
  return settingBoolean("sessionAutoRename.enabled", true, cwd);
31
31
  }
32
32
 
33
+ // pi raises this exact prefix when a captured ExtensionContext is touched
34
+ // after newSession / fork / switchSession / reload. Recognising it lets us
35
+ // abort gracefully instead of crashing the host process.
36
+ export function isStaleCtxError(error: unknown): boolean {
37
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
38
+ return /extension ctx is stale/i.test(message);
39
+ }
40
+
33
41
  function autoRenameCtxAlive(ctx: ExtensionContext): boolean {
34
42
  // Auto-rename runs from a deferred timer / async path, so the captured
35
43
  // extension context can be invalidated mid-flight by newSession / fork /
@@ -44,6 +52,16 @@ function autoRenameCtxAlive(ctx: ExtensionContext): boolean {
44
52
  }
45
53
  }
46
54
 
55
+ // Read a ctx property without crashing if the ctx has been replaced. Used
56
+ // for the cwd-derived setting reads that downstream helpers depend on.
57
+ function readCtxCwd(ctx: ExtensionContext): string | undefined {
58
+ try {
59
+ return ctx.cwd;
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
47
65
  function autoRenameDebug(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
48
66
  if (!autoRenameCtxAlive(ctx)) return;
49
67
  if (settingBoolean("sessionAutoRename.debug", false, ctx.cwd)) ctx.ui.notify(`[auto-rename] ${message}`, level);
@@ -230,7 +248,13 @@ function modelCost(model: any): number {
230
248
  }
231
249
 
232
250
  async function cheapestAvailableAutoRenameModel(ctx: ExtensionContext): Promise<AutoRenameAuth | undefined> {
233
- const registry = ctx.modelRegistry as any;
251
+ let registry: any;
252
+ try {
253
+ registry = ctx.modelRegistry as any;
254
+ } catch (error) {
255
+ if (isStaleCtxError(error)) return undefined;
256
+ throw error;
257
+ }
234
258
  const rawModels = typeof registry.getAvailable === "function" ? registry.getAvailable() : typeof registry.getAll === "function" ? registry.getAll() : [];
235
259
  const models = Array.isArray(rawModels) ? rawModels.filter((model) => model?.input?.includes?.("text") ?? true) : [];
236
260
  models.sort((a, b) => modelCost(a) - modelCost(b) || modelLabel(a).localeCompare(modelLabel(b)));
@@ -239,7 +263,8 @@ async function cheapestAvailableAutoRenameModel(ctx: ExtensionContext): Promise<
239
263
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
240
264
  const headers = headerRecord(auth.headers);
241
265
  if (auth.ok && (auth.apiKey || headers)) return { apiKey: auth.apiKey, headers, label: modelLabel(model), model, source: "cheapest" };
242
- } catch {
266
+ } catch (error) {
267
+ if (isStaleCtxError(error)) return undefined;
243
268
  // Try the next available model.
244
269
  }
245
270
  }
@@ -247,48 +272,58 @@ async function cheapestAvailableAutoRenameModel(ctx: ExtensionContext): Promise<
247
272
  }
248
273
 
249
274
  async function resolveAutoRenameModel(ctx: ExtensionContext, configured: string): Promise<AutoRenameAuth | undefined> {
250
- if (configured.trim().toLowerCase() === "cheapest") return cheapestAvailableAutoRenameModel(ctx);
251
- const model = resolveConfiguredModel(ctx, configured);
252
- if (!model) {
253
- autoRenameDebug(ctx, `model not found: ${configured}`, "warning");
254
- return undefined;
255
- }
256
275
  try {
257
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
258
- const headers = headerRecord(auth.headers);
259
- if (!auth.ok) {
260
- autoRenameDebug(ctx, `auth unavailable for ${modelLabel(model)}: ${auth.error}`, "warning");
276
+ if (configured.trim().toLowerCase() === "cheapest") return await cheapestAvailableAutoRenameModel(ctx);
277
+ const model = resolveConfiguredModel(ctx, configured);
278
+ if (!model) {
279
+ autoRenameDebug(ctx, `model not found: ${configured}`, "warning");
261
280
  return undefined;
262
281
  }
263
- if (!auth.apiKey && !headers) {
264
- autoRenameDebug(ctx, `no auth for ${modelLabel(model)}; use /login or models.json`, "warning");
282
+ try {
283
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
284
+ const headers = headerRecord(auth.headers);
285
+ if (!auth.ok) {
286
+ autoRenameDebug(ctx, `auth unavailable for ${modelLabel(model)}: ${auth.error}`, "warning");
287
+ return undefined;
288
+ }
289
+ if (!auth.apiKey && !headers) {
290
+ autoRenameDebug(ctx, `no auth for ${modelLabel(model)}; use /login or models.json`, "warning");
291
+ return undefined;
292
+ }
293
+ return { apiKey: auth.apiKey, headers, label: modelLabel(model), model, source: configured };
294
+ } catch (error) {
295
+ if (isStaleCtxError(error)) return undefined;
296
+ autoRenameDebug(ctx, `auth failed for ${modelLabel(model)}: ${stringifyError(error)}`, "warning");
265
297
  return undefined;
266
298
  }
267
- return { apiKey: auth.apiKey, headers, label: modelLabel(model), model, source: configured };
268
299
  } catch (error) {
269
- autoRenameDebug(ctx, `auth failed for ${modelLabel(model)}: ${stringifyError(error)}`, "warning");
270
- return undefined;
300
+ if (isStaleCtxError(error)) return undefined;
301
+ throw error;
271
302
  }
272
303
  }
273
304
 
274
305
  export async function generateAutoRenameName(query: string, ctx: ExtensionContext, fullConversation = false): Promise<{ name?: string; source: string }> {
275
- const maxNameChars = Math.max(20, Math.floor(settingNumber("sessionAutoRename.maxNameChars", DEFAULT_AUTO_RENAME_NAME_CHARS, ctx.cwd)));
276
- const maxTokens = Math.max(16, Math.floor(settingNumber("sessionAutoRename.maxTokens", DEFAULT_AUTO_RENAME_MAX_TOKENS, ctx.cwd)));
306
+ // Snapshot cwd once. If the ctx has already gone stale, fall through with
307
+ // undefined cwd — downstream settings reads handle that and the rename
308
+ // will abort with "none".
309
+ const cwd = readCtxCwd(ctx);
310
+ const maxNameChars = Math.max(20, Math.floor(settingNumber("sessionAutoRename.maxNameChars", DEFAULT_AUTO_RENAME_NAME_CHARS, cwd)));
311
+ const maxTokens = Math.max(16, Math.floor(settingNumber("sessionAutoRename.maxTokens", DEFAULT_AUTO_RENAME_MAX_TOKENS, cwd)));
277
312
  const prompt = fullConversation
278
313
  ? `${AUTO_RENAME_SYSTEM_PROMPT}\n\nName this session using the conversation transcript below. Return only the title.\n\n${query}`
279
- : autoRenamePrompt(query, ctx.cwd);
314
+ : autoRenamePrompt(query, cwd);
280
315
  const message: Message = {
281
316
  content: [{ text: prompt, type: "text" }],
282
317
  role: "user",
283
318
  timestamp: Date.now(),
284
319
  };
285
320
 
286
- for (const configured of autoRenameModelSettings(ctx.cwd)) {
321
+ for (const configured of autoRenameModelSettings(cwd)) {
287
322
  const resolved = await resolveAutoRenameModel(ctx, configured);
288
323
  if (!resolved) continue;
289
324
  try {
290
325
  const controller = new AbortController();
291
- const timeoutMs = Math.max(1000, Math.floor(settingNumber("sessionAutoRename.timeoutMs", DEFAULT_AUTO_RENAME_TIMEOUT_MS, ctx.cwd)));
326
+ const timeoutMs = Math.max(1000, Math.floor(settingNumber("sessionAutoRename.timeoutMs", DEFAULT_AUTO_RENAME_TIMEOUT_MS, cwd)));
292
327
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
293
328
  timeout.unref?.();
294
329
  try {
@@ -308,12 +343,13 @@ export async function generateAutoRenameName(query: string, ctx: ExtensionContex
308
343
  clearTimeout(timeout);
309
344
  }
310
345
  } catch (error) {
346
+ if (isStaleCtxError(error)) return { source: "none" };
311
347
  autoRenameDebug(ctx, `${resolved.label} failed: ${stringifyError(error)}`, "warning");
312
348
  }
313
349
  }
314
350
 
315
- const deterministic = deterministicAutoRenameName(query, ctx.cwd);
316
- return deterministic ? { name: deterministic, source: `fallback:${autoRenameFallbackMode(ctx.cwd)}` } : { source: "none" };
351
+ const deterministic = deterministicAutoRenameName(query, cwd);
352
+ return deterministic ? { name: deterministic, source: `fallback:${autoRenameFallbackMode(cwd)}` } : { source: "none" };
317
353
  }
318
354
 
319
355
  export function withAutoRenamePrefix(name: string, cwd?: string): string {
package/extensions/qol.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  conversationTranscriptText,
51
51
  firstUserMessageText,
52
52
  generateAutoRenameName,
53
+ isStaleCtxError,
53
54
  withAutoRenamePrefix,
54
55
  } from "./qol/session-rename.js";
55
56
  import {
@@ -122,34 +123,56 @@ export default function qol(pi: ExtensionAPI): void {
122
123
  };
123
124
 
124
125
  const attemptAutoRename = async (ctx: ExtensionContext, options: { force?: boolean; fullConversation?: boolean; notify?: boolean } = {}) => {
125
- const force = options.force === true;
126
- if (!force && !autoRenameEnabled(ctx.cwd)) return;
127
- if (autoRenameInProgress) return;
128
- if (!force && (autoRenameAttempted || pi.getSessionName())) return;
129
- const branch = ctx.sessionManager.getBranch?.() ?? [];
130
- const maxInputChars = Math.max(200, Math.floor(settingNumber("sessionAutoRename.maxInputChars", 2000, ctx.cwd)));
131
- const sourceText = options.fullConversation ? conversationTranscriptText(branch, maxInputChars) : firstUserMessageText(branch);
132
- if (!sourceText) {
133
- if (force) autoRenameNotify(ctx, "No user message found to name this session.", "warning", true);
134
- return;
135
- }
136
- const generation = autoRenameGeneration;
137
- if (!force) autoRenameAttempted = true;
138
- autoRenameInProgress = true;
126
+ // All ctx reads here can throw if the captured context has been replaced
127
+ // by newSession / fork / switchSession / reload. Wrap the whole body so
128
+ // a stale ctx aborts the rename quietly instead of crashing pi (and
129
+ // killing the in-flight tool dispatch that triggered agent_end).
139
130
  try {
140
- const result = await generateAutoRenameName(sourceText, ctx, options.fullConversation === true);
141
- if (generation !== autoRenameGeneration) return;
142
- if (!result.name) {
143
- autoRenameNotify(ctx, "No session name generated.", "warning", options.notify === true || force);
131
+ const force = options.force === true;
132
+ let cwd: string | undefined;
133
+ try {
134
+ cwd = ctx.cwd;
135
+ } catch (error) {
136
+ if (isStaleCtxError(error)) return;
137
+ throw error;
138
+ }
139
+ if (!force && !autoRenameEnabled(cwd)) return;
140
+ if (autoRenameInProgress) return;
141
+ if (!force && (autoRenameAttempted || pi.getSessionName())) return;
142
+ let branch;
143
+ try {
144
+ branch = ctx.sessionManager.getBranch?.() ?? [];
145
+ } catch (error) {
146
+ if (isStaleCtxError(error)) return;
147
+ throw error;
148
+ }
149
+ const maxInputChars = Math.max(200, Math.floor(settingNumber("sessionAutoRename.maxInputChars", 2000, cwd)));
150
+ const sourceText = options.fullConversation ? conversationTranscriptText(branch, maxInputChars) : firstUserMessageText(branch);
151
+ if (!sourceText) {
152
+ if (force) autoRenameNotify(ctx, "No user message found to name this session.", "warning", true);
144
153
  return;
145
154
  }
146
- if (!force && pi.getSessionName()) return;
147
- const name = withAutoRenamePrefix(result.name, ctx.cwd);
148
- if (!name) return;
149
- pi.setSessionName(name);
150
- autoRenameNotify(ctx, `Session named: ${name} (${result.source})`, "info", options.notify === true || force);
151
- } finally {
152
- if (generation === autoRenameGeneration) autoRenameInProgress = false;
155
+ const generation = autoRenameGeneration;
156
+ if (!force) autoRenameAttempted = true;
157
+ autoRenameInProgress = true;
158
+ try {
159
+ const result = await generateAutoRenameName(sourceText, ctx, options.fullConversation === true);
160
+ if (generation !== autoRenameGeneration) return;
161
+ if (!result.name) {
162
+ autoRenameNotify(ctx, "No session name generated.", "warning", options.notify === true || force);
163
+ return;
164
+ }
165
+ if (!force && pi.getSessionName()) return;
166
+ const name = withAutoRenamePrefix(result.name, cwd);
167
+ if (!name) return;
168
+ pi.setSessionName(name);
169
+ autoRenameNotify(ctx, `Session named: ${name} (${result.source})`, "info", options.notify === true || force);
170
+ } finally {
171
+ if (generation === autoRenameGeneration) autoRenameInProgress = false;
172
+ }
173
+ } catch (error) {
174
+ if (isStaleCtxError(error)) return;
175
+ throw error;
153
176
  }
154
177
  };
155
178
 
@@ -171,7 +194,10 @@ export default function qol(pi: ExtensionAPI): void {
171
194
 
172
195
  const startThinkingTimerTicker = () => {
173
196
  if (thinkingTimerTicker) return;
174
- thinkingTimerTicker = setInterval(tickThinkingTimer, 100);
197
+ // 250ms cadence (was 100ms): user-visible precision of "thinking 1.2s" still feels live,
198
+ // but render rate drops 2.5x. Every tick mutates an inline chat label, which makes the
199
+ // pi-tui above-viewport diff (firstChanged < prevViewportTop) fire as full-screen redraws.
200
+ thinkingTimerTicker = setInterval(tickThinkingTimer, 250);
175
201
  thinkingTimerTicker.unref?.();
176
202
  };
177
203
 
@@ -185,6 +211,27 @@ export default function qol(pi: ExtensionAPI): void {
185
211
  thinkingTimerStore.enabled = !!ctx?.hasUI && settingBoolean("thinkingTimer.enabled", true, ctx?.cwd);
186
212
  };
187
213
 
214
+ // Working indicator mode.
215
+ // The built-in pi-tui Loader ticks every 80ms during streaming. Each tick
216
+ // mutates a line in statusContainer; once total rendered content exceeds the
217
+ // terminal viewport (overlay content like /tree, or chat overflow), every tick
218
+ // trips pi-tui's firstChanged < prevViewportTop branch and triggers a full
219
+ // screen + scrollback clear (visible flash). This setting lets the user trade
220
+ // the spinner animation away for a stable display in overflow scenarios.
221
+ // Implementation note: Loader.restartAnimation() bails out when frames.length
222
+ // is <= 1, so a single-frame indicator does NOT start the setInterval at all.
223
+ const applyWorkingIndicatorMode = (ctx: ExtensionContext): void => {
224
+ if (!ctx.hasUI) return;
225
+ const mode = settingString("workingIndicator.mode", "animated", ctx.cwd);
226
+ if (mode === "static") {
227
+ ctx.ui.setWorkingVisible(true);
228
+ ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "●")] });
229
+ return;
230
+ }
231
+ ctx.ui.setWorkingVisible(true);
232
+ ctx.ui.setWorkingIndicator(undefined);
233
+ };
234
+
188
235
  const updateThinkingTimerEnabled = (ctx: ExtensionContext): boolean => {
189
236
  thinkingTimerStore.cwd = ctx.cwd;
190
237
  thinkingTimerStore.theme = ctx.ui.theme;
@@ -218,15 +265,36 @@ export default function qol(pi: ExtensionAPI): void {
218
265
  const delayMs = Math.max(1, Math.floor(settingNumber("compaction.idleTimeoutSeconds", DEFAULT_IDLE_COMPACTION_SECONDS, ctx.cwd))) * 1000;
219
266
  idleCompactionTimer = setTimeout(() => {
220
267
  idleCompactionTimer = undefined;
221
- if (!ctx.isIdle?.()) return;
268
+ // Same stale-ctx risk as auto-rename: the captured ctx may be invalidated
269
+ // by newSession/fork/switchSession/reload between schedule and fire.
270
+ // Bail silently if so; the next session_start will reschedule.
271
+ try {
272
+ if (!ctx.isIdle?.()) return;
273
+ } catch (error) {
274
+ if (isStaleCtxError(error)) return;
275
+ throw error;
276
+ }
222
277
  const latestReason = compactionTriggerReason(ctx);
223
278
  if (!latestReason) return;
224
- if (ctx.hasUI && settingBoolean("compaction.notify", true, ctx.cwd)) ctx.ui.notify(`QOL idle compaction starting: ${latestReason}`, "info");
225
- ctx.compact?.({
226
- customInstructions: `QOL idle compaction triggered after inactivity because ${latestReason}. Preserve current task state, decisions, files, blockers, and next steps.`,
227
- onComplete: () => { if (ctx.hasUI && settingBoolean("compaction.notify", true, ctx.cwd)) ctx.ui.notify("QOL idle compaction completed.", "info"); },
228
- onError: (error: Error) => { if (ctx.hasUI && settingBoolean("compaction.notify", true, ctx.cwd)) ctx.ui.notify(`QOL idle compaction failed: ${stringifyError(error)}`, "error"); },
229
- });
279
+ const notifySafely = (message: string, level: "info" | "error") => {
280
+ try {
281
+ if (ctx.hasUI && settingBoolean("compaction.notify", true, ctx.cwd)) ctx.ui.notify(message, level);
282
+ } catch (error) {
283
+ if (isStaleCtxError(error)) return;
284
+ throw error;
285
+ }
286
+ };
287
+ notifySafely(`QOL idle compaction starting: ${latestReason}`, "info");
288
+ try {
289
+ ctx.compact?.({
290
+ customInstructions: `QOL idle compaction triggered after inactivity because ${latestReason}. Preserve current task state, decisions, files, blockers, and next steps.`,
291
+ onComplete: () => notifySafely("QOL idle compaction completed.", "info"),
292
+ onError: (error: Error) => notifySafely(`QOL idle compaction failed: ${stringifyError(error)}`, "error"),
293
+ });
294
+ } catch (error) {
295
+ if (isStaleCtxError(error)) return;
296
+ throw error;
297
+ }
230
298
  }, delayMs);
231
299
  idleCompactionTimer.unref?.();
232
300
  };
@@ -447,6 +515,7 @@ export default function qol(pi: ExtensionAPI): void {
447
515
  installPendingQueueThemePatch(ctx);
448
516
  if (ctx.hasUI) {
449
517
  ctx.ui.setHiddenThinkingLabel(hiddenThinkingLabel(ctx.ui.theme, ctx.cwd));
518
+ applyWorkingIndicatorMode(ctx);
450
519
  gitState = makeFallbackGitState(ctx.cwd);
451
520
  void refreshStatusline(ctx);
452
521
  installSessionTitle(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-qol",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "Pi quality-of-life extension: compact statusline/π prompt, reliable multiline input, styled pasted-image chips, session naming/search/context import, handoff, permission prompts, notifications, custom compaction, and a collapsed-thinking timer.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -800,6 +800,20 @@
800
800
  "default": true,
801
801
  "category": "Thinking",
802
802
  "apply": "live"
803
+ },
804
+ {
805
+ "key": "workingIndicator.mode",
806
+ "label": "Working indicator mode",
807
+ "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.",
808
+ "type": "enum",
809
+ "enumValues": [
810
+ "animated",
811
+ "static"
812
+ ],
813
+ "default": "animated",
814
+ "category": "Thinking",
815
+ "apply": "reload",
816
+ "requiresReload": true
803
817
  }
804
818
  ]
805
819
  }