Skip to content

Commit 2f1d330

Browse files
committed
feat: implement answer card
1 parent 036d54e commit 2f1d330

6 files changed

Lines changed: 262 additions & 26 deletions

File tree

Wave.xcodeproj/project.pbxproj

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,23 @@
184184
};
185185
/* End PBXProject section */
186186

187+
/* Begin PBXResourcesBuildPhase section */
188+
3CA5A78D2F5063F600DBB766 /* Resources */ = {
189+
isa = PBXResourcesBuildPhase;
190+
buildActionMask = 2147483647;
191+
files = (
192+
);
193+
runOnlyForDeploymentPostprocessing = 0;
194+
};
195+
7B4E00082F60000000DBB766 /* Resources */ = {
196+
isa = PBXResourcesBuildPhase;
197+
buildActionMask = 2147483647;
198+
files = (
199+
);
200+
runOnlyForDeploymentPostprocessing = 0;
201+
};
202+
/* End PBXResourcesBuildPhase section */
203+
187204
/* Begin PBXShellScriptBuildPhase section */
188205
7B4E000C2F60000000DBB766 /* Generate whisper dSYM */ = {
189206
isa = PBXShellScriptBuildPhase;
@@ -208,23 +225,6 @@
208225
};
209226
/* End PBXShellScriptBuildPhase section */
210227

211-
/* Begin PBXResourcesBuildPhase section */
212-
3CA5A78D2F5063F600DBB766 /* Resources */ = {
213-
isa = PBXResourcesBuildPhase;
214-
buildActionMask = 2147483647;
215-
files = (
216-
);
217-
runOnlyForDeploymentPostprocessing = 0;
218-
};
219-
7B4E00082F60000000DBB766 /* Resources */ = {
220-
isa = PBXResourcesBuildPhase;
221-
buildActionMask = 2147483647;
222-
files = (
223-
);
224-
runOnlyForDeploymentPostprocessing = 0;
225-
};
226-
/* End PBXResourcesBuildPhase section */
227-
228228
/* Begin PBXSourcesBuildPhase section */
229229
3CA5A78B2F5063F600DBB766 /* Sources */ = {
230230
isa = PBXSourcesBuildPhase;

Wave/AppState.swift

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,9 @@ final class AppState {
362362
return
363363
}
364364

365+
// Dismiss any visible answer card before starting a new session
366+
overlayPanel?.hideAnswer()
367+
365368
do {
366369
status = .recording
367370
showOverlay()
@@ -435,22 +438,51 @@ final class AppState {
435438

436439
status = .idle
437440
overlayPanel?.setAIMode(false)
441+
let wasAIMode = isAIMode
442+
let hadSelection = selectedContext != nil
438443
isAIMode = false
439444
selectedContext = nil
440445
hideOverlayIfIdle()
441446

442447
if let text = text, !text.isEmpty {
443-
print("[wave] pasting: '\(text)'")
444448
historyManager.add(text)
445-
try? await Task.sleep(for: .milliseconds(100))
446-
PasteService.paste(text: text)
449+
450+
// Route:
451+
// - Regular dictation → always paste
452+
// - AI mode: paste only if there's an editable field to paste into.
453+
// Otherwise (reading webpage, PDF, etc.), show the answer card.
454+
// Selected text is just context for the LLM, not a routing signal.
455+
let shouldShowCard = wasAIMode && !PasteService.hasEditableFocus()
456+
457+
if shouldShowCard {
458+
print("[wave] showing answer card: '\(text)'")
459+
overlayPanel?.showAnswer(
460+
text,
461+
onCopy: { [weak self] in
462+
self?.copyAnswerToClipboard(text)
463+
},
464+
onClose: { [weak self] in
465+
self?.overlayPanel?.hideAnswer()
466+
}
467+
)
468+
} else {
469+
print("[wave] pasting: '\(text)' (hadSelection=\(hadSelection))")
470+
try? await Task.sleep(for: .milliseconds(100))
471+
PasteService.paste(text: text)
472+
}
447473
} else {
448474
print("[wave] nothing to paste")
449475
}
450476

451477
status = .idle
452478
}
453479

480+
private func copyAnswerToClipboard(_ text: String) {
481+
let pb = NSPasteboard.general
482+
pb.clearContents()
483+
pb.setString(text, forType: .string)
484+
}
485+
454486
func verifyAndFetchGroqModels() async {
455487
guard !groqAPIKey.isEmpty else { groqAPIStatus = .unknown; return }
456488
groqAPIStatus = .checking

Wave/Services/PasteService.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import AppKit
2+
import ApplicationServices
23
import Carbon.HIToolbox
34

45
struct PasteService {
@@ -95,4 +96,32 @@ struct PasteService {
9596

9697
return text?.isEmpty == false ? text : nil
9798
}
99+
100+
/// Returns true if the focused UI element accepts text input
101+
/// (e.g. user has cursor in a text field, text area, search bar).
102+
static func hasEditableFocus() -> Bool {
103+
let systemWide = AXUIElementCreateSystemWide()
104+
var focusedElement: CFTypeRef?
105+
guard AXUIElementCopyAttributeValue(systemWide, kAXFocusedUIElementAttribute as CFString, &focusedElement) == .success,
106+
let element = focusedElement else { return false }
107+
let axElement = element as! AXUIElement
108+
109+
// Primary signal: role suggests an editable field
110+
var role: CFTypeRef?
111+
if AXUIElementCopyAttributeValue(axElement, kAXRoleAttribute as CFString, &role) == .success,
112+
let roleString = role as? String {
113+
let editableRoles: Set<String> = [
114+
"AXTextField",
115+
"AXTextArea",
116+
"AXComboBox",
117+
"AXSearchField",
118+
]
119+
if editableRoles.contains(roleString) { return true }
120+
}
121+
122+
// Fallback: element lets us set its value (works for some browser inputs)
123+
var settable: DarwinBoolean = false
124+
AXUIElementIsAttributeSettable(axElement, kAXValueAttribute as CFString, &settable)
125+
return settable.boolValue
126+
}
98127
}

Wave/Views/AnswerCardView.swift

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import SwiftUI
2+
3+
struct AnswerCardView: View {
4+
let text: String
5+
let onCopy: () -> Void
6+
let onClose: () -> Void
7+
8+
@State private var didCopy = false
9+
10+
private let cardWidth: CGFloat = 360
11+
private let textMaxHeight: CGFloat = 220
12+
// If text exceeds this many characters, use a scrolling view. Otherwise the card fits the text.
13+
private let scrollThreshold = 400
14+
15+
var body: some View {
16+
VStack(alignment: .leading, spacing: 10) {
17+
if text.count > scrollThreshold {
18+
ScrollView(.vertical, showsIndicators: true) {
19+
Text(text)
20+
.font(.system(size: 13))
21+
.foregroundStyle(.white.opacity(0.95))
22+
.textSelection(.enabled)
23+
.fixedSize(horizontal: false, vertical: true)
24+
.padding(.trailing, 4)
25+
.frame(maxWidth: .infinity, alignment: .leading)
26+
}
27+
.frame(height: textMaxHeight)
28+
} else {
29+
Text(text)
30+
.font(.system(size: 13))
31+
.foregroundStyle(.white.opacity(0.95))
32+
.textSelection(.enabled)
33+
.fixedSize(horizontal: false, vertical: true)
34+
.frame(maxWidth: .infinity, alignment: .leading)
35+
}
36+
37+
HStack(spacing: 6) {
38+
Button(action: {
39+
onCopy()
40+
withAnimation(.easeInOut(duration: 0.15)) { didCopy = true }
41+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
42+
withAnimation(.easeInOut(duration: 0.2)) { didCopy = false }
43+
}
44+
}) {
45+
HStack(spacing: 4) {
46+
Image(systemName: didCopy ? "checkmark" : "doc.on.doc")
47+
.font(.system(size: 10, weight: .semibold))
48+
Text(didCopy ? "Copied" : "Copy")
49+
.font(.system(size: 11, weight: .medium))
50+
}
51+
.foregroundStyle(.white.opacity(0.9))
52+
.padding(.horizontal, 8)
53+
.padding(.vertical, 4)
54+
.background(
55+
RoundedRectangle(cornerRadius: 6)
56+
.fill(.white.opacity(0.08))
57+
)
58+
}
59+
.buttonStyle(.plain)
60+
61+
Spacer()
62+
63+
Button(action: onClose) {
64+
Image(systemName: "xmark")
65+
.font(.system(size: 10, weight: .semibold))
66+
.foregroundStyle(.white.opacity(0.7))
67+
.frame(width: 22, height: 22)
68+
.background(
69+
RoundedRectangle(cornerRadius: 6)
70+
.fill(.white.opacity(0.08))
71+
)
72+
}
73+
.buttonStyle(.plain)
74+
}
75+
}
76+
.padding(12)
77+
.frame(width: cardWidth)
78+
.background(
79+
RoundedRectangle(cornerRadius: 14)
80+
.fill(Color(red: 0.13, green: 0.13, blue: 0.13).opacity(0.95))
81+
.overlay(
82+
RoundedRectangle(cornerRadius: 14)
83+
.stroke(.white.opacity(0.1), lineWidth: 0.5)
84+
)
85+
)
86+
}
87+
}

Wave/Views/OverlayPanel.swift

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import AppKit
22
import QuartzCore
3+
import SwiftUI
34

45
// Idle: tiny pill. Active: expanded wave container.
56
private let kIdleW: CGFloat = 44
@@ -221,6 +222,8 @@ final class OverlayPanel: NSPanel {
221222
private var baseCenterX: CGFloat = 0
222223
private var shortcutLabel: String = ""
223224
private var tooltipTimer: Timer?
225+
private var answerHost: NSHostingView<AnswerCardView>?
226+
private(set) var isShowingAnswer = false
224227

225228
var onMouseDown: (() -> Void)? {
226229
get { drawView.onMouseDown }
@@ -272,6 +275,9 @@ final class OverlayPanel: NSPanel {
272275
}
273276

274277
func updateStatus(_ status: AppStatus) {
278+
// Don't overwrite the card while it's showing
279+
if isShowingAnswer { return }
280+
275281
drawView.status = status
276282

277283
if !isVisible {
@@ -297,7 +303,9 @@ final class OverlayPanel: NSPanel {
297303
let targetFrame = NSRect(x: baseCenterX - w / 2, y: baseY, width: w, height: h)
298304

299305
DispatchQueue.main.async { [weak self] in
300-
self?.setFrame(targetFrame, display: true, animate: true)
306+
// Re-check — the answer card may have taken over in the meantime
307+
guard let self, !self.isShowingAnswer else { return }
308+
self.setFrame(targetFrame, display: true, animate: true)
301309
}
302310
}
303311

@@ -329,6 +337,61 @@ final class OverlayPanel: NSPanel {
329337
drawView.isAIMode = enabled
330338
}
331339

340+
// MARK: - Answer card morph
341+
342+
func showAnswer(_ text: String, onCopy: @escaping () -> Void, onClose: @escaping () -> Void) {
343+
// Dismiss tooltip if visible
344+
tooltipTimer?.invalidate()
345+
tooltipTimer = nil
346+
tooltipPanel.hide()
347+
348+
// Make sure baseCenterX/baseY are current
349+
if baseCenterX == 0 { positionOnScreen() }
350+
351+
let card = AnswerCardView(text: text, onCopy: onCopy, onClose: onClose)
352+
let host = NSHostingView(rootView: card)
353+
354+
// Force SwiftUI to lay out and compute an honest intrinsic size
355+
host.layoutSubtreeIfNeeded()
356+
let fitSize = host.fittingSize
357+
let cardW: CGFloat = max(360, fitSize.width)
358+
let cardH: CGFloat = min(320, max(90, fitSize.height))
359+
host.frame = NSRect(x: 0, y: 0, width: cardW, height: cardH)
360+
361+
contentView = host
362+
answerHost = host
363+
isShowingAnswer = true
364+
ignoresMouseEvents = false
365+
366+
// Anchor bottom-center; grow upward from pill's base Y
367+
let targetFrame = NSRect(
368+
x: baseCenterX - cardW / 2,
369+
y: baseY,
370+
width: cardW,
371+
height: cardH
372+
)
373+
if !isVisible { orderFrontRegardless() }
374+
setFrame(targetFrame, display: true, animate: true)
375+
}
376+
377+
func hideAnswer() {
378+
guard isShowingAnswer else { return }
379+
isShowingAnswer = false
380+
answerHost = nil
381+
382+
drawView.frame = NSRect(x: 0, y: 0, width: kIdleW, height: kIdleH)
383+
drawView.alphaValue = 1
384+
contentView = drawView
385+
386+
let targetFrame = NSRect(
387+
x: baseCenterX - kIdleW / 2,
388+
y: baseY,
389+
width: kIdleW,
390+
height: kIdleH
391+
)
392+
setFrame(targetFrame, display: true)
393+
}
394+
332395
@objc private func screenDidChange() { positionOnScreen() }
333396

334397
private func positionOnScreen() {
@@ -341,6 +404,14 @@ final class OverlayPanel: NSPanel {
341404
? visibleFrame.minY + 8
342405
: screenFrame.minY + 12
343406

407+
if isShowingAnswer {
408+
// Re-center the card at new baseCenterX, keep its current size
409+
let w = frame.width
410+
let h = frame.height
411+
setFrame(NSRect(x: baseCenterX - w / 2, y: baseY, width: w, height: h), display: true)
412+
return
413+
}
414+
344415
let w = frame.width > kIdleW ? kActiveW : kIdleW
345416
let h = frame.height > kIdleH ? kActiveH : kIdleH
346417
setFrame(NSRect(x: baseCenterX - w / 2, y: baseY, width: w, height: h), display: true)

Wave/waveApp.swift

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,7 @@ struct WaveApp: App {
7979

8080
Button("Settings...") {
8181
appState.pendingNavSelection = .general
82-
openWindow(id: "main")
83-
NSApp.activate(ignoringOtherApps: true)
84-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
85-
NSApp.activate(ignoringOtherApps: true)
86-
}
82+
openMainWindow()
8783
}
8884
.keyboardShortcut(",", modifiers: .command)
8985

@@ -98,6 +94,27 @@ struct WaveApp: App {
9894
.renderingMode(.template)
9995
}
10096
}
97+
98+
/// Reliably shows the main window from MenuBarExtra.
99+
/// Works around SwiftUI `Window` scene + `openWindow(id:)` re-open bugs
100+
/// by finding and re-showing an existing window via AppKit first,
101+
/// falling back to `openWindow` only for the very first launch.
102+
private func openMainWindow() {
103+
// Find an existing window for the main scene (may be hidden/closed)
104+
let existing = NSApp.windows.first { window in
105+
// SwiftUI-created window titles match the Scene title; also check identifier
106+
window.title == "Wave" || window.identifier?.rawValue.contains("main") == true
107+
}
108+
109+
if let window = existing {
110+
if window.isMiniaturized { window.deminiaturize(nil) }
111+
window.makeKeyAndOrderFront(nil)
112+
} else {
113+
openWindow(id: "main")
114+
}
115+
116+
NSApp.activate(ignoringOtherApps: true)
117+
}
101118
}
102119

103120
final class AppDelegate: NSObject, NSApplicationDelegate {

0 commit comments

Comments
 (0)