Watch
1
0
Fork
You've already forked souveraine
0
souveraine/surfaces/quickshell/modules/ii/sidebarLeft/AiChat.qml
Fimeg 2bd61ec4b3 panel: put Souveraine controls at the hand
Move agent and conversation authority into truthful footer controls, keep external sessions observed-only, and give recent server conversations an explicit picker.\n\nProject itinerary state into a persistent ribbon, invalidate it on every itinerary mutation, and keep failures in the transcript while successful route chatter yields to the ribbon. Long tool returns now scroll in place.\n\nThe SAF now owns the full Panel contract and parity boundary; the abandoned green observer card is removed.
2026-08-17 11:55:36 -04:00

1075 lines
48 KiB
QML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import qs.modules.souveraine.subconscious
import qs.modules.ii.sidebarLeft.aiChat
import qs.modules.souveraine.agent
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: []
property bool agentMenuOpen: false
property bool conversationMenuOpen: false
// Turn clock formatting. A local 9B can take minutes on a cold prompt, so
// the readout has to stay legible from milliseconds to tens of minutes.
function formatTurnDuration(ms) {
if (ms < 1000)
return `${ms}ms`;
const seconds = ms / 1000;
if (seconds < 60)
return `${seconds.toFixed(1)}s`;
const minutes = Math.floor(seconds / 60);
const rest = Math.floor(seconds % 60);
return `${minutes}m ${rest < 10 ? "0" : ""}${rest}s`;
}
function formatClockTime(msEpoch) {
if (!msEpoch)
return "—";
return new Date(msEpoch).toLocaleTimeString(Qt.locale(), "HH:mm:ss");
}
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: "agent",
description: Translation.tr("Choose Souveraine agent"),
execute: args => {
Ai.setModel(args[0]);
}
},
{
name: "resume",
description: Translation.tr("Resume the selected agent's latest conversation"),
execute: () => {
Ai.resumeConversation();
}
},
{
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: "new",
description: Translation.tr("Start a new conversation"),
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);
}
},
]
// Latest visible assistant reply — what gets read aloud. Skips
// lock-redacted content (LockContentPolicy swaps it for a marker).
function lastAssistantText() {
for (let i = Ai.messageIDs.length - 1; i >= 0; i--) {
const m = Ai.messageByID[Ai.messageIDs[i]];
if (m && m.role === "assistant" && (m.visibleToUser ?? true)
&& m.content && m.content.length > 0
&& !m.content.startsWith("[content hidden")) {
return StringUtils.ttsClean(m.content);
}
}
return "";
}
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.context.known || Ai.tokenCount.total > 0
}
StatusItem {
// Context occupancy. Until 2026-08-12 this pill showed
// `event.tokens`, which was actually the model's
// ceiling — a constant 250000 presented as usage. Now
// it shows both numbers and the ratio between them,
// which is the only reading that means anything.
visible: Ai.context.known || Ai.tokenCount.total > 0
// Tiers mirror ConsciousnessEngine::on_response exactly:
// 80 / 90 / 95 are the three compaction-warning tiers.
// They are advisory — the engine never forces compaction.
icon: Ai.context.percent >= 90 ? "error"
: Ai.context.percent >= 80 ? "warning"
: "token"
statusText: Ai.context.known
? `${Math.round(Ai.context.used / 1000)}k/${Math.round(Ai.context.limit / 1000)}k · ${Ai.context.percent}%`
: (Ai.tokenCount.total > 0 ? `${Ai.tokenCount.total}` : "—")
description: Ai.context.known
? Translation.tr("Context: %1 of %2 tokens (%3%)\nCeiling is this agent's configured context window.\nAdvisory warnings at 80/90/95% — compaction is never forced.")
.arg(Ai.context.used.toLocaleString())
.arg(Ai.context.limit.toLocaleString())
.arg(Ai.context.percent)
: Translation.tr("Context occupancy unknown until the next turn reports it.")
}
StatusSeparator {
visible: Souveraine.turnStartedAt > 0
}
StatusItem {
visible: Souveraine.turnStartedAt > 0
icon: Souveraine.turnActive ? "hourglass_top" : "timer"
statusText: root.formatTurnDuration(Souveraine.turnElapsedMs)
description: Souveraine.turnActive
? Translation.tr("Waiting on the agent\nSent at %1").arg(root.formatClockTime(Souveraine.turnStartedAt))
: Translation.tr("Last turn took %1\nSent at %2").arg(root.formatTurnDuration(Souveraine.turnElapsedMs)).arg(root.formatClockTime(Souveraine.turnStartedAt))
}
StatusSeparator {
visible: Ai.queuedInterjections > 0
}
StatusItem {
visible: Ai.queuedInterjections > 0
icon: "pan_tool"
statusText: Translation.tr("%1 queued").arg(Ai.queuedInterjections)
description: Translation.tr("Notes handed to the turn in flight.\nShe reads them at the next round boundary — a long tool round finishes first.\nQueued means sent, not yet read.")
}
}
}
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: AgentMessage {
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);
}
}
}
SubconsciousTicker {
// Tier 1 declares the intent; mounting Tier 2 is the host's job.
onRequestOpenPanel: root.subconsciousPanelOpen = true
}
ItineraryRibbon {
Layout.fillWidth: true
visible: Souveraine.itinerary?.exists ?? false
Layout.preferredHeight: visible ? implicitHeight : 0
itinerary: Souveraine.itinerary
stale: Souveraine.itineraryStale
}
AgentPaneMenu {
Layout.fillWidth: true
visible: root.agentMenuOpen
Layout.preferredHeight: visible ? implicitHeight : 0
onPicked: {
root.agentMenuOpen = false;
messageInputField.forceActiveFocus();
}
}
ConversationMenu {
Layout.fillWidth: true
visible: root.conversationMenuOpen
Layout.preferredHeight: visible ? implicitHeight : 0
onPicked: {
root.conversationMenuOpen = false;
messageInputField.forceActiveFocus();
}
}
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
// Input-method focus is evidence that typing is
// possible, not a reliable request to show the OSK.
// ViewTop preserves focus across a keyboard dismiss,
// so a second tap may not produce another focus edge.
// Drive both paths: focus raises it the first time;
// every deliberate tap raises it again after a user
// dismissal. The user-close path remains authoritative
// and no hold is taken merely because chat is open.
onActiveFocusChanged: {
if (activeFocus)
GlobalStates.oskOpen = true;
}
TapHandler {
onTapped: {
messageInputField.forceActiveFocus();
GlobalStates.oskOpen = true;
}
}
onTextChanged: {
// Handle suggestions
if (messageInputField.text.length === 0) {
root.suggestionQuery = "";
root.suggestionList = [];
return;
} else if (messageInputField.text.startsWith(`${root.commandPrefix}model`) || messageInputField.text.startsWith(`${root.commandPrefix}agent`)) {
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 ? (messageInputField.text.startsWith(`${root.commandPrefix}agent`) ? (root.commandPrefix + "agent ") : (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 detaches a file first — that is the
// older, narrower meaning and it keeps it.
if (Ai.pendingFilePath.length > 0) {
Ai.attachFile("");
event.accepted = true;
} else if (Souveraine.turnActive) {
// Nothing attached and a turn is running:
// Esc is the raised hand. Cancellation is
// read at a round boundary, not mid-tool
// (`src/server/turn.rs:248`), so partial
// work survives.
Souveraine.cancelTurn();
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
}
]
ApiInputBoxIndicator {
// This is the pane's agent control, not a provider/model
// observer. The menu it opens keeps external Codex and
// Claude sessions explicitly read-only.
icon: "neurology"
text: Ai.getModel()?.name ?? Translation.tr("No agent")
tooltipText: Souveraine.turnActive
? Translation.tr("%1 is running. Finish or cancel the turn before switching.").arg(Ai.getModel()?.name ?? Translation.tr("Agent"))
: Translation.tr("Agent controlling this conversation: %1").arg(Ai.getModel()?.name ?? Translation.tr("none"))
MouseArea {
anchors.fill: parent
hoverEnabled: false
cursorShape: Qt.PointingHandCursor
onClicked: {
root.conversationMenuOpen = false;
root.agentMenuOpen = !root.agentMenuOpen;
}
}
}
ApiInputBoxIndicator {
icon: "forum"
text: Souveraine.offeredConversationId.length > 0
? Translation.tr("Continue")
: Souveraine.conversationId.length > 0
? Translation.tr("Thread")
: Translation.tr("New")
tooltipText: Souveraine.conversationId.length > 0
? Translation.tr("Choose or start a conversation")
: Souveraine.offeredConversationId.length > 0
? Translation.tr("A recent conversation is ready to continue")
: Translation.tr("Start or resume a conversation")
MouseArea {
anchors.fill: parent
hoverEnabled: false
cursorShape: Qt.PointingHandCursor
onClicked: {
root.agentMenuOpen = false;
root.conversationMenuOpen = !root.conversationMenuOpen;
}
}
}
ApiInputBoxIndicator {
// Tier-2 subconscious log. The ticker's long-press is the
// other way in, but the ticker only exists while a pass is
// actually running — without this the event log would be
// reachable only during the few seconds it is already on
// screen, which is to say not reachable.
icon: "psychology"
text: Ai.subconsciousEvents.length > 0 ? String(Ai.subconsciousEvents.length) : ""
tooltipText: Translation.tr("Subconscious event log")
MouseArea {
anchors.fill: parent
// hoverEnabled false so hover still falls through to the
// tooltip's own MouseArea underneath; we only want clicks.
hoverEnabled: false
cursorShape: Qt.PointingHandCursor
onClicked: root.subconsciousPanelOpen = !root.subconsciousPanelOpen
}
}
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();
}
}
}
}
}
}
}
}
// ── Tier-2 subconscious event log ────────────────────────────────────
// SubconsciousEventPanel is content, not a window: by design it carries no
// scrim, no close button and no placement — its own header says so, and
// names the gap ("no souveraine host mounts this yet"). Somebody has to be
// the host, and until now nobody was: the panel was written, deployed, and
// unreachable. That is exactly the failure SHELL-SURFACES.md exists to
// stop — a surface that reaches the tree but never the glass, silently,
// because nothing errors when nothing instantiates.
//
// The AI chat is the right host: the Tier-1 ticker already lives here and
// Tier 2 is the same stream after the fact. Chrome is ours; the panel stays
// placeable anywhere else later, viewtop included.
property bool subconsciousPanelOpen: false
Item {
id: subconsciousPanelHost
anchors.fill: parent
z: 100
visible: opacity > 0
opacity: root.subconsciousPanelOpen ? 1 : 0
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Rectangle { // Scrim — tap outside the panel to dismiss
anchors.fill: parent
radius: Appearance.rounding.small
color: Appearance.colors.colScrim
MouseArea {
anchors.fill: parent
preventStealing: true
propagateComposedEvents: false
onClicked: root.subconsciousPanelOpen = false
}
}
// The panel ships no close affordance, so the host draws one. It sits
// above the panel rather than over it: the panel's own header already
// occupies its top-right with the event count.
RowLayout {
id: subconsciousChrome
anchors {
top: parent.top
left: parent.left
right: parent.right
margins: 8
}
height: 32
Item { Layout.fillWidth: true }
Item {
// Finger-sized hit area; the glyph alone is far too small a
// target on the phone.
Layout.preferredWidth: 32
Layout.preferredHeight: 32
MaterialSymbol {
anchors.centerIn: parent
text: "close"
iconSize: Appearance.font.pixelSize.larger
color: Appearance.colors.colSubtext
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.subconsciousPanelOpen = false
}
}
}
SubconsciousEventPanel {
anchors {
top: subconsciousChrome.bottom
left: parent.left
right: parent.right
bottom: parent.bottom
leftMargin: 8
rightMargin: 8
bottomMargin: 8
topMargin: 4
}
}
}
}