diff --git a/build.sh b/build.sh index dd1d54c..f021c8d 100755 --- a/build.sh +++ b/build.sh @@ -52,6 +52,7 @@ build_app "$APP" "stack-nudge" \ panel/Settings.swift \ panel/SessionStore.swift \ panel/Sessions.swift \ + panel/Phrases.swift \ panel/Welcome.swift \ shared/AppActivator.swift \ -framework Foundation -framework AppKit -framework SwiftUI -framework Carbon \ diff --git a/notify.sh b/notify.sh index 798834b..991efd6 100755 --- a/notify.sh +++ b/notify.sh @@ -167,6 +167,41 @@ voice_phrase_for() { # shellcheck disable=SC1090 source "$lang_file" + # Layer user-managed phrases from phrases.user.json on top of the shipped + # arrays. Each pool can declare disabled defaults (skipped) and custom + # additions (appended). Quiet failure if jq isn't installed or the file's + # missing — the user just gets the unmodified defaults. + local user_json="${HOME}/.stack-nudge/phrases.user.json" + if [[ -f "$user_json" ]] && command -v jq >/dev/null 2>&1; then + # filter_pool + filter_pool() { + local pool="$1" arr="$2" + local disabled + disabled=$(jq -r --arg lang "$lang" --arg pool "$pool" \ + '.[$lang][$pool].disabled[]?' "$user_json" 2>/dev/null) + if [[ -n "$disabled" ]]; then + local kept=() + local existing + eval "existing=(\"\${${arr}[@]}\")" + for existing_item in "${existing[@]}"; do + local skip=0 + while IFS= read -r d; do + [[ "$existing_item" == "$d" ]] && { skip=1; break; } + done <<< "$disabled" + [[ $skip -eq 0 ]] && kept+=("$existing_item") + done + eval "${arr}=(\"\${kept[@]}\")" + fi + local extra + while IFS= read -r extra; do + [[ -n "$extra" ]] && eval "${arr}+=(\"\$extra\")" + done < <(jq -r --arg lang "$lang" --arg pool "$pool" \ + '.[$lang][$pool].custom[]?' "$user_json" 2>/dev/null) + } + filter_pool "response" TEMPLATES_RESPONSE + filter_pool "notification" TEMPLATES_NOTIFICATION + fi + local templates=() case "$event" in permission) templates=("${TEMPLATES_NOTIFICATION[@]}") ;; diff --git a/panel/Panel.swift b/panel/Panel.swift index f58ec67..bacdd7c 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -66,6 +66,7 @@ struct PanelContentView: View { @ObservedObject var store: EventStore @ObservedObject var sessions: SessionStore @ObservedObject var nav: PanelNav + @ObservedObject var phrases: PhrasesViewModel let onGrantPermissions: () -> Void @@ -83,6 +84,7 @@ struct PanelContentView: View { case .events: eventsBody case .sessions: SessionsView(store: sessions) case .settings: SettingsView(nav: nav) + case .phrases: PhrasesView(model: phrases) { nav.mode = .settings } } } } @@ -298,6 +300,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, private let store = EventStore() private let sessions = SessionStore() private let nav = PanelNav() + private let phrases = PhrasesViewModel() private var listener: EventListener? private var menuBar: MenuBarController? private var permissionsWC: PermissionsWindowController? @@ -317,7 +320,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, blur.layer?.masksToBounds = true let host = NSHostingView(rootView: PanelContentView( - store: store, sessions: sessions, nav: nav, + store: store, sessions: sessions, nav: nav, phrases: phrases, onGrantPermissions: { [weak self] in self?.handleGrantPermissions() } )) host.translatesAutoresizingMaskIntoConstraints = false @@ -348,6 +351,11 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, self?.nav.mode = .events NSWorkspace.shared.open(URL(fileURLWithPath: ConfigFile.path)) }, + editPhrases: { [weak self] in + self?.phrases.load() + self?.phrases.selectedRow = nil + self?.nav.mode = .phrases + }, quit: { NSApp.terminate(nil) } ) nav.setHotkey = { [weak self] spec in @@ -743,6 +751,36 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, return true } + // Phrases mode: ↑/↓ navigate every row (defaults + custom), + // Space toggles the selected default, ⌫ removes the selected + // custom, Esc returns to Settings. Typing / Tab / Enter for + // adding still fall through to SwiftUI's TextField. + if nav.mode == .phrases { + let plain = mods.intersection([.command, .control, .option, .shift]).isEmpty + guard plain else { return false } + switch event.keyCode { + case KeyCode.escape: + nav.mode = .settings + return true + case KeyCode.upArrow: + phrases.selectPrevious() + return true + case KeyCode.downArrow: + phrases.selectNext() + return true + case KeyCode.space: + guard let row = phrases.selectedRow, row.isDefault else { return false } + phrases.toggleSelected() + return true + case KeyCode.delete, KeyCode.forwardDelete: + guard let row = phrases.selectedRow, !row.isDefault else { return false } + phrases.removeSelected() + return true + default: + return false + } + } + // In settings mode, the controller drives row selection and value // cycling on PanelNav so the SettingsView stays a pure renderer. if nav.mode == .settings { diff --git a/panel/PanelNav.swift b/panel/PanelNav.swift index c061de6..cacaf2b 100644 --- a/panel/PanelNav.swift +++ b/panel/PanelNav.swift @@ -5,6 +5,7 @@ enum PanelMode { case events case sessions case settings + case phrases } // Action callbacks the controller wires into nav so settings rows like @@ -13,6 +14,7 @@ enum PanelMode { struct SettingsActions { let checkPermissions: () -> Void let openConfig: () -> Void + let editPhrases: () -> Void let quit: () -> Void } @@ -71,7 +73,7 @@ final class PanelNav: ObservableObject { "I'd love your input on this.", ] - var rowCount: Int { 12 } + var rowCount: Int { 13 } // Row layout (kept in one place so the controller, view, and indexing // logic all agree on what each row index means): @@ -84,9 +86,10 @@ final class PanelNav: ObservableObject { // 6 Permission sound cycle // 7 Voice cycle // 8 Speed cycle - // 9 Check permissions… action - // 10 Open config file… action - // 11 Quit panel action + // 9 Edit phrases… action + // 10 Check permissions… action + // 11 Open config file… action + // 12 Quit panel action // MARK: - Disk I/O @@ -160,9 +163,10 @@ final class PanelNav: ObservableObject { func activate() { switch selectedSettingIndex { case 0: startRecordingHotkey() - case 9: actions?.checkPermissions() - case 10: actions?.openConfig() - case 11: actions?.quit() + case 9: actions?.editPhrases() + case 10: actions?.checkPermissions() + case 11: actions?.openConfig() + case 12: actions?.quit() default: applyCycle(forward: true) } } diff --git a/panel/Phrases.swift b/panel/Phrases.swift new file mode 100644 index 0000000..129084f --- /dev/null +++ b/panel/Phrases.swift @@ -0,0 +1,510 @@ +import AppKit +import SwiftUI + +// User-managed phrase pools layered on top of the shipped phrases/.sh +// defaults. Two pools per language: response (stop events) and notification +// (permission events). The user can: +// - Add custom phrases (kept in the JSON) +// - Disable individual defaults (kept in the JSON as a parallel set) +// notify.sh reads both and produces defaults\disabled ∪ custom at fire time. +// +// Storage at ~/.stack-nudge/phrases.user.json: +// { "en": { "response": { "custom": [...], "disabled": [...] }, +// "notification": { ... } } } +enum PhrasePool: String, CaseIterable { + case response, notification + + var title: String { + switch self { + case .response: return "Task complete" + case .notification: return "Permission request" + } + } + + var subtitle: String { + switch self { + case .response: return "Spoken when an agent finishes a turn." + case .notification: return "Spoken when an agent pauses for approval." + } + } +} + +struct PhraseStore { + + // Sample repo name used for the editor preview. Hard-coded so users see + // a realistic substitution without us having to know their current dir. + static let previewRepo = "stack-nudge" + + static var jsonURL: URL { + let dir = (NSHomeDirectory() as NSString).appendingPathComponent(".stack-nudge") + return URL(fileURLWithPath: dir).appendingPathComponent("phrases.user.json") + } + + struct PoolState: Equatable { + var custom: [String] = [] + var disabled: [String] = [] // subset of shipped defaults the user has muted + } + + typealias LangState = [PhrasePool: PoolState] + + // Load all user state for the given language. Tolerant of missing or + // malformed file — returns empty pools then. + static func load(lang: String) -> LangState { + var out: LangState = [:] + for pool in PhrasePool.allCases { out[pool] = PoolState() } + + guard let data = try? Data(contentsOf: jsonURL), + let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let langDict = raw[lang] as? [String: Any] + else { return out } + + for pool in PhrasePool.allCases { + guard let poolDict = langDict[pool.rawValue] as? [String: Any] else { continue } + var state = PoolState() + if let arr = poolDict["custom"] as? [String] { state.custom = arr } + if let arr = poolDict["disabled"] as? [String] { state.disabled = arr } + out[pool] = state + } + return out + } + + static func save(lang: String, state: LangState) { + let dir = jsonURL.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + var raw: [String: Any] = [:] + if let data = try? Data(contentsOf: jsonURL), + let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + raw = existing + } + + var langDict: [String: Any] = [:] + for pool in PhrasePool.allCases { + let s = state[pool] ?? PoolState() + var poolDict: [String: Any] = [:] + if !s.custom.isEmpty { poolDict["custom"] = s.custom } + if !s.disabled.isEmpty { poolDict["disabled"] = s.disabled } + if !poolDict.isEmpty { langDict[pool.rawValue] = poolDict } + } + + if langDict.isEmpty { raw.removeValue(forKey: lang) } + else { raw[lang] = langDict } + + let data = (try? JSONSerialization.data( + withJSONObject: raw, options: [.prettyPrinted, .sortedKeys])) ?? Data() + try? data.write(to: jsonURL, options: .atomic) + } + + // Read shipped defaults for a given language by sourcing the bash file + // and printing the arrays as JSON. We don't want to maintain two sources + // of truth for the defaults — they live in phrases/.sh. + static func defaults(lang: String) -> [PhrasePool: [String]] { + let phrasesDir = (NSHomeDirectory() as NSString) + .appendingPathComponent(".stack-nudge/phrases") + let path = "\(phrasesDir)/\(lang).sh" + guard FileManager.default.fileExists(atPath: path) else { return [:] } + + let task = Process() + task.executableURL = URL(fileURLWithPath: "/bin/bash") + task.arguments = [ + "-c", + "source \"\(path)\"; printf '%s\\0' \"${TEMPLATES_RESPONSE[@]}\"; printf '\\0'; printf '%s\\0' \"${TEMPLATES_NOTIFICATION[@]}\"", + ] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = Pipe() + do { try task.run() } catch { return [:] } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() + + let parts = data.split(separator: 0x00, maxSplits: .max, + omittingEmptySubsequences: false) + var responseItems: [String] = [] + var notificationItems: [String] = [] + var crossedBoundary = false + for part in parts { + if part.isEmpty { + if !crossedBoundary { crossedBoundary = true } + continue + } + let s = String(data: Data(part), encoding: .utf8) ?? "" + guard !s.isEmpty else { continue } + if !crossedBoundary { responseItems.append(s) } + else { notificationItems.append(s) } + } + return [.response: responseItems, .notification: notificationItems] + } +} + +// MARK: - View model + +final class PhrasesViewModel: ObservableObject { + @Published var lang: String = "en" + @Published var state: PhraseStore.LangState = [:] + @Published var defaults: [PhrasePool: [String]] = [:] + @Published var draft: String = "" + @Published var draftPool: PhrasePool = .response + @Published var error: String? + @Published var selectedRow: Row? + + struct Row: Equatable { + let pool: PhrasePool + let phrase: String + let isDefault: Bool + } + + func load() { + state = PhraseStore.load(lang: lang) + defaults = PhraseStore.defaults(lang: lang) + } + + func add() { + let trimmed = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + var s = state[draftPool] ?? .init() + guard !s.custom.contains(trimmed) else { + error = "Already in your list." + return + } + s.custom.append(trimmed) + state[draftPool] = s + draft = "" + error = nil + persist() + } + + func removeCustom(pool: PhrasePool, phrase: String) { + var s = state[pool] ?? .init() + s.custom.removeAll { $0 == phrase } + state[pool] = s + if selectedRow?.phrase == phrase { selectedRow = nil } + persist() + } + + // Toggle a shipped default phrase between enabled and disabled. + func toggleDefault(pool: PhrasePool, phrase: String) { + var s = state[pool] ?? .init() + if let idx = s.disabled.firstIndex(of: phrase) { + s.disabled.remove(at: idx) + } else { + s.disabled.append(phrase) + } + state[pool] = s + persist() + } + + func isDisabled(pool: PhrasePool, phrase: String) -> Bool { + state[pool]?.disabled.contains(phrase) ?? false + } + + private func persist() { + PhraseStore.save(lang: lang, state: state) + } + + // Flat list of every visible row in display order, used by ↑/↓. + var navigableRows: [Row] { + var rows: [Row] = [] + for pool in PhrasePool.allCases { + for phrase in defaults[pool] ?? [] { + rows.append(Row(pool: pool, phrase: phrase, isDefault: true)) + } + for phrase in state[pool]?.custom ?? [] { + rows.append(Row(pool: pool, phrase: phrase, isDefault: false)) + } + } + return rows + } + + func selectNext() { + let rows = navigableRows + guard !rows.isEmpty else { return } + if let current = selectedRow, + let idx = rows.firstIndex(of: current), + idx + 1 < rows.count { + selectedRow = rows[idx + 1] + } else { + selectedRow = rows.first + } + } + + func selectPrevious() { + let rows = navigableRows + guard !rows.isEmpty else { return } + if let current = selectedRow, + let idx = rows.firstIndex(of: current), + idx - 1 >= 0 { + selectedRow = rows[idx - 1] + } else { + selectedRow = rows.last + } + } + + // Space: toggle a default on/off if a default is selected. No-op on custom. + func toggleSelected() { + guard let row = selectedRow, row.isDefault else { return } + toggleDefault(pool: row.pool, phrase: row.phrase) + } + + // ⌫: remove the selected custom row. No-op on default. + @discardableResult + func removeSelected() -> Bool { + guard let row = selectedRow, !row.isDefault else { return false } + removeCustom(pool: row.pool, phrase: row.phrase) + return true + } +} + +// MARK: - View + +struct PhrasesView: View { + + @ObservedObject var model: PhrasesViewModel + let onBack: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider().opacity(0.4) + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 18) { + pool(.response) + pool(.notification) + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + .background(ThinScrollers()) + } + .onChange(of: model.selectedRow) { newValue in + guard let row = newValue else { return } + withAnimation(.easeOut(duration: 0.15)) { + proxy.scrollTo(rowID(row), anchor: .center) + } + } + } + footer + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onAppear { model.load() } + } + + // Stable scroll target for a row. Pool + isDefault + phrase keeps custom + // and default rows that share text (unlikely but possible) distinct. + private func rowID(_ row: PhrasesViewModel.Row) -> String { + "\(row.pool.rawValue)|\(row.isDefault ? "d" : "c")|\(row.phrase)" + } + + private var header: some View { + HStack(spacing: 8) { + Button(action: onBack) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.caption.weight(.semibold)) + Text("Settings") + .font(.caption) + } + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + + Text("Phrases") + .font(.subheadline.weight(.medium)) + .padding(.leading, 6) + + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + @ViewBuilder + private func pool(_ pool: PhrasePool) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text(pool.title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Spacer() + let custom = model.state[pool]?.custom.count ?? 0 + let disabled = model.state[pool]?.disabled.count ?? 0 + Text("\(custom) custom · \(disabled) muted") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.tertiary) + } + Text(pool.subtitle) + .font(.caption2) + .foregroundStyle(.tertiary) + + // Defaults — toggleable on/off. + ForEach(model.defaults[pool] ?? [], id: \.self) { phrase in + defaultRow(pool: pool, phrase: phrase) + } + + // Custom additions. + ForEach(model.state[pool]?.custom ?? [], id: \.self) { phrase in + customRow(pool: pool, phrase: phrase) + } + + inputField(for: pool) + } + } + + @ViewBuilder + private func defaultRow(pool: PhrasePool, phrase: String) -> some View { + let row = PhrasesViewModel.Row(pool: pool, phrase: phrase, isDefault: true) + let disabled = model.isDisabled(pool: pool, phrase: phrase) + let selected = model.selectedRow == row + HStack(alignment: .firstTextBaseline, spacing: 8) { + Button { + model.toggleDefault(pool: pool, phrase: phrase) + } label: { + Image(systemName: disabled ? "circle" : "checkmark.circle.fill") + .font(.callout) + .foregroundStyle(disabled ? Color.tertiaryLabelColor : Color.accentColor.opacity(0.8)) + } + .buttonStyle(.plain) + .frame(width: 18, alignment: .center) + + VStack(alignment: .leading, spacing: 1) { + Text(phrase) + .font(.callout) + .foregroundStyle(disabled ? .tertiary : .secondary) + .strikethrough(disabled, color: Color.tertiaryLabelColor) + Text(preview(of: phrase)) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.tail) + } + + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(rowBackground(selected: selected, dim: disabled)) + .contentShape(Rectangle()) + .id(rowID(row)) + .onTapGesture { + model.selectedRow = row + } + } + + @ViewBuilder + private func customRow(pool: PhrasePool, phrase: String) -> some View { + let row = PhrasesViewModel.Row(pool: pool, phrase: phrase, isDefault: false) + let selected = model.selectedRow == row + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "diamond.fill") + .font(.caption2) + .foregroundStyle(Color.accentColor.opacity(0.7)) + .frame(width: 18, alignment: .center) + + VStack(alignment: .leading, spacing: 1) { + Text(phrase) + .font(.callout) + Text(preview(of: phrase)) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.tail) + } + + Spacer() + + Button { + model.removeCustom(pool: pool, phrase: phrase) + } label: { + Image(systemName: "xmark.circle.fill") + .font(.callout) + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(rowBackground(selected: selected, dim: false)) + .contentShape(Rectangle()) + .id(rowID(row)) + .onTapGesture { + model.selectedRow = row + } + } + + private func rowBackground(selected: Bool, dim: Bool) -> some View { + let fill: Color = selected ? Color.accentColor.opacity(0.22) + : Color.primary.opacity(dim ? 0.03 : 0.06) + return RoundedRectangle(cornerRadius: 6, style: .continuous).fill(fill) + } + + @ViewBuilder + private func inputField(for pool: PhrasePool) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + TextField("Add a phrase. Use %s for the repo name.", + text: Binding( + get: { model.draftPool == pool ? model.draft : "" }, + set: { + model.draft = $0 + model.draftPool = pool + if !$0.isEmpty { model.selectedRow = nil } + } + )) + .textFieldStyle(.plain) + .font(.callout) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.06)) + ) + .onSubmit { + model.draftPool = pool + model.add() + } + + Button { + model.draftPool = pool + model.add() + } label: { + Image(systemName: "plus.circle.fill") + .font(.title3) + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + .disabled(model.draftPool != pool || + model.draft.trimmingCharacters(in: .whitespaces).isEmpty) + } + + if model.draftPool == pool, !model.draft.isEmpty { + Text("Preview: \(preview(of: model.draft))") + .font(.caption2) + .foregroundStyle(.tertiary) + } + if model.draftPool == pool, let err = model.error { + Text(err).font(.caption2).foregroundStyle(.red) + } + } + } + + private var footer: some View { + PageFooter { + FooterHint(label: "Add", keys: ["⏎"], primary: true) + FooterHint(label: "Toggle", keys: ["␣"]) + FooterHint(label: "Remove", keys: ["⌫"]) + FooterHint(label: "Back", keys: ["esc"]) + } + } + + private func preview(of template: String) -> String { + let repo = PhraseStore.previewRepo + guard let range = template.range(of: "%s") else { return template } + return template.replacingCharacters(in: range, with: repo) + } +} + +private extension Color { + static var tertiaryLabelColor: Color { + Color(nsColor: .tertiaryLabelColor) + } +} diff --git a/panel/Settings.swift b/panel/Settings.swift index 7a00b27..f7c2543 100644 --- a/panel/Settings.swift +++ b/panel/Settings.swift @@ -45,9 +45,10 @@ struct SettingsView: View { } section("Actions") { - row(9, label: "Check permissions…", kind: .action, value: "") - row(10, label: "Open config file…", kind: .action, value: "") - row(11, label: "Quit panel", kind: .action, value: "") + row(9, label: "Edit phrases…", kind: .action, value: "") + row(10, label: "Check permissions…", kind: .action, value: "") + row(11, label: "Open config file…", kind: .action, value: "") + row(12, label: "Quit panel", kind: .action, value: "") } aboutFooter