diff --git a/docs/changelog/v0.2.9.md b/docs/changelog/v0.2.9.md
index 3fc6db7..0aa286d 100644
--- a/docs/changelog/v0.2.9.md
+++ b/docs/changelog/v0.2.9.md
@@ -3,3 +3,7 @@
## Bug Fixes
- Fix Linux/macOS client install — enable single-file publish so the install script copies one self-contained binary instead of just the native host (which failed with "does not exist: EchoHub.Client.dll")
+
+## New Features
+
+- Command palette — press Ctrl+K to open a searchable command palette for quick navigation and actions (enter channel, open profile, etc.)
diff --git a/docs/todo.md b/docs/todo.md
index eb04e7d..a33c4ca 100644
--- a/docs/todo.md
+++ b/docs/todo.md
@@ -17,7 +17,7 @@
- that means basically multiple servers linked, so users can chat cross-server in this network
- [x] when users clicks public -> private -> public checkbox in the channel creation, it ends up creating the channel on 3rd check switch
- [ ] add keyboard only controls | at least for most important parts and the rest might be accessible with: (down)
-- [ ] add search bar / search modal – that will allow users to instantly navigate to room / focus on app element & etc
+- [x] add search bar / search modal – that will allow users to instantly navigate to room / focus on app element & etc
- [ ] Actually smart data management – cache messages, lazy load messages on scroll (currently hardcoded 100msgs fetched + new ones)
- [x] Another thing would be stateful userlist – basically fetch once and listen for userlist updates
- [x] Send to EchohubSpace only state changes, currently we send user count periodically, instead of updating it on update
diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs
index 5c1f52d..fc47c3c 100644
--- a/src/EchoHub.Client/AppOrchestrator.cs
+++ b/src/EchoHub.Client/AppOrchestrator.cs
@@ -91,6 +91,7 @@ private void WireMainWindowEvents()
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
_mainWindow.OnUserProfileRequested += HandleViewProfile;
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
+ _mainWindow.OnSearchRequested += HandleSearchRequested;
}
// ── Command Handler Wiring ─────────────────────────────────────────────
@@ -733,6 +734,38 @@ private void HandleChannelJoinFromMessage(string channelName)
HandleChannelSelected(channelName);
}
+
+ private void HandleSearchRequested()
+ {
+ var result = SearchDialog.Show(_app, _mainWindow.GetChannelNames());
+ if (result is null) return;
+
+ switch (result.Type)
+ {
+ case SearchResultType.Channel:
+ _mainWindow.SwitchToChannel(result.Key);
+ HandleChannelSelected(result.Key);
+ break;
+
+ case SearchResultType.Action:
+ switch (result.Key)
+ {
+ case "connect": HandleConnect(); break;
+ case "disconnect": HandleDisconnect(); break;
+ case "logout": HandleLogout(); break;
+ case "profile": HandleProfileRequested(); break;
+ case "status": HandleStatusRequested(); break;
+ case "create-channel": HandleCreateChannelRequested(); break;
+ case "delete-channel": HandleDeleteChannelRequested(); break;
+ case "servers": HandleSavedServersRequested(); break;
+ case "toggle-users": _mainWindow.ToggleUsersPanel(); break;
+ case "updates": HandleCheckForUpdatesRequested(); break;
+ case "quit": _app.RequestStop(); break;
+ }
+ break;
+ }
+ }
+
private void HandleProfileRequested()
{
HandleViewProfile(null);
diff --git a/src/EchoHub.Client/UI/Dialogs/SearchDialog.cs b/src/EchoHub.Client/UI/Dialogs/SearchDialog.cs
new file mode 100644
index 0000000..6f7b930
--- /dev/null
+++ b/src/EchoHub.Client/UI/Dialogs/SearchDialog.cs
@@ -0,0 +1,160 @@
+using EchoHub.Client.UI.ListSources;
+
+using System.Collections;
+using System.Collections.Specialized;
+using System.Diagnostics;
+
+using Terminal.Gui.App;
+using Terminal.Gui.Drawing;
+using Terminal.Gui.Input;
+using Terminal.Gui.Text;
+using Terminal.Gui.ViewBase;
+using Terminal.Gui.Views;
+
+namespace EchoHub.Client.UI.Dialogs;
+
+public enum SearchResultType
+{
+ Channel,
+ Action
+}
+
+public record SearchResult(SearchResultType Type, string Key, string Label);
+
+///
+/// Command-palette style search dialog (Ctrl+K) for navigating channels and triggering app actions.
+///
+public static class SearchDialog
+{
+ private static readonly IReadOnlyList DefaultActions = [
+ new(SearchResultType.Action, "connect", "Connect to Server"),
+ new(SearchResultType.Action, "disconnect", "Disconnect"),
+ new(SearchResultType.Action, "logout", "Logout"),
+ new(SearchResultType.Action, "profile", "My Profile"),
+ new(SearchResultType.Action, "status", "Set Status"),
+ new(SearchResultType.Action, "create-channel", "Create Channel"),
+ new(SearchResultType.Action, "delete-channel", "Delete Channel"),
+ new(SearchResultType.Action, "servers", "Saved Servers"),
+ new(SearchResultType.Action, "toggle-users", "Toggle Users Panel"),
+ new(SearchResultType.Action, "updates", "Check for Updates"),
+ new(SearchResultType.Action, "quit", "Quit"),
+ ];
+
+ public static SearchResult? Show(IApplication app, IReadOnlyList channels)
+ {
+ SearchResult? result = null;
+ var source = new SearchListSource(BuildAllItems(channels));
+
+ var dialog = new Dialog
+ {
+ Title = "Search",
+ Width = 59,
+ Height = 22,
+ };
+
+ var hintLabel = new Label
+ {
+ Text = "Channels and actions \u2502 \u2193 to navigate \u2502 Enter to select",
+ X = 1,
+ Y = 1,
+ };
+
+ var searchField = new TextField
+ {
+ X = 1,
+ Y = 2,
+ Title = "Search",
+ Width = Dim.Fill(2),
+ };
+
+ var resultList = new ListView
+ {
+ X = 1,
+ Y = 4,
+ Width = Dim.Fill(2),
+ Height = Dim.Fill(3),
+ Source = source
+ };
+
+ var cancelButton = new Button
+ {
+ Text = "Cancel",
+ X = Pos.Center(),
+ Y = Pos.AnchorEnd(1),
+ };
+
+ if (source.Count > 0)
+ resultList.SelectedItem = 0;
+
+ searchField.KeyDown += (s, e) =>
+ {
+ if (e.KeyCode == Key.K.WithCtrl)
+ {
+ e.Handled = true;
+ app.RequestStop();
+ }
+ };
+
+ searchField.TextChanged += (s, e) =>
+ {
+ source.Filter(searchField.Text ?? string.Empty);
+ resultList.Source = source;
+ if (source.Count > 0)
+ resultList.SelectedItem = 0;
+ };
+
+ searchField.Accepting += (s, e) => TryConfirm(e);
+
+ resultList.Accepting += (s, e) => TryConfirm(e);
+
+ resultList.KeystrokeNavigator.SearchStringChanged += (s, e) =>
+ {
+ app.Invoke(() =>
+ {
+ searchField.SetFocus();
+ });
+ };
+
+ cancelButton.Accepting += (s, e) =>
+ {
+ result = null;
+ e.Handled = true;
+ app.RequestStop();
+ };
+
+ dialog.KeyDown += (s, e) =>
+ {
+ if (e.KeyCode == Key.K.WithCtrl)
+ {
+ e.Handled = true;
+ app.RequestStop();
+ }
+ };
+
+ dialog.Add(hintLabel, searchField, resultList, cancelButton);
+ searchField.SetFocus();
+ app.Run(dialog);
+
+ return result;
+
+ void TryConfirm(CommandEventArgs e)
+ {
+ var idx = resultList.SelectedItem ?? 0;
+ if (source.Count > 0 && idx >= 0 && idx < source.Count)
+ {
+ result = source.GetItem(idx);
+ e.Handled = true;
+ app.RequestStop();
+ }
+ }
+ }
+
+ private static List BuildAllItems(IReadOnlyList channels)
+ {
+ var items = new List();
+ foreach (var ch in channels)
+ items.Add(new SearchResult(SearchResultType.Channel, ch, $"#{ch}"));
+ items.AddRange(DefaultActions);
+ return items;
+ }
+}
diff --git a/src/EchoHub.Client/UI/ListSources/SearchListSource.cs b/src/EchoHub.Client/UI/ListSources/SearchListSource.cs
new file mode 100644
index 0000000..bc57a7a
--- /dev/null
+++ b/src/EchoHub.Client/UI/ListSources/SearchListSource.cs
@@ -0,0 +1,87 @@
+using EchoHub.Client.UI.Chat;
+using EchoHub.Client.UI.Dialogs;
+
+using System.Collections;
+using System.Collections.Specialized;
+
+using Terminal.Gui.Drawing;
+using Terminal.Gui.Text;
+using Terminal.Gui.Views;
+
+using Attribute = Terminal.Gui.Drawing.Attribute;
+
+namespace EchoHub.Client.UI.ListSources;
+
+///
+/// List data source for the search dialog with filtering and colored rendering.
+///
+public class SearchListSource(List items) : IListDataSource
+{
+ private readonly List _allItems = items;
+ private List _filtered = [.. items];
+
+ private static readonly Attribute ChannelAttribute = new(Color.BrightCyan, Color.None);
+ private static readonly Attribute ActionAttribute = new(Color.White, Color.None);
+
+ public event NotifyCollectionChangedEventHandler? CollectionChanged;
+ public int Count => _filtered.Count;
+ public int MaxItemLength => _filtered.Count > 0 ? _filtered.Max(i => i.Label.GetColumns()) : 0;
+ public bool SuspendCollectionChangedEvent { get; set; }
+
+ public void Filter(string query)
+ {
+ if (string.IsNullOrWhiteSpace(query))
+ {
+ _filtered = [.. _allItems];
+ }
+ else
+ {
+ _filtered = [.. _allItems.Where(i =>
+ i.Label.Contains(query, StringComparison.OrdinalIgnoreCase)
+ || i.Key.Contains(query, StringComparison.OrdinalIgnoreCase))];
+ }
+
+ if (!SuspendCollectionChangedEvent)
+ CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
+ }
+
+ public SearchResult? GetItem(int index) => index >= 0 && index < _filtered.Count ? _filtered[index] : null;
+
+ public bool IsMarked(int item) => false;
+ public void SetMark(int item, bool value) { }
+ public IList ToList() => _filtered.Select(i => (object)i.Label).ToList();
+
+ public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
+ {
+ listView.Move(Math.Max(col - viewportX, 0), row);
+
+ var entry = _filtered[item];
+ var fillAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
+
+ Attribute itemAttr;
+ if (selected)
+ {
+ itemAttr = fillAttr;
+ }
+ else
+ {
+ var raw = entry.Type switch
+ {
+ SearchResultType.Channel => ChannelAttribute,
+ SearchResultType.Action => ActionAttribute,
+ _ => fillAttr
+ };
+ itemAttr = raw.Background == Color.None ? raw with { Background = fillAttr.Background } : raw;
+ }
+
+ listView.SetAttribute(itemAttr);
+
+ var drawn = RenderHelpers.WriteText(listView, entry.Label, 0, width);
+
+ listView.SetAttribute(fillAttr);
+ for (var i = drawn; i < width; i++)
+ listView.AddStr(" ");
+ }
+
+ public void Dispose() { }
+}
diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs
index f46e5cc..4bdaddf 100644
--- a/src/EchoHub.Client/UI/MainWindow.cs
+++ b/src/EchoHub.Client/UI/MainWindow.cs
@@ -48,6 +48,7 @@ public sealed partial class MainWindow : Runnable
private static readonly Key NewlineKey = Key.N.WithCtrl;
private static readonly Key AltQKey = Key.Q.WithAlt;
private static readonly Key TabKey = Key.Tab;
+ private static readonly Key CtrlKKey = Key.K.WithCtrl;
// Available slash commands for Tab autocomplete
private static readonly string[] SlashCommands =
@@ -151,6 +152,11 @@ public sealed partial class MainWindow : Runnable
///
public event Action? OnChannelJoinRequested;
+ ///
+ /// Fired when the user requests to open the search dialog (via menu or Ctrl+K).
+ ///
+ public event Action? OnSearchRequested;
+
public MainWindow(IApplication app, ChatMessageManager messageManager)
{
_app = app;
@@ -222,7 +228,7 @@ public MainWindow(IApplication app, ChatMessageManager messageManager)
// Bottom input area
_inputFrame = new FrameView
{
- Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete",
+ Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete \u2502 Ctrl+K=search",
X = 22,
Y = Pos.Bottom(_chatFrame),
Width = Dim.Fill(UsersPanelWidth),
@@ -513,6 +519,11 @@ private void OnInputKeyDown(object? sender, Key e)
_app.RequestStop();
e.Handled = true;
}
+ else if (e.KeyCode == CtrlKKey.KeyCode)
+ {
+ ShowSearchDialog();
+ e.Handled = true;
+ }
}
private bool _suppressEmojiReplace;
@@ -597,6 +608,16 @@ private void OnWindowKeyDown(object? sender, Key e)
ToggleUsersPanel();
e.Handled = true;
}
+ else if (e.KeyCode == CtrlKKey.KeyCode)
+ {
+ ShowSearchDialog();
+ e.Handled = true;
+ }
+ }
+
+ private void ShowSearchDialog()
+ {
+ OnSearchRequested?.Invoke();
}
private void OnMessagesChanged(string channelName)