feat(menu): #140 — Ask opens a web chat, provider is in-flake state
All checks were successful
Check / eval (push) Successful in 3m57s

Bernardo: "opening claude-code is too disruptive". SUPER+CTRL+A spent a
terminal, an npm fetch and a REPL on "what's the syntax for…" — the prompt
was the right surface, the destination was not. The query is now
URL-encoded into the provider's web chat and handed to xdg-open.

settings.ask.provider (claude|chatgpt|gemini|grok, default claude) with a
Preferences row, read from state at RUN TIME so switching needs no
rebuild, and any unknown/absent value falls back rather than opening
nothing. He asked for chatgpt + grok alongside claude/gemini, and for the
CLI to go: claude-code is deleted and pkgs.nodejs leaves with it (carried
only for that npx). Keybind is "Ask AI" now the destination is the user's.

The failure mode is the design point: prefill is undocumented on all four
vendors, each can drop `?q=` unilaterally, and it fails SILENTLY — you
land in an empty chat having already typed the question. So the query is
also copied to the clipboard (a vendor breaking change costs one paste,
not the question), and the URLs sit in one attrset so the repair is one
line.

Verified against the generated script: bash -n clean; every bad state
value (unset/null/garbage) falls back to claude; a hostile query encodes
correctly (& → %26, ? → %3F, # → %23, $(whoami) neutralised, café →
caf%C3%A9). NOT verified on purpose: whether each vendor fills the box or
auto-submits — that means posting into Bernardo's own accounts.

Sweep: ROADMAP's shipped-modules line described the npx REPL; nix-ld's
comment cited claude-code as its example; both fixed. Menu row + keybind
renamed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 11:37:15 +01:00
parent 48e73ab6d3
commit 207b6f3c64
5 changed files with 91 additions and 46 deletions

View File

@@ -54,7 +54,7 @@
{ mods = "$mod CTRL"; key = "K"; action = "exec, nomarchy-menu keyboard"; desc = "Keyboard layout"; group = "Menu"; }
{ mods = "$mod CTRL"; key = "S"; action = "exec, nomarchy-menu capture"; desc = "Screenshot / capture"; group = "Media"; }
{ mods = "$mod CTRL"; key = "P"; action = "exec, nomarchy-menu colorpicker"; desc = "Color picker ( clipboard)"; group = "Menu"; }
{ mods = "$mod CTRL"; key = "A"; action = "exec, nomarchy-menu ask"; desc = "Ask Claude"; group = "Menu"; }
{ mods = "$mod CTRL"; key = "A"; action = "exec, nomarchy-menu ask"; desc = "Ask AI"; group = "Menu"; }
{ mods = "$mod CTRL"; key = "D"; action = "exec, nomarchy-menu dnd"; desc = "Do Not Disturb toggle"; group = "Media"; }
# I as in "settings" muscle memory (Super+I elsewhere); T for Tools.
{ mods = "$mod CTRL"; key = "I"; action = "exec, nomarchy-menu system"; desc = "System menu"; group = "Menu"; }

View File

@@ -12,6 +12,20 @@ let
t = cfg.theme;
c = t.colors;
termSheet = import ./term-sheet.nix { inherit pkgs; };
# Ask ▸ web chat (#140). One table, because these URLs are the vendors' to
# change: prefill is undocumented on every one of them, so when a `?q=`
# stops working the fix must be a one-line edit here and nothing else.
# Claude and ChatGPT are the well-attested pair; Gemini and Grok are
# best-effort — hence the clipboard fallback in the `ask` handler, which is
# what keeps a dropped parameter from costing the user their question.
askProviders = {
claude = "https://claude.ai/new?q=";
chatgpt = "https://chatgpt.com/?q=";
gemini = "https://gemini.google.com/app?q=";
grok = "https://grok.com/?q=";
};
askDefaultProvider = "claude";
inherit (config.lib.formats.rasi) mkLiteral;
px = n: mkLiteral "${toString n}px";
@@ -897,13 +911,30 @@ ${themeRows}
exit 0 ;;
ask)
# Free-text question claude CLI in a terminal (OAuth, no API key).
# Pulled fresh from npm via npx rather than nixpkgs' claude-code,
# which lags behind model releases; npx caches after first run. The
# REPL stays open for follow-ups.
q=$(rofi_menu -p claude < /dev/null) || exit 0
# Free-text question the provider's web chat in the default browser
# (#140). It used to open claude-code in a terminal: a coding agent, an
# npm fetch on first run and a REPL left sitting there, all to answer
# "what's the syntax for". The prompt was the right surface; the
# destination was not.
#
# The provider is read from the in-flake state on every invocation, so
# switching it in Preferences takes effect immediately no rebuild
# and an unknown/absent value falls back rather than opening nothing.
q=$(rofi_menu -p ask < /dev/null) || exit 0
{ [ -n "$q" ] && [ "$q" != "$BACK" ]; } || exit 0
exec ${cfg.terminal} -e npx --yes @anthropic-ai/claude-code@latest "$q" ;;
p=$(nomarchy-state-sync get settings.ask.provider 2>/dev/null)
case "$p" in ${lib.concatStringsSep "|" (lib.attrNames askProviders)}) ;; *) p=${askDefaultProvider} ;; esac
enc=$(printf '%s' "$q" | ${pkgs.jq}/bin/jq -sRr @uri)
case "$p" in
${lib.concatStringsSep "\n" (lib.mapAttrsToList
(name: url: " ${name}) url='${url}'\"$enc\" ;;") askProviders)}
esac
# Prefill is the *vendor's* feature, not ours: each can change or drop
# `?q=` whenever they like, and the failure is silent you land in an
# empty chat having already typed the question. So the text goes to the
# clipboard too. Worst case is one paste, not retyping.
printf '%s' "$q" | ${pkgs.wl-clipboard}/bin/wl-copy 2>/dev/null || true
exec xdg-open "$url" ;;
dnd)
# Toggle swaync Do-Not-Disturb (-d prints the new state). With DND
@@ -1223,7 +1254,7 @@ ${themeRows}
row "Web search${menuHint "web"}" system-search
row "Capture${menuHint "capture"}" applets-screenshooter
command -v hyprpicker >/dev/null 2>&1 && row "Color picker${menuHint "colorpicker"}" preferences-desktop-color
row "Ask Claude${menuHint "ask"}" internet-chat
row "Ask AI${menuHint "ask"}" internet-chat
back
} | rofi_menu -show-icons -markup-rows -p Tools) || exit 0
case "$choice" in
@@ -1332,6 +1363,9 @@ ${themeRows}
true|True) row "Update checks (on)" system-software-update ;;
*) row "Update checks (off)" system-software-update ;;
esac
askp=$(nomarchy-state-sync get settings.ask.provider 2>/dev/null)
case "$askp" in ${lib.concatStringsSep "|" (lib.attrNames askProviders)}) ;; *) askp=${askDefaultProvider} ;; esac
row "Ask provider ($askp)" system-users
case "$(nomarchy-state-sync get settings.bluetooth.enable 2>/dev/null)" in
false|False) row "Bluetooth package (off)" bluetooth ;;
*) row "Bluetooth package (on)" bluetooth ;;
@@ -1361,6 +1395,7 @@ ${themeRows}
*"Auto-login"*) exec "$0" autologin ;;
*"Auto-commit"*) exec "$0" autocommit ;;
*"Update checks"*) exec "$0" updates-toggle ;;
*"Ask provider"*) exec "$0" ask-provider ;;
*"Bluetooth package"*) exec "$0" bluetooth-package ;;
*Printing*) exec "$0" printing-toggle ;;
*"Power profile"*) exec "$0" power-profile ;;
@@ -1369,6 +1404,25 @@ ${themeRows}
*Doctor*) exec "$0" doctor ;;
esac ;;
ask-provider)
# settings.ask.provider which web chat SUPER+CTRL+A opens (#140).
# `--no-switch`: the ask handler reads this key at run time, so there
# is nothing to rebuild the next question just goes elsewhere.
cur=$(nomarchy-state-sync get settings.ask.provider 2>/dev/null)
case "$cur" in ${lib.concatStringsSep "|" (lib.attrNames askProviders)}) ;; *) cur=${askDefaultProvider} ;; esac
choice=$( {
${lib.concatStringsSep "\n" (map
(name: " row \"${name}\" system-users") (lib.attrNames askProviders))}
back
} | rofi_menu -show-icons -p "Ask provider (now: $cur)") || exit 0
[ "$choice" = "$BACK" ] && exec "$0" system-preferences
case "$choice" in
${lib.concatStringsSep "|" (lib.attrNames askProviders)})
nomarchy-state-sync --quiet set settings.ask.provider "$choice" --no-switch
notify-send "Ask" "SUPER+CTRL+A now opens $choice." ;;
esac
exec "$0" system-preferences ;;
updates-toggle)
# settings.updates.enable HM nomarchy.updates (Waybar checker).
case "$(nomarchy-state-sync get settings.updates.enable 2>/dev/null)" in
@@ -1684,7 +1738,6 @@ in
nomarchy-vpn # VPN submenu (NM connect/import + Tailscale)
pkgs.fd # files module (fuzzy search over $HOME)
pkgs.xdg-utils # xdg-open for the files + web modules
pkgs.nodejs # npx, for the Ask Claude module (claude-code from npm)
pkgs.networkmanager_dmenu # network module: rofi wifi/VPN picker
pkgs.rofi-pulse-select # audio module: sink/source switcher
# Capture OCR region: English only — the unscoped wrapper drags