Souveraine.send returns false for a busy turn exactly as it does for a dead
server, and the sidebar rendered every false as "server unreachable" — so
typing while she worked showed the words, then a lie, and dropped the text.
The verbs existed on both sides since 93fe30a and nothing called them.
Type-while-busy now interjects, as the TUI has always done
(src/ui/app/mod.rs:1006). Esc with nothing attached raises a hand. A queued
count sits in the status row and says sent, not read, because 202 from
/interject means only that it was taken.
Deliberately unchanged: the two drain sites and their system/user registers.
Providers disagree about which one they want and that choice is not ready to
be made. Latency is unchanged too — turn.rs:600 runs a whole tool round
without looking, so a note sent into a fifteen-call round waits for it.
1028 lines
46 KiB
QML
1028 lines
46 KiB
QML
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.island
|
||
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: []
|
||
|
||
// 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
|
||
|
||
AgentSessionPanel {
|
||
Layout.fillWidth: true
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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
|
||
},
|
||
{
|
||
name: "new",
|
||
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 ?? "/")
|
||
}
|
||
|
||
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();
|
||
}
|
||
if (modelData.name === "new") {
|
||
messageInputField.text = "";
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 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
|
||
}
|
||
}
|
||
}
|
||
}
|