quickshell fixes

This commit is contained in:
nvrl committed 2026-09-24 13:35:20 +02:00
1 parent d2975650fa
commit 32b43bdbb3
68 files changed
+1461 -1047

No files matched your search

+26
View File
@@ -17,6 +17,7 @@ Singleton {
property bool lockRequested: false
property bool sidebarOpen: false
property bool launcherOpen: false
property bool askOpen: false
// Seed text for the next launcher open. Lets a keybind or a script hand the
// launcher a query it did not have to type — `qs ipc call shell prompt
@@ -32,6 +33,13 @@ Singleton {
// Same story for the power menu: a window per screen, one of them wanted.
property string powerMenuScreen: ""
// …and for the ask panel, which also takes the keyboard.
property string askScreen: ""
// Seed text for the next ask open. A seeded open asks straight away, so
// `qs ipc call shell askAbout "$(wl-paste)"` is a one-shot question.
property string askSeed: ""
signal osdRequested(string kind)
function togglePowerMenu() {
@@ -57,6 +65,22 @@ Singleton {
}
function closeLauncher() { root.launcherOpen = false; }
function toggleAsk() {
if (!root.askOpen) {
root.askSeed = "";
root.askScreen = root.focusedScreenName();
}
root.askOpen = !root.askOpen;
}
function closeAsk() { root.askOpen = false; }
function askAbout(seed: string) {
root.askSeed = seed;
root.askScreen = root.focusedScreenName();
root.askOpen = false;
root.askOpen = true;
}
// Stand in for `fuzzel --dmenu`. See Apps.beginDmenu for the transport.
function dmenuLauncher(inPath: string, outPath: string, prompt: string) {
Apps.beginDmenu(inPath, outPath, prompt);
@@ -106,6 +130,8 @@ Singleton {
function lock(): void { root.lock(); }
function power(): void { root.togglePowerMenu(); }
function launcher(): void { root.toggleLauncher(); }
function ask(): void { root.toggleAsk(); }
function askAbout(question: string): void { root.askAbout(question); }
function prompt(seed: string): void { root.promptLauncher(seed); }
function dmenu(inPath: string, outPath: string, prompt: string): void {
root.dmenuLauncher(inPath, outPath, prompt);
+248
View File
@@ -0,0 +1,248 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
// Ask — the question panel's brain.
//
// One `claude -p` per turn, streamed. The CLI is the transport rather than the
// API because the subscription lives in the CLI's credentials: nothing here
// ever sees a key, and nothing here has to hold one.
//
// The defaults deliberately strip Claude Code down to a chat: no settings
// sources, no MCP servers, no project files. That is not only about tokens —
// a session started from the shell would otherwise inherit whatever hooks and
// CLAUDE.md the last project left lying around, and answer in its voice.
// Everything below is overridable from ~/.config/quickshell/ask.json.
Singleton {
id: root
// ---------------------------------------------------------------- config
readonly property string configPath:
Quickshell.env("HOME") + "/.config/quickshell/ask.json"
property var config: ({})
readonly property var defaults: ({
model: "sonnet",
fallbackModel: "",
systemPrompt: "You are a desktop answer panel. Answer the question directly and "
+ "compactly, in markdown, with no preamble. Prefer a few sentences over a "
+ "list, and a list over an essay. Say plainly when you do not know.",
keepContext: true,
// "" = none. "user,project,local" is the full Claude Code behaviour.
settingSources: "",
allowedTools: ["WebSearch", "WebFetch"],
disallowedTools: ["Bash", "Edit", "Write", "NotebookEdit", "Agent", "Task"],
workingDirectory: Quickshell.env("HOME"),
maxTurns: 6
})
function setting(key: string): var {
const v = root.config[key];
return v === undefined || v === null ? root.defaults[key] : v;
}
readonly property string model: root.setting("model")
// The model ring. Ctrl+M walks it; whatever ask.json asks for is row zero,
// so a custom model stays reachable even when it is not one of the three.
readonly property var models: {
const ring = ["sonnet", "opus", "haiku"];
const m = root.model;
return ring.indexOf(m) >= 0 ? ring : [m].concat(ring);
}
function cycleModel(): void {
const ring = root.models;
const next = ring[(ring.indexOf(root.model) + 1) % ring.length];
const c = JSON.parse(JSON.stringify(root.config));
c.model = next;
root.config = c;
store.setText(JSON.stringify(c, null, 2) + "\n");
}
FileView {
id: store
path: root.configPath
preload: true
watchChanges: true
printErrors: false
atomicWrites: true
onFileChanged: store.reload()
onLoaded: {
try {
root.config = JSON.parse(store.text()) || ({});
} catch (e) {
root.config = ({});
root.error = "ask.json is not valid JSON";
}
}
// No file yet: write the defaults out so there is something to edit.
onLoadFailed: error => {
root.config = ({});
store.setText(JSON.stringify(root.defaults, null, 2) + "\n");
}
}
// ----------------------------------------------------------------- state
// `turns` is the transcript the panel draws: { role, text } in order. The
// answer being streamed is the last entry and is rewritten in place.
property var turns: []
property bool busy: false
property string error: ""
property string sessionId: ""
property real lastCost: 0
property int exitCode: 0
property string stderrText: ""
signal answered()
function clear(): void {
root.stop();
root.turns = [];
root.sessionId = "";
root.error = "";
root.lastCost = 0;
}
function stop(): void {
if (proc.running) proc.running = false;
root.busy = false;
}
function ask(question: string): void {
const q = question.trim();
if (q === "" || root.busy) return;
root.error = "";
root.turns = root.turns.concat([
{ role: "user", text: q },
{ role: "assistant", text: "" }
]);
root.busy = true;
root.exitCode = 0;
root.stderrText = "";
proc.running = false;
// stdin is closed explicitly. Inherited from the shell it never ends,
// and the CLI waits three seconds for input that is never coming
// before it starts on the question.
proc.command = ["sh", "-c", 'exec claude "$@" </dev/null', "claude"]
.concat(root.argv(q).slice(1));
proc.running = true;
}
// Arguments, not a shell line: the question is a single argv entry, so a
// question containing quotes or `$(…)` is data and stays data.
function argv(question: string): var {
const args = ["claude", "-p", question,
"--model", root.setting("model"),
"--output-format", "stream-json",
"--include-partial-messages",
"--verbose",
"--setting-sources", root.setting("settingSources"),
"--strict-mcp-config",
"--mcp-config", '{"mcpServers":{}}',
"--max-turns", String(root.setting("maxTurns"))];
const system = root.setting("systemPrompt");
if (system !== "") args.push("--system-prompt", system);
const fallback = root.setting("fallbackModel");
if (fallback !== "") args.push("--fallback-model", fallback);
const allowed = root.setting("allowedTools");
if (allowed.length > 0) args.push("--allowed-tools", allowed.join(","));
const disallowed = root.setting("disallowedTools");
if (disallowed.length > 0) args.push("--disallowed-tools", disallowed.join(","));
// Continuity is a resume, not a re-send: the CLI keeps the transcript,
// so a follow-up costs one question rather than the whole thread.
if (root.setting("keepContext") && root.sessionId !== "")
args.push("--resume", root.sessionId);
return args;
}
// The streamed answer lives in the last turn. Rewriting an array element in
// place does not notify, so the array is rebuilt — the transcript is a
// handful of entries, and this keeps the panel's binding honest.
function _append(text: string): void {
const t = root.turns.slice();
const last = t.length - 1;
if (last < 0 || t[last].role !== "assistant") return;
t[last] = { role: "assistant", text: t[last].text + text };
root.turns = t;
}
function _ingest(line: string): void {
if (line === "") return;
let ev;
try {
ev = JSON.parse(line);
} catch (e) {
return; // partial line, or a stray log
}
switch (ev.type) {
case "system":
if (ev.subtype === "init" && ev.session_id) root.sessionId = ev.session_id;
return;
case "stream_event": {
const inner = ev.event;
if (inner?.type === "content_block_delta" && inner.delta?.type === "text_delta")
root._append(inner.delta.text);
return;
}
case "result":
root.busy = false;
if (ev.session_id) root.sessionId = ev.session_id;
if (typeof ev.total_cost_usd === "number") root.lastCost = ev.total_cost_usd;
if (ev.is_error) root.error = ev.result || "the request failed";
// A turn that streamed nothing (a refusal, a rate limit) still has
// an answer in the result field. Use it rather than showing a gap.
else if (root.turns.length > 0
&& root.turns[root.turns.length - 1].text === ""
&& ev.result)
root._append(ev.result);
root.answered();
return;
}
}
Process {
id: proc
// Somewhere harmless and stable. The CLI still reads the directory it
// is started in for project context; the shell's own is whatever
// Hyprland handed it at login.
workingDirectory: root.setting("workingDirectory")
stdout: SplitParser {
onRead: data => root._ingest(data)
}
// Only shown when the run actually failed. The CLI writes advisory
// warnings here on a good run too, and a warning in red under the
// answer reads as the answer being wrong.
stderr: StdioCollector {
onStreamFinished: root.stderrText = this.text.trim();
}
onExited: (code, status) => {
root.busy = false;
root.exitCode = code;
if (code === 0 || root.error !== "") return;
const lines = root.stderrText.split("\n").filter(l => l.trim() !== "");
root.error = lines.length > 0
? lines[lines.length - 1]
: "claude exited with " + code;
}
}
}
+2 -2
View File
@@ -58,9 +58,9 @@ Singleton {
}
readonly property color tint: {
if (root.charging || root.full) return Theme.ok;
if (root.charging || root.full) return Theme.text;
if (root.critical) return Theme.alarm;
if (root.low) return Theme.warn;
if (root.low) return Theme.alarm;
return Theme.primary;
}