@pi-unipi/notify 2.15.0 → 2.16.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 +32 -0
- package/activity.ts +100 -0
- package/events.ts +12 -2
- package/index.ts +20 -0
- package/package.json +2 -2
- package/settings.ts +22 -7
- package/skills/configure-notify/SKILL.md +23 -0
- package/summarize.ts +23 -1
- package/tui/settings-overlay.ts +141 -19
- package/types.ts +21 -0
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:
|
|
@@ -104,6 +120,22 @@ Settings stored at `~/.unipi/config/notify/config.json`. Edit via `/unipi:notify
|
|
|
104
120
|
|
|
105
121
|
Per-event platform routing lets you control where each event type goes. The settings overlay shows all events with platform toggles.
|
|
106
122
|
|
|
123
|
+
### Recap (thinking models)
|
|
124
|
+
|
|
125
|
+
Recap summarizes the last assistant message into a one-line push notification (100-token budget). Thinking models served by llama.cpp or vLLM can spend that entire budget on reasoning and return nothing, falling back to a plain 100-character truncation. If your recap endpoint supports chat-template kwargs, set `recap.disableThinking` to skip reasoning tokens:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"recap": {
|
|
130
|
+
"enabled": true,
|
|
131
|
+
"model": "localhost/gemma-4-e4b",
|
|
132
|
+
"disableThinking": true
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
This sends `chat_template_kwargs: { enable_thinking: false, preserve_thinking: false }` with the request. Keep it `false` (the default) for strict OpenAI-compatible endpoints — they reject unknown params. Anthropic models are unaffected (thinking is opt-in there).
|
|
138
|
+
|
|
107
139
|
## License
|
|
108
140
|
|
|
109
141
|
MIT
|
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
|
-
|
|
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
|
|
@@ -344,7 +352,9 @@ function registerAgentNotification(
|
|
|
344
352
|
.then((apiKeyResult) => {
|
|
345
353
|
const apiKey = apiKeyResult.ok ? (apiKeyResult as { apiKey?: string }).apiKey : undefined;
|
|
346
354
|
if (apiKey) {
|
|
347
|
-
return summarizeLastMessage(lastText, apiKey, model.baseUrl, model.api, modelId
|
|
355
|
+
return summarizeLastMessage(lastText, apiKey, model.baseUrl, model.api, modelId, {
|
|
356
|
+
disableThinking: config.recap.disableThinking,
|
|
357
|
+
})
|
|
348
358
|
.then((recap) => sessionName ? `${sessionName}: ${recap}` : recap);
|
|
349
359
|
}
|
|
350
360
|
return buildAgentLifecycleMessage(eventKey, sessionName);
|
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.
|
|
3
|
+
"version": "2.16.1",
|
|
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.
|
|
37
|
+
"@pi-unipi/core": "2.16.1",
|
|
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) */
|
|
@@ -44,6 +45,12 @@ export const DEFAULT_CONFIG: NotifyConfig = {
|
|
|
44
45
|
recap: {
|
|
45
46
|
enabled: false,
|
|
46
47
|
model: "openrouter/openai/gpt-oss-20b",
|
|
48
|
+
disableThinking: false,
|
|
49
|
+
},
|
|
50
|
+
silenceAfterInput: {
|
|
51
|
+
enabled: false,
|
|
52
|
+
windowMs: 10000,
|
|
53
|
+
platforms: ["native"],
|
|
47
54
|
},
|
|
48
55
|
};
|
|
49
56
|
|
|
@@ -60,7 +67,10 @@ export function loadConfig(): NotifyConfig {
|
|
|
60
67
|
} catch (_err) {
|
|
61
68
|
// Config load failure — using defaults silently.
|
|
62
69
|
}
|
|
63
|
-
|
|
70
|
+
// Deep copy: callers (e.g. the settings overlay) mutate the returned config.
|
|
71
|
+
// A shallow copy would share nested objects with DEFAULT_CONFIG and leak
|
|
72
|
+
// mutations into later loadConfig() calls (even after Esc/cancel).
|
|
73
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
/** Save config to disk, creating directory if needed */
|
|
@@ -110,12 +120,17 @@ export function validateConfig(config: NotifyConfig): string[] {
|
|
|
110
120
|
|
|
111
121
|
/** Merge loaded config with defaults to ensure all fields exist */
|
|
112
122
|
function mergeWithDefaults(loaded: Partial<NotifyConfig>): NotifyConfig {
|
|
123
|
+
const base = structuredClone(DEFAULT_CONFIG);
|
|
113
124
|
return {
|
|
114
|
-
defaultPlatforms: loaded.defaultPlatforms ??
|
|
115
|
-
events: { ...
|
|
116
|
-
native: { ...
|
|
117
|
-
gotify: { ...
|
|
118
|
-
telegram: { ...
|
|
119
|
-
recap: { ...
|
|
125
|
+
defaultPlatforms: loaded.defaultPlatforms ?? base.defaultPlatforms,
|
|
126
|
+
events: { ...base.events, ...loaded.events },
|
|
127
|
+
native: { ...base.native, ...loaded.native },
|
|
128
|
+
gotify: { ...base.gotify, ...loaded.gotify },
|
|
129
|
+
telegram: { ...base.telegram, ...loaded.telegram },
|
|
130
|
+
recap: { ...base.recap, ...loaded.recap },
|
|
131
|
+
silenceAfterInput: mergeSilenceAfterInput(
|
|
132
|
+
loaded.silenceAfterInput,
|
|
133
|
+
base.silenceAfterInput,
|
|
134
|
+
),
|
|
120
135
|
};
|
|
121
136
|
}
|
|
@@ -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:
|
package/summarize.ts
CHANGED
|
@@ -12,6 +12,17 @@ const MAX_TOKENS = 100;
|
|
|
12
12
|
const TIMEOUT_MS = 10_000;
|
|
13
13
|
const FALLBACK_TRUNCATE_CHARS = 100;
|
|
14
14
|
|
|
15
|
+
/** Options for summarizeLastMessage */
|
|
16
|
+
export interface SummarizeOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Send `chat_template_kwargs: { enable_thinking: false, preserve_thinking: false }`
|
|
19
|
+
* on OpenAI-compatible requests (llama.cpp / vLLM chat templates) so thinking
|
|
20
|
+
* models don't burn the token budget on reasoning (issue #36). Anthropic
|
|
21
|
+
* ignores this — thinking is opt-in there already.
|
|
22
|
+
*/
|
|
23
|
+
disableThinking?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
/**
|
|
16
27
|
* Summarize a message using an LLM.
|
|
17
28
|
*
|
|
@@ -20,6 +31,7 @@ const FALLBACK_TRUNCATE_CHARS = 100;
|
|
|
20
31
|
* @param baseUrl - Provider base URL (from Model.baseUrl)
|
|
21
32
|
* @param api - API type (from Model.api, e.g. "openai-completions")
|
|
22
33
|
* @param modelId - Model ID to use
|
|
34
|
+
* @param opts - Optional summarization options
|
|
23
35
|
* @returns Summarized text, or truncated original on failure
|
|
24
36
|
*/
|
|
25
37
|
export async function summarizeLastMessage(
|
|
@@ -28,6 +40,7 @@ export async function summarizeLastMessage(
|
|
|
28
40
|
baseUrl: string,
|
|
29
41
|
api: string,
|
|
30
42
|
modelId: string,
|
|
43
|
+
opts?: SummarizeOptions,
|
|
31
44
|
): Promise<string> {
|
|
32
45
|
// Truncate input if too long
|
|
33
46
|
const input =
|
|
@@ -41,7 +54,7 @@ export async function summarizeLastMessage(
|
|
|
41
54
|
return await callAnthropic(baseUrl, apiKey, modelId, input);
|
|
42
55
|
}
|
|
43
56
|
// Default: OpenAI-compatible (covers openai-completions, openai-responses, etc.)
|
|
44
|
-
return await callOpenAICompatible(baseUrl, apiKey, modelId, input);
|
|
57
|
+
return await callOpenAICompatible(baseUrl, apiKey, modelId, input, opts);
|
|
45
58
|
} catch {
|
|
46
59
|
return fallbackSummary(messageText);
|
|
47
60
|
}
|
|
@@ -53,6 +66,7 @@ async function callOpenAICompatible(
|
|
|
53
66
|
apiKey: string,
|
|
54
67
|
modelId: string,
|
|
55
68
|
input: string,
|
|
69
|
+
opts?: SummarizeOptions,
|
|
56
70
|
): Promise<string> {
|
|
57
71
|
const url = `${baseUrl.replace(/\/$/, "")}/chat/completions`;
|
|
58
72
|
const controller = new AbortController();
|
|
@@ -68,6 +82,14 @@ async function callOpenAICompatible(
|
|
|
68
82
|
body: JSON.stringify({
|
|
69
83
|
model: modelId,
|
|
70
84
|
max_tokens: MAX_TOKENS,
|
|
85
|
+
...(opts?.disableThinking
|
|
86
|
+
? {
|
|
87
|
+
chat_template_kwargs: {
|
|
88
|
+
enable_thinking: false,
|
|
89
|
+
preserve_thinking: false,
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
: {}),
|
|
71
93
|
messages: [
|
|
72
94
|
{ role: "system", content: SYSTEM_PROMPT },
|
|
73
95
|
{ role: "user", content: input },
|
package/tui/settings-overlay.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
@@ -69,6 +69,25 @@ export interface RecapConfig {
|
|
|
69
69
|
enabled: boolean;
|
|
70
70
|
/** Model to use for recap (e.g. "openrouter/openai/gpt-oss-20b") */
|
|
71
71
|
model: string;
|
|
72
|
+
/**
|
|
73
|
+
* Send `chat_template_kwargs: { enable_thinking: false, preserve_thinking: false }`
|
|
74
|
+
* with recap requests so llama.cpp/vLLM-style servers skip reasoning tokens.
|
|
75
|
+
* Without this, a thinking model can burn the entire 100-token budget on
|
|
76
|
+
* reasoning and return no summary (issue #36). Only enable for endpoints
|
|
77
|
+
* that accept these params — strict OpenAI-compatible servers reject them.
|
|
78
|
+
* The Anthropic path ignores this (thinking is opt-in there already).
|
|
79
|
+
*/
|
|
80
|
+
disableThinking?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Quiet listed platforms after recent terminal input */
|
|
84
|
+
export interface SilenceAfterInputConfig {
|
|
85
|
+
/** Master switch */
|
|
86
|
+
enabled: boolean;
|
|
87
|
+
/** Quiet window after the last keypress, in milliseconds */
|
|
88
|
+
windowMs: number;
|
|
89
|
+
/** Platforms to suppress (empty = all enabled platforms) */
|
|
90
|
+
platforms: NotifyPlatform[];
|
|
72
91
|
}
|
|
73
92
|
|
|
74
93
|
/** Full notification configuration */
|
|
@@ -85,6 +104,8 @@ export interface NotifyConfig {
|
|
|
85
104
|
telegram: TelegramConfig;
|
|
86
105
|
/** Recap summarization settings */
|
|
87
106
|
recap: RecapConfig;
|
|
107
|
+
/** Suppress listed platforms after recent terminal input */
|
|
108
|
+
silenceAfterInput: SilenceAfterInputConfig;
|
|
88
109
|
}
|
|
89
110
|
|
|
90
111
|
/** Parameters for the notify_user agent tool */
|