pi-ui-extend 0.1.5 → 0.1.8

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/dist/app/app.d.ts CHANGED
@@ -26,6 +26,7 @@ export declare class PiUiExtendApp {
26
26
  private readonly subagentsWidgetController;
27
27
  private readonly todoWidgetController;
28
28
  private readonly terminalController;
29
+ private readonly terminalBellSoundController;
29
30
  private readonly toastController;
30
31
  private readonly nerdFontController;
31
32
  private readonly popupActions;
@@ -86,6 +87,7 @@ export declare class PiUiExtendApp {
86
87
  private toolDefaultExpanded;
87
88
  private stopBlinking;
88
89
  private stop;
90
+ private toggleTerminalBellSound;
89
91
  private refreshModelUsageStatusFromClick;
90
92
  private showToast;
91
93
  private clearToastTimers;
package/dist/app/app.js CHANGED
@@ -37,6 +37,7 @@ import { AppTodoWidgetController } from "./todo-widget-controller.js";
37
37
  import { AppTabsController } from "./tabs-controller.js";
38
38
  import { TabLineRenderer } from "./tab-line-renderer.js";
39
39
  import { AppTerminalController } from "./terminal-controller.js";
40
+ import { TerminalBellSoundController } from "./terminal-bell-sound-controller.js";
40
41
  import { AppToastController } from "./toast-controller.js";
41
42
  import { checkPixUpdate, formatPixStartupUpdateDialog } from "./update.js";
42
43
  import { AppVoiceController } from "./voice-controller.js";
@@ -72,6 +73,7 @@ export class PiUiExtendApp {
72
73
  subagentsWidgetController;
73
74
  todoWidgetController;
74
75
  terminalController;
76
+ terminalBellSoundController;
75
77
  toastController;
76
78
  nerdFontController;
77
79
  popupActions;
@@ -174,6 +176,7 @@ export class PiUiExtendApp {
174
176
  });
175
177
  this.pixConfig = loadPixConfig();
176
178
  setAppIconTheme(this.pixConfig.iconTheme.name);
179
+ this.terminalBellSoundController = new TerminalBellSoundController();
177
180
  this.promptEnhancer = new AppPromptEnhancerController({
178
181
  runtime: () => this.runtime,
179
182
  inputEditor: () => this.inputEditor,
@@ -244,6 +247,8 @@ export class PiUiExtendApp {
244
247
  promptEnhancerStatusWidgetText: () => this.promptEnhancer.statusWidgetText(),
245
248
  promptEnhancerStatusWidgetActive: () => this.promptEnhancer.statusWidgetActive(),
246
249
  promptEnhancerStatusWidgetEnabled: () => this.promptEnhancer.statusWidgetEnabled(),
250
+ terminalBellSoundStatusWidgetText: () => this.terminalBellSoundController.statusWidgetText(),
251
+ terminalBellSoundStatusWidgetEnabled: () => this.terminalBellSoundController.isEnabled(),
247
252
  voiceStatusWidgetText: () => this.voiceController.statusWidgetText(),
248
253
  voiceStatusWidgetActive: () => this.voiceController.statusWidgetActive(),
249
254
  userMessageJumpMenuActive: () => this.popupMenus.directMenu === "user-message-jump",
@@ -502,6 +507,7 @@ export class PiUiExtendApp {
502
507
  this.superCompactTools = !this.superCompactTools;
503
508
  this.render();
504
509
  },
510
+ toggleTerminalBellSound: () => this.toggleTerminalBellSound(),
505
511
  handleExtensionInputMouse: (event) => this.extensionUiController.handleCustomUiMouse(event),
506
512
  render: () => this.render(),
507
513
  }, this.popupMenus, this.popupActions, this.scrollController, this.commandController);
@@ -657,6 +663,7 @@ export class PiUiExtendApp {
657
663
  this.mouseController.statusUserJumpTarget = undefined;
658
664
  this.mouseController.statusThinkingExpandTarget = undefined;
659
665
  this.mouseController.statusCompactToolsTarget = undefined;
666
+ this.mouseController.statusTerminalBellSoundTarget = undefined;
660
667
  this.mouseController.statusSessionTarget = undefined;
661
668
  this.mouseController.statusPromptEnhancerTarget = undefined;
662
669
  this.mouseController.statusVoiceMicTarget = undefined;
@@ -829,6 +836,17 @@ export class PiUiExtendApp {
829
836
  async stop() {
830
837
  await this.terminalController.stop();
831
838
  }
839
+ toggleTerminalBellSound() {
840
+ try {
841
+ const enabled = this.terminalBellSoundController.toggle();
842
+ this.showToast(enabled ? "Terminal bell notifications enabled" : "Terminal bell notifications muted", "info");
843
+ this.render();
844
+ }
845
+ catch (error) {
846
+ const message = error instanceof Error ? error.message : String(error);
847
+ this.showToast(`Could not update terminal bell notifications: ${message}`, "error");
848
+ }
849
+ }
832
850
  refreshModelUsageStatusFromClick() {
833
851
  const refresh = this.modelUsageController.refreshNow();
834
852
  if (refresh.kind === "unsupported") {
@@ -207,6 +207,7 @@ export class ExtensionUiController {
207
207
  setHeader: () => undefined,
208
208
  setTitle: (title) => {
209
209
  process.title = title;
210
+ renderIfRunning();
210
211
  },
211
212
  custom: (async (factory) => await this.showCustomUi(factory)),
212
213
  pasteToEditor: (text) => {
@@ -16,6 +16,8 @@ declare const NERD_FONT_ICONS: {
16
16
  readonly plus: "󰐕";
17
17
  readonly record: "󰑊";
18
18
  readonly refresh: "󰑐";
19
+ readonly volumeHigh: "󰕾";
20
+ readonly volumeOff: "󰖁";
19
21
  readonly user: "󰀄";
20
22
  readonly compactTools: "󰍜";
21
23
  readonly thinkingExpanded: "󰌵";
package/dist/app/icons.js CHANGED
@@ -22,6 +22,8 @@ const NERD_FONT_ICONS = {
22
22
  plus: "\u{f0415}",
23
23
  record: "\u{f044a}",
24
24
  refresh: "\u{f0450}",
25
+ volumeHigh: "\u{f057e}",
26
+ volumeOff: "\u{f0581}",
25
27
  user: "\u{f0004}",
26
28
  compactTools: "\u{f035c}",
27
29
  thinkingExpanded: "\u{f0335}",
@@ -45,6 +47,8 @@ const FALLBACK_ICONS = {
45
47
  plus: "+",
46
48
  record: "●",
47
49
  refresh: "↻",
50
+ volumeHigh: "♪",
51
+ volumeOff: "ø",
48
52
  user: "@",
49
53
  compactTools: "≡",
50
54
  thinkingExpanded: ">",
@@ -6,7 +6,7 @@ import type { ToastEntry, ToastVariant } from "../ui.js";
6
6
  import type { AppPopupActionController } from "./popup-action-controller.js";
7
7
  import type { AppPopupMenuController } from "./popup-menu-controller.js";
8
8
  import type { AppScrollController } from "./scroll-controller.js";
9
- import type { Entry, ImageClickTarget, MouseEvent, MouseSelection, StatusContextTarget, StatusCompactToolsTarget, StatusModelTarget, StatusModelUsageTarget, StatusPromptEnhancerTarget, StatusSessionTarget, TabLineMouseTarget, StatusThinkingExpandTarget, StatusThinkingTarget, StatusUserJumpTarget, StatusVoiceLanguageTarget, StatusVoiceMicTarget } from "./types.js";
9
+ import type { Entry, ImageClickTarget, MouseEvent, MouseSelection, StatusContextTarget, StatusCompactToolsTarget, StatusModelTarget, StatusModelUsageTarget, StatusPromptEnhancerTarget, StatusSessionTarget, StatusTerminalBellSoundTarget, TabLineMouseTarget, StatusThinkingExpandTarget, StatusThinkingTarget, StatusUserJumpTarget, StatusVoiceLanguageTarget, StatusVoiceMicTarget } from "./types.js";
10
10
  import { type RenderedLink } from "./file-links.js";
11
11
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
12
12
  type ClickFlash = {
@@ -58,6 +58,7 @@ export type AppMouseControllerHost = {
58
58
  refreshModelUsageStatus(): void | Promise<void>;
59
59
  toggleAllThinkingExpanded?(): void;
60
60
  toggleSuperCompactTools?(): void;
61
+ toggleTerminalBellSound?(): void;
61
62
  copyTextToClipboard?(text: string): void;
62
63
  handleExtensionInputMouse(event: MouseEvent & {
63
64
  localRow: number;
@@ -101,6 +102,7 @@ export declare class AppMouseController {
101
102
  statusUserJumpTarget: StatusUserJumpTarget | undefined;
102
103
  statusThinkingExpandTarget: StatusThinkingExpandTarget | undefined;
103
104
  statusCompactToolsTarget: StatusCompactToolsTarget | undefined;
105
+ statusTerminalBellSoundTarget: StatusTerminalBellSoundTarget | undefined;
104
106
  statusSessionTarget: StatusSessionTarget | undefined;
105
107
  statusPromptEnhancerTarget: StatusPromptEnhancerTarget | undefined;
106
108
  statusVoiceMicTarget: StatusVoiceMicTarget | undefined;
@@ -152,6 +154,7 @@ export declare class AppMouseController {
152
154
  private handleStatusUserJumpClick;
153
155
  private handleStatusThinkingExpandClick;
154
156
  private handleStatusCompactToolsClick;
157
+ private handleStatusTerminalBellSoundClick;
155
158
  private handleStatusSessionClick;
156
159
  private handleStatusPromptEnhancerClick;
157
160
  private handleStatusVoiceMicClick;
@@ -25,6 +25,7 @@ export class AppMouseController {
25
25
  statusUserJumpTarget;
26
26
  statusThinkingExpandTarget;
27
27
  statusCompactToolsTarget;
28
+ statusTerminalBellSoundTarget;
28
29
  statusSessionTarget;
29
30
  statusPromptEnhancerTarget;
30
31
  statusVoiceMicTarget;
@@ -78,6 +79,8 @@ export class AppMouseController {
78
79
  return;
79
80
  if (event.button === 0 && this.withClickFlash(event, () => this.handleStatusCompactToolsClick(event)))
80
81
  return;
82
+ if (event.button === 0 && this.withClickFlash(event, () => this.handleStatusTerminalBellSoundClick(event)))
83
+ return;
81
84
  if (event.button === 0 && this.withClickFlash(event, () => this.handleStatusSessionClick(event)))
82
85
  return;
83
86
  if (event.button === 0 && this.withClickFlash(event, () => this.handleStatusPromptEnhancerClick(event)))
@@ -522,6 +525,15 @@ export class AppMouseController {
522
525
  this.host.toggleSuperCompactTools?.();
523
526
  return true;
524
527
  }
528
+ handleStatusTerminalBellSoundClick(event) {
529
+ const target = this.statusTerminalBellSoundTarget;
530
+ if (!target)
531
+ return false;
532
+ if (event.y !== target.row || event.x < target.startColumn || event.x >= target.endColumn)
533
+ return false;
534
+ this.host.toggleTerminalBellSound?.();
535
+ return true;
536
+ }
525
537
  handleStatusSessionClick(event) {
526
538
  const target = this.statusSessionTarget;
527
539
  if (!target)
@@ -294,6 +294,7 @@ export class AppRenderController {
294
294
  this.deps.mouseController.statusUserJumpTarget = this.deps.statusLineRenderer.userJumpTarget?.(statusLayout, statusRow);
295
295
  this.deps.mouseController.statusThinkingExpandTarget = this.deps.statusLineRenderer.thinkingExpandTarget?.(statusLayout, statusRow);
296
296
  this.deps.mouseController.statusCompactToolsTarget = this.deps.statusLineRenderer.compactToolsTarget?.(statusLayout, statusRow);
297
+ this.deps.mouseController.statusTerminalBellSoundTarget = this.deps.statusLineRenderer.terminalBellSoundTarget?.(statusLayout, statusRow);
297
298
  this.deps.mouseController.statusSessionTarget = this.deps.statusLineRenderer.sessionTarget(statusLayout.text, statusRow, statusLayout.sessionLabel, statusLayout.workspaceLabel);
298
299
  this.deps.mouseController.statusPromptEnhancerTarget = this.deps.statusLineRenderer.promptEnhancerTarget(statusLayout, statusRow);
299
300
  this.deps.mouseController.statusVoiceMicTarget = this.deps.statusLineRenderer.voiceMicTarget(statusLayout, statusRow);
@@ -67,6 +67,9 @@ export class AppSessionLifecycleController {
67
67
  async bindCurrentSession() {
68
68
  const runtime = this.requireRuntime();
69
69
  this.unsubscribe?.();
70
+ this.unsubscribe = runtime.session.subscribe((event) => {
71
+ this.host.handleSessionEvent(event);
72
+ });
70
73
  this.host.closeSdkMenuForBind();
71
74
  this.host.clearExtensionWidgets();
72
75
  await runtime.session.bindExtensions({
@@ -75,9 +78,6 @@ export class AppSessionLifecycleController {
75
78
  shutdownHandler: this.host.extensionShutdownHandler(),
76
79
  onError: (error) => this.host.handleExtensionError(error),
77
80
  });
78
- this.unsubscribe = runtime.session.subscribe((event) => {
79
- this.host.handleSessionEvent(event);
80
- });
81
81
  }
82
82
  unsubscribeSession() {
83
83
  this.unsubscribe?.();
@@ -1,6 +1,6 @@
1
1
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
2
2
  import type { Theme } from "../theme.js";
3
- import type { SessionActivity, StatusCompactToolsTarget, StatusContextTarget, StatusLineLayout, StatusModelTarget, StatusModelUsageTarget, StatusPromptEnhancerTarget, StatusSessionTarget, StatusThinkingExpandTarget, StatusThinkingTarget, StatusUserJumpTarget, StatusVoiceLanguageTarget, StatusVoiceMicTarget } from "./types.js";
3
+ import type { SessionActivity, StatusCompactToolsTarget, StatusContextTarget, StatusLineLayout, StatusModelTarget, StatusModelUsageTarget, StatusPromptEnhancerTarget, StatusSessionTarget, StatusTerminalBellSoundTarget, StatusThinkingExpandTarget, StatusThinkingTarget, StatusUserJumpTarget, StatusVoiceLanguageTarget, StatusVoiceMicTarget } from "./types.js";
4
4
  import type { ScreenStyler } from "./screen-styler.js";
5
5
  import { type ModelColorsConfig } from "../config.js";
6
6
  export type StatusLineRendererHost = {
@@ -22,6 +22,8 @@ export type StatusLineRendererHost = {
22
22
  promptEnhancerStatusWidgetText(): string;
23
23
  promptEnhancerStatusWidgetActive(): boolean;
24
24
  promptEnhancerStatusWidgetEnabled(): boolean;
25
+ terminalBellSoundStatusWidgetText(): string;
26
+ terminalBellSoundStatusWidgetEnabled(): boolean;
25
27
  voiceStatusWidgetText(): string;
26
28
  voiceStatusWidgetActive(): boolean;
27
29
  userMessageJumpMenuActive?(): boolean;
@@ -43,12 +45,14 @@ export declare class StatusLineRenderer {
43
45
  userJumpTarget(layout: StatusLineLayout, row: number): StatusUserJumpTarget | undefined;
44
46
  thinkingExpandTarget(layout: StatusLineLayout, row: number): StatusThinkingExpandTarget | undefined;
45
47
  compactToolsTarget(layout: StatusLineLayout, row: number): StatusCompactToolsTarget | undefined;
48
+ terminalBellSoundTarget(layout: StatusLineLayout, row: number): StatusTerminalBellSoundTarget | undefined;
46
49
  sessionTarget(statusText: string, row: number, label: string, workspaceLabel: string): StatusSessionTarget | undefined;
47
50
  private segments;
48
51
  private pushPromptEnhancerWidgetSegment;
49
52
  private pushUserJumpWidgetSegment;
50
53
  private pushThinkingExpandWidgetSegment;
51
54
  private pushCompactToolsWidgetSegment;
55
+ private pushTerminalBellSoundWidgetSegment;
52
56
  private pushVoiceWidgetSegment;
53
57
  private pushWorkspaceSegments;
54
58
  private pushModelUsageSegments;
@@ -16,9 +16,10 @@ export class StatusLineRenderer {
16
16
  const userJumpButton = APP_ICONS.user;
17
17
  const thinkingExpandButton = APP_ICONS.thinkingExpanded;
18
18
  const compactToolsButton = APP_ICONS.compactTools;
19
+ const terminalBellSoundWidgetText = this.host.terminalBellSoundStatusWidgetText();
19
20
  const promptEnhancerWidgetText = this.host.promptEnhancerStatusWidgetText();
20
21
  const voiceWidgetText = this.host.voiceStatusWidgetText();
21
- const rightWidgetText = [userJumpButton, thinkingExpandButton, compactToolsButton, promptEnhancerWidgetText, voiceWidgetText].filter((text) => text.length > 0).join(" ");
22
+ const rightWidgetText = [userJumpButton, terminalBellSoundWidgetText, thinkingExpandButton, compactToolsButton, promptEnhancerWidgetText, voiceWidgetText].filter((text) => text.length > 0).join(" ");
22
23
  const rightWidgetWidth = stringDisplayWidth(rightWidgetText);
23
24
  const leftWidth = rightWidgetWidth > 0 && contentWidth > rightWidgetWidth + 1 ? contentWidth - rightWidgetWidth - 1 : contentWidth;
24
25
  const baseStatus = this.host.currentStatus();
@@ -38,6 +39,11 @@ export class StatusLineRenderer {
38
39
  : undefined;
39
40
  if (userJumpWidget)
40
41
  nextWidgetStartColumn = userJumpWidget.endColumn + 1;
42
+ const terminalBellSoundWidget = leftWidth < contentWidth && terminalBellSoundWidgetText.length > 0
43
+ ? this.widgetLayout(nextWidgetStartColumn, terminalBellSoundWidgetText)
44
+ : undefined;
45
+ if (terminalBellSoundWidget)
46
+ nextWidgetStartColumn = terminalBellSoundWidget.endColumn + 1;
41
47
  const thinkingExpandWidget = leftWidth < contentWidth
42
48
  ? this.widgetLayout(nextWidgetStartColumn, thinkingExpandButton)
43
49
  : undefined;
@@ -62,6 +68,7 @@ export class StatusLineRenderer {
62
68
  ...(userJumpWidget ? { userJumpWidget } : {}),
63
69
  ...(thinkingExpandWidget ? { thinkingExpandWidget } : {}),
64
70
  ...(compactToolsWidget ? { compactToolsWidget } : {}),
71
+ ...(terminalBellSoundWidget ? { terminalBellSoundWidget } : {}),
65
72
  ...(modelUsageLabel ? { modelUsageLabel } : {}),
66
73
  ...(contextBarLabel ? { contextBarLabel } : {}),
67
74
  ...(promptEnhancerWidget ? { promptEnhancerWidget } : {}),
@@ -162,6 +169,12 @@ export class StatusLineRenderer {
162
169
  return undefined;
163
170
  return { row, startColumn: widget.startColumn, endColumn: widget.endColumn };
164
171
  }
172
+ terminalBellSoundTarget(layout, row) {
173
+ const widget = layout.terminalBellSoundWidget;
174
+ if (!widget)
175
+ return undefined;
176
+ return { row, startColumn: widget.startColumn, endColumn: widget.endColumn };
177
+ }
165
178
  sessionTarget(statusText, row, label, workspaceLabel) {
166
179
  if (!this.host.session || !label)
167
180
  return undefined;
@@ -185,6 +198,7 @@ export class StatusLineRenderer {
185
198
  this.pushUserJumpWidgetSegment(segments, statusText);
186
199
  this.pushThinkingExpandWidgetSegment(segments, statusText);
187
200
  this.pushCompactToolsWidgetSegment(segments, statusText);
201
+ this.pushTerminalBellSoundWidgetSegment(segments, statusText);
188
202
  this.pushWorkspaceSegments(segments, statusText, layout.workspaceLabel);
189
203
  if (layout.modelUsageLabel)
190
204
  this.pushModelUsageSegments(segments, statusText, layout.modelUsageLabel);
@@ -248,6 +262,13 @@ export class StatusLineRenderer {
248
262
  return;
249
263
  this.pushSegment(segments, start, buttonText.length, this.host.superCompactToolsActive?.() ? this.host.theme.colors.info : this.host.theme.colors.muted);
250
264
  }
265
+ pushTerminalBellSoundWidgetSegment(segments, statusText) {
266
+ const widgetText = this.host.terminalBellSoundStatusWidgetText();
267
+ const start = statusText.lastIndexOf(widgetText);
268
+ if (start < 0 || widgetText.length <= 0)
269
+ return;
270
+ this.pushSegment(segments, start, widgetText.length, this.host.terminalBellSoundStatusWidgetEnabled() ? this.host.theme.colors.info : this.host.theme.colors.muted);
271
+ }
251
272
  pushVoiceWidgetSegment(segments, statusText) {
252
273
  const widgetText = this.host.voiceStatusWidgetText();
253
274
  const start = statusText.lastIndexOf(widgetText);
@@ -261,7 +282,7 @@ export class StatusLineRenderer {
261
282
  }
262
283
  const separatorIndex = widgetText.indexOf(" ");
263
284
  const micLength = separatorIndex >= 0 ? separatorIndex : widgetText.length;
264
- this.pushSegment(segments, start, micLength, this.host.theme.colors.info);
285
+ this.pushSegment(segments, start, micLength, this.host.theme.colors.muted);
265
286
  }
266
287
  pushWorkspaceSegments(segments, statusText, workspaceLabel) {
267
288
  const start = statusText.lastIndexOf(workspaceLabel);
@@ -0,0 +1,11 @@
1
+ export declare function getPiToolsSuiteUserConfigPath(homeDir?: string): string;
2
+ export declare function readTerminalBellSoundEnabled(configPath?: string): boolean;
3
+ export declare function writeTerminalBellSoundEnabled(enabled: boolean, configPath?: string): void;
4
+ export declare class TerminalBellSoundController {
5
+ private readonly configPath;
6
+ private enabled;
7
+ constructor(configPath?: string);
8
+ statusWidgetText(): string;
9
+ isEnabled(): boolean;
10
+ toggle(): boolean;
11
+ }
@@ -0,0 +1,58 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { applyEdits, modify, parse as parseJsonc } from "jsonc-parser";
5
+ import { APP_ICONS } from "./icons.js";
6
+ const TERMINAL_BELL_CONFIG_KEY = "terminalBell";
7
+ const SOUND_CONFIG_KEY = "sound";
8
+ export function getPiToolsSuiteUserConfigPath(homeDir = homedir()) {
9
+ return join(homeDir, ".config", "pi", "pi-tools-suite.jsonc");
10
+ }
11
+ function isRecord(value) {
12
+ return value !== null && typeof value === "object" && !Array.isArray(value);
13
+ }
14
+ export function readTerminalBellSoundEnabled(configPath = getPiToolsSuiteUserConfigPath()) {
15
+ if (!existsSync(configPath))
16
+ return true;
17
+ try {
18
+ const parsed = parseJsonc(readFileSync(configPath, "utf-8"));
19
+ if (!isRecord(parsed))
20
+ return true;
21
+ const terminalBell = parsed[TERMINAL_BELL_CONFIG_KEY];
22
+ if (!isRecord(terminalBell))
23
+ return true;
24
+ return typeof terminalBell[SOUND_CONFIG_KEY] === "boolean" ? terminalBell[SOUND_CONFIG_KEY] : true;
25
+ }
26
+ catch {
27
+ return true;
28
+ }
29
+ }
30
+ export function writeTerminalBellSoundEnabled(enabled, configPath = getPiToolsSuiteUserConfigPath()) {
31
+ const original = existsSync(configPath) ? readFileSync(configPath, "utf-8") : "{}\n";
32
+ const edits = modify(original, [TERMINAL_BELL_CONFIG_KEY, SOUND_CONFIG_KEY], enabled, {
33
+ formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" },
34
+ });
35
+ const updated = applyEdits(original, edits);
36
+ mkdirSync(dirname(configPath), { recursive: true });
37
+ writeFileSync(configPath, updated.endsWith("\n") ? updated : `${updated}\n`, "utf-8");
38
+ }
39
+ export class TerminalBellSoundController {
40
+ configPath;
41
+ enabled;
42
+ constructor(configPath = getPiToolsSuiteUserConfigPath()) {
43
+ this.configPath = configPath;
44
+ this.enabled = readTerminalBellSoundEnabled(this.configPath);
45
+ }
46
+ statusWidgetText() {
47
+ return this.enabled ? APP_ICONS.volumeHigh : APP_ICONS.volumeOff;
48
+ }
49
+ isEnabled() {
50
+ return this.enabled;
51
+ }
52
+ toggle() {
53
+ const nextEnabled = !this.enabled;
54
+ writeTerminalBellSoundEnabled(nextEnabled, this.configPath);
55
+ this.enabled = nextEnabled;
56
+ return this.enabled;
57
+ }
58
+ }
@@ -266,6 +266,7 @@ export type StatusLineLayout = {
266
266
  userJumpWidget?: StatusUserJumpWidgetLayout;
267
267
  thinkingExpandWidget?: StatusThinkingExpandWidgetLayout;
268
268
  compactToolsWidget?: StatusCompactToolsWidgetLayout;
269
+ terminalBellSoundWidget?: StatusTerminalBellSoundWidgetLayout;
269
270
  promptEnhancerWidget?: StatusPromptEnhancerWidgetLayout;
270
271
  voiceWidget?: StatusVoiceWidgetLayout;
271
272
  };
@@ -281,10 +282,19 @@ export type StatusCompactToolsWidgetLayout = {
281
282
  startColumn: number;
282
283
  endColumn: number;
283
284
  };
285
+ export type StatusTerminalBellSoundWidgetLayout = {
286
+ startColumn: number;
287
+ endColumn: number;
288
+ };
284
289
  export type StatusPromptEnhancerWidgetLayout = {
285
290
  startColumn: number;
286
291
  endColumn: number;
287
292
  };
293
+ export type StatusTerminalBellSoundTarget = {
294
+ row: number;
295
+ startColumn: number;
296
+ endColumn: number;
297
+ };
288
298
  export type StatusVoiceWidgetLayout = {
289
299
  startColumn: number;
290
300
  micEndColumn: number;
@@ -1,7 +1,9 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { spawn } from "node:child_process";
3
- import { existsSync } from "node:fs";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
4
5
  import { delimiter, isAbsolute, join } from "node:path";
6
+ import { parse as parseJsonc } from "jsonc-parser";
5
7
 
6
8
  const BELL = "\x07";
7
9
  const DEFAULT_IDLE_DELAY_MS = 250;
@@ -13,6 +15,8 @@ const DEFAULT_NOTIFICATION_TITLE = "Pi";
13
15
  const DEFAULT_NOTIFICATION_MESSAGE = "Session stopped";
14
16
  const DEFAULT_ASK_USER_NOTIFICATION_MESSAGE = "Waiting for your answer";
15
17
  const DEFAULT_MAC_SOUND = "Glass";
18
+ const TERMINAL_BELL_CONFIG_KEY = "terminalBell";
19
+ const SOUND_CONFIG_KEY = "sound";
16
20
 
17
21
  const TERM_PROGRAM_BUNDLE_IDS: Record<string, string> = {
18
22
  Apple_Terminal: "com.apple.Terminal",
@@ -50,29 +54,55 @@ function extensionDisabled(): boolean {
50
54
  return isTruthyEnv(process.env.HEADLESS) || isTruthyEnv(process.env.PI_TERMINAL_BELL_DISABLED);
51
55
  }
52
56
 
53
- function canRingTerminal(): boolean {
54
- if (process.env.PI_TERMINAL_BELL === "0") return false;
55
- if (process.env.PI_TERMINAL_BELL_FORCE === "1") return true;
56
- return Boolean(process.stdout.isTTY || process.stderr.isTTY);
57
+ function getPiToolsSuiteUserConfigPath(homeDir = homedir()): string {
58
+ return join(homeDir, ".config", "pi", "pi-tools-suite.jsonc");
57
59
  }
58
60
 
59
- function writeBell(): void {
60
- const stream = process.stdout.isTTY || !process.stderr.isTTY ? process.stdout : process.stderr;
61
- stream.write(BELL);
61
+ function isRecord(value: unknown): value is Record<string, unknown> {
62
+ return value !== null && typeof value === "object" && !Array.isArray(value);
63
+ }
64
+
65
+ export function readTerminalBellSoundConfig(configPath = getPiToolsSuiteUserConfigPath()): boolean | undefined {
66
+ if (!existsSync(configPath)) return undefined;
67
+ try {
68
+ const parsed = parseJsonc(readFileSync(configPath, "utf-8")) as unknown;
69
+ if (!isRecord(parsed)) return undefined;
70
+ const terminalBell = parsed[TERMINAL_BELL_CONFIG_KEY];
71
+ if (!isRecord(terminalBell)) return undefined;
72
+ const sound = terminalBell[SOUND_CONFIG_KEY];
73
+ return typeof sound === "boolean" ? sound : undefined;
74
+ } catch {
75
+ return undefined;
76
+ }
62
77
  }
63
78
 
64
- function soundEnabled(ctx: ExtensionContext): boolean {
79
+ export function terminalBellSoundEnabled(ctx: Pick<ExtensionContext, "hasUI">, configPath = getPiToolsSuiteUserConfigPath()): boolean {
65
80
  if (process.env.PI_TERMINAL_BELL_SOUND === "0") return false;
66
81
  if (process.env.PI_TERMINAL_BELL_SOUND === "1") return true;
82
+ const configured = readTerminalBellSoundConfig(configPath);
83
+ if (configured !== undefined) return configured;
67
84
  return ctx.hasUI === true;
68
85
  }
69
86
 
70
- function notificationsEnabled(ctx: ExtensionContext): boolean {
87
+ export function terminalBellNotificationsEnabled(ctx: Pick<ExtensionContext, "hasUI">, configPath = getPiToolsSuiteUserConfigPath()): boolean {
88
+ if (!terminalBellSoundEnabled(ctx, configPath)) return false;
71
89
  if (process.env.PI_TERMINAL_BELL_NOTIFY === "0") return false;
72
90
  if (process.env.PI_TERMINAL_BELL_NOTIFY === "1") return true;
73
91
  return ctx.hasUI === true;
74
92
  }
75
93
 
94
+ export function canRingTerminal(ctx: Pick<ExtensionContext, "hasUI">, configPath = getPiToolsSuiteUserConfigPath()): boolean {
95
+ if (!terminalBellSoundEnabled(ctx, configPath)) return false;
96
+ if (process.env.PI_TERMINAL_BELL === "0") return false;
97
+ if (process.env.PI_TERMINAL_BELL_FORCE === "1") return true;
98
+ return Boolean(process.stdout.isTTY || process.stderr.isTTY);
99
+ }
100
+
101
+ function writeBell(): void {
102
+ const stream = process.stdout.isTTY || !process.stderr.isTTY ? process.stdout : process.stderr;
103
+ stream.write(BELL);
104
+ }
105
+
76
106
  function appleScriptString(value: string): string {
77
107
  return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
78
108
  }
@@ -124,7 +154,7 @@ function detectMacActivationBundleId(): string | undefined {
124
154
  }
125
155
 
126
156
  function playAttentionSound(ctx: ExtensionContext): void {
127
- if (!soundEnabled(ctx)) return;
157
+ if (!terminalBellSoundEnabled(ctx)) return;
128
158
  if (process.platform !== "darwin") return;
129
159
  const sound = process.env.PI_TERMINAL_BELL_SOUND && process.env.PI_TERMINAL_BELL_SOUND !== "1"
130
160
  ? process.env.PI_TERMINAL_BELL_SOUND
@@ -139,7 +169,7 @@ function notifySessionStopped(
139
169
  macActivationBundleId: string | undefined,
140
170
  message = process.env.PI_TERMINAL_BELL_NOTIFY_MESSAGE ?? DEFAULT_NOTIFICATION_MESSAGE,
141
171
  ): void {
142
- if (!notificationsEnabled(ctx)) return;
172
+ if (!terminalBellNotificationsEnabled(ctx)) return;
143
173
  const title = process.env.PI_TERMINAL_BELL_NOTIFY_TITLE ?? DEFAULT_NOTIFICATION_TITLE;
144
174
 
145
175
  if (process.platform === "darwin") {
@@ -221,7 +251,7 @@ export default function terminalBell(pi: ExtensionAPI) {
221
251
  }
222
252
 
223
253
  function notifyAttention(ctx: ExtensionContext, message?: string): void {
224
- if (canRingTerminal()) writeBell();
254
+ if (canRingTerminal(ctx)) writeBell();
225
255
  playAttentionSound(ctx);
226
256
  notifySessionStopped(ctx, macActivationBundleId, message);
227
257
  pi.events.emit(TERMINAL_BELL_ATTENTION_EVENT, {
@@ -2,11 +2,10 @@
2
2
 
3
3
  Local all-in-one Pi extension package.
4
4
 
5
- This package keeps the former standalone extensions as ordinary source folders under `src/` and registers them through one entrypoint.
5
+ This package keeps shared Pi tools as ordinary source folders under `src/` and registers them through one entrypoint.
6
6
 
7
7
  - `src/ast-grep` — `ast_grep` / `ast_apply`
8
8
  - `src/async-subagents` — `subagents` tool and sub-agent slash commands, including oh-my-openagent-style `/ultrawork` (`/ulw`) and `/hyperplan` orchestration prompts, plus config-defined sub-agent model/thinking/args presets selected via `/subagent-preset` from `asyncSubagents` in `~/.config/pi/pi-tools-suite.jsonc`; includes the `frontend` profile for Gemini-friendly UI/UX and visual frontend work plus the `vision` profile for screenshot/image description via `openai-codex/gpt-5.4-mini`; enforces a 30-minute per-agent execution timeout, project-wide `maxConcurrent` queueing, optional retry/backoff, and `result.json` structured metadata/chaining fields next to raw `result.md`; stores project-local run files and a registry under `.pi/subagents/` so result/status collection can recover after compaction or reload while the main session remains alive
9
- - `src/terminal-bell` — terminal bell, macOS attention sound, and best-effort OS notification when the main agent session returns to idle; defers the alert while sub-agents are still running or the main agent is waiting for sub-agent results
10
9
  - `src/lsp` — shared LSP diagnostics hook/library that enriches mutating tool results with diagnostics and shuts down language servers on session shutdown
11
10
  - `src/repo-discovery` — `/idx-init`, `/idx-update`, and indexed-only `repo_architecture` / `repo_structure` / `repo_ast` / `repo_search` / `repo_explain` / `repo_deps`; tools register only when the launch project has `.indexer-cli`
12
11
  - `src/antigravity-auth` — `antigravity` custom provider with Google Antigravity OAuth login, startup account list, `/antigravity-import` credential migration from opencode, `/antigravity-add-account` OAuth append into rotation, `/antigravity-account` status display, account rotation/failover, Antigravity plus Gemini CLI model registration, and streaming through the Cloud Code Assist unified gateway
@@ -19,7 +18,7 @@ This package keeps the former standalone extensions as ordinary source folders u
19
18
 
20
19
  `index.ts` is intentionally only a thin auto-discovery shim that re-exports `src/index.ts`. There is no `pi.extensions` manifest here, so local Pi auto-discovery loads the suite once via `~/.pi/agent/extensions/pi-tools-suite/index.ts` and does not double-register tools.
21
20
 
22
- Registration order is preserved in `src/index.ts`: ast-grep, async-subagents, terminal-bell, lsp, repo-discovery command/tool gate, antigravity-auth provider, todo, model-tools, usage, web-search, dcp, then prompt-commands. Tool metadata and active model-specific tool sets have two modes: standard and repo-aware. When `.indexer-cli` enables `repo_*`, those tools stay active ahead of overlapping lower-level aliases so the indexed discovery surface has priority.
21
+ Registration order is preserved in `src/index.ts`: ast-grep, async-subagents, lsp, repo-discovery command/tool gate, antigravity-auth provider, todo, model-tools, usage, web-search, dcp, then prompt-commands. Tool metadata and active model-specific tool sets have two modes: standard and repo-aware. When `.indexer-cli` enables `repo_*`, those tools stay active ahead of overlapping lower-level aliases so the indexed discovery surface has priority.
23
22
 
24
23
  ## Disabling modules
25
24
 
@@ -27,14 +26,14 @@ Disable suite modules without editing `src/index.ts` via config or environment v
27
26
 
28
27
  ```jsonc
29
28
  {
30
- "disabledModules": ["terminal-bell", "web-search"]
29
+ "disabledModules": ["web-search"]
31
30
  }
32
31
  ```
33
32
 
34
33
  Environment overrides are applied last:
35
34
 
36
35
  ```bash
37
- PI_TOOLS_SUITE_DISABLED_MODULES=terminal-bell,web-search pi ...
36
+ PI_TOOLS_SUITE_DISABLED_MODULES=web-search pi ...
38
37
  PI_TOOLS_SUITE_DISABLED=1 pi ... # disables all pi-tools-suite modules
39
38
  ```
40
39
 
@@ -152,50 +151,6 @@ Troubleshooting:
152
151
 
153
152
  Do not send secrets, tokens, private repository text, or credential-bearing URLs through these tools; Ollama may query external web services to satisfy the request.
154
153
 
155
- ## Terminal bell / idle alert
156
-
157
- `src/terminal-bell` alerts the user when the main Pi agent returns to idle. It does not alert while sub-agents are still running or while the main agent is waiting for sub-agent results.
158
-
159
- Disable it entirely for headless runs:
160
-
161
- ```bash
162
- HEADLESS=1 pi ...
163
- # or
164
- PI_TERMINAL_BELL_DISABLED=1 pi ...
165
- ```
166
-
167
- Common environment options:
168
-
169
- | Variable | Effect |
170
- | --- | --- |
171
- | `PI_TERMINAL_BELL=0` | Disable terminal `\x07` bell only |
172
- | `PI_TERMINAL_BELL_FORCE=1` | Emit terminal bell even without TTY |
173
- | `PI_TERMINAL_BELL_DELAY_MS=250` | Delay before alerting after idle |
174
- | `PI_TERMINAL_BELL_SOUND=0` | Disable macOS `afplay` attention sound |
175
- | `PI_TERMINAL_BELL_SOUND=Glass` | macOS sound name or absolute `.aiff` path |
176
- | `PI_TERMINAL_BELL_NOTIFY=0` | Disable OS notification only |
177
- | `PI_TERMINAL_BELL_NOTIFY=1` | Force OS notification even outside UI mode |
178
- | `PI_TERMINAL_BELL_NOTIFY_TITLE=Pi` | Notification title |
179
- | `PI_TERMINAL_BELL_NOTIFY_MESSAGE="Session stopped"` | Notification body |
180
-
181
- macOS clickable notifications require `terminal-notifier`:
182
-
183
- ```bash
184
- brew install terminal-notifier
185
- ```
186
-
187
- At extension startup, the module resolves the app to activate on click from `PI_TERMINAL_BELL_NOTIFY_ACTIVATE`, `__CFBundleIdentifier`, or `TERM_PROGRAM` (Zed, iTerm2, Terminal, WezTerm, Warp, Ghostty, Kitty, Alacritty, VS Code). The resolved bundle id is passed to `terminal-notifier -activate` and to an explicit `open -b <bundleId>` click action.
188
-
189
- macOS-specific notification options:
190
-
191
- | Variable | Effect |
192
- | --- | --- |
193
- | `PI_TERMINAL_BELL_NOTIFY_ACTIVATE=dev.zed.Zed` | Override click activation bundle id |
194
- | `PI_TERMINAL_BELL_NOTIFY_ACTIVATE=0` | Disable click activation |
195
- | `PI_TERMINAL_BELL_NOTIFIER=/path/to/terminal-notifier` | Use a custom notifier binary |
196
- | `PI_TERMINAL_BELL_NOTIFY_SENDER=1` | Also pass `-sender <bundleId>` (can break click handling on some macOS versions) |
197
- | `PI_TERMINAL_BELL_NOTIFY_OSASCRIPT=1` | Use the `osascript` fallback when `terminal-notifier` is missing; clicking these notifications can open Script Editor |
198
-
199
154
  ## Layout
200
155
 
201
156
  ```text
@@ -206,7 +161,6 @@ pi-tools-suite/
206
161
  index.ts
207
162
  ast-grep/
208
163
  async-subagents/
209
- terminal-bell/
210
164
  lsp/
211
165
  repo-discovery/
212
166
  antigravity-auth/
@@ -673,7 +673,6 @@ function subagentEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
673
673
  PI_TOOLS_SUITE_DISABLED_MODULES: appendEnvList(env.PI_TOOLS_SUITE_DISABLED_MODULES, [
674
674
  "async-subagents",
675
675
  "question",
676
- "terminal-bell",
677
676
  ]),
678
677
  };
679
678
  }