aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/assistant
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules/assistant')
-rw-r--r--desktop/modules/assistant/AssistantModule.qml96
-rw-r--r--desktop/modules/assistant/AssistantPage.qml94
-rw-r--r--desktop/modules/assistant/PresetsModule.qml30
-rw-r--r--desktop/modules/assistant/PresetsPage.qml60
-rw-r--r--desktop/modules/assistant/README.md13
5 files changed, 194 insertions, 99 deletions
diff --git a/desktop/modules/assistant/AssistantModule.qml b/desktop/modules/assistant/AssistantModule.qml
index 8253cc6..f738ea6 100644
--- a/desktop/modules/assistant/AssistantModule.qml
+++ b/desktop/modules/assistant/AssistantModule.qml
@@ -42,10 +42,29 @@ Module {
readonly property var labels: ({ llm: "Server", system: "System prompt", tool_rounds: "Tool rounds",
temperature: "Temperature", thinking: "Thinking" })
- // config.toml as last read or written; "" when there is none.
+ // config.toml as last read or written; "" when there is none. The page
+ // edits draft, which save() writes; base is the file the draft began
+ // from, so a save over a file changed meanwhile is refused.
property string text: ""
- readonly property var values: C.scalars(am.text, am.keys)
- readonly property var envList: C.env(am.text)
+ property string base: ""
+ property string draft: ""
+ readonly property var values: C.scalars(am.draft, am.keys)
+ readonly property var envList: C.env(am.draft)
+ // The [env] keys, assigned only when the set changes: a model bound to the
+ // draft would rebuild every row on each keystroke and drop the focus of
+ // the one being typed in. Rows read their values from envValue().
+ property var envKeys: []
+ onDraftChanged: {
+ const k = C.env(am.draft).map(e => e.key);
+ if (JSON.stringify(k) !== JSON.stringify(am.envKeys)) am.envKeys = k;
+ }
+ function envValue(key) { return (am.envList.find(e => e.key === key) ?? { value: "" }).value; }
+ // Tool switches flipped but not saved: name -> wanted on.
+ property var toolsWanted: ({})
+ // Fields holding a value that cannot be written; Save waits for none.
+ property var invalid: ({})
+ readonly property bool valid: Object.keys(am.invalid).length === 0
+ dirty: am.draft !== am.base || Object.keys(am.toolsWanted).length > 0
// The daemon's built-in values, from assistant.py --defaults.
property var defaults: ({})
// [{name, on, description}]
@@ -87,8 +106,12 @@ Module {
watchChanges: true
printErrors: false
onFileChanged: reload()
- onLoaded: am.text = text()
- onLoadFailed: am.text = ""
+ // A clean draft follows the file; a dirty one is kept for save().
+ onLoaded: {
+ am.text = text();
+ if (am.draft === am.base) { am.base = am.text; am.draft = am.text; }
+ }
+ onLoadFailed: if (am.draft === am.base) { am.text = ""; am.base = ""; am.draft = ""; }
}
// One Process per call, so calls never share one (a running Process
@@ -130,15 +153,40 @@ Module {
+ "with open(p + '.tmp', 'w') as f:\n f.write(t)\n"
+ "os.replace(p + '.tmp', p)\n"
- function save(t) {
+ // The config first, then the tool renames, each only when there is
+ // something to do; any failure stops there and says why.
+ function save() {
+ if (am.text !== am.base) {
+ am.error = "config.toml changed on disk since these edits began. Discard to load it, then redo them.";
+ return;
+ }
+ am.error = "";
+ const t = am.draft;
+ const tools = () => am.applyTools(Object.keys(am.toolsWanted), () => { am.pending = true; am.listTools(); });
+ if (t === am.text) { tools(); return; }
am.run([am.python, "-c", am.writer, am.cfgPath], t, (code, out, err) => {
- if (code === 0) { am.text = t; am.pending = true; am.error = ""; }
- else am.error = err.trim() || "config.toml could not be written";
+ if (code !== 0) { am.error = err.trim() || "config.toml could not be written"; return; }
+ am.text = t;
+ am.base = t;
+ tools();
});
}
+ function discard() {
+ am.error = "";
+ am.invalid = ({});
+ am.toolsWanted = ({});
+ am.base = am.text;
+ am.draft = am.text;
+ }
+ function markInvalid(id, bad) {
+ if (!!am.invalid[id] === bad) return;
+ const next = Object.assign({}, am.invalid);
+ if (bad) next[id] = true; else delete next[id];
+ am.invalid = next;
+ }
// toml is the value written as TOML, or null to fall back to the default.
- function setKey(key, toml) { am.save(C.setScalar(am.text, key, toml)); }
- function setEnv(key, value) { am.save(C.setEnv(am.text, key, value)); }
+ function setKey(key, toml) { am.draft = C.setScalar(am.draft, key, toml); }
+ function setEnv(key, value) { am.draft = C.setEnv(am.draft, key, value); }
function quote(s) { return C.quote(s); }
// Every tools/*.toml is loaded; *.toml.off is the same file switched off.
@@ -158,14 +206,27 @@ Module {
});
}
+ // Whether a tool is on in the draft: its wanted state, else the file's.
+ function toolOn(t) { return am.toolsWanted[t.name] ?? t.on; }
function toggleTool(t) {
- const on = am.toolsDir + "/" + t.name + ".toml";
+ const next = Object.assign({}, am.toolsWanted);
+ if (!am.toolOn(t) === t.on) delete next[t.name]; else next[t.name] = !am.toolOn(t);
+ am.toolsWanted = next;
+ }
+ // One rename at a time, then done(). -n never overwrites a file that
+ // already holds the other name.
+ function applyTools(names, done) {
+ if (names.length === 0) { done(); return; }
+ const name = names[0];
+ const on = am.toolsDir + "/" + name + ".toml";
const off = on + ".off";
- // -n: never overwrite a file that already holds the other name.
- am.run(["mv", "-n", t.on ? on : off, t.on ? off : on], null, (code, out, err) => {
- if (code === 0) { am.pending = true; am.error = ""; }
- else am.error = err.trim() || "could not switch " + t.name;
- am.listTools();
+ const want = am.toolsWanted[name];
+ am.run(["mv", "-n", want ? off : on, want ? on : off], null, (code, out, err) => {
+ if (code !== 0) { am.error = err.trim() || "could not switch " + name; am.listTools(); return; }
+ const next = Object.assign({}, am.toolsWanted);
+ delete next[name];
+ am.toolsWanted = next;
+ am.applyTools(names.slice(1), done);
});
}
@@ -240,6 +301,9 @@ Module {
page: Component {
Page {
title: "Assistant"
+ actions: Component {
+ SaveBar { dirty: am.dirty; valid: am.valid; onSave: am.save(); onDiscard: am.discard() }
+ }
AssistantPage { width: parent.width; asst: am }
}
}
diff --git a/desktop/modules/assistant/AssistantPage.qml b/desktop/modules/assistant/AssistantPage.qml
index c6b63fc..832e9be 100644
--- a/desktop/modules/assistant/AssistantPage.qml
+++ b/desktop/modules/assistant/AssistantPage.qml
@@ -14,9 +14,9 @@ import QtQuick.Controls
import "../.."
// The view over AssistantModule: the model settings, the tools' environment,
-// the tools themselves, and the restart that applies them. A field saves on
-// Enter; emptied, it falls back to the daemon's default, which it shows as
-// its placeholder.
+// the tools themselves, and the restart that applies them. Every field and
+// switch edits the module's draft; the header's Save writes it. Emptied, a
+// field falls back to the daemon's default, which it shows as placeholder.
Column {
id: page
spacing: 8
@@ -44,20 +44,24 @@ Column {
color: Theme.text
}
- // A one-line input that saves on Enter and undoes on Escape; edited
- // reports every keystroke, for the add row.
+ // A one-line input reporting every keystroke, which the page turns into
+ // a draft edit. It is not bound to value, which would reset the cursor
+ // and eat a trailing space; it follows the value only when the draft
+ // moves under it (Discard, an add). bad outlines a refused value.
component Field: Rectangle {
id: field
property string value: ""
property string placeholder: ""
- signal commit(string text)
+ property bool bad: false
signal edited(string text)
- // Commit on Enter even when unchanged: the add row's fields track
- // their value as they are typed, so they never differ from it.
- property bool live: false
+ signal accepted
height: 34
radius: 8
color: Theme.surface
+ border.width: field.bad ? 1 : 0
+ border.color: Theme.red
+ onValueChanged: if (ti.text.trim() !== field.value.trim()) ti.text = field.value
+ onBadChanged: if (!field.bad) ti.text = field.value
TextInput {
id: ti
anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
@@ -66,13 +70,9 @@ Column {
selectByMouse: true
color: Theme.text
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
- text: field.value
+ Component.onCompleted: text = field.value
onTextEdited: field.edited(text)
- onAccepted: if (field.live || text !== field.value) field.commit(text)
- Keys.onEscapePressed: event => {
- if (text !== field.value) text = field.value;
- else event.accepted = false;
- }
+ onAccepted: field.accepted()
Text {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
@@ -126,7 +126,7 @@ Column {
anchors { left: parent.left; right: restartBtn.left; rightMargin: 8; verticalCenter: parent.verticalCenter }
wrapMode: Text.Wrap
text: page.asst.pending ? "Saved. The assistant reads these at startup: restart it to apply."
- : "The assistant reads these at startup."
+ : "The assistant reads these at startup: Save, then restart it."
color: Theme.text
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
}
@@ -245,7 +245,7 @@ Column {
anchors.fill: parent
value: page.v.llm ?? ""
placeholder: page.d.llm ?? ""
- onCommit: t => page.asst.setKey("llm", t.trim() ? page.asst.quote(t.trim()) : null)
+ onEdited: t => page.asst.setKey("llm", t.trim() ? page.asst.quote(t.trim()) : null)
}
}
@@ -258,16 +258,18 @@ Column {
width: 120
value: page.v.temperature !== undefined ? String(page.v.temperature) : ""
placeholder: "the model's preset"
- onCommit: t => {
+ bad: page.asst.invalid["key:temperature"] ?? false
+ onEdited: t => {
const x = Number(t.trim());
- if (t.trim() === "") page.asst.setKey("temperature", null);
- else if (!isNaN(x) && x >= 0 && x <= 2) page.asst.setKey("temperature", String(x));
- else page.asst.error = "Temperature is a number from 0 to 2.";
+ const ok = t.trim() === "" || (!isNaN(x) && x >= 0 && x <= 2);
+ page.asst.markInvalid("key:temperature", !ok);
+ if (ok) page.asst.setKey("temperature", t.trim() === "" ? null : String(x));
}
}
Small {
anchors { left: temp.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
text: "0 to 2; empty uses the llama-server preset's"
+ color: temp.bad ? Theme.red : Theme.subtext
}
}
@@ -280,16 +282,18 @@ Column {
width: 120
value: page.v.tool_rounds !== undefined ? String(page.v.tool_rounds) : ""
placeholder: page.d.tool_rounds !== undefined ? String(page.d.tool_rounds) : ""
- onCommit: t => {
+ bad: page.asst.invalid["key:tool_rounds"] ?? false
+ onEdited: t => {
const n = Number(t.trim());
- if (t.trim() === "") page.asst.setKey("tool_rounds", null);
- else if (Number.isInteger(n) && n >= 0 && n <= 20) page.asst.setKey("tool_rounds", String(n));
- else page.asst.error = "Tool rounds is a whole number from 0 to 20.";
+ const ok = t.trim() === "" || (Number.isInteger(n) && n >= 0 && n <= 20);
+ page.asst.markInvalid("key:tool_rounds", !ok);
+ if (ok) page.asst.setKey("tool_rounds", t.trim() === "" ? null : String(n));
}
}
Small {
anchors { left: rounds.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
- text: "tool round trips per question"
+ text: "a whole number, 0 to 20: tool round trips per question"
+ color: rounds.bad ? Theme.red : Theme.subtext
}
}
@@ -338,6 +342,11 @@ Column {
background: null
padding: 0
text: page.shown("system")
+ // Into the draft as it is typed; the default, or nothing,
+ // unsets the key rather than copying the default in.
+ onTextChanged: if (prompt.text !== page.shown("system"))
+ page.asst.setKey("system", prompt.text.trim() === "" || prompt.text === page.d.system
+ ? null : page.asst.quote(prompt.text))
}
}
}
@@ -345,12 +354,6 @@ Column {
Row {
spacing: 8
Button {
- text: "Save prompt"
- enabled: prompt.text.trim() !== "" && prompt.text !== page.shown("system")
- onClicked: page.asst.setKey("system", prompt.text === page.d.system ? null
- : page.asst.quote(prompt.text))
- }
- Button {
text: "Use the default"
enabled: page.v.system !== undefined
onClicked: page.asst.setKey("system", null)
@@ -374,12 +377,12 @@ Column {
property string confirming: ""
Repeater {
- model: page.asst.envList
+ model: page.asst.envKeys
delegate: Item {
id: envRow
- required property var modelData
- objectName: "env:" + modelData.key
+ required property string modelData
+ objectName: "env:" + modelData
width: page.width
height: 34
@@ -388,25 +391,25 @@ Column {
anchors { left: parent.left; verticalCenter: parent.verticalCenter }
width: 120
elide: Text.ElideRight
- text: envRow.modelData.key
+ text: envRow.modelData
color: Theme.subtext
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
}
Field {
anchors { left: envKey.right; leftMargin: 8; right: envDel.left; rightMargin: 8; top: parent.top; bottom: parent.bottom }
- value: envRow.modelData.value
- onCommit: t => page.asst.setEnv(envRow.modelData.key, t)
+ value: page.asst.envValue(envRow.modelData)
+ onEdited: t => page.asst.setEnv(envRow.modelData, t)
}
Button {
id: envDel
anchors { right: parent.right; verticalCenter: parent.verticalCenter }
danger: true
- text: page.confirming === envRow.modelData.key ? "Confirm" : "Remove"
+ text: page.confirming === envRow.modelData ? "Confirm" : "Remove"
onClicked: {
- if (page.confirming === envRow.modelData.key) {
+ if (page.confirming === envRow.modelData) {
page.confirming = "";
- page.asst.setEnv(envRow.modelData.key, null);
- } else page.confirming = envRow.modelData.key;
+ page.asst.setEnv(envRow.modelData, null);
+ } else page.confirming = envRow.modelData;
}
}
}
@@ -439,9 +442,8 @@ Column {
anchors { left: keyField.right; leftMargin: 8; right: addBtn.left; rightMargin: 8; top: parent.top; bottom: parent.bottom }
value: page.newValue
placeholder: "value"
- live: true
onEdited: t => page.newValue = t
- onCommit: page.addEnv()
+ onAccepted: page.addEnv()
}
Button {
id: addBtn
@@ -474,7 +476,7 @@ Column {
Switch {
id: toolSw
anchors { left: parent.left; verticalCenter: parent.verticalCenter }
- checked: toolRow.modelData.on
+ checked: page.asst.toolOn(toolRow.modelData)
onToggled: page.asst.toggleTool(toolRow.modelData)
}
Column {
@@ -482,7 +484,7 @@ Column {
anchors { left: toolSw.right; leftMargin: 10; right: parent.right; verticalCenter: parent.verticalCenter }
Text {
text: toolRow.modelData.name
- color: toolRow.modelData.on ? Theme.text : Theme.subtext
+ color: page.asst.toolOn(toolRow.modelData) ? Theme.text : Theme.subtext
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
}
Small {
diff --git a/desktop/modules/assistant/PresetsModule.qml b/desktop/modules/assistant/PresetsModule.qml
index 8617b02..1c62f73 100644
--- a/desktop/modules/assistant/PresetsModule.qml
+++ b/desktop/modules/assistant/PresetsModule.qml
@@ -15,8 +15,10 @@ import "../../assistant/Presets.js" as Presets
// The assistant canvas's Ask row: the canned questions offered for the
// staged files. They live in Prefs and the canvas reads them live, so
-// unlike the daemon's settings these need no restart.
+// unlike the daemon's settings these need no restart. The page edits a
+// draft here; Save hands it to Prefs.
Module {
+ id: pm
name: "presets"
label: "Ask presets"
// nf-fa-lightbulb_o, present in Inconsolata Nerd Font.
@@ -24,12 +26,34 @@ Module {
settings: true
group: "Assistant"
keywords: ["assistant", "canvas", "ask", "question", "prompt", "summarize", "files"]
- searchItems: Presets.list(Prefs.presets).map((p, i) => ({ text: p.label + ": " + p.prompt, key: "preset:" + i }))
+ searchItems: pm.shown.map((p, i) => ({ text: p.label + ": " + p.prompt, key: "preset:" + i }))
+
+ // null while there are no edits: the page then shows Prefs' list.
+ property var draft: null
+ // Reset to defaults was pressed: Save drops the saved list rather than
+ // saving a copy of the defaults, so later default changes still arrive.
+ property bool resetting: false
+ readonly property var shown: pm.draft ?? Presets.list(Prefs.presets)
+ // Presets with an empty label or question, by index; Save waits.
+ readonly property bool valid: pm.shown.every(p => p.label.trim() !== "" && p.prompt.trim() !== "")
+ dirty: pm.draft !== null
+
+ function edit(list) { pm.draft = list; pm.resetting = false; }
+ function reset() { pm.draft = Presets.list(null).map(p => Object.assign({}, p, { kinds: p.kinds.slice() })); pm.resetting = true; }
+ function save() {
+ if (pm.resetting) Prefs.resetPresets(); else Prefs.setPresets(pm.draft);
+ pm.discard();
+ }
+ function discard() { pm.draft = null; pm.resetting = false; }
page: Component {
Page {
title: "Ask presets"
- PresetsPage { width: parent.width }
+ actions: Component {
+ SaveBar { dirty: pm.dirty; valid: pm.valid; note: "every preset needs a label and a question"
+ onSave: pm.save(); onDiscard: pm.discard() }
+ }
+ PresetsPage { width: parent.width; owner: pm }
}
}
}
diff --git a/desktop/modules/assistant/PresetsPage.qml b/desktop/modules/assistant/PresetsPage.qml
index ec103cf..56d507c 100644
--- a/desktop/modules/assistant/PresetsPage.qml
+++ b/desktop/modules/assistant/PresetsPage.qml
@@ -13,14 +13,17 @@ import QtQuick
import "../.."
import "../../assistant/Presets.js" as Presets
-// One card per preset: its label and question, saved on Enter, and the file
-// kinds it is offered for. Every change saves the whole list to Prefs; Reset
-// drops the saved list, which brings the built-in one back.
+// One card per preset: its label and question and the file kinds it is
+// offered for. Every change edits the module's draft; the header's Save
+// hands the list to Prefs. Reset fills the draft with the built-in list.
Column {
id: page
spacing: 10
- readonly property var list: Presets.list(Prefs.presets)
+ // The module, holding the draft. Not named pm, its id (it would bind to
+ // itself, AGENTS.md).
+ required property QtObject owner
+ readonly property var list: owner.shown
// The kinds a preset can name: the four the canvas chats about, and
// "many", offered whenever two or more files are staged.
readonly property var kinds: ["pdf", "text", "code", "image", "many"]
@@ -38,13 +41,13 @@ Column {
if (r) out.push(r);
});
page.error = "";
- page.confirming = -1;
- Prefs.setPresets(out);
+ if (fn === null || out.length !== page.list.length) page.confirming = -1;
+ page.owner.edit(out);
}
function add() {
- Prefs.setPresets(page.list.concat([{ label: "New preset", icon: Presets.FALLBACK,
- kinds: ["text"], prompt: "Summarize this." }]));
+ page.owner.edit(page.list.concat([{ label: "New preset", icon: Presets.FALLBACK,
+ kinds: ["text"], prompt: "Summarize this." }]));
}
component Small: Text {
@@ -52,27 +55,30 @@ Column {
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
}
+ // Reports every keystroke into the draft. Not bound to value, which
+ // would reset the cursor; it follows the value only when the draft moves
+ // under it (Discard, Reset). Empty is outlined, since Save refuses it.
component Field: Rectangle {
id: field
property string value: ""
property string placeholder: ""
- signal commit(string text)
+ signal edited(string text)
height: 32
radius: 8
color: Qt.alpha(Theme.base, 0.6)
+ border.width: ti.text.trim() === "" ? 1 : 0
+ border.color: Theme.red
+ onValueChanged: if (ti.text !== field.value) ti.text = field.value
TextInput {
+ id: ti
anchors { fill: parent; leftMargin: 10; rightMargin: 10 }
verticalAlignment: TextInput.AlignVCenter
clip: true
selectByMouse: true
color: Theme.text
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
- text: field.value
- onAccepted: if (text !== field.value) field.commit(text)
- Keys.onEscapePressed: event => {
- if (text !== field.value) text = field.value;
- else event.accepted = false;
- }
+ Component.onCompleted: text = field.value
+ onTextEdited: field.edited(text)
Text {
anchors.verticalCenter: parent.verticalCenter
visible: parent.text === ""
@@ -87,8 +93,8 @@ Column {
width: page.width
wrapMode: Text.Wrap
text: "Offered on the assistant canvas for the staged files: a preset shows when every staged file is "
- + "one of its kinds, or, with \"many\", when two or more are staged. Enter saves a field; "
- + "changes apply at once."
+ + "one of its kinds, or, with \"many\", when two or more are staged. Save applies the "
+ + "changes, which the canvas picks up at once."
}
Text {
@@ -101,12 +107,14 @@ Column {
}
Repeater {
- model: page.list
+ // A count, not the list: a new list on every keystroke would rebuild
+ // every card and drop the focus of the field being typed in.
+ model: page.list.length
delegate: Rectangle {
id: card
- required property var modelData
required property int index
+ readonly property var modelData: page.list[index] ?? { label: "", prompt: "", kinds: [], icon: "" }
objectName: "preset:" + index
width: page.width
height: body.implicitHeight + 20
@@ -133,10 +141,7 @@ Column {
anchors { left: glyph.right; leftMargin: 6; right: del.left; rightMargin: 8; top: parent.top; bottom: parent.bottom }
value: card.modelData.label
placeholder: "label"
- onCommit: t => {
- if (t.trim() === "") { page.error = "A preset needs a label."; return; }
- page.edit(card.index, c => Object.assign(c, { label: t.trim() }));
- }
+ onEdited: t => page.edit(card.index, c => Object.assign(c, { label: t }))
}
Button {
id: del
@@ -154,10 +159,7 @@ Column {
width: parent.width
value: card.modelData.prompt
placeholder: "the question asked"
- onCommit: t => {
- if (t.trim() === "") { page.error = "A preset needs a question."; return; }
- page.edit(card.index, c => Object.assign(c, { prompt: t.trim() }));
- }
+ onEdited: t => page.edit(card.index, c => Object.assign(c, { prompt: t }))
}
// A kind toggles on click; the last one cannot be cleared,
@@ -208,8 +210,8 @@ Column {
Button {
text: "Reset to defaults"
danger: true
- enabled: Prefs.presets !== null
- onClicked: Prefs.resetPresets()
+ enabled: !page.owner.resetting && (Prefs.presets !== null || page.owner.dirty)
+ onClicked: page.owner.reset()
}
}
}
diff --git a/desktop/modules/assistant/README.md b/desktop/modules/assistant/README.md
index 004ec05..3c35dc6 100644
--- a/desktop/modules/assistant/README.md
+++ b/desktop/modules/assistant/README.md
@@ -5,11 +5,14 @@ canvas (`../../assistant/`).
**Assistant** edits the daemon's `~/.config/desktop-assistant/config.toml`
and switches its tools. The model fields (server, temperature, tool rounds,
-thinking) save on Enter, and an emptied field falls back to the daemon's
-default, shown as its placeholder; the defaults come from `assistant.py
---defaults`, so they are never copied here. The system prompt has its own
-box, Save and "Use the default". The tool environment (`[env]`) is one row
-per key, with an add row below. A tool's switch renames `tools/<name>.toml`
+thinking), the system prompt, the `[env]` rows and the tool switches all
+edit a draft, and the header's Save writes it: the config first, then the
+tool renames. An emptied field falls back to the daemon's default, shown as
+its placeholder; the defaults come from `assistant.py --defaults`, so they
+are never copied here. A refused number is outlined and holds Save back.
+The system prompt also has "Use the default". The tool environment
+(`[env]`) is one row per key, with an add row below. A tool's switch, once
+saved, renames `tools/<name>.toml`
to `<name>.toml.off`, which the daemon's `*.toml` glob skips.
The page also lists llama-server's models with their state. The daemon