summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--llamachat/ui.py38
-rwxr-xr-xtest_llamachat.py108
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()