@hasna/recordings 0.1.10 → 0.1.12

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 (71) hide show
  1. package/README.md +2 -0
  2. package/dist/cli/index.js +342 -153
  3. package/dist/db/database.d.ts.map +1 -1
  4. package/dist/db/recordings.d.ts.map +1 -1
  5. package/dist/index.js +114 -33
  6. package/dist/lib/config.d.ts.map +1 -1
  7. package/dist/lib/enhancer.d.ts +2 -2
  8. package/dist/lib/enhancer.d.ts.map +1 -1
  9. package/dist/lib/recorder.d.ts.map +1 -1
  10. package/dist/lib/transcriber.d.ts +8 -2
  11. package/dist/lib/transcriber.d.ts.map +1 -1
  12. package/dist/mcp/index.js +183 -52
  13. package/dist/types/index.d.ts +3 -0
  14. package/dist/types/index.d.ts.map +1 -1
  15. package/dist/version.d.ts +2 -0
  16. package/dist/version.d.ts.map +1 -0
  17. package/package.json +20 -3
  18. package/scripts/install_macos_app.sh +76 -0
  19. package/src/native/Recordings/{Recordings → App}/RecordingsApp.swift +16 -4
  20. package/src/native/Recordings/Package.resolved +19 -1
  21. package/src/native/Recordings/Package.swift +16 -5
  22. package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +131 -0
  23. package/src/native/Recordings/{Recordings → RecordingsLib}/Info.plist +6 -0
  24. package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +361 -0
  25. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +164 -0
  26. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +113 -0
  27. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +126 -0
  28. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +383 -0
  29. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +724 -0
  30. package/src/native/Recordings/RecordingsLib/SettingsView.swift +218 -0
  31. package/src/native/Recordings/{Recordings → RecordingsLib}/VoiceShortcuts.swift +12 -8
  32. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +63 -0
  33. package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +33 -0
  34. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +92 -0
  35. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +58 -0
  36. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +113 -0
  37. package/src/native/Recordings/build.sh +6 -6
  38. package/bun.lock +0 -250
  39. package/bunfig.toml +0 -2
  40. package/src/__tests__/agents.test.ts +0 -136
  41. package/src/__tests__/config.test.ts +0 -252
  42. package/src/__tests__/database.test.ts +0 -167
  43. package/src/__tests__/enhancer.test.ts +0 -574
  44. package/src/__tests__/preload.ts +0 -4
  45. package/src/__tests__/projects.test.ts +0 -109
  46. package/src/__tests__/recorder.test.ts +0 -278
  47. package/src/__tests__/recordings.test.ts +0 -353
  48. package/src/__tests__/transcriber.test.ts +0 -322
  49. package/src/__tests__/types.test.ts +0 -75
  50. package/src/cli/index.ts +0 -1115
  51. package/src/db/agents.ts +0 -104
  52. package/src/db/database.ts +0 -163
  53. package/src/db/pg-migrations.ts +0 -82
  54. package/src/db/projects.ts +0 -71
  55. package/src/db/recordings.ts +0 -225
  56. package/src/index.ts +0 -81
  57. package/src/lib/config.ts +0 -183
  58. package/src/lib/enhancer.ts +0 -167
  59. package/src/lib/recorder.ts +0 -198
  60. package/src/lib/transcriber.ts +0 -105
  61. package/src/mcp/index.ts +0 -464
  62. package/src/native/Recordings/Recordings/FnKeyMonitor.swift +0 -122
  63. package/src/native/Recordings/Recordings/MenuBarPopover.swift +0 -188
  64. package/src/native/Recordings/Recordings/RecordingEngine.swift +0 -355
  65. package/src/native/Recordings/Recordings/SettingsView.swift +0 -93
  66. package/src/native/Recordings/test_fn.swift +0 -79
  67. package/src/native/Recordings/test_fn2.swift +0 -33
  68. package/src/native/RecordingsHelper.swift +0 -395
  69. package/src/types/index.ts +0 -144
  70. package/tsconfig.json +0 -21
  71. /package/src/native/Recordings/{Recordings → RecordingsLib}/Recordings.entitlements +0 -0
@@ -1,15 +1,28 @@
1
+ @preconcurrency import Cocoa
1
2
  import SwiftUI
2
3
  import KeyboardShortcuts
4
+ import RecordingsLib
3
5
 
4
6
  @main
5
7
  struct RecordingsApp: App {
6
8
  @StateObject private var engine = RecordingEngine()
7
9
  @StateObject private var shortcuts = VoiceShortcuts()
10
+ @StateObject private var projectStore = ProjectStore()
11
+
12
+ init() {
13
+ AXIsProcessTrustedWithOptions(
14
+ [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
15
+ )
16
+ }
8
17
 
9
18
  var body: some Scene {
10
19
  MenuBarExtra {
11
- MenuBarPopover(engine: engine, shortcuts: shortcuts)
12
- .frame(width: 320, height: 440)
20
+ MenuBarPopover(engine: engine, shortcuts: shortcuts, projectStore: projectStore)
21
+ .frame(width: 320, height: 420)
22
+ .onAppear {
23
+ engine.projectStore = projectStore
24
+ engine.voiceShortcuts = shortcuts
25
+ }
13
26
  } label: {
14
27
  if engine.isRecording {
15
28
  Image(systemName: "record.circle.fill")
@@ -22,9 +35,8 @@ struct RecordingsApp: App {
22
35
  }
23
36
  .menuBarExtraStyle(.window)
24
37
 
25
- // Settings window — opened via SettingsLink
26
38
  Settings {
27
- SettingsView(engine: engine, shortcuts: shortcuts)
39
+ SettingsView(engine: engine, shortcuts: shortcuts, projectStore: projectStore)
28
40
  }
29
41
  }
30
42
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "originHash" : "a6c896b6f831f2130914352c7417e95b036b1f02a1ff70318f1f4e5aa8966e9b",
2
+ "originHash" : "99390ba8c7a8c4d4b60ff9bc6f60500c0cc28740d0a509ae347d8785b97022b5",
3
3
  "pins" : [
4
4
  {
5
5
  "identity" : "keyboardshortcuts",
@@ -9,6 +9,24 @@
9
9
  "revision" : "1aef85578fdd4f9eaeeb8d53b7b4fc31bf08fe27",
10
10
  "version" : "2.4.0"
11
11
  }
12
+ },
13
+ {
14
+ "identity" : "swift-syntax",
15
+ "kind" : "remoteSourceControl",
16
+ "location" : "https://github.com/swiftlang/swift-syntax.git",
17
+ "state" : {
18
+ "revision" : "0687f71944021d616d34d922343dcef086855920",
19
+ "version" : "600.0.1"
20
+ }
21
+ },
22
+ {
23
+ "identity" : "swift-testing",
24
+ "kind" : "remoteSourceControl",
25
+ "location" : "https://github.com/apple/swift-testing.git",
26
+ "state" : {
27
+ "revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
28
+ "version" : "0.99.0"
29
+ }
12
30
  }
13
31
  ],
14
32
  "version" : 3
@@ -8,14 +8,25 @@ let package = Package(
8
8
  .macOS(.v26)
9
9
  ],
10
10
  dependencies: [
11
- .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.0.0")
11
+ .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.0.0"),
12
+ .package(url: "https://github.com/apple/swift-testing.git", .upToNextMinor(from: "0.99.0")),
12
13
  ],
13
14
  targets: [
14
- .executableTarget(
15
- name: "Recordings",
15
+ .target(
16
+ name: "RecordingsLib",
16
17
  dependencies: ["KeyboardShortcuts"],
17
- path: "Recordings",
18
+ path: "RecordingsLib",
18
19
  exclude: ["Info.plist", "Recordings.entitlements"]
19
- )
20
+ ),
21
+ .executableTarget(
22
+ name: "App",
23
+ dependencies: ["RecordingsLib"],
24
+ path: "App"
25
+ ),
26
+ .testTarget(
27
+ name: "RecordingsTests",
28
+ dependencies: ["RecordingsLib", .product(name: "Testing", package: "swift-testing")],
29
+ path: "RecordingsTests"
30
+ ),
20
31
  ]
21
32
  )
@@ -0,0 +1,131 @@
1
+ import Cocoa
2
+
3
+ /// Monitors the fn/Globe key using CGEventTap.
4
+ /// Fn is exposed as a modifier flag, so we watch flagsChanged events and
5
+ /// check CGEventFlags.maskSecondaryFn rather than relying on a raw bit mask.
6
+ final class FnKeyMonitor: @unchecked Sendable {
7
+ var onFnKeyDown: (() -> Void)?
8
+ var onFnKeyUp: (() -> Void)?
9
+
10
+ private var eventTap: CFMachPort?
11
+ private var runLoopSource: CFRunLoopSource?
12
+ private var healthCheckTimer: Timer?
13
+ private var fnIsDown = false
14
+
15
+ private static let fnKeyCode: UInt16 = 63
16
+
17
+ /// Start monitoring. Returns true if successful.
18
+ func start() -> Bool {
19
+ guard eventTap == nil else {
20
+ fputs("[FnKeyMonitor] Already running\n", stderr)
21
+ return true
22
+ }
23
+
24
+ let eventMask: CGEventMask = 1 << CGEventType.flagsChanged.rawValue
25
+ let selfPtr = Unmanaged.passUnretained(self).toOpaque()
26
+ let candidates: [(CGEventTapLocation, CGEventTapPlacement, String)] = [
27
+ (.cgAnnotatedSessionEventTap, .tailAppendEventTap, "annotated-session/tail"),
28
+ (.cgSessionEventTap, .tailAppendEventTap, "session/tail"),
29
+ (.cghidEventTap, .headInsertEventTap, "hid/head"),
30
+ ]
31
+
32
+ for (tapLocation, tapPlacement, label) in candidates {
33
+ fputs("[FnKeyMonitor] Trying event tap: \(label)\n", stderr)
34
+
35
+ guard let tap = CGEvent.tapCreate(
36
+ tap: tapLocation,
37
+ place: tapPlacement,
38
+ options: .defaultTap,
39
+ eventsOfInterest: eventMask,
40
+ callback: { _, type, event, refcon -> Unmanaged<CGEvent>? in
41
+ guard let refcon else { return Unmanaged.passRetained(event) }
42
+ let monitor = Unmanaged<FnKeyMonitor>.fromOpaque(refcon).takeUnretainedValue()
43
+ return monitor.handleEvent(type: type, event: event)
44
+ },
45
+ userInfo: selfPtr
46
+ ) else {
47
+ continue
48
+ }
49
+
50
+ eventTap = tap
51
+ runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
52
+ if let runLoopSource {
53
+ CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
54
+ }
55
+ CGEvent.tapEnable(tap: tap, enable: true)
56
+ fputs("[FnKeyMonitor] Event tap ready: \(label)\n", stderr)
57
+
58
+ // Health check: macOS can silently disable taps — re-enable every 3 seconds.
59
+ healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
60
+ guard let self, let tap = self.eventTap else { return }
61
+ if !CGEvent.tapIsEnabled(tap: tap) {
62
+ fputs("[FnKeyMonitor] Tap was disabled, re-enabling\n", stderr)
63
+ CGEvent.tapEnable(tap: tap, enable: true)
64
+ }
65
+ }
66
+
67
+ return true
68
+ }
69
+
70
+ fputs("[FnKeyMonitor] Failed to create any event tap\n", stderr)
71
+ return false
72
+ }
73
+
74
+ func stop() {
75
+ healthCheckTimer?.invalidate()
76
+ healthCheckTimer = nil
77
+
78
+ if let tap = eventTap {
79
+ CGEvent.tapEnable(tap: tap, enable: false)
80
+ }
81
+ if let source = runLoopSource {
82
+ CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, .commonModes)
83
+ }
84
+ eventTap = nil
85
+ runLoopSource = nil
86
+ fnIsDown = false
87
+ }
88
+
89
+ private func handleEvent(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
90
+ if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
91
+ if let tap = eventTap {
92
+ CGEvent.tapEnable(tap: tap, enable: true)
93
+ }
94
+ return Unmanaged.passRetained(event)
95
+ }
96
+
97
+ guard type == .flagsChanged else {
98
+ return Unmanaged.passRetained(event)
99
+ }
100
+
101
+ let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode))
102
+ let fnPressed = event.flags.contains(.maskSecondaryFn)
103
+ let isFnTransition = keyCode == FnKeyMonitor.fnKeyCode || fnPressed || fnIsDown
104
+
105
+ guard isFnTransition else {
106
+ return Unmanaged.passRetained(event)
107
+ }
108
+
109
+ let flags = event.flags.rawValue
110
+ fputs("[FnKeyMonitor] flagsChanged keyCode=\(keyCode) flags=0x\(String(flags, radix: 16)) fnPressed=\(fnPressed) fnIsDown=\(fnIsDown)\n", stderr)
111
+
112
+ if fnPressed && !fnIsDown {
113
+ fnIsDown = true
114
+ fputs("[FnKeyMonitor] fn DOWN — starting recording\n", stderr)
115
+ DispatchQueue.main.async { [weak self] in
116
+ self?.onFnKeyDown?()
117
+ }
118
+ return nil
119
+ }
120
+
121
+ if !fnPressed && fnIsDown {
122
+ fnIsDown = false
123
+ DispatchQueue.main.async { [weak self] in
124
+ self?.onFnKeyUp?()
125
+ }
126
+ return nil
127
+ }
128
+
129
+ return Unmanaged.passRetained(event)
130
+ }
131
+ }
@@ -8,15 +8,21 @@
8
8
  <string>Recordings</string>
9
9
  <key>CFBundleDisplayName</key>
10
10
  <string>Recordings</string>
11
+ <key>CFBundleExecutable</key>
12
+ <string>Recordings</string>
11
13
  <key>CFBundleIdentifier</key>
12
14
  <string>com.hasna.recordings</string>
13
15
  <key>CFBundleVersion</key>
14
16
  <string>1</string>
17
+ <key>CFBundlePackageType</key>
18
+ <string>APPL</string>
15
19
  <key>CFBundleShortVersionString</key>
16
20
  <string>0.1.0</string>
17
21
  <key>LSMinimumSystemVersion</key>
18
22
  <string>26.0</string>
19
23
  <key>NSMicrophoneUsageDescription</key>
20
24
  <string>Recordings needs microphone access to record and transcribe your speech.</string>
25
+ <key>NSAppleEventsUsageDescription</key>
26
+ <string>Recordings needs Apple Events access to paste transcribed text into your active app.</string>
21
27
  </dict>
22
28
  </plist>
@@ -0,0 +1,361 @@
1
+ import SwiftUI
2
+ import KeyboardShortcuts
3
+
4
+ public struct MenuBarPopover: View {
5
+ @ObservedObject public var engine: RecordingEngine
6
+ @ObservedObject public var shortcuts: VoiceShortcuts
7
+ @ObservedObject public var projectStore: ProjectStore
8
+ @State private var copiedIndex: Int?
9
+ @State private var filterProjectId: String?
10
+
11
+ public init(engine: RecordingEngine, shortcuts: VoiceShortcuts, projectStore: ProjectStore) {
12
+ self.engine = engine
13
+ self.shortcuts = shortcuts
14
+ self.projectStore = projectStore
15
+ }
16
+
17
+ private var filteredTranscriptions: [TranscriptionResult] {
18
+ guard let filter = filterProjectId else {
19
+ return engine.recentTranscriptions
20
+ }
21
+ if filter == "__none__" {
22
+ return engine.recentTranscriptions.filter { $0.projectId == nil }
23
+ }
24
+ return engine.recentTranscriptions.filter { $0.projectId == filter }
25
+ }
26
+
27
+ public var body: some View {
28
+ VStack(spacing: 0) {
29
+ header
30
+ .padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
31
+ Divider()
32
+ modePicker
33
+ .padding(.horizontal, 16).padding(.top, 10)
34
+ recordingArea
35
+ .padding(.horizontal, 16).padding(.top, 8).padding(.bottom, 12)
36
+ Divider()
37
+ if !engine.recentTranscriptions.isEmpty {
38
+ filterBar
39
+ .padding(.horizontal, 16).padding(.vertical, 6)
40
+ Divider()
41
+ }
42
+ recentList
43
+ .frame(maxHeight: 200)
44
+ Divider()
45
+ footerMenu
46
+ }
47
+ }
48
+
49
+ // MARK: - Header
50
+
51
+ private var header: some View {
52
+ VStack(alignment: .leading, spacing: 4) {
53
+ HStack {
54
+ Image(systemName: "mic.fill")
55
+ .foregroundStyle(.tint)
56
+ Text("Hasna Recordings")
57
+ Spacer()
58
+ if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
59
+ Text(shortcut.description)
60
+ .foregroundStyle(.secondary)
61
+ }
62
+ }
63
+ if !projectStore.settings.projects.isEmpty {
64
+ HStack(spacing: 4) {
65
+ Image(systemName: "folder.fill")
66
+ .foregroundColor(projectStore.activeProject != nil ? .accentColor : .secondary)
67
+ Menu {
68
+ Button("None") { projectStore.setActive(nil) }
69
+ Divider()
70
+ ForEach(projectStore.settings.projects) { project in
71
+ Button {
72
+ projectStore.setActive(project.id)
73
+ } label: {
74
+ HStack {
75
+ Text(project.name)
76
+ if project.id == projectStore.settings.activeProjectId {
77
+ Image(systemName: "checkmark")
78
+ }
79
+ }
80
+ }
81
+ }
82
+ } label: {
83
+ Text(projectStore.activeProject?.name ?? "No project")
84
+ .foregroundStyle(projectStore.activeProject == nil ? .secondary : .primary)
85
+ }
86
+ .menuStyle(.borderlessButton)
87
+ .fixedSize()
88
+ Spacer()
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ // MARK: - Recording Area
95
+
96
+ private var modePicker: some View {
97
+ Picker("Mode", selection: $engine.mode) {
98
+ ForEach(RecordingMode.allCases) { mode in
99
+ Image(systemName: mode.icon)
100
+ .tag(mode)
101
+ .help(mode.rawValue)
102
+ }
103
+ }
104
+ .pickerStyle(.segmented)
105
+ }
106
+
107
+ @ViewBuilder
108
+ private var recordingArea: some View {
109
+ if engine.isRecording {
110
+ VStack(spacing: 6) {
111
+ HStack {
112
+ Circle().fill(.red).frame(width: 8, height: 8)
113
+ Text(fmt(engine.recordingDuration))
114
+ .monospacedDigit()
115
+ Spacer()
116
+ Button("Stop") { engine.stopAndTranscribe() }
117
+ .controlSize(.small)
118
+ }
119
+ // Live streaming transcription text
120
+ if !engine.liveTranscriptionText.isEmpty {
121
+ Text(engine.liveTranscriptionText)
122
+ .font(.callout)
123
+ .foregroundStyle(.primary)
124
+ .multilineTextAlignment(.leading)
125
+ .lineLimit(3)
126
+ .padding(.vertical, 4)
127
+ Text("Listening...")
128
+ .font(.caption)
129
+ .foregroundStyle(.green)
130
+ }
131
+ if let project = projectStore.activeProject {
132
+ HStack(spacing: 4) {
133
+ Image(systemName: "folder.fill").foregroundStyle(.tint)
134
+ Text(project.name).foregroundStyle(.secondary)
135
+ Spacer()
136
+ }
137
+ }
138
+ }
139
+ .padding(10)
140
+ .glassEffect(.regular)
141
+ } else if engine.isTranscribing {
142
+ VStack(spacing: 6) {
143
+ HStack(spacing: 8) {
144
+ ProgressView().controlSize(.small)
145
+ Text("Transcribing...")
146
+ .foregroundStyle(.secondary)
147
+ Spacer()
148
+ }
149
+ // Show accumulated text during transcribing
150
+ if !engine.liveTranscriptionText.isEmpty {
151
+ Text(engine.liveTranscriptionText)
152
+ .font(.callout)
153
+ .foregroundStyle(.primary)
154
+ .multilineTextAlignment(.leading)
155
+ .lineLimit(3)
156
+ .padding(.vertical, 4)
157
+ }
158
+ if let project = projectStore.activeProject {
159
+ HStack(spacing: 4) {
160
+ Image(systemName: "folder.fill").foregroundStyle(.tint)
161
+ Text(project.name).foregroundStyle(.secondary)
162
+ Spacer()
163
+ }
164
+ }
165
+ }
166
+ .frame(maxWidth: .infinity)
167
+ .padding(10)
168
+ .glassEffect(.regular)
169
+ } else {
170
+ VStack(spacing: 6) {
171
+ Button { engine.startRecording() } label: {
172
+ Label("Record", systemImage: "mic.circle.fill")
173
+ }
174
+ .controlSize(.regular)
175
+
176
+ if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
177
+ Text("or hold \(shortcut.description)")
178
+ .foregroundStyle(.tertiary)
179
+ }
180
+ Text(engine.statusMessage)
181
+ .font(.caption)
182
+ .foregroundStyle(engine.statusMessage == "Ready" ? Color.secondary : Color.orange)
183
+ .multilineTextAlignment(.center)
184
+ .lineLimit(2)
185
+ }
186
+ .frame(maxWidth: .infinity)
187
+ .padding(10)
188
+ .glassEffect(.regular)
189
+ }
190
+ }
191
+
192
+ // MARK: - Filter Bar
193
+
194
+ private var filterBar: some View {
195
+ ScrollView(.horizontal, showsIndicators: false) {
196
+ HStack(spacing: 6) {
197
+ FilterChip(label: "All", isActive: filterProjectId == nil) {
198
+ filterProjectId = nil
199
+ }
200
+ ForEach(projectStore.settings.projects) { project in
201
+ let count = engine.recentTranscriptions.filter { $0.projectId == project.id }.count
202
+ if count > 0 {
203
+ FilterChip(
204
+ label: "\(project.name) (\(count))",
205
+ isActive: filterProjectId == project.id
206
+ ) {
207
+ filterProjectId = filterProjectId == project.id ? nil : project.id
208
+ }
209
+ }
210
+ }
211
+ let noProjectCount = engine.recentTranscriptions.filter { $0.projectId == nil }.count
212
+ if noProjectCount > 0 {
213
+ FilterChip(
214
+ label: "No project (\(noProjectCount))",
215
+ isActive: filterProjectId == "__none__"
216
+ ) {
217
+ filterProjectId = filterProjectId == "__none__" ? nil : "__none__"
218
+ }
219
+ }
220
+ }
221
+ }
222
+ }
223
+
224
+ // MARK: - Recent
225
+
226
+ private var recentList: some View {
227
+ Group {
228
+ if filteredTranscriptions.isEmpty {
229
+ Spacer()
230
+ } else {
231
+ ScrollView {
232
+ LazyVStack(alignment: .leading, spacing: 2) {
233
+ ForEach(filteredTranscriptions.indices, id: \.self) { i in
234
+ let item = filteredTranscriptions[i]
235
+ TranscriptionRow(
236
+ item: item,
237
+ showProject: filterProjectId == nil,
238
+ isCopied: copiedIndex == i
239
+ ) {
240
+ NSPasteboard.general.clearContents()
241
+ NSPasteboard.general.setString(item.displayText, forType: .string)
242
+ withAnimation(.easeInOut(duration: 0.15)) {
243
+ copiedIndex = i
244
+ }
245
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
246
+ withAnimation { if copiedIndex == i { copiedIndex = nil } }
247
+ }
248
+ }
249
+ }
250
+ }
251
+ .padding(.horizontal, 16).padding(.vertical, 6)
252
+ }
253
+ }
254
+ }
255
+ }
256
+
257
+ // MARK: - Footer
258
+
259
+ private var footerMenu: some View {
260
+ VStack(spacing: 0) {
261
+ SettingsLink {
262
+ HStack {
263
+ Text("Settings...")
264
+ Spacer()
265
+ Text("⌘,").foregroundStyle(.tertiary)
266
+ }
267
+ .contentShape(Rectangle())
268
+ .padding(.horizontal, 16).padding(.vertical, 6)
269
+ }
270
+ .buttonStyle(.plain)
271
+
272
+ Divider()
273
+
274
+ Button {
275
+ NSApplication.shared.terminate(nil)
276
+ } label: {
277
+ HStack {
278
+ Text("Quit Hasna Recordings")
279
+ Spacer()
280
+ Text("⌘Q").foregroundStyle(.tertiary)
281
+ }
282
+ .contentShape(Rectangle())
283
+ .padding(.horizontal, 16).padding(.vertical, 6)
284
+ }
285
+ .buttonStyle(.plain)
286
+ }
287
+ }
288
+
289
+ private func fmt(_ t: TimeInterval) -> String {
290
+ String(format: "%d:%02d", Int(t) / 60, Int(t) % 60)
291
+ }
292
+ }
293
+
294
+ // MARK: - Filter Chip
295
+
296
+ struct FilterChip: View {
297
+ let label: String
298
+ let isActive: Bool
299
+ let action: () -> Void
300
+
301
+ var body: some View {
302
+ Button(action: action) {
303
+ Text(label)
304
+ .padding(.horizontal, 8)
305
+ .padding(.vertical, 3)
306
+ .background(isActive ? Color.accentColor.opacity(0.15) : Color.clear)
307
+ .clipShape(RoundedRectangle(cornerRadius: 6))
308
+ }
309
+ .buttonStyle(.plain)
310
+ .foregroundStyle(isActive ? .primary : .secondary)
311
+ }
312
+ }
313
+
314
+ // MARK: - Transcription Row
315
+
316
+ struct TranscriptionRow: View {
317
+ let item: TranscriptionResult
318
+ let showProject: Bool
319
+ let isCopied: Bool
320
+ let onCopy: () -> Void
321
+
322
+ var body: some View {
323
+ VStack(alignment: .leading, spacing: 2) {
324
+ HStack(alignment: .top, spacing: 8) {
325
+ Text(item.displayText)
326
+ .lineLimit(2)
327
+ .frame(maxWidth: .infinity, alignment: .leading)
328
+
329
+ if isCopied {
330
+ Image(systemName: "checkmark")
331
+ .foregroundStyle(.green)
332
+ .transition(.scale.combined(with: .opacity))
333
+ } else {
334
+ Text(relativeTime(item.timestamp))
335
+ .foregroundStyle(.tertiary)
336
+ }
337
+ }
338
+ if showProject, let name = item.projectName {
339
+ HStack(spacing: 3) {
340
+ Image(systemName: "folder")
341
+ Text(name)
342
+ }
343
+ .foregroundStyle(.tertiary)
344
+ }
345
+ }
346
+ .padding(.vertical, 4)
347
+ .padding(.horizontal, 6)
348
+ .contentShape(Rectangle())
349
+ .onTapGesture { onCopy() }
350
+ .onHover { hovering in
351
+ if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() }
352
+ }
353
+ }
354
+
355
+ private func relativeTime(_ date: Date) -> String {
356
+ let s = Date().timeIntervalSince(date)
357
+ if s < 60 { return "now" }
358
+ if s < 3600 { return "\(Int(s / 60))m" }
359
+ return "\(Int(s / 3600))h"
360
+ }
361
+ }