ghost-tab 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +151 -0
  2. package/VERSION +1 -0
  3. package/bin/ghost-tab +360 -0
  4. package/bin/npx-ghost-tab.js +141 -0
  5. package/ghostty/config +5 -0
  6. package/lib/ai-select-tui.sh +66 -0
  7. package/lib/ai-tools.sh +19 -0
  8. package/lib/config-tui.sh +95 -0
  9. package/lib/ghostty-config.sh +26 -0
  10. package/lib/input.sh +39 -0
  11. package/lib/install.sh +224 -0
  12. package/lib/loading.sh +190 -0
  13. package/lib/menu-tui.sh +189 -0
  14. package/lib/notification-setup.sh +210 -0
  15. package/lib/process.sh +13 -0
  16. package/lib/project-actions-tui.sh +29 -0
  17. package/lib/project-actions.sh +9 -0
  18. package/lib/projects.sh +18 -0
  19. package/lib/settings-json.sh +178 -0
  20. package/lib/settings-menu-tui.sh +32 -0
  21. package/lib/setup.sh +16 -0
  22. package/lib/statusline-setup.sh +60 -0
  23. package/lib/statusline.sh +31 -0
  24. package/lib/tab-title-watcher.sh +118 -0
  25. package/lib/terminal-select-tui.sh +119 -0
  26. package/lib/terminals/adapter.sh +19 -0
  27. package/lib/terminals/ghostty.sh +58 -0
  28. package/lib/terminals/iterm2.sh +51 -0
  29. package/lib/terminals/kitty.sh +40 -0
  30. package/lib/terminals/registry.sh +37 -0
  31. package/lib/terminals/wezterm.sh +50 -0
  32. package/lib/tmux-session.sh +49 -0
  33. package/lib/tui.sh +80 -0
  34. package/lib/update.sh +52 -0
  35. package/package.json +42 -0
  36. package/templates/ccstatusline-settings.json +29 -0
  37. package/templates/statusline-command.sh +30 -0
  38. package/templates/statusline-wrapper.sh +40 -0
  39. package/terminals/ghostty/config +5 -0
  40. package/terminals/kitty/config +1 -0
  41. package/terminals/wezterm/config.lua +4 -0
  42. package/wrapper.sh +222 -0
@@ -0,0 +1,189 @@
1
+ #!/bin/bash
2
+ # TUI wrapper for main menu
3
+ # Uses ghost-tab-tui main-menu subcommand
4
+
5
+ # Compute the target path for a new worktree.
6
+ # Args: project_path project_name branch worktree_base
7
+ # Outputs the computed path to stdout.
8
+ compute_worktree_path() {
9
+ local project_path="$1"
10
+ local project_name="$2"
11
+ local branch="$3"
12
+ local worktree_base="$4"
13
+
14
+ # Sanitize branch name: strip origin/ prefix, replace / with -
15
+ local sanitized="${branch#origin/}"
16
+ sanitized="${sanitized//\//-}"
17
+
18
+ if [[ -n "$worktree_base" ]]; then
19
+ echo "${worktree_base}/${project_name}--${sanitized}"
20
+ else
21
+ local parent_dir
22
+ parent_dir="$(dirname "$project_path")"
23
+ echo "${parent_dir}/${project_name}--${sanitized}"
24
+ fi
25
+ }
26
+
27
+ # Interactive project selection using ghost-tab-tui main-menu
28
+ # Returns 0 if an actionable item was selected, 1 if quit/cancelled
29
+ # Sets: _selected_project_name, _selected_project_path, _selected_project_action, _selected_ai_tool
30
+ select_project_interactive() {
31
+ local projects_file="$1"
32
+
33
+ if ! command -v ghost-tab-tui &>/dev/null; then
34
+ error "ghost-tab-tui binary not found. Please reinstall."
35
+ return 1
36
+ fi
37
+
38
+ # Read preferences from settings file
39
+ local ghost_display="animated"
40
+ local tab_title="full"
41
+ local settings_file="${XDG_CONFIG_HOME:-$HOME/.config}/ghost-tab/settings"
42
+ if [ -f "$settings_file" ]; then
43
+ local saved_display
44
+ saved_display=$(grep '^ghost_display=' "$settings_file" 2>/dev/null | cut -d= -f2)
45
+ if [ -n "$saved_display" ]; then
46
+ ghost_display="$saved_display"
47
+ fi
48
+ local saved_tab_title
49
+ saved_tab_title=$(grep '^tab_title=' "$settings_file" 2>/dev/null | cut -d= -f2)
50
+ if [ -n "$saved_tab_title" ]; then
51
+ tab_title="$saved_tab_title"
52
+ fi
53
+ fi
54
+
55
+ # Read sound notification state
56
+ local sound_name=""
57
+ local gt_config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/ghost-tab"
58
+ if type get_sound_name &>/dev/null; then
59
+ sound_name="$(get_sound_name "${SELECTED_AI_TOOL:-claude}" "$gt_config_dir")"
60
+ fi
61
+
62
+ # Build AI tools comma-separated list
63
+ local ai_tools_csv
64
+ ai_tools_csv=$(IFS=,; echo "${AI_TOOLS_AVAILABLE[*]}")
65
+
66
+ # Build command args
67
+ local ai_tool_file="${XDG_CONFIG_HOME:-$HOME/.config}/ghost-tab/ai-tool"
68
+ local cmd_args=("main-menu" "--projects-file" "$projects_file")
69
+ cmd_args+=("--ai-tool" "${SELECTED_AI_TOOL:-claude}")
70
+ cmd_args+=("--ai-tools" "$ai_tools_csv")
71
+ cmd_args+=("--ai-tool-file" "$ai_tool_file")
72
+ cmd_args+=("--ghost-display" "$ghost_display")
73
+ cmd_args+=("--tab-title" "$tab_title")
74
+ cmd_args+=("--settings-file" "$settings_file")
75
+ local sound_file="$gt_config_dir/${SELECTED_AI_TOOL:-claude}-features.json"
76
+ cmd_args+=("--sound-file" "$sound_file")
77
+ if [[ -n "$sound_name" ]]; then
78
+ cmd_args+=("--sound-name" "$sound_name")
79
+ fi
80
+ if [ -n "${_update_version:-}" ]; then
81
+ cmd_args+=("--update-version" "$_update_version")
82
+ fi
83
+
84
+ local result
85
+ if ! result=$(ghost-tab-tui "${cmd_args[@]}" 2>/dev/null); then
86
+ return 1
87
+ fi
88
+
89
+ local action
90
+ if ! action=$(echo "$result" | jq -r '.action' 2>/dev/null); then
91
+ error "Failed to parse menu response"
92
+ return 1
93
+ fi
94
+
95
+ if [[ -z "$action" || "$action" == "null" ]]; then
96
+ return 1
97
+ fi
98
+
99
+ # Update AI tool if changed (persist regardless of exit action)
100
+ local ai_tool
101
+ ai_tool=$(echo "$result" | jq -r '.ai_tool // ""' 2>/dev/null)
102
+ if [[ -n "$ai_tool" && "$ai_tool" != "null" ]]; then
103
+ _selected_ai_tool="$ai_tool"
104
+ # Persist for next session if tool changed
105
+ if [[ "$ai_tool" != "${SELECTED_AI_TOOL:-}" ]]; then
106
+ local ai_tool_file="${XDG_CONFIG_HOME:-$HOME/.config}/ghost-tab/ai-tool"
107
+ mkdir -p "$(dirname "$ai_tool_file")"
108
+ echo "$ai_tool" > "$ai_tool_file"
109
+ fi
110
+ fi
111
+
112
+ _selected_project_action="$action"
113
+
114
+ case "$action" in
115
+ select-project|open-once)
116
+ local name path
117
+ name=$(echo "$result" | jq -r '.name' 2>/dev/null)
118
+ path=$(echo "$result" | jq -r '.path' 2>/dev/null)
119
+
120
+ if [[ -z "$name" || "$name" == "null" ]]; then
121
+ error "TUI returned invalid project name"
122
+ return 1
123
+ fi
124
+ if [[ -z "$path" || "$path" == "null" ]]; then
125
+ error "TUI returned invalid project path"
126
+ return 1
127
+ fi
128
+
129
+ _selected_project_name="$name"
130
+ _selected_project_path="$path"
131
+ return 0
132
+ ;;
133
+ quit)
134
+ return 1
135
+ ;;
136
+ add-worktree)
137
+ local wt_project_name wt_project_path
138
+ wt_project_name=$(echo "$result" | jq -r '.name' 2>/dev/null)
139
+ wt_project_path=$(echo "$result" | jq -r '.path' 2>/dev/null)
140
+
141
+ if [[ -z "$wt_project_path" || "$wt_project_path" == "null" ]]; then
142
+ error "TUI returned invalid project path for worktree"
143
+ _selected_project_action="add-worktree"
144
+ return 0
145
+ fi
146
+
147
+ # Launch branch picker
148
+ local branch_result
149
+ if ! branch_result=$(ghost-tab-tui select-branch --project-path "$wt_project_path" --ai-tool "${SELECTED_AI_TOOL:-claude}" 2>/dev/null); then
150
+ _selected_project_action="add-worktree"
151
+ return 0
152
+ fi
153
+
154
+ local branch_selected
155
+ branch_selected=$(echo "$branch_result" | jq -r '.selected' 2>/dev/null)
156
+ if [[ "$branch_selected" != "true" ]]; then
157
+ _selected_project_action="add-worktree"
158
+ return 0
159
+ fi
160
+
161
+ local branch
162
+ branch=$(echo "$branch_result" | jq -r '.branch' 2>/dev/null)
163
+
164
+ # Read worktree base from settings
165
+ local worktree_base=""
166
+ if [ -f "$settings_file" ]; then
167
+ worktree_base=$(grep '^worktree_base=' "$settings_file" 2>/dev/null | cut -d= -f2)
168
+ fi
169
+
170
+ # Compute worktree path
171
+ local wt_path
172
+ wt_path=$(compute_worktree_path "$wt_project_path" "$wt_project_name" "$branch" "$worktree_base")
173
+
174
+ # Create worktree
175
+ if git -C "$wt_project_path" worktree add "$wt_path" "$branch" 2>/dev/null; then
176
+ success "Created worktree at $wt_path"
177
+ else
178
+ error "Failed to create worktree for branch '$branch'"
179
+ fi
180
+
181
+ _selected_project_action="add-worktree"
182
+ return 0
183
+ ;;
184
+ *)
185
+ # Other actions (plain-terminal, settings)
186
+ return 0
187
+ ;;
188
+ esac
189
+ }
@@ -0,0 +1,210 @@
1
+ #!/bin/bash
2
+ # Notification setup — sound hooks.
3
+ # Depends on: tui.sh (success, warn)
4
+
5
+ # Play notification sound if enabled for the given AI tool.
6
+ # Reads sound preference from features JSON and plays via afplay in background.
7
+ # Usage: play_notification_sound <ai_tool> <config_dir>
8
+ play_notification_sound() {
9
+ local ai_tool="$1" config_dir="$2"
10
+ local sound_name
11
+ sound_name="$(get_sound_name "$ai_tool" "$config_dir")"
12
+ if [[ -n "$sound_name" ]]; then
13
+ afplay "/System/Library/Sounds/${sound_name}.aiff" &
14
+ fi
15
+ }
16
+
17
+ # Set up sound notification for the given AI tool.
18
+ # Manages only the notification channel (to prevent double sounds).
19
+ # Sound playback is handled by the tab-title-watcher.
20
+ # Usage: setup_sound_notification <config_dir>
21
+ setup_sound_notification() {
22
+ local config_dir="$1"
23
+ set_claude_notif_channel "$config_dir"
24
+ }
25
+
26
+ # Set Claude Code's preferredNotifChannel to terminal_bell to prevent
27
+ # double sounds (ghost-tab hook + built-in notification).
28
+ # Saves the previous value to <config_dir>/prev-notif-channel for restoration.
29
+ # Usage: set_claude_notif_channel <config_dir>
30
+ set_claude_notif_channel() {
31
+ local config_dir="$1"
32
+ if ! command -v claude &>/dev/null; then
33
+ return 0
34
+ fi
35
+ mkdir -p "$config_dir"
36
+ local prev
37
+ prev="$(CLAUDECODE="" claude config get preferredNotifChannel 2>/dev/null || true)"
38
+ echo "$prev" > "$config_dir/prev-notif-channel"
39
+ CLAUDECODE="" claude config set preferredNotifChannel terminal_bell 2>/dev/null || true
40
+ }
41
+
42
+ # Restore Claude Code's preferredNotifChannel from saved value.
43
+ # If no saved value exists, does nothing.
44
+ # Usage: restore_claude_notif_channel <config_dir>
45
+ restore_claude_notif_channel() {
46
+ local config_dir="$1"
47
+ local saved_file="$config_dir/prev-notif-channel"
48
+ if [ ! -f "$saved_file" ]; then
49
+ return 0
50
+ fi
51
+ if ! command -v claude &>/dev/null; then
52
+ return 0
53
+ fi
54
+ local prev
55
+ prev="$(tr -d '[:space:]' < "$saved_file")"
56
+ if [[ -n "$prev" ]]; then
57
+ CLAUDECODE="" claude config set preferredNotifChannel "$prev" 2>/dev/null || true
58
+ else
59
+ CLAUDECODE="" claude config set preferredNotifChannel "" 2>/dev/null || true
60
+ fi
61
+ rm -f "$saved_file"
62
+ }
63
+
64
+ # Check if sound notifications are enabled for the given AI tool.
65
+ # Usage: is_sound_enabled <tool> <config_dir>
66
+ # Outputs "true" or "false".
67
+ is_sound_enabled() {
68
+ local tool="$1" config_dir="$2"
69
+ local features_file="$config_dir/${tool}-features.json"
70
+ if [ -f "$features_file" ]; then
71
+ local val
72
+ val="$(python3 -c "
73
+ import json, sys
74
+ try:
75
+ d = json.load(open(sys.argv[1]))
76
+ print('false' if d.get('sound') is False else 'true')
77
+ except Exception:
78
+ print('true')
79
+ " "$features_file" 2>/dev/null)"
80
+ echo "${val:-true}"
81
+ else
82
+ echo "true"
83
+ fi
84
+ }
85
+
86
+ # Get the sound name for the given AI tool.
87
+ # Returns the sound name (e.g. "Bottle") or empty string if sound is disabled.
88
+ # Usage: get_sound_name <tool> <config_dir>
89
+ get_sound_name() {
90
+ local tool="$1" config_dir="$2"
91
+ local features_file="$config_dir/${tool}-features.json"
92
+ if [ -f "$features_file" ]; then
93
+ python3 -c "
94
+ import json, sys
95
+ try:
96
+ d = json.load(open(sys.argv[1]))
97
+ if d.get('sound') is False:
98
+ print('')
99
+ else:
100
+ print(d.get('sound_name', 'Bottle'))
101
+ except Exception:
102
+ print('Bottle')
103
+ " "$features_file" 2>/dev/null
104
+ else
105
+ echo "Bottle"
106
+ fi
107
+ }
108
+
109
+ # Set the sound name for the given AI tool.
110
+ # Usage: set_sound_name <tool> <config_dir> <name>
111
+ set_sound_name() {
112
+ local tool="$1" config_dir="$2" name="$3"
113
+ local features_file="$config_dir/${tool}-features.json"
114
+ mkdir -p "$config_dir"
115
+ python3 -c "
116
+ import json, sys
117
+ path = sys.argv[1]
118
+ name = sys.argv[2]
119
+ try:
120
+ d = json.load(open(path))
121
+ except Exception:
122
+ d = {}
123
+ d['sound_name'] = name
124
+ with open(path, 'w') as f:
125
+ json.dump(d, f)
126
+ f.write('\n')
127
+ " "$features_file" "$name"
128
+ }
129
+
130
+ # Set sound feature flag for the given AI tool.
131
+ # Usage: set_sound_feature_flag <tool> <config_dir> <true|false>
132
+ set_sound_feature_flag() {
133
+ local tool="$1" config_dir="$2" enabled="$3"
134
+ local features_file="$config_dir/${tool}-features.json"
135
+ mkdir -p "$config_dir"
136
+ python3 -c "
137
+ import json, sys, os
138
+ path = sys.argv[1]
139
+ enabled = sys.argv[2] == 'true'
140
+ try:
141
+ d = json.load(open(path))
142
+ except Exception:
143
+ d = {}
144
+ d['sound'] = enabled
145
+ with open(path, 'w') as f:
146
+ json.dump(d, f)
147
+ f.write('\n')
148
+ " "$features_file" "$enabled"
149
+ }
150
+
151
+ # Remove sound notification setup for the given AI tool.
152
+ # Restores the notification channel.
153
+ # Usage: remove_sound_notification <config_dir>
154
+ remove_sound_notification() {
155
+ local config_dir="$1"
156
+ restore_claude_notif_channel "$config_dir"
157
+ }
158
+
159
+ # Toggle sound notification for the given AI tool.
160
+ # Usage: toggle_sound_notification <tool> <config_dir>
161
+ # Reads current state, flips it, applies the change.
162
+ toggle_sound_notification() {
163
+ local tool="$1" config_dir="$2"
164
+ local current
165
+ current="$(is_sound_enabled "$tool" "$config_dir")"
166
+
167
+ if [[ "$current" == "true" ]]; then
168
+ set_sound_feature_flag "$tool" "$config_dir" false
169
+ case "$tool" in
170
+ claude)
171
+ remove_sound_notification "$config_dir"
172
+ ;;
173
+ esac
174
+ success "Sound notifications disabled"
175
+ else
176
+ set_sound_feature_flag "$tool" "$config_dir" true
177
+ case "$tool" in
178
+ claude)
179
+ setup_sound_notification "$config_dir"
180
+ ;;
181
+ esac
182
+ success "Sound notifications enabled"
183
+ fi
184
+ }
185
+
186
+ # Apply sound notification state for the given AI tool.
187
+ # Usage: apply_sound_notification <tool> <config_dir> <sound_name>
188
+ # If sound_name is empty, disables sound. Otherwise enables with that sound.
189
+ apply_sound_notification() {
190
+ local tool="$1" config_dir="$2" sound_name="$3"
191
+
192
+ if [[ -z "$sound_name" ]]; then
193
+ set_sound_feature_flag "$tool" "$config_dir" false
194
+ case "$tool" in
195
+ claude)
196
+ remove_sound_notification "$config_dir"
197
+ ;;
198
+ esac
199
+ success "Sound notifications disabled"
200
+ else
201
+ set_sound_feature_flag "$tool" "$config_dir" true
202
+ set_sound_name "$tool" "$config_dir" "$sound_name"
203
+ case "$tool" in
204
+ claude)
205
+ setup_sound_notification "$config_dir"
206
+ ;;
207
+ esac
208
+ success "Sound notifications enabled"
209
+ fi
210
+ }
package/lib/process.sh ADDED
@@ -0,0 +1,13 @@
1
+ #!/bin/bash
2
+ # Process management helpers — no side effects on source.
3
+
4
+ # Recursively kills a process tree (depth-first: children first, then parent).
5
+ kill_tree() {
6
+ local pid=$1
7
+ local sig=${2:-TERM}
8
+ for child in $(pgrep -P "$pid" 2>/dev/null); do
9
+ kill_tree "$child" "$sig"
10
+ done
11
+ kill -"$sig" "$pid" 2>/dev/null
12
+ return 0
13
+ }
@@ -0,0 +1,29 @@
1
+ #!/bin/bash
2
+ # Interactive TUI wrappers for project actions using ghost-tab-tui
3
+
4
+ # Interactive project addition using ghost-tab-tui
5
+ # Returns 0 if confirmed, 1 if cancelled
6
+ # Sets: _add_project_name, _add_project_path
7
+ add_project_interactive() {
8
+ if ! command -v ghost-tab-tui &>/dev/null; then
9
+ error "ghost-tab-tui binary not found. Please reinstall."
10
+ return 1
11
+ fi
12
+
13
+ local result
14
+ if ! result=$(ghost-tab-tui add-project 2>/dev/null); then
15
+ return 1
16
+ fi
17
+
18
+ local confirmed
19
+ confirmed=$(echo "$result" | jq -r '.confirmed')
20
+
21
+ if [[ "$confirmed" != "true" ]]; then
22
+ return 1
23
+ fi
24
+
25
+ _add_project_name=$(echo "$result" | jq -r '.name')
26
+ _add_project_path=$(echo "$result" | jq -r '.path')
27
+
28
+ return 0
29
+ }
@@ -0,0 +1,9 @@
1
+ #!/bin/bash
2
+ # Project file operations — add, delete, validate.
3
+
4
+ # Append a project entry to the projects file (creates parent dirs).
5
+ add_project_to_file() {
6
+ local name="$1" path="$2" projects_file="$3"
7
+ mkdir -p "$(dirname "$projects_file")"
8
+ echo "${name}:${path}" >> "$projects_file"
9
+ }
@@ -0,0 +1,18 @@
1
+ #!/bin/bash
2
+ # Project file helpers — pure, no side effects on source.
3
+
4
+ # Reads a projects file and outputs valid lines (skips blanks and comments).
5
+ # Usage: mapfile -t projects < <(load_projects "$file")
6
+ load_projects() {
7
+ local file="$1" line
8
+ [ ! -f "$file" ] && return
9
+ while IFS= read -r line; do
10
+ [[ -z "$line" || "$line" == \#* ]] && continue
11
+ echo "$line"
12
+ done < "$file"
13
+ }
14
+
15
+ # Expands ~ to $HOME at the start of a path.
16
+ path_expand() {
17
+ echo "${1/#\~/$HOME}"
18
+ }
@@ -0,0 +1,178 @@
1
+ #!/bin/bash
2
+ # Claude settings.json manipulation helpers.
3
+
4
+ # Merge statusLine into Claude settings.json (create if missing).
5
+ merge_claude_settings() {
6
+ local path="$1"
7
+ mkdir -p "$(dirname "$path")"
8
+ if [ -f "$path" ]; then
9
+ if grep -q '"statusLine"' "$path"; then
10
+ success "Claude status line already configured"
11
+ else
12
+ sed -i '' '$ s/}$/,\n "statusLine": {\n "type": "command",\n "command": "bash ~\/.claude\/statusline-wrapper.sh"\n }\n}/' "$path"
13
+ success "Added status line to Claude settings"
14
+ fi
15
+ else
16
+ cat > "$path" << 'CSEOF'
17
+ {
18
+ "statusLine": {
19
+ "type": "command",
20
+ "command": "bash ~/.claude/statusline-wrapper.sh"
21
+ }
22
+ }
23
+ CSEOF
24
+ success "Created Claude settings with status line"
25
+ fi
26
+ }
27
+
28
+ # Add waiting indicator hooks (Stop + PreToolUse + UserPromptSubmit) to settings.json.
29
+ # Uses $GHOST_TAB_MARKER_FILE env var so hooks are safe outside Ghost Tab.
30
+ # Outputs "added", "upgraded", or "exists".
31
+ add_waiting_indicator_hooks() {
32
+ local path="$1"
33
+ mkdir -p "$(dirname "$path")"
34
+ python3 - "$path" << 'PYEOF'
35
+ import json, sys, os
36
+
37
+ settings_path = sys.argv[1]
38
+
39
+ if os.path.exists(settings_path):
40
+ try:
41
+ with open(settings_path, "r") as f:
42
+ settings = json.load(f)
43
+ except (json.JSONDecodeError, ValueError):
44
+ settings = {}
45
+ else:
46
+ settings = {}
47
+
48
+ hooks = settings.setdefault("hooks", {})
49
+
50
+ stop_cmd = 'if [ -n "$GHOST_TAB_MARKER_FILE" ]; then touch "$GHOST_TAB_MARKER_FILE"; fi'
51
+ clear_cmd = 'if [ -n "$GHOST_TAB_MARKER_FILE" ]; then rm -f "$GHOST_TAB_MARKER_FILE"; fi'
52
+
53
+ marker = "GHOST_TAB_MARKER_FILE"
54
+
55
+ # Check if current Stop-based format is already installed
56
+ stop_list = hooks.get("Stop", [])
57
+ stop_exists = any(
58
+ marker in h.get("command", "")
59
+ for entry in stop_list
60
+ for h in entry.get("hooks", [])
61
+ )
62
+
63
+ # Check if old Notification-based format exists (needs upgrade)
64
+ notif_list = hooks.get("Notification", [])
65
+ notif_exists = any(
66
+ marker in h.get("command", "")
67
+ for entry in notif_list
68
+ for h in entry.get("hooks", [])
69
+ )
70
+
71
+ # Check if old Stop format without matcher exists (needs upgrade)
72
+ pre_list = hooks.get("PreToolUse", [])
73
+ old_stop_needs_upgrade = stop_exists and not any(
74
+ entry.get("matcher") == "AskUserQuestion"
75
+ for entry in pre_list
76
+ if any(marker in h.get("command", "") for h in entry.get("hooks", []))
77
+ )
78
+
79
+ if stop_exists and not old_stop_needs_upgrade:
80
+ # Current Stop format already installed
81
+ print("exists")
82
+ sys.exit(0)
83
+ elif notif_exists or old_stop_needs_upgrade:
84
+ # Old format — remove ghost-tab hooks so they get re-added below
85
+ for event in ["Stop", "Notification", "PreToolUse", "UserPromptSubmit"]:
86
+ event_list = hooks.get(event, [])
87
+ new_list = [
88
+ entry for entry in event_list
89
+ if not any(marker in h.get("command", "") for h in entry.get("hooks", []))
90
+ ]
91
+ if new_list:
92
+ hooks[event] = new_list
93
+ elif event in hooks:
94
+ del hooks[event]
95
+ action = "upgraded"
96
+ else:
97
+ action = "added"
98
+
99
+ # Add Stop hook (fires immediately when Claude stops generating)
100
+ hooks.setdefault("Stop", []).append({
101
+ "hooks": [{"type": "command", "command": stop_cmd}]
102
+ })
103
+
104
+ # Add PreToolUse hook with matcher for AskUserQuestion (creates marker — user input needed)
105
+ hooks.setdefault("PreToolUse", []).append({
106
+ "matcher": "AskUserQuestion",
107
+ "hooks": [{"type": "command", "command": stop_cmd}]
108
+ })
109
+
110
+ # Add PreToolUse catch-all hook (clears marker — Claude is actively working)
111
+ hooks.setdefault("PreToolUse", []).append({
112
+ "hooks": [{"type": "command", "command": clear_cmd}]
113
+ })
114
+
115
+ # Add UserPromptSubmit hook (clears marker when user answers)
116
+ hooks.setdefault("UserPromptSubmit", []).append({
117
+ "hooks": [{"type": "command", "command": clear_cmd}]
118
+ })
119
+
120
+ with open(settings_path, "w") as f:
121
+ json.dump(settings, f, indent=2)
122
+ f.write("\n")
123
+
124
+ print(action)
125
+ PYEOF
126
+ }
127
+
128
+ # Remove waiting indicator hooks from settings.json.
129
+ # Outputs "removed" or "not_found".
130
+ remove_waiting_indicator_hooks() {
131
+ local path="$1"
132
+ if [ ! -f "$path" ]; then
133
+ echo "not_found"
134
+ return 0
135
+ fi
136
+ python3 - "$path" << 'PYEOF'
137
+ import json, sys, os
138
+
139
+ settings_path = sys.argv[1]
140
+ marker = "GHOST_TAB_MARKER_FILE"
141
+
142
+ try:
143
+ with open(settings_path, "r") as f:
144
+ settings = json.load(f)
145
+ except (json.JSONDecodeError, ValueError, FileNotFoundError):
146
+ print("not_found")
147
+ sys.exit(0)
148
+
149
+ hooks = settings.get("hooks", {})
150
+ found = False
151
+
152
+ for event in ["Stop", "Notification", "PreToolUse", "UserPromptSubmit"]:
153
+ event_list = hooks.get(event, [])
154
+ new_list = [
155
+ entry for entry in event_list
156
+ if not any(marker in h.get("command", "") for h in entry.get("hooks", []))
157
+ ]
158
+ if len(new_list) != len(event_list):
159
+ found = True
160
+ if new_list:
161
+ hooks[event] = new_list
162
+ else:
163
+ del hooks[event]
164
+
165
+ if not found:
166
+ print("not_found")
167
+ sys.exit(0)
168
+
169
+ if not hooks:
170
+ del settings["hooks"]
171
+
172
+ with open(settings_path, "w") as f:
173
+ json.dump(settings, f, indent=2)
174
+ f.write("\n")
175
+
176
+ print("removed")
177
+ PYEOF
178
+ }
@@ -0,0 +1,32 @@
1
+ #!/bin/bash
2
+ # TUI wrapper for settings menu
3
+ # Uses ghost-tab-tui settings-menu subcommand
4
+
5
+ # Interactive settings menu using ghost-tab-tui
6
+ # Returns action string (or empty if quit)
7
+ settings_menu_interactive() {
8
+ if ! command -v ghost-tab-tui &>/dev/null; then
9
+ error "ghost-tab-tui binary not found. Please reinstall."
10
+ return 1
11
+ fi
12
+
13
+ local result
14
+ if ! result=$(ghost-tab-tui settings-menu 2>/dev/null); then
15
+ return 1
16
+ fi
17
+
18
+ local action
19
+ if ! action=$(echo "$result" | jq -r '.action' 2>/dev/null); then
20
+ error "Failed to parse settings menu response"
21
+ return 1
22
+ fi
23
+
24
+ # Validate null/empty (empty is OK for quit, but null is not)
25
+ if [[ "$action" == "null" ]]; then
26
+ error "TUI returned invalid action"
27
+ return 1
28
+ fi
29
+
30
+ echo "$action"
31
+ return 0
32
+ }