Skip to content

Commit 8217562

Browse files
committed
search issue fixes
1 parent 35e0037 commit 8217562

3 files changed

Lines changed: 150 additions & 43 deletions

File tree

‎singularity/AppModel.swift‎

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ final class AppModel: NSObject, ObservableObject {
313313
@Published var ttsPartProgressLabel: String? = nil
314314
@Published var ttsElapsed: TimeInterval = 0
315315
@Published var ttsDuration: TimeInterval = 0
316-
@Published var ttsRateIndex: Int = 0 // 0:1x,1:1.5x,2:2x
316+
@Published var ttsRateIndex: Int = 0 // 0:1x,1:1.2x,2:1.5x,3:2x
317317
@Published var isTTSSynthesisPaused: Bool = false
318318
@Published var ttsApproxLine: Int? = nil
319319
@Published var ttsApproxLineCount: Int? = nil
@@ -346,7 +346,7 @@ final class AppModel: NSObject, ObservableObject {
346346
@Published var isSpeakingTTS: Bool = false
347347
private var ttsTimer: Timer? = nil
348348
private var lastTTSElapsedEmitted: TimeInterval = 0
349-
private let ttsRates: [Float] = [1.0, 1.5, 2.0]
349+
private let ttsRates: [Float] = [1.0, 1.2, 1.5, 2.0]
350350
private struct SavedTTS: Codable { let file: String; let position: Double; let rateIndex: Int }
351351
private struct TTSPart: Codable { let index: Int; let file: String; let startChar: Int; let endChar: Int; let duration: Double?; let complete: Bool; let text: String? }
352352
private struct TTSManifest: Codable { let hash: String; let model: String; let voice: String; let responseFormat: String; let totalParts: Int; var parts: [TTSPart]; var complete: Bool }
@@ -4523,18 +4523,66 @@ table{border-collapse:collapse;width:100%;} th,td{border:1px solid rgba(127,127,
45234523
func pauseTTS() { ttsPlayer?.pause(); isSpeakingTTS = false; stopTTSTimer(); flushTTSPositions() }
45244524

45254525
func resetAndPlayTTS() {
4526-
guard let url = currentTTSURLIfExists() else { toggleSpeakResponse(); return }
4527-
do {
4528-
if ttsPlayer == nil || ttsPlayer?.url != url {
4529-
ttsPlayer = try AVAudioPlayer(contentsOf: url)
4530-
ttsPlayer?.delegate = self
4531-
}
4532-
ttsPlayer?.currentTime = 0
4533-
ttsPlayer?.play()
4534-
ttsDuration = ttsPlayer?.duration ?? 0
4535-
isSpeakingTTS = true
4536-
startTTSTimer()
4537-
} catch { /* ignore */ }
4526+
let text = conversation.last(where: { $0.role == .assistant })?.text ?? resultText
4527+
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
4528+
guard !trimmed.isEmpty else { showToast = .init(message: "No response to speak"); return }
4529+
ttsSourceText = trimmed
4530+
// Always reset to the beginning: combined file if exists; else first part
4531+
let beginURL: URL? = {
4532+
if let dir = try? ttsDirectory() {
4533+
let hash = sha256Hex(trimmed)
4534+
let full = dir.appendingPathComponent("tts-\(hash).mp3")
4535+
if FileManager.default.fileExists(atPath: full.path) { return full }
4536+
let p1 = dir.appendingPathComponent("tts-\(hash)-p1.mp3")
4537+
if FileManager.default.fileExists(atPath: p1.path) { return p1 }
4538+
}
4539+
return nil
4540+
}()
4541+
// Clear saved position so we do not resume mid-way
4542+
let key = sessionKeyString()
4543+
ttsPositions[key] = SavedTTS(file: "", position: 0, rateIndex: ttsRateIndex)
4544+
saveTTSPositions()
4545+
if let url = beginURL {
4546+
do {
4547+
if ttsPlayer == nil || ttsPlayer?.url != url {
4548+
ttsPlayer = try AVAudioPlayer(contentsOf: url)
4549+
ttsPlayer?.delegate = self
4550+
ttsPlayer?.enableRate = true
4551+
applyTTSRate()
4552+
}
4553+
// Reset multi-part index to the first segment
4554+
multiTTS = buildMultiStateIfAny(startURL: url)
4555+
if var m = multiTTS { m.currentIndex = 0; multiTTS = m }
4556+
ttsPlayer?.currentTime = 0
4557+
ttsPlayer?.play()
4558+
if let m = multiTTS { ttsDuration = m.partDurations.reduce(0, +) } else { ttsDuration = ttsPlayer?.duration ?? 0 }
4559+
isSpeakingTTS = true
4560+
startTTSTimer()
4561+
} catch { /* ignore */ }
4562+
return
4563+
}
4564+
// No audio yet — generate and start from the beginning
4565+
Task.detached(priority: .userInitiated) { [weak self] in
4566+
guard let self else { return }
4567+
do {
4568+
let url = try await self.ensureTTSAudio(for: trimmed)
4569+
await MainActor.run {
4570+
do {
4571+
self.ttsPlayer = try AVAudioPlayer(contentsOf: url)
4572+
self.ttsPlayer?.delegate = self
4573+
self.ttsPlayer?.enableRate = true
4574+
self.applyTTSRate()
4575+
self.multiTTS = self.buildMultiStateIfAny(startURL: url)
4576+
if var m = self.multiTTS { m.currentIndex = 0; self.multiTTS = m }
4577+
self.ttsPlayer?.currentTime = 0
4578+
self.ttsPlayer?.play()
4579+
if let m = self.multiTTS { self.ttsDuration = m.partDurations.reduce(0, +) } else { self.ttsDuration = self.ttsPlayer?.duration ?? 0 }
4580+
self.isSpeakingTTS = true
4581+
self.startTTSTimer()
4582+
} catch { }
4583+
}
4584+
} catch { await self.logTTSError(error, extra: "resetAndPlay"); await MainActor.run { self.showToast = .init(message: "TTS failed") } }
4585+
}
45384586
}
45394587

45404588
private func ensureTTSAudio(for text: String) async throws -> URL {

‎singularity/PanelView.swift‎

Lines changed: 79 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ struct PanelView: View {
7575
// Find-in-results state
7676
@State private var showResultsFindBar: Bool = false
7777
@State private var resultsFindQuery: String = ""
78+
@State private var resultsFindQueryDebounced: String = ""
7879
@State private var resultsFindMatches: [UUID] = []
7980
@State private var resultsFindIndex: Int = 0
8081
@State private var resultsHighlightIDs: Set<UUID> = []
@@ -357,14 +358,16 @@ struct PanelView: View {
357358
showResultsFindBar.toggle()
358359
if showResultsFindBar {
359360
prevTranscriptViewMode = model.transcriptViewMode
360-
model.transcriptViewMode = 3
361-
computeResultsFindMatches()
361+
// Prefer rendered transcript for search to enable in-text highlighting
362+
model.transcriptViewMode = 1
363+
// Do not run search until Enter is pressed
364+
resultsFindQueryDebounced = ""
362365
resultsFindFocused = true
363366
}
364367
}
365368

366-
private func computeResultsFindMatches() {
367-
let q = resultsFindQuery.trimmingCharacters(in: .whitespacesAndNewlines)
369+
private func computeResultsFindMatches(for qIn: String? = nil) {
370+
let q = (qIn ?? resultsFindQueryDebounced).trimmingCharacters(in: .whitespacesAndNewlines)
368371
guard !q.isEmpty else { resultsFindMatches = []; resultsFindIndex = 0; resultsActiveID = nil; return }
369372
var ids: [UUID] = []
370373
for e in model.conversation where e.role == .assistant {
@@ -404,6 +407,10 @@ struct PanelView: View {
404407

405408
private func closeResultsFindBar() {
406409
showResultsFindBar = false
410+
// Restore prior transcript view mode after closing find, if any
411+
if let prev = prevTranscriptViewMode { model.transcriptViewMode = prev }
412+
prevTranscriptViewMode = nil
413+
resultsFindQueryDebounced = ""
407414
resultsFindQuery = ""
408415
resultsFindMatches = []
409416
resultsFindIndex = 0
@@ -643,7 +650,11 @@ struct PanelView: View {
643650
if showResultsFindBar {
644651
HStack(spacing: 8) {
645652
Image(systemName: "magnifyingglass")
646-
TextField("Find in session", text: $resultsFindQuery, onCommit: { computeResultsFindMatches(); jumpToResult(at: 0) })
653+
TextField("Find in session", text: $resultsFindQuery, onCommit: {
654+
resultsFindQueryDebounced = resultsFindQuery
655+
computeResultsFindMatches(for: resultsFindQueryDebounced)
656+
jumpToResult(at: 0)
657+
})
647658
.textFieldStyle(.roundedBorder)
648659
.frame(width: 200)
649660
.focused($resultsFindFocused)
@@ -712,7 +723,7 @@ struct PanelView: View {
712723
model: model,
713724
highlightIDs: resultsHighlightIDs,
714725
activeID: resultsActiveID,
715-
searchQuery: (showResultsFindBar && !resultsFindQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) ? resultsFindQuery : nil,
726+
searchQuery: (showResultsFindBar && !resultsFindQueryDebounced.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) ? resultsFindQueryDebounced : nil,
716727
onConfirmPlugin: { task in confirmPluginTask(task) },
717728
onCancelPlugin: { task in
718729
model.updatePluginTask(id: task.id, status: .canceled)
@@ -724,20 +735,39 @@ struct PanelView: View {
724735
}
725736
)
726737
} else if model.transcriptViewMode == 2 {
727-
Text(model.transcriptRawAssistantMarkdown())
728-
.textSelection(.enabled)
729-
.font(.system(size: model.uiMonoPointSize, design: .monospaced))
730-
.multilineTextAlignment(.leading)
731-
.fixedSize(horizontal: false, vertical: true)
732-
.padding(.horizontal, 8)
738+
let raw = model.transcriptRawAssistantMarkdown()
739+
if showResultsFindBar && !resultsFindQueryDebounced.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
740+
Text(PanelView.highlightedAll(text: raw, query: resultsFindQueryDebounced))
741+
.textSelection(.enabled)
742+
.font(.system(size: model.uiMonoPointSize, design: .monospaced))
743+
.multilineTextAlignment(.leading)
744+
.fixedSize(horizontal: false, vertical: true)
745+
.padding(.horizontal, 8)
746+
} else {
747+
Text(raw)
748+
.textSelection(.enabled)
749+
.font(.system(size: model.uiMonoPointSize, design: .monospaced))
750+
.multilineTextAlignment(.leading)
751+
.fixedSize(horizontal: false, vertical: true)
752+
.padding(.horizontal, 8)
753+
}
733754
} else {
734-
// Plain text transcript (user + assistant), single block for multi-line selection
735-
Text(model.transcriptPlainTextAll())
736-
.textSelection(.enabled)
737-
.font(.system(size: model.uiBodyPointSize))
738-
.multilineTextAlignment(.leading)
739-
.fixedSize(horizontal: false, vertical: true)
740-
.padding(.horizontal, 8)
755+
let plain = model.transcriptPlainTextAll()
756+
if showResultsFindBar && !resultsFindQueryDebounced.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
757+
Text(PanelView.highlightedAll(text: plain, query: resultsFindQueryDebounced))
758+
.textSelection(.enabled)
759+
.font(.system(size: model.uiBodyPointSize))
760+
.multilineTextAlignment(.leading)
761+
.fixedSize(horizontal: false, vertical: true)
762+
.padding(.horizontal, 8)
763+
} else {
764+
Text(plain)
765+
.textSelection(.enabled)
766+
.font(.system(size: model.uiBodyPointSize))
767+
.multilineTextAlignment(.leading)
768+
.fixedSize(horizontal: false, vertical: true)
769+
.padding(.horizontal, 8)
770+
}
741771
}
742772
}
743773
.frame(maxWidth: .infinity, alignment: .leading)
@@ -2598,7 +2628,7 @@ private extension PanelView {
25982628
markdown: item.assistant.text,
25992629
scale: model.uiTextScaleFactor,
26002630
onCopy: { model.showToast = .init(message: "Copied") },
2601-
highlightQuery: nil,
2631+
highlightQuery: searchQuery,
26022632
currentLine: (item.assistant.id == lastAssistantId ? model.ttsApproxLine : nil)
26032633
)
26042634
}
@@ -3242,7 +3272,7 @@ private extension PanelView {
32423272
}(), systemImage: "keyboard")
32433273
Label({
32443274
let s = KeyboardLayoutDisplay.combo(cmd: true, shift: true, keyCode: UInt16(kVK_ANSI_Semicolon))
3245-
return "Speed 1x/1.5x/2x — " + s
3275+
return "Speed 1x/1.2x/1.5x/2x — " + s
32463276
}(), systemImage: "keyboard")
32473277
Label("Stop TTS — ⌘⎋", systemImage: "keyboard")
32483278
}
@@ -3750,7 +3780,7 @@ private struct TTSControlsView: View, Equatable {
37503780
}
37513781
}.buttonStyle(.plain).help(tooltipsEnabled ? "Play/Pause TTS" : "")
37523782
Button(action: onForward15) { Image(systemName: "goforward.15") }.buttonStyle(.plain).help(tooltipsEnabled ? "Forward 15s (⇧⌘.)" : "")
3753-
Button(action: onToggleRate) { Text(rateIndex == 0 ? "1x" : (rateIndex == 1 ? "1.5x" : "2x")) }.buttonStyle(.plain).help(tooltipsEnabled ? "Toggle speed (⇧⌘;)" : "")
3783+
Button(action: onToggleRate) { Text(rateIndex == 0 ? "1x" : (rateIndex == 1 ? "1.2x" : (rateIndex == 2 ? "1.5x" : "2x"))) }.buttonStyle(.plain).help(tooltipsEnabled ? "Toggle speed (⇧⌘;)" : "")
37543784
if progressActive {
37553785
HStack(spacing: 4) {
37563786
ProgressView().controlSize(.small).scaleEffect(0.75).frame(width: 14, height: 14)
@@ -3783,7 +3813,12 @@ private struct MarkdownContainer: View, Equatable {
37833813
var onCopy: () -> Void
37843814
var highlightQuery: String? = nil
37853815
var currentLine: Int? = nil
3786-
static func == (l: MarkdownContainer, r: MarkdownContainer) -> Bool { l.markdown == r.markdown && l.scale == r.scale && l.currentLine == r.currentLine }
3816+
static func == (l: MarkdownContainer, r: MarkdownContainer) -> Bool {
3817+
l.markdown == r.markdown &&
3818+
l.scale == r.scale &&
3819+
l.currentLine == r.currentLine &&
3820+
l.highlightQuery == r.highlightQuery
3821+
}
37873822
var body: some View {
37883823
let hasQuery = (highlightQuery?.isEmpty == false)
37893824
let sanitized = sanitizeMarkdown(markdown)
@@ -3814,8 +3849,7 @@ private struct MarkdownContainer: View, Equatable {
38143849
if idx >= 0 && idx < lines.count {
38153850
let s = lines[idx]
38163851
if !s.trimmingCharacters(in: .whitespaces).isEmpty {
3817-
// Add a clear audible marker and bold for visibility
3818-
lines[idx] = "🔊 **" + s + "**"
3852+
lines[idx] = "🔊 _" + s + "_"
38193853
base = lines.joined(separator: "\n")
38203854
}
38213855
}
@@ -4064,6 +4098,26 @@ extension PanelView {
40644098
}
40654099
return attr
40664100
}
4101+
4102+
static func highlightedAll(text: String, query: String) -> AttributedString {
4103+
if query.isEmpty { return AttributedString(text) }
4104+
var attr = AttributedString(text)
4105+
let lower = text.lowercased()
4106+
let ql = query.lowercased()
4107+
var searchStart = lower.startIndex
4108+
while let r = lower.range(of: ql, options: [.caseInsensitive, .diacriticInsensitive], range: searchStart..<lower.endIndex) {
4109+
let start = lower.distance(from: lower.startIndex, to: r.lowerBound)
4110+
let length = lower.distance(from: r.lowerBound, to: r.upperBound)
4111+
let cappedStart = max(0, min(start, text.count))
4112+
let cappedEnd = max(cappedStart, min(start + length, text.count))
4113+
let s = attr.index(attr.startIndex, offsetByCharacters: cappedStart)
4114+
let e = attr.index(attr.startIndex, offsetByCharacters: cappedEnd)
4115+
attr[s..<e].backgroundColor = .yellow.opacity(0.35)
4116+
attr[s..<e].foregroundColor = .primary
4117+
searchStart = r.upperBound
4118+
}
4119+
return attr
4120+
}
40674121
private var settingsPopover: some View {
40684122
VStack(spacing: 0) {
40694123
WindowCaptureOptOut(allowCapture: model.allowScreenCapture)

‎singularity/RichMarkdownView.swift‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,15 @@ struct RichMarkdownView: View {
2424
.padding(8)
2525
.fixedSize(horizontal: false, vertical: true)
2626
.if(highlightEnabled) { view in
27-
view.markdownTextStyle(\.strong) { // highlight **match** only when enabled
28-
BackgroundColor(Color.yellow.opacity(0.4))
29-
ForegroundColor(.primary)
30-
}
27+
view
28+
.markdownTextStyle(\.strong) {
29+
BackgroundColor(Color.yellow.opacity(0.75))
30+
ForegroundColor(.yellow)
31+
}
32+
.markdownTextStyle(\.emphasis) {
33+
BackgroundColor(Color.blue.opacity(0.65))
34+
ForegroundColor(.primary)
35+
}
3136
}
3237
.markdownTextStyle(\.text) { FontSize(.em(textScale)) }
3338
.transaction { txn in txn.disablesAnimations = true }

0 commit comments

Comments
 (0)