Files
Hermit/app/qml/SettingsDialog.qml
T
WHC 69842edf16 v0.8.10: settings dialog (provider, model, key), window starts on a provider
The window could be started, but it could not be pointed anywhere: a provider,
a model and a key only came in on the command line. The gear in the header now
opens a dialog that changes all three on the engine's live config.

AgentCore (app/src/core/AgentCore.*):
- providers(): the engine's preset table (cst_provider_count/at), with base
  URL, the remembered or preset model, whether a key exists and where it comes
  from (cst_creds_resolve: "saved", or the environment variable), the preset's
  key variable and the source ("built-in" / "user"). A key is read into a local
  buffer only to answer "is there one"; it never leaves the C++ side.
- modelChoices(provider): the provider's own list (cst_provider_models), the
  fetched cache (cst_ps_cached -- which writes through its out-parameters, so
  it takes a real int, not NULL: passing one cost a segfault) and the
  remembered pick. No network. modelInfo() is cst_model_info + its "200K ·
  text only" label; usage() is cst_agent_usage for the session and the turn.
- useProvider(provider, model, key) -> "" or a reason: a different provider
  goes through cst_ps_switch_provider() (the key is verified against /models
  before it is saved; offline it is kept unverified and the header says so),
  a key for the current one through cst_ps_set_key(), and the model is
  remembered in settings.json. syncConfig() takes the engine's config back
  over the app's copy, so provider/model/approval read correctly afterwards.
  The engine's session is never touched by a switch.
- open() no longer starts with an empty provider: it takes the one the engine
  used last (settings.json), else the built-in default, plus that provider's
  base URL and model. Without this the first turn could not be sent at all and
  no entry in the dialog was "in use".

QML (app/qml/SettingsDialog.qml, Main.qml):
- a modal dialog with the themed surface the other dialogs use (the Basic
  style's light panel washes out the dark theme), the provider list with its
  key state, a model field with the info label and clickable candidates, a
  password field for a new key, the session's token counters, and a note that
  reports what the last "Use this" answered.

Tests: 8 more checks -- the presets are readable, an unknown provider is
refused, the counters are readable, the gear opens the panel, the rows exist, a
model typed into the field reaches the engine, the note says so, Close puts the
dialog away. 36 checks pass. --uitest also points XDG_CONFIG_HOME at its
scratch directory now, so a run cannot rewrite the user's settings.json or
credentials, and CST_APP_SHOT writes shot.png at the end plus
shot-settings.png while the dialog is open (that is how the dialog above was
reviewed on a machine without a screen).

Verified: 36/36 UI checks, --selftest, ctest cst-app-selftest, make check rc=0,
and a screenshot of the dialog.

Version 0.8.10.
2026-10-11 12:25:33 +08:00

317 lines
12 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.text : Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: prow.selected
}
Text {
visible: prow.modelData.current === true
text: "in use"
color: Theme.ok
font.pixelSize: Theme.smallSize
}
Item { Layout.fillWidth: true }
Text {
text: prow.modelData.source
color: 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: 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
placeholderText: "model name"
inputMethodHints: Qt.ImhNoPredictiveText
}
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
placeholderText: "API key (leave empty to keep what is there)"
inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhSensitiveData
onAccepted: dlg.applyRequested(dlg.picked, modelField.text, keyField.text)
}
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 {
objectName: "settingsClose"
text: "Close"
implicitHeight: Theme.touchTarget
onClicked: dlg.close()
}
Button {
objectName: "settingsApply"
text: "Use this"
highlighted: true
implicitHeight: Theme.touchTarget
onClicked: dlg.applyRequested(dlg.picked, modelField.text, keyField.text)
}
}
}
}