Cosmetic changes, UX/UI improvements in TUI mode, added colored text support in dialogs

This commit is contained in:
Anon 2026-06-14 02:11:04 +02:00
parent 2aa85cbb17
commit c0d983272b
9 changed files with 321 additions and 105 deletions

View file

@ -1,12 +1,81 @@
using System;
using System.Text.RegularExpressions;
namespace MinecraftClient
{
/// <summary>
/// Console backend wrapping the ConsoleInteractive library (existing behavior).
/// </summary>
public class ClassicConsoleBackend : IConsoleBackend
public partial class ClassicConsoleBackend : IConsoleBackend
{
private static readonly (byte R, byte G, byte B, char Code)[] McStandardColors =
[
(0, 0, 0, '0'), // black
(0, 0, 170, '1'), // dark_blue
(0, 170, 0, '2'), // dark_green
(0, 170, 170, '3'), // dark_aqua
(170, 0, 0, '4'), // dark_red
(170, 0, 170, '5'), // dark_purple
(255, 170, 0, '6'), // gold
(170, 170, 170, '7'), // gray
(85, 85, 85, '8'), // dark_gray
(85, 85, 255, '9'), // blue
(85, 255, 85, 'a'), // green
(85, 255, 255, 'b'), // aqua
(255, 85, 85, 'c'), // red
(255, 85, 255, 'd'), // light_purple
(255, 255, 85, 'e'), // yellow
(255, 255, 255, 'f'), // white
];
[GeneratedRegex("§#([0-9a-fA-F]{6})")]
private static partial Regex HexColorRegex();
private static char NearestMcColor(byte r, byte g, byte b)
{
int bestIdx = 0;
long bestDist = long.MaxValue;
for (int i = 0; i < McStandardColors.Length; i++)
{
var (sr, sg, sb, _) = McStandardColors[i];
long dr = r - sr;
long dg = g - sg;
long db = b - sb;
long dist = dr * dr + dg * dg + db * db;
if (dist < bestDist)
{
bestDist = dist;
bestIdx = i;
}
}
return McStandardColors[bestIdx].Code;
}
private static string ResolveHexColors(string text)
{
if (string.IsNullOrEmpty(text) || !text.Contains("§#", StringComparison.Ordinal))
return text;
return HexColorRegex().Replace(text, match =>
{
ReadOnlySpan<char> hex = match.Groups[1].ValueSpan;
byte r = (byte)((HexVal(hex[0]) << 4) | HexVal(hex[1]));
byte g = (byte)((HexVal(hex[2]) << 4) | HexVal(hex[3]));
byte b = (byte)((HexVal(hex[4]) << 4) | HexVal(hex[5]));
return $"§{NearestMcColor(r, g, b)}";
});
}
private static int HexVal(char c) => c switch
{
>= '0' and <= '9' => c - '0',
>= 'a' and <= 'f' => c - 'a' + 10,
>= 'A' and <= 'F' => c - 'A' + 10,
_ => 0
};
public event EventHandler<string>? MessageReceived;
public event EventHandler<ConsoleInputBuffer>? OnInputChange;
@ -28,7 +97,7 @@ namespace MinecraftClient
public void WriteLineFormatted(string text)
{
ConsoleInteractive.ConsoleWriter.WriteLineFormatted(text);
ConsoleInteractive.ConsoleWriter.WriteLineFormatted(ResolveHexColors(text));
}
public void BeginReadThread()

View file

@ -53,9 +53,11 @@ public class Dialog : Command
{
var handler = CmdResult.currentHandler!;
var current = handler.Dialogs.Current;
return current is null
? r.SetAndReturn(CmdResult.Status.Fail, Translations.dialog_none)
: r.SetAndReturn(CmdResult.Status.Done, DialogFormatter.Render(current));
if (current is null)
return r.SetAndReturn(CmdResult.Status.Fail, Translations.dialog_none);
ConsoleIO.WriteLineFormatted(DialogFormatter.Render(current), acceptnewlines: true);
return r.SetAndReturn(CmdResult.Status.Done);
}
private static int Open(CmdResult r)

View file

@ -11,17 +11,21 @@ public static class DialogFormatter
if (!string.IsNullOrWhiteSpace(definition.ExternalTitle))
return definition.ExternalTitle!;
return string.IsNullOrWhiteSpace(definition.Title) ? definition.Type : definition.Title;
return string.IsNullOrWhiteSpace(definition.Title) ? DisplayType(definition.Type) : definition.Title;
}
private const int BoxWidth = 50;
public static string Render(DialogInstance instance)
{
StringBuilder builder = new();
builder.AppendLine(string.Format(Translations.dialog_render_header, instance.Revision, instance.Phase, instance.Definition.DisplayTitle()));
builder.AppendLine(string.Format(Translations.dialog_render_type, DisplayType(instance.Definition.Type)));
string border = new('-', BoxWidth);
builder.AppendLine(border);
builder.AppendLine(" " + string.Format(Translations.dialog_render_header, instance.Revision, instance.Phase, instance.Definition.DisplayTitle()));
builder.AppendLine(border);
foreach (var body in instance.Definition.Body.Where(static body => !string.IsNullOrWhiteSpace(body.Text)))
builder.AppendLine(string.Format(Translations.dialog_render_body, body.Text));
builder.AppendLine(body.Text);
if (instance.Definition.Inputs.Count > 0)
{
@ -30,7 +34,7 @@ public static class DialogFormatter
{
instance.Values.TryGetValue(input.Key, out var value);
value ??= input.InitialValue;
builder.AppendLine(string.Format(Translations.dialog_render_input, input.Key, input.Kind, input.Label, value, DescribeInput(input)));
builder.AppendLine(string.Format(Translations.dialog_render_input, input.Key, DescribeKind(input.Kind), input.Label, value, DescribeInput(input)));
}
}
@ -41,9 +45,7 @@ public static class DialogFormatter
builder.AppendLine(string.Format(Translations.dialog_render_action, action.Index, action.Label, DescribeAction(action.Action)));
}
if (instance.Definition.CancelAction is not null || instance.Definition.CanCloseWithEscape)
builder.AppendLine(Translations.dialog_render_cancel_hint);
builder.Append(border);
return builder.ToString();
}
@ -60,6 +62,18 @@ public static class DialogFormatter
};
}
private static string DescribeKind(DialogInputKind kind)
{
return kind switch
{
DialogInputKind.Text => Translations.dialog_input_kind_text,
DialogInputKind.Boolean => Translations.dialog_input_kind_boolean,
DialogInputKind.SingleOption => Translations.dialog_input_kind_options,
DialogInputKind.NumberRange => Translations.dialog_input_kind_number,
_ => Translations.dialog_input_kind_unknown
};
}
private static string DescribeInput(DialogInput input)
{
return input.Kind switch

View file

@ -72,7 +72,7 @@ public sealed class DialogManager
_current = instance;
}
_client.Log.Info(string.Format(Translations.dialog_received, instance.Definition.DisplayTitle()));
_client.Log.Info("§e" + string.Format(Translations.dialog_received, instance.Definition.DisplayTitle()));
DialogShown?.Invoke(instance);
return instance;
}

View file

@ -79,9 +79,6 @@ public sealed class DialogNbtParser
var body = ParseBody(GetValue(nbt, "body"));
var inputs = ParseInputs(GetValue(nbt, "inputs"));
if (string.IsNullOrWhiteSpace(title))
title = type;
return new DialogCommon(title, externalTitle, canCloseWithEscape, pause, afterAction, body, inputs);
}

View file

@ -8101,5 +8101,25 @@ namespace MinecraftClient {
get { return ResourceManager.GetString("dialog.type.unknown", resourceCulture); }
}
internal static string dialog_input_kind_text {
get { return ResourceManager.GetString("dialog.input_kind.text", resourceCulture); }
}
internal static string dialog_input_kind_boolean {
get { return ResourceManager.GetString("dialog.input_kind.boolean", resourceCulture); }
}
internal static string dialog_input_kind_options {
get { return ResourceManager.GetString("dialog.input_kind.options", resourceCulture); }
}
internal static string dialog_input_kind_number {
get { return ResourceManager.GetString("dialog.input_kind.number", resourceCulture); }
}
internal static string dialog_input_kind_unknown {
get { return ResourceManager.GetString("dialog.input_kind.unknown", resourceCulture); }
}
}
}

View file

@ -2834,7 +2834,7 @@ see item details.</value>
<value>View and interact with the current server custom dialog.</value>
</data>
<data name="dialog.received" xml:space="preserve">
<value>Server showed custom dialog: {0}. Use dialog show.</value>
<value>Server showed custom dialog: {0}. Use /dialog show.</value>
</data>
<data name="dialog.cleared" xml:space="preserve">
<value>Server cleared the custom dialog.</value>
@ -2993,13 +2993,13 @@ see item details.</value>
<value> [{0}] {1} ({2})</value>
</data>
<data name="dialog.render.cancel_hint" xml:space="preserve">
<value>Use dialog cancel to close or run the cancel action.</value>
<value>Use /dialog cancel to close or run the cancel action.</value>
</data>
<data name="dialog.input_desc_text" xml:space="preserve">
<value>max {0} chars</value>
</data>
<data name="dialog.input_desc_boolean" xml:space="preserve">
<value>true={0}, false={1}</value>
<value>Yes={0}, No={1}</value>
</data>
<data name="dialog.input_desc_options" xml:space="preserve">
<value>options: {0}</value>
@ -3041,7 +3041,7 @@ see item details.</value>
<value>Confirmation</value>
</data>
<data name="dialog.type.multi_action" xml:space="preserve">
<value>Multi-action</value>
<value>Multi Action</value>
</data>
<data name="dialog.type.dialog_list" xml:space="preserve">
<value>Dialog list</value>
@ -3052,4 +3052,19 @@ see item details.</value>
<data name="dialog.type.unknown" xml:space="preserve">
<value>Unknown</value>
</data>
<data name="dialog.input_kind.text" xml:space="preserve">
<value>Text</value>
</data>
<data name="dialog.input_kind.boolean" xml:space="preserve">
<value>Check box</value>
</data>
<data name="dialog.input_kind.options" xml:space="preserve">
<value>Options</value>
</data>
<data name="dialog.input_kind.number" xml:space="preserve">
<value>Number</value>
</data>
<data name="dialog.input_kind.unknown" xml:space="preserve">
<value>Unknown</value>
</data>
</root>

View file

@ -59,20 +59,27 @@ public static class DialogTuiHost
internal sealed class DialogView : Border, IOverlayCloseHandler
{
private static readonly Color AccentColor = Color.FromRgb(80, 180, 255);
private static readonly Color BorderColor = Color.FromRgb(70, 70, 70);
private static readonly Color SectionBg = Color.FromRgb(20, 20, 20);
private static readonly Color InputBg = Color.FromRgb(35, 35, 35);
private readonly McClient _handler;
private readonly DialogInstance _instance;
private readonly TextBlock _status;
private readonly Dictionary<string, Control> _inputControls = new(StringComparer.Ordinal);
private readonly StackPanel _inputsPanel;
private readonly WrapPanel _buttonsPanel;
public DialogView(McClient handler, DialogInstance instance)
{
_handler = handler;
_instance = instance;
BorderBrush = Brushes.White;
BorderBrush = new SolidColorBrush(BorderColor);
BorderThickness = new Thickness(1);
Background = Brushes.Black;
Padding = new Thickness(1);
Background = new SolidColorBrush(Color.FromRgb(12, 12, 12));
Padding = new Thickness(2);
HorizontalAlignment = HorizontalAlignment.Stretch;
VerticalAlignment = VerticalAlignment.Stretch;
Focusable = true;
@ -81,14 +88,18 @@ internal sealed class DialogView : Border, IOverlayCloseHandler
{
Foreground = Brushes.Gray,
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(1, 0)
Margin = new Thickness(0, 1, 0, 0)
};
_inputsPanel = new StackPanel { Spacing = 0, Margin = new Thickness(0) };
_buttonsPanel = new WrapPanel { Orientation = Orientation.Horizontal };
Child = BuildContent();
AttachedToVisualTree += (_, _) =>
{
AddHandler(KeyDownEvent, OnTunnelKeyDown, RoutingStrategies.Tunnel, handledEventsToo: true);
FocusFirstInput();
Focus();
};
DetachedFromVisualTree += (_, _) => RemoveHandler(KeyDownEvent, OnTunnelKeyDown);
@ -112,75 +123,97 @@ internal sealed class DialogView : Border, IOverlayCloseHandler
private Control BuildContent()
{
var main = new StackPanel
var root = new DockPanel { Margin = new Thickness(0) };
var scroll = new ScrollViewer
{
Spacing = 1,
Margin = new Thickness(1)
HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled,
VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto
};
main.Children.Add(new TextBlock
var main = new StackPanel { Spacing = 0, Margin = new Thickness(0) };
// Title
main.Children.Add(McColorParser.CreateColoredTextBlock(_instance.Definition.DisplayTitle()));
// Separator
main.Children.Add(new Border
{
Text = _instance.Definition.DisplayTitle(),
Foreground = Brushes.Yellow,
FontWeight = FontWeight.Bold,
TextWrapping = TextWrapping.Wrap
Height = 1,
Background = new SolidColorBrush(BorderColor),
Margin = new Thickness(0, 1, 0, 1)
});
// Body
foreach (var body in _instance.Definition.Body)
{
if (string.IsNullOrWhiteSpace(body.Text))
continue;
main.Children.Add(McColorParser.CreateColoredTextBlock(body.Text));
}
// Build action buttons
EnsureActionButtons();
// Inputs
foreach (var input in _instance.Definition.Inputs)
main.Children.Add(BuildInput(input));
// Action buttons
if (_buttonsPanel.Children.Count > 0)
main.Children.Add(_buttonsPanel);
// Cancel hint
if (_instance.Definition.CancelAction is not null || _instance.Definition.CanCloseWithEscape)
{
main.Children.Add(new TextBlock
{
Text = body.Text,
Foreground = Brushes.White,
Text = Translations.dialog_render_cancel_hint,
Foreground = new SolidColorBrush(Color.FromRgb(120, 120, 120)),
TextWrapping = TextWrapping.Wrap
});
}
foreach (var input in _instance.Definition.Inputs)
main.Children.Add(BuildInput(input));
main.Children.Add(_status);
scroll.Content = main;
root.Children.Add(scroll);
var buttons = new WrapPanel
return root;
}
private void EnsureActionButtons()
{
Orientation = Orientation.Horizontal
};
if (_buttonsPanel.Children.Count > 0)
return;
foreach (var action in _instance.Definition.Actions)
{
var button = new Button
var btn = new Button
{
Content = action.Label,
Margin = new Thickness(0, 0, 1, 1),
MinWidth = Math.Max(8, Math.Min(action.Label.Length + 4, 32))
Content = McColorParser.CreateColoredTextBlock(action.Label),
Padding = new Thickness(1),
BorderThickness = new Thickness(1),
BorderBrush = new SolidColorBrush(Color.FromRgb(60, 60, 60)),
Background = new SolidColorBrush(Color.FromRgb(40, 40, 40)),
Margin = new Thickness(0, 0, 1, 1)
};
button.Click += (_, _) => Click(action.Index);
buttons.Children.Add(button);
btn.Click += (_, _) => Click(action.Index);
_buttonsPanel.Children.Add(btn);
}
if (_instance.Definition.CancelAction is not null || _instance.Definition.CanCloseWithEscape)
{
var cancel = new Button
{
Content = Translations.tui_dialog_cancel,
Margin = new Thickness(0, 0, 1, 1)
Content = McColorParser.CreateColoredTextBlock(Translations.tui_dialog_cancel),
Padding = new Thickness(1),
BorderThickness = new Thickness(1),
BorderBrush = new SolidColorBrush(Color.FromRgb(80, 40, 40)),
Background = new SolidColorBrush(Color.FromRgb(50, 25, 25))
};
cancel.Click += (_, _) => TryCloseByUser();
buttons.Children.Add(cancel);
_buttonsPanel.Children.Add(cancel);
}
if (buttons.Children.Count > 0)
main.Children.Add(buttons);
main.Children.Add(_status);
return new ScrollViewer
{
Content = main,
HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled,
VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto
};
}
private Control BuildInput(DialogInput input)
@ -188,84 +221,125 @@ internal sealed class DialogView : Border, IOverlayCloseHandler
_instance.Values.TryGetValue(input.Key, out var value);
value ??= input.InitialValue;
var panel = new StackPanel
{
Spacing = 0,
Margin = new Thickness(0, 1)
};
var panel = new StackPanel { Spacing = 0, Margin = new Thickness(0) };
if (input.LabelVisible && !string.IsNullOrWhiteSpace(input.Label))
{
panel.Children.Add(new TextBlock
{
Text = input.Label,
Foreground = Brushes.LightGray,
TextWrapping = TextWrapping.Wrap
});
}
panel.Children.Add(McColorParser.CreateColoredTextBlock(input.Label));
Control control = input.Kind switch
Control inner = input.Kind switch
{
DialogInputKind.Boolean => BuildBooleanInput(value),
DialogInputKind.Boolean => BuildBooleanInput(value, input),
DialogInputKind.SingleOption => BuildOptionInput(input, value),
DialogInputKind.NumberRange => BuildNumberInput(input, value),
_ => BuildTextInput(input, value)
};
_inputControls[input.Key] = control;
panel.Children.Add(control);
_inputControls[input.Key] = inner;
if (input.Kind == DialogInputKind.Boolean)
{
panel.Children.Add(inner);
}
else
{
panel.Children.Add(new Border
{
Background = new SolidColorBrush(InputBg),
BorderBrush = new SolidColorBrush(Color.FromRgb(55, 55, 55)),
BorderThickness = new Thickness(1),
Padding = new Thickness(1),
Child = inner
});
}
return panel;
}
private static Control BuildTextInput(DialogInput input, string value)
private Control BuildTextInput(DialogInput input, string value)
{
return new TextBox
var tb = new TextBox
{
Text = value,
AcceptsReturn = input.Multiline,
TextWrapping = TextWrapping.Wrap,
TextWrapping = input.Multiline ? TextWrapping.Wrap : TextWrapping.NoWrap,
MaxLength = input.MaxLength,
Foreground = Brushes.White,
Background = Brushes.Black,
BorderBrush = Brushes.Gray
Background = new SolidColorBrush(InputBg),
BorderThickness = new Thickness(0),
Padding = new Thickness(0)
};
return tb;
}
private static Control BuildBooleanInput(string value)
private Control BuildBooleanInput(string value, DialogInput input)
{
return new CheckBox
{
IsChecked = value.Equals("true", StringComparison.OrdinalIgnoreCase),
Foreground = Brushes.White
Foreground = Brushes.White,
Padding = new Thickness(0)
};
}
private static Control BuildOptionInput(DialogInput input, string value)
private Control BuildOptionInput(DialogInput input, string value)
{
var combo = new ComboBox
{
ItemsSource = input.Options ?? [],
Foreground = Brushes.White
Foreground = Brushes.White,
Background = new SolidColorBrush(InputBg),
BorderThickness = new Thickness(0),
Padding = new Thickness(1, 0)
};
combo.SelectionBoxItemTemplate = null;
combo.SelectedItem = input.Options?.FirstOrDefault(option => option.Id.Equals(value, StringComparison.Ordinal))
?? input.Options?.FirstOrDefault();
return combo;
}
private static Control BuildNumberInput(DialogInput input, string value)
private Control BuildNumberInput(DialogInput input, string value)
{
double numValue = double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
? parsed
: input.InitialNumber ?? input.Start;
double min = Math.Min(input.Start, input.End);
double max = Math.Max(input.Start, input.End);
var panel = new DockPanel { LastChildFill = true };
var slider = new Slider
{
Minimum = Math.Min(input.Start, input.End),
Maximum = Math.Max(input.Start, input.End),
Value = double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number)
? number
: input.InitialNumber ?? input.Start,
Minimum = min,
Maximum = max,
Value = numValue,
TickFrequency = input.Step ?? 1,
IsSnapToTickEnabled = input.Step is not null
IsSnapToTickEnabled = input.Step is not null,
Foreground = new SolidColorBrush(AccentColor)
};
return slider;
var label = new TextBlock
{
Text = numValue.ToString(CultureInfo.InvariantCulture),
Foreground = Brushes.White,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(2, 0, 0, 0),
MinWidth = 16
};
slider.PropertyChanged += (_, e) =>
{
if (e.Property == Slider.ValueProperty)
label.Text = ((float)slider.Value).ToString(CultureInfo.InvariantCulture);
};
DockPanel.SetDock(label, Dock.Right);
panel.Children.Add(slider);
panel.Children.Add(label);
return panel;
}
private void Click(int index)
@ -306,6 +380,20 @@ internal sealed class DialogView : Border, IOverlayCloseHandler
return true;
}
private void FocusFirstInput()
{
var first = _inputControls.Values.FirstOrDefault();
if (first is TextBox tb)
{
tb.Focus();
tb.SelectAll();
}
else
{
first?.Focus();
}
}
private void CloseIfInactive()
{
var current = _handler.Dialogs.Current;
@ -326,11 +414,22 @@ internal sealed class DialogView : Border, IOverlayCloseHandler
private void OnTunnelKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key != Key.Escape)
return;
if (e.Key == Key.Escape)
{
TryCloseByUser();
e.Handled = true;
return;
}
if (e.Key == Key.Enter)
{
var focused = TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement();
if (focused is TextBox && _instance.Definition.Actions.Count > 0)
{
Click(_instance.Definition.Actions[0].Index);
e.Handled = true;
}
}
}
private static string NumberToString(float value)

View file

@ -151,14 +151,14 @@ header "1. Notice Dialog"
mc-rcon 'dialog show MCCBot {type:"minecraft:notice", title:{text:"Notice Title"}}' 2>&1 | ansi_strip | grep -v "^$"
assert_dialog_shown "notice dialog" "Notice Title"
write_input "dialog show"
assert_log "notice type" "Type: Notice" 10
assert_log "notice render" "Dialog #1" 10
assert_log "notice OK button" "OK (close)" 3
header "2. Confirmation Dialog"
mc-rcon 'dialog show MCCBot {type:"minecraft:confirmation", title:{text:"Confirm?"}, yes:{label:{text:"Yes"}}, no:{label:{text:"No"}}}' 2>&1 | ansi_strip | grep -v "^$"
assert_dialog_shown "confirmation" "Confirm?"
write_input "dialog show"
assert_log "confirmation type" "Type: Confirmation" 10
assert_log "confirmation render" "Dialog #1" 10
assert_log "yes button" "Yes (close)" 3
assert_log "no button" "No (close)" 3
@ -166,7 +166,7 @@ header "3. Multi-Action Dialog"
mc-rcon 'dialog show MCCBot {type:"minecraft:multi_action", title:{text:"Choose"}, actions:[{label:{text:"Alpha"}}, {label:{text:"Beta"}}, {label:{text:"Gamma"}}]}' 2>&1 | ansi_strip | grep -v "^$"
assert_dialog_shown "multi_action" "Choose"
write_input "dialog show"
assert_log "multi_action type" "Type: Multi-action" 10
assert_log "multi_action render" "Dialog #1" 10
assert_log "multi_action button 1" "Alpha (close)" 3
assert_log "multi_action button 2" "Beta (close)" 3
assert_log "multi_action button 3" "Gamma (close)" 3
@ -175,7 +175,7 @@ header "4. Dialog-List Dialog"
mc-rcon 'dialog show MCCBot {type:"minecraft:dialog_list", title:{text:"List"}, dialogs:[{type:"minecraft:notice", title:{text:"Sub One"}}, {type:"minecraft:notice", title:{text:"Sub Two"}}]}' 2>&1 | ansi_strip | grep -v "^$"
assert_dialog_shown "dialog_list" "List"
write_input "dialog show"
assert_log "dialog_list type" "Type: Dialog list" 10
assert_log "dialog_list render" "Dialog #1" 10
assert_log "dialog_list sub 1" "Sub One (show dialog)" 3
assert_log "dialog_list sub 2" "Sub Two (show dialog)" 3
@ -183,7 +183,7 @@ header "5. Server-Links Dialog"
mc-rcon 'dialog show MCCBot {type:"minecraft:server_links", title:{text:"Links"}}' 2>&1 | ansi_strip | grep -v "^$"
assert_dialog_shown "server_links" "Links"
write_input "dialog show"
assert_log "server_links type" "Type: Server links" 10
assert_log "server_links render" "Dialog #1" 10
header "6. Body Content"
mc-rcon 'dialog show MCCBot {type:"minecraft:notice", title:{text:"With Body"}, body:[{type:"minecraft:plain_message", contents:{text:"Hello from body"}}]}' 2>&1 | ansi_strip | grep -v "^$"