fantasy-claude 1.0.3
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.
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/VERSION +1 -0
- package/cli.sh +7 -0
- package/config.json +135 -0
- package/configure.py +1922 -0
- package/configure.sh +5 -0
- package/hooks/notify-auth.sh +17 -0
- package/hooks/notify-elicitation.sh +17 -0
- package/hooks/notify-idle.sh +9 -0
- package/hooks/notify-permission.sh +17 -0
- package/hooks/notify.sh +38 -0
- package/hooks/sounds.sh +55 -0
- package/install.sh +67 -0
- package/package.json +25 -0
- package/sounds/error/.gitkeep +0 -0
- package/sounds/error/FFAAAAH.mp3 +0 -0
- package/sounds/error/lego-break.mp3 +0 -0
- package/sounds/notification/.gitkeep +0 -0
- package/statusline/elements/battery.sh +12 -0
- package/statusline/elements/burn-rate.sh +6 -0
- package/statusline/elements/context-pct.sh +40 -0
- package/statusline/elements/cwd.sh +21 -0
- package/statusline/elements/datetime.sh +2 -0
- package/statusline/elements/file-entropy.sh +31 -0
- package/statusline/elements/git-branch.sh +3 -0
- package/statusline/elements/github-repo.sh +6 -0
- package/statusline/elements/haiku.sh +77 -0
- package/statusline/elements/model.sh +33 -0
- package/statusline/elements/mood.sh +27 -0
- package/statusline/elements/moon-phase.sh +31 -0
- package/statusline/elements/pomodoro.sh +39 -0
- package/statusline/elements/reset-time.sh +15 -0
- package/statusline/elements/session-cost.sh +42 -0
- package/statusline/elements/session-duration.sh +43 -0
- package/statusline/elements/streak.sh +47 -0
- package/statusline/elements/usage-5h.sh +6 -0
- package/statusline/statusline.sh +223 -0
package/configure.py
ADDED
|
@@ -0,0 +1,1922 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Interactive TUI configurator for claude-hooks."""
|
|
3
|
+
|
|
4
|
+
import curses
|
|
5
|
+
import json
|
|
6
|
+
import platform
|
|
7
|
+
import subprocess
|
|
8
|
+
import unicodedata
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
REPO_DIR = Path(__file__).parent
|
|
12
|
+
VERSION = (REPO_DIR / "VERSION").read_text().strip() if (REPO_DIR / "VERSION").exists() else "unknown"
|
|
13
|
+
|
|
14
|
+
EVENT_DIRS = {
|
|
15
|
+
"on_error": "sounds/error",
|
|
16
|
+
"on_stop": "sounds/notification",
|
|
17
|
+
"on_notification": "sounds/notification",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
EVENT_LABELS = {
|
|
21
|
+
"on_error": "On tool error",
|
|
22
|
+
"on_stop": "On tool stop",
|
|
23
|
+
"on_notification": "On notification",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
HOOKS_ITEMS = ["on_error", "on_stop", "on_notification"]
|
|
27
|
+
MAIN_ITEMS = ["Hooks", "Statusline", "Integrations"]
|
|
28
|
+
MAX_ELEMENTS_PER_LINE = 4
|
|
29
|
+
|
|
30
|
+
SETTINGS_PATH = Path.home() / ".claude" / "settings.json"
|
|
31
|
+
SESSION_HOOK_SCRIPT = Path.home() / ".claude" / "hooks" / "session-git-cleanup.sh"
|
|
32
|
+
INTEGRATIONS_PATH = Path.home() / ".claude" / "integrations.json"
|
|
33
|
+
|
|
34
|
+
NOTIFY_SCRIPTS = {
|
|
35
|
+
"permission_prompt": REPO_DIR / "hooks" / "notify-permission.sh",
|
|
36
|
+
"idle_prompt": REPO_DIR / "hooks" / "notify-idle.sh",
|
|
37
|
+
"elicitation_dialog": REPO_DIR / "hooks" / "notify-elicitation.sh",
|
|
38
|
+
"auth_success": REPO_DIR / "hooks" / "notify-auth.sh",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
NOTIFY_DESCRIPTION = [
|
|
42
|
+
"Sends a notification when Claude Code needs your",
|
|
43
|
+
"input. Uses iTerm2 native alerts on macOS or",
|
|
44
|
+
"notify-send on Linux.",
|
|
45
|
+
"",
|
|
46
|
+
"To avoid duplicate notifications, disable Claude",
|
|
47
|
+
"Code's built-in notifications: run /config, scroll",
|
|
48
|
+
"down to Notifications, set to Disabled, then",
|
|
49
|
+
"restart your Claude Code sessions.",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
GIT_CLEANUP_DESCRIPTION = [
|
|
53
|
+
"Keeps local branches in sync with remote on each",
|
|
54
|
+
"session start. Fetches all remotes, prunes stale",
|
|
55
|
+
"tracking refs, and safely deletes local branches",
|
|
56
|
+
"whose upstream is gone (preserves unmerged changes).",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
OBSIDIAN_FIELDS = [
|
|
60
|
+
("vault_path", "Vault path", True),
|
|
61
|
+
("default_folder", "Default folder", False),
|
|
62
|
+
("templates_folder", "Templates folder", False),
|
|
63
|
+
("daily_notes_folder", "Daily notes folder", False),
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
OB_IDX_VAULT_PATH = 1
|
|
67
|
+
OB_IDX_DEF_FOLDER = 2
|
|
68
|
+
OB_IDX_TMPL_FOLDER = 3
|
|
69
|
+
OB_IDX_DAILY_FOLDER = 4
|
|
70
|
+
OB_IDX_INSTALL_MCP = 5
|
|
71
|
+
OB_IDX_INSTRUCTIONS = 6
|
|
72
|
+
|
|
73
|
+
ELEMENT_EMOJIS = {
|
|
74
|
+
"battery": "\U0001f50b",
|
|
75
|
+
"cwd": "\U0001f4c1",
|
|
76
|
+
"datetime": "\U0001f552",
|
|
77
|
+
"git-branch": "\U0001f33f",
|
|
78
|
+
"usage-5h": "\U0001f4ca",
|
|
79
|
+
"burn-rate": "\U0001f525",
|
|
80
|
+
"reset-time": "\u23f0",
|
|
81
|
+
"context-pct": "\U0001f4cb",
|
|
82
|
+
"session-duration": "\u23f1",
|
|
83
|
+
"session-cost": "\U0001f4b0",
|
|
84
|
+
"github-repo": "\U0001f4cd",
|
|
85
|
+
"mood": "\U0001f3ad",
|
|
86
|
+
"streak": "\U0001f4c8",
|
|
87
|
+
"pomodoro": "\U0001f345",
|
|
88
|
+
"file-entropy": "\U0001f500",
|
|
89
|
+
"moon-phase": "\U0001f319",
|
|
90
|
+
"haiku": "\U0001f4dc",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
ELEMENT_LABELS = {
|
|
94
|
+
"battery": "bat",
|
|
95
|
+
"cwd": "dir",
|
|
96
|
+
"datetime": "time",
|
|
97
|
+
"git-branch": "branch",
|
|
98
|
+
"usage-5h": "5h",
|
|
99
|
+
"burn-rate": "burn",
|
|
100
|
+
"reset-time": "reset",
|
|
101
|
+
"context-pct": "ctx",
|
|
102
|
+
"session-duration": "dur",
|
|
103
|
+
"session-cost": "cost",
|
|
104
|
+
"model": "model",
|
|
105
|
+
"github-repo": "repo",
|
|
106
|
+
"mood": "mood",
|
|
107
|
+
"streak": "streak",
|
|
108
|
+
"pomodoro": "pomo",
|
|
109
|
+
"file-entropy": "files",
|
|
110
|
+
"moon-phase": "phase",
|
|
111
|
+
"haiku": "haiku",
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
ELEMENT_CATEGORIES = {
|
|
115
|
+
"Claude / AI": ["model", "context-pct", "burn-rate", "session-cost", "session-duration", "file-entropy", "streak"],
|
|
116
|
+
"Git / Project": ["git-branch", "cwd", "github-repo"],
|
|
117
|
+
"System": ["battery", "datetime"],
|
|
118
|
+
"Rate limits": ["usage-5h", "reset-time"],
|
|
119
|
+
"Fun": ["mood", "moon-phase", "haiku", "pomodoro"],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
MOOD_EMOJI_SETS = {
|
|
123
|
+
1: ["😴", "☕", "🍕", "💻", "🌆", "🌙"],
|
|
124
|
+
2: ["🥱", "⚡", "🎯", "🔥", "🍺", "🌃"],
|
|
125
|
+
3: ["💤", "🌅", "☀️", "🌤️", "🌇", "🌌"],
|
|
126
|
+
}
|
|
127
|
+
POMO_DURATIONS = [15, 20, 25, 30, 45, 60] # minutes
|
|
128
|
+
|
|
129
|
+
MODEL_EMOJI_SETS = [
|
|
130
|
+
{}, # index 0: unused placeholder
|
|
131
|
+
{ # index 1: set 1
|
|
132
|
+
"haiku": "\U0001f6eb", # 🛫 airplane departing
|
|
133
|
+
"sonnet": "\U0001f6f0", # 🛰 satellite
|
|
134
|
+
"opus": "\U0001f6f8", # 🛸 flying saucer
|
|
135
|
+
},
|
|
136
|
+
{ # index 2: set 2
|
|
137
|
+
"haiku": "\u26b1\ufe0f", # ⚱️ urn
|
|
138
|
+
"sonnet": "\U0001f3fa", # 🏺 amphora
|
|
139
|
+
"opus": "\U0001f52e", # 🔮 crystal ball
|
|
140
|
+
},
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
BAR_ELEMENTS = {
|
|
144
|
+
"context-pct": "usage",
|
|
145
|
+
"usage-5h": "usage",
|
|
146
|
+
"battery": "battery",
|
|
147
|
+
}
|
|
148
|
+
BAR_OPTIONS = ["off", "mono", "multi"]
|
|
149
|
+
BAR_WIDTH = 8
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _bar_color_for(rule: str, pct: int) -> str:
|
|
153
|
+
if rule == "usage":
|
|
154
|
+
if pct <= 60:
|
|
155
|
+
return "green"
|
|
156
|
+
elif pct <= 80:
|
|
157
|
+
return "orange"
|
|
158
|
+
else:
|
|
159
|
+
return "red"
|
|
160
|
+
elif rule == "battery":
|
|
161
|
+
return "red" if pct < 20 else "green"
|
|
162
|
+
return ""
|
|
163
|
+
|
|
164
|
+
COLOR_OPTIONS = [
|
|
165
|
+
("none", None),
|
|
166
|
+
("green", "32"),
|
|
167
|
+
("cyan", "36"),
|
|
168
|
+
("red", "31"),
|
|
169
|
+
("yellow", "33"),
|
|
170
|
+
("orange", "38;5;208"),
|
|
171
|
+
("light gray", "37"),
|
|
172
|
+
("dark gray", "90"),
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
# Curses color pair index for each color name (initialized in run())
|
|
176
|
+
CURSES_COLOR_MAP: dict[str, int] = {}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def init_colors() -> None:
|
|
180
|
+
"""Initialize curses color pairs for element preview colors."""
|
|
181
|
+
pairs = [
|
|
182
|
+
("green", curses.COLOR_GREEN),
|
|
183
|
+
("cyan", curses.COLOR_CYAN),
|
|
184
|
+
("red", curses.COLOR_RED),
|
|
185
|
+
("yellow", curses.COLOR_YELLOW),
|
|
186
|
+
("orange", 208), # 256-color
|
|
187
|
+
("light gray", 7),
|
|
188
|
+
("dark gray", 8),
|
|
189
|
+
]
|
|
190
|
+
for i, (name, fg) in enumerate(pairs, start=1):
|
|
191
|
+
try:
|
|
192
|
+
curses.init_pair(i, fg, -1)
|
|
193
|
+
CURSES_COLOR_MAP[name] = i
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_preview_proc: subprocess.Popen | None = None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def preview_sound(sound_path: Path) -> None:
|
|
202
|
+
global _preview_proc
|
|
203
|
+
if _preview_proc and _preview_proc.poll() is None:
|
|
204
|
+
_preview_proc.kill()
|
|
205
|
+
if not sound_path.exists():
|
|
206
|
+
return
|
|
207
|
+
if platform.system() == "Darwin":
|
|
208
|
+
_preview_proc = subprocess.Popen(
|
|
209
|
+
["afplay", str(sound_path)],
|
|
210
|
+
stdout=subprocess.DEVNULL,
|
|
211
|
+
stderr=subprocess.DEVNULL,
|
|
212
|
+
)
|
|
213
|
+
else:
|
|
214
|
+
for player in ("paplay", "aplay"):
|
|
215
|
+
if subprocess.run(["command", "-v", player], capture_output=True).returncode == 0:
|
|
216
|
+
_preview_proc = subprocess.Popen(
|
|
217
|
+
[player, str(sound_path)],
|
|
218
|
+
stdout=subprocess.DEVNULL,
|
|
219
|
+
stderr=subprocess.DEVNULL,
|
|
220
|
+
)
|
|
221
|
+
break
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _build_display_list(elements: list[str]) -> list[tuple]:
|
|
225
|
+
"""Build a flat display list interleaving category headers and element names."""
|
|
226
|
+
result = []
|
|
227
|
+
categorized: set[str] = set()
|
|
228
|
+
for cat_name, cat_elems in ELEMENT_CATEGORIES.items():
|
|
229
|
+
matching = [e for e in cat_elems if e in elements]
|
|
230
|
+
if not matching:
|
|
231
|
+
continue
|
|
232
|
+
result.append(("header", cat_name))
|
|
233
|
+
for e in matching:
|
|
234
|
+
result.append(("element", e))
|
|
235
|
+
categorized.add(e)
|
|
236
|
+
uncategorized = [e for e in elements if e not in categorized]
|
|
237
|
+
if uncategorized:
|
|
238
|
+
result.append(("header", "Other"))
|
|
239
|
+
for e in uncategorized:
|
|
240
|
+
result.append(("element", e))
|
|
241
|
+
return result
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _clamp_elem_scroll(scroll: int, cursor: int, elements: list[str], avail_rows: int) -> int:
|
|
245
|
+
"""Adjust scroll so the cursor element is visible in the element list."""
|
|
246
|
+
if not elements or avail_rows < 1:
|
|
247
|
+
return 0
|
|
248
|
+
cursor_elem = elements[cursor]
|
|
249
|
+
display_items = _build_display_list(elements)
|
|
250
|
+
cursor_di = 0
|
|
251
|
+
for di, item in enumerate(display_items):
|
|
252
|
+
if item[0] == "element" and item[1] == cursor_elem:
|
|
253
|
+
cursor_di = di
|
|
254
|
+
break
|
|
255
|
+
if cursor_di < scroll:
|
|
256
|
+
scroll = cursor_di
|
|
257
|
+
elif cursor_di >= scroll + avail_rows:
|
|
258
|
+
scroll = cursor_di - avail_rows + 1
|
|
259
|
+
return max(0, scroll)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def list_sounds(directory: str) -> list[str]:
|
|
263
|
+
d = REPO_DIR / directory
|
|
264
|
+
if not d.exists():
|
|
265
|
+
return ["(none)"]
|
|
266
|
+
return ["(none)"] + sorted(p.stem for p in d.glob("*.mp3"))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def display_width(s: str) -> int:
|
|
270
|
+
"""Return the terminal display width of s, correctly counting wide/emoji chars as 2."""
|
|
271
|
+
width = 0
|
|
272
|
+
chars = list(s)
|
|
273
|
+
i = 0
|
|
274
|
+
while i < len(chars):
|
|
275
|
+
cp = ord(chars[i])
|
|
276
|
+
if 0xFE00 <= cp <= 0xFE0F: # bare variation selector: already counted by lookahead
|
|
277
|
+
i += 1
|
|
278
|
+
continue
|
|
279
|
+
next_cp = ord(chars[i + 1]) if i + 1 < len(chars) else 0
|
|
280
|
+
has_vs16 = 0xFE00 <= next_cp <= 0xFE0F
|
|
281
|
+
if cp >= 0x10000: # supplementary plane (emoji, etc.) are wide
|
|
282
|
+
width += 2
|
|
283
|
+
elif has_vs16: # BMP char + VS16 = emoji presentation = 2 wide
|
|
284
|
+
width += 2
|
|
285
|
+
else:
|
|
286
|
+
eaw = unicodedata.east_asian_width(chars[i])
|
|
287
|
+
if eaw in ('W', 'F'):
|
|
288
|
+
width += 2
|
|
289
|
+
elif unicodedata.category(chars[i]) in ('Mn', 'Cf'):
|
|
290
|
+
pass # zero-width
|
|
291
|
+
else:
|
|
292
|
+
width += 1
|
|
293
|
+
i += 1
|
|
294
|
+
return width
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def list_elements() -> list[str]:
|
|
298
|
+
d = REPO_DIR / "statusline/elements"
|
|
299
|
+
if not d.exists():
|
|
300
|
+
return []
|
|
301
|
+
return sorted(p.stem for p in d.glob("*.sh"))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def run_element(name: str, settings: dict | None = None) -> list[tuple[str, str | None]]:
|
|
305
|
+
"""Return a list of (text, color_name) segments for rendering."""
|
|
306
|
+
script = REPO_DIR / "statusline/elements" / f"{name}.sh"
|
|
307
|
+
try:
|
|
308
|
+
r = subprocess.run(["bash", str(script)], capture_output=True, text=True, timeout=2)
|
|
309
|
+
raw = r.stdout.strip() or name
|
|
310
|
+
except Exception:
|
|
311
|
+
raw = name
|
|
312
|
+
if settings:
|
|
313
|
+
s = settings.get(name, {})
|
|
314
|
+
prefix_parts = []
|
|
315
|
+
if s.get("emoji"):
|
|
316
|
+
if name == "model":
|
|
317
|
+
set_idx = s.get("emoji_set", 1)
|
|
318
|
+
if not isinstance(set_idx, int) or set_idx not in (1, 2):
|
|
319
|
+
set_idx = 1
|
|
320
|
+
emoji_set_map = MODEL_EMOJI_SETS[set_idx]
|
|
321
|
+
raw_lower = raw.lower()
|
|
322
|
+
for keyword, em in emoji_set_map.items():
|
|
323
|
+
if keyword in raw_lower:
|
|
324
|
+
prefix_parts.append(em)
|
|
325
|
+
break
|
|
326
|
+
else:
|
|
327
|
+
emoji = ELEMENT_EMOJIS.get(name, "")
|
|
328
|
+
if emoji:
|
|
329
|
+
prefix_parts.append(emoji)
|
|
330
|
+
if s.get("label"):
|
|
331
|
+
lbl = ELEMENT_LABELS.get(name, "")
|
|
332
|
+
if lbl:
|
|
333
|
+
prefix_parts.append(lbl)
|
|
334
|
+
bar_mode = s.get("bar", "off") or "off"
|
|
335
|
+
if bar_mode != "off" and name in BAR_ELEMENTS:
|
|
336
|
+
digits = "".join(c for c in raw if c.isdigit())
|
|
337
|
+
if digits:
|
|
338
|
+
pct = min(100, int(digits))
|
|
339
|
+
filled = pct * BAR_WIDTH // 100
|
|
340
|
+
bar = "█" * filled + "░" * (BAR_WIDTH - filled)
|
|
341
|
+
if bar_mode == "multi":
|
|
342
|
+
bar_color = _bar_color_for(BAR_ELEMENTS[name], pct)
|
|
343
|
+
prefix_text = (" ".join(prefix_parts) + " ") if prefix_parts else ""
|
|
344
|
+
segs: list[tuple[str, str | None]] = []
|
|
345
|
+
if prefix_text:
|
|
346
|
+
segs.append((prefix_text, None))
|
|
347
|
+
segs.append((bar, bar_color or None))
|
|
348
|
+
segs.append((" " + raw, None))
|
|
349
|
+
return segs
|
|
350
|
+
else: # mono
|
|
351
|
+
parts = prefix_parts + [bar, raw]
|
|
352
|
+
return [(" ".join(p for p in parts if p), None)]
|
|
353
|
+
text = f"{' '.join(prefix_parts)} {raw}" if prefix_parts else raw
|
|
354
|
+
return [(text, None)]
|
|
355
|
+
return [(raw, None)]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def load_config() -> dict:
|
|
359
|
+
cfg_path = REPO_DIR / "config.json"
|
|
360
|
+
with open(cfg_path) as f:
|
|
361
|
+
return json.load(f)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def save_config(cfg: dict) -> None:
|
|
365
|
+
cfg_path = REPO_DIR / "config.json"
|
|
366
|
+
with open(cfg_path, "w") as f:
|
|
367
|
+
json.dump(cfg, f, indent=2)
|
|
368
|
+
f.write("\n")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def load_integrations() -> dict:
|
|
372
|
+
try:
|
|
373
|
+
with open(INTEGRATIONS_PATH) as f:
|
|
374
|
+
data = json.load(f)
|
|
375
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
376
|
+
data = {}
|
|
377
|
+
data.setdefault("obsidian", {})
|
|
378
|
+
ob = data["obsidian"]
|
|
379
|
+
for key, _label, _required in OBSIDIAN_FIELDS:
|
|
380
|
+
ob.setdefault(key, "")
|
|
381
|
+
return data
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def save_integrations(integ: dict) -> None:
|
|
385
|
+
INTEGRATIONS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
386
|
+
with open(INTEGRATIONS_PATH, "w") as f:
|
|
387
|
+
json.dump(integ, f, indent=2)
|
|
388
|
+
f.write("\n")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def check_obsidian_cli() -> bool:
|
|
392
|
+
return subprocess.run(["which", "obsidian"], capture_output=True).returncode == 0
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def run_mcp_install() -> tuple[bool, str]:
|
|
396
|
+
try:
|
|
397
|
+
r = subprocess.run(
|
|
398
|
+
["claude", "mcp", "add", "obsidian-local-rest-api"],
|
|
399
|
+
capture_output=True,
|
|
400
|
+
text=True,
|
|
401
|
+
timeout=30,
|
|
402
|
+
)
|
|
403
|
+
if r.returncode == 0:
|
|
404
|
+
return True, "MCP provider installed successfully"
|
|
405
|
+
msg = (r.stderr.strip() or r.stdout.strip() or "Install failed")
|
|
406
|
+
return False, msg
|
|
407
|
+
except FileNotFoundError:
|
|
408
|
+
return False, "claude not found in PATH"
|
|
409
|
+
except subprocess.TimeoutExpired:
|
|
410
|
+
return False, "Install timed out"
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
OBSIDIAN_INSTRUCTIONS = [
|
|
414
|
+
"Setup Instructions",
|
|
415
|
+
"",
|
|
416
|
+
"1. Install obsidian-cli",
|
|
417
|
+
" macOS: brew install obsidian-cli",
|
|
418
|
+
"",
|
|
419
|
+
"2. Enable 'Local REST API' community plugin in Obsidian",
|
|
420
|
+
" Settings > Community plugins > Browse > Local REST API",
|
|
421
|
+
" Copy the API key from the plugin settings.",
|
|
422
|
+
"",
|
|
423
|
+
"3. Keep Obsidian open — CLI and MCP require it running.",
|
|
424
|
+
"",
|
|
425
|
+
"4. Set vault path in this screen, then press",
|
|
426
|
+
" [Install MCP provider] to register the MCP server.",
|
|
427
|
+
"",
|
|
428
|
+
"5. What configure.py CANNOT do (must be done manually):",
|
|
429
|
+
" - Install the Local REST API Obsidian plugin",
|
|
430
|
+
" - Provide the API key to the MCP server",
|
|
431
|
+
" - Install obsidian-cli itself",
|
|
432
|
+
" - Restart Claude Code after MCP install",
|
|
433
|
+
" (required for MCP to take effect)",
|
|
434
|
+
"",
|
|
435
|
+
" ESC / q to go back",
|
|
436
|
+
]
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _load_settings() -> dict:
|
|
440
|
+
try:
|
|
441
|
+
return json.loads(SETTINGS_PATH.read_text())
|
|
442
|
+
except Exception:
|
|
443
|
+
return {}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _save_settings(data: dict) -> None:
|
|
447
|
+
SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
448
|
+
SETTINGS_PATH.write_text(json.dumps(data, indent=2) + "\n")
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def is_git_cleanup_enabled() -> bool:
|
|
452
|
+
settings = _load_settings()
|
|
453
|
+
hooks = settings.get("hooks", {})
|
|
454
|
+
for entry in hooks.get("SessionStart", []):
|
|
455
|
+
cmd = entry if isinstance(entry, str) else entry.get("command", "")
|
|
456
|
+
if "session-git-cleanup" in cmd:
|
|
457
|
+
return True
|
|
458
|
+
return False
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def toggle_git_cleanup(enable: bool) -> None:
|
|
462
|
+
settings = _load_settings()
|
|
463
|
+
hooks = settings.setdefault("hooks", {})
|
|
464
|
+
session_hooks = hooks.get("SessionStart", [])
|
|
465
|
+
cmd = str(SESSION_HOOK_SCRIPT)
|
|
466
|
+
|
|
467
|
+
# Remove existing entry
|
|
468
|
+
session_hooks = [
|
|
469
|
+
e for e in session_hooks
|
|
470
|
+
if "session-git-cleanup" not in (e if isinstance(e, str) else e.get("command", ""))
|
|
471
|
+
]
|
|
472
|
+
|
|
473
|
+
if enable:
|
|
474
|
+
session_hooks.append({"command": cmd})
|
|
475
|
+
|
|
476
|
+
hooks["SessionStart"] = session_hooks
|
|
477
|
+
settings["hooks"] = hooks
|
|
478
|
+
_save_settings(settings)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
NOTIFY_LABELS = {
|
|
482
|
+
"permission_prompt": "🔐 Permission prompt",
|
|
483
|
+
"idle_prompt": "💤 Idle prompt",
|
|
484
|
+
"elicitation_dialog": "❓ Elicitation dialog",
|
|
485
|
+
"auth_success": "✅ Auth success",
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def get_notify_states() -> dict[str, bool]:
|
|
490
|
+
"""Return enabled state for each notification type."""
|
|
491
|
+
settings = _load_settings()
|
|
492
|
+
hooks = settings.get("hooks", {})
|
|
493
|
+
states = {k: False for k in NOTIFY_SCRIPTS}
|
|
494
|
+
for entry in hooks.get("Notification", []):
|
|
495
|
+
matcher = entry.get("matcher", "")
|
|
496
|
+
for h in (entry.get("hooks", []) if isinstance(entry, dict) else []):
|
|
497
|
+
cmd = h.get("command", "")
|
|
498
|
+
if "notify-" in cmd and cmd.endswith(".sh") and matcher in NOTIFY_SCRIPTS:
|
|
499
|
+
states[matcher] = True
|
|
500
|
+
return states
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def is_notify_enabled() -> bool:
|
|
504
|
+
return any(get_notify_states().values())
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def toggle_notify_type(ntype: str, enable: bool) -> None:
|
|
508
|
+
"""Toggle a single notification type on or off."""
|
|
509
|
+
settings = _load_settings()
|
|
510
|
+
hooks = settings.setdefault("hooks", {})
|
|
511
|
+
notif_hooks = hooks.get("Notification", [])
|
|
512
|
+
|
|
513
|
+
# Remove existing entry for this type (and legacy notify.sh)
|
|
514
|
+
notif_hooks = [
|
|
515
|
+
e for e in notif_hooks
|
|
516
|
+
if not (
|
|
517
|
+
e.get("matcher", "") == ntype
|
|
518
|
+
and any(
|
|
519
|
+
("notify-" in h.get("command", "") or "notify.sh" in h.get("command", ""))
|
|
520
|
+
for h in (e.get("hooks", []) if isinstance(e, dict) else [])
|
|
521
|
+
)
|
|
522
|
+
)
|
|
523
|
+
]
|
|
524
|
+
|
|
525
|
+
if enable:
|
|
526
|
+
script = NOTIFY_SCRIPTS[ntype]
|
|
527
|
+
notif_hooks.append({
|
|
528
|
+
"matcher": ntype,
|
|
529
|
+
"hooks": [{"type": "command", "command": f"bash {script}"}]
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
hooks["Notification"] = notif_hooks
|
|
533
|
+
settings["hooks"] = hooks
|
|
534
|
+
_save_settings(settings)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def draw_notify_config(stdscr, states: dict[str, bool], cursor: int) -> None:
|
|
538
|
+
stdscr.erase()
|
|
539
|
+
h, w = stdscr.getmaxyx()
|
|
540
|
+
|
|
541
|
+
header = " claude-hooks · Hooks › Notifications "
|
|
542
|
+
stdscr.addstr(0, 0, header[:w], curses.A_BOLD)
|
|
543
|
+
stdscr.addstr(1, 0, "─" * min(w, 60))
|
|
544
|
+
|
|
545
|
+
row = 3
|
|
546
|
+
for line in NOTIFY_DESCRIPTION:
|
|
547
|
+
if row >= h - 4:
|
|
548
|
+
break
|
|
549
|
+
stdscr.addstr(row, 4, line[:w - 4], curses.A_DIM)
|
|
550
|
+
row += 1
|
|
551
|
+
|
|
552
|
+
row += 1
|
|
553
|
+
ntypes = list(NOTIFY_SCRIPTS.keys())
|
|
554
|
+
for i, ntype in enumerate(ntypes):
|
|
555
|
+
if row >= h - 4:
|
|
556
|
+
break
|
|
557
|
+
marker = "x" if states.get(ntype, False) else " "
|
|
558
|
+
label = NOTIFY_LABELS[ntype]
|
|
559
|
+
text = f"[{marker}] {label}"
|
|
560
|
+
attr = curses.A_REVERSE if i == cursor else 0
|
|
561
|
+
stdscr.addstr(row, 4, text[:w - 4], attr)
|
|
562
|
+
row += 1
|
|
563
|
+
|
|
564
|
+
# Warning if tool not found (Linux only)
|
|
565
|
+
row += 1
|
|
566
|
+
if row < h - 3 and platform.system() != "Darwin":
|
|
567
|
+
if not _check_command("notify-send"):
|
|
568
|
+
stdscr.addstr(row, 4, "⚠ notify-send not found"[:w - 4], curses.A_DIM)
|
|
569
|
+
|
|
570
|
+
footer_row = h - 2
|
|
571
|
+
if footer_row > row + 1:
|
|
572
|
+
stdscr.addstr(footer_row - 1, 2, "─" * min(w - 4, 56))
|
|
573
|
+
hint = "↑↓ navigate Enter toggle ← back q quit"
|
|
574
|
+
if footer_row < h:
|
|
575
|
+
stdscr.addstr(footer_row, 2, hint[:w - 2], curses.A_DIM)
|
|
576
|
+
|
|
577
|
+
stdscr.refresh()
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _check_command(name: str) -> bool:
|
|
581
|
+
try:
|
|
582
|
+
subprocess.run(["which", name], capture_output=True, check=True)
|
|
583
|
+
return True
|
|
584
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
585
|
+
return False
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def draw_git_cleanup_config(stdscr, enabled: bool) -> None:
|
|
589
|
+
stdscr.erase()
|
|
590
|
+
h, w = stdscr.getmaxyx()
|
|
591
|
+
|
|
592
|
+
header = " claude-hooks · Hooks › Session Git Cleanup "
|
|
593
|
+
stdscr.addstr(0, 0, header[:w], curses.A_BOLD)
|
|
594
|
+
stdscr.addstr(1, 0, "─" * min(w, 60))
|
|
595
|
+
|
|
596
|
+
row = 3
|
|
597
|
+
for line in GIT_CLEANUP_DESCRIPTION:
|
|
598
|
+
if row >= h - 4:
|
|
599
|
+
break
|
|
600
|
+
stdscr.addstr(row, 4, line[:w - 4], curses.A_DIM)
|
|
601
|
+
row += 1
|
|
602
|
+
|
|
603
|
+
row += 1
|
|
604
|
+
if row < h - 3:
|
|
605
|
+
marker = "x" if enabled else " "
|
|
606
|
+
text = f"[{marker}] Enabled"
|
|
607
|
+
stdscr.addstr(row, 4, text[:w - 4], curses.A_REVERSE)
|
|
608
|
+
|
|
609
|
+
footer_row = h - 2
|
|
610
|
+
if footer_row > row + 1:
|
|
611
|
+
stdscr.addstr(footer_row - 1, 2, "─" * min(w - 4, 56))
|
|
612
|
+
hint = "Enter toggle ← back q quit"
|
|
613
|
+
if footer_row < h:
|
|
614
|
+
stdscr.addstr(footer_row, 2, hint[:w - 2], curses.A_DIM)
|
|
615
|
+
|
|
616
|
+
stdscr.refresh()
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def draw_obsidian_instructions(stdscr, scroll: int) -> None:
|
|
620
|
+
stdscr.erase()
|
|
621
|
+
h, w = stdscr.getmaxyx()
|
|
622
|
+
try:
|
|
623
|
+
stdscr.addstr(0, 0, " claude-hooks · Integrations › Obsidian › Instructions "[:w], curses.A_BOLD)
|
|
624
|
+
stdscr.addstr(1, 0, "─" * min(w, 60))
|
|
625
|
+
except curses.error:
|
|
626
|
+
pass
|
|
627
|
+
visible = h - 5
|
|
628
|
+
for i, line in enumerate(OBSIDIAN_INSTRUCTIONS[scroll:scroll + visible]):
|
|
629
|
+
try:
|
|
630
|
+
stdscr.addstr(3 + i, 2, line[:w - 2])
|
|
631
|
+
except curses.error:
|
|
632
|
+
pass
|
|
633
|
+
if scroll > 0:
|
|
634
|
+
try:
|
|
635
|
+
stdscr.addstr(2, w - 5, " ↑ ", curses.A_DIM)
|
|
636
|
+
except curses.error:
|
|
637
|
+
pass
|
|
638
|
+
if scroll + visible < len(OBSIDIAN_INSTRUCTIONS):
|
|
639
|
+
try:
|
|
640
|
+
stdscr.addstr(h - 3, w - 5, " ↓ ", curses.A_DIM)
|
|
641
|
+
except curses.error:
|
|
642
|
+
pass
|
|
643
|
+
try:
|
|
644
|
+
stdscr.addstr(h - 2, 2, "↑↓ scroll ESC / q back"[:w - 2], curses.A_DIM)
|
|
645
|
+
except curses.error:
|
|
646
|
+
pass
|
|
647
|
+
stdscr.refresh()
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def draw_obsidian_config(
|
|
651
|
+
stdscr,
|
|
652
|
+
cli_installed: bool,
|
|
653
|
+
integ: dict,
|
|
654
|
+
cursor: int,
|
|
655
|
+
edit_field: str | None,
|
|
656
|
+
edit_buf: str,
|
|
657
|
+
status_msg: str,
|
|
658
|
+
) -> None:
|
|
659
|
+
stdscr.erase()
|
|
660
|
+
h, w = stdscr.getmaxyx()
|
|
661
|
+
try:
|
|
662
|
+
stdscr.addstr(0, 0, " claude-hooks · Integrations › Obsidian "[:w], curses.A_BOLD)
|
|
663
|
+
stdscr.addstr(1, 0, "─" * min(w, 60))
|
|
664
|
+
except curses.error:
|
|
665
|
+
pass
|
|
666
|
+
|
|
667
|
+
row = 3
|
|
668
|
+
cli_str = "✓ installed" if cli_installed else "✗ not found"
|
|
669
|
+
cli_pair = CURSES_COLOR_MAP.get("green" if cli_installed else "red", 0)
|
|
670
|
+
try:
|
|
671
|
+
stdscr.addstr(row, 2, "obsidian CLI: "[:w - 2])
|
|
672
|
+
if cli_pair and 22 < w:
|
|
673
|
+
stdscr.addstr(row, 22, cli_str[:w - 22], curses.color_pair(cli_pair))
|
|
674
|
+
elif 22 < w:
|
|
675
|
+
stdscr.addstr(row, 22, cli_str[:w - 22])
|
|
676
|
+
except curses.error:
|
|
677
|
+
pass
|
|
678
|
+
row += 2
|
|
679
|
+
|
|
680
|
+
ob = integ.get("obsidian", {})
|
|
681
|
+
label_col = 22
|
|
682
|
+
for idx, (key, label, _required) in enumerate(OBSIDIAN_FIELDS, start=OB_IDX_VAULT_PATH):
|
|
683
|
+
val = ob.get(key, "")
|
|
684
|
+
is_cursor = cursor == idx
|
|
685
|
+
if edit_field == key:
|
|
686
|
+
display = edit_buf + "_"
|
|
687
|
+
val_attr = curses.A_REVERSE
|
|
688
|
+
elif val:
|
|
689
|
+
max_val_w = w - label_col - 2
|
|
690
|
+
display = val if len(val) <= max_val_w else "…" + val[-(max_val_w - 1):]
|
|
691
|
+
val_attr = curses.A_NORMAL
|
|
692
|
+
else:
|
|
693
|
+
display = "(empty)"
|
|
694
|
+
val_attr = curses.A_DIM
|
|
695
|
+
try:
|
|
696
|
+
line_label = f"{label:<20}"
|
|
697
|
+
if is_cursor:
|
|
698
|
+
stdscr.addstr(row, 2, f"> {line_label}"[:w - 2], curses.A_BOLD)
|
|
699
|
+
else:
|
|
700
|
+
stdscr.addstr(row, 2, f" {line_label}"[:w - 2])
|
|
701
|
+
if label_col < w:
|
|
702
|
+
stdscr.addstr(row, label_col, display[:w - label_col - 1], val_attr)
|
|
703
|
+
except curses.error:
|
|
704
|
+
pass
|
|
705
|
+
row += 1
|
|
706
|
+
|
|
707
|
+
row += 1
|
|
708
|
+
actions = [
|
|
709
|
+
(OB_IDX_INSTALL_MCP, "[Install MCP provider]"),
|
|
710
|
+
(OB_IDX_INSTRUCTIONS, "[View setup instructions]"),
|
|
711
|
+
]
|
|
712
|
+
for act_idx, act_label in actions:
|
|
713
|
+
is_cursor = cursor == act_idx
|
|
714
|
+
try:
|
|
715
|
+
if is_cursor:
|
|
716
|
+
stdscr.addstr(row, 2, f"> {act_label}"[:w - 2], curses.A_REVERSE)
|
|
717
|
+
else:
|
|
718
|
+
stdscr.addstr(row, 2, f" {act_label}"[:w - 2])
|
|
719
|
+
except curses.error:
|
|
720
|
+
pass
|
|
721
|
+
row += 1
|
|
722
|
+
|
|
723
|
+
footer_row = h - 2
|
|
724
|
+
if status_msg:
|
|
725
|
+
try:
|
|
726
|
+
stdscr.addstr(footer_row - 1, 2, status_msg[:w - 2], curses.A_BOLD)
|
|
727
|
+
except curses.error:
|
|
728
|
+
pass
|
|
729
|
+
elif footer_row - 1 > row + 1:
|
|
730
|
+
try:
|
|
731
|
+
stdscr.addstr(footer_row - 1, 2, "─" * min(w - 4, 56))
|
|
732
|
+
except curses.error:
|
|
733
|
+
pass
|
|
734
|
+
try:
|
|
735
|
+
stdscr.addstr(footer_row, 2, "↑↓ navigate Enter edit/action ESC back"[:w - 2], curses.A_DIM)
|
|
736
|
+
except curses.error:
|
|
737
|
+
pass
|
|
738
|
+
stdscr.refresh()
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def draw_screen(stdscr, title: str, items: list[str], cursor: int, hint: str = "") -> None:
|
|
742
|
+
stdscr.erase()
|
|
743
|
+
h, w = stdscr.getmaxyx()
|
|
744
|
+
|
|
745
|
+
# Header
|
|
746
|
+
header = f" claude-hooks v{VERSION} · {title} "
|
|
747
|
+
stdscr.addstr(0, 0, header[:w], curses.A_BOLD)
|
|
748
|
+
stdscr.addstr(1, 0, "─" * min(w, 60))
|
|
749
|
+
|
|
750
|
+
# Items
|
|
751
|
+
for i, item in enumerate(items):
|
|
752
|
+
row = 3 + i
|
|
753
|
+
if row >= h - 3:
|
|
754
|
+
break
|
|
755
|
+
if i == cursor:
|
|
756
|
+
stdscr.addstr(row, 2, f"> {item}"[:w - 2], curses.A_REVERSE)
|
|
757
|
+
else:
|
|
758
|
+
stdscr.addstr(row, 2, f" {item}"[:w - 2])
|
|
759
|
+
|
|
760
|
+
# Footer hint
|
|
761
|
+
footer_row = h - 2
|
|
762
|
+
if footer_row > 3 + len(items):
|
|
763
|
+
stdscr.addstr(footer_row - 1, 2, "─" * min(w - 4, 56))
|
|
764
|
+
if hint and footer_row < h:
|
|
765
|
+
stdscr.addstr(footer_row, 2, hint[:w - 2], curses.A_DIM)
|
|
766
|
+
|
|
767
|
+
stdscr.refresh()
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def draw_statusline_editor(
|
|
771
|
+
stdscr,
|
|
772
|
+
lines: list[list[str]],
|
|
773
|
+
current_line: int,
|
|
774
|
+
cursor: int,
|
|
775
|
+
elements: list[str],
|
|
776
|
+
status_msg: str,
|
|
777
|
+
element_settings: dict | None = None,
|
|
778
|
+
elem_scroll: int = 0,
|
|
779
|
+
) -> None:
|
|
780
|
+
stdscr.erase()
|
|
781
|
+
h, w = stdscr.getmaxyx()
|
|
782
|
+
n_lines = len(lines)
|
|
783
|
+
|
|
784
|
+
title = f"claude-hooks · Statusline · Line {current_line + 1} of {n_lines}"
|
|
785
|
+
stdscr.addstr(0, 0, f" {title} "[:w], curses.A_BOLD)
|
|
786
|
+
stdscr.addstr(1, 0, "─" * min(w, 60))
|
|
787
|
+
|
|
788
|
+
row = 3
|
|
789
|
+
stdscr.addstr(row, 2, "Preview (all lines):"[:w - 2])
|
|
790
|
+
row += 1
|
|
791
|
+
for i, line_elems in enumerate(lines):
|
|
792
|
+
if row >= h - 8:
|
|
793
|
+
break
|
|
794
|
+
prefix = "> " if i == current_line else " "
|
|
795
|
+
base_attr = curses.A_BOLD if i == current_line else curses.A_NORMAL
|
|
796
|
+
stdscr.addstr(row, 2, f"{prefix}Line {i + 1}: "[:w - 2], base_attr)
|
|
797
|
+
col = 2 + len(f"{prefix}Line {i + 1}: ")
|
|
798
|
+
if line_elems:
|
|
799
|
+
for ei, e in enumerate(line_elems):
|
|
800
|
+
if ei > 0:
|
|
801
|
+
sep = " | "
|
|
802
|
+
if col + len(sep) < w:
|
|
803
|
+
stdscr.addstr(row, col, sep, base_attr)
|
|
804
|
+
col += len(sep)
|
|
805
|
+
segments = run_element(e, element_settings)
|
|
806
|
+
text_color = (element_settings or {}).get(e, {}).get("color")
|
|
807
|
+
for seg_text, seg_color in segments:
|
|
808
|
+
color_name = seg_color if seg_color is not None else text_color
|
|
809
|
+
pair_idx = CURSES_COLOR_MAP.get(color_name or "", 0)
|
|
810
|
+
attr = curses.color_pair(pair_idx) | base_attr if pair_idx else base_attr
|
|
811
|
+
seg_w = display_width(seg_text)
|
|
812
|
+
if col + seg_w < w:
|
|
813
|
+
stdscr.addstr(row, col, seg_text, attr)
|
|
814
|
+
col += seg_w
|
|
815
|
+
else:
|
|
816
|
+
stdscr.addstr(row, col, "(empty)", base_attr)
|
|
817
|
+
row += 1
|
|
818
|
+
|
|
819
|
+
row += 1
|
|
820
|
+
try:
|
|
821
|
+
stdscr.addstr(row, 2, "─" * min(w - 4, 56))
|
|
822
|
+
except curses.error:
|
|
823
|
+
pass
|
|
824
|
+
row += 1
|
|
825
|
+
|
|
826
|
+
list_start_row = row
|
|
827
|
+
|
|
828
|
+
# Build display list with category headers
|
|
829
|
+
display_items = _build_display_list(elements)
|
|
830
|
+
total_display = len(display_items)
|
|
831
|
+
|
|
832
|
+
used_elsewhere: set[str] = set()
|
|
833
|
+
for i, line_elems in enumerate(lines):
|
|
834
|
+
if i != current_line:
|
|
835
|
+
used_elsewhere.update(line_elems)
|
|
836
|
+
|
|
837
|
+
cursor_elem = elements[cursor] if elements else ""
|
|
838
|
+
|
|
839
|
+
# Scroll up indicator
|
|
840
|
+
if elem_scroll > 0 and list_start_row < h:
|
|
841
|
+
try:
|
|
842
|
+
stdscr.addstr(list_start_row - 1, w - 5, " ↑ ", curses.A_DIM)
|
|
843
|
+
except curses.error:
|
|
844
|
+
pass
|
|
845
|
+
|
|
846
|
+
last_visible_di = -1
|
|
847
|
+
for di, item in enumerate(display_items):
|
|
848
|
+
if di < elem_scroll:
|
|
849
|
+
continue
|
|
850
|
+
if row >= h - 4:
|
|
851
|
+
break
|
|
852
|
+
last_visible_di = di
|
|
853
|
+
|
|
854
|
+
if item[0] == "header":
|
|
855
|
+
cat_label = f" {item[1]} "
|
|
856
|
+
line_fill = "─" * max(0, min(w - 6, 38) - len(cat_label))
|
|
857
|
+
header_str = f"──{cat_label}{line_fill}"
|
|
858
|
+
try:
|
|
859
|
+
stdscr.addstr(row, 2, header_str[:w - 2], curses.A_DIM)
|
|
860
|
+
except curses.error:
|
|
861
|
+
pass
|
|
862
|
+
else:
|
|
863
|
+
elem = item[1]
|
|
864
|
+
checked = elem in lines[current_line]
|
|
865
|
+
grayed = elem in used_elsewhere
|
|
866
|
+
marker = "[x]" if checked else ("[~]" if grayed else "[ ]")
|
|
867
|
+
es = (element_settings or {}).get(elem, {})
|
|
868
|
+
prefix_parts = []
|
|
869
|
+
if es.get("emoji"):
|
|
870
|
+
if elem == "model":
|
|
871
|
+
set_idx = es.get("emoji_set", 1)
|
|
872
|
+
if not isinstance(set_idx, int) or set_idx not in (1, 2):
|
|
873
|
+
set_idx = 1
|
|
874
|
+
e_icon = next(iter(MODEL_EMOJI_SETS[set_idx].values()), "")
|
|
875
|
+
else:
|
|
876
|
+
e_icon = ELEMENT_EMOJIS.get(elem, "")
|
|
877
|
+
if e_icon:
|
|
878
|
+
prefix_parts.append(e_icon)
|
|
879
|
+
if es.get("label"):
|
|
880
|
+
e_lbl = ELEMENT_LABELS.get(elem, "")
|
|
881
|
+
if e_lbl:
|
|
882
|
+
prefix_parts.append(e_lbl)
|
|
883
|
+
prefix = " ".join(prefix_parts) + " " if prefix_parts else ""
|
|
884
|
+
color_indicator = f" ({es['color']})" if es.get("color") else ""
|
|
885
|
+
bar_mode = es.get("bar", "off") or "off"
|
|
886
|
+
bar_indicator = f" [bar:{bar_mode}]" if bar_mode != "off" and elem in BAR_ELEMENTS else ""
|
|
887
|
+
label = f"{marker} {prefix}{elem}{color_indicator}{bar_indicator}"
|
|
888
|
+
if grayed:
|
|
889
|
+
label += " ← used on another line"
|
|
890
|
+
elem_color = CURSES_COLOR_MAP.get(es.get("color", ""), 0)
|
|
891
|
+
is_cursor = elem == cursor_elem
|
|
892
|
+
if is_cursor:
|
|
893
|
+
try:
|
|
894
|
+
stdscr.addstr(row, 2, f"> {label}"[:w - 2], curses.A_REVERSE)
|
|
895
|
+
except curses.error:
|
|
896
|
+
pass
|
|
897
|
+
elif grayed:
|
|
898
|
+
try:
|
|
899
|
+
stdscr.addstr(row, 2, f" {label}"[:w - 2], curses.A_DIM)
|
|
900
|
+
except curses.error:
|
|
901
|
+
pass
|
|
902
|
+
else:
|
|
903
|
+
attr = curses.color_pair(elem_color) if elem_color else curses.A_NORMAL
|
|
904
|
+
try:
|
|
905
|
+
stdscr.addstr(row, 2, f" {label}"[:w - 2], attr)
|
|
906
|
+
except curses.error:
|
|
907
|
+
pass
|
|
908
|
+
row += 1
|
|
909
|
+
|
|
910
|
+
# Scroll down indicator
|
|
911
|
+
if last_visible_di >= 0 and last_visible_di < total_display - 1:
|
|
912
|
+
try:
|
|
913
|
+
stdscr.addstr(row - 1, w - 5, " ↓ ", curses.A_DIM)
|
|
914
|
+
except curses.error:
|
|
915
|
+
pass
|
|
916
|
+
|
|
917
|
+
if row < h - 3:
|
|
918
|
+
try:
|
|
919
|
+
stdscr.addstr(row, 2, "─" * min(w - 4, 56))
|
|
920
|
+
except curses.error:
|
|
921
|
+
pass
|
|
922
|
+
|
|
923
|
+
footer_row = h - 2
|
|
924
|
+
if status_msg and footer_row < h:
|
|
925
|
+
try:
|
|
926
|
+
stdscr.addstr(footer_row - 1, 2, status_msg[:w - 2], curses.A_BOLD)
|
|
927
|
+
except curses.error:
|
|
928
|
+
pass
|
|
929
|
+
hint = "1-9/Tab/←/→ line ↑↓ navigate Enter toggle c config s save q cancel"
|
|
930
|
+
if footer_row < h:
|
|
931
|
+
try:
|
|
932
|
+
stdscr.addstr(footer_row, 2, hint[:w - 2], curses.A_DIM)
|
|
933
|
+
except curses.error:
|
|
934
|
+
pass
|
|
935
|
+
|
|
936
|
+
stdscr.refresh()
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def draw_save_confirm(
|
|
940
|
+
stdscr,
|
|
941
|
+
lines: list[list[str]],
|
|
942
|
+
element_settings: dict | None = None,
|
|
943
|
+
) -> None:
|
|
944
|
+
stdscr.erase()
|
|
945
|
+
h, w = stdscr.getmaxyx()
|
|
946
|
+
|
|
947
|
+
title = "claude-hooks · Save Statusline"
|
|
948
|
+
stdscr.addstr(0, 0, f" {title} "[:w], curses.A_BOLD)
|
|
949
|
+
stdscr.addstr(1, 0, "─" * min(w, 60))
|
|
950
|
+
|
|
951
|
+
row = 3
|
|
952
|
+
stdscr.addstr(row, 2, "Preview:"[:w - 2], curses.A_BOLD)
|
|
953
|
+
row += 1
|
|
954
|
+
|
|
955
|
+
compacted = [line for line in lines if line]
|
|
956
|
+
if compacted:
|
|
957
|
+
for line_elems in compacted:
|
|
958
|
+
if row >= h - 6:
|
|
959
|
+
break
|
|
960
|
+
col = 4
|
|
961
|
+
stdscr.addstr(row, 2, "│ ")
|
|
962
|
+
for ei, e in enumerate(line_elems):
|
|
963
|
+
if ei > 0:
|
|
964
|
+
sep = " | "
|
|
965
|
+
if col + len(sep) < w:
|
|
966
|
+
stdscr.addstr(row, col, sep)
|
|
967
|
+
col += len(sep)
|
|
968
|
+
segments = run_element(e, element_settings)
|
|
969
|
+
text_color = (element_settings or {}).get(e, {}).get("color")
|
|
970
|
+
for seg_text, seg_color in segments:
|
|
971
|
+
color_name = seg_color if seg_color is not None else text_color
|
|
972
|
+
pair_idx = CURSES_COLOR_MAP.get(color_name or "", 0)
|
|
973
|
+
attr = curses.color_pair(pair_idx) if pair_idx else curses.A_NORMAL
|
|
974
|
+
seg_w = display_width(seg_text)
|
|
975
|
+
if col + seg_w < w:
|
|
976
|
+
stdscr.addstr(row, col, seg_text, attr)
|
|
977
|
+
col += seg_w
|
|
978
|
+
row += 1
|
|
979
|
+
else:
|
|
980
|
+
stdscr.addstr(row, 4, "(statusline disabled — all lines empty)"[:w - 4], curses.A_DIM)
|
|
981
|
+
row += 1
|
|
982
|
+
|
|
983
|
+
row += 1
|
|
984
|
+
if row < h - 4:
|
|
985
|
+
stdscr.addstr(row, 2, "─" * min(w - 4, 56))
|
|
986
|
+
|
|
987
|
+
footer_row = h - 2
|
|
988
|
+
hint = "Save? [y] yes [n / Del] cancel"
|
|
989
|
+
if footer_row < h:
|
|
990
|
+
stdscr.addstr(footer_row, 2, hint[:w - 2])
|
|
991
|
+
|
|
992
|
+
stdscr.refresh()
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def draw_element_config(
|
|
996
|
+
stdscr,
|
|
997
|
+
element: str,
|
|
998
|
+
emoji_on: bool,
|
|
999
|
+
label_on: bool,
|
|
1000
|
+
color_name: str | None,
|
|
1001
|
+
cursor: int,
|
|
1002
|
+
bar: str = "off",
|
|
1003
|
+
emoji_set: int = 1,
|
|
1004
|
+
mood_set: int = 1,
|
|
1005
|
+
pomo_duration: int = 25,
|
|
1006
|
+
streak_unit: bool = True,
|
|
1007
|
+
moon_name: bool = False,
|
|
1008
|
+
cwd_basename: bool = False,
|
|
1009
|
+
) -> None:
|
|
1010
|
+
stdscr.erase()
|
|
1011
|
+
h, w = stdscr.getmaxyx()
|
|
1012
|
+
|
|
1013
|
+
if element == "model":
|
|
1014
|
+
cur_set = MODEL_EMOJI_SETS[emoji_set] if emoji_set in (1, 2) else MODEL_EMOJI_SETS[1]
|
|
1015
|
+
emoji_char = " ".join(cur_set.values())
|
|
1016
|
+
elif element == "mood":
|
|
1017
|
+
emoji_char = " ".join(MOOD_EMOJI_SETS.get(1, []))
|
|
1018
|
+
else:
|
|
1019
|
+
emoji_char = ELEMENT_EMOJIS.get(element, "")
|
|
1020
|
+
label_word = ELEMENT_LABELS.get(element, "")
|
|
1021
|
+
title = f"claude-hooks · Element: {element}"
|
|
1022
|
+
stdscr.addstr(0, 0, f" {title} "[:w], curses.A_BOLD)
|
|
1023
|
+
stdscr.addstr(1, 0, "─" * min(w, 60))
|
|
1024
|
+
|
|
1025
|
+
row = 3
|
|
1026
|
+
emoji_text = f"{'[x]' if emoji_on else '[ ]'} Emoji: {emoji_char}" if emoji_char else "[ ] Emoji: (none available)"
|
|
1027
|
+
if cursor == 0:
|
|
1028
|
+
stdscr.addstr(row, 2, f"> {emoji_text}"[:w - 2], curses.A_REVERSE)
|
|
1029
|
+
else:
|
|
1030
|
+
stdscr.addstr(row, 2, f" {emoji_text}"[:w - 2])
|
|
1031
|
+
row += 1
|
|
1032
|
+
|
|
1033
|
+
label_text = f"{'[x]' if label_on else '[ ]'} Label: {label_word}" if label_word else "[ ] Label: (none available)"
|
|
1034
|
+
if cursor == 1:
|
|
1035
|
+
stdscr.addstr(row, 2, f"> {label_text}"[:w - 2], curses.A_REVERSE)
|
|
1036
|
+
else:
|
|
1037
|
+
stdscr.addstr(row, 2, f" {label_text}"[:w - 2])
|
|
1038
|
+
row += 2
|
|
1039
|
+
|
|
1040
|
+
stdscr.addstr(row, 2, "Color:"[:w - 2], curses.A_BOLD)
|
|
1041
|
+
row += 1
|
|
1042
|
+
|
|
1043
|
+
for i, (cname, _) in enumerate(COLOR_OPTIONS):
|
|
1044
|
+
list_idx = i + 2
|
|
1045
|
+
marker = "●" if cname == (color_name or "none") else "○"
|
|
1046
|
+
label = f"{marker} {cname}"
|
|
1047
|
+
if list_idx == cursor:
|
|
1048
|
+
stdscr.addstr(row, 2, f"> {label}"[:w - 2], curses.A_REVERSE)
|
|
1049
|
+
else:
|
|
1050
|
+
stdscr.addstr(row, 2, f" {label}"[:w - 2])
|
|
1051
|
+
row += 1
|
|
1052
|
+
|
|
1053
|
+
extra_start = 2 + len(COLOR_OPTIONS)
|
|
1054
|
+
|
|
1055
|
+
if element == "model" and row < h - 5:
|
|
1056
|
+
row += 1
|
|
1057
|
+
stdscr.addstr(row, 2, "Emoji set:"[:w - 2], curses.A_BOLD)
|
|
1058
|
+
row += 1
|
|
1059
|
+
for i, set_map in enumerate(MODEL_EMOJI_SETS[1:], start=1):
|
|
1060
|
+
list_idx = extra_start + (i - 1)
|
|
1061
|
+
marker = "●" if emoji_set == i else "○"
|
|
1062
|
+
icons = " ".join(set_map.values())
|
|
1063
|
+
opt_label = f"{marker} set {i} {icons}"
|
|
1064
|
+
if list_idx == cursor:
|
|
1065
|
+
stdscr.addstr(row, 2, f"> {opt_label}"[:w - 2], curses.A_REVERSE)
|
|
1066
|
+
else:
|
|
1067
|
+
stdscr.addstr(row, 2, f" {opt_label}"[:w - 2])
|
|
1068
|
+
row += 1
|
|
1069
|
+
|
|
1070
|
+
if element in BAR_ELEMENTS and row < h - 6:
|
|
1071
|
+
row += 1
|
|
1072
|
+
stdscr.addstr(row, 2, "Bar:"[:w - 2], curses.A_BOLD)
|
|
1073
|
+
row += 1
|
|
1074
|
+
for i, opt in enumerate(BAR_OPTIONS):
|
|
1075
|
+
list_idx = extra_start + i
|
|
1076
|
+
marker = "●" if opt == (bar or "off") else "○"
|
|
1077
|
+
opt_label = f"{marker} {opt}"
|
|
1078
|
+
if list_idx == cursor:
|
|
1079
|
+
stdscr.addstr(row, 2, f"> {opt_label}"[:w - 2], curses.A_REVERSE)
|
|
1080
|
+
else:
|
|
1081
|
+
stdscr.addstr(row, 2, f" {opt_label}"[:w - 2])
|
|
1082
|
+
row += 1
|
|
1083
|
+
|
|
1084
|
+
if element == "mood" and row < h - 5:
|
|
1085
|
+
row += 1
|
|
1086
|
+
stdscr.addstr(row, 2, "Emoji set:"[:w - 2], curses.A_BOLD)
|
|
1087
|
+
row += 1
|
|
1088
|
+
for i, emojis in MOOD_EMOJI_SETS.items():
|
|
1089
|
+
list_idx = extra_start + (i - 1)
|
|
1090
|
+
marker = "●" if mood_set == i else "○"
|
|
1091
|
+
preview = " ".join(emojis)
|
|
1092
|
+
opt_label = f"{marker} set {i} {preview}"
|
|
1093
|
+
if list_idx == cursor:
|
|
1094
|
+
stdscr.addstr(row, 2, f"> {opt_label}"[:w - 2], curses.A_REVERSE)
|
|
1095
|
+
else:
|
|
1096
|
+
stdscr.addstr(row, 2, f" {opt_label}"[:w - 2])
|
|
1097
|
+
row += 1
|
|
1098
|
+
|
|
1099
|
+
if element == "pomodoro" and row < h - 5:
|
|
1100
|
+
row += 1
|
|
1101
|
+
stdscr.addstr(row, 2, "Duration:"[:w - 2], curses.A_BOLD)
|
|
1102
|
+
row += 1
|
|
1103
|
+
for i, mins in enumerate(POMO_DURATIONS):
|
|
1104
|
+
list_idx = extra_start + i
|
|
1105
|
+
marker = "●" if pomo_duration == mins else "○"
|
|
1106
|
+
opt_label = f"{marker} {mins} min"
|
|
1107
|
+
if list_idx == cursor:
|
|
1108
|
+
stdscr.addstr(row, 2, f"> {opt_label}"[:w - 2], curses.A_REVERSE)
|
|
1109
|
+
else:
|
|
1110
|
+
stdscr.addstr(row, 2, f" {opt_label}"[:w - 2])
|
|
1111
|
+
row += 1
|
|
1112
|
+
|
|
1113
|
+
if element == "streak" and row < h - 5:
|
|
1114
|
+
row += 1
|
|
1115
|
+
list_idx = extra_start
|
|
1116
|
+
marker = "[x]" if streak_unit else "[ ]"
|
|
1117
|
+
unit_label = f"{marker} Show unit (e.g. '5 days' vs '5d')"
|
|
1118
|
+
if list_idx == cursor:
|
|
1119
|
+
stdscr.addstr(row, 2, f"> {unit_label}"[:w - 2], curses.A_REVERSE)
|
|
1120
|
+
else:
|
|
1121
|
+
stdscr.addstr(row, 2, f" {unit_label}"[:w - 2])
|
|
1122
|
+
row += 1
|
|
1123
|
+
|
|
1124
|
+
if element == "moon-phase" and row < h - 5:
|
|
1125
|
+
row += 1
|
|
1126
|
+
list_idx = extra_start
|
|
1127
|
+
marker = "[x]" if moon_name else "[ ]"
|
|
1128
|
+
name_label = f"{marker} Show phase name (e.g. '🌗 Last Quarter')"
|
|
1129
|
+
if list_idx == cursor:
|
|
1130
|
+
stdscr.addstr(row, 2, f"> {name_label}"[:w - 2], curses.A_REVERSE)
|
|
1131
|
+
else:
|
|
1132
|
+
stdscr.addstr(row, 2, f" {name_label}"[:w - 2])
|
|
1133
|
+
row += 1
|
|
1134
|
+
|
|
1135
|
+
if element == "cwd" and row < h - 5:
|
|
1136
|
+
row += 1
|
|
1137
|
+
list_idx = extra_start
|
|
1138
|
+
marker = "[x]" if cwd_basename else "[ ]"
|
|
1139
|
+
bn_label = f"{marker} Basename only (e.g. 'my-project' vs '~/Dev/my-project')"
|
|
1140
|
+
if list_idx == cursor:
|
|
1141
|
+
stdscr.addstr(row, 2, f"> {bn_label}"[:w - 2], curses.A_REVERSE)
|
|
1142
|
+
else:
|
|
1143
|
+
stdscr.addstr(row, 2, f" {bn_label}"[:w - 2])
|
|
1144
|
+
row += 1
|
|
1145
|
+
|
|
1146
|
+
footer_row = h - 2
|
|
1147
|
+
if footer_row > row + 1:
|
|
1148
|
+
stdscr.addstr(footer_row - 1, 2, "─" * min(w - 4, 56))
|
|
1149
|
+
hint = "↑↓ navigate Enter toggle/select q back"
|
|
1150
|
+
if footer_row < h:
|
|
1151
|
+
stdscr.addstr(footer_row, 2, hint[:w - 2], curses.A_DIM)
|
|
1152
|
+
|
|
1153
|
+
stdscr.refresh()
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def draw_statusline_settings(
|
|
1157
|
+
stdscr,
|
|
1158
|
+
section: int,
|
|
1159
|
+
line_cursor: int,
|
|
1160
|
+
color_cursor: int,
|
|
1161
|
+
current_lines: int,
|
|
1162
|
+
default_color: str | None,
|
|
1163
|
+
separator_color: str | None,
|
|
1164
|
+
) -> None:
|
|
1165
|
+
stdscr.erase()
|
|
1166
|
+
h, w = stdscr.getmaxyx()
|
|
1167
|
+
stdscr.addstr(0, 0, " claude-hooks · Statusline Settings "[:w], curses.A_BOLD)
|
|
1168
|
+
stdscr.addstr(1, 0, "─" * min(w, 60))
|
|
1169
|
+
|
|
1170
|
+
# Section tabs
|
|
1171
|
+
tab0 = " [ Number of lines ] "
|
|
1172
|
+
tab1 = " [ General color ] "
|
|
1173
|
+
row = 3
|
|
1174
|
+
try:
|
|
1175
|
+
if section == 0:
|
|
1176
|
+
stdscr.addstr(row, 2, tab0, curses.A_REVERSE)
|
|
1177
|
+
stdscr.addstr(row, 2 + len(tab0), tab1)
|
|
1178
|
+
else:
|
|
1179
|
+
stdscr.addstr(row, 2, tab0)
|
|
1180
|
+
stdscr.addstr(row, 2 + len(tab0), tab1, curses.A_REVERSE)
|
|
1181
|
+
except curses.error:
|
|
1182
|
+
pass
|
|
1183
|
+
|
|
1184
|
+
row += 1
|
|
1185
|
+
try:
|
|
1186
|
+
stdscr.addstr(row, 0, "─" * min(w, 60))
|
|
1187
|
+
except curses.error:
|
|
1188
|
+
pass
|
|
1189
|
+
|
|
1190
|
+
row += 2 # row 6
|
|
1191
|
+
|
|
1192
|
+
if section == 0:
|
|
1193
|
+
try:
|
|
1194
|
+
stdscr.addstr(row, 2, "Select how many status bar rows to display:"[:w - 2])
|
|
1195
|
+
except curses.error:
|
|
1196
|
+
pass
|
|
1197
|
+
row += 2
|
|
1198
|
+
items = ["0 — disabled", "1", "2", "3", "4", "5", "6"]
|
|
1199
|
+
for i, item in enumerate(items):
|
|
1200
|
+
if row >= h - 3:
|
|
1201
|
+
break
|
|
1202
|
+
marker = "✓ " if i == current_lines else " "
|
|
1203
|
+
label = f"{marker}{item}"
|
|
1204
|
+
try:
|
|
1205
|
+
if i == line_cursor:
|
|
1206
|
+
stdscr.addstr(row, 2, f"> {label}"[:w - 2], curses.A_REVERSE)
|
|
1207
|
+
else:
|
|
1208
|
+
stdscr.addstr(row, 2, f" {label}"[:w - 2])
|
|
1209
|
+
except curses.error:
|
|
1210
|
+
pass
|
|
1211
|
+
row += 1
|
|
1212
|
+
else:
|
|
1213
|
+
try:
|
|
1214
|
+
stdscr.addstr(row, 2, "Set fallback colors for elements and separators:"[:w - 2])
|
|
1215
|
+
except curses.error:
|
|
1216
|
+
pass
|
|
1217
|
+
row += 2
|
|
1218
|
+
color_rows = [
|
|
1219
|
+
("Default element color:", default_color),
|
|
1220
|
+
("Separator color: ", separator_color),
|
|
1221
|
+
]
|
|
1222
|
+
for i, (label, val) in enumerate(color_rows):
|
|
1223
|
+
if row >= h - 4:
|
|
1224
|
+
break
|
|
1225
|
+
val_str = val or "none"
|
|
1226
|
+
line_str = f"{label} [ {val_str} ]"
|
|
1227
|
+
try:
|
|
1228
|
+
if i == color_cursor:
|
|
1229
|
+
stdscr.addstr(row, 2, f"> {line_str}"[:w - 2], curses.A_REVERSE)
|
|
1230
|
+
else:
|
|
1231
|
+
stdscr.addstr(row, 2, f" {line_str}"[:w - 2])
|
|
1232
|
+
except curses.error:
|
|
1233
|
+
pass
|
|
1234
|
+
row += 1
|
|
1235
|
+
if row < h - 4:
|
|
1236
|
+
try:
|
|
1237
|
+
stdscr.addstr(row + 1, 2, "(Enter cycles through color options)"[:w - 2], curses.A_DIM)
|
|
1238
|
+
except curses.error:
|
|
1239
|
+
pass
|
|
1240
|
+
|
|
1241
|
+
footer_row = h - 2
|
|
1242
|
+
if footer_row > row + 2:
|
|
1243
|
+
try:
|
|
1244
|
+
stdscr.addstr(footer_row - 1, 2, "─" * min(w - 4, 56))
|
|
1245
|
+
except curses.error:
|
|
1246
|
+
pass
|
|
1247
|
+
hint = "←→ switch section ↑↓ navigate Enter select ESC back q quit"
|
|
1248
|
+
if footer_row < h:
|
|
1249
|
+
try:
|
|
1250
|
+
stdscr.addstr(footer_row, 2, hint[:w - 2], curses.A_DIM)
|
|
1251
|
+
except curses.error:
|
|
1252
|
+
pass
|
|
1253
|
+
|
|
1254
|
+
stdscr.refresh()
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def _play_preview(event: str, sounds: list[str], cursor: int) -> None:
|
|
1258
|
+
name = sounds[cursor]
|
|
1259
|
+
if name == "(none)":
|
|
1260
|
+
return
|
|
1261
|
+
sound_file = REPO_DIR / EVENT_DIRS[event] / f"{name}.mp3"
|
|
1262
|
+
preview_sound(sound_file)
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def run(stdscr) -> None:
|
|
1266
|
+
curses.curs_set(0)
|
|
1267
|
+
try:
|
|
1268
|
+
curses.use_default_colors()
|
|
1269
|
+
except Exception:
|
|
1270
|
+
pass
|
|
1271
|
+
init_colors()
|
|
1272
|
+
|
|
1273
|
+
cfg = load_config()
|
|
1274
|
+
stack: list[tuple[str, int]] = [] # (screen_id, cursor)
|
|
1275
|
+
screen = "main"
|
|
1276
|
+
cursor = 0
|
|
1277
|
+
sounds: list[str] = [] # populated when in sound_picker screen
|
|
1278
|
+
|
|
1279
|
+
# Statusline editor state
|
|
1280
|
+
sl_lines: list[list[str]] = []
|
|
1281
|
+
sl_current_line: int = 0
|
|
1282
|
+
sl_elem_cursor: int = 0
|
|
1283
|
+
sl_elements: list[str] = []
|
|
1284
|
+
sl_status_msg: str = ""
|
|
1285
|
+
sl_elem_scroll: int = 0
|
|
1286
|
+
|
|
1287
|
+
# Element config state
|
|
1288
|
+
ec_elem: str = ""
|
|
1289
|
+
ec_emoji_on: bool = False
|
|
1290
|
+
ec_label_on: bool = False
|
|
1291
|
+
ec_color: str | None = None
|
|
1292
|
+
ec_bar: str = "off"
|
|
1293
|
+
ec_model_set: int = 1
|
|
1294
|
+
ec_mood_set: int = 1
|
|
1295
|
+
ec_pomo_duration: int = 25
|
|
1296
|
+
ec_streak_unit: bool = True
|
|
1297
|
+
ec_moon_name: bool = False
|
|
1298
|
+
ec_cwd_basename: bool = False
|
|
1299
|
+
ec_cursor: int = 0
|
|
1300
|
+
|
|
1301
|
+
# Statusline settings screen state
|
|
1302
|
+
sl_settings_section: int = 0 # 0 = Number of lines, 1 = General color
|
|
1303
|
+
sl_settings_color_cursor: int = 0 # 0 = default_color, 1 = separator_color
|
|
1304
|
+
|
|
1305
|
+
# Obsidian integration state
|
|
1306
|
+
ob_cli_installed: bool = False
|
|
1307
|
+
ob_cursor: int = OB_IDX_VAULT_PATH
|
|
1308
|
+
ob_edit_field: str | None = None
|
|
1309
|
+
ob_edit_buf: str = ""
|
|
1310
|
+
ob_edit_orig: str = ""
|
|
1311
|
+
ob_status_msg: str = ""
|
|
1312
|
+
ob_integ: dict = {}
|
|
1313
|
+
ob_instr_scroll: int = 0
|
|
1314
|
+
|
|
1315
|
+
while True:
|
|
1316
|
+
# --- Build display for current screen ---
|
|
1317
|
+
if screen == "main":
|
|
1318
|
+
title = "Main"
|
|
1319
|
+
items = MAIN_ITEMS
|
|
1320
|
+
hint = "↑↓ navigate Enter select q quit"
|
|
1321
|
+
|
|
1322
|
+
elif screen == "hooks":
|
|
1323
|
+
title = "Hooks"
|
|
1324
|
+
items = []
|
|
1325
|
+
for event in HOOKS_ITEMS:
|
|
1326
|
+
val = cfg["sounds"].get(event) or "(none)"
|
|
1327
|
+
label = EVENT_LABELS[event]
|
|
1328
|
+
items.append(f"{label:<22} [{val}]")
|
|
1329
|
+
gc_status = "enabled" if is_git_cleanup_enabled() else "disabled"
|
|
1330
|
+
items.append(f"{'Session Git Cleanup':<22} [{gc_status}]")
|
|
1331
|
+
nf_states = get_notify_states()
|
|
1332
|
+
nf_count = sum(nf_states.values())
|
|
1333
|
+
notify_status = f"{nf_count}/{len(nf_states)}" if nf_count else "disabled"
|
|
1334
|
+
items.append(f"{'Notifications':<22} [{notify_status}]")
|
|
1335
|
+
hint = "↑↓ navigate Enter select ← back q quit"
|
|
1336
|
+
|
|
1337
|
+
elif screen.startswith("sound_picker:"):
|
|
1338
|
+
event = screen.split(":", 1)[1]
|
|
1339
|
+
title = f"Hooks › {EVENT_LABELS[event]}"
|
|
1340
|
+
sounds = list_sounds(EVENT_DIRS[event])
|
|
1341
|
+
current = cfg["sounds"].get(event) or "(none)"
|
|
1342
|
+
items = [f"{s} ✓" if s == current else s for s in sounds]
|
|
1343
|
+
hint = "↑↓ navigate + preview Enter confirm ← back q quit"
|
|
1344
|
+
|
|
1345
|
+
elif screen == "statusline_editor":
|
|
1346
|
+
draw_statusline_editor(
|
|
1347
|
+
stdscr, sl_lines, sl_current_line, sl_elem_cursor, sl_elements, sl_status_msg,
|
|
1348
|
+
cfg.get("statusline", {}).get("element_settings"),
|
|
1349
|
+
sl_elem_scroll,
|
|
1350
|
+
)
|
|
1351
|
+
key = stdscr.getch()
|
|
1352
|
+
|
|
1353
|
+
# Handle ESC sequences manually as fallback for broken keypad mode.
|
|
1354
|
+
# Arrow keys send: ESC [ A/B/C/D. If keypad(True) isn't decoding
|
|
1355
|
+
# them, getch() returns 27 (ESC) first.
|
|
1356
|
+
if key == 27:
|
|
1357
|
+
stdscr.nodelay(True)
|
|
1358
|
+
k2 = stdscr.getch()
|
|
1359
|
+
k3 = stdscr.getch()
|
|
1360
|
+
stdscr.nodelay(False)
|
|
1361
|
+
if k2 == ord("["):
|
|
1362
|
+
if k3 == ord("A"):
|
|
1363
|
+
key = curses.KEY_UP
|
|
1364
|
+
elif k3 == ord("B"):
|
|
1365
|
+
key = curses.KEY_DOWN
|
|
1366
|
+
elif k3 == ord("C"):
|
|
1367
|
+
key = curses.KEY_RIGHT
|
|
1368
|
+
elif k3 == ord("D"):
|
|
1369
|
+
key = curses.KEY_LEFT
|
|
1370
|
+
elif k3 == ord("Z"):
|
|
1371
|
+
key = curses.KEY_BTAB
|
|
1372
|
+
# else: stray ESC, ignore
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
if key in (curses.KEY_UP, ord("k")):
|
|
1376
|
+
sl_elem_cursor = max(0, sl_elem_cursor - 1)
|
|
1377
|
+
sl_status_msg = ""
|
|
1378
|
+
_h, _ = stdscr.getmaxyx()
|
|
1379
|
+
_avail = _h - (6 + len(sl_lines)) - 5
|
|
1380
|
+
sl_elem_scroll = _clamp_elem_scroll(sl_elem_scroll, sl_elem_cursor, sl_elements, max(1, _avail))
|
|
1381
|
+
|
|
1382
|
+
elif key in (curses.KEY_DOWN, ord("j")):
|
|
1383
|
+
sl_elem_cursor = min(len(sl_elements) - 1, sl_elem_cursor + 1)
|
|
1384
|
+
sl_status_msg = ""
|
|
1385
|
+
_h, _ = stdscr.getmaxyx()
|
|
1386
|
+
_avail = _h - (6 + len(sl_lines)) - 5
|
|
1387
|
+
sl_elem_scroll = _clamp_elem_scroll(sl_elem_scroll, sl_elem_cursor, sl_elements, max(1, _avail))
|
|
1388
|
+
|
|
1389
|
+
elif key in (curses.KEY_LEFT, ord("h"), curses.KEY_BTAB):
|
|
1390
|
+
sl_current_line = max(0, sl_current_line - 1)
|
|
1391
|
+
sl_elem_cursor = 0
|
|
1392
|
+
sl_elem_scroll = 0
|
|
1393
|
+
sl_status_msg = ""
|
|
1394
|
+
|
|
1395
|
+
elif key in (curses.KEY_RIGHT, ord("l"), ord("\t")): # Tab = next line
|
|
1396
|
+
sl_current_line = min(len(sl_lines) - 1, sl_current_line + 1)
|
|
1397
|
+
sl_elem_cursor = 0
|
|
1398
|
+
sl_elem_scroll = 0
|
|
1399
|
+
sl_status_msg = ""
|
|
1400
|
+
|
|
1401
|
+
elif ord("1") <= key <= ord("9"):
|
|
1402
|
+
n = key - ord("1") # key "1" → index 0
|
|
1403
|
+
if n < len(sl_lines):
|
|
1404
|
+
sl_current_line = n
|
|
1405
|
+
sl_elem_cursor = 0
|
|
1406
|
+
sl_elem_scroll = 0
|
|
1407
|
+
sl_status_msg = ""
|
|
1408
|
+
|
|
1409
|
+
elif key in (curses.KEY_ENTER, ord("\n"), ord("\r")):
|
|
1410
|
+
elem = sl_elements[sl_elem_cursor]
|
|
1411
|
+
used_elsewhere: set[str] = set()
|
|
1412
|
+
for i, line_elems in enumerate(sl_lines):
|
|
1413
|
+
if i != sl_current_line:
|
|
1414
|
+
used_elsewhere.update(line_elems)
|
|
1415
|
+
if elem in used_elsewhere:
|
|
1416
|
+
sl_status_msg = f"'{elem}' is already used on another line"
|
|
1417
|
+
elif elem in sl_lines[sl_current_line]:
|
|
1418
|
+
sl_lines[sl_current_line].remove(elem)
|
|
1419
|
+
sl_status_msg = ""
|
|
1420
|
+
else:
|
|
1421
|
+
if len(sl_lines[sl_current_line]) >= MAX_ELEMENTS_PER_LINE:
|
|
1422
|
+
sl_status_msg = f"Max {MAX_ELEMENTS_PER_LINE} elements per line"
|
|
1423
|
+
else:
|
|
1424
|
+
sl_lines[sl_current_line].append(elem)
|
|
1425
|
+
sl_status_msg = ""
|
|
1426
|
+
|
|
1427
|
+
elif key == ord("c"):
|
|
1428
|
+
# Open element config
|
|
1429
|
+
elem = sl_elements[sl_elem_cursor]
|
|
1430
|
+
ec_elem = elem
|
|
1431
|
+
settings = cfg.get("statusline", {}).get("element_settings", {}).get(elem, {})
|
|
1432
|
+
ec_emoji_on = settings.get("emoji", False)
|
|
1433
|
+
ec_label_on = settings.get("label", False)
|
|
1434
|
+
ec_color = settings.get("color", None)
|
|
1435
|
+
ec_bar = settings.get("bar", "off") or "off"
|
|
1436
|
+
raw_set = settings.get("emoji_set", 1)
|
|
1437
|
+
ec_model_set = raw_set if isinstance(raw_set, int) and raw_set in (1, 2) else 1
|
|
1438
|
+
raw_mood = settings.get("mood_set", 1)
|
|
1439
|
+
ec_mood_set = raw_mood if isinstance(raw_mood, int) and raw_mood in MOOD_EMOJI_SETS else 1
|
|
1440
|
+
raw_dur = settings.get("duration", 25)
|
|
1441
|
+
ec_pomo_duration = raw_dur if raw_dur in POMO_DURATIONS else 25
|
|
1442
|
+
ec_streak_unit = settings.get("show_unit", True)
|
|
1443
|
+
ec_moon_name = settings.get("show_name", False)
|
|
1444
|
+
ec_cwd_basename = settings.get("basename_only", False)
|
|
1445
|
+
ec_cursor = 0
|
|
1446
|
+
stack.append((screen, cursor))
|
|
1447
|
+
screen = "element_config"
|
|
1448
|
+
cursor = 0
|
|
1449
|
+
|
|
1450
|
+
elif key == ord("s"):
|
|
1451
|
+
stack.append((screen, cursor))
|
|
1452
|
+
screen = "statusline_save_confirm"
|
|
1453
|
+
|
|
1454
|
+
elif key == ord("q"):
|
|
1455
|
+
if stack:
|
|
1456
|
+
screen, cursor = stack.pop()
|
|
1457
|
+
|
|
1458
|
+
continue
|
|
1459
|
+
|
|
1460
|
+
elif screen == "statusline_save_confirm":
|
|
1461
|
+
draw_save_confirm(
|
|
1462
|
+
stdscr, sl_lines,
|
|
1463
|
+
cfg.get("statusline", {}).get("element_settings"),
|
|
1464
|
+
)
|
|
1465
|
+
key = stdscr.getch()
|
|
1466
|
+
if key in (ord("y"), ord("Y")):
|
|
1467
|
+
compacted = [line for line in sl_lines if line]
|
|
1468
|
+
cfg.setdefault("statusline", {})["lines"] = compacted
|
|
1469
|
+
cfg["statusline"].pop("elements", None)
|
|
1470
|
+
save_config(cfg)
|
|
1471
|
+
stack.clear()
|
|
1472
|
+
screen = "main"
|
|
1473
|
+
cursor = 0
|
|
1474
|
+
elif key in (ord("n"), ord("N"), curses.KEY_BACKSPACE, 127, curses.KEY_DC, 27, ord("q")):
|
|
1475
|
+
if stack:
|
|
1476
|
+
screen, cursor = stack.pop()
|
|
1477
|
+
continue
|
|
1478
|
+
|
|
1479
|
+
elif screen == "element_config":
|
|
1480
|
+
draw_element_config(stdscr, ec_elem, ec_emoji_on, ec_label_on, ec_color, ec_cursor, ec_bar, ec_model_set, ec_mood_set, ec_pomo_duration, ec_streak_unit, ec_moon_name, ec_cwd_basename)
|
|
1481
|
+
key = stdscr.getch()
|
|
1482
|
+
|
|
1483
|
+
# ESC sequence handling
|
|
1484
|
+
if key == 27:
|
|
1485
|
+
stdscr.nodelay(True)
|
|
1486
|
+
k2 = stdscr.getch()
|
|
1487
|
+
k3 = stdscr.getch()
|
|
1488
|
+
stdscr.nodelay(False)
|
|
1489
|
+
if k2 == ord("["):
|
|
1490
|
+
if k3 == ord("A"):
|
|
1491
|
+
key = curses.KEY_UP
|
|
1492
|
+
elif k3 == ord("B"):
|
|
1493
|
+
key = curses.KEY_DOWN
|
|
1494
|
+
elif k3 == ord("D"):
|
|
1495
|
+
key = curses.KEY_LEFT
|
|
1496
|
+
|
|
1497
|
+
extra_start = 2 + len(COLOR_OPTIONS)
|
|
1498
|
+
max_idx = extra_start - 1
|
|
1499
|
+
if ec_elem == "model":
|
|
1500
|
+
max_idx = extra_start + len(MODEL_EMOJI_SETS) - 2
|
|
1501
|
+
elif ec_elem in BAR_ELEMENTS:
|
|
1502
|
+
max_idx = extra_start + len(BAR_OPTIONS) - 1
|
|
1503
|
+
elif ec_elem == "mood":
|
|
1504
|
+
max_idx = extra_start + len(MOOD_EMOJI_SETS) - 1
|
|
1505
|
+
elif ec_elem == "pomodoro":
|
|
1506
|
+
max_idx = extra_start + len(POMO_DURATIONS) - 1
|
|
1507
|
+
elif ec_elem in ("streak", "moon-phase", "cwd"):
|
|
1508
|
+
max_idx = extra_start
|
|
1509
|
+
if key in (curses.KEY_UP, ord("k")):
|
|
1510
|
+
ec_cursor = max(0, ec_cursor - 1)
|
|
1511
|
+
elif key in (curses.KEY_DOWN, ord("j")):
|
|
1512
|
+
ec_cursor = min(max_idx, ec_cursor + 1)
|
|
1513
|
+
elif key in (curses.KEY_ENTER, ord("\n"), ord("\r")):
|
|
1514
|
+
_extra = 2 + len(COLOR_OPTIONS)
|
|
1515
|
+
if ec_cursor == 0:
|
|
1516
|
+
ec_emoji_on = not ec_emoji_on
|
|
1517
|
+
elif ec_cursor == 1:
|
|
1518
|
+
ec_label_on = not ec_label_on
|
|
1519
|
+
elif ec_cursor >= _extra:
|
|
1520
|
+
idx = ec_cursor - _extra
|
|
1521
|
+
if ec_elem == "model":
|
|
1522
|
+
ec_model_set = idx + 1
|
|
1523
|
+
elif ec_elem in BAR_ELEMENTS:
|
|
1524
|
+
ec_bar = BAR_OPTIONS[idx]
|
|
1525
|
+
elif ec_elem == "mood":
|
|
1526
|
+
ec_mood_set = idx + 1
|
|
1527
|
+
elif ec_elem == "pomodoro":
|
|
1528
|
+
ec_pomo_duration = POMO_DURATIONS[idx]
|
|
1529
|
+
elif ec_elem == "streak":
|
|
1530
|
+
ec_streak_unit = not ec_streak_unit
|
|
1531
|
+
elif ec_elem == "moon-phase":
|
|
1532
|
+
ec_moon_name = not ec_moon_name
|
|
1533
|
+
elif ec_elem == "cwd":
|
|
1534
|
+
ec_cwd_basename = not ec_cwd_basename
|
|
1535
|
+
else:
|
|
1536
|
+
cname, _ = COLOR_OPTIONS[ec_cursor - 2]
|
|
1537
|
+
ec_color = None if cname == "none" else cname
|
|
1538
|
+
# Save immediately
|
|
1539
|
+
sl_settings = cfg.setdefault("statusline", {}).setdefault("element_settings", {})
|
|
1540
|
+
entry = {"emoji": ec_emoji_on, "label": ec_label_on, "color": ec_color, "bar": ec_bar}
|
|
1541
|
+
if ec_elem == "model":
|
|
1542
|
+
entry["emoji_set"] = ec_model_set
|
|
1543
|
+
if ec_elem == "mood":
|
|
1544
|
+
entry["mood_set"] = ec_mood_set
|
|
1545
|
+
if ec_elem == "pomodoro":
|
|
1546
|
+
entry["duration"] = ec_pomo_duration
|
|
1547
|
+
if ec_elem == "streak":
|
|
1548
|
+
entry["show_unit"] = ec_streak_unit
|
|
1549
|
+
if ec_elem == "moon-phase":
|
|
1550
|
+
entry["show_name"] = ec_moon_name
|
|
1551
|
+
if ec_elem == "cwd":
|
|
1552
|
+
entry["basename_only"] = ec_cwd_basename
|
|
1553
|
+
sl_settings[ec_elem] = entry
|
|
1554
|
+
save_config(cfg)
|
|
1555
|
+
elif key in (ord("q"), curses.KEY_LEFT, ord("h"), 27):
|
|
1556
|
+
if stack:
|
|
1557
|
+
screen, cursor = stack.pop()
|
|
1558
|
+
|
|
1559
|
+
continue
|
|
1560
|
+
|
|
1561
|
+
elif screen == "statusline_lines":
|
|
1562
|
+
_sl_cfg = cfg.get("statusline", {})
|
|
1563
|
+
_cur_lines = len(_sl_cfg.get("lines") or [])
|
|
1564
|
+
draw_statusline_settings(
|
|
1565
|
+
stdscr,
|
|
1566
|
+
sl_settings_section,
|
|
1567
|
+
cursor,
|
|
1568
|
+
sl_settings_color_cursor,
|
|
1569
|
+
_cur_lines,
|
|
1570
|
+
_sl_cfg.get("default_color"),
|
|
1571
|
+
_sl_cfg.get("separator_color"),
|
|
1572
|
+
)
|
|
1573
|
+
key = stdscr.getch()
|
|
1574
|
+
|
|
1575
|
+
# ESC sequence decoding
|
|
1576
|
+
_bare_esc = False
|
|
1577
|
+
if key == 27:
|
|
1578
|
+
stdscr.nodelay(True)
|
|
1579
|
+
k2 = stdscr.getch()
|
|
1580
|
+
k3 = stdscr.getch()
|
|
1581
|
+
stdscr.nodelay(False)
|
|
1582
|
+
if k2 == ord("["):
|
|
1583
|
+
if k3 == ord("A"):
|
|
1584
|
+
key = curses.KEY_UP
|
|
1585
|
+
elif k3 == ord("B"):
|
|
1586
|
+
key = curses.KEY_DOWN
|
|
1587
|
+
elif k3 == ord("C"):
|
|
1588
|
+
key = curses.KEY_RIGHT
|
|
1589
|
+
elif k3 == ord("D"):
|
|
1590
|
+
key = curses.KEY_LEFT
|
|
1591
|
+
else:
|
|
1592
|
+
_bare_esc = True
|
|
1593
|
+
|
|
1594
|
+
if _bare_esc or key == ord("q"):
|
|
1595
|
+
if stack:
|
|
1596
|
+
screen, cursor = stack.pop()
|
|
1597
|
+
sl_settings_section = 0
|
|
1598
|
+
sl_settings_color_cursor = 0
|
|
1599
|
+
elif key in (curses.KEY_LEFT,):
|
|
1600
|
+
sl_settings_section = (sl_settings_section - 1) % 2
|
|
1601
|
+
elif key in (curses.KEY_RIGHT,):
|
|
1602
|
+
sl_settings_section = (sl_settings_section + 1) % 2
|
|
1603
|
+
elif key in (curses.KEY_UP, ord("k")):
|
|
1604
|
+
if sl_settings_section == 0:
|
|
1605
|
+
cursor = max(0, cursor - 1)
|
|
1606
|
+
else:
|
|
1607
|
+
sl_settings_color_cursor = max(0, sl_settings_color_cursor - 1)
|
|
1608
|
+
elif key in (curses.KEY_DOWN, ord("j")):
|
|
1609
|
+
if sl_settings_section == 0:
|
|
1610
|
+
cursor = min(6, cursor + 1)
|
|
1611
|
+
else:
|
|
1612
|
+
sl_settings_color_cursor = min(1, sl_settings_color_cursor + 1)
|
|
1613
|
+
elif key in (curses.KEY_ENTER, ord("\n"), ord("\r")):
|
|
1614
|
+
if sl_settings_section == 0:
|
|
1615
|
+
n = cursor # 0 = disabled, 1–6 = line count
|
|
1616
|
+
if n == 0:
|
|
1617
|
+
cfg.setdefault("statusline", {})["lines"] = []
|
|
1618
|
+
cfg["statusline"].pop("elements", None)
|
|
1619
|
+
save_config(cfg)
|
|
1620
|
+
if stack:
|
|
1621
|
+
screen, cursor = stack.pop()
|
|
1622
|
+
sl_settings_section = 0
|
|
1623
|
+
sl_settings_color_cursor = 0
|
|
1624
|
+
else:
|
|
1625
|
+
sl_data = cfg.get("statusline", {})
|
|
1626
|
+
existing = sl_data.get("lines")
|
|
1627
|
+
if existing is None:
|
|
1628
|
+
elems = sl_data.get("elements", [])
|
|
1629
|
+
existing = [elems] if elems else []
|
|
1630
|
+
sl_lines = [list(existing[i]) if i < len(existing) else [] for i in range(n)]
|
|
1631
|
+
sl_current_line = 0
|
|
1632
|
+
for idx, ln in enumerate(sl_lines):
|
|
1633
|
+
if not ln:
|
|
1634
|
+
sl_current_line = idx
|
|
1635
|
+
break
|
|
1636
|
+
sl_elem_cursor = 0
|
|
1637
|
+
sl_elem_scroll = 0
|
|
1638
|
+
sl_elements = [
|
|
1639
|
+
item[1] for item in _build_display_list(list_elements())
|
|
1640
|
+
if item[0] == "element"
|
|
1641
|
+
]
|
|
1642
|
+
sl_status_msg = ""
|
|
1643
|
+
stack.append((screen, cursor))
|
|
1644
|
+
screen = "statusline_editor"
|
|
1645
|
+
cursor = 0
|
|
1646
|
+
else:
|
|
1647
|
+
# Cycle color for focused row
|
|
1648
|
+
sl_cfg = cfg.setdefault("statusline", {})
|
|
1649
|
+
key_name = "default_color" if sl_settings_color_cursor == 0 else "separator_color"
|
|
1650
|
+
cur_val = sl_cfg.get(key_name)
|
|
1651
|
+
cur_names = [c[0] for c in COLOR_OPTIONS]
|
|
1652
|
+
cur_name = cur_val or "none"
|
|
1653
|
+
try:
|
|
1654
|
+
idx = cur_names.index(cur_name)
|
|
1655
|
+
except ValueError:
|
|
1656
|
+
idx = 0
|
|
1657
|
+
next_name, _ = COLOR_OPTIONS[(idx + 1) % len(COLOR_OPTIONS)]
|
|
1658
|
+
sl_cfg[key_name] = None if next_name == "none" else next_name
|
|
1659
|
+
save_config(cfg)
|
|
1660
|
+
|
|
1661
|
+
continue
|
|
1662
|
+
|
|
1663
|
+
elif screen == "obsidian_config":
|
|
1664
|
+
draw_obsidian_config(stdscr, ob_cli_installed, ob_integ, ob_cursor, ob_edit_field, ob_edit_buf, ob_status_msg)
|
|
1665
|
+
key = stdscr.getch()
|
|
1666
|
+
|
|
1667
|
+
# ESC sequence decoding
|
|
1668
|
+
_bare_esc = False
|
|
1669
|
+
if key == 27:
|
|
1670
|
+
stdscr.nodelay(True)
|
|
1671
|
+
k2 = stdscr.getch()
|
|
1672
|
+
k3 = stdscr.getch()
|
|
1673
|
+
stdscr.nodelay(False)
|
|
1674
|
+
if k2 == ord("["):
|
|
1675
|
+
if k3 == ord("A"):
|
|
1676
|
+
key = curses.KEY_UP
|
|
1677
|
+
elif k3 == ord("B"):
|
|
1678
|
+
key = curses.KEY_DOWN
|
|
1679
|
+
else:
|
|
1680
|
+
_bare_esc = True
|
|
1681
|
+
|
|
1682
|
+
if ob_edit_field is not None:
|
|
1683
|
+
# Text input mode
|
|
1684
|
+
if key in (curses.KEY_ENTER, ord("\n"), ord("\r")):
|
|
1685
|
+
ob_integ.setdefault("obsidian", {})[ob_edit_field] = ob_edit_buf
|
|
1686
|
+
save_integrations(ob_integ)
|
|
1687
|
+
ob_status_msg = "Saved"
|
|
1688
|
+
ob_edit_field = None
|
|
1689
|
+
ob_edit_buf = ""
|
|
1690
|
+
elif _bare_esc:
|
|
1691
|
+
ob_integ.setdefault("obsidian", {})[ob_edit_field] = ob_edit_orig
|
|
1692
|
+
ob_edit_field = None
|
|
1693
|
+
ob_edit_buf = ""
|
|
1694
|
+
ob_status_msg = ""
|
|
1695
|
+
elif key in (127, curses.KEY_BACKSPACE):
|
|
1696
|
+
ob_edit_buf = ob_edit_buf[:-1]
|
|
1697
|
+
elif 32 <= key <= 126:
|
|
1698
|
+
ob_edit_buf += chr(key)
|
|
1699
|
+
else:
|
|
1700
|
+
# Navigation mode
|
|
1701
|
+
if _bare_esc or key == ord("q"):
|
|
1702
|
+
if stack:
|
|
1703
|
+
screen, cursor = stack.pop()
|
|
1704
|
+
elif key in (curses.KEY_UP, ord("k")):
|
|
1705
|
+
ob_cursor = max(OB_IDX_VAULT_PATH, ob_cursor - 1)
|
|
1706
|
+
ob_status_msg = ""
|
|
1707
|
+
elif key in (curses.KEY_DOWN, ord("j")):
|
|
1708
|
+
ob_cursor = min(OB_IDX_INSTRUCTIONS, ob_cursor + 1)
|
|
1709
|
+
ob_status_msg = ""
|
|
1710
|
+
elif key in (curses.KEY_ENTER, ord("\n"), ord("\r")):
|
|
1711
|
+
if OB_IDX_VAULT_PATH <= ob_cursor <= OB_IDX_DAILY_FOLDER:
|
|
1712
|
+
field_key = OBSIDIAN_FIELDS[ob_cursor - OB_IDX_VAULT_PATH][0]
|
|
1713
|
+
ob_edit_field = field_key
|
|
1714
|
+
ob_edit_buf = ob_integ.get("obsidian", {}).get(field_key, "")
|
|
1715
|
+
ob_edit_orig = ob_edit_buf
|
|
1716
|
+
ob_status_msg = ""
|
|
1717
|
+
elif ob_cursor == OB_IDX_INSTALL_MCP:
|
|
1718
|
+
ob_status_msg = "Installing…"
|
|
1719
|
+
draw_obsidian_config(stdscr, ob_cli_installed, ob_integ, ob_cursor, None, "", ob_status_msg)
|
|
1720
|
+
success, msg = run_mcp_install()
|
|
1721
|
+
ob_status_msg = msg
|
|
1722
|
+
elif ob_cursor == OB_IDX_INSTRUCTIONS:
|
|
1723
|
+
ob_instr_scroll = 0
|
|
1724
|
+
stack.append((screen, cursor))
|
|
1725
|
+
screen = "obsidian_instructions"
|
|
1726
|
+
cursor = 0
|
|
1727
|
+
|
|
1728
|
+
continue
|
|
1729
|
+
|
|
1730
|
+
elif screen == "obsidian_instructions":
|
|
1731
|
+
draw_obsidian_instructions(stdscr, ob_instr_scroll)
|
|
1732
|
+
key = stdscr.getch()
|
|
1733
|
+
|
|
1734
|
+
_bare_esc = False
|
|
1735
|
+
if key == 27:
|
|
1736
|
+
stdscr.nodelay(True)
|
|
1737
|
+
k2 = stdscr.getch()
|
|
1738
|
+
k3 = stdscr.getch()
|
|
1739
|
+
stdscr.nodelay(False)
|
|
1740
|
+
if k2 == ord("["):
|
|
1741
|
+
if k3 == ord("A"):
|
|
1742
|
+
key = curses.KEY_UP
|
|
1743
|
+
elif k3 == ord("B"):
|
|
1744
|
+
key = curses.KEY_DOWN
|
|
1745
|
+
else:
|
|
1746
|
+
_bare_esc = True
|
|
1747
|
+
|
|
1748
|
+
max_scroll = max(0, len(OBSIDIAN_INSTRUCTIONS) - (stdscr.getmaxyx()[0] - 5))
|
|
1749
|
+
if key in (curses.KEY_UP, ord("k")):
|
|
1750
|
+
ob_instr_scroll = max(0, ob_instr_scroll - 1)
|
|
1751
|
+
elif key in (curses.KEY_DOWN, ord("j")):
|
|
1752
|
+
ob_instr_scroll = min(max_scroll, ob_instr_scroll + 1)
|
|
1753
|
+
elif _bare_esc or key == ord("q"):
|
|
1754
|
+
if stack:
|
|
1755
|
+
screen, cursor = stack.pop()
|
|
1756
|
+
|
|
1757
|
+
continue
|
|
1758
|
+
|
|
1759
|
+
elif screen == "git_cleanup_config":
|
|
1760
|
+
gc_enabled = is_git_cleanup_enabled()
|
|
1761
|
+
draw_git_cleanup_config(stdscr, gc_enabled)
|
|
1762
|
+
key = stdscr.getch()
|
|
1763
|
+
|
|
1764
|
+
_bare_esc = False
|
|
1765
|
+
if key == 27:
|
|
1766
|
+
stdscr.nodelay(True)
|
|
1767
|
+
k2 = stdscr.getch()
|
|
1768
|
+
k3 = stdscr.getch()
|
|
1769
|
+
stdscr.nodelay(False)
|
|
1770
|
+
if k2 == ord("["):
|
|
1771
|
+
if k3 == ord("D"):
|
|
1772
|
+
key = curses.KEY_LEFT
|
|
1773
|
+
else:
|
|
1774
|
+
_bare_esc = True
|
|
1775
|
+
|
|
1776
|
+
if key in (curses.KEY_ENTER, ord("\n"), ord("\r")):
|
|
1777
|
+
toggle_git_cleanup(not gc_enabled)
|
|
1778
|
+
elif _bare_esc or key in (ord("q"), curses.KEY_LEFT):
|
|
1779
|
+
if stack:
|
|
1780
|
+
screen, cursor = stack.pop()
|
|
1781
|
+
|
|
1782
|
+
continue
|
|
1783
|
+
|
|
1784
|
+
elif screen == "notify_config":
|
|
1785
|
+
nf_states = get_notify_states()
|
|
1786
|
+
ntypes = list(NOTIFY_SCRIPTS.keys())
|
|
1787
|
+
n_items = len(ntypes)
|
|
1788
|
+
draw_notify_config(stdscr, nf_states, cursor)
|
|
1789
|
+
key = stdscr.getch()
|
|
1790
|
+
|
|
1791
|
+
_bare_esc = False
|
|
1792
|
+
if key == 27:
|
|
1793
|
+
stdscr.nodelay(True)
|
|
1794
|
+
k2 = stdscr.getch()
|
|
1795
|
+
k3 = stdscr.getch()
|
|
1796
|
+
stdscr.nodelay(False)
|
|
1797
|
+
if k2 == ord("["):
|
|
1798
|
+
if k3 == ord("A"):
|
|
1799
|
+
key = curses.KEY_UP
|
|
1800
|
+
elif k3 == ord("B"):
|
|
1801
|
+
key = curses.KEY_DOWN
|
|
1802
|
+
elif k3 == ord("D"):
|
|
1803
|
+
key = curses.KEY_LEFT
|
|
1804
|
+
else:
|
|
1805
|
+
_bare_esc = True
|
|
1806
|
+
|
|
1807
|
+
if key == curses.KEY_UP:
|
|
1808
|
+
cursor = (cursor - 1) % n_items
|
|
1809
|
+
elif key == curses.KEY_DOWN:
|
|
1810
|
+
cursor = (cursor + 1) % n_items
|
|
1811
|
+
elif key in (curses.KEY_ENTER, ord("\n"), ord("\r")):
|
|
1812
|
+
ntype = ntypes[cursor]
|
|
1813
|
+
toggle_notify_type(ntype, not nf_states[ntype])
|
|
1814
|
+
elif _bare_esc or key in (ord("q"), curses.KEY_LEFT):
|
|
1815
|
+
if stack:
|
|
1816
|
+
screen, cursor = stack.pop()
|
|
1817
|
+
|
|
1818
|
+
continue
|
|
1819
|
+
|
|
1820
|
+
elif screen == "integrations":
|
|
1821
|
+
title = "Integrations"
|
|
1822
|
+
items = ["Obsidian"]
|
|
1823
|
+
hint = "↑↓ navigate Enter select ESC back q quit"
|
|
1824
|
+
|
|
1825
|
+
else:
|
|
1826
|
+
title = screen.capitalize()
|
|
1827
|
+
items = ["(coming soon)"]
|
|
1828
|
+
hint = "← back q quit"
|
|
1829
|
+
|
|
1830
|
+
draw_screen(stdscr, title, items, cursor, hint)
|
|
1831
|
+
|
|
1832
|
+
key = stdscr.getch()
|
|
1833
|
+
|
|
1834
|
+
# Navigation
|
|
1835
|
+
if key in (curses.KEY_UP, ord("k")):
|
|
1836
|
+
cursor = max(0, cursor - 1)
|
|
1837
|
+
if screen.startswith("sound_picker:"):
|
|
1838
|
+
_play_preview(screen.split(":", 1)[1], sounds, cursor)
|
|
1839
|
+
|
|
1840
|
+
elif key in (curses.KEY_DOWN, ord("j")):
|
|
1841
|
+
cursor = min(len(items) - 1, cursor + 1)
|
|
1842
|
+
if screen.startswith("sound_picker:"):
|
|
1843
|
+
_play_preview(screen.split(":", 1)[1], sounds, cursor)
|
|
1844
|
+
|
|
1845
|
+
elif key in (curses.KEY_LEFT, ord("h"), 27): # 27 = Esc
|
|
1846
|
+
if stack:
|
|
1847
|
+
screen, cursor = stack.pop()
|
|
1848
|
+
|
|
1849
|
+
elif key == ord("q"):
|
|
1850
|
+
break
|
|
1851
|
+
|
|
1852
|
+
elif key in (curses.KEY_ENTER, ord("\n"), ord("\r")):
|
|
1853
|
+
if screen == "main":
|
|
1854
|
+
choice = MAIN_ITEMS[cursor]
|
|
1855
|
+
if choice == "Hooks":
|
|
1856
|
+
stack.append((screen, cursor))
|
|
1857
|
+
screen = "hooks"
|
|
1858
|
+
cursor = 0
|
|
1859
|
+
elif choice == "Integrations":
|
|
1860
|
+
stack.append((screen, cursor))
|
|
1861
|
+
screen = "integrations"
|
|
1862
|
+
cursor = 0
|
|
1863
|
+
else:
|
|
1864
|
+
stack.append((screen, cursor))
|
|
1865
|
+
screen = "statusline_lines"
|
|
1866
|
+
cursor = 0
|
|
1867
|
+
sl_settings_section = 0
|
|
1868
|
+
sl_settings_color_cursor = 0
|
|
1869
|
+
|
|
1870
|
+
elif screen == "integrations":
|
|
1871
|
+
# Only one item: Obsidian
|
|
1872
|
+
ob_integ = load_integrations()
|
|
1873
|
+
ob_cli_installed = check_obsidian_cli()
|
|
1874
|
+
ob_cursor = OB_IDX_VAULT_PATH
|
|
1875
|
+
ob_edit_field = None
|
|
1876
|
+
ob_edit_buf = ""
|
|
1877
|
+
ob_status_msg = ""
|
|
1878
|
+
stack.append((screen, cursor))
|
|
1879
|
+
screen = "obsidian_config"
|
|
1880
|
+
cursor = 0
|
|
1881
|
+
|
|
1882
|
+
elif screen == "hooks":
|
|
1883
|
+
if cursor < len(HOOKS_ITEMS):
|
|
1884
|
+
event = HOOKS_ITEMS[cursor]
|
|
1885
|
+
stack.append((screen, cursor))
|
|
1886
|
+
screen = f"sound_picker:{event}"
|
|
1887
|
+
sounds = list_sounds(EVENT_DIRS[event])
|
|
1888
|
+
current = cfg["sounds"].get(event) or "(none)"
|
|
1889
|
+
try:
|
|
1890
|
+
cursor = sounds.index(current)
|
|
1891
|
+
except ValueError:
|
|
1892
|
+
cursor = 0
|
|
1893
|
+
elif cursor == len(HOOKS_ITEMS):
|
|
1894
|
+
# Session Git Cleanup item
|
|
1895
|
+
stack.append((screen, cursor))
|
|
1896
|
+
screen = "git_cleanup_config"
|
|
1897
|
+
cursor = 0
|
|
1898
|
+
elif cursor == len(HOOKS_ITEMS) + 1:
|
|
1899
|
+
# Notifications item
|
|
1900
|
+
stack.append((screen, cursor))
|
|
1901
|
+
screen = "notify_config"
|
|
1902
|
+
cursor = 0
|
|
1903
|
+
|
|
1904
|
+
elif screen.startswith("sound_picker:"):
|
|
1905
|
+
event = screen.split(":", 1)[1]
|
|
1906
|
+
chosen = sounds[cursor]
|
|
1907
|
+
cfg["sounds"][event] = None if chosen == "(none)" else chosen
|
|
1908
|
+
save_config(cfg)
|
|
1909
|
+
if stack:
|
|
1910
|
+
screen, cursor = stack.pop()
|
|
1911
|
+
|
|
1912
|
+
else:
|
|
1913
|
+
if stack:
|
|
1914
|
+
screen, cursor = stack.pop()
|
|
1915
|
+
|
|
1916
|
+
|
|
1917
|
+
def main() -> None:
|
|
1918
|
+
curses.wrapper(run)
|
|
1919
|
+
|
|
1920
|
+
|
|
1921
|
+
if __name__ == "__main__":
|
|
1922
|
+
main()
|