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: {
var log = ollie.readLog()
var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId)
if (log) {
chatModel.appendData(log)
chatModel.trimFront(30)
chatModel.trimFront(20)
}
scrollTimer.start()
buildSessionTree()
@ -155,10 +155,10 @@ ApplicationWindow {
target: ollie
function onActiveSessionIdChanged() {
chatModel.reset()
var log = ollie.readLog()
var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId)
if (log) {
chatModel.appendData(log)
chatModel.trimFront(30)
chatModel.trimFront(20)
}
scrollTimer.start()
// Expand the active session in the tree
@ -335,31 +335,32 @@ ApplicationWindow {
RowLayout {
Layout.fillWidth: true
Label { text: "Backend:"; Layout.preferredWidth: 80 }
TextField {
id: backendField
ComboBox {
id: backendCombo
Layout.fillWidth: true
placeholderText: "ollama"
}
Button {
text: "..."
onClicked: backendPicker.open()
enabled: ollie.rootBackendsLoaded
model: ollie.availableBackends
currentIndex: 0
editable: true
onAccepted: backendCombo.editText = backendCombo.currentText
}
}
// Model list (populated reactively from backend selection)
ListModel { id: modelList }
// Model
RowLayout {
Layout.fillWidth: true
Label { text: "Model:"; Layout.preferredWidth: 80 }
TextField {
id: modelField
ComboBox {
id: modelCombo
Layout.fillWidth: true
placeholderText: "(auto-populated)"
enabled: backendField.text.trim() !== ""
}
Button {
text: "..."
enabled: backendField.text.trim() !== "" && ollie.rootModelsLoaded
onClicked: modelPicker.open()
enabled: backendCombo.currentIndex >= 0 && backendCombo.currentText.trim() !== "" && ollie.rootModelsLoaded
model: modelList
currentIndex: -1
editable: true
onAccepted: modelCombo.editText = modelCombo.currentText
}
}
@ -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: {
if (!ollie.rootBackendsLoaded) ollie.loadRootBackends()
if (!ollie.rootAgentsLoaded) ollie.loadRootAgents()
}
Connections {
target: backendField
function onTextChanged() {
var be = backendField.text.trim()
target: backendCombo
function onCurrentTextChanged() {
var be = backendCombo.currentText.trim()
modelList.clear()
if (be !== "" && ollie.rootModelsLoaded) {
var models = ollie.getAvailableModels(be)
for (var i = 0; i < models.length; ++i) {
modelList.append({ modelData: models[i] })
}
if (models.length > 0) {
modelField.placeholderText = models[0]
modelCombo.currentIndex = 0
} else {
modelField.placeholderText = "(no models)"
modelCombo.currentIndex = -1
}
} else {
modelField.placeholderText = "(auto-populated)"
modelCombo.currentIndex = -1
}
}
}
@ -463,8 +422,8 @@ ApplicationWindow {
return
}
var name = nameField.text.trim()
var backend = backendField.text.trim()
var model = modelField.text.trim()
var backend = backendCombo.currentText.trim()
var model = modelCombo.currentIndex >= 0 ? modelCombo.currentText.trim() : ""
var agent = agentCombo.currentText
var remote = remoteField.text.trim()
@ -647,102 +606,113 @@ ApplicationWindow {
}
delegate: ColumnLayout {
id: blockItem
width: chatView.width - 8
spacing: 2
// Header decoration
Rectangle {
Layout.fillWidth: true
height: 22
radius: 3
color: {
// 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
}
property bool blockExpanded: {
switch (blockType) {
case "user": return true
case "assistant": return true
case "tool": return true
default: return false
}
}
ColumnLayout {
// Clickable header
Rectangle {
Layout.fillWidth: true
Layout.leftMargin: 4
Layout.rightMargin: 4
Layout.bottomMargin: 4
spacing: 0
// Rendered HTML (fenced code/diff) — shown when available
TextEdit {
Layout.fillWidth: true
visible: 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
height: 22
radius: 3
color: {
var bg = theme.baseColor
if (theme.isDark) {
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 {
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)
Label {
Layout.fillWidth: true
visible: (!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
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: blockItem.blockExpanded = !blockItem.blockExpanded
}
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);
}
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)
{
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;
QProcess proc;
QStringList fullArgs = {"-a", serverAddr(), "write", "new"};
QStringList fullArgs = {"-a", serverAddr(), "write", "session/new"};
proc.start(ninepBin(), fullArgs);
proc.waitForStarted(3000);
@ -280,7 +287,9 @@ void Ollie9pClient::ensureRootDataLoaded()
QString be = line.left(tab).trimmed();
QString mo = line.mid(tab + 1).trimmed();
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;

View File

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