mako QoL update

This commit is contained in:
2026-08-08 13:27:14 +02:00
parent 28b8fcb492
commit 444fca8aff
12 changed files with 304 additions and 50 deletions

View File

@@ -90,7 +90,8 @@ eventbox, box, label, scale {
.battery-icon,
.battery-percent,
.time-label,
.notification-icon {
.notification-icon,
.dnd-icon {
color: $fg;
font-weight: bold;
padding: 0 $icon-pad;
@@ -101,7 +102,8 @@ eventbox, box, label, scale {
.signal-eventbox,
.net-eventbox,
.notification-eventbox {
.notification-eventbox,
.dnd-eventbox {
min-width: 0;
min-height: 0;
}

View File

@@ -70,11 +70,18 @@
;; Notifications
;; ---------------------------------------------------------------------------
(defpoll dnd-state :interval "1s" "bash ~/.config/eww/scripts/mako-dnd-state")
(defwidget notification-widget []
(eventbox :class "notification-eventbox"
:onclick "bash ~/.config/eww/scripts/notification-history-toggle"
:tooltip "Toggle notification history"
(label :text "" :class "notification-icon")))
(box :orientation "h" :space-evenly false :spacing gap-tight
(eventbox :class "notification-eventbox"
:onclick "bash ~/.config/eww/scripts/notification-history-toggle"
:tooltip "Toggle notification history"
(label :text "" :class "notification-icon"))
(eventbox :class "notification-eventbox dnd-eventbox"
:onclick "bash ~/.config/eww/scripts/mako-dnd-toggle"
:tooltip "Toggle do-not-disturb"
(label :text {dnd-state} :class "notification-icon dnd-icon"))))
;; ---------------------------------------------------------------------------
;; Battery

View File

@@ -0,0 +1,8 @@
#!/bin/bash
# Print the current do-not-disturb state as a bar icon.
# Called by eww as a defpoll.
if makoctl mode 2>/dev/null | grep -q dnd; then
echo "" # bell-off: do-not-disturb active
else
echo "" # bell-ring: notifications on
fi

View File

@@ -0,0 +1,3 @@
#!/bin/bash
# Toggle do-not-disturb mode for mako notifications.
makoctl mode -t dnd

View File

@@ -14,6 +14,7 @@ height=300
margin=5
padding=8
icon-path=/usr/share/icons/Papirus-Dark
max-icon-size=48
# Layout
anchor=top-right
@@ -21,29 +22,66 @@ outer-margin=30,0,0,0
# Behavior
default-timeout=6000
ignore-timeout=0
max-visible=20
# Force mako's own timeouts so no notification (critical or not) can hang
# indefinitely, even if the app requests timeout=0 (never).
ignore-timeout=1
max-visible=5
sort=-time
group-by=app-name
max-history=20
# Format - show app name in italics
format=<b>%s</b> <i>(%a)</i>\n%b
markup=1
# Progress bar for notifications that report progress (downloads, updates)
progress-color=over #69C3FF
# Mouse bindings - click to focus app, invoke action, and close
# Format - plain text (markup=0) so app text with <tag> renders literally.
# Priority is shown by a leading icon instead of markup/colors.
markup=0
format= %s (%a)\n%b
# Mouse bindings
on-button-left=exec ~/.config/mako/scripts/handle-click $id
on-button-middle=none
on-button-right=exec makoctl dismiss -n $id
on-notify=exec ~/.config/mako/scripts/notify-sound $id
# Urgency levels
[urgency=low]
background-color=#212a3b
border-color=#405275
format= %s (%a)\n%b
[urgency=normal]
background-color=#1c2433
border-color=#69C3FF
format= %s (%a)\n%b
[urgency=critical]
background-color=#1c2433
border-color=#FF738A
default-timeout=0
format=󰈸 %s (%a)\n%b
# Was 0 (never) - critical notifications now auto-dismiss after 15s
default-timeout=15000
# Grouped notifications (group-by=app-name) - plain text, icon per urgency
[grouped]
format= (%g) %s\n%b
[grouped urgency=low]
format= (%g) %s\n%b
[grouped urgency=critical]
format=󰈸 (%g) %s\n%b
# Placeholder shown when more notifications exist than max-visible
[hidden]
format=󰁉 +%h more
# Pi agent notifications (sent via the pi QoL extension from wezterm)
[app-name=wezterm summary=Pi]
border-color=#EACD61
background-color=#212a3b
# Do-not-disturb: makoctl mode -a dnd hides all notifications.
# Must stay last so it overrides every style above.
[mode=dnd]
invisible=1

View File

@@ -14,6 +14,7 @@ height=300
margin=5
padding=8
icon-path=/usr/share/icons/Papirus-Dark
max-icon-size=48
# Layout
anchor=top-center
@@ -21,29 +22,67 @@ outer-margin=30,0,0,0
# Behavior
default-timeout=6000
ignore-timeout=0
max-visible=20
# Force mako's own timeouts so no notification (critical or not) can hang
# indefinitely, even if the app requests timeout=0 (never).
ignore-timeout=1
max-visible=5
sort=-time
group-by=app-name
max-history=20
# Format - show app name in italics
format=<b>%s</b> <i>(%a)</i>\n%b
markup=1
# Progress bar for notifications that report progress (downloads, updates)
progress-color=over #$BORDER
# Mouse bindings - click to focus app, invoke action, and close
# Format - plain text (markup=0) so app text with <tag> renders literally.
# Priority is shown by a leading icon instead of markup/colors.
markup=0
format=󰁉 %s (%a)\n%b
# Mouse bindings
on-button-left=exec ~/.config/mako/scripts/handle-click $id
on-button-middle=none
on-button-right=exec makoctl dismiss -n $id
on-notify=exec ~/.config/mako/scripts/notify-sound $id
# Urgency levels
[urgency=low]
background-color=#$BG_BAR
border-color=#$TRACK
format=󰁉 %s (%a)\n%b
[urgency=normal]
background-color=#$BG
border-color=#$BORDER
format=󰁁 %s (%a)\n%b
[urgency=critical]
background-color=#$BG
border-color=#$URGENT
default-timeout=0
format=󰀤 %s (%a)\n%b
# Was 0 (never) - critical notifications now auto-dismiss after 15s
default-timeout=15000
# Grouped notifications (group-by=app-name) - plain text, icon per urgency
[grouped]
format=󰁁 (%g) %s\n%b
[grouped urgency=low]
format=󰁉 (%g) %s\n%b
[grouped urgency=critical]
format=󰀤 (%g) %s\n%b
# Placeholder shown when more notifications exist than max-visible
[hidden]
format=󰁉 +%h more
# Pi agent notifications (sent via the pi QoL extension from wezterm).
# Matches theme accent $accent (#EACD61).
[app-name=wezterm summary=Pi]
border-color=#EACD61
background-color=#$BG_BAR
# Do-not-disturb: makoctl mode -a dnd hides all notifications.
# Must stay last so it overrides every style above.
[mode=dnd]
invisible=1

Binary file not shown.

Binary file not shown.

64
mako/.config/mako/scripts/handle-click Normal file → Executable file
View File

@@ -1,43 +1,47 @@
#!/bin/bash
# Handle notification click: focus app → invoke action → close
# Called by mako with notification ID as parameter
# Handle notification click: focus the app that sent it → invoke action → dismiss
# Called by mako with the notification ID as argument
ID="$1"
[ -z "$ID" ] && exit 0
if [ -z "$ID" ]; then
exit 0
fi
# Pull the app name out of makoctl list, scoped to this notification ID
APP=$(makoctl list 2>/dev/null | awk -v id="$ID" '
$1 == "Notification" { if (seen) exit; if ($2 == id) seen = 1; next }
seen && /^ App name:/ { sub(/^ App name: /, ""); print; exit }')
# Get app name from notification using makoctl
APPNAME=$(makoctl list | grep -A 2 "^Notification $ID" | grep "App name:" | cut -d: -f2- | xargs)
# Find a sway window matching the app and focus it.
# Exact app_id match first, then X11 class, then partial app_id as fallback.
if [ -n "$APP" ]; then
TREE=$(swaymsg -t get_tree 2>/dev/null)
# Step 1: Focus the application window in Sway (if app name found)
if [ -n "$APPNAME" ]; then
# Get the workspace of the window
WORKSPACE=$(swaymsg -t get_tree | jq -r ".. | select(.app_id?==\"$APPNAME\" or .window_properties?.class?==\"$APPNAME\") | .workspace" | head -1)
CID=$(echo "$TREE" | jq -r --arg app "$APP" '
[ .. | objects | select(.type? == "con")
| select((.app_id? // "") != "")
| select((.app_id | ascii_downcase) == ($app | ascii_downcase))
| .id ][0] // empty')
# If workspace not found, try case-insensitive
if [ -z "$WORKSPACE" ] || [ "$WORKSPACE" = "null" ]; then
WORKSPACE=$(swaymsg -t get_tree | jq -r ".. | select(.app_id?|test(\"$APPNAME\";\"i\") or .window_properties?.class?|test(\"$APPNAME\";\"i\")) | .workspace" | head -1)
if [ -z "$CID" ]; then
CID=$(echo "$TREE" | jq -r --arg app "$APP" '
[ .. | objects | select(.type? == "con")
| select((.window_properties?.class? // "") != "")
| select((.window_properties.class | ascii_downcase) == ($app | ascii_downcase))
| .id ][0] // empty')
fi
# Switch to the workspace if found
if [ -n "$WORKSPACE" ] && [ "$WORKSPACE" != "null" ]; then
swaymsg workspace "$WORKSPACE" 2>/dev/null
sleep 0.1
if [ -z "$CID" ]; then
CID=$(echo "$TREE" | jq -r --arg app "$APP" '
[ .. | objects | select(.type? == "con")
| select((.app_id? // "") != "")
| select((.app_id | ascii_downcase) | contains($app | ascii_downcase))
| .id ][0] // empty')
fi
# Now focus the window
swaymsg "[app_id=\"$APPNAME\"]" focus 2>/dev/null || \
swaymsg "[app_id=\"(?i)$APPNAME\"]" focus 2>/dev/null || \
swaymsg "[class=\"$APPNAME\"]" focus 2>/dev/null || \
swaymsg "[class=\"(?i)$APPNAME\"]" focus 2>/dev/null
sleep 0.1
if [ -n "$CID" ]; then
swaymsg "[con_id=$CID]" focus >/dev/null 2>&1
fi
fi
# Step 2: Invoke the notification's default action (if any)
makoctl invoke -n "$ID" 2>/dev/null
# Step 3: Dismiss the notification
makoctl dismiss -n "$ID" 2>/dev/null
# Invoke the notification's default action (may open/focus the app), then dismiss
makoctl invoke -n "$ID" >/dev/null 2>&1
makoctl dismiss -n "$ID" >/dev/null 2>&1

View File

@@ -0,0 +1,39 @@
#!/bin/bash
# Play a notification sound, alternating between Chord.wav and Chord2.wav.
# Skips: do-not-disturb mode and low-urgency notifications.
# Usage: notify-sound <notification-id> (called from mako's on-notify binding)
ID="${1:-}"
# Silence while do-not-disturb is active
if makoctl mode 2>/dev/null | grep -q dnd; then
exit 0
fi
# Only play for normal/critical urgency
if [ -n "$ID" ]; then
urgency=$(makoctl list 2>/dev/null | awk -v id="$ID" '
$1 == "Notification" { if (seen) exit; if ($2 == id) seen = 1; next }
seen && /^ Urgency:/ { print $2; exit }')
if [ "$urgency" = "low" ]; then
exit 0
fi
fi
SOUND_DIR=~/.config/mako/scripts
STATE_FILE="${XDG_CACHE_HOME:-$HOME/.cache}/mako-notify-sound"
# Alternate between the two sounds on every play
if [ -f "$STATE_FILE" ] && [ "$(cat "$STATE_FILE")" = "0" ]; then
SOUND="$SOUND_DIR/Chord2.wav"
echo "1" > "$STATE_FILE"
else
SOUND="$SOUND_DIR/Chord.wav"
echo "0" > "$STATE_FILE"
fi
if command -v paplay >/dev/null 2>&1; then
paplay "$SOUND"
elif command -v pw-play >/dev/null 2>&1; then
pw-play "$SOUND"
fi

Binary file not shown.

View File

@@ -0,0 +1,114 @@
/**
* Subagent pane lifecycle reminder.
*
* When the parent agent dispatches to a `pane: true` agent via `subagent`,
* this extension floats a cleanup reminder to the parent only:
*
* - immediately: appended to the `subagent` tool result of that call;
* - persistently: a one-line system-prompt note on later turns until the
* parent calls `stop_subagent` for that agent (or the session ends).
*
* Nothing is injected for bg agents (`pane: false`), for `delegate_subagent`
* (pane targets are rejected there anyway), for `/agents` commands, or in
* sessions that never spawn a pane agent. Other sessions and workflows stay
* untouched.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import * as fs from "node:fs";
import * as path from "node:path";
import { homedir } from "node:os";
interface SubagentInput {
agent?: unknown;
tasks?: Array<{ agent?: unknown }>;
chain?: Array<{ agent?: unknown }>;
}
const USER_AGENT_DIRS: string[] = [
path.join(homedir(), ".pi", "agent", "agents"),
path.join(homedir(), ".claude", "agents"),
];
function projectAgentDirs(cwd: string): string[] {
const dirs: string[] = [];
let current = path.resolve(cwd);
for (;;) {
dirs.push(path.join(current, ".pi", "agents"), path.join(current, ".claude", "agents"));
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return dirs;
}
function hasPaneFrontmatter(text: string): boolean {
const block = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
return block ? /^\s*pane\s*:\s*(?:true|yes|on)\s*$/im.test(block[1]) : false;
}
function isPaneAgent(name: string, cwd: string): boolean {
for (const dir of [...USER_AGENT_DIRS, ...projectAgentDirs(cwd)]) {
for (const ext of [".md", ".markdown"]) {
try {
if (hasPaneFrontmatter(fs.readFileSync(path.join(dir, `${name}${ext}`), "utf8"))) return true;
} catch {
// not present in this directory
}
}
}
return false;
}
function agentNamesFrom(input: SubagentInput): string[] {
const names = new Set<string>();
const add = (value: unknown) => {
if (typeof value === "string" && value.trim()) names.add(value.trim());
};
add(input.agent);
for (const item of input.tasks ?? []) add(item.agent);
for (const item of input.chain ?? []) add(item.agent);
return [...names];
}
function reminderText(names: string[]): string {
const quoted = names.map((name) => `\`${name}\``).join(", ");
return (
`\n\nPane cleanup: you spawned persistent pane agent(s) ${quoted}. ` +
`After you collect the result, call \`stop_subagent\` with the agent name to kill its tmux pane. ` +
`Skip only if you plan a follow-up (steer or another subagent call to the same agent); stop it at the end of the workflow. ` +
`Stopping preserves the session file, so a later \`subagent\` call resumes the agent's memory.`
);
}
export default function (pi: ExtensionAPI) {
// Agent names whose panes are still alive this session.
const pending = new Set<string>();
pi.on("tool_call", (event) => {
if (event.toolName === "stop_subagent") {
const agent = (event.input as { agent?: unknown } | undefined)?.agent;
if (typeof agent === "string") pending.delete(agent);
}
});
pi.on("tool_result", (event, ctx) => {
if (event.toolName !== "subagent" || event.isError) return;
const input = (event.input as SubagentInput | undefined) ?? {};
const paneAgents = agentNamesFrom(input).filter((name) => isPaneAgent(name, ctx.cwd));
if (paneAgents.length === 0) return;
for (const name of paneAgents) pending.add(name);
return {
content: [...(event.content ?? []), { type: "text" as const, text: reminderText(paneAgents) }],
};
});
pi.on("before_agent_start", (event) => {
if (pending.size === 0) return;
const note =
"\n\nPane cleanup (pending): " +
`[${[...pending].join(", ")}]. ` +
"After collecting their results, call stop_subagent for each (unless a follow-up is planned).";
return { systemPrompt: event.systemPrompt + note };
});
}