diff --git a/MinecraftClient/Commands/Tab.cs b/MinecraftClient/Commands/Tab.cs new file mode 100644 index 00000000..b5ea5c36 --- /dev/null +++ b/MinecraftClient/Commands/Tab.cs @@ -0,0 +1,50 @@ +using Avalonia.Threading; +using Brigadier.NET; +using Brigadier.NET.Builder; +using MinecraftClient.CommandHandler; +using MinecraftClient.Tui; + +namespace MinecraftClient.Commands +{ + public class Tab : Command + { + public override string CmdName => "tab"; + public override string CmdUsage => "tab"; + public override string CmdDesc => Translations.cmd_tab_desc; + + public override void RegisterCommand(CommandDispatcher dispatcher) + { + dispatcher.Register(l => l.Literal("help") + .Then(l => l.Literal(CmdName) + .Executes(r => GetUsage(r.Source))) + ); + + dispatcher.Register(l => l.Literal(CmdName) + .Executes(r => ShowTab(r.Source)) + .Then(l => l.Literal("_help") + .Executes(r => GetUsage(r.Source)) + .Redirect(dispatcher.GetRoot().GetChild("help").GetChild(CmdName))) + ); + } + + private int GetUsage(CmdResult r) => r.SetAndReturn(GetCmdDescTranslated()); + + private static int ShowTab(CmdResult r) + { + McClient handler = CmdResult.currentHandler!; + var snapshot = handler.GetTabListSnapshot(); + + if (ConsoleIO.Backend is TuiConsoleBackend) + { + var view = TuiConsoleBackend.Instance?.GetView(); + if (view is null) + return r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_tab_tui_unavailable); + + Dispatcher.UIThread.Post(() => view.ShowOverlay(new TabListOverlay(handler))); + return r.SetAndReturn(CmdResult.Status.Done, Translations.cmd_tab_tui_opened); + } + + return r.SetAndReturn(CmdResult.Status.Done, TabListFormatter.Render(snapshot)); + } + } +} diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index 4d23bb60..8c3808f1 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -38,6 +38,9 @@ namespace MinecraftClient private readonly Dictionary onlinePlayers = new(); private static bool commandsLoaded = false; + private readonly Lock tabListHeaderFooterLock = new(); + private string tabListHeader = string.Empty; + private string tabListFooter = string.Empty; private readonly Queue chatQueue = new(); private static DateTime nextMessageSendTime = DateTime.MinValue; @@ -1550,6 +1553,86 @@ namespace MinecraftClient return null; } + internal TabListSnapshot GetTabListSnapshot() + { + List<(Guid Uuid, string Name, string DisplayName, int Gamemode, int Ping, int TabListOrder, bool Listed)> players; + lock (onlinePlayers) + { + players = onlinePlayers + .Select(static pair => ( + pair.Key, + pair.Value.Name, + pair.Value.DisplayName ?? string.Empty, + pair.Value.Gamemode, + pair.Value.Ping, + pair.Value.TabListOrder, + pair.Value.Listed)) + .ToList(); + } + + Dictionary teamSnapshot; + lock (teams) + { + teamSnapshot = teams.ToDictionary( + static pair => pair.Key, + static pair => + { + var sourceTeam = pair.Value; + var copy = new PlayerTeam + { + Name = sourceTeam.Name, + DisplayName = sourceTeam.DisplayName, + AllowFriendlyFire = sourceTeam.AllowFriendlyFire, + SeeFriendlyInvisibles = sourceTeam.SeeFriendlyInvisibles, + NameTagVisibility = sourceTeam.NameTagVisibility, + CollisionRule = sourceTeam.CollisionRule, + Color = sourceTeam.Color, + Prefix = sourceTeam.Prefix, + Suffix = sourceTeam.Suffix + }; + + foreach (string member in sourceTeam.Members) + copy.Members.Add(member); + + return copy; + }, + StringComparer.OrdinalIgnoreCase); + } + + string header; + string footer; + lock (tabListHeaderFooterLock) + { + header = tabListHeader; + footer = tabListFooter; + } + + var entries = players + .Select(player => + { + PlayerTeam? team = teamSnapshot.Values.FirstOrDefault( + team => team.Members.Contains(player.Name)); + + string displayName = !string.IsNullOrWhiteSpace(player.DisplayName) + ? player.DisplayName + : TabListFormatter.FormatTeamMemberName(player.Name, team); + + return new TabListEntry( + player.Uuid, + player.Name, + displayName, + team?.Name ?? string.Empty, + !string.IsNullOrWhiteSpace(team?.DisplayName) ? team.DisplayName : team?.Name ?? string.Empty, + player.Gamemode, + player.Ping, + player.TabListOrder, + player.Listed); + }) + .ToList(); + + return new TabListSnapshot(header, footer, entries); + } + public PlayerKeyPair? GetPlayerKeyPair() { return playerKeyPair; @@ -4260,6 +4343,11 @@ namespace MinecraftClient /// Footer public void OnTabListHeaderAndFooter(string header, string footer) { + lock (tabListHeaderFooterLock) + { + tabListHeader = header; + tabListFooter = footer; + } DispatchBotEvent(bot => bot.OnTabListHeaderAndFooter(header, footer)); } diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs index d5549330..a8b1f6b4 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol18.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs @@ -2138,7 +2138,7 @@ namespace MinecraftClient.Protocol.Handlers // Consume all action-selected fields to keep entry boundaries aligned. if (protocolVersion >= MC_1_21_2_Version && (actionBitset & 1 << 6) > 0) // Actions bit 6: update list order - dataTypes.ReadNextVarInt(packetData); + player.TabListOrder = dataTypes.ReadNextVarInt(packetData); if (protocolVersion >= MC_1_21_4_Version && (actionBitset & 1 << 7) > 0) // Actions bit 7: update hat dataTypes.ReadNextBool(packetData); diff --git a/MinecraftClient/Protocol/PlayerInfo.cs b/MinecraftClient/Protocol/PlayerInfo.cs index 66e2441c..c0e211d6 100644 --- a/MinecraftClient/Protocol/PlayerInfo.cs +++ b/MinecraftClient/Protocol/PlayerInfo.cs @@ -22,6 +22,8 @@ namespace MinecraftClient.Protocol public bool Listed = true; + public int TabListOrder; + // Entity info public Mapping.Entity? entity; @@ -73,6 +75,7 @@ namespace MinecraftClient.Protocol Uuid = uuid; Gamemode = -1; Ping = 0; + TabListOrder = 0; lastMessageVerified = true; precedingSignature = null; } diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx index 22028071..4ff326dd 100644 --- a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx +++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx @@ -1002,6 +1002,12 @@ Note: This does NOT require a Bot Token, only an Application ID. Discord must be Cave rendering mode: "auto" (detect ceiling), "on" (always cave view), "off" (always surface view). + + Settings for the /tab command and live TUI tab overlay. + + + Show a separate team column in /tab output. Disabled by default for a more vanilla-like player list. + Yggdrasil authlib multi-user selection. diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index 4eb1a044..9a9f0879 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -4178,6 +4178,78 @@ namespace MinecraftClient { return ResourceManager.GetString("cmd.list.players", resourceCulture); } } + + /// + /// Looks up a localized string similar to show a vanilla-like tab list. In TUI mode, opens a live overlay.. + /// + internal static string cmd_tab_desc { + get { + return ResourceManager.GetString("cmd.tab.desc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No listed players are currently tracked.. + /// + internal static string cmd_tab_no_players { + get { + return ResourceManager.GetString("cmd.tab.no_players", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Tab list ({0} players). + /// + internal static string cmd_tab_title { + get { + return ResourceManager.GetString("cmd.tab.title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Ping. + /// + internal static string cmd_tab_column_ping { + get { + return ResourceManager.GetString("cmd.tab.column_ping", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Team. + /// + internal static string cmd_tab_column_team { + get { + return ResourceManager.GetString("cmd.tab.column_team", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Player. + /// + internal static string cmd_tab_column_player { + get { + return ResourceManager.GetString("cmd.tab.column_player", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Live tab overlay opened.. + /// + internal static string cmd_tab_tui_opened { + get { + return ResourceManager.GetString("cmd.tab.tui_opened", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The tab overlay is not available right now.. + /// + internal static string cmd_tab_tui_unavailable { + get { + return ResourceManager.GetString("cmd.tab.tui_unavailable", resourceCulture); + } + } /// /// Looks up a localized string similar to log some text to the console.. @@ -4746,7 +4818,7 @@ namespace MinecraftClient { return ResourceManager.GetString("cmd.teams.team_no_members", resourceCulture); } } - + /// /// Looks up a localized string similar to Place a block or open chest. /// @@ -6896,6 +6968,15 @@ namespace MinecraftClient { } } + /// + /// Looks up a localized string similar to Press Esc to close. Refreshes automatically.. + /// + internal static string tui_tab_hint { + get { + return ResourceManager.GetString("tui.tab.hint", resourceCulture); + } + } + /// /// Looks up a localized string similar to You're now under {0} effect (Duration: {1}).. /// diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index 779572d9..18b25cea 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -1447,6 +1447,30 @@ Note that parameters in '[]' are optional. PlayerList: {0} + + show a vanilla-like tab list. In TUI mode, opens a live overlay. + + + No listed players are currently tracked. + + + Tab list ({0} players) + + + Ping + + + Team + + + Player + + + Live tab overlay opened. + + + The tab overlay is not available right now. + log some text to the console. @@ -2437,6 +2461,9 @@ see item details. {0} items + + Press Esc to close. Refreshes automatically. + You're now under {0} effect (Duration: {1}). diff --git a/MinecraftClient/Settings.cs b/MinecraftClient/Settings.cs index 4a9b9c6c..724d2809 100644 --- a/MinecraftClient/Settings.cs +++ b/MinecraftClient/Settings.cs @@ -1114,6 +1114,9 @@ namespace MinecraftClient [TomlPrecedingComment("$Console.Minimap$")] public MinimapConfig Minimap = new(); + [TomlPrecedingComment("$Console.TabList$")] + public TabListConfig TabList = new(); + public void OnSettingUpdate() { var backend = ConsoleIO.Backend; @@ -1305,6 +1308,13 @@ namespace MinecraftClient Tui.MinimapControl.MinRefreshMs, Tui.MinimapControl.MaxRefreshMs); } } + + [TomlDoNotInlineObject] + public class TabListConfig + { + [TomlInlineComment("$Console.TabList.ShowTeams$")] + public bool ShowTeams = false; + } } } diff --git a/MinecraftClient/TabList/TabListFormatter.cs b/MinecraftClient/TabList/TabListFormatter.cs new file mode 100644 index 00000000..633cc46f --- /dev/null +++ b/MinecraftClient/TabList/TabListFormatter.cs @@ -0,0 +1,310 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using MinecraftClient.Mapping; +using MinecraftClient.Scripting; + +namespace MinecraftClient +{ + internal sealed record TabListSnapshot(string Header, string Footer, IReadOnlyList Entries); + + internal sealed record TabListEntry( + Guid Uuid, + string Name, + string DisplayName, + string TeamName, + string TeamDisplayName, + int Gamemode, + int Ping, + int TabListOrder, + bool Listed); + + internal static class TabListFormatter + { + private const int MaxRowsPerColumn = 20; + private const int PingColumnWidth = 11; + private const int ColumnGapWidth = 4; + + public static string FormatTeamMemberName(string playerName, PlayerTeam? team) + { + ArgumentException.ThrowIfNullOrWhiteSpace(playerName); + + if (team is null) + return playerName; + + var sb = new StringBuilder(); + sb.Append(team.Prefix); + + string colorCode = TeamColorToTag(team.Color); + if (!string.IsNullOrEmpty(colorCode)) + sb.Append(colorCode); + + sb.Append(playerName); + sb.Append(team.Suffix); + return sb.ToString(); + } + + public static string Render(TabListSnapshot snapshot, bool includeOverlayHint = false) + { + ArgumentNullException.ThrowIfNull(snapshot); + bool showTeams = Settings.Config.Console.TabList.ShowTeams; + + var lines = new List + { + $"§e{string.Format(Translations.cmd_tab_title, snapshot.Entries.Count)}§r" + }; + + AppendSection(lines, snapshot.Header); + + var listedEntries = snapshot.Entries + .Where(static entry => entry.Listed && !string.IsNullOrWhiteSpace(entry.Name)) + .OrderBy(static entry => entry.TabListOrder) + .ThenBy(static entry => entry.Gamemode == 3 ? 1 : 0) + .ThenBy(static entry => entry.TeamName, StringComparer.OrdinalIgnoreCase) + .ThenBy(static entry => entry.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (listedEntries.Count == 0) + { + lines.Add($"§7{Translations.cmd_tab_no_players}§r"); + } + else + { + lines.AddRange(BuildTableLines(listedEntries, showTeams)); + } + + AppendSection(lines, snapshot.Footer); + + if (includeOverlayHint) + { + lines.Add(string.Empty); + lines.Add($"§8{Translations.tui_tab_hint}§r"); + } + + return string.Join('\n', lines); + } + + private static void AppendSection(List lines, string text) + { + if (string.IsNullOrWhiteSpace(text)) + return; + + foreach (string line in text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n')) + { + if (!string.IsNullOrWhiteSpace(line)) + lines.Add(line); + } + } + + private static List BuildTableLines(IReadOnlyList entries, bool showTeams) + { + int columns = 1; + int rows = entries.Count; + while (rows > MaxRowsPerColumn) + { + columns++; + rows = (entries.Count + columns - 1) / columns; + } + + int teamColumnWidth = showTeams + ? Math.Max( + GetVisibleLength(Translations.cmd_tab_column_team), + entries.Max(static entry => GetVisibleLength(GetTeamLabel(entry)))) + : 0; + + string headerRow = BuildRow( + $"§8{Translations.cmd_tab_column_ping}§r", + showTeams ? $"§8{Translations.cmd_tab_column_team}§r" : null, + $"§8{Translations.cmd_tab_column_player}§r", + teamColumnWidth); + + List renderedRows = entries + .Select(entry => BuildRow( + GetPingCell(entry.Ping), + showTeams ? GetTeamLabel(entry) : null, + GetPlayerLabel(entry), + teamColumnWidth)) + .ToList(); + + int[] columnWidths = new int[columns]; + for (int column = 0; column < columns; column++) + { + int width = GetVisibleLength(headerRow); + for (int row = 0; row < rows; row++) + { + int index = row + (column * rows); + if (index >= renderedRows.Count) + break; + + width = Math.Max(width, GetVisibleLength(renderedRows[index])); + } + columnWidths[column] = width; + } + + var lines = new List { CombineColumns(headerRow, rows, columns, columnWidths) }; + for (int row = 0; row < rows; row++) + lines.Add(CombineColumns(renderedRows, row, rows, columns, columnWidths)); + + return lines; + } + + private static string CombineColumns(string headerRow, int rows, int columns, IReadOnlyList columnWidths) + { + var perColumnValues = new string[columns]; + for (int column = 0; column < columns; column++) + perColumnValues[column] = headerRow; + + return CombineColumns(perColumnValues, columnWidths); + } + + private static string CombineColumns(IReadOnlyList renderedRows, int row, int rows, int columns, IReadOnlyList columnWidths) + { + var perColumnValues = new string[columns]; + for (int column = 0; column < columns; column++) + { + int index = row + (column * rows); + perColumnValues[column] = index < renderedRows.Count ? renderedRows[index] : string.Empty; + } + + return CombineColumns(perColumnValues, columnWidths); + } + + private static string CombineColumns(IReadOnlyList parts, IReadOnlyList widths) + { + var sb = new StringBuilder(); + for (int index = 0; index < parts.Count; index++) + { + if (index > 0) + sb.Append(' ', ColumnGapWidth); + + sb.Append(PadFormattedRight(parts[index], widths[index])); + } + return sb.ToString().TrimEnd(); + } + + private static string BuildRow(string pingCell, string? teamCell, string playerCell, int teamColumnWidth) + { + var sb = new StringBuilder(); + sb.Append(PadFormattedRight(pingCell, PingColumnWidth)); + sb.Append(" "); + if (!string.IsNullOrEmpty(teamCell)) + { + sb.Append(PadFormattedRight(teamCell, teamColumnWidth)); + sb.Append(" "); + } + sb.Append(playerCell); + return sb.ToString(); + } + + private static string GetPingCell(int ping) + { + string barColor; + int filledBars; + + if (ping < 0) + { + barColor = "§8"; + filledBars = 0; + } + else if (ping < 150) + { + barColor = "§a"; + filledBars = 5; + } + else if (ping < 300) + { + barColor = "§e"; + filledBars = 4; + } + else if (ping < 600) + { + barColor = "§6"; + filledBars = 3; + } + else if (ping < 1000) + { + barColor = "§c"; + filledBars = 2; + } + else + { + barColor = "§4"; + filledBars = 1; + } + + string numericPing = ping >= 0 ? $"{Math.Min(ping, 9999),4}ms" : " ???ms"; + return $"{barColor}{new string('|', filledBars)}§8{new string('.', 5 - filledBars)}§r {numericPing}"; + } + + private static string GetTeamLabel(TabListEntry entry) + { + if (string.IsNullOrWhiteSpace(entry.TeamName)) + return "§8-§r"; + + if (!string.IsNullOrWhiteSpace(entry.TeamDisplayName)) + return entry.TeamDisplayName; + + if (LooksLikeOpaqueTeamName(entry.TeamName)) + return "§8-§r"; + + return entry.TeamName; + } + + private static string GetPlayerLabel(TabListEntry entry) + { + string label = string.IsNullOrWhiteSpace(entry.DisplayName) + ? entry.Name + : entry.DisplayName; + + if (entry.Gamemode == 3) + return $"§7§o{label}§r"; + + return label; + } + + private static string PadFormattedRight(string text, int totalWidth) + { + int visibleLength = GetVisibleLength(text); + if (visibleLength >= totalWidth) + return text; + + return text + new string(' ', totalWidth - visibleLength); + } + + private static int GetVisibleLength(string text) => ChatBot.GetVerbatim(text).Length; + + private static bool LooksLikeOpaqueTeamName(string text) + { + if (Guid.TryParse(text, out _)) + return true; + + int hyphenCount = text.Count(static ch => ch == '-'); + if (text.Length >= 24 && hyphenCount >= 3) + return true; + + return false; + } + + private static string TeamColorToTag(int color) => color switch + { + 0 => "§0", + 1 => "§1", + 2 => "§2", + 3 => "§3", + 4 => "§4", + 5 => "§5", + 6 => "§6", + 7 => "§7", + 8 => "§8", + 9 => "§9", + 10 => "§a", + 11 => "§b", + 12 => "§c", + 13 => "§d", + 14 => "§e", + 15 => "§f", + _ => string.Empty + }; + } +} diff --git a/MinecraftClient/Tui/TabListOverlay.cs b/MinecraftClient/Tui/TabListOverlay.cs new file mode 100644 index 00000000..6e4c5c29 --- /dev/null +++ b/MinecraftClient/Tui/TabListOverlay.cs @@ -0,0 +1,92 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; + +namespace MinecraftClient.Tui +{ + internal sealed class TabListOverlay : Border + { + private readonly McClient _handler; + private readonly ScrollViewer _scrollViewer; + private readonly DispatcherTimer _refreshTimer; + + public TabListOverlay(McClient handler) + { + ArgumentNullException.ThrowIfNull(handler); + _handler = handler; + + BorderBrush = Brushes.White; + BorderThickness = new Thickness(1); + Background = Brushes.Black; + Padding = new Thickness(1); + HorizontalAlignment = HorizontalAlignment.Stretch; + VerticalAlignment = VerticalAlignment.Stretch; + Focusable = true; + + _scrollViewer = new ScrollViewer + { + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + Focusable = true, + }; + + Child = _scrollViewer; + + _refreshTimer = new DispatcherTimer(TimeSpan.FromMilliseconds(500), DispatcherPriority.Background, static (_, _) => { }) + { + IsEnabled = false + }; + _refreshTimer.Tick += (_, _) => Refresh(); + + AttachedToVisualTree += (_, _) => + { + AddHandler(KeyDownEvent, OnTunnelKeyDown, RoutingStrategies.Tunnel); + Refresh(); + _refreshTimer.Start(); + Focus(); + Dispatcher.UIThread.Post(() => _scrollViewer.Focus(), DispatcherPriority.Input); + }; + + DetachedFromVisualTree += (_, _) => + { + RemoveHandler(KeyDownEvent, OnTunnelKeyDown); + _refreshTimer.Stop(); + }; + } + + private void OnTunnelKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key != Key.Escape) + return; + + TuiConsoleBackend.Instance?.DismissOverlay(); + e.Handled = true; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + TuiConsoleBackend.Instance?.DismissOverlay(); + e.Handled = true; + return; + } + + base.OnKeyDown(e); + } + + private void Refresh() + { + string text = TabListFormatter.Render(_handler.GetTabListSnapshot(), includeOverlayHint: true); + var block = McColorParser.CreateColoredTextBlock(text, TextWrapping.NoWrap); + block.Margin = new Thickness(0); + _scrollViewer.Content = block; + } + } +} diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 6a9812b5..acb1ba55 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1183,6 +1183,40 @@ Coordinate = { x = 145, y = 64, z = 2045 } - **Default:** `30` +### Console TabList section + +- **Section header:** `Console.TabList` + +- **Description:** + + Settings for the `/tab` command and the live tab overlay in TUI mode. + +
+Tab list settings + +#### `ShowTeams` + +- **Description:** + + Show a separate team column in `/tab` output. + + This is disabled by default so `/tab` stays closer to the in-game player list and keeps the output compact. Team formatting still applies to player names even when the extra column is hidden. + + When enabled, MCC shows the team display name when the server provides one. If the server only sends an internal team identifier, MCC hides that noise instead of printing a raw UUID-like value. + +- **Type:** `boolean` + +- **Default:** `false` + +- **Example:** + + ```toml + [Console.TabList] + ShowTeams = true + ``` + +
+ #### `Max_Displayed_Suggestions` - **Description:** diff --git a/docs/guide/usage.md b/docs/guide/usage.md index 24478acb..270bca36 100644 --- a/docs/guide/usage.md +++ b/docs/guide/usage.md @@ -879,6 +879,32 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q +
+tab + +- **Description:** + + Show the current player tab list in a more detailed format than `/list`. + + In the classic console, `/tab` prints a colored table with ping and player names. Team prefixes, suffixes, and display names are applied when the server sends them. + + In TUI mode, `/tab` opens a live overlay that refreshes automatically while it is visible. Press `Esc` to close it. + + If you want a separate team column, enable [Console.TabList.ShowTeams](configuration.md#showteams). + +- **Usage:** + + ``` + /tab + ``` + +- **Notes:** + + - `/tab` uses the tab list information sent by the server, so players hidden from the server tab list will not appear here. + - The TUI overlay follows the live player list, so joins, leaves, ping updates, and scoreboard team updates show up without reopening it. + +
+
set