@@ -545,6 +545,8 @@ func (t ToolGrep) GetTimeout() time.Duration {
// is owned by hooks.Runner so a JSON round-trip, merge, or reload can't
// silently drop compiled state.
type HookConfig struct {
+ // Friendly display name shown in the TUI. Falls back to Command when empty.
+ Name string `json:"name,omitempty" jsonschema:"description=Friendly display name shown in the TUI for this hook"`
// Regex pattern tested against the tool name. Empty means match all.
Matcher string `json:"matcher,omitempty" jsonschema:"description=Regex pattern tested against the tool name. Empty means match all tools."`
// Shell command to execute.
@@ -553,6 +555,15 @@ type HookConfig struct {
Timeout int `json:"timeout,omitempty" jsonschema:"description=Timeout in seconds for the hook command,default=30"`
}
+// DisplayName returns the hook name for display purposes. It returns Name
+// when set, otherwise falls back to Command.
+func (h *HookConfig) DisplayName() string {
+ if h.Name != "" {
+ return h.Name
+ }
+ return h.Command
+}
+
// TimeoutDuration returns the hook timeout as a time.Duration, defaulting
// to 30s.
func (h *HookConfig) TimeoutDuration() time.Duration {
@@ -476,6 +476,37 @@ func TestValidateHooksNormalizesEventNames(t *testing.T) {
}
}
+func TestRunnerHookNameUsesDisplayName(t *testing.T) {
+ t.Parallel()
+
+ t.Run("name field is used when set", func(t *testing.T) {
+ t.Parallel()
+ hookCfg := config.HookConfig{
+ Name: "my-hook",
+ Command: `echo '{"decision":"allow"}'`,
+ }
+ r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
+ result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
+ require.NoError(t, err)
+ require.Equal(t, DecisionAllow, result.Decision)
+ require.Len(t, result.Hooks, 1)
+ require.Equal(t, "my-hook", result.Hooks[0].Name)
+ })
+
+ t.Run("command is used when name is empty", func(t *testing.T) {
+ t.Parallel()
+ hookCfg := config.HookConfig{
+ Command: `echo '{"decision":"allow"}'`,
+ }
+ r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
+ result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
+ require.NoError(t, err)
+ require.Equal(t, DecisionAllow, result.Decision)
+ require.Len(t, result.Hooks, 1)
+ require.Equal(t, `echo '{"decision":"allow"}'`, result.Hooks[0].Name)
+ })
+}
+
func TestRunnerParallelExecution(t *testing.T) {
t.Parallel()
// Two hooks: one allows, one denies. Deny should win.
@@ -123,7 +123,7 @@ func (r *Runner) Run(ctx context.Context, eventName, sessionID, toolName, toolIn
agg.Hooks = make([]HookInfo, len(deduped))
for i, h := range deduped {
agg.Hooks[i] = HookInfo{
- Name: h.Command,
+ Name: h.DisplayName(),
Matcher: h.Matcher,
Decision: results[i].Decision.String(),
Halt: results[i].Halt,
@@ -106,6 +106,10 @@
},
"HookConfig": {
"properties": {
+ "name": {
+ "type": "string",
+ "description": "Friendly display name shown in the TUI for this hook"
+ },
"matcher": {
"type": "string",
"description": "Regex pattern tested against the tool name. Empty means match all tools."