gui: replace backend/model text fields with editable ComboBoxes, make chat blocks collapsible

- New session dialog: Backend and Model now use proper ComboBoxes with
  editable=true so users can type or select from dropdown
- Backend picker and model picker dialogs removed (no longer needed)
- Model list populated reactively via Connections on backend selection
- Fix model caching: m_rootModels[be].toStringList() returns a copy;
  extract to local QStringList, append, store back into map
- Session creation uses correct path 'session/new' instead of 'new'
- Chat blocks are now collapsible: user/assistant/tool expanded by default,
  reasoning/call/error/info/default collapsed
- Fix chat history on session switch: readLogForSession() reads from the
  explicit session/agent path instead of relying on active state
- Fix delegate layout: use plain ColumnLayout instead of Item-wrapped
  ColumnLayout so ListView calculates implicit heights correctly
This commit is contained in:
Levi Neely 2026-08-01 12:25:36 +02:00
parent 65dee58a6a
commit 3255522144
3 changed files with 142 additions and 162 deletions

View File

@ -142,10 +142,10 @@ ApplicationWindow {
} }
Component.onCompleted: { Component.onCompleted: {
var log = ollie.readLog() var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId)
if (log) { if (log) {
chatModel.appendData(log) chatModel.appendData(log)
chatModel.trimFront(30) chatModel.trimFront(20)
} }
scrollTimer.start() scrollTimer.start()
buildSessionTree() buildSessionTree()
@ -155,10 +155,10 @@ ApplicationWindow {
target: ollie target: ollie
function onActiveSessionIdChanged() { function onActiveSessionIdChanged() {
chatModel.reset() chatModel.reset()
var log = ollie.readLog() var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId)
if (log) { if (log) {
chatModel.appendData(log) chatModel.appendData(log)
chatModel.trimFront(30) chatModel.trimFront(20)
} }
scrollTimer.start() scrollTimer.start()
// Expand the active session in the tree // Expand the active session in the tree
@ -335,31 +335,32 @@ ApplicationWindow {
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
Label { text: "Backend:"; Layout.preferredWidth: 80 } Label { text: "Backend:"; Layout.preferredWidth: 80 }
TextField { ComboBox {
id: backendField id: backendCombo
Layout.fillWidth: true Layout.fillWidth: true
placeholderText: "ollama" enabled: ollie.rootBackendsLoaded
} model: ollie.availableBackends
Button { currentIndex: 0
text: "..." editable: true
onClicked: backendPicker.open() onAccepted: backendCombo.editText = backendCombo.currentText
} }
} }
// Model list (populated reactively from backend selection)
ListModel { id: modelList }
// Model // Model
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
Label { text: "Model:"; Layout.preferredWidth: 80 } Label { text: "Model:"; Layout.preferredWidth: 80 }
TextField { ComboBox {
id: modelField id: modelCombo
Layout.fillWidth: true Layout.fillWidth: true
placeholderText: "(auto-populated)" enabled: backendCombo.currentIndex >= 0 && backendCombo.currentText.trim() !== "" && ollie.rootModelsLoaded
enabled: backendField.text.trim() !== "" model: modelList
} currentIndex: -1
Button { editable: true
text: "..." onAccepted: modelCombo.editText = modelCombo.currentText
enabled: backendField.text.trim() !== "" && ollie.rootModelsLoaded
onClicked: modelPicker.open()
} }
} }
@ -387,70 +388,28 @@ ApplicationWindow {
} }
} }
// Backend picker dialog
Dialog {
id: backendPicker
title: "Select Backend"
anchors.centerIn: parent
modal: true
standardButtons: Dialog.Cancel
contentItem: ListView {
width: 300
height: 200
model: ollie.availableBackends
delegate: ItemDelegate {
width: parent.width
text: modelData
onClicked: {
backendField.text = modelData
backendPicker.close()
}
}
}
}
// Model picker dialog
Dialog {
id: modelPicker
title: "Select Model"
anchors.centerIn: parent
modal: true
standardButtons: Dialog.Cancel
contentItem: ListView {
width: 400
height: 300
model: ollie.getAvailableModels(backendField.text.trim())
delegate: ItemDelegate {
width: parent.width
text: modelData
onClicked: {
modelField.text = modelData
modelPicker.close()
}
}
}
}
Component.onCompleted: { Component.onCompleted: {
if (!ollie.rootBackendsLoaded) ollie.loadRootBackends() if (!ollie.rootBackendsLoaded) ollie.loadRootBackends()
if (!ollie.rootAgentsLoaded) ollie.loadRootAgents() if (!ollie.rootAgentsLoaded) ollie.loadRootAgents()
} }
Connections { Connections {
target: backendField target: backendCombo
function onTextChanged() { function onCurrentTextChanged() {
var be = backendField.text.trim() var be = backendCombo.currentText.trim()
modelList.clear()
if (be !== "" && ollie.rootModelsLoaded) { if (be !== "" && ollie.rootModelsLoaded) {
var models = ollie.getAvailableModels(be) var models = ollie.getAvailableModels(be)
for (var i = 0; i < models.length; ++i) {
modelList.append({ modelData: models[i] })
}
if (models.length > 0) { if (models.length > 0) {
modelField.placeholderText = models[0] modelCombo.currentIndex = 0
} else { } else {
modelField.placeholderText = "(no models)" modelCombo.currentIndex = -1
} }
} else { } else {
modelField.placeholderText = "(auto-populated)" modelCombo.currentIndex = -1
} }
} }
} }
@ -463,8 +422,8 @@ ApplicationWindow {
return return
} }
var name = nameField.text.trim() var name = nameField.text.trim()
var backend = backendField.text.trim() var backend = backendCombo.currentText.trim()
var model = modelField.text.trim() var model = modelCombo.currentIndex >= 0 ? modelCombo.currentText.trim() : ""
var agent = agentCombo.currentText var agent = agentCombo.currentText
var remote = remoteField.text.trim() var remote = remoteField.text.trim()
@ -647,102 +606,113 @@ ApplicationWindow {
} }
delegate: ColumnLayout { delegate: ColumnLayout {
id: blockItem
width: chatView.width - 8 width: chatView.width - 8
spacing: 2 spacing: 2
// Header decoration property bool blockExpanded: {
Rectangle { switch (blockType) {
Layout.fillWidth: true case "user": return true
height: 22 case "assistant": return true
radius: 3 case "tool": return true
color: { default: return false
// Derive header colors from base background with subtle tint.
// Always readable because text uses theme.textColor.
var bg = theme.baseColor
if (theme.isDark) {
// Dark theme: lighter tints against dark background
switch (blockType) {
case "user": return Qt.lighter(bg, 1.8)
case "assistant": return Qt.lighter(bg, 1.5)
case "reasoning": return Qt.lighter(bg, 1.3)
case "call": return Qt.lighter(bg, 1.7)
case "tool": return Qt.lighter(bg, 1.2)
case "error": return Qt.rgba(0.6, 0.15, 0.15, 0.7)
case "info": return Qt.lighter(bg, 1.4)
default: return bg
}
} else {
// Light theme: slightly darker tints
switch (blockType) {
case "user": return Qt.darker(bg, 1.12)
case "assistant": return Qt.darker(bg, 1.06)
case "reasoning": return Qt.darker(bg, 1.03)
case "call": return Qt.darker(bg, 1.1)
case "tool": return Qt.darker(bg, 1.04)
case "error": return Qt.rgba(1.0, 0.8, 0.8, 0.7)
case "info": return Qt.darker(bg, 1.02)
default: return bg
}
}
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 6
spacing: 4
Label {
text: {
switch (blockType) {
case "user": return "User"
case "assistant": return "Assistant" + (blockName ? " (" + blockName + ")" : "")
case "reasoning": return "Reasoning"
case "call": return "Call: " + blockName
case "tool": return "Tool: " + blockName
case "error": return "Error"
case "info": return "Info"
default: return ""
}
}
font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.85 : 11
font.bold: true
color: theme.textColor
opacity: 0.9
}
} }
} }
ColumnLayout { // Clickable header
Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.leftMargin: 4 height: 22
Layout.rightMargin: 4 radius: 3
Layout.bottomMargin: 4 color: {
spacing: 0 var bg = theme.baseColor
if (theme.isDark) {
// Rendered HTML (fenced code/diff) — shown when available switch (blockType) {
TextEdit { case "user": return Qt.lighter(bg, 1.8)
Layout.fillWidth: true case "assistant": return Qt.lighter(bg, 1.5)
visible: blockRenderedHtml && blockRenderedHtml.length > 0 case "reasoning": return Qt.lighter(bg, 1.3)
text: blockRenderedHtml case "call": return Qt.lighter(bg, 1.7)
textFormat: Text.RichText case "tool": return Qt.lighter(bg, 1.2)
readOnly: true case "error": return Qt.rgba(0.6, 0.15, 0.15, 0.7)
wrapMode: TextEdit.Wrap case "info": return Qt.lighter(bg, 1.4)
font.family: theme.fontFamily || "monospace" default: return bg
font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12 }
color: theme.textColor } else {
switch (blockType) {
case "user": return Qt.darker(bg, 1.12)
case "assistant": return Qt.darker(bg, 1.06)
case "reasoning": return Qt.darker(bg, 1.03)
case "call": return Qt.darker(bg, 1.1)
case "tool": return Qt.darker(bg, 1.04)
case "error": return Qt.rgba(1.0, 0.8, 0.8, 0.7)
case "info": return Qt.darker(bg, 1.02)
default: return bg
}
}
} }
// Plain text fallback (prose, streaming content) MouseArea {
Label { anchors.fill: parent
Layout.fillWidth: true cursorShape: Qt.PointingHandCursor
visible: (!blockRenderedHtml || blockRenderedHtml.length === 0) onClicked: blockItem.blockExpanded = !blockItem.blockExpanded
text: blockContent || ""
wrapMode: Text.Wrap
textFormat: Text.PlainText
font.family: theme.fontFamily || "monospace"
font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12
color: theme.textColor
} }
RowLayout {
anchors.fill: parent
anchors.leftMargin: 6
spacing: 4
Label {
text: blockItem.blockExpanded ? "▼" : "▶"
font.pixelSize: 8
opacity: 0.6
}
Label {
Layout.fillWidth: true
text: {
switch (blockType) {
case "user": return "User"
case "assistant": return "Assistant" + (blockName ? " (" + blockName + ")" : "")
case "reasoning": return "Reasoning"
case "call": return "Call: " + blockName
case "tool": return "Tool: " + blockName
case "error": return "Error"
case "info": return "Info"
default: return ""
}
}
font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.85 : 11
font.bold: true
color: theme.textColor
opacity: 0.9
}
}
}
// Rendered HTML (fenced code/diff)
TextEdit {
Layout.fillWidth: true
visible: blockItem.blockExpanded && blockRenderedHtml && blockRenderedHtml.length > 0
text: blockRenderedHtml
textFormat: Text.RichText
readOnly: true
wrapMode: TextEdit.Wrap
font.family: theme.fontFamily || "monospace"
font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12
color: theme.textColor
}
// Plain text fallback
Label {
Layout.fillWidth: true
visible: blockItem.blockExpanded && (!blockRenderedHtml || blockRenderedHtml.length === 0)
text: blockContent || ""
wrapMode: Text.Wrap
textFormat: Text.PlainText
font.family: theme.fontFamily || "monospace"
font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12
color: theme.textColor
} }
} }
} }

View File

@ -110,6 +110,13 @@ QString Ollie9pClient::readLog()
return QString::fromUtf8(out); return QString::fromUtf8(out);
} }
QString Ollie9pClient::readLogForSession(const QString &sessionId, const QString &agentId)
{
if (sessionId.isEmpty() || agentId.isEmpty()) return {};
QByteArray out = run9p({"read", "session/" + sessionId + "/agent/" + agentId + "/log"});
return QString::fromUtf8(out);
}
bool Ollie9pClient::submit(const QString &prompt) bool Ollie9pClient::submit(const QString &prompt)
{ {
if (m_activeSessionId.isEmpty() || prompt.trimmed().isEmpty()) return false; if (m_activeSessionId.isEmpty() || prompt.trimmed().isEmpty()) return false;
@ -244,7 +251,7 @@ bool Ollie9pClient::createSession(const QString &cwd, const QString &name, const
if (!remote.isEmpty()) args << "remote=" + remote; if (!remote.isEmpty()) args << "remote=" + remote;
QProcess proc; QProcess proc;
QStringList fullArgs = {"-a", serverAddr(), "write", "new"}; QStringList fullArgs = {"-a", serverAddr(), "write", "session/new"};
proc.start(ninepBin(), fullArgs); proc.start(ninepBin(), fullArgs);
proc.waitForStarted(3000); proc.waitForStarted(3000);
@ -280,7 +287,9 @@ void Ollie9pClient::ensureRootDataLoaded()
QString be = line.left(tab).trimmed(); QString be = line.left(tab).trimmed();
QString mo = line.mid(tab + 1).trimmed(); QString mo = line.mid(tab + 1).trimmed();
if (!be.isEmpty() && !mo.isEmpty()) { if (!be.isEmpty() && !mo.isEmpty()) {
m_rootModels[be].toStringList().append(mo); QStringList list = m_rootModels[be].toStringList();
list.append(mo);
m_rootModels[be] = QVariant(list);
} }
} }
m_rootModelsLoaded = true; m_rootModelsLoaded = true;

View File

@ -46,6 +46,7 @@ public:
Q_INVOKABLE void refreshSessions(); Q_INVOKABLE void refreshSessions();
Q_INVOKABLE QString readLog(); Q_INVOKABLE QString readLog();
Q_INVOKABLE QString readLogForSession(const QString &sessionId, const QString &agentId);
Q_INVOKABLE bool submit(const QString &prompt); Q_INVOKABLE bool submit(const QString &prompt);
Q_INVOKABLE bool interrupt(); Q_INVOKABLE bool interrupt();
Q_INVOKABLE bool kill(); Q_INVOKABLE bool kill();