From 748a345ea9d0b49bba5ce194a8ff7e8005ad69a9 Mon Sep 17 00:00:00 2001 From: rope Date: Sun, 2 Aug 2026 11:27:08 +0100 Subject: [PATCH] qs-ask: log outcomes to journal; launcher: spinner while Claude answers Co-Authored-By: Claude Opus 5 --- hosts/FredOS-Mediaserver.nix | 29 +++++++++++++++++++------ settings/quickshell.nix | 41 ++++++++++++++++++++++++++++++------ 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/hosts/FredOS-Mediaserver.nix b/hosts/FredOS-Mediaserver.nix index 9df87c2..a4fcc82 100644 --- a/hosts/FredOS-Mediaserver.nix +++ b/hosts/FredOS-Mediaserver.nix @@ -99,18 +99,32 @@ # call fails or the model doesn't know — silence is what the launcher # reads as "keep the Wikipedia answer". Every reason for that silence goes # to STDERR instead, which the launcher discards and a human running - # `qs-ask` by hand can read. + # `qs-ask` by hand can read — and to the journal, since over ssh nobody + # ever sees that STDERR. `journalctl -t qs-ask` is the only way to tell a + # blank card caused by UNKNOWN from one caused by a failed call. (pkgs.writeShellScriptBin "qs-ask" '' + # Every line carries this script's pid, because the launcher fires a + # fresh call per debounce and concurrent runs otherwise interleave with + # no way to pair a question with its outcome. The pid goes in the + # message text rather than through logger's --id: --id sets SYSLOG_PID, + # which journalctl does not display — it shows the pid of the logger + # process itself, a different number on every line. + log() { + printf 'qs-ask: %s\n' "$*" >&2 + ${pkgs.util-linux}/bin/logger -t qs-ask -- "[$$] $*" + } + q=$(${pkgs.coreutils}/bin/cat) - [ -z "$q" ] && { echo "qs-ask: no question on stdin" >&2; exit 0; } + [ -z "$q" ] && { log "no question on stdin"; exit 0; } + log "ask: $q" # Claude Code derives its project state (transcripts, auto-memory) from # the working directory, so run from a dedicated empty one. Combined with # --no-session-persistence and --safe-mode, a launcher question leaves no # trace in the real projects' history or memory. work="$HOME/.cache/qs-ask" - ${pkgs.coreutils}/bin/mkdir -p "$work" || { echo "qs-ask: cannot create $work" >&2; exit 0; } - cd "$work" || { echo "qs-ask: cannot enter $work" >&2; exit 0; } + ${pkgs.coreutils}/bin/mkdir -p "$work" || { log "cannot create $work"; exit 0; } + cd "$work" || { log "cannot enter $work"; exit 0; } # MAX_THINKING_TOKENS=0 is the single biggest win here, worth ~2.4x. # Measured: Haiku defaults to extended thinking, and it spent 225 output @@ -142,14 +156,15 @@ --name qs-ask \ --system-prompt 'Answer the question in one or two short sentences, under 240 characters. Lead with the specific fact asked for, including units. The query is often bare keywords rather than a question ("shadow hunter knife classic wow"); treat that as a request for the key facts about that thing, not as unanswerable. No preamble, no caveats, no markdown, no follow-up offers. Reply with exactly UNKNOWN only when you genuinely do not know, or the answer depends on live data you do not have.' \ 2>/dev/null) \ - || { echo "qs-ask: claude exited non-zero (auth expired? run 'claude' once to log in)" >&2; exit 0; } + || { log "claude exited non-zero (auth expired? run 'claude' once to log in)"; exit 0; } ans=$(printf '%s' "$ans" | ${pkgs.coreutils}/bin/tr -d '\r' | ${pkgs.gnused}/bin/sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') case "$ans" in - "") echo "qs-ask: empty answer" >&2; exit 0 ;; - UNKNOWN*) echo "qs-ask: model replied UNKNOWN" >&2; exit 0 ;; + "") log "empty answer"; exit 0 ;; + UNKNOWN*) log "model replied UNKNOWN"; exit 0 ;; esac + log "ok: $ans" printf '%s' "$ans" '') ]; diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 3aaea75..26c9f51 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -2415,7 +2415,10 @@ in height: answerCol.height + 28 radius: Theme.radiusCard color: Theme.cardBg - visible: launcherPanel.answerFact !== "" + // Also up while Claude's call is out with nothing + // on screen yet, so the spinner has somewhere to + // live during the first second. + visible: launcherPanel.answerFact !== "" || askProc.running Column { id: answerCol @@ -2451,6 +2454,7 @@ in // answer already came from it. SText { width: parent.width + visible: launcherPanel.answerFact !== "" text: launcherPanel.answerFact font.pixelSize: 13 wrapMode: Text.WordWrap @@ -2466,11 +2470,36 @@ in } // Always say who answered — the two sources - // carry very different trust. - SText { - text: launcherPanel.answerSource - font.pixelSize: 10 - color: Theme.base03 + // carry very different trust. The spinner + // runs for as long as Claude's ~6s call is + // out, which is the only cue that a + // Wikipedia answer already on screen is + // still provisional — and that a card that + // never changes was Claude declining, not + // Claude being slow. + Row { + spacing: 5 + SIcon { + anchors.verticalCenter: parent.verticalCenter + visible: askProc.running + text: "loader-circle" + font.pixelSize: 11 + color: Theme.base03 + NumberAnimation on rotation { + from: 0; to: 360 + duration: 900 + loops: Animation.Infinite + running: askProc.running + } + } + SText { + anchors.verticalCenter: parent.verticalCenter + text: askProc.running + ? (launcherPanel.answerSource === "" ? "Claude" : launcherPanel.answerSource + " · Claude") + : launcherPanel.answerSource + font.pixelSize: 10 + color: Theme.base03 + } } }