aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-07-31 11:24:58 +0200
committerDanilo M. <danix@danix.xyz>2026-07-31 11:24:58 +0200
commite1105f76e127bf8c19511a7494a7c1331181b9dc (patch)
treef0f8f72205778c413507c52b5b7b2cbb44681e71
parent9182c84b8ac9d905a9d553b95155a993338568b8 (diff)
downloadllamachat-e1105f76e127bf8c19511a7494a7c1331181b9dc.tar.gz
llamachat-e1105f76e127bf8c19511a7494a7c1331181b9dc.zip
feat: add a history panel toggle
The history panel now hides, on the ☰ button in the top bar and on Ctrl+\. Its width is captured before hiding and reapplied on show, so toggling does not snap the panel back to a default. Both the width and whether it was hidden persist between runs. Layout state lives in state.ini beside the config rather than at QSettings' default path, which keeps it obvious where it is and lets the checks point it somewhere temporary instead of writing to the real one. The button's checked state, not isVisible(), is the authority for whether the panel is shown: isVisible() is False for every child of a window that has not been mapped yet, so consulting it during startup would disagree with what the user sees. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-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()