using System.ComponentModel;
using TinyChat.Messages.Formatting;
namespace TinyChat;
///
/// A user control that provides a chat interface with message display and text input functionality.
///
public partial class ChatControl : UserControl
{
private const string ROBOT_WELCOME = "●\n┌─┴─┐\n◉‿◉\n└───┘\n\nGreetings human.\nHow can I help you today?";
private List _messages = [];
///
/// Occurs when a message is sent from the text box and allows the cancellation of sending.
///
public event EventHandler? MessageSending;
///
/// Occurs when a message has been sent from the user interface.
///
public event EventHandler? MessageSent;
///
/// Gets the control that manages and displays the chat message history.
///
///
/// The control responsible for displaying chat messages, or if not initialized.
///
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public Control? MessageHistoryControl { get; private set; }
///
/// Gets the control that displays the welcome message when no chat messages are present.
///
///
/// The welcome message control, or if not initialized.
///
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public Control? WelcomeControl { get; private set; }
///
/// Gets the control that provides the chat input interface for sending messages.
///
///
/// The input control for entering and sending chat messages, or if not initialized.
///
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public Control? InputControl { get; private set; }
///
/// Gets the split container control that divides the chat history panel from the input panel.
///
///
/// The split container control managing the layout of history and input areas, or if not initialized.
///
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public Control? SplitContainerControl { get; private set; }
///
/// Initializes a new instance of the class.
///
public ChatControl() => InitializeComponent();
///
/// Gets or sets the message history displayed in the chat control.
///
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public IEnumerable Messages
{
get => _messages.AsReadOnly();
set
{
_messages = value is null ? [] : [.. value];
PopulateMessages();
}
}
///
/// Gets or sets the welcome message displayed when no messages are present in the chat history.
///
[Category("Chat")]
[Description("Gets or sets the welcome message displayed when no messages are present in the chat history.")]
[DefaultValue(ROBOT_WELCOME)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)]
public string WelcomeMessage { get; set; } = ROBOT_WELCOME;
///
/// Gets or sets the splitter position dividing the chat message history from the chat input box below.
///
[Category("Chat")]
[DefaultValue(60)]
[Description("Gets or sets the splitter position dividing the chat message history from the chat input box below.")]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)]
public int SplitterPosition
{
get => (SplitContainerControl as ISplitContainerControl)?.SplitterPosition ?? 0;
set
{
if (SplitContainerControl is ISplitContainerControl splitContainer)
splitContainer.SplitterPosition = value;
}
}
///
/// Gets or sets the sender for messages sent from this chat control.
///
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public ISender Sender { get; set; } = new NamedSender(Environment.UserName);
///
/// Gets or sets the formatter that converts message content into displayable strings.
///
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public IMessageFormatter MessageFormatter { get; set; } = new PlainTextMessageFormatter();
///
/// Updates the visibility of the welcome control based on the current message history.
///
protected virtual void UpdateWelcomeControlVisibility()
{
if (WelcomeControl is not null)
WelcomeControl.Visible = ShouldShowWelcomeControl();
}
///
/// Determines whether the welcome control should be displayed based on the current message history.
///
protected virtual bool ShouldShowWelcomeControl() => _messages.Count == 0;
///
protected override void OnHandleCreated(EventArgs e)
{
base.OnHandleCreated(e);
MessageFormatter = CreateDefaultMessageFormatter() ?? MessageFormatter;
}
///
/// Creates the message formatter that is used to display chat messages contents in the chat user interface
///
///
protected virtual IMessageFormatter? CreateDefaultMessageFormatter() => null;
///
/// Adds a chat message to the message history control.
///
/// The sender of the message.
/// The content of the message.
///
public virtual IChatMessageControl AddMessage(ISender sender, IChatMessageContent content)
{
var message = AddChatMessage(sender, content);
UpdateWelcomeControlVisibility();
return AppendMessageControl(message);
}
///
/// Adds a chat message with with support of streaming input, like when an AI assistant is streaming tokens
///
/// The sender of the streaming message.
/// The stream of the tokens.
/// An optional callback that can be used to process the streamed messages after it was received completely.
/// An optional callback that can be used to process exceptions that occured during the processing of the stream.
/// An optional synchronization context. Only required if the applications does not provide a default synchronization context.
/// The token to cancel the operation with.
///
public virtual IChatMessageControl AddStreamingMessage(
ISender sender,
IAsyncEnumerable stream,
SynchronizationContext? synchronizationContext = default,
Action? completionCallback = default,
Action? exceptionCallback = default,
CancellationToken cancellationToken = default)
{
var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
void InputControl_CancellationRequested(object? sender, EventArgs e) => cancellationSource.Cancel();
var stringBuilder = new NotifyingStringBuilder();
var content = new ChangingMessageContent(stringBuilder);
var message = AddChatMessage(sender, content);
var context = (synchronizationContext ?? SynchronizationContext.Current) ?? throw new InvalidOperationException("No synchronization context available. Please make sure a the default SynchronizationContext is available or pass in an SynchronizationContext as argument!");
UpdateWelcomeControlVisibility();
var messageControl = AppendMessageControl(message);
var inputControl = InputControl as IChatInputControl;
// loop through the stream in a background thread and append the chunks to the string builder
context.Post(async (_) =>
{
try
{
if (inputControl != null)
inputControl.CancellationRequested += InputControl_CancellationRequested;
messageControl.SetIsReceivingStream(true);
inputControl?.SetIsReceivingStream(true, allowCancellation: cancellationToken.CanBeCanceled);
await foreach (var chunk in stream.ConfigureAwait(true).WithCancellation(cancellationSource.Token))
{
stringBuilder.Append(chunk);
// leave the chat if cancellation was requested, the stream might or might not support cancellation.
if (cancellationToken.IsCancellationRequested)
break;
}
}
catch (Exception ex)
{
exceptionCallback?.Invoke(ex);
if (!cancellationSource.Token.IsCancellationRequested)
throw;
}
finally
{
inputControl?.SetIsReceivingStream(false, allowCancellation: false);
messageControl.SetIsReceivingStream(false);
if (inputControl != null)
inputControl.CancellationRequested -= InputControl_CancellationRequested;
}
completionCallback?.Invoke(stringBuilder.ToString());
}, state: null);
return messageControl;
}
///
/// Removes a given message from the chat
///
///
public virtual void RemoveMessage(IChatMessage message)
{
_messages.Remove(message);
if (MessageHistoryControl is IChatMessageHistoryControl casted)
casted.RemoveMessageControl(message);
UpdateWelcomeControlVisibility();
}
///
/// Raises the event and initializes the chat control layout.
///
protected override void OnCreateControl()
{
base.OnCreateControl();
var splitContainer = CreateSplitContainerControl();
SplitContainerControl = (Control)splitContainer;
Controls.Add(SplitContainerControl);
LayoutSplitContainerControl(SplitContainerControl);
MessageHistoryControl = (Control)CreateMessageHistoryControl();
splitContainer?.HistoryPanel?.Controls.Add(MessageHistoryControl);
LayoutMessageHistoryControl(MessageHistoryControl);
WelcomeControl = CreateWelcomeControl();
splitContainer?.HistoryPanel?.Controls.Add(WelcomeControl);
LayoutWelcomeControl(WelcomeControl);
var inputControl = CreateChatInputControl();
inputControl.MessageSending += (_, e) => SendMessage(e);
InputControl = (Control)inputControl;
splitContainer?.ChatInputPanel?.Controls.Add(InputControl);
LayoutChatInputControl(InputControl);
PopulateMessages();
}
///
/// Adds the messages to the controls
///
private void PopulateMessages()
{
if (MessageHistoryControl is IChatMessageHistoryControl casted)
casted.ClearMessageControls();
foreach (var message in _messages)
AppendMessageControl(message);
UpdateWelcomeControlVisibility();
}
///
/// Appends a chat message to the message container.
///
/// The chat message to append.
protected virtual IChatMessageControl AppendMessageControl(IChatMessage message)
{
var messageControl = CreateMessageControl(message);
messageControl.Message = message;
var control = (Control)messageControl;
if (MessageHistoryControl is IChatMessageHistoryControl casted)
{
LayoutMessageControl(MessageHistoryControl, control);
casted.AppendMessageControl(messageControl);
}
return messageControl;
}
///
/// Creates the container control that will hold all chat messages.
///
/// A that serves as the messages container.
protected virtual IChatMessageHistoryControl CreateMessageHistoryControl() => new FlowLayoutMessageHistoryControl();
///
/// Applies layout settings to the messages container control.
///
/// The control to layout.
protected virtual void LayoutMessageHistoryControl(Control control)
{
control.Dock = DockStyle.Fill;
}
///
/// Creates the container control that will hold all chat messages.
///
/// A that serves as the messages container.
protected virtual Control CreateWelcomeControl()
{
var label = new Label { Text = WelcomeMessage, TextAlign = ContentAlignment.MiddleCenter, Dock = DockStyle.Fill, Font = new Font("Tahoma", 14f), UseMnemonic = false };
var panel = new Panel();
panel.Controls.Add(label);
return panel;
}
///
/// Applies layout settings to the messages container control.
///
/// The control to layout.
protected virtual void LayoutWelcomeControl(Control control)
{
control.Dock = DockStyle.Fill;
control.BringToFront();
}
///
/// Creates a message control for displaying a specific chat message.
///
/// The chat message to create a control for.
/// An instance for the message.
protected virtual IChatMessageControl CreateMessageControl(IChatMessage message) => new ChatMessageControl() { Message = message, MessageFormatter = MessageFormatter };
///
/// Applies layout settings to a chat message control and adds it to the container.
///
/// The container to add the message control to.
/// The chat message control to layout and add.
protected virtual void LayoutMessageControl(Control container, Control chatMessageControl)
{
chatMessageControl.Dock = DockStyle.Fill;
}
///
/// Creates the split container control that holds the message history and input controls.
///
///
protected virtual ISplitContainerControl CreateSplitContainerControl() => new ChatSplitContainerControl();
///
/// Applies layout settings to the split container control.
///
///
protected virtual void LayoutSplitContainerControl(Control splitter)
{
splitter.Dock = DockStyle.Fill;
((ISplitContainerControl)splitter).SplitterPosition = 60;
}
///
/// Creates the text input control for sending new messages.
///
/// An instance for message input.
protected virtual IChatInputControl CreateChatInputControl() => new ChatInputControl();
///
/// Applies layout settings to the text input control.
///
/// The text box control to layout.
protected virtual void LayoutChatInputControl(Control textBox) => textBox.Dock = DockStyle.Fill;
///
/// Sends a message from the current sender with the specified text content.
///
/// The text content of the message to send.
///
/// if the message was sent successfully;
/// if the message sending was cancelled.
///
///
/// This method creates a wrapper around the provided text
/// and uses the control's default property for the message sender.
/// The message sending can be cancelled by handling the event
/// and setting the MessageSendingEventArgs.Cancel property to .
///
public bool SendMessage(string message)
{
var args = new MessageSendingEventArgs(Sender, new StringMessageContent(message));
SendMessage(args);
return !args.Cancel;
}
///
/// Sends a message from the specified sender with the given content.
///
/// The sender of the message.
/// The content of the message to send.
///
/// if the message was sent successfully;
/// if the message sending was cancelled.
///
///
/// This method allows specifying both the sender and content of the message explicitly.
/// The message sending can be cancelled by handling the event
/// and setting the MessageSendingEventArgs.Cancel property to .
///
public bool SendMessage(ISender sender, IChatMessageContent content)
{
var args = new MessageSendingEventArgs(sender, content);
SendMessage(args);
return !args.Cancel;
}
///
/// Sends a message using the provided event arguments, handling the complete message sending workflow.
///
/// The event arguments containing the sender, content, and cancellation state.
///
/// This method orchestrates the complete message sending process:
///
/// - Determines the effective sender (uses .Sender if provided, otherwise falls back to the control's )
/// - Raises the event to allow subscribers to inspect or cancel the operation
/// - If not cancelled, adds the message to the chat history and displays it
/// - Raises the event to notify subscribers that the message was successfully sent
///
/// The message sending can be cancelled by setting the MessageSendingEventArgs.Cancel property to
/// in the event handler.
///
public virtual void SendMessage(MessageSendingEventArgs e)
{
var sender = e.Sender ?? Sender;
MessageSending?.Invoke(this, e);
if (!e.Cancel)
{
AppendMessageControl(AddChatMessage(sender, e.Content));
MessageSent?.Invoke(this, new MessageSentEventArgs(sender, e.Content));
}
}
///
/// Creates a new chat message instance.
///
/// The sender of the message.
/// The content of the message.
/// A new instance.
protected virtual IChatMessage CreateChatMessage(ISender sender, IChatMessageContent content) => new ChatMessage(sender, content);
///
/// Creates a new chat message and adds it to the message history.
///
/// The sender of the message.
/// The content of the message.
/// A new instance.
protected virtual IChatMessage AddChatMessage(ISender sender, IChatMessageContent content)
{
var message = CreateChatMessage(sender, content);
_messages.Add(message);
return message;
}
}