From 2af7d65f4eb4cc47d350440dbfa88ecb71afa9c3 Mon Sep 17 00:00:00 2001 From: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:56:32 +0200 Subject: [PATCH] convert slash autocomplete to inline text expansion with LCP and arg completion --- gui/main.qml | 346 ++++++++++++++++++++++----------------------------- 1 file changed, 150 insertions(+), 196 deletions(-) diff --git a/gui/main.qml b/gui/main.qml index ef576ef..2f8c40e 100644 --- a/gui/main.qml +++ b/gui/main.qml @@ -62,9 +62,7 @@ ApplicationWindow { Shortcut { sequence: "Escape" onActivated: { - if (completionPopup.visible) { - completionPopup.close() - } else if (ollie.activeState !== "idle") { + if (ollie.activeState !== "idle") { ollie.interrupt() } } @@ -82,175 +80,95 @@ ApplicationWindow { { 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 + // --- Inline autocomplete state machine --- + // States: none -> start -> autocompleting -> done (tab/esc/no-match) + property string _acBase: "" // text before the slash command (includes "/") + property string _acTyped: "" // what user typed after / + property var _acMatches: [] // matching options + property bool _acActive: false // in autocomplete mode + property int _acMatchIndex: 0 // which match is currently shown + property string _acType: "cmd" // "cmd", "model", or "backend" - background: Rectangle { - radius: 6 - color: theme.isDark ? "#1e1e1e" : "#ffffff" - border.color: theme.midColor - border.width: 1 - } - - 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() - } + function acFindMatches(prefix, type) { + if (type === "model") { + var models = ollie.getAvailableModels(ollie.currentBackend || "") + var m = [] + for (var i = 0; i < models.length; ++i) { + if (models[i].toLowerCase().indexOf(prefix.toLowerCase()) >= 0) + m.push(models[i]) } - - 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 - } + return m + } else if (type === "backend") { + var bks = ollie.availableBackends + var m = [] + for (var j = 0; j < bks.length; ++j) { + if (bks[j].toLowerCase().indexOf(prefix.toLowerCase()) >= 0) + m.push(bks[j]) } - - Keys.onTabPressed: { - if (completionList.currentIndex >= 0) { - acceptCompletion() - event.accepted = true - } + return m + } else { + var m = [] + for (var k = 0; k < root.slashCommands.length; ++k) { + if (root.slashCommands[k].name.indexOf(prefix) >= 0) + m.push(root.slashCommands[k].name) } + return m } - - 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 } + function acLCP(arr) { + if (arr.length === 0) return "" + if (arr.length === 1) return arr[0] + var lcp = arr[0] + for (var i = 1; i < arr.length; ++i) { + while (!arr[i].startsWith(lcp)) { + lcp = lcp.substring(0, lcp.length - 1) + if (lcp === "") return "" + } + } + return lcp + } + + function acEnter(prefix, type) { + _acActive = true + _acTyped = prefix + _acMatches = acFindMatches(prefix, type) + _acMatchIndex = 0 + if (_acMatches.length > 0) { + var lcp = acLCP(_acMatches) + if (lcp.length > prefix.length) { + text = _acBase + lcp + cursorPosition = text.length + } + } else { + // No options — transition to done immediately + _acActive = false + _acBase = "" + _acTyped = "" + _acMatches = [] + } + } + + function acCycle() { + if (!_acActive || _acMatches.length <= 1) return + _acMatchIndex = (_acMatchIndex + 1) % _acMatches.length + var parts = text.substring(text.lastIndexOf("/") + 1).split(" ") + parts[0] = _acMatches[_acMatchIndex] + text = _acBase + "/" + parts.join(" ") + cursorPosition = text.length + } + + function acCancel() { + if (!_acActive) return + text = _acBase + _acTyped + cursorPosition = text.length + _acActive = false + _acBase = "" + _acTyped = "" + _acMatches = [] + } + + // Deferred scroll timer — fires once after model changes settle Timer { id: scrollTimer @@ -928,6 +846,8 @@ ApplicationWindow { enabled: ollie.activeSessionId !== "" wrapMode: TextEdit.Wrap + property string _lastText: "" + property int _lastCursorPos: 0 property string _lastText: "" property int _lastCursorPos: 0 @@ -936,20 +856,49 @@ ApplicationWindow { var newText = text var cursorPos = cursorPosition - // Detect slash prefix (text after last "/" up to cursor) - if (newText.indexOf("/") >= 0) { + // Check if we just completed a command name and added a space + if (_acActive && _acMatches.length > 0) { + var afterSlash = newText.substring(newText.lastIndexOf("/") + 1) + var parts = afterSlash.split(" ") + // If first part matches a known command exactly and there's a second part + if (parts.length >= 2) { + var cmdName = parts[0].toLowerCase() + if (cmdName === "model") { + // Switch to model arg completion — update base to include command + _acBase = newText.substring(0, newText.lastIndexOf("/") + 1) + parts[0] + " " + acEnter(parts.slice(1).join(" "), "model") + return + } else if (cmdName === "backend") { + // Switch to backend arg completion + _acBase = newText.substring(0, newText.lastIndexOf("/") + 1) + parts[0] + " " + acEnter(parts.slice(1).join(" "), "backend") + return + } + } + } + + if (!_acActive && 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) + if (prefix.length > 0 && prefix !== " ") { + _acBase = newText.substring(0, slashPos + 1) + acEnter(prefix, "cmd") } - } else { - completionPopup.close() } - } else { - completionPopup.close() + } else if (_acActive) { + // User is typing inside autocomplete — re-enter with updated prefix + var slashPos = newText.lastIndexOf("/") + var prefix = newText.substring(slashPos + 1, cursorPos) + if (prefix.length > 0 && prefix !== " ") { + _acBase = newText.substring(0, slashPos + 1) + acEnter(prefix, _acType) + } else { + acCancel() + } + } else if (newText.indexOf("/") < 0) { + // No slash at all — cancel any lingering state + acCancel() } _lastText = newText @@ -957,41 +906,47 @@ ApplicationWindow { } onCursorPositionChanged: { - var newText = text - if (newText.indexOf("/") >= 0) { - var slashPos = newText.lastIndexOf("/") + if (!_acActive && text.indexOf("/") >= 0) { + var slashPos = text.lastIndexOf("/") if (slashPos < cursorPosition) { - var prefix = newText.substring(slashPos + 1, cursorPosition) - completionPopup.open(prefix) - } else { - completionPopup.close() + var prefix = text.substring(slashPos + 1, cursorPosition) + if (prefix.length > 0 && prefix !== " ") { + _acBase = text.substring(0, slashPos + 1) + acEnter(prefix, "cmd") + } + } + } else if (_acActive) { + var slashPos = text.lastIndexOf("/") + var prefix = text.substring(slashPos + 1, cursorPosition) + if (prefix.length > 0 && prefix !== " ") { + _acBase = text.substring(0, slashPos + 1) + acEnter(prefix, _acType) + } else { + acCancel() } - } else { - completionPopup.close() } } Keys.onPressed: function(event) { - if (completionPopup.visible) { + if (_acActive) { if (event.key === Qt.Key_Tab) { - completionPopup.acceptCompletion() + acCycle() 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() + acCancel() event.accepted = true } else if (event.key === Qt.Key_Down) { - if (completionPopup.completionList.currentIndex < completionPopup.completionList.count - 1) { - completionPopup.completionList.currentIndex++ + if (_acMatches.length > 1) { + acCycle() event.accepted = true } } else if (event.key === Qt.Key_Up) { - if (completionPopup.completionList.currentIndex > 0) { - completionPopup.completionList.currentIndex-- + if (_acMatches.length > 1) { + _acMatchIndex = (_acMatchIndex - 1 + _acMatches.length) % _acMatches.length + var parts = text.substring(text.lastIndexOf("/") + 1).split(" ") + parts[0] = _acMatches[_acMatchIndex] + text = _acBase + "/" + parts.join(" ") + cursorPosition = text.length event.accepted = true } } @@ -1014,4 +969,3 @@ ApplicationWindow { } } } -} \ No newline at end of file