Show plugin ids as subtext in the plugin picker menus

Select-menu options gain an optional third field rendered under the
label, filtered alongside it, and returned with the selection. The
plugin picker uses it to show every plugin's id and act on the id the
selection hands back, replacing the duplicate-name label suffix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-08-07 02:38:06 -07:00
co-authored by Claude Fable 5
parent 9cb3640c9f
commit bc75b03114
5 changed files with 48 additions and 62 deletions
+7 -10
View File
@@ -22,21 +22,18 @@ esac
plugins=$(omarchy-plugin-list --json) plugins=$(omarchy-plugin-list --json)
# Two plugins may share a name, so keep the id with the row and show it when the # The id rides along as row subtext: it tells same-named plugins apart on
# name alone cannot identify the pick. # screen and comes back with the selection as the key to act on.
rows=$(jq -r --arg icon "$PLUGIN_ICON" \ rows=$(jq -r --arg icon "$PLUGIN_ICON" \
". as \$plugins ". as \$plugins
| ([.[] | select($filter)]) as \$rows | .[] | select($filter)
| \$rows[] | \$icon + \"\\t\" + .name + \"\\t\" + .id" <<<"$plugins")
| .name as \$name
| (if ([\$rows[] | select(.name == \$name)] | length) > 1 then \$name + \" (\" + .id + \")\" else \$name end) as \$label
| \$icon + \"\\t\" + \$label + \"\\t\" + .id" <<<"$plugins")
[[ -n $rows ]] || { omarchy-notification-send "No plugin to ${1}"; exit 0; } [[ -n $rows ]] || { omarchy-notification-send "No plugin to ${1}"; exit 0; }
name=$(omarchy-menu-select "${1^} plugin" < <(cut -f1,2 <<<"$rows")) || exit 0 selection=$(omarchy-menu-select "${1^} plugin" <<<"$rows") || exit 0
[[ -n $name ]] || exit 0 [[ -n $selection ]] || exit 0
id=$(awk -F'\t' -v label="$name" '$2 == label { print $3; exit }' <<<"$rows") id=$(cut -f2 <<<"$selection")
[[ -n $id ]] || exit 1 [[ -n $id ]] || exit 1
if [[ $1 == "clone" ]]; then if [[ $1 == "clone" ]]; then
+5 -2
View File
@@ -6,8 +6,11 @@
# omarchy:args=prompt [option...] [-- menu args...] # omarchy:args=prompt [option...] [-- menu args...]
# omarchy:examples=omarchy menu select Format jpg png|omarchy-menu-select Resolution 4k 1080p 720p -- --width 400 # omarchy:examples=omarchy menu select Format jpg png|omarchy-menu-select Resolution 4k 1080p 720p -- --width 400
# An option may lead with an icon, as "<glyph><TAB><label>". The menu shows the # An option may lead with an icon, as "<glyph><TAB><label>", and may trail a
# glyph and returns the label alone, so callers never strip it back off. # subtext shown under the label, as "<glyph><TAB><label><TAB><subtext>". The
# menu shows the glyph but never returns it. A plain option returns the label
# alone; an option with a subtext returns "<label><TAB><subtext>", so callers
# with same-named rows get the subtext back as the stable key.
set -euo pipefail set -euo pipefail
+16 -10
View File
@@ -141,8 +141,10 @@ Item {
Util.execDetached(command) Util.execDetached(command)
} }
// Menu rows only surface their detail while a search is narrowing them;
// dmenu rows carry caller-supplied subtext that must always be visible.
function rowHeightForDetail(detail) { function rowHeightForDetail(detail) {
return root.filterText && detail ? root.detailRowHeight : root.baseRowHeight return (root.filterText || root.dmenuActive) && detail ? root.detailRowHeight : root.baseRowHeight
} }
// Height the card can devote to rows before running off the screen — or // Height the card can devote to rows before running off the screen — or
@@ -206,7 +208,7 @@ Item {
var total = 0 var total = 0
for (var i = 0; i < displayModel.count; i++) { for (var i = 0; i < displayModel.count; i++) {
if (i > 0) total += root.rowSpacing if (i > 0) total += root.rowSpacing
total += root.baseRowHeight total += root.rowHeightForDetail(displayModel.get(i).detail)
totals.push(total) totals.push(total)
} }
@@ -516,13 +518,16 @@ Item {
var query = root.filterText.trim().toLowerCase() var query = root.filterText.trim().toLowerCase()
for (var i = 0; i < root.dmenuOptions.length; i++) { for (var i = 0; i < root.dmenuOptions.length; i++) {
// An option may lead with an icon, as "<glyph>\t<label>". Only the label // An option is "<label>", "<glyph>\t<label>", or
// is filtered against and handed back, so the caller never sees a glyph // "<glyph>\t<label>\t<subtext>". The glyph never comes back with the
// it has to strip off the selection. // selection; the subtext renders under the label, filters alongside it,
// and returns with the selection as a stable key for same-named rows.
var parts = String(root.dmenuOptions[i] || "").split("\t") var parts = String(root.dmenuOptions[i] || "").split("\t")
var icon = parts.length > 1 ? parts.shift() : "" var icon = parts.length > 1 ? parts.shift() : ""
var label = parts.join("\t") var label = parts.shift() || ""
if (query && label.toLowerCase().indexOf(query) < 0) continue var detail = parts.join("\t")
if (query && label.toLowerCase().indexOf(query) < 0
&& detail.toLowerCase().indexOf(query) < 0) continue
displayModel.append({ displayModel.append({
itemId: "dmenu." + i, itemId: "dmenu." + i,
kind: "dmenu", kind: "dmenu",
@@ -532,7 +537,7 @@ Item {
appId: "", appId: "",
label: label, label: label,
target: "", target: "",
detail: "", detail: detail,
path: "", path: "",
childCount: 0, childCount: 0,
action: "", action: "",
@@ -717,7 +722,8 @@ Item {
return return
} }
if (index < 0 || index >= displayModel.count) return if (index < 0 || index >= displayModel.count) return
root.applyDmenuSelection(displayModel.get(index).label) var picked = displayModel.get(index)
root.applyDmenuSelection(picked.detail ? picked.label + "\t" + picked.detail : picked.label)
return return
} }
@@ -1264,7 +1270,7 @@ Item {
Text { Text {
width: parent.width width: parent.width
text: row.detail text: row.detail
visible: root.filterText && row.detail.length > 0 visible: (root.filterText || row.kind === "dmenu") && row.detail.length > 0
color: root.foreground color: root.foreground
opacity: 0.52 opacity: 0.52
font.family: root.fontFamily font.family: root.fontFamily
+12 -32
View File
@@ -65,8 +65,8 @@ pick() {
CALLS=$(cat "$TMPDIR/calls") CALLS=$(cat "$TMPDIR/calls")
} }
# Two plugins can declare the same display name. When both are eligible for the # Two plugins can declare the same display name. The id rides along as row
# same verb, neither row can stand on the name alone. # subtext and comes back with the selection, so the pick resolves by id.
cat >"$TMPDIR/plugins.json" <<'JSON' cat >"$TMPDIR/plugins.json" <<'JSON'
[ [
{"id": "omarchy.clock", "name": "Clock", "kinds": ["bar-widget"], "enabled": false, "active": false, "canDisable": true, "firstParty": true}, {"id": "omarchy.clock", "name": "Clock", "kinds": ["bar-widget"], "enabled": false, "active": false, "canDisable": true, "firstParty": true},
@@ -74,46 +74,26 @@ cat >"$TMPDIR/plugins.json" <<'JSON'
] ]
JSON JSON
pick enable "Clock (tester.clock)" pick enable "$(printf 'Clock\ttester.clock')"
[[ $ROWS == *"Clock (omarchy.clock)"* && $ROWS == *"Clock (tester.clock)"* ]] \ [[ $ROWS == *"$(printf 'Clock\tomarchy.clock')"* && $ROWS == *"$(printf 'Clock\ttester.clock')"* ]] \
|| fail "picker tells two plugins of the same name apart" "$ROWS" || fail "picker offers the id as subtext on every row" "$ROWS"
pass "picker tells two plugins of the same name apart" pass "picker offers the id as subtext on every row"
[[ $CALLS == *"omarchy-plugin-enable tester.clock"* ]] \ [[ $CALLS == *"omarchy-plugin-enable tester.clock"* ]] \
|| fail "picker acts on the row that was picked, not the one that shares its name" "$CALLS" || fail "picker acts on the row that was picked, not the one that shares its name" "$CALLS"
pass "picker acts on the row that was picked, not the one that shares its name" pass "picker acts on the row that was picked, not the one that shares its name"
# Only one of them is eligible, so the row needs no id -- but resolving it by pick remove "$(printf 'Clock\ttester.clock')"
# name alone would still find the wrong plugin, since the other one exists.
cat >"$TMPDIR/plugins.json" <<'JSON'
[
{"id": "omarchy.clock", "name": "Clock", "kinds": ["bar-widget"], "enabled": true, "active": false, "canDisable": true, "firstParty": true},
{"id": "tester.clock", "name": "Clock", "kinds": ["bar-widget"], "enabled": false, "active": false, "canDisable": true, "firstParty": false}
]
JSON
pick enable "Clock"
[[ $ROWS != *"("* ]] || fail "picker adorns a row only when its name is taken twice over" "$ROWS"
pass "picker adorns a row only when its name is taken twice over"
[[ $CALLS == *"omarchy-plugin-enable tester.clock"* ]] \
|| fail "picker resolves a lone row to the plugin the verb offered, not a namesake it filtered out" "$CALLS"
pass "picker resolves a lone row to the plugin the verb offered, not a namesake it filtered out"
pick remove "Clock"
[[ $CALLS == *"omarchy-plugin-remove tester.clock"* ]] \ [[ $CALLS == *"omarchy-plugin-remove tester.clock"* ]] \
|| fail "picker removes the plugin whose row was picked" "$CALLS" || fail "picker removes the plugin whose row was picked" "$CALLS"
pass "picker removes the plugin whose row was picked" pass "picker removes the plugin whose row was picked"
# A name that stands alone is left alone: no id trailing a row that needs none.
cat >"$TMPDIR/plugins.json" <<'JSON' cat >"$TMPDIR/plugins.json" <<'JSON'
[ [
{"id": "acme.weather", "name": "Weather", "kinds": ["bar-widget"], "enabled": false, "active": false, "canDisable": true, "firstParty": false} {"id": "acme.weather", "name": "Weather", "kinds": ["bar-widget"], "enabled": false, "active": false, "canDisable": true, "firstParty": false}
] ]
JSON JSON
pick enable "Weather" pick enable "$(printf 'Weather\tacme.weather')"
[[ $ROWS == *"Weather"* && $ROWS != *"acme.weather)"* ]] \
|| fail "picker leaves an unambiguous name unadorned" "$ROWS"
pass "picker leaves an unambiguous name unadorned"
[[ $CALLS == *"omarchy-plugin-enable acme.weather"* ]] \ [[ $CALLS == *"omarchy-plugin-enable acme.weather"* ]] \
|| fail "picker delegates plugin enablement to the plugin command" "$CALLS" || fail "picker delegates plugin enablement to the plugin command" "$CALLS"
pass "picker delegates plugin enablement to the plugin command" pass "picker delegates plugin enablement to the plugin command"
@@ -127,7 +107,7 @@ cat >"$TMPDIR/plugins.json" <<'JSON'
] ]
JSON JSON
pick clone "Clock" pick clone "$(printf 'Clock\tomarchy.clock')"
[[ $ROWS == *"Clock"* && $ROWS != *"Weather"* ]] || [[ $ROWS == *"Clock"* && $ROWS != *"Weather"* ]] ||
fail "clone picker offers only built-in plugins" "$ROWS" fail "clone picker offers only built-in plugins" "$ROWS"
pass "clone picker offers built-in plugins" pass "clone picker offers built-in plugins"
@@ -157,7 +137,7 @@ cat >"$TMPDIR/plugins.json" <<'JSON'
] ]
JSON JSON
pick enable "Fancy" pick enable "$(printf 'Fancy\tacme.fancy')"
[[ $CALLS == *"omarchy-plugin-enable acme.fancy"* && $CALLS != *"--section"* ]] \ [[ $CALLS == *"omarchy-plugin-enable acme.fancy"* && $CALLS != *"--section"* ]] \
|| fail "picker delegates kind-specific enablement" "$CALLS" || fail "picker delegates kind-specific enablement" "$CALLS"
pass "picker delegates kind-specific enablement" pass "picker delegates kind-specific enablement"
@@ -171,7 +151,7 @@ cat >"$TMPDIR/plugins.json" <<'JSON'
] ]
JSON JSON
pick disable "Clock" pick disable "$(printf 'Clock\tomarchy.clock')"
[[ $ROWS == *"Clock"* && $ROWS != *"Fancy"* ]] \ [[ $ROWS == *"Clock"* && $ROWS != *"Fancy"* ]] \
|| fail "picker keeps a bar out of disable" "$ROWS" || fail "picker keeps a bar out of disable" "$ROWS"
pass "picker keeps a bar out of disable" pass "picker keeps a bar out of disable"
@@ -184,7 +164,7 @@ cat >"$TMPDIR/plugins.json" <<'JSON'
] ]
JSON JSON
pick enable "Bar" pick enable "$(printf 'Bar\tomarchy.bar')"
[[ $ROWS == *"Bar"* && $ROWS != *"Neon Bar"* ]] \ [[ $ROWS == *"Bar"* && $ROWS != *"Neon Bar"* ]] \
|| fail "picker offers every bar except the one already running" "$ROWS" || fail "picker offers every bar except the one already running" "$ROWS"
pass "picker offers every bar except the one already running" pass "picker offers every bar except the one already running"
+8 -8
View File
@@ -291,18 +291,18 @@ assert(
/"omarchy-plugin-\$1" "\$id"/.test(pluginPicker), /"omarchy-plugin-\$1" "\$id"/.test(pluginPicker),
'plugin picker delegates enable and disable without interpreting plugin kinds' 'plugin picker delegates enable and disable without interpreting plugin kinds'
) )
// Icons ride along as "<glyph>\tlabel"; the menu shows the glyph and hands // Icons ride along as "<glyph>\tlabel\tsubtext"; the menu shows the glyph,
// back the label, so nothing downstream has to strip one off. The id rides in // renders the subtext under the label, and hands back "label\tsubtext" so the
// a third field, cut off before the menu is ever shown it. What the picker // picker can act on the id without resolving a display name. What the picker
// then does with the row it gets back is checked in menu-plugin-test.sh. // then does with the row it gets back is checked in menu-plugin-test.sh.
assert( assert(
/\$label \+ \\"\\\\t\\" \+ \.id/.test(pluginPicker) /\.name \+ \\"\\\\t\\" \+ \.id/.test(pluginPicker)
&& /omarchy-menu-select "\$\{1\^\} plugin" < <\(cut -f1,2 <<<"\$rows"\)/.test(pluginPicker), && /id=\$\(cut -f2 <<<"\$selection"\)/.test(pluginPicker),
'plugin picker labels its rows with glyphs and keeps the id out of the label' 'plugin picker shows the id as row subtext and acts on the id the selection hands back'
) )
assert( assert(
/var icon = parts\.length > 1 \? parts\.shift\(\) : ""\s*\n\s*var label = parts\.join\("\\t"\)/.test(menuQml), /var icon = parts\.length > 1 \? parts\.shift\(\) : ""\s*\n\s*var label = parts\.shift\(\) \|\| ""\s*\n\s*var detail = parts\.join\("\\t"\)/.test(menuQml),
'menu select mode reads a leading icon off an option and filters on the label alone' 'menu select mode reads a leading icon and a trailing subtext off an option'
) )
assert( assert(
/omarchy-launch-floating-terminal-with-presentation "omarchy-plugin-remove/.test(pluginPicker), /omarchy-launch-floating-terminal-with-presentation "omarchy-plugin-remove/.test(pluginPicker),