diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-17 11:34:07 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-17 11:34:07 +0200 |
| commit | 080d3d9aaffa8848d6de8546511f096d8e7c30f5 (patch) | |
| tree | 2663bd5c774d8db7fa024e82546f6cfa04c07e29 /desktop/modules/assistant/AssistantModule.qml | |
| parent | b8c0f01f058c87db5924067dbb475aa241c71b46 (diff) | |
| download | quickshell-080d3d9aaffa8848d6de8546511f096d8e7c30f5.tar.gz quickshell-080d3d9aaffa8848d6de8546511f096d8e7c30f5.zip | |
settings: pages edit a draft, and Save writes it
The qtmaildir, Assistant and Ask presets pages wrote a field on Enter,
and a switch or chip the moment it was clicked, so a half-finished
change reached the file and a slip could not be taken back. They now
edit a draft as fields are typed, and a Save and Discard pair in the
page header (SaveBar, through Page.actions) writes or drops it. Save
waits while a field holds a value that cannot be written, and a draft
over a file that changed on disk meanwhile is refused at Save rather
than written over it. A dot in the sidebar marks a page with unsaved
edits; drafts survive moving between pages. The four Mail pages share
one draft, since they are one file. Tool switches are staged too and
renamed on Save; model Load, Prefer and Restart stay immediate, being
actions rather than settings.
Live editing needed two rules. A field is no longer bound to its value,
which would reset the cursor and eat a typed trailing space; it follows
the value only when the draft moves under it. And a Repeater's model is
now the set of row keys (or a count), assigned only when that set
changes: an array rebuilt from the draft on each keystroke recreates
every row, including the one being typed in, which loses its focus.
Git, autostart's rows, HyprSunset, HyprIdle and User keep their own
buttons.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop/modules/assistant/AssistantModule.qml')
| -rw-r--r-- | desktop/modules/assistant/AssistantModule.qml | 96 |
1 files changed, 80 insertions, 16 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 } } } |
