Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 82 additions & 58 deletions packages/ui/src/Toast.ts
Original file line number Diff line number Diff line change
@@ -1,74 +1,98 @@
// Toast auto-dismiss notification
// Toast - auto-dismiss notification
import { Widget } from '@termuijs/widgets';
import { type Screen, mergeStyles, defaultStyle, styleToCellAttrs, caps } from '@termuijs/core';

export type ToastType = 'info' | 'success' | 'warning' | 'error';
export interface ToastMessage { text: string; type: ToastType; expireAt: number; }

export interface ToastMessage {
text: string;
type: ToastType;
expireAt: number;
createdAt: number;
}

export interface ToastOptions {
position?: 'top-right' | 'bottom-right' | 'top-left' | 'bottom-left';
durationMs?: number;
maxVisible?: number;
/** Enable screen reader announcements (default: true) */
announce?: boolean;
position?: 'top-right' | 'bottom-right' | 'top-left' | 'bottom-left';
durationMs?: number;
maxVisible?: number;
announce?: boolean;
animationMs?: number;
}

const ICONS_UNICODE: Record<ToastType, string> = { info: '', success: '', warning: '', error: '' };
const ICONS_UNICODE: Record<ToastType, string> = { info: 'i', success: '+', warning: '!', error: 'x' };
const ICONS_ASCII: Record<ToastType, string> = { info: 'i', success: '+', warning: '!', error: 'x' };
const COLORS: Record<ToastType, string> = { info: 'cyan', success: 'green', warning: 'yellow', error: 'red' };

export class Toast extends Widget {
private _messages: ToastMessage[] = [];
private _position: string;
private _durationMs: number;
private _maxVisible: number;
private _announce: boolean;
private _messages: ToastMessage[] = [];
private _position: string;
private _durationMs: number;
private _maxVisible: number;
private _announce: boolean;
private _animationMs: number;

constructor(options: ToastOptions = {}) {
super(mergeStyles(defaultStyle(), {}));
this._position = options.position ?? 'top-right';
this._durationMs = options.durationMs ?? 3000;
this._maxVisible = options.maxVisible ?? 5;
this._announce = options.announce ?? true;
}
constructor(options: ToastOptions = {}) {
super(mergeStyles(defaultStyle(), {}));
this._position = options.position ?? 'top-right';
this._durationMs = options.durationMs ?? 3000;
this._maxVisible = options.maxVisible ?? 5;
this._announce = options.announce ?? true;
this._animationMs = options.animationMs ?? 300;
}

push(text: string, type: ToastType = 'info'): void {
this._messages.push({ text, type, expireAt: Date.now() + this._durationMs });
this.markDirty();
if (this._announce) {
this._announceToScreenReader(text, type);
}
}
info(text: string): void { this.push(text, 'info'); }
success(text: string): void { this.push(text, 'success'); }
warning(text: string): void { this.push(text, 'warning'); }
error(text: string): void { this.push(text, 'error'); }
push(text: string, type: ToastType = 'info'): void {
const now = Date.now();
this._messages.push({ text, type, expireAt: now + this._durationMs, createdAt: now });
this.markDirty();
if (this._announce) this._announceToScreenReader(text, type);
}

private _announceToScreenReader(text: string, _type: ToastType): void {
try {
const announcement = `[${text}]`;
const oscSequence = `\x1b]777;notify;TermUI;${announcement}\x07`;
process.stderr.write(oscSequence);
} catch {
// Silently fail if stderr is not writable
}
}
protected _renderSelf(screen: Screen): void {
const now = Date.now();
this._messages = this._messages.filter(m => m.expireAt > now);
if (this._messages.length === 0) return;
const { x, y, width, height } = this._rect;
const visible = this._messages.slice(-this._maxVisible);
const tw = Math.min(40, width - 2);
const isRight = this._position.includes('right');
const isBottom = this._position.includes('bottom');
const sx = isRight ? x + width - tw - 1 : x + 1;
const sy = isBottom ? y + height - visible.length - 1 : y + 1;
const icons = caps.unicode ? ICONS_UNICODE : ICONS_ASCII;
const attrs = styleToCellAttrs(this.style);
for (let i = 0; i < visible.length; i++) {
const m = visible[i];
const label = ` ${icons[m.type]} ${m.text} `.slice(0, tw).padEnd(tw);
screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] as any }, bold: true }); // as any: COLORS values are string but Style.name expects NamedColor; types need aligning
}
info(text: string): void { this.push(text, 'info'); }
success(text: string): void { this.push(text, 'success'); }
warning(text: string): void { this.push(text, 'warning'); }
error(text: string): void { this.push(text, 'error'); }

private _announceToScreenReader(text: string, _type: ToastType): void {
try {
process.stderr.write('\x1b]777;notify;TermUI;[' + text + ']\x07');
} catch { }
Comment on lines +55 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Escape toast text before writing OSC notification sequence

Line 57 embeds raw text into an OSC control sequence. If text contains ESC/BEL/ST characters, it can terminate/inject terminal control codes. Sanitize control bytes before concatenation.

Suggested fix
   private _announceToScreenReader(text: string, _type: ToastType): void {
     try {
-      process.stderr.write('\x1b]777;notify;TermUI;[' + text + ']\x07');
+      const safeText = text.replace(/[\x07\x1b\x9c]/g, '');
+      process.stderr.write('\x1b]777;notify;TermUI;[' + safeText + ']\x07');
     } catch { }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/Toast.ts` around lines 55 - 58, The _announceToScreenReader
method embeds the raw text parameter directly into an OSC control sequence on
line 57, which is a security risk as control characters like ESC, BEL, or ST in
the text could break the sequence or inject malicious commands. Sanitize the
text parameter by escaping or removing control bytes and special characters
before concatenating it into the OSC notification sequence to ensure only safe
characters are embedded in the terminal control code.

}

private _getAnimationProgress(createdAt: number, expireAt: number): number {
const now = Date.now();
const elapsed = now - createdAt;
const remaining = expireAt - now;
if (remaining < this._animationMs) return Math.max(0, remaining / this._animationMs);
if (elapsed < this._animationMs) return elapsed / this._animationMs;
return 1;
}

protected _renderSelf(screen: Screen): void {
const now = Date.now();
this._messages = this._messages.filter(m => m.expireAt > now);
if (this._messages.length === 0) return;
const { x, y, width, height } = this._rect;
const visible = this._messages.slice(-this._maxVisible);
const tw = Math.min(40, width - 2);
const isRight = this._position.includes('right');
const isBottom = this._position.includes('bottom');
const sx = isRight ? x + width - tw - 1 : x + 1;
const sy = isBottom ? y + height - visible.length - 1 : y + 1;
Comment on lines +76 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard narrow widget widths before computing toast text width

Line 76 can produce tw <= 0 when width < 3, which then skews sx (Line 79) and can cause incorrect draw coordinates/overlap. Clamp width and early-return on unusable geometry.

Suggested fix
   protected _renderSelf(screen: Screen): void {
     const now = Date.now();
     this._messages = this._messages.filter(m => m.expireAt > now);
     if (this._messages.length === 0) return;
     const { x, y, width, height } = this._rect;
+    if (width < 3 || height < 2) return;
     const visible = this._messages.slice(-this._maxVisible);
-    const tw = Math.min(40, width - 2);
+    const tw = Math.max(1, Math.min(40, width - 2));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/Toast.ts` around lines 76 - 80, The calculation of tw on line
76 can produce zero or negative values when width is less than 3, which causes
incorrect positioning in the sx calculation on line 79 and potential toast
overlap issues. Add a guard clause before the tw variable assignment to check if
width is less than 3, and if so, return early from the function since the widget
geometry is unusable for rendering. This ensures sx positioning calculations
only proceed with valid, non-negative text width values.

const icons = caps.unicode ? ICONS_UNICODE : ICONS_ASCII;
const attrs = styleToCellAttrs(this.style);
for (let i = 0; i < visible.length; i++) {
const m = visible[i];
const progress = this._getAnimationProgress(m.createdAt, m.expireAt);
const fullLabel = (' ' + icons[m.type] + ' ' + m.text + ' ').slice(0, tw).padEnd(tw);
const visibleChars = Math.floor(progress * tw);
const label = fullLabel.slice(0, visibleChars).padEnd(tw);
screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] as any }, bold: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify assertion/any usage in the changed file and strict-mode declaration in repo tsconfig(s)
rg -n --type=ts '\bas any\b|@\s*ts-ignore|as\s+[A-Za-z0-9_<>{}\[\]\|]+' packages/ui/src/Toast.ts
fd -i '^tsconfig(\..+)?\.json$' | xargs -r rg -n '"strict"\s*:\s*true'

Repository: Karanjot786/TermUI

Length of output: 635


🏁 Script executed:

# Get context around line 89 in Toast.ts
sed -n '80,100p' packages/ui/src/Toast.ts

# Find where COLORS is defined
rg -n 'COLORS\s*[:=]' packages/ui/src/Toast.ts

# Check the entire file structure
wc -l packages/ui/src/Toast.ts

Repository: Karanjot786/TermUI

Length of output: 1151


🏁 Script executed:

# Get the top of Toast.ts to see imports and type definitions
head -30 packages/ui/src/Toast.ts

# Find ToastType definition
rg -n 'type ToastType|enum ToastType' packages/ui/src/Toast.ts

Repository: Karanjot786/TermUI

Length of output: 1174


🏁 Script executed:

# Look for Color type definition in the `@termuijs/core` package
find packages -name "*.ts" -o -name "*.tsx" | xargs rg -l "type Color|interface Color" | head -5

# Search for the Color type more broadly
rg -A 5 "type Color\s*=|interface Color\s*\{" packages/core/src/

Repository: Karanjot786/TermUI

Length of output: 650


🏁 Script executed:

# Find NamedColor type definition
rg -A 5 "type NamedColor" packages/core/src/style/Color.ts

Repository: Karanjot786/TermUI

Length of output: 311


Type COLORS as Record<ToastType, NamedColor> to eliminate the as any cast

Line 89 uses COLORS[m.type] as any unnecessarily. The COLORS map should be explicitly typed as Record<ToastType, NamedColor> instead of Record<ToastType, string>. All values ('cyan', 'green', 'yellow', 'red') are valid NamedColor values, so the cast can be removed entirely. This eliminates the type assertion that violates the no-any/no-assertions-without-comments guidelines.

Suggested change
-const COLORS: Record<ToastType, string> = { info: 'cyan', success: 'green', warning: 'yellow', error: 'red' };
+const COLORS: Record<ToastType, NamedColor> = { info: 'cyan', success: 'green', warning: 'yellow', error: 'red' };

Then remove the as any cast on line 89:

-      screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] as any }, bold: true });
+      screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] }, bold: true });

Also add the import for NamedColor if not already present:

-import { type Screen, mergeStyles, defaultStyle, styleToCellAttrs, caps } from '`@termuijs/core`';
+import { type Screen, type NamedColor, mergeStyles, defaultStyle, styleToCellAttrs, caps } from '`@termuijs/core`';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] as any }, bold: true });
import { type Screen, type NamedColor, mergeStyles, defaultStyle, styleToCellAttrs, caps } from '`@termuijs/core`';
Suggested change
screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] as any }, bold: true });
const COLORS: Record<ToastType, NamedColor> = { info: 'cyan', success: 'green', warning: 'yellow', error: 'red' };
Suggested change
screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] as any }, bold: true });
screen.writeString(sx, sy + i, label, { ...attrs, fg: { type: 'named', name: COLORS[m.type] }, bold: true });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/Toast.ts` at line 89, The COLORS object on line 89 is typed
as Record<ToastType, string> but should be explicitly typed as Record<ToastType,
NamedColor> since all values ('cyan', 'green', 'yellow', 'red') are valid
NamedColor values. Change the type definition of the COLORS map from
Record<ToastType, string> to Record<ToastType, NamedColor>, then remove the
unnecessary as any cast from the usage on line 89 where COLORS[m.type] is
referenced. Ensure the NamedColor type is imported from the appropriate module
if not already present.

Source: Coding guidelines

}
const anyAnimating = visible.some(m => {
const elapsed = now - m.createdAt;
const remaining = m.expireAt - now;
return elapsed < this._animationMs || remaining < this._animationMs;
});
if (anyAnimating) setTimeout(() => this.markDirty(), 16);
}
Comment on lines +91 to +97

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid scheduling overlapping animation timers every render

Lines 91-97 schedule a new timeout whenever anyAnimating is true. Repeated renders can stack multiple pending timers and cause redundant markDirty() churn. Track one pending timer ID and only schedule when none exists.

Suggested fix
 export class Toast extends Widget {
   private _messages: ToastMessage[] = [];
+  private _animationTimer: ReturnType<typeof setTimeout> | null = null;
@@
-    if (anyAnimating) setTimeout(() => this.markDirty(), 16);
+    if (anyAnimating && this._animationTimer === null) {
+      this._animationTimer = setTimeout(() => {
+        this._animationTimer = null;
+        this.markDirty();
+      }, 16);
+    }
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const anyAnimating = visible.some(m => {
const elapsed = now - m.createdAt;
const remaining = m.expireAt - now;
return elapsed < this._animationMs || remaining < this._animationMs;
});
if (anyAnimating) setTimeout(() => this.markDirty(), 16);
}
const anyAnimating = visible.some(m => {
const elapsed = now - m.createdAt;
const remaining = m.expireAt - now;
return elapsed < this._animationMs || remaining < this._animationMs;
});
if (anyAnimating && this._animationTimer === null) {
this._animationTimer = setTimeout(() => {
this._animationTimer = null;
this.markDirty();
}, 16);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/Toast.ts` around lines 91 - 97, The timeout scheduling in the
animation check loop creates a new timer every render cycle when anyAnimating is
true, causing timer stacking and unnecessary markDirty() calls. Add a property
to track the pending animation timer ID (for example, _pendingAnimationTimerId),
and only schedule a new setTimeout call when no timer is already pending. When
the timer fires and calls markDirty(), reset the tracking property to null or
clear it so the next animation cycle can schedule a fresh timer if needed.

}
Loading