gui: Ctrl+F filter bar to search chat messages

Adds a filterText property to ChatBlockModel that filters all
parsed blocks by case-insensitive substring match on content
or tool/call name. Toggle the filter bar with Ctrl+F; Escape
or the close button clears the filter and hides the bar.
Shows match count while filtering.
This commit is contained in:
Levi Neely 2026-07-27 09:37:26 +02:00
parent 4392c7ec7b
commit dc18436bae
4 changed files with 152 additions and 3 deletions

View File

@ -165,6 +165,42 @@ Item {
Rectangle { Layout.fillWidth: true; height: 1; color: palette.mid }
// Filter bar (Ctrl+F)
RowLayout {
id: filterBar
Layout.fillWidth: true
Layout.margins: 4
spacing: 4
visible: false
TextField {
id: filterInput
Layout.fillWidth: true
placeholderText: "Filter messages..."
onTextChanged: paneModel.filterText = text
Keys.onEscapePressed: {
filterBar.visible = false
paneModel.filterText = ""
text = ""
}
}
Label {
text: paneModel.filtering ? paneModel.rowCount() + " matches" : ""
opacity: 0.6
font.pixelSize: Qt.application.font.pixelSize * 0.85
}
ToolButton {
icon.name: "window-close"
onClicked: {
filterBar.visible = false
paneModel.filterText = ""
filterInput.text = ""
}
}
}
// Chat view
Item {
Layout.fillWidth: true
@ -529,6 +565,21 @@ Item {
// Filter shortcut
Shortcut {
sequence: "Ctrl+F"
onActivated: {
filterBar.visible = !filterBar.visible
if (filterBar.visible) {
filterInput.forceActiveFocus()
filterInput.selectAll()
} else {
paneModel.filterText = ""
filterInput.text = ""
}
}
}
// Sloppy focus — focus prompt when mouse hovers inside pane
HoverHandler {
id: sloppyHover

View File

@ -36,15 +36,17 @@ ChatBlockModel::ChatBlockModel(QObject *parent)
int ChatBlockModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) return 0;
return m_blocks.size();
const auto &blocks = m_filterText.isEmpty() ? m_blocks : m_filteredBlocks;
return blocks.size();
}
QVariant ChatBlockModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() >= m_blocks.size())
const auto &blocks = m_filterText.isEmpty() ? m_blocks : m_filteredBlocks;
if (!index.isValid() || index.row() >= blocks.size())
return {};
const ChatBlock &block = m_blocks[index.row()];
const ChatBlock &block = blocks[index.row()];
switch (role) {
case BlockTypeRole: {
switch (block.type) {
@ -108,9 +110,36 @@ void ChatBlockModel::setRawText(const QString &text)
if (text == m_rawText) return;
m_rawText = text;
reparse();
if (!m_filterText.isEmpty())
applyFilter();
emit rawTextChanged();
}
void ChatBlockModel::setFilterText(const QString &text)
{
if (text == m_filterText) return;
m_filterText = text;
applyFilter();
emit filterTextChanged();
}
void ChatBlockModel::applyFilter()
{
beginResetModel();
if (m_filterText.isEmpty()) {
m_filteredBlocks.clear();
} else {
m_filteredBlocks.clear();
for (const auto &block : m_allBlocks) {
if (block.content.contains(m_filterText, Qt::CaseInsensitive) ||
block.name.contains(m_filterText, Qt::CaseInsensitive)) {
m_filteredBlocks.append(block);
}
}
}
endResetModel();
}
static constexpr int INITIAL_BLOCKS = 20;
static constexpr int LOAD_MORE_COUNT = 30;

View File

@ -41,6 +41,8 @@ class ChatBlockModel : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(QString rawText READ rawText WRITE setRawText NOTIFY rawTextChanged)
Q_PROPERTY(QString filterText READ filterText WRITE setFilterText NOTIFY filterTextChanged)
Q_PROPERTY(bool filtering READ filtering NOTIFY filterTextChanged)
public:
enum Roles {
@ -60,6 +62,10 @@ public:
QString rawText() const { return m_rawText; }
void setRawText(const QString &text);
QString filterText() const { return m_filterText; }
void setFilterText(const QString &text);
bool filtering() const { return !m_filterText.isEmpty(); }
Q_INVOKABLE QString highlightedHtml(const QString &code, const QString &language) const;
Q_INVOKABLE QString prettyJson(const QString &text) const;
Q_INVOKABLE void copyToClipboard(const QString &text) const;
@ -71,16 +77,20 @@ public:
signals:
void rawTextChanged();
void filterTextChanged();
private:
void reparse();
void updateVisibleWindow();
void applyFilter();
QList<ChatBlock> parseBlocks(const QString &text) const;
QVariantList parseSubBlocks(const QString &content) const;
QString m_rawText;
QString m_filterText;
QList<ChatBlock> m_allBlocks; // all parsed blocks
QList<ChatBlock> m_blocks; // visible window (tail of m_allBlocks)
QList<ChatBlock> m_filteredBlocks; // filtered subset when filterText is active
int m_visibleStart = 0; // index into m_allBlocks where visible window starts
mutable QHash<QString, QString> m_highlightCache;
};

View File

@ -770,6 +770,18 @@ ApplicationWindow {
}
}
Shortcut {
sequence: "Ctrl+F"
onActivated: {
var pane = mainContent.panes[mainContent.focusedPaneIndex]
if (!pane) return
var fb = pane.children[0] // ColumnLayout
// Toggle via the filterBar id inside the pane
// Since filterBar is inside the Component, access via pane property
pane.toggleFilter()
}
}
// Handle pane swap: find target pane at drop coordinates
function handlePaneSwap(sourcePane, rootX, rootY) {
for (var i = 0; i < mainContent.panes.length; i++) {
@ -845,6 +857,17 @@ ApplicationWindow {
if (sessionId !== "") refreshPaneChat()
}
function toggleFilter() {
filterBar.visible = !filterBar.visible
if (filterBar.visible) {
filterInput.forceActiveFocus()
filterInput.selectAll()
} else {
paneModel.filterText = ""
filterInput.text = ""
}
}
function refreshPaneChat() {
paneModel.rawText = ""
chatOffset = 0
@ -973,6 +996,42 @@ ApplicationWindow {
Rectangle { Layout.fillWidth: true; height: 1; color: palette.mid }
// Filter bar (Ctrl+F)
RowLayout {
id: filterBar
Layout.fillWidth: true
Layout.margins: 4
spacing: 4
visible: false
TextField {
id: filterInput
Layout.fillWidth: true
placeholderText: "Filter messages..."
onTextChanged: paneRoot.paneModel.filterText = text
Keys.onEscapePressed: {
filterBar.visible = false
paneRoot.paneModel.filterText = ""
text = ""
}
}
Label {
text: paneRoot.paneModel.filtering ? paneRoot.paneModel.rowCount() + " matches" : ""
opacity: 0.6
font.pixelSize: Qt.application.font.pixelSize * 0.85
}
ToolButton {
icon.name: "window-close"
onClicked: {
filterBar.visible = false
paneRoot.paneModel.filterText = ""
filterInput.text = ""
}
}
}
// Chat view
Item {
Layout.fillWidth: true