Skip to content
Merged
Show file tree
Hide file tree
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
31 changes: 29 additions & 2 deletions src/MandoCode.Desktop/Controls/ChatTabView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,10 @@
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>

<Border x:Name="SuggestionsPanel" Grid.Row="0" Grid.ColumnSpan="2" Visibility="Collapsed"
<Border x:Name="SuggestionsPanel" Grid.Row="0" Grid.ColumnSpan="3" Visibility="Collapsed"
Background="{StaticResource MandoPanelBrush}"
BorderBrush="{StaticResource MandoBorderBrush}" BorderThickness="1"
CornerRadius="8" Margin="0,0,0,8" MaxHeight="220">
Expand All @@ -287,7 +288,33 @@
AcceptsReturn="True" TextWrapping="Wrap" MaxHeight="140"
PreviewKeyDown="InputBox_PreviewKeyDown" TextChanged="InputBox_TextChanged"/>

<Button x:Name="SendButton" Grid.Row="1" Grid.Column="1" Click="SendButton_Click"
<Button x:Name="EmojiButton" Grid.Row="1" Grid.Column="1"
VerticalAlignment="Stretch" Padding="10,0"
Background="Transparent" BorderThickness="0"
ToolTipService.ToolTip="Insert emoji — or type :fire: shortcodes (Win + . opens the full Windows picker)">
<FontIcon Glyph="&#xE76E;" FontSize="16"/>
<Button.Flyout>
<Flyout Placement="TopEdgeAlignedRight">
<StackPanel Width="312" Spacing="6">
<GridView x:Name="EmojiGrid" SelectionMode="None" IsItemClickEnabled="True"
ItemClick="EmojiGrid_ItemClick" MaxHeight="248"
ScrollViewer.VerticalScrollBarVisibility="Auto">
<GridView.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Text="{x:Bind}" FontFamily="Segoe UI Emoji" FontSize="20"
Width="30" Height="30" TextAlignment="Center"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</DataTemplate>
</GridView.ItemTemplate>
</GridView>
<TextBlock Text="Tip: type :fire: style shortcodes in the input box for suggestions, or press Win + . for the full Windows emoji picker"
FontSize="11" Opacity="0.6" TextWrapping="Wrap"/>
</StackPanel>
</Flyout>
</Button.Flyout>
</Button>

<Button x:Name="SendButton" Grid.Row="1" Grid.Column="2" Click="SendButton_Click"
VerticalAlignment="Stretch" MinWidth="88"
Style="{StaticResource AccentButtonStyle}">
<StackPanel Orientation="Horizontal" Spacing="6">
Expand Down
118 changes: 117 additions & 1 deletion src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public sealed partial class ChatTabView : UserControl, IApprovalUi

private readonly ObservableCollection<CommandSuggestion> _suggestions = new();

private enum SuggestMode { None, Command, File }
private enum SuggestMode { None, Command, File, Emoji }
private SuggestMode _suggestMode = SuggestMode.None;
private int _tokenStart; // index of the '@' (File mode) — replaced on accept
private int _tokenEnd; // caret position when suggestions were computed
Expand Down Expand Up @@ -101,6 +101,7 @@ public ChatTabView(Window owner, AgentSession session, TranscriptHtmlBuilder htm
// This tab's approval service renders into this tab's overlay.
Session.Approvals.Ui = this;
SuggestionsList.ItemsSource = _suggestions;
EmojiGrid.ItemsSource = QuickEmojis;
TranscriptView.DefaultBackgroundColor = ThemeManager.C(ThemeManager.Current.Background);

// Method groups, not lambdas: Shutdown has to be able to detach them. A closed tab that
Expand Down Expand Up @@ -200,6 +201,10 @@ public async Task InitializeAsync()
OpenTranscriptPath(msg["open-file:".Length..]);
else if (msg != null && msg.StartsWith("copy:", StringComparison.Ordinal))
ClipboardCopyRequested?.Invoke(msg["copy:".Length..]);
else if (msg != null && msg.StartsWith("react:", StringComparison.Ordinal))
HandleReaction(msg["react:".Length..], add: true);
else if (msg != null && msg.StartsWith("unreact:", StringComparison.Ordinal))
HandleReaction(msg["unreact:".Length..], add: false);
};

// Serve bundled web assets (highlight.js) to the transcript document.
Expand All @@ -225,6 +230,24 @@ public async Task InitializeAsync()
await Task.Run(_controller.InitializeAsync);
}

/// <summary>A reaction chip was toggled in the transcript. Payload is JSON from the
/// transcript's rxChip handler: { id, emoji, snippet }. Adds/removes the pending entry
/// the controller folds into the next model turn; malformed payloads are ignored.</summary>
private void HandleReaction(string json, bool add)
{
try
{
using var doc = JsonDocument.Parse(json);
var id = doc.RootElement.GetProperty("id").GetString() ?? "";
var emoji = doc.RootElement.GetProperty("emoji").GetString() ?? "";
var snippet = doc.RootElement.GetProperty("snippet").GetString() ?? "";
if (emoji.Length == 0) return;
if (add) _controller.AddReaction(id, emoji, snippet);
else _controller.RemoveReaction(id, emoji);
}
catch { /* malformed payload — not ours to crash over */ }
}

private Task WaitForLoadedAsync()
{
if (TranscriptView.IsLoaded) return Task.CompletedTask;
Expand Down Expand Up @@ -799,6 +822,42 @@ private void UpdateSuggestions()
return;
}

// :emoji: shortcodes (Slack-style). Two behaviors on the token containing the caret:
// - ":name:" fully typed with an exact match → replace it with the emoji right here.
// - ":fra" partially typed (2+ chars, no closing ':') → suggest matching shortcodes.
// The 2-char minimum keeps ordinary colons (":)", "note:") from popping the list.
if (tokenStart < caret && tokenStart < text.Length && text[tokenStart] == ':')
{
var body = text[(tokenStart + 1)..caret];
if (body.Length > 1 && body.EndsWith(':'))
{
var name = body[..^1].ToLowerInvariant();
var exact = EmojiShortcodes.FirstOrDefault(s => s.Name == name).Emoji;
if (exact != null)
{
InputBox.Text = text[..tokenStart] + exact + text[caret..];
InputBox.SelectionStart = tokenStart + exact.Length;
HideSuggestions();
return;
}
}
else if (body.Length >= 2 && !body.Contains(':'))
{
var frag = body.ToLowerInvariant();
var matches = EmojiShortcodes.Where(s => s.Name.StartsWith(frag))
.Concat(EmojiShortcodes.Where(s => !s.Name.StartsWith(frag) && s.Name.Contains(frag)));

if (ShowSuggestions(SuggestMode.Emoji, tokenStart, caret,
matches.Select(m => new CommandSuggestion
{
Command = ":" + m.Name + ":",
Description = m.Emoji,
InsertText = m.Emoji,
})))
return;
}
}

HideSuggestions();
}

Expand Down Expand Up @@ -844,6 +903,16 @@ private void AcceptSuggestion(CommandSuggestion s)
// folder → drilled listing reopens; file → token ended with a space, stays hidden.
UpdateSuggestions();
}
else if (_suggestMode == SuggestMode.Emoji)
{
var text = InputBox.Text;
var start = Math.Min(_tokenStart, text.Length);
var end = Math.Min(_tokenEnd, text.Length);
var emoji = s.InsertText ?? s.Command;
InputBox.Text = text[..start] + emoji + text[end..];
InputBox.SelectionStart = start + emoji.Length;
HideSuggestions();
}
else
{
InputBox.Text = s.Command + " ";
Expand All @@ -853,6 +922,51 @@ private void AcceptSuggestion(CommandSuggestion s)
InputBox.Focus(FocusState.Programmatic);
}

/// <summary>Curated quick-pick set for the emoji flyout; Win + . remains the full picker.</summary>
private static readonly string[] QuickEmojis =
{
"😀", "😄", "😂", "🤣", "😊", "😉", "😍", "🥰", "😎", "🤓", "🤔", "🙃",
"😅", "😬", "😭", "🥳", "🤯", "😴", "🙄", "😤", "😱", "🫠", "🤗", "🫡",
"👍", "👎", "👌", "🙏", "👏", "💪", "🤝", "✌️", "🤞", "👀", "🧠", "💯",
"🔥", "✨", "🚀", "🎉", "🎯", "💡", "⚡", "⭐", "❤️", "💔", "✅", "❌",
"⚠️", "❓", "❗", "💬", "🐛", "🔧", "🔒", "🔑", "📝", "📌", "📁", "🖥️",
"☕", "🍕", "🎮", "🤖",
};

/// <summary>Slack-style shortcode → emoji. Aliases are separate rows pointing at the same
/// emoji. Names must be lowercase; lookup lowercases the typed fragment.</summary>
private static readonly (string Name, string Emoji)[] EmojiShortcodes =
{
("grinning", "😀"), ("smile", "😄"), ("joy", "😂"), ("rofl", "🤣"),
("blush", "😊"), ("wink", "😉"), ("heart_eyes", "😍"), ("smiling_hearts", "🥰"),
("sunglasses", "😎"), ("coolglasses", "😎"), ("nerd", "🤓"), ("thinking", "🤔"),
("upside_down", "🙃"), ("sweat_smile", "😅"), ("grimacing", "😬"), ("sob", "😭"),
("partying", "🥳"), ("mind_blown", "🤯"), ("sleeping", "😴"), ("eye_roll", "🙄"),
("triumph", "😤"), ("scream", "😱"), ("melting", "🫠"), ("hugs", "🤗"),
("salute", "🫡"), ("thumbsup", "👍"), ("+1", "👍"), ("thumbsdown", "👎"),
("-1", "👎"), ("ok_hand", "👌"), ("pray", "🙏"), ("clap", "👏"),
("muscle", "💪"), ("handshake", "🤝"), ("victory", "✌️"), ("crossed_fingers", "🤞"),
("eyes", "👀"), ("brain", "🧠"), ("100", "💯"), ("fire", "🔥"),
("sparkles", "✨"), ("rocket", "🚀"), ("tada", "🎉"), ("party_popper", "🎉"),
("dart", "🎯"), ("bulb", "💡"), ("idea", "💡"), ("zap", "⚡"),
("star", "⭐"), ("heart", "❤️"), ("broken_heart", "💔"), ("check", "✅"),
("white_check_mark", "✅"), ("x", "❌"), ("cross", "❌"), ("warning", "⚠️"),
("question", "❓"), ("exclamation", "❗"), ("speech_balloon", "💬"), ("bug", "🐛"),
("wrench", "🔧"), ("lock", "🔒"), ("key", "🔑"), ("memo", "📝"),
("note", "📝"), ("pushpin", "📌"), ("pin", "📌"), ("folder", "📁"),
("desktop", "🖥️"), ("coffee", "☕"), ("pizza", "🍕"), ("video_game", "🎮"),
("robot", "🤖"),
};

private void EmojiGrid_ItemClick(object sender, ItemClickEventArgs e)
{
if (e.ClickedItem is not string emoji || !InputBox.IsEnabled) return;
var caret = Math.Min(InputBox.SelectionStart, InputBox.Text.Length);
InputBox.Text = InputBox.Text.Insert(caret, emoji);
InputBox.SelectionStart = caret + emoji.Length;
InputBox.Focus(FocusState.Programmatic);
}

private void HideSuggestions()
{
_suggestMode = SuggestMode.None;
Expand Down Expand Up @@ -1122,6 +1236,7 @@ private void ShowPlanApprovalBar(ApprovalRequest request, Action<string> onChose
// Gate input while the plan is awaiting a decision.
InputBox.IsEnabled = false;
SendButton.IsEnabled = false;
EmojiButton.IsEnabled = false;

PlanApprovalBar.Visibility = Visibility.Visible;
ApprovalStateChanged?.Invoke(this);
Expand Down Expand Up @@ -1153,6 +1268,7 @@ private void HidePlanApprovalBar()
PlanApprovalBar.Visibility = Visibility.Collapsed;
InputBox.IsEnabled = true;
SendButton.IsEnabled = true;
EmojiButton.IsEnabled = true;
ApprovalStateChanged?.Invoke(this);
InputBox.Focus(FocusState.Programmatic);
}
Expand Down
4 changes: 4 additions & 0 deletions src/MandoCode.Desktop/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ public sealed class CommandSuggestion
{
public string Command { get; init; } = "";
public string Description { get; init; } = "";

/// <summary>What accepting the row inserts, when that differs from <see cref="Command"/>
/// (e.g. the ":fire:" row inserts 🔥). Null means insert the command itself.</summary>
public string? InsertText { get; init; }
}

/// <summary>Row model for the snapshot summarizer dropdown — a model name plus whether it's a cloud
Expand Down
Loading
Loading