From 64afb587e36219726726aa1fb9d57ac5a6b529cd Mon Sep 17 00:00:00 2001 From: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:47:37 +0200 Subject: [PATCH] add slash command autocomplete: popup, model/backend completions, keyboard nav --- gui/main.qml | 257 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 256 insertions(+), 1 deletion(-) diff --git a/gui/main.qml b/gui/main.qml index e819448..9eeb4f4 100644 --- a/gui/main.qml +++ b/gui/main.qml @@ -62,12 +62,196 @@ ApplicationWindow { Shortcut { sequence: "Escape" onActivated: { - if (ollie.activeState !== "idle") { + if (completionPopup.visible) { + completionPopup.close() + } else if (ollie.activeState !== "idle") { ollie.interrupt() } } } + // --- Slash command definitions --- + readonly property var slashCommands: [ + { name: "model", desc: "Set model" }, + { name: "backend", desc: "Set backend" }, + { name: "agent", desc: "Switch agent" }, + { name: "stop", desc: "Stop current run" }, + { name: "kill", desc: "Kill session" }, + { name: "new", desc: "New session" }, + { name: "compact", desc: "Compact context" }, + { name: "help", desc: "Show help" } + ] + + // --- Completion popup --- + Popup { + id: completionPopup + x: 80 + y: parent.height - 280 + width: Math.min(360, parent.width - 160) + height: Math.min(200, visibleMatches * 28 + 32) + modal: false + focus: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent + + background: Rectangle { + radius: 6 + color: theme.isDark ? "#1e1e1e" : "#ffffff" + border.color: theme.midColor + border.width: 1 + elevation: 4 + } + + ListView { + id: completionList + anchors.fill: parent + anchors.margins: 4 + model: completionModel + currentIndex: -1 + clip: true + + delegate: ItemDelegate { + width: completionList.width + height: 28 + highlighted: index === completionList.currentIndex + + contentItem: RowLayout { + spacing: 8 + + Label { + text: model.name + font.bold: true + font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 11 + color: theme.textColor + } + + Item { Layout.fillWidth: true } + + Label { + text: model.desc || "" + opacity: 0.5 + font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.8 : 10 + color: theme.textColor + } + } + + onClicked: { + completionList.currentIndex = index + acceptCompletion() + } + } + + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Down) { + if (completionList.currentIndex < completionList.count - 1) { + completionList.currentIndex++ + event.accepted = true + } + } else if (event.key === Qt.Key_Up) { + if (completionList.currentIndex > 0) { + completionList.currentIndex-- + event.accepted = true + } + } else if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) { + if (completionList.currentIndex >= 0) { + acceptCompletion() + event.accepted = true + } + } else if (event.key === Qt.Key_Escape) { + completionPopup.close() + event.accepted = true + } + } + + Keys.onTabPressed: { + if (completionList.currentIndex >= 0) { + acceptCompletion() + event.accepted = true + } + } + } + + ListModel { + id: completionModel + } + + function open(prefix) { + updateCompletions(prefix) + visible = true + promptInput.forceActiveFocus() + } + + function close() { + visible = false + completionModel.clear() + completionList.currentIndex = -1 + } + + function updateCompletions(prefix) { + completionModel.clear() + var parts = prefix.split(" ") + var cmd = parts[0].toLowerCase() + var arg = parts.length > 1 ? parts.slice(1).join(" ") : "" + + // If there's an argument part after a known command, show argument completions + if (arg !== "") { + if (cmd === "model") { + var models = ollie.getAvailableModels(ollie.availableBackends[currentBackendIndex] || "") + for (var i = 0; i < models.length; ++i) { + if (models[i].toLowerCase().indexOf(arg.toLowerCase()) >= 0) { + completionModel.append({ name: models[i], desc: "", isArg: true }) + } + } + } else if (cmd === "backend") { + var bks = ollie.availableBackends + for (var j = 0; j < bks.length; ++j) { + if (bks[j].toLowerCase().indexOf(arg.toLowerCase()) >= 0) { + completionModel.append({ name: bks[j], desc: "", isArg: true }) + } + } + } + } else { + // Show command completions filtered by prefix + for (var k = 0; k < root.slashCommands.length; ++k) { + var sc = root.slashCommands[k] + if (sc.name.indexOf(cmd) >= 0) { + completionModel.append({ name: sc.name, desc: sc.desc, isArg: false }) + } + } + } + + if (completionModel.count > 0) { + completionList.currentIndex = 0 + visible = true + } else { + visible = false + } + } + + function acceptCompletion() { + if (completionList.currentIndex < 0 || completionList.currentIndex >= completionModel.count) return + var item = completionModel.get(completionList.currentIndex) + var oldText = promptInput.text + var slashPos = oldText.lastIndexOf("/") + if (slashPos < 0) return + + var beforeSlash = oldText.substring(0, slashPos) + var afterSlash = oldText.substring(slashPos + 1) + var parts = afterSlash.split(" ") + + if (item.isArg) { + // Replace the argument part + parts[parts.length - 1] = item.name + promptInput.text = beforeSlash + "/" + parts.join(" ") + } else { + // Replace command with command + space + promptInput.text = beforeSlash + "/" + item.name + " " + } + close() + } + + property int visibleMatches: completionModel.count + } + // Deferred scroll timer — fires once after model changes settle Timer { id: scrollTimer @@ -744,6 +928,77 @@ ApplicationWindow { placeholderText: ollie.activeSessionId ? "Message..." : "No session" enabled: ollie.activeSessionId !== "" wrapMode: TextEdit.Wrap + + property string _lastText: "" + property int _lastCursorPos: 0 + + onTextChanged: { + var oldText = _lastText + var newText = text + var cursorPos = cursorPosition + + // Detect slash prefix (text after last "/" up to cursor) + if (newText.indexOf("/") >= 0) { + var slashPos = newText.lastIndexOf("/") + if (slashPos < cursorPos) { + var prefix = newText.substring(slashPos + 1, cursorPos) + if (prefix !== oldText.substring(oldText.lastIndexOf("/"), cursorPos) || + oldText !== _lastText) { + completionPopup.open(prefix) + } + } else { + completionPopup.close() + } + } else { + completionPopup.close() + } + + _lastText = newText + _lastCursorPos = cursorPos + } + + onCursorPositionChanged: { + var newText = text + if (newText.indexOf("/") >= 0) { + var slashPos = newText.lastIndexOf("/") + if (slashPos < cursorPosition) { + var prefix = newText.substring(slashPos + 1, cursorPosition) + completionPopup.open(prefix) + } else { + completionPopup.close() + } + } else { + completionPopup.close() + } + } + + Keys.onPressed: function(event) { + if (completionPopup.visible) { + if (event.key === Qt.Key_Tab) { + completionPopup.acceptCompletion() + event.accepted = true + } else if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) { + if (completionPopup.completionList.currentIndex >= 0) { + completionPopup.acceptCompletion() + event.accepted = true + } + } else if (event.key === Qt.Key_Escape) { + completionPopup.close() + event.accepted = true + } else if (event.key === Qt.Key_Down) { + if (completionPopup.completionList.currentIndex < completionPopup.completionList.count - 1) { + completionPopup.completionList.currentIndex++ + event.accepted = true + } + } else if (event.key === Qt.Key_Up) { + if (completionPopup.completionList.currentIndex > 0) { + completionPopup.completionList.currentIndex-- + event.accepted = true + } + } + } + } + Keys.onReturnPressed: function(event) { if (event.modifiers & Qt.ShiftModifier) { event.accepted = false