224 lines
9.0 KiB
QML
224 lines
9.0 KiB
QML
// Agents.
|
|
//
|
|
// Which AI tool answers when the desktop offers to investigate something, what
|
|
// the desktop is allowed to hand it, and how much of your subscription is left.
|
|
//
|
|
// The preferred agent ships as "none" and that is not a placeholder: until one
|
|
// is chosen, every rung of the escalation ladder stays silent -- a crash
|
|
// notification carries no action, System Health grows no button, a failed
|
|
// reload says only that it failed. A desktop that volunteered a tool nobody
|
|
// installed would be worse than one that says nothing.
|
|
|
|
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import qs.config
|
|
import qs.services
|
|
import qs.widgets
|
|
|
|
SettingsPage {
|
|
id: root
|
|
|
|
objectName: "agents-page"
|
|
|
|
title: "Agents"
|
|
lede: "Your AI tools, and what the desktop is allowed to hand them."
|
|
|
|
// The options come from the schema rather than from a list here, so this
|
|
// page cannot offer an agent the preference would refuse.
|
|
readonly property var agentOptions: PreferenceSchema.spec("preferredAgent")?.options ?? []
|
|
readonly property string preferred: String(DesktopPreferences.get("preferredAgent") ?? "none")
|
|
|
|
// ── Install state, or no claim at all ───────────────────────────────────
|
|
//
|
|
// A tile says "Not installed" only once something has actually looked. Any
|
|
// other order gets it wrong in the direction that matters: a page telling
|
|
// somebody their agent is missing, when it is sitting right there, teaches
|
|
// them not to believe the page.
|
|
//
|
|
// Probed through a LOGIN shell rather than this one. Quickshell is started
|
|
// by systemd, whose PATH does not include ~/.local/bin -- where both of
|
|
// these usually land -- and a login shell is the environment the launcher
|
|
// hands the agent when it spawns a terminal. Asking with the shell's own
|
|
// PATH would report "not installed" for an agent that starts perfectly.
|
|
property var installed: ({})
|
|
property bool probed: false
|
|
|
|
function absorbProbe(text: string): void {
|
|
const found = {};
|
|
for (const line of String(text).split("\n")) {
|
|
const name = line.trim();
|
|
if (name !== "")
|
|
found[name] = true;
|
|
}
|
|
root.installed = found;
|
|
root.probed = true;
|
|
}
|
|
|
|
Process {
|
|
id: agentProbe
|
|
|
|
running: true
|
|
command: ["bash", "-lc",
|
|
"for agent in claude codex; do command -v \"$agent\" >/dev/null 2>&1 && printf '%s\\n' \"$agent\"; done"]
|
|
stdout: StdioCollector {
|
|
onStreamFinished: root.absorbProbe(this.text)
|
|
}
|
|
}
|
|
|
|
function iconFor(value: string): string {
|
|
switch (value) {
|
|
case "claude": return "starred-symbolic";
|
|
case "codex": return "utilities-terminal-symbolic";
|
|
default: return "notifications-disabled-symbolic";
|
|
}
|
|
}
|
|
|
|
function tileDetail(value: string): string {
|
|
if (value === "none")
|
|
return "Stay quiet";
|
|
if (!root.probed)
|
|
return "";
|
|
return root.installed[value] === true ? "Installed" : "Not installed";
|
|
}
|
|
|
|
SettingsCard {
|
|
title: "Preferred agent"
|
|
subtitle: "Who answers when the desktop offers to investigate something. Until one is chosen, crash notifications carry no action — the desktop stays quiet rather than volunteering a tool you do not use."
|
|
|
|
// The same tile shape the power profiles use: three rows with the word
|
|
// "Active" in one of them is a list you have to read to find out what
|
|
// is set; three tiles with one lit answers that without reading.
|
|
Flow {
|
|
id: tiles
|
|
|
|
width: parent.width
|
|
spacing: 10
|
|
bottomPadding: 12
|
|
|
|
readonly property int columns: tiles.width >= 460 ? 3 : 1
|
|
readonly property real tileWidth:
|
|
(tiles.width - tiles.spacing * (tiles.columns - 1)) / tiles.columns
|
|
|
|
Repeater {
|
|
model: root.agentOptions
|
|
|
|
Rectangle {
|
|
id: tile
|
|
|
|
required property var modelData
|
|
|
|
readonly property string value: String(tile.modelData.value)
|
|
readonly property bool selected: tile.value === root.preferred
|
|
readonly property string detail: root.tileDetail(tile.value)
|
|
|
|
objectName: `agent-tile:${tile.value}`
|
|
width: tiles.tileWidth
|
|
implicitHeight: tileBody.implicitHeight + 24
|
|
radius: Theme.cardRadius
|
|
color: tile.selected
|
|
? Theme.alpha(Theme.accent, 0.09)
|
|
: Theme.alpha(Theme.fg, tileHover.hovered ? 0.08 : 0.04)
|
|
border.width: tile.selected || tile.activeFocus ? 2 : 1
|
|
border.color: tile.activeFocus
|
|
? Theme.accentSecondary
|
|
: (tile.selected ? Theme.alpha(Theme.accent, 0.6) : Theme.alpha(Theme.fg, 0.08))
|
|
activeFocusOnTab: true
|
|
|
|
Accessible.role: Accessible.RadioButton
|
|
Accessible.name: String(tile.modelData.label)
|
|
Accessible.description: tile.detail
|
|
Accessible.checked: tile.selected
|
|
|
|
// An agent this machine cannot start is still selectable:
|
|
// the probe answers for THIS session's login shell, and
|
|
// being wrong about that must not lock somebody out of a
|
|
// choice they are entitled to make. The tile says what it
|
|
// found; the person decides.
|
|
function choose(): void {
|
|
if (!tile.selected)
|
|
SystemSettings.commitPreference("preferredAgent", tile.value);
|
|
}
|
|
|
|
Keys.onReturnPressed: tile.choose()
|
|
Keys.onSpacePressed: tile.choose()
|
|
|
|
Column {
|
|
id: tileBody
|
|
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
anchors.margins: 12
|
|
spacing: 6
|
|
|
|
ThemedIcon {
|
|
icon: root.iconFor(tile.value)
|
|
iconFallback: "system-run-symbolic"
|
|
size: 20
|
|
tint: tile.selected ? Theme.accent : Theme.fgDim
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: String(tile.modelData.label)
|
|
color: Theme.fg
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
font.weight: Font.DemiBold
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
visible: tile.detail !== ""
|
|
text: tile.detail
|
|
color: Theme.fgMuted
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
}
|
|
|
|
HoverHandler {
|
|
id: tileHover
|
|
cursorShape: Qt.PointingHandCursor
|
|
}
|
|
|
|
TapHandler {
|
|
onTapped: {
|
|
tile.choose();
|
|
tile.forceActiveFocus();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingsCard {
|
|
title: "When something breaks"
|
|
subtitle: "Each of these is a failure that used to be a dead end. Every one still needs an agent chosen above before it offers anything."
|
|
|
|
ToggleRow { setting: "crashDiagnoseOffer" }
|
|
ToggleRow { setting: "reloadFailureOffer" }
|
|
ToggleRow { setting: "healthAgentHandoff" }
|
|
ToggleRow { setting: "agentAutoApprove"; divider: false }
|
|
}
|
|
|
|
SettingsCard {
|
|
title: "Usage in the bar"
|
|
subtitle: "The bar shows the fullest limit — the one that stops your next prompt. Clicking it opens the whole picture."
|
|
|
|
// The same switch the Bar page carries, deliberately: this is the page
|
|
// somebody is on when they wonder where the number went, and the Bar
|
|
// page is the page they are on when they are choosing what the bar
|
|
// contains. Declared as an intentional mirror in
|
|
// tests/quickshell/settings-ownership-contract.
|
|
ToggleRow { setting: "showAgentUsage" }
|
|
ToggleRow { setting: "agentUsageClaude" }
|
|
ToggleRow { setting: "agentUsageCodex" }
|
|
SliderRow { setting: "agentUsageRefreshMinutes"; divider: false }
|
|
}
|
|
}
|