@hasna/recordings 0.1.9 → 0.1.11

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.
@@ -4,89 +4,81 @@ import KeyboardShortcuts
4
4
  struct MenuBarPopover: View {
5
5
  @ObservedObject var engine: RecordingEngine
6
6
  @ObservedObject var shortcuts: VoiceShortcuts
7
+ @ObservedObject var projectStore: ProjectStore
8
+ @State private var copiedIndex: Int?
9
+ @State private var filterProjectId: String?
10
+
11
+ private var filteredTranscriptions: [TranscriptionResult] {
12
+ guard let filter = filterProjectId else {
13
+ return engine.recentTranscriptions
14
+ }
15
+ if filter == "__none__" {
16
+ return engine.recentTranscriptions.filter { $0.projectId == nil }
17
+ }
18
+ return engine.recentTranscriptions.filter { $0.projectId == filter }
19
+ }
7
20
 
8
21
  var body: some View {
9
22
  VStack(spacing: 0) {
10
- header.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
11
- Divider()
12
- modeSelector.padding(.horizontal, 16).padding(.vertical, 10)
13
- shortcutRow.padding(.horizontal, 16).padding(.bottom, 8)
23
+ header
24
+ .padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
14
25
  Divider()
15
- recordingArea.padding(.horizontal, 16).padding(.vertical, 12)
26
+ recordingArea
27
+ .padding(.horizontal, 16).padding(.vertical, 12)
16
28
  Divider()
17
- recentList.frame(maxHeight: 180)
29
+ if !engine.recentTranscriptions.isEmpty {
30
+ filterBar
31
+ .padding(.horizontal, 16).padding(.vertical, 6)
32
+ Divider()
33
+ }
34
+ recentList
35
+ .frame(maxHeight: 200)
18
36
  Divider()
19
- footer.padding(.horizontal, 16).padding(.vertical, 8)
37
+ footerMenu
20
38
  }
21
39
  }
22
40
 
23
41
  // MARK: - Header
24
42
 
25
43
  private var header: some View {
26
- HStack {
27
- Image(systemName: "mic.fill").font(.title3).foregroundStyle(.tint)
28
- Text("Recordings").font(.headline)
29
- Spacer()
30
- Toggle(isOn: $engine.isWhisperMode) {
31
- Label("Whisper", systemImage: "speaker.wave.1.fill").font(.caption)
32
- }
33
- .toggleStyle(.button).buttonStyle(.bordered).controlSize(.small)
34
- }
35
- }
36
-
37
- // MARK: - Mode Selector
38
-
39
- private var modeSelector: some View {
40
- HStack(spacing: 8) {
41
- modeBtn(.pushToTalk)
42
- modeBtn(.dictation)
43
- modeBtn(.command)
44
- }
45
- }
46
-
47
- @ViewBuilder
48
- private func modeBtn(_ m: RecordingMode) -> some View {
49
- if engine.mode == m {
50
- Button { engine.mode = m } label: {
51
- Label(m.rawValue, systemImage: m.icon).font(.caption)
52
- .foregroundStyle(.white).frame(maxWidth: .infinity)
53
- }.buttonStyle(.borderedProminent).controlSize(.small)
54
- } else {
55
- Button { engine.mode = m } label: {
56
- Label(m.rawValue, systemImage: m.icon).font(.caption)
57
- .frame(maxWidth: .infinity)
58
- }.buttonStyle(.bordered).controlSize(.small)
59
- }
60
- }
61
-
62
- // MARK: - Shortcut Row
63
-
64
- private var shortcutRow: some View {
65
- VStack(spacing: 6) {
66
- // fn key toggle
44
+ VStack(alignment: .leading, spacing: 4) {
67
45
  HStack {
68
- Toggle(isOn: $engine.useFnKey) {
69
- HStack(spacing: 4) {
70
- Text("fn").font(.system(.caption, design: .monospaced)).bold()
71
- Text("Globe key").font(.caption).foregroundStyle(.secondary)
72
- }
46
+ Image(systemName: "mic.fill")
47
+ .foregroundStyle(.tint)
48
+ Text("Hasna Recordings")
49
+ Spacer()
50
+ if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
51
+ Text(shortcut.description)
52
+ .foregroundStyle(.secondary)
73
53
  }
74
- .toggleStyle(.switch).controlSize(.small)
75
54
  }
76
-
77
- // Custom shortcut
78
- HStack(spacing: 8) {
79
- Text("Custom").font(.caption).foregroundStyle(.secondary)
80
- Spacer()
81
- let current = KeyboardShortcuts.getShortcut(for: .toggleRecording)
82
- Text(current?.description ?? "None")
83
- .font(.system(.caption, design: .monospaced))
84
- .padding(.horizontal, 8).padding(.vertical, 3)
85
- .background(.quaternary, in: RoundedRectangle(cornerRadius: 5))
86
-
87
- SettingsLink {
88
- Text("Set").font(.caption2)
89
- }.buttonStyle(.bordered).controlSize(.mini)
55
+ if !projectStore.settings.projects.isEmpty {
56
+ HStack(spacing: 4) {
57
+ Image(systemName: "folder.fill")
58
+ .foregroundColor(projectStore.activeProject != nil ? .accentColor : .secondary)
59
+ Menu {
60
+ Button("None") { projectStore.setActive(nil) }
61
+ Divider()
62
+ ForEach(projectStore.settings.projects) { project in
63
+ Button {
64
+ projectStore.setActive(project.id)
65
+ } label: {
66
+ HStack {
67
+ Text(project.name)
68
+ if project.id == projectStore.settings.activeProjectId {
69
+ Image(systemName: "checkmark")
70
+ }
71
+ }
72
+ }
73
+ }
74
+ } label: {
75
+ Text(projectStore.activeProject?.name ?? "No project")
76
+ .foregroundStyle(projectStore.activeProject == nil ? .secondary : .primary)
77
+ }
78
+ .menuStyle(.borderlessButton)
79
+ .fixedSize()
80
+ Spacer()
81
+ }
90
82
  }
91
83
  }
92
84
  }
@@ -94,41 +86,94 @@ struct MenuBarPopover: View {
94
86
  // MARK: - Recording Area
95
87
 
96
88
  private var recordingArea: some View {
97
- VStack(spacing: 8) {
89
+ Group {
98
90
  if engine.isRecording {
99
- HStack(spacing: 12) {
100
- Circle().fill(.red).frame(width: 10, height: 10)
101
- Text(fmt(engine.recordingDuration))
102
- .font(.system(.title2, design: .monospaced))
103
- Spacer()
104
- Button("Stop") { engine.stopAndTranscribe() }
105
- .buttonStyle(.borderedProminent).tint(.red).controlSize(.small)
91
+ VStack(spacing: 6) {
92
+ HStack {
93
+ Circle().fill(.red).frame(width: 8, height: 8)
94
+ Text(fmt(engine.recordingDuration))
95
+ .monospacedDigit()
96
+ Spacer()
97
+ Button("Stop") { engine.stopAndTranscribe() }
98
+ .controlSize(.small)
99
+ }
100
+ if let project = projectStore.activeProject {
101
+ HStack(spacing: 4) {
102
+ Image(systemName: "folder.fill").foregroundStyle(.tint)
103
+ Text(project.name).foregroundStyle(.secondary)
104
+ Spacer()
105
+ }
106
+ }
106
107
  }
107
- .padding(12).glassEffect(.regular.tint(.red))
108
+ .padding(10)
109
+ .glassEffect(.regular)
108
110
  } else if engine.isTranscribing {
109
- HStack(spacing: 8) {
110
- ProgressView().controlSize(.small)
111
- Text("Transcribing...").font(.subheadline).foregroundStyle(.secondary)
111
+ VStack(spacing: 6) {
112
+ HStack(spacing: 8) {
113
+ ProgressView().controlSize(.small)
114
+ Text("Transcribing...")
115
+ .foregroundStyle(.secondary)
116
+ Spacer()
117
+ }
118
+ if let project = projectStore.activeProject {
119
+ HStack(spacing: 4) {
120
+ Image(systemName: "folder.fill").foregroundStyle(.tint)
121
+ Text(project.name).foregroundStyle(.secondary)
122
+ Spacer()
123
+ }
124
+ }
112
125
  }
113
- .frame(maxWidth: .infinity).padding(12).glassEffect(.regular)
126
+ .frame(maxWidth: .infinity)
127
+ .padding(10)
128
+ .glassEffect(.regular)
114
129
  } else {
115
- VStack(spacing: 10) {
130
+ VStack(spacing: 6) {
116
131
  Button { engine.startRecording() } label: {
117
- Label("Record", systemImage: "mic.circle.fill").font(.title3)
118
- }.buttonStyle(.borderedProminent).controlSize(.large)
132
+ Label("Record", systemImage: "mic.circle.fill")
133
+ }
134
+ .controlSize(.regular)
119
135
 
120
- Text(engine.mode.hint)
121
- .font(.callout).foregroundStyle(.primary.opacity(0.7))
122
- .multilineTextAlignment(.center)
123
- .fixedSize(horizontal: false, vertical: true)
136
+ if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
137
+ Text("or hold \(shortcut.description)")
138
+ .foregroundStyle(.tertiary)
139
+ }
124
140
  }
125
- .frame(maxWidth: .infinity).padding(14).glassEffect(.clear)
141
+ .frame(maxWidth: .infinity)
142
+ .padding(10)
143
+ .glassEffect(.regular)
126
144
  }
145
+ }
146
+ }
127
147
 
128
- Text(engine.statusMessage)
129
- .font(.caption2).foregroundStyle(.secondary)
130
- .lineLimit(2).truncationMode(.tail)
131
- .frame(maxWidth: .infinity, alignment: .leading)
148
+ // MARK: - Filter Bar
149
+
150
+ private var filterBar: some View {
151
+ ScrollView(.horizontal, showsIndicators: false) {
152
+ HStack(spacing: 6) {
153
+ FilterChip(label: "All", isActive: filterProjectId == nil) {
154
+ filterProjectId = nil
155
+ }
156
+ ForEach(projectStore.settings.projects) { project in
157
+ let count = engine.recentTranscriptions.filter { $0.projectId == project.id }.count
158
+ if count > 0 {
159
+ FilterChip(
160
+ label: "\(project.name) (\(count))",
161
+ isActive: filterProjectId == project.id
162
+ ) {
163
+ filterProjectId = filterProjectId == project.id ? nil : project.id
164
+ }
165
+ }
166
+ }
167
+ let noProjectCount = engine.recentTranscriptions.filter { $0.projectId == nil }.count
168
+ if noProjectCount > 0 {
169
+ FilterChip(
170
+ label: "No project (\(noProjectCount))",
171
+ isActive: filterProjectId == "__none__"
172
+ ) {
173
+ filterProjectId = filterProjectId == "__none__" ? nil : "__none__"
174
+ }
175
+ }
176
+ }
132
177
  }
133
178
  }
134
179
 
@@ -136,18 +181,30 @@ struct MenuBarPopover: View {
136
181
 
137
182
  private var recentList: some View {
138
183
  Group {
139
- if engine.recentTranscriptions.isEmpty {
140
- VStack { Spacer()
141
- Text("No recent transcriptions").font(.caption).foregroundStyle(.quaternary)
142
- Spacer()
143
- }.frame(maxWidth: .infinity)
184
+ if filteredTranscriptions.isEmpty {
185
+ Spacer()
144
186
  } else {
145
187
  ScrollView {
146
- LazyVStack(alignment: .leading, spacing: 4) {
147
- ForEach(engine.recentTranscriptions.indices, id: \.self) { i in
148
- TranscriptionRow(item: engine.recentTranscriptions[i])
188
+ LazyVStack(alignment: .leading, spacing: 2) {
189
+ ForEach(filteredTranscriptions.indices, id: \.self) { i in
190
+ let item = filteredTranscriptions[i]
191
+ TranscriptionRow(
192
+ item: item,
193
+ showProject: filterProjectId == nil,
194
+ isCopied: copiedIndex == i
195
+ ) {
196
+ NSPasteboard.general.clearContents()
197
+ NSPasteboard.general.setString(item.displayText, forType: .string)
198
+ withAnimation(.easeInOut(duration: 0.15)) {
199
+ copiedIndex = i
200
+ }
201
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
202
+ withAnimation { if copiedIndex == i { copiedIndex = nil } }
203
+ }
204
+ }
149
205
  }
150
- }.padding(.horizontal, 16).padding(.vertical, 6)
206
+ }
207
+ .padding(.horizontal, 16).padding(.vertical, 6)
151
208
  }
152
209
  }
153
210
  }
@@ -155,12 +212,33 @@ struct MenuBarPopover: View {
155
212
 
156
213
  // MARK: - Footer
157
214
 
158
- private var footer: some View {
159
- HStack {
160
- SettingsLink { Image(systemName: "gear") }.buttonStyle(.borderless)
161
- Spacer()
162
- Button("Quit") { NSApplication.shared.terminate(nil) }
163
- .buttonStyle(.borderless).foregroundStyle(.secondary)
215
+ private var footerMenu: some View {
216
+ VStack(spacing: 0) {
217
+ SettingsLink {
218
+ HStack {
219
+ Text("Settings...")
220
+ Spacer()
221
+ Text("⌘,").foregroundStyle(.tertiary)
222
+ }
223
+ .contentShape(Rectangle())
224
+ .padding(.horizontal, 16).padding(.vertical, 6)
225
+ }
226
+ .buttonStyle(.plain)
227
+
228
+ Divider()
229
+
230
+ Button {
231
+ NSApplication.shared.terminate(nil)
232
+ } label: {
233
+ HStack {
234
+ Text("Quit Hasna Recordings")
235
+ Spacer()
236
+ Text("⌘Q").foregroundStyle(.tertiary)
237
+ }
238
+ .contentShape(Rectangle())
239
+ .padding(.horizontal, 16).padding(.vertical, 6)
240
+ }
241
+ .buttonStyle(.plain)
164
242
  }
165
243
  }
166
244
 
@@ -169,20 +247,71 @@ struct MenuBarPopover: View {
169
247
  }
170
248
  }
171
249
 
250
+ // MARK: - Filter Chip
251
+
252
+ struct FilterChip: View {
253
+ let label: String
254
+ let isActive: Bool
255
+ let action: () -> Void
256
+
257
+ var body: some View {
258
+ Button(action: action) {
259
+ Text(label)
260
+ .padding(.horizontal, 8)
261
+ .padding(.vertical, 3)
262
+ .background(isActive ? Color.accentColor.opacity(0.15) : Color.clear)
263
+ .clipShape(RoundedRectangle(cornerRadius: 6))
264
+ }
265
+ .buttonStyle(.plain)
266
+ .foregroundStyle(isActive ? .primary : .secondary)
267
+ }
268
+ }
269
+
270
+ // MARK: - Transcription Row
271
+
172
272
  struct TranscriptionRow: View {
173
273
  let item: TranscriptionResult
274
+ let showProject: Bool
275
+ let isCopied: Bool
276
+ let onCopy: () -> Void
277
+
174
278
  var body: some View {
175
- HStack(alignment: .top, spacing: 8) {
176
- Text(item.displayText).font(.caption).lineLimit(2)
177
- .frame(maxWidth: .infinity, alignment: .leading)
178
- Text({ let s = Date().timeIntervalSince(item.timestamp)
179
- return s < 60 ? "now" : s < 3600 ? "\(Int(s/60))m" : "\(Int(s/3600))h"
180
- }()).font(.caption2).foregroundStyle(.tertiary)
279
+ VStack(alignment: .leading, spacing: 2) {
280
+ HStack(alignment: .top, spacing: 8) {
281
+ Text(item.displayText)
282
+ .lineLimit(2)
283
+ .frame(maxWidth: .infinity, alignment: .leading)
284
+
285
+ if isCopied {
286
+ Image(systemName: "checkmark")
287
+ .foregroundStyle(.green)
288
+ .transition(.scale.combined(with: .opacity))
289
+ } else {
290
+ Text(relativeTime(item.timestamp))
291
+ .foregroundStyle(.tertiary)
292
+ }
293
+ }
294
+ if showProject, let name = item.projectName {
295
+ HStack(spacing: 3) {
296
+ Image(systemName: "folder")
297
+ Text(name)
298
+ }
299
+ .foregroundStyle(.tertiary)
300
+ }
181
301
  }
182
- .padding(.vertical, 4).contentShape(Rectangle())
183
- .onTapGesture {
184
- NSPasteboard.general.clearContents()
185
- NSPasteboard.general.setString(item.displayText, forType: .string)
302
+ .padding(.vertical, 4)
303
+ .padding(.horizontal, 6)
304
+ .contentShape(Rectangle())
305
+ .onTapGesture { onCopy() }
306
+ .onHover { hovering in
307
+ if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() }
186
308
  }
187
309
  }
310
+
311
+ private func relativeTime(_ date: Date) -> String {
312
+ let s = Date().timeIntervalSince(date)
313
+ if s < 60 { return "now" }
314
+ if s < 3600 { return "\(Int(s / 60))m" }
315
+ return "\(Int(s / 3600))h"
316
+ }
188
317
  }
@@ -0,0 +1,126 @@
1
+ import Foundation
2
+
3
+ struct RecProject: Codable, Identifiable, Sendable {
4
+ let id: String
5
+ var name: String
6
+ var path: String?
7
+ var systemPrompt: String?
8
+ var appBundleIds: [String]?
9
+
10
+ init(name: String, path: String? = nil, systemPrompt: String? = nil, appBundleIds: [String]? = nil) {
11
+ self.id = UUID().uuidString
12
+ self.name = name
13
+ self.path = path
14
+ self.systemPrompt = systemPrompt
15
+ self.appBundleIds = appBundleIds
16
+ }
17
+ }
18
+
19
+ struct ProjectSettings: Codable, Sendable {
20
+ var globalSystemPrompt: String
21
+ var projects: [RecProject]
22
+ var activeProjectId: String?
23
+
24
+ init() {
25
+ globalSystemPrompt = ""
26
+ projects = []
27
+ activeProjectId = nil
28
+ }
29
+ }
30
+
31
+ @MainActor
32
+ final class ProjectStore: ObservableObject {
33
+ @Published var settings = ProjectSettings()
34
+
35
+ private let filePath: String
36
+
37
+ var activeProject: RecProject? {
38
+ settings.projects.first { $0.id == settings.activeProjectId }
39
+ }
40
+
41
+ var effectiveSystemPrompt: String {
42
+ let global = settings.globalSystemPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
43
+ let project = activeProject?.systemPrompt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
44
+ if global.isEmpty && project.isEmpty { return "" }
45
+ if global.isEmpty { return project }
46
+ if project.isEmpty { return global }
47
+ return "\(global)\n\n\(project)"
48
+ }
49
+
50
+ init() {
51
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
52
+ filePath = "\(home)/.hasna/recordings/projects.json"
53
+ load()
54
+ }
55
+
56
+ func load() {
57
+ guard FileManager.default.fileExists(atPath: filePath),
58
+ let data = FileManager.default.contents(atPath: filePath) else { return }
59
+ do {
60
+ settings = try JSONDecoder().decode(ProjectSettings.self, from: data)
61
+ } catch {
62
+ fputs("[ProjectStore] Failed to load: \(error)\n", stderr)
63
+ }
64
+ }
65
+
66
+ func save() {
67
+ do {
68
+ let data = try JSONEncoder().encode(settings)
69
+ let dir = (filePath as NSString).deletingLastPathComponent
70
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
71
+ try data.write(to: URL(fileURLWithPath: filePath))
72
+ } catch {
73
+ fputs("[ProjectStore] Failed to save: \(error)\n", stderr)
74
+ }
75
+ }
76
+
77
+ func addProject(name: String, path: String? = nil, systemPrompt: String? = nil) {
78
+ let project = RecProject(name: name, path: path, systemPrompt: systemPrompt)
79
+ settings.projects.append(project)
80
+ save()
81
+ }
82
+
83
+ func updateProject(_ project: RecProject) {
84
+ guard let idx = settings.projects.firstIndex(where: { $0.id == project.id }) else { return }
85
+ settings.projects[idx] = project
86
+ save()
87
+ }
88
+
89
+ func removeProject(id: String) {
90
+ settings.projects.removeAll { $0.id == id }
91
+ if settings.activeProjectId == id { settings.activeProjectId = nil }
92
+ save()
93
+ }
94
+
95
+ func setActive(_ id: String?) {
96
+ settings.activeProjectId = id
97
+ save()
98
+ }
99
+
100
+ // MARK: - Auto-detection
101
+
102
+ nonisolated static func matchProject(windowTitle: String?, bundleId: String?, projects: [RecProject]) -> RecProject? {
103
+ if let bundleId {
104
+ if let match = projects.first(where: { $0.appBundleIds?.contains(bundleId) == true }) {
105
+ return match
106
+ }
107
+ }
108
+
109
+ if let title = windowTitle {
110
+ let lower = title.lowercased()
111
+ for project in projects {
112
+ let name = project.name.lowercased()
113
+ if lower.contains(name) { return project }
114
+ let slug = name.replacingOccurrences(of: " ", with: "-")
115
+ if lower.contains(slug) { return project }
116
+ if let path = project.path {
117
+ let dirName = (path as NSString).lastPathComponent.lowercased()
118
+ if lower.contains(dirName) { return project }
119
+ }
120
+ }
121
+ }
122
+
123
+ return nil
124
+ }
125
+
126
+ }