@tokentop/ttop 0.6.0 → 0.6.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 +33 -11
- package/package.json +3 -3
- package/src/config/schema.ts +1 -1
- package/src/plugins/notification-bus.ts +27 -0
- package/src/plugins/notifications/terminal-bell.ts +80 -16
- package/src/plugins/notifications/visual-flash.ts +5 -18
- package/src/tui/App.tsx +6 -0
- package/src/tui/components/NotificationFlash.tsx +141 -0
- package/src/tui/components/SettingsModal.tsx +20 -4
- package/src/tui/components/Toast.tsx +1 -1
- package/src/tui/contexts/PluginContext.tsx +8 -0
- package/src/tui/hooks/useNotificationBridge.ts +29 -0
- package/src/tui/views/SettingsView.tsx +26 -8
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
> [!
|
|
2
|
-
> **tokentop is under active development and not yet ready for general use.** APIs and configuration may change without notice. If you're interested, star the repo and check back soon.
|
|
1
|
+
> **Early release** — tokentop is functional and actively developed. Please [report issues](https://github.com/tokentopapp/tokentop/issues)!
|
|
3
2
|
|
|
4
3
|
<div align="center">
|
|
5
4
|
|
|
@@ -18,7 +17,9 @@ Real-time terminal monitoring of LLM token usage and spending across providers a
|
|
|
18
17
|
[](https://bun.sh)
|
|
19
18
|
[](https://visitorbadge.io/status?path=https://github.com/tokentopapp/tokentop)
|
|
20
19
|
|
|
21
|
-
[Features](#features) · [Install](#installation) · [Quick Start](#quick-start) · [
|
|
20
|
+
[Features](#features) · [Install](#installation) · [Quick Start](#quick-start) · [Agents](#agents) · [Plugins](#plugin-system) · [Docs](https://tokentop.app/docs/getting-started/)
|
|
21
|
+
|
|
22
|
+
**[tokentop.app](https://tokentop.app)**
|
|
22
23
|
|
|
23
24
|
</div>
|
|
24
25
|
|
|
@@ -45,7 +46,7 @@ That's tokentop.
|
|
|
45
46
|
|
|
46
47
|
- **Real-time dashboard** — Live token counts, costs, burn rate, and activity sparklines
|
|
47
48
|
- **11 providers** — Anthropic, OpenAI, Google Gemini, GitHub Copilot, Codex, Perplexity, Antigravity, MiniMax, Zai, OpenCode Zen, Chutes
|
|
48
|
-
- **
|
|
49
|
+
- **7 coding agents** — Claude Code, OpenCode, Cursor, Copilot CLI, Gemini CLI, Antigravity, and Windsurf. See every session with model, tokens, cost, and duration
|
|
49
50
|
- **Budget guardrails** — Daily, weekly, and monthly limits with visual warnings at limit percentages you set
|
|
50
51
|
- **Smart sidebar** — Adaptive panel that breaks down spending by model, project, or agent
|
|
51
52
|
- **Efficiency insights** — Cache leverage, output verbosity, and cost-per-request analysis to help you spend less
|
|
@@ -53,11 +54,11 @@ That's tokentop.
|
|
|
53
54
|
- **Projects view** — See which codebase is costing you the most
|
|
54
55
|
- **Provider limits** — Visual gauges showing how close you are to rate limits
|
|
55
56
|
- **Live pricing** — Fetches current model pricing from [models.dev](https://models.dev) with local caching
|
|
56
|
-
- **15 built-in themes** —
|
|
57
|
+
- **[15 built-in themes](https://tokentop.app/docs/themes/built-in/)** — Tokyo Night, Dracula, Nord, Catppuccin, Gruvbox, Rosé Pine, Kanagawa, and more. Dark and light variants. Create your own with the [theme plugin API](https://tokentop.app/docs/themes/custom-themes/)
|
|
57
58
|
- **Plugin system** — Extend with custom providers, agents, themes, and notifications
|
|
58
59
|
- **Responsive layout** — Adapts to any terminal size; sidebar, KPI strip, header, and tables all reflow automatically from ultrawide to laptop-width
|
|
59
60
|
- **Demo mode** — Explore the UI with synthetic data, no API keys needed
|
|
60
|
-
- **Zero config** — Auto-discovers credentials from Claude Code, OpenCode, Cursor, Copilot CLI, environment variables, and CLI auth files
|
|
61
|
+
- **Zero config** — Auto-discovers credentials from Claude Code, OpenCode, Cursor, Copilot CLI, Gemini CLI, environment variables, and CLI auth files
|
|
61
62
|
|
|
62
63
|
## Installation
|
|
63
64
|
|
|
@@ -141,9 +142,21 @@ tokentop has 4 main views, switchable with `1`–`4`:
|
|
|
141
142
|
| `3` | **Trends** | ASCII step charts of cost over 7/30/90 days |
|
|
142
143
|
| `4` | **Projects** | Cost and token breakdown by local project/repo |
|
|
143
144
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
145
|
+
## Agents
|
|
146
|
+
|
|
147
|
+
tokentop tracks sessions from 7 coding agents. Each agent is a standalone plugin — built-in agents ship with the app, community agents install from npm.
|
|
148
|
+
|
|
149
|
+
| Agent | What it tracks | Plugin |
|
|
150
|
+
|-------|---------------|--------|
|
|
151
|
+
| [Claude Code](https://tokentop.app/docs/agents/claude-code/) | Sessions, OAuth tokens, multi-provider usage | [`@tokentop/agent-claude-code`](https://github.com/tokentopapp/agent-claude-code) |
|
|
152
|
+
| [OpenCode](https://tokentop.app/docs/agents/opencode/) | Sessions, OAuth credentials, multi-provider support | [`@tokentop/agent-opencode`](https://github.com/tokentopapp/agent-opencode) |
|
|
153
|
+
| [Cursor](https://tokentop.app/docs/agents/cursor/) | Sessions, CSV server log enrichment | [`@tokentop/agent-cursor`](https://github.com/tokentopapp/agent-cursor) |
|
|
154
|
+
| [Copilot CLI](https://tokentop.app/docs/agents/copilot-cli/) | Sessions, GitHub token auth | [`@tokentop/agent-copilot-cli`](https://github.com/tokentopapp/agent-copilot-cli) |
|
|
155
|
+
| [Gemini CLI](https://tokentop.app/docs/agents/gemini-cli/) | Sessions, Google OAuth | [`@tokentop/agent-gemini`](https://github.com/tokentopapp/agent-gemini) |
|
|
156
|
+
| [Antigravity](https://tokentop.app/docs/agents/antigravity/) | Sessions, Google OAuth | [`@tokentop/agent-gemini`](https://github.com/tokentopapp/agent-gemini) |
|
|
157
|
+
| [Windsurf](https://tokentop.app/docs/agents/windsurf/) | Sessions, Codeium auth | [`@tokentop/agent-windsurf`](https://github.com/tokentopapp/agent-windsurf) |
|
|
158
|
+
|
|
159
|
+
All agents are auto-discovered — if you have the tool installed, tokentop finds it. See the [agent docs](https://tokentop.app/docs/agents/) for details.
|
|
147
160
|
|
|
148
161
|
## Keyboard Shortcuts
|
|
149
162
|
|
|
@@ -219,7 +232,7 @@ tokentop is built on a plugin architecture with four extension points:
|
|
|
219
232
|
|
|
220
233
|
All plugins run in a **permission sandbox** — they must declare network, filesystem, and environment access upfront. Anyone can publish community plugins to npm — no org membership needed.
|
|
221
234
|
|
|
222
|
-
See the [Plugin Guide](docs/plugins
|
|
235
|
+
See the [Plugin Guide](https://tokentop.app/docs/plugins/overview/) for installation, configuration, and development details.
|
|
223
236
|
|
|
224
237
|
## How It Works
|
|
225
238
|
|
|
@@ -232,6 +245,15 @@ Costs are calculated from token counts and live pricing data from [models.dev](h
|
|
|
232
245
|
|
|
233
246
|
All data is stored in a **local SQLite database**. Nothing is sent anywhere. No telemetry, no analytics, no network calls except to the provider APIs you've already authenticated with.
|
|
234
247
|
|
|
248
|
+
## Alternatives
|
|
249
|
+
|
|
250
|
+
| Tool | Approach | Tokentop Difference |
|
|
251
|
+
|------|----------|---------------------|
|
|
252
|
+
| Provider dashboards | Web, hours behind | Real-time, in terminal |
|
|
253
|
+
| [tokentap](https://github.com/nicholasgasior/tokentap) | MitM proxy (abandoned) | Native API polling, no proxy |
|
|
254
|
+
| [toktop](https://github.com/jnsahaj/toktop) | Rust TUI, 2 providers | 11 providers, 7 agents, plugins |
|
|
255
|
+
| [sniffly](https://github.com/chiphuyen/sniffly) | Python dashboard | Terminal-native, multi-agent |
|
|
256
|
+
|
|
235
257
|
## Development
|
|
236
258
|
|
|
237
259
|
```bash
|
|
@@ -239,7 +261,7 @@ bun install # Install dependencies
|
|
|
239
261
|
bun run dev # Dev mode with hot reload
|
|
240
262
|
bun test # Run tests
|
|
241
263
|
bun run typecheck # TypeScript check
|
|
242
|
-
bun run lint #
|
|
264
|
+
bun run lint # Biome
|
|
243
265
|
```
|
|
244
266
|
|
|
245
267
|
### Demo mode for development
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokentop/ttop",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Real-time AI token usage monitor - htop for your API costs",
|
|
5
5
|
"author": "Nigel Bazzeghin <nbazzeghin@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
"analytics": "bun .opencode/skills/analytics/scripts/analytics.ts"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@opentui/core": "0.1.
|
|
38
|
-
"@opentui/react": "0.1.
|
|
37
|
+
"@opentui/core": "0.1.87",
|
|
38
|
+
"@opentui/react": "0.1.87",
|
|
39
39
|
"@tokentop/agent-claude-code": "^1.1.0",
|
|
40
40
|
"@tokentop/agent-copilot-cli": "^1.0.0",
|
|
41
41
|
"@tokentop/agent-cursor": "^1.2.0",
|
package/src/config/schema.ts
CHANGED
|
@@ -18,6 +18,16 @@ class NotificationBus {
|
|
|
18
18
|
private plugins: NotificationPlugin[] = [];
|
|
19
19
|
private recentEvents = new Map<string, DedupEntry>();
|
|
20
20
|
private pluginConfigs = new Map<string, Record<string, unknown>>();
|
|
21
|
+
private toastHandler: ((event: NotificationEvent) => void) | null = null;
|
|
22
|
+
private flashHandler: ((severity: NotificationEvent["severity"]) => void) | null = null;
|
|
23
|
+
|
|
24
|
+
setToastHandler(handler: ((event: NotificationEvent) => void) | null): void {
|
|
25
|
+
this.toastHandler = handler;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
setFlashHandler(handler: ((severity: NotificationEvent["severity"]) => void) | null): void {
|
|
29
|
+
this.flashHandler = handler;
|
|
30
|
+
}
|
|
21
31
|
|
|
22
32
|
registerPlugins(plugins: NotificationPlugin[]): void {
|
|
23
33
|
this.plugins = plugins;
|
|
@@ -124,6 +134,23 @@ class NotificationBus {
|
|
|
124
134
|
);
|
|
125
135
|
}),
|
|
126
136
|
);
|
|
137
|
+
|
|
138
|
+
this.toastHandler?.(event);
|
|
139
|
+
this.flashHandler?.(event.severity);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async testNotification(): Promise<void> {
|
|
143
|
+
const event: NotificationEvent = {
|
|
144
|
+
type: "budget.thresholdCrossed",
|
|
145
|
+
severity: "warning",
|
|
146
|
+
title: "Test Notification",
|
|
147
|
+
message: "This is a test notification from tokentop.",
|
|
148
|
+
timestamp: Date.now(),
|
|
149
|
+
data: {},
|
|
150
|
+
};
|
|
151
|
+
// Bypass dedup for test
|
|
152
|
+
await this.emit("__test__", event);
|
|
153
|
+
this.recentEvents.delete("__test__");
|
|
127
154
|
}
|
|
128
155
|
|
|
129
156
|
private isDuplicate(key: string): boolean {
|
|
@@ -4,6 +4,80 @@ import type {
|
|
|
4
4
|
NotificationPlugin,
|
|
5
5
|
} from "@tokentop/plugin-sdk";
|
|
6
6
|
|
|
7
|
+
const SEVERITY_ORDER = ["info", "warning", "critical"];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Write directly to stderr so the escape sequences bypass OpenTUI's
|
|
11
|
+
* stdout render pipeline. Most terminals still interpret BEL and OSC
|
|
12
|
+
* sequences arriving on fd 2.
|
|
13
|
+
*/
|
|
14
|
+
function writeEsc(data: string): void {
|
|
15
|
+
process.stderr.write(data);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* OSC 777 (notify;title;body) — supported by iTerm2, Kitty, WezTerm, rxvt-unicode.
|
|
20
|
+
* OSC 9 (notify;body) — supported by Windows Terminal, ConEmu.
|
|
21
|
+
* BEL (\x07) — universal fallback (if terminal has bell enabled).
|
|
22
|
+
*
|
|
23
|
+
* We send all three; unsupported sequences are silently ignored.
|
|
24
|
+
*/
|
|
25
|
+
function sendDesktopNotification(title: string, body: string): void {
|
|
26
|
+
writeEsc(`\x1b]777;notify;${title};${body}\x07`);
|
|
27
|
+
writeEsc(`\x1b]9;${title}: ${body}\x07`);
|
|
28
|
+
writeEsc("\x07");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* macOS: afplay with system sounds (Ping, Funk, Glass, etc.)
|
|
33
|
+
* Linux: paplay → aplay → canberra-gtk-play (first available)
|
|
34
|
+
*
|
|
35
|
+
* Fire-and-forget — errors are silently ignored so a missing
|
|
36
|
+
* sound binary never breaks notifications.
|
|
37
|
+
*/
|
|
38
|
+
const SEVERITY_SOUNDS: Record<string, string> = {
|
|
39
|
+
critical: "Funk.aiff",
|
|
40
|
+
warning: "Ping.aiff",
|
|
41
|
+
info: "Pop.aiff",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function playSystemSound(severity: string): void {
|
|
45
|
+
const platform = process.platform;
|
|
46
|
+
|
|
47
|
+
if (platform === "darwin") {
|
|
48
|
+
const sound = SEVERITY_SOUNDS[severity] ?? "Ping.aiff";
|
|
49
|
+
Bun.spawn(["afplay", `/System/Library/Sounds/${sound}`], {
|
|
50
|
+
stdout: "ignore",
|
|
51
|
+
stderr: "ignore",
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (platform === "linux") {
|
|
57
|
+
const xdgSound =
|
|
58
|
+
severity === "critical"
|
|
59
|
+
? "dialog-error"
|
|
60
|
+
: severity === "warning"
|
|
61
|
+
? "dialog-warning"
|
|
62
|
+
: "dialog-information";
|
|
63
|
+
|
|
64
|
+
Bun.spawn(["canberra-gtk-play", "-i", xdgSound], {
|
|
65
|
+
stdout: "ignore",
|
|
66
|
+
stderr: "ignore",
|
|
67
|
+
}).exited.then((code) => {
|
|
68
|
+
if (code !== 0) {
|
|
69
|
+
Bun.spawn(["paplay", "/usr/share/sounds/freedesktop/stereo/bell.oga"], {
|
|
70
|
+
stdout: "ignore",
|
|
71
|
+
stderr: "ignore",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Windows / other — BEL only (already sent via sendDesktopNotification)
|
|
79
|
+
}
|
|
80
|
+
|
|
7
81
|
export const terminalBellPlugin: NotificationPlugin = {
|
|
8
82
|
apiVersion: 2,
|
|
9
83
|
id: "terminal-bell",
|
|
@@ -12,7 +86,7 @@ export const terminalBellPlugin: NotificationPlugin = {
|
|
|
12
86
|
version: "1.0.0",
|
|
13
87
|
|
|
14
88
|
meta: {
|
|
15
|
-
description: "
|
|
89
|
+
description: "Desktop notification + system sound alerts",
|
|
16
90
|
},
|
|
17
91
|
|
|
18
92
|
permissions: {},
|
|
@@ -50,30 +124,20 @@ export const terminalBellPlugin: NotificationPlugin = {
|
|
|
50
124
|
},
|
|
51
125
|
|
|
52
126
|
async notify(ctx: NotificationContext, event: NotificationEvent): Promise<void> {
|
|
53
|
-
const severityOrder = ["info", "warning", "critical"];
|
|
54
127
|
const minSeverity = (ctx.config.minSeverity as string) ?? "warning";
|
|
55
128
|
|
|
56
|
-
if (
|
|
129
|
+
if (SEVERITY_ORDER.indexOf(event.severity) < SEVERITY_ORDER.indexOf(minSeverity)) {
|
|
57
130
|
return;
|
|
58
131
|
}
|
|
59
132
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
for (let i = 0; i < bellCount; i++) {
|
|
63
|
-
process.stdout.write("\x07");
|
|
64
|
-
if (i < bellCount - 1) {
|
|
65
|
-
await sleep(200);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
133
|
+
sendDesktopNotification(event.title, event.message);
|
|
134
|
+
playSystemSound(event.severity);
|
|
68
135
|
},
|
|
69
136
|
|
|
70
137
|
async test(ctx: NotificationContext): Promise<boolean> {
|
|
71
138
|
ctx.logger.info("Testing terminal bell...");
|
|
72
|
-
|
|
139
|
+
sendDesktopNotification("tokentop", "Test notification");
|
|
140
|
+
playSystemSound("warning");
|
|
73
141
|
return true;
|
|
74
142
|
},
|
|
75
143
|
};
|
|
76
|
-
|
|
77
|
-
function sleep(ms: number): Promise<void> {
|
|
78
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
79
|
-
}
|
|
@@ -12,7 +12,7 @@ export const visualFlashPlugin: NotificationPlugin = {
|
|
|
12
12
|
version: "1.0.0",
|
|
13
13
|
|
|
14
14
|
meta: {
|
|
15
|
-
description: "Visual screen flash
|
|
15
|
+
description: "Visual screen flash for alerts (rendered via TUI overlay)",
|
|
16
16
|
},
|
|
17
17
|
|
|
18
18
|
permissions: {},
|
|
@@ -27,7 +27,7 @@ export const visualFlashPlugin: NotificationPlugin = {
|
|
|
27
27
|
duration: {
|
|
28
28
|
type: "number",
|
|
29
29
|
label: "Flash duration (ms)",
|
|
30
|
-
default:
|
|
30
|
+
default: 150,
|
|
31
31
|
description: "Flash duration in milliseconds",
|
|
32
32
|
},
|
|
33
33
|
},
|
|
@@ -44,26 +44,13 @@ export const visualFlashPlugin: NotificationPlugin = {
|
|
|
44
44
|
ctx.logger.debug("Visual flash notification plugin initialized");
|
|
45
45
|
},
|
|
46
46
|
|
|
47
|
-
async notify(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const colorCode =
|
|
51
|
-
event.severity === "critical" ? "41" : event.severity === "warning" ? "43" : "42";
|
|
52
|
-
|
|
53
|
-
process.stdout.write(`\x1b[${colorCode}m`);
|
|
54
|
-
await sleep(duration);
|
|
55
|
-
process.stdout.write("\x1b[0m");
|
|
47
|
+
async notify(_ctx: NotificationContext, _event: NotificationEvent): Promise<void> {
|
|
48
|
+
// Flash is handled by the NotificationFlash React component via
|
|
49
|
+
// notificationBus.flashHandler — raw ANSI escapes conflict with OpenTUI.
|
|
56
50
|
},
|
|
57
51
|
|
|
58
52
|
async test(ctx: NotificationContext): Promise<boolean> {
|
|
59
53
|
ctx.logger.info("Testing visual flash...");
|
|
60
|
-
process.stdout.write("\x1b[44m");
|
|
61
|
-
await sleep(100);
|
|
62
|
-
process.stdout.write("\x1b[0m");
|
|
63
54
|
return true;
|
|
64
55
|
},
|
|
65
56
|
};
|
|
66
|
-
|
|
67
|
-
function sleep(ms: number): Promise<void> {
|
|
68
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
69
|
-
}
|
package/src/tui/App.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import { copyToClipboard } from "@/utils/clipboard.ts";
|
|
|
16
16
|
import { type CommandAction, CommandPalette } from "./components/CommandPalette.tsx";
|
|
17
17
|
import { DebugPanel } from "./components/DebugPanel.tsx";
|
|
18
18
|
import { Header } from "./components/Header.tsx";
|
|
19
|
+
import { NotificationFlash } from "./components/NotificationFlash.tsx";
|
|
19
20
|
import { SessionDetailsDrawer } from "./components/SessionDetailsDrawer.tsx";
|
|
20
21
|
import { SettingsModal } from "./components/SettingsModal.tsx";
|
|
21
22
|
import { StatusBar } from "./components/StatusBar.tsx";
|
|
@@ -35,6 +36,7 @@ import { RealTimeActivityProvider } from "./contexts/RealTimeActivityContext.tsx
|
|
|
35
36
|
import { StorageProvider } from "./contexts/StorageContext.tsx";
|
|
36
37
|
import { TimeWindowProvider } from "./contexts/TimeWindowContext.tsx";
|
|
37
38
|
import { ToastProvider, useToastContext } from "./contexts/ToastContext.tsx";
|
|
39
|
+
import { useNotificationBridge } from "./hooks/useNotificationBridge.ts";
|
|
38
40
|
import { useSafeRenderer } from "./hooks/useSafeRenderer.ts";
|
|
39
41
|
import { triggerShutdown } from "./shutdown.ts";
|
|
40
42
|
import { Dashboard } from "./views/Dashboard.tsx";
|
|
@@ -68,6 +70,8 @@ function AppContent() {
|
|
|
68
70
|
const { debugDataRef, activity, sparkData } = useDashboardRuntime();
|
|
69
71
|
const burstRecorderRef = { current: null as BurstRecorder | null };
|
|
70
72
|
|
|
73
|
+
useNotificationBridge();
|
|
74
|
+
|
|
71
75
|
const refreshInterval = config.refresh.pauseAutoRefresh ? 0 : config.refresh.intervalMs;
|
|
72
76
|
|
|
73
77
|
const [lastRefresh, setLastRefresh] = useState<number | null>(null);
|
|
@@ -363,6 +367,8 @@ function AppContent() {
|
|
|
363
367
|
{isDrawerOpen && selectedSession && (
|
|
364
368
|
<SessionDetailsDrawer session={selectedSession} onClose={hideDrawer} />
|
|
365
369
|
)}
|
|
370
|
+
|
|
371
|
+
<NotificationFlash />
|
|
366
372
|
</box>
|
|
367
373
|
);
|
|
368
374
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { NotificationEvent } from "@tokentop/plugin-sdk";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { notificationBus } from "@/plugins/notification-bus.ts";
|
|
4
|
+
import { useColors } from "../contexts/ThemeContext.tsx";
|
|
5
|
+
import { animationTick } from "../hooks/useAnimationTick.ts";
|
|
6
|
+
import { Z_INDEX } from "./ModalBackdrop.tsx";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Notification flash as an animated border glow.
|
|
10
|
+
*
|
|
11
|
+
* On notification:
|
|
12
|
+
* 0–15% Overbright burst → alert color (double border)
|
|
13
|
+
* 15–40% Alert color fading (double border)
|
|
14
|
+
* 40–100% Continuing fade → background (single border, then unmount)
|
|
15
|
+
*
|
|
16
|
+
* Uses the global 30 Hz animation tick for smooth interpolation.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const GLOW_DURATION_MS = 500;
|
|
20
|
+
|
|
21
|
+
/** Bright, saturated alert colors — vivid regardless of theme */
|
|
22
|
+
const GLOW_COLORS: Record<NotificationEvent["severity"], string> = {
|
|
23
|
+
critical: "#ff3333",
|
|
24
|
+
warning: "#ffaa22",
|
|
25
|
+
info: "#3399ff",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ── Color math ──────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function parseHex(hex: string): [number, number, number] {
|
|
31
|
+
const h = hex.replace("#", "");
|
|
32
|
+
return [
|
|
33
|
+
Number.parseInt(h.slice(0, 2), 16),
|
|
34
|
+
Number.parseInt(h.slice(2, 4), 16),
|
|
35
|
+
Number.parseInt(h.slice(4, 6), 16),
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toHex(r: number, g: number, b: number): string {
|
|
40
|
+
const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)));
|
|
41
|
+
return `#${[r, g, b].map((v) => clamp(v).toString(16).padStart(2, "0")).join("")}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Linearly interpolate two hex colors. t=0 → from, t=1 → to. */
|
|
45
|
+
function lerpColor(from: string, to: string, t: number): string {
|
|
46
|
+
const [r1, g1, b1] = parseHex(from);
|
|
47
|
+
const [r2, g2, b2] = parseHex(to);
|
|
48
|
+
return toHex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Brighten a color by a factor (>1 = brighter). Clamps at 255. */
|
|
52
|
+
function brighten(hex: string, factor = 1.4): string {
|
|
53
|
+
const [r, g, b] = parseHex(hex);
|
|
54
|
+
return toHex(r * factor, g * factor, b * factor);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** easeOutCubic — fast attack, gentle tail */
|
|
58
|
+
function easeOutCubic(t: number): number {
|
|
59
|
+
return 1 - (1 - t) ** 3;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Component ───────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
export function NotificationFlash() {
|
|
65
|
+
const colors = useColors();
|
|
66
|
+
const [flash, setFlash] = useState<{
|
|
67
|
+
severity: NotificationEvent["severity"];
|
|
68
|
+
startTime: number;
|
|
69
|
+
} | null>(null);
|
|
70
|
+
const [borderColor, setBorderColor] = useState<string | null>(null);
|
|
71
|
+
const [borderStyle, setBorderStyle] = useState<"double" | "single">("double");
|
|
72
|
+
const unsubRef = useRef<(() => void) | null>(null);
|
|
73
|
+
|
|
74
|
+
// Register flash handler on the notification bus
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
notificationBus.setFlashHandler((severity) => {
|
|
77
|
+
setFlash({ severity, startTime: Date.now() });
|
|
78
|
+
});
|
|
79
|
+
return () => notificationBus.setFlashHandler(null);
|
|
80
|
+
}, []);
|
|
81
|
+
|
|
82
|
+
// Drive the animation via the global 30 Hz tick
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!flash) {
|
|
85
|
+
setBorderColor(null);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
unsubRef.current?.();
|
|
90
|
+
|
|
91
|
+
const alertColor = GLOW_COLORS[flash.severity];
|
|
92
|
+
const brightColor = brighten(alertColor);
|
|
93
|
+
const bgColor = colors.background;
|
|
94
|
+
|
|
95
|
+
unsubRef.current = animationTick.subscribe((now) => {
|
|
96
|
+
const elapsed = now - flash.startTime;
|
|
97
|
+
const progress = Math.min(elapsed / GLOW_DURATION_MS, 1);
|
|
98
|
+
|
|
99
|
+
let currentColor: string;
|
|
100
|
+
if (progress < 0.15) {
|
|
101
|
+
// Burst phase: overbright → alert color
|
|
102
|
+
currentColor = lerpColor(brightColor, alertColor, progress / 0.15);
|
|
103
|
+
} else {
|
|
104
|
+
// Fade phase: alert → background (eased for gentle tail)
|
|
105
|
+
const fadeT = (progress - 0.15) / 0.85;
|
|
106
|
+
currentColor = lerpColor(alertColor, bgColor, easeOutCubic(fadeT));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
setBorderColor(currentColor);
|
|
110
|
+
setBorderStyle(progress < 0.4 ? "double" : "single");
|
|
111
|
+
|
|
112
|
+
if (progress >= 1) {
|
|
113
|
+
unsubRef.current?.();
|
|
114
|
+
unsubRef.current = null;
|
|
115
|
+
setFlash(null);
|
|
116
|
+
setBorderColor(null);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return () => {
|
|
121
|
+
unsubRef.current?.();
|
|
122
|
+
unsubRef.current = null;
|
|
123
|
+
};
|
|
124
|
+
}, [flash, colors.background]);
|
|
125
|
+
|
|
126
|
+
if (!borderColor) return null;
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<box
|
|
130
|
+
position="absolute"
|
|
131
|
+
left={0}
|
|
132
|
+
top={0}
|
|
133
|
+
width="100%"
|
|
134
|
+
height="100%"
|
|
135
|
+
zIndex={Z_INDEX.TOAST + 1}
|
|
136
|
+
border
|
|
137
|
+
borderStyle={borderStyle}
|
|
138
|
+
borderColor={borderColor}
|
|
139
|
+
/>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -2,6 +2,7 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
|
2
2
|
import type { ConfigField, PluginType } from "@tokentop/plugin-sdk";
|
|
3
3
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
4
4
|
import type { AppConfig, SparklineOrientation, SparklineStyle } from "@/config/schema.ts";
|
|
5
|
+
import { notificationBus } from "@/plugins/notification-bus.ts";
|
|
5
6
|
import { formatBudgetDisplay, parseCurrencyInput } from "@/utils/currency.ts";
|
|
6
7
|
import { useConfig } from "../contexts/ConfigContext.tsx";
|
|
7
8
|
import { useDemoMode } from "../contexts/DemoModeContext.tsx";
|
|
@@ -20,10 +21,11 @@ interface SettingItem {
|
|
|
20
21
|
description?: string;
|
|
21
22
|
category: SettingCategory;
|
|
22
23
|
pluginId?: string;
|
|
23
|
-
type: "toggle" | "select" | "number";
|
|
24
|
+
type: "toggle" | "select" | "number" | "action";
|
|
24
25
|
options?: string[];
|
|
25
26
|
getValue: (config: AppConfig) => string | number | boolean | null;
|
|
26
27
|
setValue: (config: AppConfig, value: string | number | boolean | null) => AppConfig;
|
|
28
|
+
onAction?: () => void;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
const BASE_SETTINGS: SettingItem[] = [
|
|
@@ -205,6 +207,16 @@ const BASE_SETTINGS: SettingItem[] = [
|
|
|
205
207
|
notifications: { ...c.notifications, soundEnabled: v as boolean },
|
|
206
208
|
}),
|
|
207
209
|
},
|
|
210
|
+
{
|
|
211
|
+
key: "testNotification",
|
|
212
|
+
label: "Test Notifications",
|
|
213
|
+
description: "Send a test notification to verify toast, flash, and sound alerts are working.",
|
|
214
|
+
category: "notifications",
|
|
215
|
+
type: "action",
|
|
216
|
+
onAction: () => void notificationBus.testNotification(),
|
|
217
|
+
getValue: () => "▸ Run",
|
|
218
|
+
setValue: (c) => c,
|
|
219
|
+
},
|
|
208
220
|
];
|
|
209
221
|
|
|
210
222
|
const CATEGORIES: { id: SettingCategory; label: string }[] = [
|
|
@@ -362,7 +374,7 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
|
|
|
362
374
|
provider: "Providers",
|
|
363
375
|
agent: "Agents",
|
|
364
376
|
theme: "Themes",
|
|
365
|
-
notification: "
|
|
377
|
+
notification: "Notifications",
|
|
366
378
|
};
|
|
367
379
|
|
|
368
380
|
// Stable ordering for plugin type groups in sidebar
|
|
@@ -621,7 +633,9 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
|
|
|
621
633
|
setSelectedIndex((i) => Math.max(i - 1, 0));
|
|
622
634
|
} else if (key.name === "return" || key.name === "space") {
|
|
623
635
|
const setting = categorySettings[selectedIndex];
|
|
624
|
-
if (setting?.
|
|
636
|
+
if (setting?.type === "action" && setting.onAction) {
|
|
637
|
+
setting.onAction();
|
|
638
|
+
} else if (setting?.key === "theme") {
|
|
625
639
|
setShowThemePicker(true);
|
|
626
640
|
} else if (setting?.type === "number") {
|
|
627
641
|
startEditingNumber();
|
|
@@ -832,7 +846,9 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
|
|
|
832
846
|
setting.type === "number" && editingSettingKey === setting.key;
|
|
833
847
|
|
|
834
848
|
let displayValue: string;
|
|
835
|
-
if (setting.
|
|
849
|
+
if (setting.type === "action") {
|
|
850
|
+
displayValue = "▸ Run";
|
|
851
|
+
} else if (setting.key === "theme") {
|
|
836
852
|
const themeName = themes.find((t) => t.id === value)?.name ?? value;
|
|
837
853
|
displayValue = `${themeName} ▸`;
|
|
838
854
|
} else if (setting.type === "toggle") {
|
|
@@ -8,7 +8,7 @@ interface ToastProps {
|
|
|
8
8
|
type?: "info" | "success" | "warning" | "error";
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
export function Toast({ message, duration =
|
|
11
|
+
export function Toast({ message, duration = 4000, onDismiss, type = "success" }: ToastProps) {
|
|
12
12
|
const colors = useColors();
|
|
13
13
|
|
|
14
14
|
useEffect(() => {
|
|
@@ -241,6 +241,14 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
|
|
|
241
241
|
initialize();
|
|
242
242
|
}, []);
|
|
243
243
|
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (!isInitialized) return;
|
|
246
|
+
notificationBus.setPluginConfig("terminal-bell", {
|
|
247
|
+
...((config.pluginConfig ?? {})["terminal-bell"] ?? {}),
|
|
248
|
+
enabled: config.notifications.soundEnabled,
|
|
249
|
+
});
|
|
250
|
+
}, [config.notifications.soundEnabled, isInitialized]);
|
|
251
|
+
|
|
244
252
|
const prevPluginConfigRef = useRef<Record<string, Record<string, unknown>>>({});
|
|
245
253
|
useEffect(() => {
|
|
246
254
|
if (!isInitialized) return;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { NotificationEvent } from "@tokentop/plugin-sdk";
|
|
2
|
+
import { useEffect } from "react";
|
|
3
|
+
import { notificationBus } from "@/plugins/notification-bus.ts";
|
|
4
|
+
import { useToastContext } from "../contexts/ToastContext.tsx";
|
|
5
|
+
|
|
6
|
+
function severityToToastType(
|
|
7
|
+
severity: NotificationEvent["severity"],
|
|
8
|
+
): "info" | "warning" | "error" {
|
|
9
|
+
switch (severity) {
|
|
10
|
+
case "critical":
|
|
11
|
+
return "error";
|
|
12
|
+
case "warning":
|
|
13
|
+
return "warning";
|
|
14
|
+
default:
|
|
15
|
+
return "info";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useNotificationBridge(): void {
|
|
20
|
+
const { showToast } = useToastContext();
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const handler = (event: NotificationEvent) => {
|
|
24
|
+
showToast(event.title, severityToToastType(event.severity));
|
|
25
|
+
};
|
|
26
|
+
notificationBus.setToastHandler(handler);
|
|
27
|
+
return () => notificationBus.setToastHandler(null);
|
|
28
|
+
}, [showToast]);
|
|
29
|
+
}
|
|
@@ -2,6 +2,7 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
|
2
2
|
import type { ConfigField } from "@tokentop/plugin-sdk";
|
|
3
3
|
import { useCallback, useMemo, useState } from "react";
|
|
4
4
|
import type { AppConfig } from "@/config/schema.ts";
|
|
5
|
+
import { notificationBus } from "@/plugins/notification-bus.ts";
|
|
5
6
|
import { useConfig } from "../contexts/ConfigContext.tsx";
|
|
6
7
|
import { useDemoMode } from "../contexts/DemoModeContext.tsx";
|
|
7
8
|
import { usePlugins } from "../contexts/PluginContext.tsx";
|
|
@@ -14,10 +15,11 @@ interface SettingItem {
|
|
|
14
15
|
key: string;
|
|
15
16
|
label: string;
|
|
16
17
|
category: SettingCategory;
|
|
17
|
-
type: "toggle" | "select" | "number";
|
|
18
|
+
type: "toggle" | "select" | "number" | "action";
|
|
18
19
|
options?: string[];
|
|
19
20
|
getValue: (config: AppConfig) => string | number | boolean;
|
|
20
21
|
setValue: (config: AppConfig, value: string | number | boolean) => AppConfig;
|
|
22
|
+
onAction?: () => void;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
const SETTINGS: SettingItem[] = [
|
|
@@ -192,6 +194,15 @@ const SETTINGS: SettingItem[] = [
|
|
|
192
194
|
notifications: { ...c.notifications, soundEnabled: v as boolean },
|
|
193
195
|
}),
|
|
194
196
|
},
|
|
197
|
+
{
|
|
198
|
+
key: "testNotification",
|
|
199
|
+
label: "Test Notifications",
|
|
200
|
+
category: "notifications",
|
|
201
|
+
type: "action",
|
|
202
|
+
onAction: () => void notificationBus.testNotification(),
|
|
203
|
+
getValue: () => "▸ Run",
|
|
204
|
+
setValue: (c) => c,
|
|
205
|
+
},
|
|
195
206
|
];
|
|
196
207
|
|
|
197
208
|
const CATEGORIES: { id: SettingCategory; label: string }[] = [
|
|
@@ -384,6 +395,11 @@ export function SettingsView() {
|
|
|
384
395
|
} else if (key.name === "up" || key.name === "k") {
|
|
385
396
|
setSelectedIndex((i) => Math.max(i - 1, 0));
|
|
386
397
|
} else if (key.name === "return" || key.name === "space") {
|
|
398
|
+
const setting = categorySettings[selectedIndex];
|
|
399
|
+
if (setting?.type === "action" && setting.onAction) {
|
|
400
|
+
setting.onAction();
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
387
403
|
toggleCurrentSetting();
|
|
388
404
|
} else if (key.name === "left" || key.name === "h") {
|
|
389
405
|
const setting = categorySettings[selectedIndex];
|
|
@@ -479,13 +495,15 @@ export function SettingsView() {
|
|
|
479
495
|
>
|
|
480
496
|
<text fg={isSelected ? colors.background : colors.text}>{setting.label}</text>
|
|
481
497
|
<text fg={isSelected ? colors.background : colors.textMuted}>
|
|
482
|
-
{setting.type === "
|
|
483
|
-
?
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
:
|
|
498
|
+
{setting.type === "action"
|
|
499
|
+
? "▸ Run"
|
|
500
|
+
: setting.type === "toggle"
|
|
501
|
+
? value
|
|
502
|
+
? "● ON"
|
|
503
|
+
: "○ OFF"
|
|
504
|
+
: setting.type === "select"
|
|
505
|
+
? `◂ ${value} ▸`
|
|
506
|
+
: String(value)}
|
|
489
507
|
</text>
|
|
490
508
|
</box>
|
|
491
509
|
);
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.6.
|
|
1
|
+
export const VERSION = "0.6.1"; // x-release-please-version
|