Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -1670,6 +1670,23 @@
"security",
"compliance"
]
},
{
"name": "claude-session-tint",
"source": "./plugins/claude-session-tint",
"description": "Tint a Terminal.app window by project and brighten it when a Claude Code response finishes unseen; tabs sharing a window get a tab-title marker instead. Includes a UserPromptSubmit hook that runs an input-box command without invoking the model.",
"version": "1.2.1",
"author": {
"name": "DotcomJack"
},
"category": "Workflow Orchestration",
"homepage": "https://github.com/dotcomjack/claude-session-tint",
"keywords": [
"terminal",
"macos",
"hooks",
"multi-session"
]
}
]
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Install or disable them dynamically with the `/plugin` command — enabling you
- [angelos-symbo](./plugins/angelos-symbo)
- [ceo-quality-controller-agent](./plugins/ceo-quality-controller-agent)
- [claude-desktop-extension](./plugins/claude-desktop-extension)
- [claude-session-tint](./plugins/claude-session-tint)
- [lyra](./plugins/lyra)
- [model-context-protocol-mcp-expert](./plugins/model-context-protocol-mcp-expert)
- [problem-solver-specialist](./plugins/problem-solver-specialist)
Expand Down
14 changes: 14 additions & 0 deletions plugins/claude-session-tint/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "claude-session-tint",
"displayName": "Claude Session Tint",
"version": "1.2.1",
"description": "Know which Claude Code session needs you. Tints a Terminal.app window by project and brightens it when a response lands unseen.",
"author": {
"name": "DotcomJack",
"url": "https://dotcomjack.com"
},
"homepage": "https://github.com/dotcomjack/claude-session-tint",
"repository": "https://github.com/dotcomjack/claude-session-tint",
"license": "MIT",
"keywords": ["terminal", "macos", "hooks", "multi-session", "productivity"]
}
21 changes: 21 additions & 0 deletions plugins/claude-session-tint/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 DotcomJack

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
27 changes: 27 additions & 0 deletions plugins/claude-session-tint/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# claude-session-tint

Know which Claude Code session needs you.

Tag a terminal window with a project. It wears that project's color quietly
while it works, brightens when a response lands while you are looking elsewhere,
and drops back the moment you focus it.

```
,api tag this window "API" (no turn, no tokens, no reply)
, show the palette
,off untag
```

Typing `,api` runs a command from inside a live session **without invoking the
model**: a `UserPromptSubmit` hook returns `decision:"block"` with
`suppressOriginalPrompt`, so Claude Code never queries the model. That pattern
works on any terminal and any OS. The window coloring itself is macOS
Terminal.app only, because Terminal.app is the one terminal with no native tab
colors.

Edit your palette at `~/.claude/tabtint-palette.conf` (falls back to the bundled
`palette.conf`). Tune with `TABTINT_IDLE_PCT` and `TABTINT_ATTN_PCT`.

Upstream, issues and full docs: https://github.com/dotcomjack/claude-session-tint

MIT.
18 changes: 18 additions & 0 deletions plugins/claude-session-tint/bin/tabtint
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/bin/bash
# Tag this terminal window with a project color.
# tabtint show this window's tag and the palette
# tabtint api tag it (case insensitive, prefixes work)
# tabtint off untag, back to the original background
# tabtint list just the palette
# tabtint sync repaint every window (after editing colors)
# tabtint idle on|off resting tint on or off
# tabtint status what the hook currently sees
#
# Prefers tabtint.sh one level up (plugin layout: bin/ inside the plugin root),
# then falls back to a manual install under ~/.claude/hooks.
here=$(cd "$(dirname "$0")" 2>/dev/null && pwd)
for c in "$here/../tabtint.sh" "$HOME/.claude/hooks/tabtint.sh"; do
[ -x "$c" ] && exec "$c" tab "$@"
done
echo "tabtint: cannot find tabtint.sh (looked in $here/.. and ~/.claude/hooks)" >&2
exit 1
17 changes: 17 additions & 0 deletions plugins/claude-session-tint/commands/tabtint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
description: Tint this terminal window with a project color. No argument shows the palette.
argument-hint: "<project> | off | list | idle on|off | status (or just type ,project)"
allowed-tools: Bash(${CLAUDE_PLUGIN_ROOT}/tabtint.sh:*)
disable-model-invocation: true
model: haiku
effort: low
---

The window color command has already run during expansion. Its output:

!`${CLAUDE_PLUGIN_ROOT}/tabtint.sh tab $ARGUMENTS`

Print that output back verbatim, then on a final line print exactly:
`tip: ,<project> in the input box does the same thing with no turn.`

No other commentary, no summary, no preamble, no tool calls. There is nothing left to do.
34 changes: 34 additions & 0 deletions plugins/claude-session-tint/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/tabtint.sh rest", "timeout": 25, "async": true }
]
}
],
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/prompt-hook.sh", "timeout": 10 }
]
},
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/tabtint.sh clear", "timeout": 25, "async": true }
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/tabtint.sh set", "timeout": 25, "async": true }
]
}
]
}
}
35 changes: 35 additions & 0 deletions plugins/claude-session-tint/palette.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# tabtint palette
#
# Format, TAB separated:
# HEX <TAB> KEY <TAB> LABEL <TAB> EMOJI
#
# KEY is what you type: `tabtint api`. Matching is case insensitive and accepts
# a prefix, so `tabtint ap` works too.
#
# HEX is the identity color at full strength. It is never painted at full
# strength: it scales to a readable dark wash (22% at rest, 48% unread).
#
# EMOJI is optional. When set, tagging a window copies a ready-to-paste
# `/rename <emoji> <label>` line to your clipboard, because most terminals
# cannot color the tab itself but every terminal renders an emoji in the title.
#
# Replace these with your own projects. Edit freely, no restart needed, then
# run `tabtint sync`.

#HEX KEY LABEL EMOJI
#A3D8E1 api API 🔵
#6FB07A web Web 🌿
#C4744A mobile Mobile 🧱
#B07AC4 infra Infra 🟣
#D9B44A docs Docs 📜
#E8833A data Data 🟠
#7E7BD4 ml ML 🎧
#46C8B8 tooling Tooling 🩵

# Unbranded tags, for scratch or throwaway windows
#D94F4F red Red 🔴
#4FD97A green Green 🟢
#2F72E6 blue Blue 🔵
#D9D94F yellow Yellow 🟡
#B84FD9 purple Purple 🟣
#CCCCCC white White ⚪
108 changes: 108 additions & 0 deletions plugins/claude-session-tint/prompt-hook.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/bin/bash
# tab-tag-prompt.sh - UserPromptSubmit hook (companion to tabtint.sh)
#
# Tag the current Terminal window from inside a running Claude session without
# spending a turn. Type a lone comma line into the Claude input box:
#
# ,aianyone tag this window (prefixes work, so ,ai is enough)
# ,off untag it
# , show this window's tag plus the palette
# ,list just the palette
# ,idle on resting tint on / off
#
# HOW THE ZERO-TURN PART ACTUALLY WORKS (verified against the 2.1.223 binary,
# not inferred from docs). On a UserPromptSubmit hook result that carries a
# blockingError, Claude Code returns { shouldQuery: false, ... }, so the model
# is never invoked: no turn, no tokens, no assistant message.
#
# The important detail, and the reason this script prints JSON instead of just
# exiting 2 with a message on stderr:
#
# V = H.suppressOriginalPrompt ? q : `${q}\n\nOriginal prompt: ${O}`
#
# A bare `exit 2` cannot set suppressOriginalPrompt, so the blocked prompt gets
# echoed back into the transcript as "Original prompt: ,ai". Emitting
# hookSpecificOutput.suppressOriginalPrompt = true drops that echo. Setting
# decision:"block" also supplies the reason text directly, instead of Claude
# Code synthesising "[<command>]: <stderr>" around it.
#
# We still exit 2 as well as printing the JSON. That is deliberate: with exit 0
# there is an earlier success branch (gated on `de.status===0`) that can yield
# before the blocking decision is applied. Exit 2 skips it, and because
# decision:"block" already populated blockingError, the exit-2 stderr fallback
# (`if (status===2 && !fe.blockingError)`) never fires either.
#
# Everything that is not a comma line exits 0 and is passed through untouched.
# Every failure path also exits 0. This must never eat a real prompt.
#
# Must be wired NON-async in settings.json. An async hook runs in the
# background, so its result arrives too late to gate the prompt.

set -uo pipefail

# Resolve tabtint.sh next to this script rather than at a fixed path. That makes
# the same file work as a plugin (both scripts sit in the plugin root, which
# moves on every update) and as a manual install (both in ~/.claude/hooks/).
TAB="$(cd "$(dirname "$0")" 2>/dev/null && pwd)/tabtint.sh"

payload=$(cat 2>/dev/null) || exit 0
[ -n "$payload" ] || exit 0
command -v jq >/dev/null 2>&1 || exit 0

# Field name is `prompt`, confirmed in the binary:
# hook_event_name:"UserPromptSubmit",prompt:e,...
prompt=$(printf '%s' "$payload" | jq -r '.prompt // empty' 2>/dev/null) || exit 0
[ -n "$prompt" ] || exit 0

# ---- the gate -------------------------------------------------------------
# Pure bash 3.2 pattern matching. No grep: `grep -E '^,...$'` matches ANY LINE
# of a multi-line prompt, so a long real message containing a short
# comma-leading line would be silently eaten. Reject newlines outright first.

case $prompt in
*'
'*) exit 0 ;; # multi-line, always a real prompt
esac

# Trim leading whitespace before matching. Typing a space before the comma is
# easy to do and used to fall straight through as a real prompt.
prompt=${prompt#"${prompt%%[![:space:]]*}"}

case $prompt in
,*) ;; # candidate
*) exit 0 ;;
esac

[ ${#prompt} -le 25 ] || exit 0 # long comma line, real prompt

# Only characters a brand key / subcommand can contain. This also excludes the
# glob metacharacters, which is what makes the unquoted expansion below safe.
case $prompt in
*[!A-Za-z0-9\ _,.-]*) exit 0 ;;
esac

[ -x "$TAB" ] || exit 0

# ---- do the work ----------------------------------------------------------

arg=${prompt#,}
arg=${arg#"${arg%%[![:space:]]*}"} # ltrim
arg=${arg%"${arg##*[![:space:]]}"} # rtrim

set -f # belt and braces, no globbing
# shellcheck disable=SC2086
out=$("$TAB" tab $arg 2>&1)
set +f

[ -n "$out" ] || out="tabcolor: no output"

jq -n --arg r "$out" '{
decision: "block",
reason: $r,
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
suppressOriginalPrompt: true
}
}' 2>/dev/null || exit 0

exit 2
Loading