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:
parent
4392c7ec7b
commit
dc18436bae
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Reference in New Issue