The window drew its icons with unicode glyphs -- a clip emoji for attach, a
gear, a hamburger, a chevron, a multiplication sign for delete. Those render
in whichever font happens to cover them (colour emoji in a grey toolbar) and
are a box on a machine without that glyph. They now come from the icon theme,
the way every other desktop app gets them.
Icons
- app/src/ui/ThemeIcons.{h,cpp}: a QQuickImageProvider for
image://csttheme/<name>?color=%23rrggbb -- QIcon::fromTheme() in the
desktop's theme (whatever the platform theme set, else breeze-dark, breeze,
hicolor), tinted with QPainter's SourceIn and rasterised at the requested
size and the screen's device pixel ratio. Qt only builds its theme search
paths from XDG once a theme name is set, so the provider fills them itself
(GenericDataLocation/icons, ~/.icons and, on NixOS,
/run/current-system/sw/share/icons, for a shell started outside the desktop
session). An unknown name yields a null image, which QML reports as
Image.Error.
- app/qml/ThemeIcon.qml: the one component that asks for an icon. Every use
still names the old character as `glyph:`, so a host with no icon theme
degrades to exactly what the window looked like before instead of going
blank. installThemeIcons() is called by main() and by --uitest, so the test
loads the same icons the app does.
- Replaced: nav (application-menu), settings (settings-configure), attach
(mail-attachment), copy (edit-copy), session delete (edit-delete), new
session (list-add), expand/collapse (arrow-right/arrow-down), jump to the end
(go-down), the plan checklist (task-complete/box). A tool card also shows
what kind of tool ran -- Theme.toolIcon(): pencil for patch/write, text page
for read, folder for list/glob, magnifier for grep, braces for a shell,
globe for web, list for a plan. Toolbar icons are 22 px (Breeze's toolbar
size), card and row icons 14-16 px.
Colours (the "some text is unreadable" part)
- textFaint #6d757b -> #8d959c: ~3.4:1 -> ~5:1 on the card fill. It carries
hints, provider URLs, session metadata and placeholders, so it is text, not
decoration.
- A selected provider row and the open session row write on the accent fill;
they now use accentText / accentTextDim / okOnAccent instead of faint grey
and green on blue (named "accentText", not "onAccent": QML reads any `onFoo`
property as a signal handler and refuses the assignment).
- The filled buttons (Send, Use this) take their ink from Theme.accentInk.
Composer
- The frame is one Theme.pad around its column on every side (anchors.fill,
implicitHeight + 2*pad). With the top margin alone the last row -- the clip
and Send -- hung over the lower border, which is what put the clip on the
frame instead of inside it.
app/README.md gains an "Icons, colors" section (the provider, why the theme
search paths are filled by hand, the qtsvg requirement for rasterising Breeze,
the tool map, and the contrast rules).
Verified: --uitest 52/52, --selftest OK, and the 0.8.14 window relaunched in
the Plasma session (no QML warnings).
Version 0.8.14.
mode = coding
362 lines
14 KiB
QML
362 lines
14 KiB
QML
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Layouts
|
|
|
|
// Provider, model and key: what the engine needs before it can talk to a model
|
|
// at all. Everything here goes through AgentCore::useProvider(), which moves
|
|
// the engine's own config (its session stays where it is) and remembers the
|
|
// choice. A key is verified against the provider before it is saved and is
|
|
// never read back into this window -- the dialog only ever says where the key
|
|
// it will use comes from.
|
|
Dialog {
|
|
id: dlg
|
|
objectName: "settingsDialog"
|
|
parent: Overlay.overlay
|
|
anchors.centerIn: parent
|
|
modal: true
|
|
closePolicy: Popup.CloseOnEscape
|
|
padding: Theme.pad
|
|
|
|
property var providers: [] // AgentCore::providers()
|
|
property var choices: [] // AgentCore::modelChoices(picked)
|
|
property string picked: ""
|
|
property string model: ""
|
|
property string note: "" // what the last "Use this" answered
|
|
property string keyEnv: ""
|
|
property string keyFrom: ""
|
|
property bool hasKey: false
|
|
|
|
signal applyRequested(string provider, string model, string key)
|
|
|
|
width: Math.min(580, parent ? parent.width - 32 : 580)
|
|
height: Math.min(560, parent ? parent.height - 60 : 560)
|
|
|
|
/* The Basic style paints a light panel; this window is dark, so the
|
|
* dialog brings its own surface like every other one here. */
|
|
background: Rectangle {
|
|
color: Theme.surface
|
|
radius: Theme.radius
|
|
border.width: 1
|
|
border.color: Theme.border
|
|
}
|
|
|
|
/* Fill it in: the presets, the provider the engine is on now, and the
|
|
* model that provider last used. */
|
|
function load(list, provider, currentModel) {
|
|
providers = list
|
|
picked = provider
|
|
model = currentModel
|
|
note = ""
|
|
for (let i = 0; i < list.length; i++)
|
|
if (list[i].current === true)
|
|
picked = list[i].name
|
|
choices = picked.length ? agent.modelChoices(picked) : []
|
|
syncPick()
|
|
}
|
|
|
|
/* Where the picked provider's key would come from. From the entries --
|
|
* a key itself never reaches the QML side. */
|
|
function syncPick() {
|
|
hasKey = false
|
|
keyFrom = ""
|
|
keyEnv = ""
|
|
for (let i = 0; i < providers.length; i++) {
|
|
const p = providers[i]
|
|
if (p.name !== picked)
|
|
continue
|
|
hasKey = p.hasKey === true
|
|
keyFrom = p.keyFrom
|
|
keyEnv = p.keyEnv
|
|
return
|
|
}
|
|
}
|
|
|
|
function keyState() {
|
|
if (hasKey)
|
|
return keyFrom === "saved" ? "a key is saved · paste another to replace it"
|
|
: "key from the environment: " + keyFrom
|
|
return keyEnv.length ? "no key yet — set " + keyEnv + ", or paste one here"
|
|
: "no key yet — paste one here"
|
|
}
|
|
|
|
function usageLine() {
|
|
const u = agent.usage()
|
|
if (!u.requests)
|
|
return "no tokens spent in this session yet"
|
|
let s = u.requests + (u.requests === 1 ? " request" : " requests") +
|
|
" · " + u.prompt + " in · " + u.out + " out"
|
|
if (u.priced)
|
|
s += " · " + u.cost.toFixed(4) + " " + u.currency
|
|
return s
|
|
}
|
|
|
|
contentItem: ColumnLayout {
|
|
objectName: "settingsPanel"
|
|
spacing: Theme.gap
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
text: "Provider, model, key"
|
|
color: Theme.text
|
|
font.pixelSize: Theme.bodySize
|
|
font.bold: true
|
|
}
|
|
Text {
|
|
Layout.fillWidth: true
|
|
text: "Keys are stored under <config>/cst-agent/credentials (mode 0600) and are never shown again."
|
|
color: Theme.textFaint
|
|
font.pixelSize: Theme.smallSize
|
|
wrapMode: Text.Wrap
|
|
}
|
|
|
|
Flickable {
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
Layout.minimumHeight: 96
|
|
clip: true
|
|
contentWidth: width
|
|
contentHeight: plist.implicitHeight
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
ScrollBar.vertical: ScrollBar {}
|
|
|
|
ColumnLayout {
|
|
id: plist
|
|
width: parent.width
|
|
spacing: 2
|
|
|
|
Repeater {
|
|
model: dlg.providers
|
|
delegate: Rectangle {
|
|
id: prow
|
|
required property var modelData
|
|
readonly property bool selected: dlg.picked === modelData.name
|
|
|
|
objectName: "providerRow:" + modelData.name
|
|
Layout.fillWidth: true
|
|
implicitHeight: prowCol.implicitHeight + 8
|
|
radius: Theme.radiusSm
|
|
color: prow.selected ? Theme.accentDim
|
|
: prowHover.hovered ? Theme.surfaceAlt
|
|
: "transparent"
|
|
border.width: 1
|
|
border.color: prow.selected ? Theme.accent : "transparent"
|
|
|
|
HoverHandler { id: prowHover }
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: {
|
|
dlg.picked = prow.modelData.name
|
|
modelField.text = prow.modelData.model
|
|
dlg.choices = agent.modelChoices(prow.modelData.name)
|
|
dlg.syncPick()
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
id: prowCol
|
|
anchors {
|
|
left: parent.left
|
|
right: parent.right
|
|
top: parent.top
|
|
margins: 4
|
|
}
|
|
spacing: 0
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: Theme.gap
|
|
Text {
|
|
text: prow.modelData.name
|
|
color: prow.selected ? Theme.accentText : Theme.textDim
|
|
font.pixelSize: Theme.smallSize
|
|
font.bold: prow.selected
|
|
}
|
|
Text {
|
|
visible: prow.modelData.current === true
|
|
text: "in use"
|
|
color: prow.selected ? Theme.okOnAccent : Theme.ok
|
|
font.pixelSize: Theme.smallSize
|
|
}
|
|
Item { Layout.fillWidth: true }
|
|
Text {
|
|
text: prow.modelData.source
|
|
color: prow.selected ? Theme.accentTextDim : Theme.textFaint
|
|
font.pixelSize: Theme.smallSize
|
|
}
|
|
}
|
|
Text {
|
|
Layout.fillWidth: true
|
|
elide: Text.ElideMiddle
|
|
text: prow.modelData.base + " · " +
|
|
(prow.modelData.hasKey === true
|
|
? (prow.modelData.keyFrom === "saved"
|
|
? "key saved"
|
|
: "key from " + prow.modelData.keyFrom)
|
|
: (prow.modelData.keyEnv.length
|
|
? "no key (" + prow.modelData.keyEnv + ")"
|
|
: "no key"))
|
|
color: prow.selected ? Theme.accentTextDim : Theme.textFaint
|
|
font.pixelSize: Theme.smallSize
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
text: "Model"
|
|
color: Theme.textDim
|
|
font.pixelSize: Theme.smallSize
|
|
font.bold: true
|
|
}
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: Theme.gap
|
|
TextField {
|
|
id: modelField
|
|
objectName: "settingsModel"
|
|
Layout.fillWidth: true
|
|
text: dlg.model
|
|
implicitHeight: Theme.touchTarget
|
|
selectByMouse: true
|
|
color: Theme.text
|
|
placeholderText: "model name"
|
|
placeholderTextColor: Theme.textFaint
|
|
selectionColor: Theme.accentDim
|
|
inputMethodHints: Qt.ImhNoPredictiveText
|
|
background: Rectangle {
|
|
radius: Theme.radiusSm
|
|
color: Theme.bg
|
|
border.width: 1
|
|
border.color: modelField.activeFocus ? Theme.accentDim
|
|
: Theme.border
|
|
}
|
|
}
|
|
Text {
|
|
Layout.preferredWidth: 90
|
|
elide: Text.ElideRight
|
|
text: modelField.text.length ? agent.modelInfo(modelField.text) : ""
|
|
color: Theme.textFaint
|
|
font.pixelSize: Theme.smallSize
|
|
}
|
|
}
|
|
Flow {
|
|
Layout.fillWidth: true
|
|
spacing: 6
|
|
visible: dlg.choices.length > 0
|
|
|
|
Repeater {
|
|
model: dlg.choices
|
|
delegate: Rectangle {
|
|
id: chip
|
|
required property var modelData
|
|
readonly property string name: String(chip.modelData)
|
|
|
|
objectName: "modelChoice:" + chip.name
|
|
implicitWidth: chipText.implicitWidth + 14
|
|
implicitHeight: 26
|
|
radius: Theme.radiusSm
|
|
color: chipHover.hovered ? Theme.surfaceHi : "transparent"
|
|
border.width: 1
|
|
border.color: modelField.text === chip.name ? Theme.accent
|
|
: Theme.border
|
|
HoverHandler { id: chipHover }
|
|
Text {
|
|
id: chipText
|
|
anchors.centerIn: parent
|
|
text: chip.name
|
|
color: modelField.text === chip.name ? Theme.text
|
|
: Theme.textDim
|
|
font.pixelSize: Theme.smallSize
|
|
}
|
|
TapHandler { onTapped: modelField.text = chip.name }
|
|
}
|
|
}
|
|
}
|
|
|
|
TextField {
|
|
id: keyField
|
|
objectName: "settingsKey"
|
|
Layout.fillWidth: true
|
|
implicitHeight: Theme.touchTarget
|
|
echoMode: TextInput.Password
|
|
selectByMouse: true
|
|
color: Theme.text
|
|
placeholderText: "API key (leave empty to keep what is there)"
|
|
placeholderTextColor: Theme.textFaint
|
|
selectionColor: Theme.accentDim
|
|
inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhSensitiveData
|
|
onAccepted: dlg.applyRequested(dlg.picked, modelField.text, keyField.text)
|
|
background: Rectangle {
|
|
radius: Theme.radiusSm
|
|
color: Theme.bg
|
|
border.width: 1
|
|
border.color: keyField.activeFocus ? Theme.accentDim : Theme.border
|
|
}
|
|
}
|
|
Text {
|
|
Layout.fillWidth: true
|
|
text: dlg.keyState()
|
|
color: Theme.textFaint
|
|
font.pixelSize: Theme.smallSize
|
|
wrapMode: Text.Wrap
|
|
}
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: Theme.gap
|
|
Text {
|
|
objectName: "settingsNote"
|
|
Layout.fillWidth: true
|
|
text: dlg.note.length ? dlg.note : dlg.usageLine()
|
|
color: dlg.note.length ? Theme.warn : Theme.textFaint
|
|
font.pixelSize: Theme.smallSize
|
|
wrapMode: Text.Wrap
|
|
maximumLineCount: 2
|
|
elide: Text.ElideRight
|
|
}
|
|
Button {
|
|
id: closeBtn
|
|
objectName: "settingsClose"
|
|
text: "Close"
|
|
implicitHeight: Theme.touchTarget
|
|
onClicked: dlg.close()
|
|
background: Rectangle {
|
|
radius: Theme.radiusSm
|
|
color: closeBtn.hovered ? Theme.surfaceHi : Theme.surfaceAlt
|
|
border.width: 1
|
|
border.color: Theme.border
|
|
}
|
|
contentItem: Text {
|
|
text: closeBtn.text
|
|
color: Theme.textDim
|
|
font.pixelSize: Theme.bodySize
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
Button {
|
|
id: applyBtn
|
|
objectName: "settingsApply"
|
|
text: "Use this"
|
|
implicitHeight: Theme.touchTarget
|
|
onClicked: dlg.applyRequested(dlg.picked, modelField.text, keyField.text)
|
|
background: Rectangle {
|
|
radius: Theme.radiusSm
|
|
color: applyBtn.hovered ? "#5cc0f0" : Theme.accent
|
|
}
|
|
contentItem: Text {
|
|
text: applyBtn.text
|
|
color: Theme.accentInk
|
|
font.pixelSize: Theme.bodySize
|
|
font.bold: true
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|