diff options
| author | Danilo M. <danix@danix.xyz> | 2026-07-31 11:25:52 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-07-31 11:25:52 +0200 |
| commit | 08e11d04ef1150e5b4a862bf82179e2196daa850 (patch) | |
| tree | 0d50cf3e59ad0a00634b5ad2c44c841ec62fd29b | |
| parent | e1105f76e127bf8c19511a7494a7c1331181b9dc (diff) | |
| download | llamachat-08e11d04ef1150e5b4a862bf82179e2196daa850.tar.gz llamachat-08e11d04ef1150e5b4a862bf82179e2196daa850.zip | |
feat: add Ctrl+N and Ctrl+F shortcuts
Ctrl+N starts a fresh conversation and puts the cursor in the input box.
Ctrl+F jumps to the search field.
The search field sits in the top bar, but its results render in the history
panel, so focusing it reveals a hidden panel rather than leaving the search
with nowhere to show its hits.
Escape now backs out of the search field before it hides the window: first
press clears the text, second returns to the input, third hides. Previously
a stray Escape while filtering dismissed the whole window.
That check reads focusWidget() rather than hasFocus(), so it still holds
when the window is not the active one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| -rw-r--r-- | llamachat/ui.py | 38 | ||||
| -rwxr-xr-x | test_llamachat.py | 108 |
2 files changed, 144 insertions, 2 deletions
diff --git a/llamachat/ui.py b/llamachat/ui.py index feab3f2..fd2380c 100644 --- a/llamachat/ui.py +++ b/llamachat/ui.py @@ -404,7 +404,7 @@ class ChatWindow(QMainWindow): top.addWidget(self.meter) self.search_box = QLineEdit() - self.search_box.setPlaceholderText("Search history…") + self.search_box.setPlaceholderText("Search history… (Ctrl+F)") self.search_box.setClearButtonEnabled(True) self.search_box.setMaximumWidth(280) self.search_box.textChanged.connect(self._on_search) @@ -455,6 +455,7 @@ class ChatWindow(QMainWindow): buttons = QHBoxLayout() self.new_button = QPushButton("New") + self.new_button.setToolTip("Start a fresh conversation (Ctrl+N)") self.new_button.clicked.connect(self.new_session) buttons.addWidget(self.new_button) @@ -502,6 +503,16 @@ class ChatWindow(QMainWindow): sidebar_action.triggered.connect(self.toggle_sidebar) self.addAction(sidebar_action) + new_action = QAction(self) + new_action.setShortcut(QKeySequence("Ctrl+N")) + new_action.triggered.connect(self.new_session) + self.addAction(new_action) + + search_action = QAction(self) + search_action.setShortcut(QKeySequence("Ctrl+F")) + search_action.triggered.connect(self.focus_search) + self.addAction(search_action) + # -- sidebar ---------------------------------------------------------- def _settings(self) -> QSettings: @@ -520,7 +531,6 @@ class ChatWindow(QMainWindow): # QSettings round-trips booleans as strings on some backends. if isinstance(visible, str): visible = visible.lower() not in ("false", "0") - # setChecked only emits when the value changes, so apply directly # rather than relying on the signal to do it. capture=False keeps # the width just read from disk: the splitter has not been laid out @@ -544,6 +554,17 @@ class ChatWindow(QMainWindow): def toggle_sidebar(self) -> None: self.sidebar_button.setChecked(not self.sidebar_button.isChecked()) + def focus_search(self) -> None: + """Jump to the search box in the top bar. + + Results land in the history panel, so a hidden panel is revealed + rather than leaving the search with nowhere to show its hits. + """ + if not self.sidebar_visible(): + self.sidebar_button.setChecked(True) + self.search_box.setFocus() + self.search_box.selectAll() + def sidebar_visible(self) -> bool: """Whether the panel is shown. @@ -729,6 +750,9 @@ class ChatWindow(QMainWindow): self.hide_status() self.select_prompt(self.cfg.default_prompt) self.update_meter() + # Starting a conversation implies wanting to type in it, and this + # also takes focus back out of the search box. + self.input.setFocus() # -- attachments ------------------------------------------------------ @@ -1227,6 +1251,16 @@ class ChatWindow(QMainWindow): def keyPressEvent(self, event) -> None: if event.key() == Qt.Key_Escape: + # Escape backs out of the search box before it hides the window, + # so a stray press while filtering does not dismiss everything. + # focusWidget() rather than hasFocus(), so this still holds when + # the window itself is not the active one. + if self.focusWidget() is self.search_box: + if self.search_box.text(): + self.search_box.clear() + else: + self.input.setFocus() + return self.hide() return super().keyPressEvent(event) diff --git a/test_llamachat.py b/test_llamachat.py index ae2b478..0986631 100755 --- a/test_llamachat.py +++ b/test_llamachat.py @@ -654,6 +654,113 @@ def test_sidebar_toggle(): print("ok sidebar toggle") +def test_shortcuts(): + """Ctrl+N, Ctrl+F and Escape behave as advertised.""" + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtCore import QEvent, Qt + from PySide6.QtGui import QKeyEvent + from PySide6.QtWidgets import QApplication + + from llamachat import backend as _backend + from llamachat import config as _config + from llamachat.ui import ChatWindow + + app = QApplication.instance() or QApplication([]) + + with tempfile.TemporaryDirectory() as tmp: + cfg = _config.load(Path(tmp) / "config.toml") + cfg.prompts_dir = Path(tmp) / "prompts" + cfg.db_path = Path(tmp) / "t.db" + history = db.History(cfg.db_path) + window = ChatWindow( + cfg, + history, + _backend.Client(cfg.base_url), + _config.parse_presets(cfg.presets_path), + ) + window.resize(1000, 700) + window.show() + window.raise_() + window.activateWindow() + # The offscreen platform only grants focus to the active window, and + # a window left over from an earlier check can still hold it. + window.setFocus() + app.processEvents() + + registered = { + a.shortcut().toString() + for a in window.actions() + if not a.shortcut().isEmpty() + } + for wanted in ("Ctrl+N", "Ctrl+F", "Ctrl+\\"): + assert wanted in registered, (wanted, registered) + + # Ctrl+F puts the cursor in the search box. The offscreen platform + # only grants real focus to one window per process, so check where + # focus was directed rather than whether the platform granted it. + window.input.setFocus() + app.processEvents() + window.focus_search() + app.processEvents() + assert window.focusWidget() is window.search_box, window.focusWidget() + + # The search box lives in the top bar, so hiding the history panel + # must not take it away. + window.sidebar_button.setChecked(False) + app.processEvents() + assert not window.sidebar_visible() + assert window.search_box.isVisible() + + # Its results render in the panel, so focusing reveals the panel. + window.focus_search() + app.processEvents() + assert window.sidebar_visible() + + def press_escape() -> None: + window.keyPressEvent( + QKeyEvent(QEvent.KeyPress, Qt.Key_Escape, Qt.NoModifier) + ) + app.processEvents() + + # Escape backs out of the search box before hiding the window, so a + # stray press while filtering does not dismiss everything. + window.search_box.setFocus() + window.search_box.setText("otters") + app.processEvents() + assert window.focusWidget() is window.search_box + press_escape() + assert window.search_box.text() == "" + assert window.isVisible() + press_escape() + assert window.focusWidget() is window.input + assert window.isVisible() + press_escape() + assert not window.isVisible() + + # Ctrl+N clears the conversation and puts the cursor in the input. + window.show() + window.raise_() + window.activateWindow() + # The offscreen platform only grants focus to the active window, and + # a window left over from an earlier check can still hold it. + window.setFocus() + app.processEvents() + session = history.create_session("chat", "m", "old") + history.add_message(session, "user", "something") + window.open_session(session) + assert window.session_id == session + window.search_box.setFocus() + window.new_session() + app.processEvents() + assert window.session_id is None + assert window.transcript.toPlainText().strip() == "" + assert window.focusWidget() is window.input + + window.close() + history.close() + print("ok shortcuts") + + def test_version_matches_changelog(): """The package version must be the newest release in the changelog.""" import re @@ -758,6 +865,7 @@ if __name__ == "__main__": test_prompt_column_migration() test_markdown_rendering() test_sidebar_toggle() + test_shortcuts() test_system_qt_theme_guard() test_version_matches_changelog() test_venv_discovery() |
