aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules')
-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
-rw-r--r--desktop/modules/qtmaildir/MailAccountsModule.qml5
-rw-r--r--desktop/modules/qtmaildir/MailComposeModule.qml5
-rw-r--r--desktop/modules/qtmaildir/MailGeneralModule.qml5
-rw-r--r--desktop/modules/qtmaildir/MailTagsKeysModule.qml5
-rw-r--r--desktop/modules/qtmaildir/Qtm.qml83
-rw-r--r--desktop/modules/qtmaildir/README.md9
-rw-r--r--desktop/modules/qtmaildir/Setting.qml50
-rw-r--r--desktop/modules/qtmaildir/TagsKeysPage.qml58
13 files changed, 353 insertions, 160 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
diff --git a/desktop/modules/qtmaildir/MailAccountsModule.qml b/desktop/modules/qtmaildir/MailAccountsModule.qml
index f634c8d..a77dbac 100644
--- a/desktop/modules/qtmaildir/MailAccountsModule.qml
+++ b/desktop/modules/qtmaildir/MailAccountsModule.qml
@@ -20,6 +20,7 @@ Module {
icon: "\uf0c0"
settings: true
group: "Mail"
+ dirty: Qtm.dirty
keywords: ["qtmaildir", "account", "address", "maildir", "trash", "spam", "drafts", "sent",
"inbox", "msmtp", "send command", "label", "colour", "color", "channel", "mbsync"]
searchItems: Qtm.accounts.map(a => ({ text: a + " " + (Qtm.get("account." + a, "address") ?? ""),
@@ -29,6 +30,10 @@ Module {
page: Component {
Page {
title: "Accounts"
+ // One draft behind all four Mail pages: Save writes them all.
+ actions: Component {
+ SaveBar { dirty: Qtm.dirty; valid: Qtm.valid; onSave: Qtm.save(); onDiscard: Qtm.discard() }
+ }
AccountsPage { width: parent.width }
}
}
diff --git a/desktop/modules/qtmaildir/MailComposeModule.qml b/desktop/modules/qtmaildir/MailComposeModule.qml
index e34fc07..6a9a198 100644
--- a/desktop/modules/qtmaildir/MailComposeModule.qml
+++ b/desktop/modules/qtmaildir/MailComposeModule.qml
@@ -20,6 +20,7 @@ Module {
icon: "\uf040"
settings: true
group: "Mail"
+ dirty: Qtm.dirty
keywords: ["qtmaildir", "html", "quote", "reply", "send", "undo", "autosave", "attachment", "draft"]
searchItems: [["send_html", "Send HTML"], ["quote_position", "Quote"], ["default_account", "Default account"],
["send_delay_ms", "Undo send window"], ["autosave_interval_ms", "Autosave every"],
@@ -30,6 +31,10 @@ Module {
page: Component {
Page {
title: "Compose"
+ // One draft behind all four Mail pages: Save writes them all.
+ actions: Component {
+ SaveBar { dirty: Qtm.dirty; valid: Qtm.valid; onSave: Qtm.save(); onDiscard: Qtm.discard() }
+ }
ComposePage { width: parent.width }
}
}
diff --git a/desktop/modules/qtmaildir/MailGeneralModule.qml b/desktop/modules/qtmaildir/MailGeneralModule.qml
index b531579..02ecd5a 100644
--- a/desktop/modules/qtmaildir/MailGeneralModule.qml
+++ b/desktop/modules/qtmaildir/MailGeneralModule.qml
@@ -21,6 +21,7 @@ Module {
icon: "\uf0e0"
settings: true
group: "Mail"
+ dirty: Qtm.dirty
keywords: ["qtmaildir", "startup", "language", "toolbar", "zoom", "date", "sync", "contacts",
"calendar", "notmuch", "mimetype", "forward"]
readonly property var fields: [
@@ -40,6 +41,10 @@ Module {
page: Component {
Page {
title: "Mail"
+ // One draft behind all four Mail pages: Save writes them all.
+ actions: Component {
+ SaveBar { dirty: Qtm.dirty; valid: Qtm.valid; onSave: Qtm.save(); onDiscard: Qtm.discard() }
+ }
GeneralPage { width: parent.width }
}
}
diff --git a/desktop/modules/qtmaildir/MailTagsKeysModule.qml b/desktop/modules/qtmaildir/MailTagsKeysModule.qml
index b4c903c..0c1eade 100644
--- a/desktop/modules/qtmaildir/MailTagsKeysModule.qml
+++ b/desktop/modules/qtmaildir/MailTagsKeysModule.qml
@@ -20,6 +20,7 @@ Module {
icon: "\uf02c"
settings: true
group: "Mail"
+ dirty: Qtm.dirty
keywords: ["qtmaildir", "tag", "colour", "color", "shortcut", "key", "binding", "action"]
searchItems: Qtm.entries("tagcolors").map(e => ({ text: "tag: " + e.key, key: "tag:" + e.key }))
.concat(Qtm.entries("keys").map(e => ({ text: e.key + ": " + e.value, key: "key:" + e.key })))
@@ -28,6 +29,10 @@ Module {
page: Component {
Page {
title: "Tags & shortcuts"
+ // One draft behind all four Mail pages: Save writes them all.
+ actions: Component {
+ SaveBar { dirty: Qtm.dirty; valid: Qtm.valid; onSave: Qtm.save(); onDiscard: Qtm.discard() }
+ }
TagsKeysPage { width: parent.width }
}
}
diff --git a/desktop/modules/qtmaildir/Qtm.qml b/desktop/modules/qtmaildir/Qtm.qml
index 744bcb4..3f6175b 100644
--- a/desktop/modules/qtmaildir/Qtm.qml
+++ b/desktop/modules/qtmaildir/Qtm.qml
@@ -19,35 +19,83 @@ import "Ini.js" as I
// qtmaildir.conf, shared by the four Mail settings pages. qtmaildir reads
// it at startup and never writes it, so editing it under a running
// qtmaildir is safe and applies on its next launch.
+//
+// The pages edit a draft, never the file: every field writes to it as it is
+// typed, and save() writes it out. The draft is shared, so one Save writes
+// what all four pages changed, and it survives moving between pages.
Singleton {
id: root
readonly property string path: (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config")
+ "/qtmaildir/qtmaildir.conf"
+ // The file as last read or written.
property string text: ""
+ // The file as the draft started from, and the draft.
+ property string base: ""
+ property string draft: ""
+ readonly property bool dirty: root.draft !== root.base
+ // Fields holding a value that cannot be written, by section/key; Save
+ // waits until there are none.
+ property var invalid: ({})
+ readonly property bool valid: Object.keys(root.invalid).length === 0
property string error: ""
// Whether qtmaildir runs now, so a page can say a change waits for a
// restart. Rechecked on every refresh().
property bool running: false
- // Keys of the [account.<key>] sections, in file order.
- readonly property var accounts: I.sections(root.text).filter(s => s.startsWith("account.")).map(s => s.slice(8))
+ // The row keys the pages repeat over: account keys, tags, shortcuts.
+ // Assigned only when the set changes, never bound to the draft: a new
+ // array rebuilds every row of a Repeater, and since every keystroke
+ // makes a new draft, a bound model would rebuild the row being typed in
+ // and drop its focus after each character. Rows read their values live.
+ property var accounts: []
+ property var tags: []
+ property var shortcuts: []
+ function same(a, b) { return JSON.stringify(a) === JSON.stringify(b); }
+ onDraftChanged: {
+ const acc = I.sections(root.draft).filter(s => s.startsWith("account.")).map(s => s.slice(8));
+ const tg = I.entries(root.draft, "tagcolors").map(e => e.key);
+ const sc = I.entries(root.draft, "keys").map(e => e.key);
+ if (!root.same(acc, root.accounts)) root.accounts = acc;
+ if (!root.same(tg, root.tags)) root.tags = tg;
+ if (!root.same(sc, root.shortcuts)) root.shortcuts = sc;
+ }
- function get(section, key) { return I.get(root.text, section, key); }
- function entries(section) { return I.entries(root.text, section); }
+ function get(section, key) { return I.get(root.draft, section, key); }
+ function entries(section) { return I.entries(root.draft, section); }
// val null unsets: the line is commented out where it stands.
- function set(section, key, val) { root.write(I.set(root.text, section, key, val)); }
- function remove(section, key) { root.write(I.remove(root.text, section, key)); }
- function addAccount(key, pairs) { root.write(I.addSection(root.text, "account." + key, pairs)); }
- function removeAccount(key) { root.write(I.removeSection(root.text, "account." + key)); }
+ function set(section, key, val) { root.draft = I.set(root.draft, section, key, val); }
+ function remove(section, key) { root.draft = I.remove(root.draft, section, key); }
+ function addAccount(key, pairs) { root.draft = I.addSection(root.draft, "account." + key, pairs); }
+ function removeAccount(key) { root.draft = I.removeSection(root.draft, "account." + key); }
+
+ function markInvalid(id, bad) {
+ if (!!root.invalid[id] === bad) return;
+ const next = Object.assign({}, root.invalid);
+ if (bad) next[id] = true; else delete next[id];
+ root.invalid = next;
+ }
+ // Refused when the file changed on disk since the draft began: writing
+ // would discard that change unseen. Discard then shows the new file.
// Never empty, since a header always remains, so FileView.setText is safe
// here (AGENTS.md: it writes nothing for empty text on an unloaded path).
- function write(t) {
- if (t === root.text) return;
+ function save() {
+ if (root.text !== root.base) {
+ root.error = "qtmaildir.conf changed on disk since these edits began. Discard to load it, then redo them.";
+ return;
+ }
+ root.error = "";
+ root.text = root.draft;
+ root.base = root.draft;
+ fv.setText(root.draft);
+ }
+
+ function discard() {
root.error = "";
- root.text = t;
- fv.setText(t);
+ root.invalid = ({});
+ root.base = root.text;
+ root.draft = root.text;
}
function refresh() {
@@ -62,10 +110,15 @@ Singleton {
atomicWrites: true
watchChanges: true
printErrors: false
- // Its own writes come back here too; reparsing the same text is a no-op.
+ // Its own writes come back here too, as the text already held.
onFileChanged: reload()
- onLoaded: root.text = text()
- onLoadFailed: root.text = ""
+ // A clean draft follows the file; a dirty one is kept, and save()
+ // notices the file moved under it.
+ onLoaded: {
+ root.text = text();
+ if (!root.dirty) { root.base = root.text; root.draft = root.text; }
+ }
+ onLoadFailed: if (!root.dirty) { root.text = ""; root.base = ""; root.draft = ""; }
onSaveFailed: err => root.error = "Cannot write " + root.path + ": " + FileViewError.toString(err)
}
diff --git a/desktop/modules/qtmaildir/README.md b/desktop/modules/qtmaildir/README.md
index 250ceb3..e74d9cc 100644
--- a/desktop/modules/qtmaildir/README.md
+++ b/desktop/modules/qtmaildir/README.md
@@ -17,9 +17,12 @@ Four settings pages, under Mail, for qtmaildir's
qtmaildir reads the file at startup and never writes it, so editing it
under a running qtmaildir is safe; the pages say when it is running and a
-change waits for its restart. A field saves on Enter, and emptied it unsets
-the key, so qtmaildir's default applies; the placeholders name those
-defaults, from qtmaildir's README. Numbers are range-checked before writing.
+change waits for its restart. Every field, switch and row edits one draft
+shared by the four pages, and Save in any page's header writes it all;
+Discard drops it. Emptied, a field unsets the key, so qtmaildir's default
+applies; the placeholders name those defaults, from qtmaildir's README.
+Numbers are range-checked, and a refused one holds Save back. A save over a
+file changed on disk since the draft began is refused rather than lost.
`Ini.js` edits the file a line at a time so its comments and order survive:
a key unset is commented out with `;`, a commented key set again is
diff --git a/desktop/modules/qtmaildir/Setting.qml b/desktop/modules/qtmaildir/Setting.qml
index 66d0a46..78103bf 100644
--- a/desktop/modules/qtmaildir/Setting.qml
+++ b/desktop/modules/qtmaildir/Setting.qml
@@ -12,10 +12,11 @@
import QtQuick
import "../.."
-// One labelled key of qtmaildir.conf: a text field that saves on Enter.
-// Emptied, the key is unset and qtmaildir uses its default, which the
-// placeholder names. kind "int" or "float" checks the number and its range
-// before writing; a refused value shows why under the row.
+// One labelled key of qtmaildir.conf: a text field that edits the draft as
+// it is typed; the page's Save writes it. Emptied, the key is unset and
+// qtmaildir uses its default, which the placeholder names. kind "int" or
+// "float" checks the number and its range: a refused value stays out of the
+// draft, shows why under the row, and holds Save back until it is fixed.
Column {
id: s
property string section
@@ -36,20 +37,31 @@ Column {
readonly property string value: Qtm.get(s.section, s.key) ?? ""
property string problem: ""
- function commit(t) {
+ function apply(t) {
t = t.trim();
- if (t === "") { s.problem = ""; Qtm.set(s.section, s.key, null); return; }
- if (s.kind !== "text") {
+ let bad = "";
+ if (t !== "" && s.kind !== "text") {
const n = Number(t);
- if (isNaN(n) || (s.kind === "int" && !Number.isInteger(n)) || n < s.min || n > s.max) {
- s.problem = s.kind === "int" ? `a whole number from ${s.min} to ${s.max}`
- : `a number from ${s.min} to ${s.max}`;
- return;
- }
+ if (isNaN(n) || (s.kind === "int" && !Number.isInteger(n)) || n < s.min || n > s.max)
+ bad = s.kind === "int" ? `a whole number from ${s.min} to ${s.max}`
+ : `a number from ${s.min} to ${s.max}`;
}
- s.problem = "";
- Qtm.set(s.section, s.key, t);
+ s.problem = bad;
+ Qtm.markInvalid(s.objectName, bad !== "");
+ if (bad === "") Qtm.set(s.section, s.key, t === "" ? null : t);
+ }
+
+ // The field is not bound to the value, which would reset the cursor and
+ // eat a typed trailing space; it follows the value only when the draft
+ // moves under it (Discard, a colour picked, a save of another page).
+ onValueChanged: if (ti.text.trim() !== s.value && s.problem === "") ti.text = s.value
+ Connections {
+ target: Qtm
+ function onInvalidChanged() { if (s.problem !== "" && !Qtm.invalid[s.objectName]) { s.problem = ""; ti.text = s.value; } }
}
+ // A field that goes away cannot hold Save back; the draft keeps its last
+ // good value.
+ Component.onDestruction: Qtm.markInvalid(s.objectName, false)
Item {
width: s.width
@@ -69,7 +81,7 @@ Column {
visible: s.colour
width: s.colour ? 34 : 0
colour: s.value
- onPicked: hex => s.commit(hex)
+ onPicked: hex => s.apply(hex)
}
Rectangle {
anchors { left: lbl.right; leftMargin: 8; right: pick.left; rightMargin: s.colour ? 8 : 0
@@ -84,12 +96,8 @@ Column {
selectByMouse: true
color: Theme.text
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
- text: s.value
- onAccepted: if (text !== s.value) s.commit(text)
- Keys.onEscapePressed: event => {
- if (text !== s.value) text = s.value;
- else event.accepted = false;
- }
+ Component.onCompleted: text = s.value
+ onTextEdited: s.apply(text)
Text {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
diff --git a/desktop/modules/qtmaildir/TagsKeysPage.qml b/desktop/modules/qtmaildir/TagsKeysPage.qml
index b47e1f2..709e943 100644
--- a/desktop/modules/qtmaildir/TagsKeysPage.qml
+++ b/desktop/modules/qtmaildir/TagsKeysPage.qml
@@ -15,7 +15,7 @@ import "../.."
// [tagcolors] and [keys]: free-form rows, each removable, with an add row.
// A row's key is its identity, so changing a tag or a shortcut is a remove
-// and an add.
+// and an add. Everything edits the shared draft; the page's Save writes it.
Column {
id: page
spacing: 6
@@ -52,6 +52,7 @@ Column {
property alias text: ti.text
property string placeholder: ""
signal accepted
+ signal edited
height: 34
radius: 8
color: Theme.surface
@@ -64,6 +65,7 @@ Column {
color: Theme.text
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
onAccepted: parent.accepted()
+ onTextEdited: parent.edited()
Text {
anchors.verticalCenter: parent.verticalCenter
visible: parent.text === ""
@@ -86,11 +88,12 @@ Column {
}
Repeater {
- model: Qtm.entries("tagcolors")
+ model: Qtm.tags
delegate: Item {
id: tagRow
- required property var modelData
- objectName: "tag:" + modelData.key
+ required property string modelData
+ readonly property string colour: Qtm.get("tagcolors", modelData) ?? ""
+ objectName: "tag:" + modelData
width: page.width
height: 34
Text {
@@ -98,29 +101,35 @@ Column {
anchors { left: parent.left; verticalCenter: parent.verticalCenter }
width: 226
elide: Text.ElideRight
- text: tagRow.modelData.key
+ text: tagRow.modelData
color: Theme.text
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
}
Box {
anchors { left: tagName.right; leftMargin: 8; right: tagPick.left; rightMargin: 8; verticalCenter: parent.verticalCenter }
- text: tagRow.modelData.value
- onAccepted: if (/^#[0-9a-fA-F]{6}$/.test(text)) Qtm.set("tagcolors", tagRow.modelData.key, text)
+ text: tagRow.colour
+ // A half-typed colour stays out of the draft and holds Save.
+ onEdited: {
+ const ok = /^#[0-9a-fA-F]{6}$/.test(text);
+ Qtm.markInvalid("tag:" + tagRow.modelData, !ok);
+ if (ok) Qtm.set("tagcolors", tagRow.modelData, text);
+ }
+ Component.onDestruction: Qtm.markInvalid("tag:" + tagRow.modelData, false)
}
ColourPick {
id: tagPick
anchors { right: tagDel.left; rightMargin: 8; verticalCenter: parent.verticalCenter }
- colour: tagRow.modelData.value
- onPicked: hex => Qtm.set("tagcolors", tagRow.modelData.key, hex)
+ colour: tagRow.colour
+ onPicked: hex => { Qtm.markInvalid("tag:" + tagRow.modelData, false); Qtm.set("tagcolors", tagRow.modelData, hex); }
}
Button {
id: tagDel
anchors { right: parent.right; verticalCenter: parent.verticalCenter }
danger: true
- text: page.confirming === "tag:" + tagRow.modelData.key ? "Confirm" : "Remove"
+ text: page.confirming === "tag:" + tagRow.modelData ? "Confirm" : "Remove"
onClicked: {
- if (page.confirming === "tag:" + tagRow.modelData.key) { page.confirming = ""; Qtm.remove("tagcolors", tagRow.modelData.key); }
- else page.confirming = "tag:" + tagRow.modelData.key;
+ if (page.confirming === "tag:" + tagRow.modelData) { page.confirming = ""; Qtm.remove("tagcolors", tagRow.modelData); }
+ else page.confirming = "tag:" + tagRow.modelData;
}
}
}
@@ -162,11 +171,12 @@ Column {
}
Repeater {
- model: Qtm.entries("keys")
+ model: Qtm.shortcuts
delegate: Item {
id: keyRow
- required property var modelData
- objectName: "key:" + modelData.key
+ required property string modelData
+ readonly property string action: Qtm.get("keys", modelData) ?? ""
+ objectName: "key:" + modelData
width: page.width
height: 34
Text {
@@ -174,24 +184,28 @@ Column {
anchors { left: parent.left; verticalCenter: parent.verticalCenter }
width: 226
elide: Text.ElideRight
- text: keyRow.modelData.key
+ text: keyRow.modelData
color: Theme.text
font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
}
Combo {
anchors { left: keySeq.right; leftMargin: 8; right: keyDel.left; rightMargin: 8; verticalCenter: parent.verticalCenter }
- Component.onCompleted: editText = keyRow.modelData.value
- onAccepted: if (editText.trim() !== "") Qtm.set("keys", keyRow.modelData.key, editText.trim())
- onActivated: i => Qtm.set("keys", keyRow.modelData.key, page.actions[i])
+ Component.onCompleted: editText = keyRow.action
+ // Only a real change: rewriting an equal value would still
+ // normalise its line and mark the page changed for nothing.
+ onEditTextChanged: {
+ const a = editText.trim();
+ if (a !== "" && a !== keyRow.action) Qtm.set("keys", keyRow.modelData, a);
+ }
}
Button {
id: keyDel
anchors { right: parent.right; verticalCenter: parent.verticalCenter }
danger: true
- text: page.confirming === "key:" + keyRow.modelData.key ? "Confirm" : "Remove"
+ text: page.confirming === "key:" + keyRow.modelData ? "Confirm" : "Remove"
onClicked: {
- if (page.confirming === "key:" + keyRow.modelData.key) { page.confirming = ""; Qtm.remove("keys", keyRow.modelData.key); }
- else page.confirming = "key:" + keyRow.modelData.key;
+ if (page.confirming === "key:" + keyRow.modelData) { page.confirming = ""; Qtm.remove("keys", keyRow.modelData); }
+ else page.confirming = "key:" + keyRow.modelData;
}
}
}