Watch
1
0
Fork
You've already forked souveraine
0

dock/pill: movement cancels hold-menu, folder-style stack icon, tap focuses running member; lua-fork hyprctl dispatch syntax; kill AiChat/notification TypeErrors

This commit is contained in:
Fimeg 2026-07-12 09:51:09 -04:00
commit 8915ac2a1a
7 changed files with 880 additions and 29 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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) => {

View file

@ -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
}
}
}
}

View file

@ -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(`
<think>
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!
</think>
## Markdown test
### Formatting
- *Italic*, \`Monospace\`, **Bold**, [Link](https://example.com)
- Arch lincox icon <img src="${Quickshell.shellPath("assets/icons/arch-symbolic.svg")}" height="${Appearance.font.pixelSize.small}"/>
### 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 <bits/stdc++.h>
// 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 = "";
}
}
}
}
}
}
}
}
}

View file

@ -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 })"])
}
}
}

View file

@ -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: []