@pi-unipi/notify 2.15.0 → 2.16.0

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
@@ -61,6 +61,22 @@ Desktop notifications via [node-notifier](https://github.com/mikaelbr/node-notif
61
61
 
62
62
  Zero configuration — works out of the box. Set `native.suppressWhenFocused` to `true` to skip native notifications when the active/focused window is already Pi.
63
63
 
64
+ ### Silence after input
65
+
66
+ After a terminal keypress, listed platforms stay quiet for `windowMs`. Default: **off**, native only, 10s. Edit in `/unipi:notify-settings` → Platforms (Quiet after activity + channel chips), or in `~/.unipi/config/notify/config.json`:
67
+
68
+ ```json
69
+ {
70
+ "silenceAfterInput": {
71
+ "enabled": true,
72
+ "windowMs": 10000,
73
+ "platforms": ["native"]
74
+ }
75
+ }
76
+ ```
77
+
78
+ Add `gotify`, `telegram`, or `ntfy` to `platforms` to silence those channels too. Empty `platforms` silences all enabled platforms (same as `events.*.platforms`).
79
+
64
80
  ### Gotify
65
81
 
66
82
  Self-hosted push notification server:
package/activity.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @pi-unipi/notify — Recent terminal-input activity
3
+ *
4
+ * Tracks the last interactive keypress so dispatch can silence selected
5
+ * platforms while the user is at the keyboard.
6
+ */
7
+
8
+ import type {
9
+ NotifyConfig,
10
+ NotifyPlatform,
11
+ SilenceAfterInputConfig,
12
+ } from "./types.js";
13
+
14
+ /** Known platform names — unknown strings are dropped when merging config. */
15
+ const VALID_PLATFORMS: ReadonlySet<NotifyPlatform> = new Set([
16
+ "native",
17
+ "gotify",
18
+ "telegram",
19
+ "ntfy",
20
+ ]);
21
+
22
+ let lastInputAt = 0;
23
+
24
+ /** Record a terminal keypress. `at` is injectable for tests. */
25
+ export function noteInput(at: number = Date.now()): void {
26
+ lastInputAt = at;
27
+ }
28
+
29
+ /** Clear activity (session start/shutdown). */
30
+ export function resetInputActivity(): void {
31
+ lastInputAt = 0;
32
+ }
33
+
34
+ /**
35
+ * Split already-enabled platforms into "send" vs "silenced by recent input".
36
+ * Does not look at platform `enabled` flags — caller filters those first.
37
+ * Empty `platforms` (while enabled) silences all incoming channels, matching
38
+ * `events.*.platforms: []` → all enabled.
39
+ */
40
+ export function filterPlatformsAfterInput(
41
+ platforms: NotifyPlatform[],
42
+ config: Pick<NotifyConfig, "silenceAfterInput">,
43
+ now: number = Date.now(),
44
+ ): { send: NotifyPlatform[]; silenced: NotifyPlatform[] } {
45
+ const cfg = config.silenceAfterInput;
46
+ if (!shouldSilence(cfg, now)) {
47
+ return { send: platforms.slice(), silenced: [] };
48
+ }
49
+ if (cfg.platforms.length === 0) {
50
+ return { send: [], silenced: platforms.slice() };
51
+ }
52
+ const silent = new Set(cfg.platforms);
53
+ const send: NotifyPlatform[] = [];
54
+ const silenced: NotifyPlatform[] = [];
55
+ for (const platform of platforms) {
56
+ if (silent.has(platform)) silenced.push(platform);
57
+ else send.push(platform);
58
+ }
59
+ return { send, silenced };
60
+ }
61
+
62
+ /** Normalize a partial config blob against defaults. */
63
+ export function mergeSilenceAfterInput(
64
+ loaded: Partial<SilenceAfterInputConfig> | undefined,
65
+ defaults: SilenceAfterInputConfig,
66
+ ): SilenceAfterInputConfig {
67
+ if (!loaded) {
68
+ return {
69
+ enabled: defaults.enabled,
70
+ windowMs: defaults.windowMs,
71
+ platforms: defaults.platforms.slice(),
72
+ };
73
+ }
74
+ const platforms = Array.isArray(loaded.platforms)
75
+ ? loaded.platforms.filter((p): p is NotifyPlatform =>
76
+ VALID_PLATFORMS.has(p as NotifyPlatform),
77
+ )
78
+ : defaults.platforms.slice();
79
+ const windowMs =
80
+ typeof loaded.windowMs === "number" &&
81
+ Number.isFinite(loaded.windowMs) &&
82
+ loaded.windowMs >= 0
83
+ ? loaded.windowMs
84
+ : defaults.windowMs;
85
+ return {
86
+ enabled: loaded.enabled ?? defaults.enabled,
87
+ windowMs,
88
+ platforms,
89
+ };
90
+ }
91
+
92
+ function shouldSilence(
93
+ cfg: SilenceAfterInputConfig | undefined,
94
+ now: number,
95
+ ): cfg is SilenceAfterInputConfig {
96
+ if (!cfg?.enabled) return false;
97
+ if (lastInputAt <= 0) return false;
98
+ if (now - lastInputAt >= cfg.windowMs) return false;
99
+ return true;
100
+ }
package/events.ts CHANGED
@@ -16,6 +16,7 @@ import { sendNtfyNotification } from "./platforms/ntfy.js";
16
16
  import { buildAskUserPromptMessage } from "./ask-user-prompt-message.js";
17
17
  import { buildPermissionPromptMessage } from "./permission-prompt-message.js";
18
18
  import { summarizeLastMessage } from "./summarize.js";
19
+ import { filterPlatformsAfterInput } from "./activity.js";
19
20
 
20
21
  // Event emitted by @juicesharp/rpiv-ask-user-question before showing its UI.
21
22
  // Keep this as a local string until that package publishes an importable
@@ -182,8 +183,11 @@ export async function dispatchNotification(
182
183
  return false;
183
184
  });
184
185
 
186
+ const { send: platformsToSend, silenced: inputSilenced } =
187
+ filterPlatformsAfterInput(enabledPlatforms, config);
188
+
185
189
  const results = await Promise.all(
186
- enabledPlatforms.map(async (platform) => {
190
+ platformsToSend.map(async (platform) => {
187
191
  try {
188
192
  const effectivePriority = await sendToPlatform(platform, title, message, config, cwd, priority);
189
193
  return { platform, success: true, ...(effectivePriority === undefined ? {} : { priority: effectivePriority }) };
@@ -202,6 +206,10 @@ export async function dispatchNotification(
202
206
  })
203
207
  );
204
208
 
209
+ for (const platform of inputSilenced) {
210
+ results.push({ platform, success: true, suppressed: true });
211
+ }
212
+
205
213
  const unsuppressed = results.filter((r) => !r.suppressed);
206
214
  const allSuccess = results.length > 0 && unsuppressed.every((r) => r.success);
207
215
  const suppressedPlatforms = results
package/index.ts CHANGED
@@ -24,10 +24,14 @@ import {
24
24
  setSessionContext,
25
25
  clearSessionContext,
26
26
  } from "./events.js";
27
+ import { noteInput, resetInputActivity } from "./activity.js";
27
28
 
28
29
  /** Package version */
29
30
  const VERSION = getPackageVersion(dirname(fileURLToPath(import.meta.url)));
30
31
 
32
+ /** Unsubscribe for the interactive-mode keypress listener. */
33
+ let unsubTerminalInput: (() => void) | undefined;
34
+
31
35
  export default function (pi: ExtensionAPI) {
32
36
 
33
37
  // Register tools and commands
@@ -37,6 +41,19 @@ export default function (pi: ExtensionAPI) {
37
41
  // Session lifecycle — register events and announce module
38
42
  pi.on("session_start", async (_event, ctx) => {
39
43
  setSessionContext(ctx);
44
+ resetInputActivity();
45
+ unsubTerminalInput?.();
46
+ unsubTerminalInput = undefined;
47
+ const onTerminalInput = ctx.ui?.onTerminalInput;
48
+ if (typeof onTerminalInput === "function") {
49
+ try {
50
+ unsubTerminalInput = onTerminalInput(() => {
51
+ noteInput();
52
+ });
53
+ } catch {
54
+ unsubTerminalInput = undefined;
55
+ }
56
+ }
40
57
  const cwd = process.cwd();
41
58
  const config = loadConfig();
42
59
  registerEventListeners(pi, config, cwd);
@@ -51,6 +68,9 @@ export default function (pi: ExtensionAPI) {
51
68
 
52
69
  // Cleanup on session shutdown
53
70
  pi.on("session_shutdown", async () => {
71
+ unsubTerminalInput?.();
72
+ unsubTerminalInput = undefined;
73
+ resetInputActivity();
54
74
  clearSessionContext();
55
75
  unregisterEventListeners();
56
76
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/notify",
3
- "version": "2.15.0",
3
+ "version": "2.16.0",
4
4
  "description": "Cross-platform notification extension for Pi — native OS, Gotify, and Telegram notifications for agent lifecycle events",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pi-unipi/core": "2.15.0",
37
+ "@pi-unipi/core": "2.16.0",
38
38
  "node-notifier": "^10.0.1"
39
39
  },
40
40
  "peerDependencies": {
package/settings.ts CHANGED
@@ -8,6 +8,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
8
8
  import { dirname, join } from "path";
9
9
  import { homedir } from "os";
10
10
  import { NOTIFY_DIRS } from "@pi-unipi/core";
11
+ import { mergeSilenceAfterInput } from "./activity.js";
11
12
  import type { NotifyConfig } from "./types.js";
12
13
 
13
14
  /** Resolve config path (expands ~ to homedir) */
@@ -45,6 +46,11 @@ export const DEFAULT_CONFIG: NotifyConfig = {
45
46
  enabled: false,
46
47
  model: "openrouter/openai/gpt-oss-20b",
47
48
  },
49
+ silenceAfterInput: {
50
+ enabled: false,
51
+ windowMs: 10000,
52
+ platforms: ["native"],
53
+ },
48
54
  };
49
55
 
50
56
  /** Load config from disk, returning defaults if missing or invalid */
@@ -60,7 +66,10 @@ export function loadConfig(): NotifyConfig {
60
66
  } catch (_err) {
61
67
  // Config load failure — using defaults silently.
62
68
  }
63
- return { ...DEFAULT_CONFIG };
69
+ // Deep copy: callers (e.g. the settings overlay) mutate the returned config.
70
+ // A shallow copy would share nested objects with DEFAULT_CONFIG and leak
71
+ // mutations into later loadConfig() calls (even after Esc/cancel).
72
+ return structuredClone(DEFAULT_CONFIG);
64
73
  }
65
74
 
66
75
  /** Save config to disk, creating directory if needed */
@@ -110,12 +119,17 @@ export function validateConfig(config: NotifyConfig): string[] {
110
119
 
111
120
  /** Merge loaded config with defaults to ensure all fields exist */
112
121
  function mergeWithDefaults(loaded: Partial<NotifyConfig>): NotifyConfig {
122
+ const base = structuredClone(DEFAULT_CONFIG);
113
123
  return {
114
- defaultPlatforms: loaded.defaultPlatforms ?? DEFAULT_CONFIG.defaultPlatforms,
115
- events: { ...DEFAULT_CONFIG.events, ...loaded.events },
116
- native: { ...DEFAULT_CONFIG.native, ...loaded.native },
117
- gotify: { ...DEFAULT_CONFIG.gotify, ...loaded.gotify },
118
- telegram: { ...DEFAULT_CONFIG.telegram, ...loaded.telegram },
119
- recap: { ...DEFAULT_CONFIG.recap, ...loaded.recap },
124
+ defaultPlatforms: loaded.defaultPlatforms ?? base.defaultPlatforms,
125
+ events: { ...base.events, ...loaded.events },
126
+ native: { ...base.native, ...loaded.native },
127
+ gotify: { ...base.gotify, ...loaded.gotify },
128
+ telegram: { ...base.telegram, ...loaded.telegram },
129
+ recap: { ...base.recap, ...loaded.recap },
130
+ silenceAfterInput: mergeSilenceAfterInput(
131
+ loaded.silenceAfterInput,
132
+ base.silenceAfterInput,
133
+ ),
120
134
  };
121
135
  }
@@ -62,6 +62,11 @@ Help users configure the `@pi-unipi/notify` notification system.
62
62
  "token": null,
63
63
  "priority": 3
64
64
  },
65
+ "silenceAfterInput": {
66
+ "enabled": false,
67
+ "windowMs": 10000,
68
+ "platforms": ["native"]
69
+ },
65
70
  "NOTE": "ntfy section is legacy — migrated to ntfy.json on first run"
66
71
  }
67
72
  ```
@@ -72,6 +77,24 @@ Help users configure the `@pi-unipi/notify` notification system.
72
77
 
73
78
  Desktop notifications via node-notifier. Works out of the box on Windows, macOS, Linux.
74
79
 
80
+ ### Silence after input
81
+
82
+ Quiet listed platforms for `windowMs` after any terminal keypress. **Default: off.** Same `config.json` as other notify settings.
83
+
84
+ ```json
85
+ "silenceAfterInput": {
86
+ "enabled": true,
87
+ "windowMs": 10000,
88
+ "platforms": ["native"]
89
+ }
90
+ ```
91
+
92
+ - `enabled` — master switch
93
+ - `windowMs` — quiet window in milliseconds (default: 10000)
94
+ - `platforms` — channels to silence (`native`, `gotify`, `telegram`, `ntfy`). Empty list silences all enabled platforms (same as `events.*.platforms`).
95
+
96
+ TUI: `/unipi:notify-settings` → Platforms → Quiet after activity (Space), ←→ then Space for channels, +/− for the window (1s steps).
97
+
75
98
  ### Gotify (default: disabled)
76
99
 
77
100
  Self-hosted push notification server. Requires:
@@ -14,12 +14,28 @@ import {
14
14
  validateConfig,
15
15
  } from "../settings.js";
16
16
  import { loadNtfyConfig, saveNtfyConfig, getNtfyConfigScope } from "../ntfy-config.js";
17
- import type { NotifyConfig, NtfyConfig } from "../types.js";
17
+ import type { NotifyConfig, NotifyPlatform, NtfyConfig } from "../types.js";
18
18
  import { OverlayTheme, boxInnerWidth } from "@pi-unipi/core";
19
19
 
20
20
  /** Section types */
21
21
  type Section = "platforms" | "events" | "recap";
22
22
 
23
+ const PLATFORM_KEYS: NotifyPlatform[] = ["native", "gotify", "telegram", "ntfy"];
24
+ const CHIP_LABELS: Record<NotifyPlatform, string> = {
25
+ native: "Native",
26
+ gotify: "Gotify",
27
+ telegram: "Telegram",
28
+ ntfy: "ntfy",
29
+ };
30
+
31
+ const SUPPRESS_FOCUSED_INDEX = 4;
32
+ const SILENCE_MASTER_INDEX = 5;
33
+ const SILENCE_CHIPS_INDEX = 6;
34
+
35
+ const WINDOW_STEP_MS = 1_000;
36
+ const WINDOW_MIN_MS = 1_000;
37
+ const WINDOW_MAX_MS = 120_000;
38
+
23
39
  /**
24
40
  * Settings overlay component.
25
41
  */
@@ -29,6 +45,8 @@ export class NotifySettingsOverlay implements Component {
29
45
  private ntfyScope: "project" | "global" | "none";
30
46
  private section: Section = "platforms";
31
47
  private selectedIndex = 0;
48
+ /** Which silence-after-input chip is focused (0–3). */
49
+ private chipIndex = 0;
32
50
  private error: string | null = null;
33
51
  private saved = false;
34
52
  onClose?: () => void;
@@ -70,6 +88,26 @@ export class NotifySettingsOverlay implements Component {
70
88
  this.selectedIndex = Math.min(this.maxItems - 1, this.selectedIndex + 1);
71
89
  return;
72
90
  }
91
+ if (this.section === "platforms" && this.selectedIndex === SILENCE_CHIPS_INDEX) {
92
+ if (matchesKey(data, "left") || data === "h") {
93
+ this.chipIndex = Math.max(0, this.chipIndex - 1);
94
+ return;
95
+ }
96
+ if (matchesKey(data, "right") || data === "l") {
97
+ this.chipIndex = Math.min(PLATFORM_KEYS.length - 1, this.chipIndex + 1);
98
+ return;
99
+ }
100
+ }
101
+ if (this.section === "platforms" && this.selectedIndex === SILENCE_MASTER_INDEX) {
102
+ if (data === "+" || data === "=") {
103
+ this.nudgeWindow(WINDOW_STEP_MS);
104
+ return;
105
+ }
106
+ if (data === "-" || data === "_") {
107
+ this.nudgeWindow(-WINDOW_STEP_MS);
108
+ return;
109
+ }
110
+ }
73
111
  if (matchesKey(data, "space")) {
74
112
  this.toggleCurrent();
75
113
  return;
@@ -99,30 +137,57 @@ export class NotifySettingsOverlay implements Component {
99
137
  }
100
138
 
101
139
  private get maxItems(): number {
102
- if (this.section === "platforms") return 5; // native, gotify, telegram, ntfy + suppress option
140
+ if (this.section === "platforms") return 7; // 4 platforms + focused + silence master + chips
103
141
  if (this.section === "recap") return 1; // toggle
104
142
  return Object.keys(this.config.events).length;
105
143
  }
106
144
 
145
+ private nudgeWindow(delta: number): void {
146
+ const current = this.config.silenceAfterInput.windowMs;
147
+ this.config.silenceAfterInput.windowMs = Math.min(
148
+ WINDOW_MAX_MS,
149
+ Math.max(WINDOW_MIN_MS, current + delta),
150
+ );
151
+ }
152
+
153
+ private chipOn(key: NotifyPlatform): boolean {
154
+ const listed = this.config.silenceAfterInput.platforms;
155
+ if (listed.length === 0) return true;
156
+ return listed.includes(key);
157
+ }
158
+
159
+ private toggleSilenceChip(key: NotifyPlatform): void {
160
+ const listed = this.config.silenceAfterInput.platforms;
161
+ const effective = listed.length === 0 ? PLATFORM_KEYS.slice() : listed.slice();
162
+ const idx = effective.indexOf(key);
163
+ if (idx >= 0) {
164
+ if (effective.length === 1) return;
165
+ effective.splice(idx, 1);
166
+ } else {
167
+ effective.push(key);
168
+ }
169
+ const ordered = PLATFORM_KEYS.filter((p) => effective.includes(p));
170
+ this.config.silenceAfterInput.platforms =
171
+ ordered.length === PLATFORM_KEYS.length ? [] : ordered;
172
+ }
173
+
107
174
  private toggleCurrent(): void {
108
175
  if (this.section === "platforms") {
109
- const platforms: Array<"native" | "gotify" | "telegram" | "ntfy"> = [
110
- "native",
111
- "gotify",
112
- "telegram",
113
- "ntfy",
114
- ];
115
- if (this.selectedIndex < platforms.length) {
116
- const key = platforms[this.selectedIndex];
176
+ if (this.selectedIndex < PLATFORM_KEYS.length) {
177
+ const key = PLATFORM_KEYS[this.selectedIndex];
117
178
  if (key === "ntfy") {
118
179
  // ntfy toggle updates the resolved ntfy config
119
180
  this.ntfyConfig.enabled = !this.ntfyConfig.enabled;
120
181
  } else if (key) {
121
182
  this.config[key].enabled = !this.config[key].enabled;
122
183
  }
123
- } else {
124
- // suppressWhenFocused toggle (index 4)
184
+ } else if (this.selectedIndex === SUPPRESS_FOCUSED_INDEX) {
125
185
  this.config.native.suppressWhenFocused = !this.config.native.suppressWhenFocused;
186
+ } else if (this.selectedIndex === SILENCE_MASTER_INDEX) {
187
+ this.config.silenceAfterInput.enabled = !this.config.silenceAfterInput.enabled;
188
+ } else if (this.selectedIndex === SILENCE_CHIPS_INDEX) {
189
+ const key = PLATFORM_KEYS[this.chipIndex];
190
+ if (key) this.toggleSilenceChip(key);
126
191
  }
127
192
  } else if (this.section === "recap") {
128
193
  this.config.recap.enabled = !this.config.recap.enabled;
@@ -196,18 +261,35 @@ export class NotifySettingsOverlay implements Component {
196
261
 
197
262
  // Footer
198
263
  lines.push(this.overlay.ruleLine(innerWidth));
199
- const footerHint = this.section === "recap"
200
- ? "↑↓ navigate · Space toggle · M change model · Tab switch · Enter save · Esc cancel"
201
- : "↑↓ navigate · Space toggle · Tab switch · Enter save · Esc cancel";
202
- lines.push(this.overlay.frameLine(this.overlay.fg("dim", footerHint), innerWidth));
264
+ lines.push(this.overlay.frameLine(this.overlay.fg("dim", this.footerHint()), innerWidth));
203
265
  lines.push(this.overlay.borderLine(innerWidth, "bottom"));
204
266
 
205
267
  return lines;
206
268
  }
207
269
 
270
+ private footerHint(): string {
271
+ if (this.section === "recap") {
272
+ return "↑↓ navigate · Space toggle · M change model · Tab switch · Enter save · Esc cancel";
273
+ }
274
+ if (this.section === "platforms" && this.selectedIndex === SILENCE_MASTER_INDEX) {
275
+ return "↑↓ navigate · Space toggle · +/− window · Tab switch · Enter save · Esc cancel";
276
+ }
277
+ if (this.section === "platforms" && this.selectedIndex === SILENCE_CHIPS_INDEX) {
278
+ return "↑↓ navigate · ←→ channel · Space toggle · Tab switch · Enter save · Esc cancel";
279
+ }
280
+ return "↑↓ navigate · Space toggle · Tab switch · Enter save · Esc cancel";
281
+ }
282
+
283
+ private silenceSummary(): string {
284
+ const seconds = Math.round(this.config.silenceAfterInput.windowMs / 1000);
285
+ const listed = this.config.silenceAfterInput.platforms;
286
+ const scope = listed.length === 0 ? "all enabled" : listed.join(", ");
287
+ return `${seconds}s · ${scope}`;
288
+ }
289
+
208
290
  private renderPlatforms(lines: string[], innerWidth: number): void {
209
291
  const platforms: Array<{
210
- key: "native" | "gotify" | "telegram" | "ntfy";
292
+ key: NotifyPlatform;
211
293
  label: string;
212
294
  detail: string;
213
295
  }> = [
@@ -259,8 +341,7 @@ export class NotifySettingsOverlay implements Component {
259
341
 
260
342
  // suppressWhenFocused toggle (index 4)
261
343
  {
262
- const i = platforms.length;
263
- const isSelected = i === this.selectedIndex;
344
+ const isSelected = this.selectedIndex === SUPPRESS_FOCUSED_INDEX;
264
345
  const isEnabled = this.config.native.suppressWhenFocused === true;
265
346
  const toggleOn = this.overlay.fg("success", "●");
266
347
  const toggleOff = this.overlay.fg("dim", "○");
@@ -277,6 +358,47 @@ export class NotifySettingsOverlay implements Component {
277
358
  )
278
359
  );
279
360
  }
361
+
362
+ this.renderSilenceAfterInput(lines, innerWidth);
363
+ }
364
+
365
+ private renderSilenceAfterInput(lines: string[], innerWidth: number): void {
366
+ const masterOn = this.config.silenceAfterInput.enabled;
367
+ const masterSelected = this.selectedIndex === SILENCE_MASTER_INDEX;
368
+ const toggle = masterOn
369
+ ? this.overlay.fg("success", "●")
370
+ : this.overlay.fg("dim", "○");
371
+ const label = masterSelected
372
+ ? this.overlay.bold("Quiet after activity")
373
+ : this.overlay.fg("dim", "Quiet after activity");
374
+ const detail = this.overlay.fg("dim", this.silenceSummary());
375
+ lines.push(
376
+ this.overlay.frameLine(
377
+ `${masterSelected ? this.overlay.fg("accent", "▸") : " "} ${toggle} ${label} ${detail}`,
378
+ innerWidth,
379
+ ),
380
+ );
381
+
382
+ const chipsSelected = this.selectedIndex === SILENCE_CHIPS_INDEX;
383
+ const chips = PLATFORM_KEYS.map((key, i) => {
384
+ const on = this.chipOn(key);
385
+ const mark = on ? "●" : "○";
386
+ const text = `${mark} ${CHIP_LABELS[key]}`;
387
+ const focused = chipsSelected && i === this.chipIndex;
388
+ if (focused) {
389
+ return this.overlay.fg("accent", this.overlay.bold(`[${text}]`));
390
+ }
391
+ const painted = on
392
+ ? `${this.overlay.fg("success", mark)} ${CHIP_LABELS[key]}`
393
+ : this.overlay.fg("dim", text);
394
+ return masterOn ? painted : this.overlay.fg("dim", text);
395
+ });
396
+ lines.push(
397
+ this.overlay.frameLine(
398
+ `${chipsSelected ? this.overlay.fg("accent", "▸") : " "} ${chips.join(" ")}`,
399
+ innerWidth,
400
+ ),
401
+ );
280
402
  }
281
403
 
282
404
  private renderEvents(lines: string[], innerWidth: number): void {
package/types.ts CHANGED
@@ -71,6 +71,16 @@ export interface RecapConfig {
71
71
  model: string;
72
72
  }
73
73
 
74
+ /** Quiet listed platforms after recent terminal input */
75
+ export interface SilenceAfterInputConfig {
76
+ /** Master switch */
77
+ enabled: boolean;
78
+ /** Quiet window after the last keypress, in milliseconds */
79
+ windowMs: number;
80
+ /** Platforms to suppress (empty = all enabled platforms) */
81
+ platforms: NotifyPlatform[];
82
+ }
83
+
74
84
  /** Full notification configuration */
75
85
  export interface NotifyConfig {
76
86
  /** Global default platforms for all events */
@@ -85,6 +95,8 @@ export interface NotifyConfig {
85
95
  telegram: TelegramConfig;
86
96
  /** Recap summarization settings */
87
97
  recap: RecapConfig;
98
+ /** Suppress listed platforms after recent terminal input */
99
+ silenceAfterInput: SilenceAfterInputConfig;
88
100
  }
89
101
 
90
102
  /** Parameters for the notify_user agent tool */