@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 CHANGED
@@ -1,5 +1,4 @@
1
- > [!CAUTION]
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
  [![Bun](https://img.shields.io/badge/Bun-runtime-f9f1e1?style=flat-square&logo=bun&logoColor=black)](https://bun.sh)
19
18
  [![Visitors](https://api.visitorbadge.io/api/visitors?path=https%3A%2F%2Fgithub.com%2Ftokentopapp%2Ftokentop&labelColor=%23697689&countColor=%23ba68c8&style=flat-square)](https://visitorbadge.io/status?path=https://github.com/tokentopapp/tokentop)
20
19
 
21
- [Features](#features) · [Install](#installation) · [Quick Start](#quick-start) · [Keyboard Shortcuts](#keyboard-shortcuts) · [Configuration](#configuration) · [Plugins](#plugin-system)
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
- - **Session tracking** — See every coding agent session with model, tokens, cost, and duration. Built-in support for Claude Code, OpenCode, Cursor, and Copilot CLI
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** — Dark: Tokyo Night (default), Dracula, Nord, Catppuccin Mocha, Gruvbox Dark, One Dark, Rosé Pine, Kanagawa, OpenCode, Claude Code · Light: Catppuccin Latte, Gruvbox Light, GitHub Light, Solarized Light, Rosé Pine Dawn
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
- <!-- TODO: Add VHS recordings of each view
145
- > _Screenshots coming soon_
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.md) for installation, configuration, and development details.
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 # ESLint
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.0",
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.86",
38
- "@opentui/react": "0.1.86",
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",
@@ -82,7 +82,7 @@ export const DEFAULT_CONFIG: AppConfig = {
82
82
  },
83
83
  notifications: {
84
84
  toastsEnabled: true,
85
- soundEnabled: false,
85
+ soundEnabled: true,
86
86
  },
87
87
  budgets: {
88
88
  daily: null,
@@ -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: "Simple terminal bell (BEL character) for alerts",
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 (severityOrder.indexOf(event.severity) < severityOrder.indexOf(minSeverity)) {
129
+ if (SEVERITY_ORDER.indexOf(event.severity) < SEVERITY_ORDER.indexOf(minSeverity)) {
57
130
  return;
58
131
  }
59
132
 
60
- const bellCount = event.severity === "critical" ? 3 : 1;
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
- process.stdout.write("\x07");
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 using ANSI escape sequences",
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: 100,
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(ctx: NotificationContext, event: NotificationEvent): Promise<void> {
48
- const duration = (ctx.config.duration as number) ?? 100;
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: "Delivery",
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?.key === "theme") {
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.key === "theme") {
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 = 2000, onDismiss, type = "success" }: ToastProps) {
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 === "toggle"
483
- ? value
484
- ? "● ON"
485
- : "○ OFF"
486
- : setting.type === "select"
487
- ? `◂ ${value} ▸`
488
- : String(value)}
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.0"; // x-release-please-version
1
+ export const VERSION = "0.6.1"; // x-release-please-version