termino
~/docs/custom/toast

Toastcustom

Toast provider with queue, tones, and auto-dismiss

~/custom/toast
live
$ termino toast — click buttons below
queue 0 auto-dismiss after ttl

##API

Context-based: wrap the app in `<ToastProvider>`, then call `useToasts().push(...)` from anywhere. Auto-dismiss via timers; a per-toast timeline renders the TTL bar.

properties
proptypedefaultdescription
ToastProviderchildren: ReactNode-Root provider, renders the toast stack overlay
push{ title, message?, tone, duration? }duration 4000Returns toast id
dismiss(id: number) => void-Remove a toast
ToastIteminterface-{ id, title, message?, tone, duration }

##Keymap

~/keymap
push()Append a toast (info | success | warning | error)
dismiss(id)Remove a toast early

##Notes

  • Timers are cleaned up on unmount — no leaks.
  • Stack is absolutely positioned top-right, above your app content.

##Source

Real implementation — this is the code that runs in your terminal.

tsxtoast.tsx
1import { createElement as h, createContext, useContext, useCallback, useEffect, useState } from "react";
2import { useTimeline } from "@opentui/react";
3
4export interface ToastItem {
5 id: number;
6 title: string;
7 message?: string;
8 tone: "info" | "success" | "warning" | "error";
9 duration: number;
10}
11
12interface ToastContextValue {
13 push: (toast: Omit<ToastItem, "id" | "duration"> & { duration?: number }) => number;
14 dismiss: (id: number) => void;
15}
16
17const ToastContext = createContext<ToastContextValue>({
18 push: () => 0,
19 dismiss: () => {},
20});
21
22export function useToasts() {
23 return useContext(ToastContext);
24}
25
26const TONES = {
27 info: { fg: "#7dcfff", bg: "#1f3a3a", glyph: "ℹ" },
28 success: { fg: "#9ece6a", bg: "#1f3a2e", glyph: "✓" },
29 warning: { fg: "#e0af68", bg: "#3a2e1f", glyph: "!" },
30 error: { fg: "#f7768e", bg: "#3a1f28", glyph: "✗" },
31} as const;
32
33let nextId = 1;
34
35const timers = new Map<number, ReturnType<typeof setTimeout>>();
36
37export function ToastProvider({ children }: { children: React.ReactNode }) {
38 const [toasts, setToasts] = useState<ToastItem[]>([]);
39
40 const dismiss = useCallback((id: number) => {
41 const timer = timers.get(id);
42 if (timer) clearTimeout(timer);
43 timers.delete(id);
44 setToasts((prev) => prev.filter((t) => t.id !== id));
45 }, []);
46
47 const push = useCallback(
48 (toast: Omit<ToastItem, "id" | "duration"> & { duration?: number }) => {
49 const id = nextId++;
50 const duration = toast.duration ?? 4000;
51 setToasts((prev) => [...prev, { ...toast, id, duration }]);
52 timers.set(
53 id,
54 setTimeout(() => dismiss(id), duration),
55 );
56 return id;
57 },
58 [dismiss],
59 );
60
61 useEffect(() => {
62 return () => {
63 for (const timer of timers.values()) clearTimeout(timer);
64 timers.clear();
65 };
66 }, []);
67
68 return h(
69 ToastContext.Provider,
70 { value: { push, dismiss } },
71 children,
72 h(ToastViewport, { toasts }),
73 );
74}
75
76function ToastViewport({ toasts }: { toasts: ToastItem[] }) {
77 return h(
78 "box",
79 { position: "absolute", right: 0, top: 0, flexDirection: "column", gap: 1 },
80 toasts.map((toast) => h(ToastCard, { key: toast.id, toast })),
81 );
82}
83
84function ToastCard({ toast }: { toast: ToastItem }) {
85 const tone = TONES[toast.tone];
86 const [pct, setPct] = useState(100);
87
88 const timeline = useTimeline({
89 duration: toast.duration,
90 autoplay: true,
91 });
92
93 useEffect(() => {
94 timeline.add(
95 { pct: 100 },
96 {
97 pct: 0,
98 duration: toast.duration,
99 ease: "linear",
100 onUpdate: (a) => setPct(a.targets[0].pct as number),
101 },
102 0,
103 );
104 }, [timeline, toast.duration]);
105
106 return h(
107 "box",
108 {
109 style: {
110 width: 40,
111 border: true,
112 borderColor: tone.fg,
113 backgroundColor: "#16161e",
114 paddingLeft: 1,
115 paddingRight: 1,
116 flexDirection: "column",
117 },
118 },
119 h(
120 "box",
121 { flexDirection: "row", justifyContent: "space-between", width: 38 },
122 h(
123 "text",
124 { fg: tone.fg },
125 `${tone.glyph} ${toast.title}`,
126 ),
127 h("text", { fg: "#565f89" }, "esc"),
128 ),
129 toast.message ? h("text", { fg: "#a9b1d6" }, toast.message) : null,
130 h("box", { style: { height: 1, marginTop: 1, backgroundColor: "#2f3449" } },
131 h("box", {
132 style: {
133 width: Math.round((pct / 100) * 38),
134 height: 1,
135 backgroundColor: tone.fg,
136 },
137 }),
138 ),
139 );
140}
141