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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -0
  3. package/VERSION +1 -0
  4. package/cli.sh +7 -0
  5. package/config.json +135 -0
  6. package/configure.py +1922 -0
  7. package/configure.sh +5 -0
  8. package/hooks/notify-auth.sh +17 -0
  9. package/hooks/notify-elicitation.sh +17 -0
  10. package/hooks/notify-idle.sh +9 -0
  11. package/hooks/notify-permission.sh +17 -0
  12. package/hooks/notify.sh +38 -0
  13. package/hooks/sounds.sh +55 -0
  14. package/install.sh +67 -0
  15. package/package.json +25 -0
  16. package/sounds/error/.gitkeep +0 -0
  17. package/sounds/error/FFAAAAH.mp3 +0 -0
  18. package/sounds/error/lego-break.mp3 +0 -0
  19. package/sounds/notification/.gitkeep +0 -0
  20. package/statusline/elements/battery.sh +12 -0
  21. package/statusline/elements/burn-rate.sh +6 -0
  22. package/statusline/elements/context-pct.sh +40 -0
  23. package/statusline/elements/cwd.sh +21 -0
  24. package/statusline/elements/datetime.sh +2 -0
  25. package/statusline/elements/file-entropy.sh +31 -0
  26. package/statusline/elements/git-branch.sh +3 -0
  27. package/statusline/elements/github-repo.sh +6 -0
  28. package/statusline/elements/haiku.sh +77 -0
  29. package/statusline/elements/model.sh +33 -0
  30. package/statusline/elements/mood.sh +27 -0
  31. package/statusline/elements/moon-phase.sh +31 -0
  32. package/statusline/elements/pomodoro.sh +39 -0
  33. package/statusline/elements/reset-time.sh +15 -0
  34. package/statusline/elements/session-cost.sh +42 -0
  35. package/statusline/elements/session-duration.sh +43 -0
  36. package/statusline/elements/streak.sh +47 -0
  37. package/statusline/elements/usage-5h.sh +6 -0
  38. 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()