Skip to main content
Glama
streaming-text.ts6.87 kB
import { html, css } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { McpBaseElement } from './base.js'; /** * Streaming text component with typewriter effect for AI responses. * Simulates real-time text generation with configurable speed. * * @element mcp-streaming-text * * @prop {string} text - The full text to stream * @prop {number} speed - Characters per second (default: 50) * @prop {boolean} cursor - Whether to show blinking cursor * @prop {boolean} autoStart - Whether to start streaming automatically * * @csspart container - The text container * @csspart cursor - The blinking cursor * * @cssprop --mcp-streaming-cursor-color - Cursor color * @cssprop --mcp-streaming-cursor-width - Cursor width * * @fires mcp-stream-start - Fired when streaming begins * @fires mcp-stream-progress - Fired during streaming with progress info * @fires mcp-stream-complete - Fired when streaming completes * * @example * <mcp-streaming-text text="Hello, I'm an AI assistant!" speed="30"></mcp-streaming-text> */ @customElement('mcp-streaming-text') export class McpStreamingText extends McpBaseElement { static styles = [ ...McpBaseElement.baseStyles, css` :host { display: block; --_cursor-color: var(--mcp-streaming-cursor-color, var(--mcp-color-primary)); --_cursor-width: var(--mcp-streaming-cursor-width, 2px); } .container { white-space: pre-wrap; word-wrap: break-word; } .cursor { display: inline-block; width: var(--_cursor-width); height: 1.2em; background: var(--_cursor-color); margin-left: 1px; vertical-align: text-bottom; animation: blink 1s step-end infinite; } .cursor.hidden { visibility: hidden; } @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } /* No animation when reduced motion preferred */ @media (prefers-reduced-motion: reduce) { .cursor { animation: none; opacity: 1; } } `, ]; /** * The full text to stream. */ @property() text = ''; /** * Characters per second. */ @property({ type: Number }) speed = 50; /** * Whether to show blinking cursor. */ @property({ type: Boolean }) cursor = true; /** * Whether to start streaming automatically when text changes. */ @property({ type: Boolean, attribute: 'auto-start' }) autoStart = true; @state() private _displayedText = ''; @state() private _isStreaming = false; @state() private _isComplete = false; private _animationFrame: number | null = null; private _startTime: number | null = null; private _lastText = ''; connectedCallback() { super.connectedCallback(); if (this.autoStart && this.text) { this.stream(this.text); } } disconnectedCallback() { super.disconnectedCallback(); this._cancelAnimation(); } updated(changedProps: Map<string, unknown>) { if (changedProps.has('text') && this.autoStart && this.text !== this._lastText) { this._lastText = this.text; this.stream(this.text); } } /** * Start streaming the given text (or current text prop). */ stream(text?: string): void { if (text !== undefined) { this.text = text; } this._cancelAnimation(); this._displayedText = ''; this._isStreaming = true; this._isComplete = false; this._startTime = null; this.emit('stream-start', { text: this.text }); this._animate(); } /** * Append additional text to the stream. */ append(text: string): void { this.text += text; if (!this._isStreaming) { this._isStreaming = true; this._isComplete = false; this._animate(); } } /** * Complete the stream immediately, showing all text. */ complete(): void { this._cancelAnimation(); this._displayedText = this.text; this._isStreaming = false; this._isComplete = true; this.emit('stream-complete', { text: this.text }); } /** * Pause the streaming animation. */ pause(): void { this._cancelAnimation(); this._isStreaming = false; } /** * Resume the streaming animation. */ resume(): void { if (this._displayedText.length < this.text.length) { this._isStreaming = true; this._startTime = null; this._animate(); } } /** * Reset the component to initial state. */ reset(): void { this._cancelAnimation(); this._displayedText = ''; this._isStreaming = false; this._isComplete = false; this._startTime = null; } private _cancelAnimation(): void { if (this._animationFrame !== null) { cancelAnimationFrame(this._animationFrame); this._animationFrame = null; } } private _animate = (): void => { if (!this._isStreaming) return; const now = performance.now(); if (this._startTime === null) { this._startTime = now; } const elapsed = now - this._startTime; const charsToShow = Math.floor((elapsed / 1000) * this.speed); const targetLength = Math.min(charsToShow, this.text.length); if (this._displayedText.length < targetLength) { this._displayedText = this.text.slice(0, targetLength); this.emit('stream-progress', { text: this._displayedText, progress: this._displayedText.length / this.text.length, total: this.text.length, current: this._displayedText.length, }); } if (this._displayedText.length >= this.text.length) { this._isStreaming = false; this._isComplete = true; this.emit('stream-complete', { text: this.text }); } else { this._animationFrame = requestAnimationFrame(this._animate); } }; /** * Get current progress as a percentage (0-100). */ get progress(): number { if (!this.text) return 0; return (this._displayedText.length / this.text.length) * 100; } /** * Whether streaming is currently active. */ get isStreaming(): boolean { return this._isStreaming; } /** * Whether streaming has completed. */ get isComplete(): boolean { return this._isComplete; } render() { const showCursor = this.cursor && !this._isComplete; return html` <div class="container" part="container" role="status" aria-live="polite" aria-busy="${this._isStreaming}" > ${this._displayedText}<span class="cursor ${showCursor ? '' : 'hidden'}" part="cursor" aria-hidden="true" ></span> </div> `; } } declare global { interface HTMLElementTagNameMap { 'mcp-streaming-text': McpStreamingText; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/heyadam/mcpsystemdesign'

If you have feedback or need assistance with the MCP directory API, please join our Discord server