aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md3
-rw-r--r--README.md9
-rw-r--r--llamachat/config.py4
-rw-r--r--llamachat/ui.py114
-rwxr-xr-xtest_llamachat.py76
5 files changed, 202 insertions, 4 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1b71e76..5b8e099 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
with.
- Streaming requests now ask for usage statistics, which is what makes the
meter exact without an extra round trip.
+- A toggle for the history panel, on the `☰` button and `Ctrl+\`. The
+ panel's width and hidden state are remembered between runs in
+ `~/.config/llamachat/state.ini`.
### Changed
diff --git a/README.md b/README.md
index fdcca99..778010f 100644
--- a/README.md
+++ b/README.md
@@ -158,8 +158,13 @@ llamachat --quit stop the running instance
The control commands do not import Qt, so they return in well under a tenth
of a second, which is what makes them usable behind a keybind.
-In the window: **Ctrl+Enter** sends, **Esc** hides, **New** starts a fresh
-conversation, right-clicking a history entry offers to delete it.
+In the window: **Ctrl+Enter** sends, **Ctrl+\\** shows or hides the history
+panel (the `☰` button does the same), **Esc** hides the window, **New**
+starts a fresh conversation, and right-clicking a history entry offers to
+delete it.
+
+The panel's width and whether it was hidden are remembered in
+`~/.config/llamachat/state.ini` between runs.
## Hyprland configuration
diff --git a/llamachat/config.py b/llamachat/config.py
index 5c5af14..dec2ba6 100644
--- a/llamachat/config.py
+++ b/llamachat/config.py
@@ -66,6 +66,7 @@ class Config:
chars_per_token: float
default_prompt: str
prompts_dir: Path
+ state_path: Path
def _runtime_dir() -> Path:
@@ -104,6 +105,9 @@ def load(path: Path = CONFIG_PATH) -> Config:
chars_per_token=float(values["chars_per_token"]),
default_prompt=str(values["default_prompt"]),
prompts_dir=path.parent / "prompts",
+ # Window layout, remembered between runs. Not user-editable config,
+ # so it sits beside it rather than inside config.toml.
+ state_path=path.parent / "state.ini",
)
diff --git a/llamachat/ui.py b/llamachat/ui.py
index f398510..feab3f2 100644
--- a/llamachat/ui.py
+++ b/llamachat/ui.py
@@ -18,7 +18,9 @@ import json
import re
from pathlib import Path
-from PySide6.QtCore import QObject, QRect, Qt, QThread, Signal, Slot
+from PySide6.QtCore import (
+ QObject, QRect, QSettings, Qt, QThread, Signal, Slot,
+)
from PySide6.QtGui import (
QAction, QColor, QDesktopServices, QKeySequence, QPainter, QTextDocument,
)
@@ -319,6 +321,7 @@ class ChatWindow(QMainWindow):
self.prompt_name = cfg.default_prompt
self.prompt_custom = ""
self.exact_tokens = 0
+ self.sidebar_width = 240
self.session_id: int | None = None
self.mode = MODE_ONESHOT
@@ -338,6 +341,7 @@ class ChatWindow(QMainWindow):
self.resize(1000, 700)
self.setAcceptDrops(True)
self._build_ui()
+ self._restore_layout()
self.refresh_models()
self.refresh_prompts()
self.refresh_history()
@@ -351,6 +355,14 @@ class ChatWindow(QMainWindow):
# Top bar: model, mode, search.
top = QHBoxLayout()
+ self.sidebar_button = QPushButton("☰")
+ self.sidebar_button.setCheckable(True)
+ self.sidebar_button.setChecked(True)
+ self.sidebar_button.setFixedWidth(32)
+ self.sidebar_button.setToolTip("Show or hide the history panel (Ctrl+\\)")
+ self.sidebar_button.toggled.connect(self.set_sidebar_visible)
+ top.addWidget(self.sidebar_button)
+
self.model_box = QComboBox()
self.model_box.setMinimumWidth(220)
self.model_box.currentTextChanged.connect(self._on_model_changed)
@@ -400,7 +412,8 @@ class ChatWindow(QMainWindow):
outer.addLayout(top)
# Body: history list beside the transcript and input.
- splitter = QSplitter(Qt.Horizontal)
+ self.splitter = QSplitter(Qt.Horizontal)
+ splitter = self.splitter
self.history_list = QListWidget()
self.history_list.setMinimumWidth(200)
@@ -484,6 +497,97 @@ class ChatWindow(QMainWindow):
send_enter.triggered.connect(self.send)
self.addAction(send_enter)
+ sidebar_action = QAction(self)
+ sidebar_action.setShortcut(QKeySequence("Ctrl+\\"))
+ sidebar_action.triggered.connect(self.toggle_sidebar)
+ self.addAction(sidebar_action)
+
+ # -- sidebar ----------------------------------------------------------
+
+ def _settings(self) -> QSettings:
+ """Where window layout is remembered.
+
+ Kept beside the config rather than left to QSettings' default so it
+ is obvious where it lives, and so tests can point it elsewhere.
+ """
+ return QSettings(str(self.cfg.state_path), QSettings.IniFormat)
+
+ def _restore_layout(self) -> None:
+ """Bring back the sidebar width and visibility from last time."""
+ settings = self._settings()
+ self.sidebar_width = int(settings.value("sidebar/width", 240))
+ visible = settings.value("sidebar/visible", True)
+ # 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
+ # yet, so asking it now would overwrite that with a default.
+ self.sidebar_button.blockSignals(True)
+ self.sidebar_button.setChecked(bool(visible))
+ self.sidebar_button.blockSignals(False)
+ self.set_sidebar_visible(bool(visible), capture=False)
+
+ def _save_layout(self) -> None:
+ # Hiding already captured the width, so only refresh it while the
+ # panel is up and the splitter still has something to report.
+ if self.sidebar_visible():
+ self._capture_width()
+
+ settings = self._settings()
+ settings.setValue("sidebar/width", self.sidebar_width)
+ settings.setValue("sidebar/visible", self.sidebar_visible())
+ settings.sync()
+
+ def toggle_sidebar(self) -> None:
+ self.sidebar_button.setChecked(not self.sidebar_button.isChecked())
+
+ def sidebar_visible(self) -> bool:
+ """Whether the panel is shown.
+
+ isVisible() is False for every child of a window that has not been
+ mapped yet, so the button's own state is the authority before the
+ window first appears.
+ """
+ return self.sidebar_button.isChecked()
+
+ def _capture_width(self) -> None:
+ """Remember the panel's width while the splitter still reports it.
+
+ Read unconditionally rather than gated on visibility: by the time a
+ toggle reaches here the button has already flipped, so asking
+ whether the sidebar is shown would skip the capture.
+ """
+ sizes = self.splitter.sizes()
+ # A zero first pane means it is already collapsed, so there is no
+ # meaningful width to keep.
+ if sizes and sizes[0] > 0:
+ self.sidebar_width = sizes[0]
+
+ def set_sidebar_visible(self, visible: bool, capture: bool = True) -> None:
+ """Show or hide the history panel, keeping its width across toggles.
+
+ `capture` is False when restoring saved state, where the width comes
+ from disk and the splitter has nothing meaningful to report yet.
+ """
+ if not visible and capture:
+ self._capture_width()
+
+ if self.sidebar_button.isChecked() != visible:
+ self.sidebar_button.blockSignals(True)
+ self.sidebar_button.setChecked(visible)
+ self.sidebar_button.blockSignals(False)
+ self.history_list.setVisible(visible)
+ self.sidebar_button.setToolTip(
+ f"{'Hide' if visible else 'Show'} the history panel (Ctrl+\\)"
+ )
+ if visible:
+ total = sum(self.splitter.sizes()) or self.width()
+ width = min(self.sidebar_width, max(total - 200, 0))
+ self.splitter.setSizes([width, total - width])
+
# -- model handling ---------------------------------------------------
def refresh_models(self) -> None:
@@ -1127,8 +1231,14 @@ class ChatWindow(QMainWindow):
return
super().keyPressEvent(event)
+ def hideEvent(self, event) -> None:
+ """The daemon rarely exits, so persist layout whenever it hides."""
+ self._save_layout()
+ super().hideEvent(event)
+
def closeEvent(self, event) -> None:
"""Closing hides; the daemon keeps running for the next toggle."""
+ self._save_layout()
event.ignore()
self.hide()
diff --git a/test_llamachat.py b/test_llamachat.py
index 7707b7b..ae2b478 100755
--- a/test_llamachat.py
+++ b/test_llamachat.py
@@ -579,6 +579,81 @@ def test_prompt_column_migration():
print("ok prompt column migration")
+def test_sidebar_toggle():
+ """The history panel hides, restores its width, and persists."""
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ 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([])
+ assert app is not None
+
+ 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"
+ # state_path already points inside tmp, since it is derived from the
+ # config path, so these checks cannot touch the real saved layout.
+ assert cfg.state_path.parent == Path(tmp), cfg.state_path
+
+ history = db.History(cfg.db_path)
+ client = _backend.Client(cfg.base_url)
+ presets = _config.parse_presets(cfg.presets_path)
+
+ window = ChatWindow(cfg, history, client, presets)
+ window.resize(1000, 700)
+
+ # The shortcut must be registered on the window itself.
+ shortcuts = [
+ a.shortcut().toString()
+ for a in window.actions()
+ if not a.shortcut().isEmpty()
+ ]
+ assert "Ctrl+\\" in shortcuts, shortcuts
+
+ window.show()
+ assert window.sidebar_visible()
+ assert window.sidebar_button.isChecked()
+
+ window.toggle_sidebar()
+ assert not window.sidebar_visible()
+ assert not window.sidebar_button.isChecked()
+
+ window.toggle_sidebar()
+ assert window.sidebar_visible()
+
+ # The width survives a hide/show rather than snapping to a default.
+ window.splitter.setSizes([333, 667])
+ app.processEvents()
+ window.toggle_sidebar()
+ window.toggle_sidebar()
+ assert window.sidebar_width == 333, window.sidebar_width
+
+ # Driving the button directly must take the same path.
+ window.sidebar_button.setChecked(False)
+ app.processEvents()
+ assert not window.sidebar_visible()
+ window.sidebar_button.setChecked(True)
+ app.processEvents()
+ assert window.sidebar_visible()
+ assert window.sidebar_width == 333, window.sidebar_width
+
+ # Hidden state and width must come back on the next start.
+ window.sidebar_button.setChecked(False)
+ window._save_layout()
+ restored = ChatWindow(cfg, history, client, presets)
+ assert not restored.sidebar_button.isChecked()
+ assert restored.sidebar_width == 333, restored.sidebar_width
+
+ restored.close()
+ window.close()
+ history.close()
+ print("ok sidebar toggle")
+
+
def test_version_matches_changelog():
"""The package version must be the newest release in the changelog."""
import re
@@ -682,6 +757,7 @@ if __name__ == "__main__":
test_session_prompt_storage()
test_prompt_column_migration()
test_markdown_rendering()
+ test_sidebar_toggle()
test_system_qt_theme_guard()
test_version_matches_changelog()
test_venv_discovery()