Applied 0009 then 0008 (0008's re-synthesize control calls a function 0009 adds). Casey applied and reloaded; verified against a running shell rather than from a clean load. 0009 Speech.qml stop() could not stop. playProc ran `sh -c "mpv ... || ffplay ..."` — a compound command, so sh does not exec-replace itself and SIGTERM killed the wrapper while the player kept sounding as an orphan. Reproduced directly. That is the back-to-back TTS slam: every stop left audio playing and the next speak started a second player over it. Shell dropped; the pid held is now the pid making noise. ffplay fallback deleted rather than repaired — needing a fallback is what forced the wrapper that broke the kill. resynthesize() added, bypassing the cache: the button existed for "that came out wrong" and, being keyed on the same text, always replayed the identical file. synthesizing/playing split out of one `speaking` boolean. 0008 AiChat.qml One line: delegate AiMessage -> AgentMessage. The vendor block named 8 tools; the registry holds 19. The 11 it could not see were exactly the interiority surface — outfit, nickname, subagent, atmosphere, reach, consult, itinerary, todo, schedule, halt, intrusive. regenerate and edit dropped deliberately, per Casey: text regeneration is not possible (Ai.regenerate() already returned advice) and there is no in-place edit (the vendor wrote to a local array the server never sees). delete is armed and states that it hides locally only. Do not restore them. Queue entries removed here, per the directory's own discipline.
993 lines
43 KiB
QML
993 lines
43 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: "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);
|
||
}
|
||
},
|
||
]
|
||
|
||
// 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.tokenCount.total > 0
|
||
}
|
||
StatusItem {
|
||
visible: Ai.tokenCount.total > 0
|
||
icon: "token"
|
||
statusText: Ai.tokenCount.total
|
||
description: Translation.tr("Context estimate: %1 tokens\n(Local count of the conversation — not the provider's usage numbers)").arg(Ai.tokenCount.total)
|
||
}
|
||
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))
|
||
}
|
||
}
|
||
}
|
||
|
||
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 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 ?? "/")
|
||
}
|
||
|
||
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 === "clear") {
|
||
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
|
||
}
|
||
}
|
||
}
|
||
}
|