diff --git a/internal/config/config.go b/internal/config/config.go index fc3bab330231e22606109263d923073f70a00f41..38c8159109dbcaf945ceb3a00626fa1f0c34048a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 { diff --git a/internal/hooks/hooks_test.go b/internal/hooks/hooks_test.go index 482833e00d16ce1fd88cca1d91a3a5913cda0733..6ae647545d18a5aa79a117f8a24a39d9c76247f5 100644 --- a/internal/hooks/hooks_test.go +++ b/internal/hooks/hooks_test.go @@ -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. diff --git a/internal/hooks/runner.go b/internal/hooks/runner.go index abc2393fa0b5c156044633c991568a669c4689e9..734fd3e5ca2078a9ac0143a272538542d77f42a7 100644 --- a/internal/hooks/runner.go +++ b/internal/hooks/runner.go @@ -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, diff --git a/schema.json b/schema.json index 8b90109b39dfc509399a135caa4214b49534e033..9580a7be217151b8d6f2543071fb635f10763016 100644 --- a/schema.json +++ b/schema.json @@ -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."