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; } // Set while a run is being killed on purpose, so its exit is not reported // as a failure. property bool stopping: false function stop(): void { if (proc.running) { root.stopping = true; 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 = ""; if (proc.running) { root.stopping = true; 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 "$@" 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) => { if (root.stopping) { root.stopping = false; return; } 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; } } }