diff --git a/surfaces/quickshell/deploy.sh b/surfaces/quickshell/deploy.sh index ffdee75..c73d0ad 100755 --- a/surfaces/quickshell/deploy.sh +++ b/surfaces/quickshell/deploy.sh @@ -24,6 +24,7 @@ services/TaskbarApps.qml ii/services/TaskbarApps.qml services/GlobalFocusGrab.qml ii/services/GlobalFocusGrab.qml services/Idle.qml ii/services/Idle.qml modules/ii/sidebarLeft/SidebarLeft.qml ii/modules/ii/sidebarLeft/SidebarLeft.qml +modules/ii/sidebarLeft/AiChat.qml ii/modules/ii/sidebarLeft/AiChat.qml modules/ii/sidebarRight/SidebarRight.qml ii/modules/ii/sidebarRight/SidebarRight.qml modules/common/Config.qml ii/modules/common/Config.qml modules/ii/dock/Dock.qml ii/modules/ii/dock/Dock.qml diff --git a/surfaces/quickshell/modules/common/Config.qml b/surfaces/quickshell/modules/common/Config.qml index 3fc615b..b6ed9bf 100644 --- a/surfaces/quickshell/modules/common/Config.qml +++ b/surfaces/quickshell/modules/common/Config.qml @@ -403,7 +403,10 @@ Singleton { property JsonObject notifications: JsonObject { property int timeout: 7000 - property JsonObject monitor: JsonObject { + // Upstream modules (NotificationPopup, settings InterfaceConfig) + // read forceMonitor; the old "monitor" name left that undefined + // and threw a TypeError on every popup evaluation. + property JsonObject forceMonitor: JsonObject { property bool enable: false property string name: "" // Name of the monitor to show notifications on, like "eDP-1". Find out with 'hyprctl monitors' command } diff --git a/surfaces/quickshell/modules/ii/dock/DockAppButton.qml b/surfaces/quickshell/modules/ii/dock/DockAppButton.qml index 5da33d7..f2c5299 100644 --- a/surfaces/quickshell/modules/ii/dock/DockAppButton.qml +++ b/surfaces/quickshell/modules/ii/dock/DockAppButton.qml @@ -64,6 +64,10 @@ DockButton { // all gestures): tap -> activate/launch, long-press -> menu, drag -> // combine. Sits above the visual button; the button's own onClicked is // routed here via root.activate() so nothing double-fires. + // Hold/drag arbitration follows VLC's DelegateTouchTapHandler rule: a + // long-press may open the menu, but the moment movement turns into a + // drag the menu is cancelled and the drag owns the gesture (Android + // home-screen semantics). MouseArea { id: dragMouse anchors.fill: parent @@ -79,6 +83,9 @@ DockButton { dragging = true dragGhost.Drag.source = root dragGhost.Drag.active = true + // Hold-then-move means drag, not menu: a menu that opened on + // the hold gets dismissed the moment real movement starts. + root.menuOpen = false } } onPressAndHold: (mouse) => { diff --git a/surfaces/quickshell/modules/ii/dock/DockStack.qml b/surfaces/quickshell/modules/ii/dock/DockStack.qml index c962576..0ce6678 100644 --- a/surfaces/quickshell/modules/ii/dock/DockStack.qml +++ b/surfaces/quickshell/modules/ii/dock/DockStack.qml @@ -64,8 +64,27 @@ DockButton { root.highlightedIndex = -1; } + // Last open window for a member appId, or null. The stack entry carries + // its members' toplevels (TaskbarApps routes them here instead of + // making standalone icons). + function runningToplevelFor(appId) { + const tls = root.appToplevel?.toplevels ?? []; + const low = (appId ?? "").toLowerCase(); + let last = null; + for (let k = 0; k < tls.length; k++) { + if ((tls[k].appId ?? "").toLowerCase() === low) last = tls[k]; + } + return last; + } + + // Tap a member: focus its open window if it has one; launch otherwise. function launchMember(i) { if (i < 0 || i >= members.length) return; + const running = runningToplevelFor(members[i]); + if (running) { + running.activate(); + return; + } const entry = DesktopEntries.heuristicLookup(members[i]); entry?.execute(); } @@ -120,8 +139,9 @@ DockButton { Behavior on scale { NumberAnimation { duration: 120; easing.type: Easing.OutBack } } } - // Collapsed icon: topmost member, with a stacked-cards shadow behind it - // and a count dot showing how many are inside. + // Collapsed icon: GNOME-folder style — a rounded plate holding a mini + // grid of the member icons (up to 4), so it reads as "a group of these + // apps" at a glance instead of a mystery blob. Count dots underneath. contentItem: Item { Item { id: collapsedStack @@ -129,28 +149,25 @@ DockButton { width: root.iconSize height: root.iconSize - // Two offset "card" hints behind the front icon for the stacked look. Rectangle { - anchors.centerIn: parent - anchors.horizontalCenterOffset: 3 - anchors.verticalCenterOffset: -3 - width: parent.width - 4 - height: parent.height - 4 - radius: Appearance.rounding.small - color: ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.75) - } - Rectangle { - anchors.centerIn: parent - anchors.horizontalCenterOffset: 1.5 - anchors.verticalCenterOffset: -1.5 - width: parent.width - 2 - height: parent.height - 2 - radius: Appearance.rounding.small - color: ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.55) - } - IconImage { anchors.fill: parent - source: Quickshell.iconPath(AppSearch.guessIcon(root.members[0] ?? ""), "image-missing") + radius: Appearance.rounding.small + color: ColorUtils.transparentize(Appearance.m3colors.m3surfaceContainer, 0.15) + border.width: 1 + border.color: ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.8) + } + Grid { + anchors.centerIn: parent + columns: 2 + spacing: 3 + Repeater { + model: Math.min(root.members.length, 4) + delegate: IconImage { + required property int index + implicitSize: (root.iconSize - 11) / 2 + source: Quickshell.iconPath(AppSearch.guessIcon(root.members[index] ?? ""), "image-missing") + } + } } } @@ -169,7 +186,9 @@ DockButton { radius: Appearance.rounding.full implicitWidth: (root.members.length <= 3) ? root.countDotWidth : root.countDotHeight implicitHeight: root.countDotHeight - color: ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.4) + color: (root.appToplevel?.toplevels?.length ?? 0) > 0 + ? Appearance.colors.colPrimary + : ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.4) } } } @@ -355,6 +374,18 @@ DockButton { anchors.fill: parent source: Quickshell.iconPath(AppSearch.guessIcon(root.members[memberIcon.index] ?? ""), "image-missing") } + // Running dot: this member has an open window. + Rectangle { + visible: root.runningToplevelFor(root.members[memberIcon.index]) !== null + anchors { + top: parent.bottom + topMargin: 1 + horizontalCenter: parent.horizontalCenter + } + width: 8; height: 3 + radius: Appearance.rounding.full + color: Appearance.colors.colPrimary + } } } } diff --git a/surfaces/quickshell/modules/ii/sidebarLeft/AiChat.qml b/surfaces/quickshell/modules/ii/sidebarLeft/AiChat.qml new file mode 100644 index 0000000..bf1300f --- /dev/null +++ b/surfaces/quickshell/modules/ii/sidebarLeft/AiChat.qml @@ -0,0 +1,797 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import qs.modules.ii.sidebarLeft.aiChat +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import Quickshell +import Quickshell.Io + +Item { + id: root + property real padding: 4 + property var inputField: messageInputField + property string commandPrefix: "/" + + property var suggestionQuery: "" + property var suggestionList: [] + + onFocusChanged: focus => { + if (focus) { + root.inputField.forceActiveFocus(); + } + } + + Keys.onPressed: event => { + messageInputField.forceActiveFocus(); + if (event.modifiers === Qt.NoModifier) { + if (event.key === Qt.Key_PageUp) { + messageListView.contentY = Math.max(0, messageListView.contentY - messageListView.height / 2); + event.accepted = true; + } else if (event.key === Qt.Key_PageDown) { + messageListView.contentY = Math.min(messageListView.contentHeight - messageListView.height / 2, messageListView.contentY + messageListView.height / 2); + event.accepted = true; + } + } + if ((event.modifiers & Qt.ControlModifier) && (event.modifiers & Qt.ShiftModifier) && event.key === Qt.Key_O) { + Ai.clearMessages(); + } + } + + property var allCommands: [ + { + name: "attach", + description: Translation.tr("Attach a file. Only works with Gemini."), + execute: args => { + Ai.attachFile(args.join(" ").trim()); + } + }, + { + name: "model", + description: Translation.tr("Choose model"), + execute: args => { + Ai.setModel(args[0]); + } + }, + { + name: "tool", + description: Translation.tr("Set the tool to use for the model."), + execute: args => { + // console.log(args) + if (args.length == 0 || args[0] == "get") { + Ai.addMessage(Translation.tr("Usage: %1tool TOOL_NAME").arg(root.commandPrefix), Ai.interfaceRole); + } else { + const tool = args[0]; + const switched = Ai.setTool(tool); + if (switched) { + Ai.addMessage(Translation.tr("Tool set to: %1").arg(tool), Ai.interfaceRole); + } + } + } + }, + { + name: "prompt", + description: Translation.tr("Set the system prompt for the model."), + execute: args => { + if (args.length === 0 || args[0] === "get") { + Ai.printPrompt(); + return; + } + Ai.loadPrompt(args.join(" ").trim()); + } + }, + { + name: "key", + description: Translation.tr("Set API key"), + execute: args => { + if (args[0] == "get") { + Ai.printApiKey(); + } else { + Ai.setApiKey(args[0]); + } + } + }, + { + name: "save", + description: Translation.tr("Save chat"), + execute: args => { + const joinedArgs = args.join(" "); + if (joinedArgs.trim().length == 0) { + Ai.addMessage(Translation.tr("Usage: %1save CHAT_NAME").arg(root.commandPrefix), Ai.interfaceRole); + return; + } + Ai.saveChat(joinedArgs); + } + }, + { + name: "load", + description: Translation.tr("Load chat"), + execute: args => { + const joinedArgs = args.join(" "); + if (joinedArgs.trim().length == 0) { + Ai.addMessage(Translation.tr("Usage: %1load CHAT_NAME").arg(root.commandPrefix), Ai.interfaceRole); + return; + } + Ai.loadChat(joinedArgs); + } + }, + { + name: "clear", + description: Translation.tr("Clear chat history"), + execute: () => { + Ai.clearMessages(); + } + }, + { + name: "temp", + description: Translation.tr("Set temperature (randomness) of the model. Values range between 0 to 2 for Gemini, 0 to 1 for other models. Default is 0.5."), + execute: args => { + // console.log(args) + if (args.length == 0 || args[0] == "get") { + Ai.printTemperature(); + } else { + const temp = parseFloat(args[0]); + Ai.setTemperature(temp); + } + } + }, + { + name: "test", + description: Translation.tr("Markdown test"), + execute: () => { + Ai.addMessage(` + +A longer think block to test revealing animation +OwO wem ipsum dowo sit amet, consekituwet awipiscing ewit, sed do eiuwsmod tempow inwididunt ut wabowe et dowo mawa. Ut enim ad minim weniam, quis nostwud exeucitation uwuwamcow bowowis nisi ut awiquip ex ea commowo consequat. Duuis aute iwuwe dowo in wepwependewit in wowuptate velit esse ciwwum dowo eu fugiat nuwa pawiatuw. Excepteuw sint occaecat cupidatat non pwowoident, sunt in cuwpa qui officia desewunt mowit anim id est wabowum. Meouw! >w< +Mowe uwu wem ipsum! + +## ✏️ Markdown test +### Formatting + +- *Italic*, \`Monospace\`, **Bold**, [Link](https://example.com) +- Arch lincox icon + +### Table + +Quickshell vs AGS/Astal + +| | Quickshell | AGS/Astal | +|--------------------------|------------------|-------------------| +| UI Toolkit | Qt | Gtk3/Gtk4 | +| Language | QML | Js/Ts/Lua | +| Reactivity | Implied | Needs declaration | +| Widget placement | Mildly difficult | More intuitive | +| Bluetooth & Wifi support | ❌ | ✅ | +| No-delay keybinds | ✅ | ❌ | +| Development | New APIs | New syntax | + +### Code block + +Just a hello world... + +\`\`\`cpp +#include +// This is intentionally very long to test scrolling +const std::string GREETING = \"UwU\"; +int main(int argc, char* argv[]) { + std::cout << GREETING; +} +\`\`\` + +### LaTeX + + +Inline w/ dollar signs: $\\frac{1}{2} = \\frac{2}{4}$ + +Inline w/ double dollar signs: $$\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$ + +Inline w/ backslash and square brackets \\[\\int_0^\\infty \\frac{1}{x^2} dx = \\infty\\] + +Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\) +`, Ai.interfaceRole); + } + }, + ] + + function handleInput(inputText) { + if (inputText.startsWith(root.commandPrefix)) { + // Handle special commands + const command = inputText.split(" ")[0].substring(1); + const args = inputText.split(" ").slice(1); + const commandObj = root.allCommands.find(cmd => cmd.name === `${command}`); + if (commandObj) { + commandObj.execute(args); + } else { + Ai.addMessage(Translation.tr("Unknown command: ") + command, Ai.interfaceRole); + } + } else { + Ai.sendUserMessage(inputText); + } + + // Always scroll to bottom when user sends a message + messageListView.positionViewAtEnd(); + } + + Process { + id: decodeImageAndAttachProc + property string imageDecodePath: Directories.cliphistDecode + property string imageDecodeFileName: "image" + property string imageDecodeFilePath: `${imageDecodePath}/${imageDecodeFileName}` + function handleEntry(entry: string) { + imageDecodeFileName = parseInt(entry.match(/^(\d+)\t/)[1]); + decodeImageAndAttachProc.exec(["bash", "-c", `[ -f ${imageDecodeFilePath} ] || echo '${StringUtils.shellSingleQuoteEscape(entry)}' | ${Cliphist.cliphistBinary} decode > '${imageDecodeFilePath}'`]); + } + onExited: (exitCode, exitStatus) => { + if (exitCode === 0) { + Ai.attachFile(imageDecodeFilePath); + } else { + console.error("[AiChat] Failed to decode image in clipboard content"); + } + } + } + + component StatusItem: MouseArea { + id: statusItem + property string icon + property string statusText + property string description + hoverEnabled: true + implicitHeight: statusItemRowLayout.implicitHeight + implicitWidth: statusItemRowLayout.implicitWidth + + RowLayout { + id: statusItemRowLayout + spacing: 0 + MaterialSymbol { + text: statusItem.icon + iconSize: Appearance.font.pixelSize.huge + color: Appearance.colors.colSubtext + } + StyledText { + font.pixelSize: Appearance.font.pixelSize.small + text: statusItem.statusText + color: Appearance.colors.colSubtext + animateChange: true + } + } + + StyledToolTip { + text: statusItem.description + extraVisibleCondition: false + alternativeVisibleCondition: statusItem.containsMouse + } + } + + component StatusSeparator: Rectangle { + implicitWidth: 4 + implicitHeight: 4 + radius: implicitWidth / 2 + color: Appearance.colors.colOutlineVariant + } + + ColumnLayout { + id: columnLayout + anchors { + fill: parent + margins: root.padding + } + spacing: root.padding + + Item { + // Messages + Layout.fillWidth: true + Layout.fillHeight: true + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: swipeView.width + height: swipeView.height + radius: Appearance.rounding.small + } + } + + StyledRectangularShadow { + z: 1 + target: statusBg + opacity: messageListView.atYBeginning ? 0 : 1 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + Rectangle { + id: statusBg + z: 2 + anchors { + horizontalCenter: parent.horizontalCenter + top: parent.top + topMargin: 4 + } + implicitWidth: statusRowLayout.implicitWidth + 10 * 2 + implicitHeight: Math.max(statusRowLayout.implicitHeight, 38) + radius: Appearance.rounding.normal - root.padding + color: messageListView.atYBeginning ? Appearance.colors.colLayer2 : Appearance.colors.colLayer2Base + Behavior on color { + animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) + } + RowLayout { + id: statusRowLayout + anchors.centerIn: parent + spacing: 10 + + StatusItem { + icon: Ai.currentModelHasApiKey ? "key" : "key_off" + statusText: "" + description: Ai.currentModelHasApiKey ? Translation.tr("API key is set\nChange with /key YOUR_API_KEY") : Translation.tr("No API key\nSet it with /key YOUR_API_KEY") + } + StatusSeparator {} + StatusItem { + icon: "device_thermostat" + statusText: Ai.temperature.toFixed(1) + description: Translation.tr("Temperature\nChange with /temp VALUE") + } + StatusSeparator { + visible: Ai.tokenCount.total > 0 + } + StatusItem { + visible: Ai.tokenCount.total > 0 + icon: "token" + statusText: Ai.tokenCount.total + description: Translation.tr("Total token count\nInput: %1\nOutput: %2").arg(Ai.tokenCount.input).arg(Ai.tokenCount.output) + } + } + } + + ScrollEdgeFade { + z: 1 + target: messageListView + vertical: true + } + + StyledListView { // Message list + id: messageListView + z: 0 + anchors.fill: parent + spacing: 10 + popin: false + topMargin: statusBg.implicitHeight + statusBg.anchors.topMargin * 2 + + touchpadScrollFactor: Config.options.interactions.scrolling.touchpadScrollFactor * 1.4 + mouseScrollFactor: Config.options.interactions.scrolling.mouseScrollFactor * 1.4 + + property int lastResponseLength: 0 + // onContentHeightChanged: { + // if (atYEnd) + // Qt.callLater(positionViewAtEnd); + // } + // onCountChanged: { + // // Auto-scroll when new messages are added + // if (atYEnd) + // Qt.callLater(positionViewAtEnd); + // } + + add: null // Prevent function calls from being janky + + model: ScriptModel { + values: Ai.messageIDs.filter(id => { + const message = Ai.messageByID[id]; + return message?.visibleToUser ?? true; + }) + } + delegate: AiMessage { + required property var modelData + required property int index + messageIndex: index + messageData: { + Ai.messageByID[modelData]; + } + messageInputField: root.inputField + } + } + + PagePlaceholder { + z: 2 + shown: Ai.messageIDs.length === 0 + icon: "neurology" + title: Translation.tr("Large language models") + description: Translation.tr("Type /key to get started with online models\nCtrl+O to expand sidebar\nCtrl+P to pin sidebar\nCtrl+D to detach sidebar") + shape: MaterialShape.Shape.PixelCircle + } + + ScrollToBottomButton { + z: 3 + target: messageListView + } + } + + DescriptionBox { + text: root.suggestionList[suggestions.selectedIndex]?.description ?? "" + showArrows: root.suggestionList.length > 1 + } + + FlowButtonGroup { // Suggestions + id: suggestions + visible: root.suggestionList.length > 0 && messageInputField.text.length > 0 + property int selectedIndex: 0 + Layout.fillWidth: true + spacing: 5 + + Repeater { + id: suggestionRepeater + model: { + suggestions.selectedIndex = 0; + return root.suggestionList.slice(0, 10); + } + delegate: ApiCommandButton { + id: commandButton + colBackground: suggestions.selectedIndex === index ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colSecondaryContainer + bounce: false + contentItem: StyledText { + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.m3colors.m3onSurface + horizontalAlignment: Text.AlignHCenter + text: modelData.displayName ?? modelData.name + } + + onHoveredChanged: { + if (commandButton.hovered) { + suggestions.selectedIndex = index; + } + } + onClicked: { + suggestions.acceptSuggestion(modelData.name); + } + } + } + + function acceptSuggestion(word) { + const words = messageInputField.text.trim().split(/\s+/); + if (words.length > 0) { + words[words.length - 1] = word; + } else { + words.push(word); + } + const updatedText = words.join(" ") + " "; + messageInputField.text = updatedText; + messageInputField.cursorPosition = messageInputField.text.length; + messageInputField.forceActiveFocus(); + } + + function acceptSelectedWord() { + if (suggestions.selectedIndex >= 0 && suggestions.selectedIndex < suggestionRepeater.count) { + const word = root.suggestionList[suggestions.selectedIndex].name; + suggestions.acceptSuggestion(word); + } + } + } + + Rectangle { // Input area + id: inputWrapper + property real spacing: 5 + Layout.fillWidth: true + radius: Appearance.rounding.normal - root.padding + color: Appearance.colors.colLayer2 + implicitHeight: Math.max(inputFieldRowLayout.implicitHeight + inputFieldRowLayout.anchors.topMargin + commandButtonsRow.implicitHeight + commandButtonsRow.anchors.bottomMargin + spacing, 45) + (attachedFileIndicator.implicitHeight + spacing + attachedFileIndicator.anchors.topMargin) + clip: true + + Behavior on implicitHeight { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + AttachedFileIndicator { + id: attachedFileIndicator + anchors { + top: parent.top + left: parent.left + right: parent.right + margins: visible ? 5 : 0 + } + filePath: Ai.pendingFilePath + onRemove: Ai.attachFile("") + } + + RowLayout { // Input field and send button + id: inputFieldRowLayout + anchors { + bottom: commandButtonsRow.top + left: parent.left + right: parent.right + bottomMargin: 5 + } + spacing: 0 + + ScrollView { + id: inputScrollView + Layout.fillWidth: true + Layout.preferredHeight: Math.min(root.height * 3/5, messageInputField.height) + clip: true + ScrollBar.vertical.policy: ScrollBar.AsNeeded + + StyledTextArea { // The actual TextArea (inside ScrollView to enable scrolling) + id: messageInputField + anchors.fill: parent + wrapMode: TextArea.Wrap + padding: 10 + color: activeFocus ? Appearance.m3colors.m3onSurface : Appearance.m3colors.m3onSurfaceVariant + placeholderText: Translation.tr('Message the model... "%1" for commands').arg(root.commandPrefix) + + background: null + + onTextChanged: { + // Handle suggestions + if (messageInputField.text.length === 0) { + root.suggestionQuery = ""; + root.suggestionList = []; + return; + } else if (messageInputField.text.startsWith(`${root.commandPrefix}model`)) { + root.suggestionQuery = messageInputField.text.split(" ")[1] ?? ""; + const modelResults = Fuzzy.go(root.suggestionQuery, Ai.modelList.map(model => { + return { + name: Fuzzy.prepare(model), + obj: model + }; + }), { + all: true, + key: "name" + }); + root.suggestionList = modelResults.map(model => { + return { + name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "model ") : ""}${model.target}`, + displayName: `${Ai.models[model.target].name}`, + description: `${Ai.models[model.target].description}` + }; + }); + } else if (messageInputField.text.startsWith(`${root.commandPrefix}prompt`)) { + root.suggestionQuery = messageInputField.text.split(" ")[1] ?? ""; + const promptFileResults = Fuzzy.go(root.suggestionQuery, Ai.promptFiles.map(file => { + return { + name: Fuzzy.prepare(file), + obj: file + }; + }), { + all: true, + key: "name" + }); + root.suggestionList = promptFileResults.map(file => { + return { + name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "prompt ") : ""}${file.target}`, + displayName: `${FileUtils.trimFileExt(FileUtils.fileNameForPath(file.target))}`, + description: Translation.tr("Load prompt from %1").arg(file.target) + }; + }); + } else if (messageInputField.text.startsWith(`${root.commandPrefix}save`)) { + root.suggestionQuery = messageInputField.text.split(" ")[1] ?? ""; + const promptFileResults = Fuzzy.go(root.suggestionQuery, Ai.savedChats.map(file => { + return { + name: Fuzzy.prepare(file), + obj: file + }; + }), { + all: true, + key: "name" + }); + root.suggestionList = promptFileResults.map(file => { + const chatName = FileUtils.trimFileExt(FileUtils.fileNameForPath(file.target)).trim(); + return { + name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "save ") : ""}${chatName}`, + displayName: `${chatName}`, + description: Translation.tr("Save chat to %1").arg(chatName) + }; + }); + } else if (messageInputField.text.startsWith(`${root.commandPrefix}load`)) { + root.suggestionQuery = messageInputField.text.split(" ")[1] ?? ""; + const promptFileResults = Fuzzy.go(root.suggestionQuery, Ai.savedChats.map(file => { + return { + name: Fuzzy.prepare(file), + obj: file + }; + }), { + all: true, + key: "name" + }); + root.suggestionList = promptFileResults.map(file => { + const chatName = FileUtils.trimFileExt(FileUtils.fileNameForPath(file.target)).trim(); + return { + name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "load ") : ""}${chatName}`, + displayName: `${chatName}`, + description: Translation.tr(`Load chat from %1`).arg(file.target) + }; + }); + } else if (messageInputField.text.startsWith(`${root.commandPrefix}tool`)) { + root.suggestionQuery = messageInputField.text.split(" ")[1] ?? ""; + const toolResults = Fuzzy.go(root.suggestionQuery, Ai.availableTools.map(tool => { + return { + name: Fuzzy.prepare(tool), + obj: tool + }; + }), { + all: true, + key: "name" + }); + root.suggestionList = toolResults.map(tool => { + const toolName = tool.target; + return { + name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "tool ") : ""}${tool.target}`, + displayName: toolName, + description: Ai.toolDescriptions[toolName] + }; + }); + } else if (messageInputField.text.startsWith(root.commandPrefix)) { + root.suggestionQuery = messageInputField.text; + root.suggestionList = root.allCommands.filter(cmd => cmd.name.startsWith(messageInputField.text.substring(1))).map(cmd => { + return { + name: `${root.commandPrefix}${cmd.name}`, + description: `${cmd.description}` + }; + }); + } + } + + function accept() { + root.handleInput(text); + text = ""; + } + + Keys.onPressed: event => { + if (event.key === Qt.Key_Tab) { + suggestions.acceptSelectedWord(); + event.accepted = true; + } else if (event.key === Qt.Key_Up && suggestions.visible) { + suggestions.selectedIndex = Math.max(0, suggestions.selectedIndex - 1); + event.accepted = true; + } else if (event.key === Qt.Key_Down && suggestions.visible) { + suggestions.selectedIndex = Math.min(root.suggestionList.length - 1, suggestions.selectedIndex + 1); + event.accepted = true; + } else if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return)) { + if (event.modifiers & Qt.ShiftModifier) { + // Insert newline + messageInputField.insert(messageInputField.cursorPosition, "\n"); + event.accepted = true; + } else { + // Accept text + const inputText = messageInputField.text; + messageInputField.clear(); + root.handleInput(inputText); + event.accepted = true; + } + } else if ((event.modifiers & Qt.ControlModifier) && event.key === Qt.Key_V) { + // Intercept Ctrl+V to handle image/file pasting + if (event.modifiers & Qt.ShiftModifier) { + // Let Shift+Ctrl+V = plain paste + messageInputField.text += Quickshell.clipboardText; + event.accepted = true; + return; + } + // Try image paste first + const currentClipboardEntry = Cliphist.entries[0]; + const cleanCliphistEntry = StringUtils.cleanCliphistEntry(currentClipboardEntry); + if (/^\d+\t\[\[.*binary data.*\d+x\d+.*\]\]$/.test(currentClipboardEntry)) { + // First entry = currently copied entry = image? + decodeImageAndAttachProc.handleEntry(currentClipboardEntry); + event.accepted = true; + return; + } else if (cleanCliphistEntry.startsWith("file://")) { + // First entry = currently copied entry = image? + const fileName = decodeURIComponent(cleanCliphistEntry); + Ai.attachFile(fileName); + event.accepted = true; + return; + } + event.accepted = false; // No image, let text pasting proceed + } else if (event.key === Qt.Key_Escape) { + // Esc to detach file + if (Ai.pendingFilePath.length > 0) { + Ai.attachFile(""); + event.accepted = true; + } else { + event.accepted = false; + } + } + } + } + } + RippleButton { // Send button + id: sendButton + Layout.alignment: Qt.AlignBottom + Layout.rightMargin: 5 + implicitWidth: 40 + implicitHeight: 40 + buttonRadius: Appearance.rounding.small + enabled: messageInputField.text.length > 0 + toggled: enabled + + MouseArea { + anchors.fill: parent + cursorShape: sendButton.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + const inputText = messageInputField.text; + root.handleInput(inputText); + messageInputField.clear(); + } + } + + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: 22 + color: sendButton.enabled ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer2Disabled + text: "arrow_upward" + } + } + } + + RowLayout { // Controls + id: commandButtonsRow + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.bottomMargin: 5 + anchors.leftMargin: 10 + anchors.rightMargin: 5 + spacing: 4 + + property var commandsShown: [ + { + name: "", + sendDirectly: false, + dontAddSpace: true + }, + { + name: "clear", + sendDirectly: true + }, + ] + + ApiInputBoxIndicator { + // Model indicator. Souveraine's Ai service has no + // hardcoded models — getModel() is undefined until the + // server's /v1/models lands, so guard every access. + icon: "api" + text: Ai.getModel()?.name ?? Translation.tr("No model") + tooltipText: Translation.tr("Current model: %1\nSet it with %2model MODEL").arg(Ai.getModel()?.name ?? Translation.tr("none")).arg(root?.commandPrefix ?? "/") + } + + ApiInputBoxIndicator { + // Tool indicator + icon: "service_toolbox" + text: (Ai.currentTool ?? "").charAt(0).toUpperCase() + (Ai.currentTool ?? "").slice(1) + tooltipText: Translation.tr("Current tool: %1\nSet it with %2tool TOOL").arg(Ai.currentTool ?? "").arg(root?.commandPrefix ?? "/") + } + + Item { + Layout.fillWidth: true + } + + ButtonGroup { + // Command buttons + padding: 0 + + Repeater { + // Command buttons + model: commandButtonsRow.commandsShown + delegate: ApiCommandButton { + property string commandRepresentation: `${root.commandPrefix}${modelData.name}` + buttonText: commandRepresentation + downAction: () => { + if (modelData.sendDirectly) { + root.handleInput(commandRepresentation); + } else { + messageInputField.text = commandRepresentation + (modelData.dontAddSpace ? "" : " "); + messageInputField.cursorPosition = messageInputField.text.length; + messageInputField.forceActiveFocus(); + } + if (modelData.name === "clear") { + messageInputField.text = ""; + } + } + } + } + } + } + } + } +} diff --git a/surfaces/quickshell/pill/shell.qml b/surfaces/quickshell/pill/shell.qml index c2f159b..bda2fd0 100644 --- a/surfaces/quickshell/pill/shell.qml +++ b/surfaces/quickshell/pill/shell.qml @@ -48,7 +48,9 @@ ShellRoot { if (fired) return if (startY - mouse.y > 35) { fired = true - Quickshell.execDetached(["hyprctl", "dispatch", "workspace", "empty"]) + // This Hyprland is the lua fork: hyprctl dispatch takes + // lua dispatcher calls, not classic "workspace empty". + Quickshell.execDetached(["hyprctl", "dispatch", "hl.dsp.focus({ workspace = \"empty\" })"]) } else if (mouse.y - startY > 35) { fired = true Quickshell.execDetached(["qs", "-c", "ii", "ipc", "call", "dock", "swipeDown"]) @@ -57,8 +59,9 @@ ShellRoot { onDoubleClicked: { fired = true // Maximize/un-maximize the active window (Hyprland fullscreen - // mode 1). No-op with nothing focused. - Quickshell.execDetached(["hyprctl", "dispatch", "fullscreen", "1"]) + // mode 1). No-op with nothing focused. Lua-fork dispatcher + // syntax — classic "fullscreen 1" errors out on this build. + Quickshell.execDetached(["hyprctl", "dispatch", "hl.dsp.window.fullscreen({ mode = 1 })"]) } } } diff --git a/surfaces/quickshell/services/TaskbarApps.qml b/surfaces/quickshell/services/TaskbarApps.qml index 7b4254d..1609108 100644 --- a/surfaces/quickshell/services/TaskbarApps.qml +++ b/surfaces/quickshell/services/TaskbarApps.qml @@ -146,8 +146,12 @@ Singleton { // so an app lives in one place: its stack. const stacks = root.stacksList(); const stackedMembers = new Set(); + const memberToStackKey = new Map(); for (const s of stacks) { - for (const m of s.members) stackedMembers.add(m.toLowerCase()); + for (const m of s.members) { + stackedMembers.add(m.toLowerCase()); + memberToStackKey.set(m.toLowerCase(), "STACK:" + s.id); + } map.set("STACK:" + s.id, { pinned: true, toplevels: [], isStack: true, members: s.members }); @@ -174,9 +178,14 @@ Singleton { // Open windows for (const toplevel of ToplevelManager.toplevels.values) { if (ignoredRegexes.some(re => re.test(toplevel.appId))) continue; - // A running app that belongs to a stack keeps its running dot on - // the stack, not as a separate icon. - if (stackedMembers.has(toplevel.appId.toLowerCase())) continue; + // A running app that belongs to a stack contributes its windows + // to the STACK entry (so tapping the member focuses the open + // window) instead of appearing as a separate icon. + const stackKey = memberToStackKey.get(toplevel.appId.toLowerCase()); + if (stackKey) { + map.get(stackKey).toplevels.push(toplevel); + continue; + } if (!map.has(toplevel.appId.toLowerCase())) map.set(toplevel.appId.toLowerCase(), ({ pinned: false, toplevels: []