diff --git a/README.md b/README.md index d63a0e0..2aa0a29 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,8 @@ Text workflow: 2. Press `Option+Space`. 3. Fuzzy-search for a prompt mode (for example, `ts` can match `To Chinese Summary`), use `Up` / `Down` to highlight it, then press `Tab` or `Enter` to commit the mode. 4. Type or paste rough text. -5. Press `Enter` to transform it. -6. Press `Enter` again to insert the result. +5. Press `Enter` to transform it. The result appears as it is generated; press `Escape` to stop. +6. Press `Enter` again to insert the result, or press `Shift+Command+C` (or click the copy button on the result) to copy it instead. Dictation workflow: @@ -74,7 +74,7 @@ The Dictation shortcut is source-local and hold-only. You can change its modifie - To Simple and Correct English - To Chinese Summary - Voice Cleanup -- Inserts generated text back into the previously focused app. +- Streams the result into the popover as it is generated, then inserts it back into the previously focused app or copies it with the result's copy button. - Uses one application-agnostic, Accessibility-first selection path. Automatic Selection Actions first ask macOS Accessibility for the selection, then use the configured temporary clipboard fallback only when Force Selection permits it. Each read stays bound to the captured source process and cancels if that process exits or loses focus. - Keeps simulated `Command+C` off by default. Menu Copy remains the safe Force Selection fallback; you can explicitly enable simulated copy as an advanced fallback for apps without a usable Copy menu, but it may interfere with games, remote desktops, or virtual machines. - Serializes temporary clipboard reads and restores the prior snapshot only while the same read still owns the observed copy result; newer clipboard contents win. The double-copy trigger is passive: it consumes the copy the user already made without issuing another synthetic copy or restoring older clipboard data. Right-click remains native and never starts a selection read. @@ -141,8 +141,9 @@ Check all UI languages with `scripts/check-localization.sh`. Add `--snapshots` t - `Tab`, `Return`, or keypad `Enter` in the mode launcher: commit the highlighted prompt mode and focus the source editor. Return remains available to confirm text or an IME candidate during active composition; Return with Command, Shift, Option, or Control does not commit a mode. - `Enter` in the editor: transform the source text, insert a result generated by the current mode, or regenerate a stale result from a previous mode with the newly committed mode. - `Command+Enter`: insert the original text without calling the model. +- `Shift+Command+C`: copy the visible result without closing the popover. - `Command+Up` / `Command+Down`: cycle through visible prompt modes. -- `Escape`: move back one level per press, from the result to the source editor, then to the mode launcher, then close the popover. While transforming, it cancels generation and stays in the editor. +- `Escape`: move back one level per press, from the result to the source editor, then to the mode launcher, then close the popover. While transforming, it stops generation, discards the partial result, and stays in the editor. - `Command+,`: open Settings while Inklet is active. Search is case- and diacritic-insensitive and supports ordered-character matching. Exact and prefix matches receive the strongest boosts; consecutive, word-start, earlier, and tighter matches generally rank higher. diff --git a/README.zh-CN.md b/README.zh-CN.md index 3960878..ef5f9f2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -48,8 +48,8 @@ Inklet 仅通过 GitHub Releases 检查更新。正式版大约每 24 小时从 2. 按 `Option+Space`。 3. 用模糊搜索查找 Prompt 模式(例如 `ts` 可以匹配 `To Chinese Summary`),用 `↑` / `↓` 高亮,然后按 `Tab` 或 `Enter` 确认。 4. 输入或粘贴一段草稿。 -5. 按 `Enter` 让 Inklet 处理文本。 -6. 再按一次 `Enter` 插入结果。 +5. 按 `Enter` 让 Inklet 处理文本。结果会边生成边显示;按 `Escape` 可停止。 +6. 再按一次 `Enter` 插入结果,或按 `Shift+Command+C`(也可点击结果上的复制按钮)改为复制。 听写流程: @@ -74,7 +74,7 @@ Inklet 仅通过 GitHub Releases 检查更新。正式版大约每 24 小时从 - To Simple and Correct English - To Chinese Summary - Voice Cleanup -- 把生成结果插回之前聚焦的应用。 +- 结果边生成边显示在浮窗中,完成后可插回之前聚焦的应用,或用结果上的复制按钮复制。 - 使用与应用无关、Accessibility 优先的通用选区流程。自动选区动作先通过 macOS Accessibility 读取;只有强制取词设置允许时,才进入按设置启用的临时剪贴板备用读取。每次读取都绑定到捕获的来源进程;来源退出或失去前台焦点时会取消。 - 默认关闭模拟 `Command+C`。菜单复制仍是安全的强制取词备用方式;对于没有可用复制菜单的 App,可以显式开启模拟复制这一高级备用选项,但它可能干扰游戏、远程桌面或虚拟机。 - 临时剪贴板读取会串行执行。只有同一次读取仍持有已观察到的复制结果时,Inklet 才恢复之前的快照;较新的剪贴板内容优先。双击复制触发是被动流程:它只读取用户已经完成的复制,不会再发一次合成复制,也不会恢复更旧的剪贴板数据。右键点击保留原生行为,不会开始选区读取。 @@ -141,8 +141,9 @@ swift test - 模式启动器中的 `Tab`、`Return` 或小键盘 `Enter`:确认高亮的 Prompt 模式并聚焦源文本编辑器。输入法正在组合文字时,Return 仍用于确认文字或候选项;配合 Command、Shift、Option 或 Control 的 Return 不会确认模式。 - 编辑器中的 `Enter`:处理源文本;插入由当前模式生成的结果;如果可见结果由之前的模式生成,则用刚确认的新模式重新生成。 - `Command+Enter`:不调用模型,直接插入原文。 +- `Shift+Command+C`:复制当前结果,浮窗保持打开。 - `Command+Up` / `Command+Down`:切换可见 prompt modes。 -- `Escape`:每按一次只返回一层,依次从结果回到源文本编辑器、模式启动器,再关闭 popover。处理文本期间按下会取消生成并停留在编辑器。 +- `Escape`:每按一次只返回一层,依次从结果回到源文本编辑器、模式启动器,再关闭 popover。处理文本期间按下会停止生成、丢弃未完成的结果,并停留在编辑器。 - `Command+,`:Inklet 激活时打开 Settings。 搜索不区分大小写和变音符号,并支持按顺序匹配字符。完全匹配和前缀匹配获得最高加权;连续、词首、更靠前及间隔更紧密的匹配通常排名更高。 diff --git a/Sources/InkletApp/InkletLocalization.swift b/Sources/InkletApp/InkletLocalization.swift index 7c44617..3d9507d 100644 --- a/Sources/InkletApp/InkletLocalization.swift +++ b/Sources/InkletApp/InkletLocalization.swift @@ -419,13 +419,16 @@ enum L10n { "popover.action.regenerate": "Regenerate", "popover.action.insert": "Insert", "popover.action.insertOriginal": "Insert Original", + "popover.action.copyResult": "Copy result", + "popover.action.copyResultHelp": "Copy result (⌘⇧C)", + "popover.hint.stop": "Stop", "popover.hint.transformInsert": "Transform/Insert", "popover.hint.original": "Original", "popover.hint.newLine": "New Line", "popover.hint.mode": "Mode", "popover.hint.back": "Back", "popover.hint.close": "Close", - "popover.hint.accessibility": "Shortcuts: Enter transforms or inserts, Command Enter inserts original text, Escape goes back or closes", + "popover.hint.accessibility": "Shortcuts: Enter transforms or inserts, Command Enter inserts original text, Command Shift C copies the result, Escape goes back or closes", "popover.busy.inserting": "Inserting...", "popover.busy.transforming": "Transforming...", "popover.error.emptyOriginal": "Enter text to insert.", @@ -734,13 +737,16 @@ enum L10n { "popover.action.regenerate": "重新生成", "popover.action.insert": "插入", "popover.action.insertOriginal": "插入原文", + "popover.action.copyResult": "复制结果", + "popover.action.copyResultHelp": "复制结果 (⌘⇧C)", + "popover.hint.stop": "停止", "popover.hint.transformInsert": "转换/插入", "popover.hint.original": "原文", "popover.hint.newLine": "换行", "popover.hint.mode": "模式", "popover.hint.back": "返回", "popover.hint.close": "关闭", - "popover.hint.accessibility": "快捷键:Enter 转换或插入,Command Enter 插入原文,Escape 返回或关闭", + "popover.hint.accessibility": "快捷键:Enter 转换或插入,Command Enter 插入原文,Command Shift C 复制结果,Escape 返回或关闭", "popover.busy.inserting": "正在插入...", "popover.busy.transforming": "正在转换...", "popover.error.emptyOriginal": "请输入要插入的文本。", @@ -967,9 +973,12 @@ enum L10n { "popover.action.regenerate": "重新產生", "popover.action.insert": "插入", "popover.action.insertOriginal": "插入原文", + "popover.action.copyResult": "複製結果", + "popover.action.copyResultHelp": "複製結果 (⌘⇧C)", + "popover.hint.stop": "停止", "popover.hint.transformInsert": "轉換/插入", "popover.hint.original": "原文", - "popover.hint.accessibility": "快捷鍵:Enter 轉換或插入,Command Enter 插入原文,Escape 返回或關閉", + "popover.hint.accessibility": "快捷鍵:Enter 轉換或插入,Command Enter 插入原文,Command Shift C 複製結果,Escape 返回或關閉", "popover.busy.inserting": "正在插入...", "popover.busy.transforming": "正在轉換...", "popover.error.emptyOriginal": "請輸入要插入的文字。", @@ -1282,9 +1291,12 @@ enum L10n { "popover.action.regenerate": "再生成", "popover.action.insert": "挿入", "popover.action.insertOriginal": "原文を挿入", + "popover.action.copyResult": "結果をコピー", + "popover.action.copyResultHelp": "結果をコピー (⌘⇧C)", + "popover.hint.stop": "停止", "popover.hint.transformInsert": "変換/挿入", "popover.hint.original": "原文", - "popover.hint.accessibility": "ショートカット: Enter で変換または挿入、Command Enter で原文を挿入、Escape で戻るまたは閉じる", + "popover.hint.accessibility": "ショートカット: Enter で変換または挿入、Command Enter で原文を挿入、Command Shift C で結果をコピー、Escape で戻るまたは閉じる", "popover.busy.inserting": "挿入中...", "popover.busy.transforming": "変換中...", "popover.error.emptyOriginal": "挿入するテキストを入力してください。", @@ -1597,9 +1609,12 @@ enum L10n { "popover.action.regenerate": "다시 생성", "popover.action.insert": "삽입", "popover.action.insertOriginal": "원문 삽입", + "popover.action.copyResult": "결과 복사", + "popover.action.copyResultHelp": "결과 복사 (⌘⇧C)", + "popover.hint.stop": "중지", "popover.hint.transformInsert": "변환/삽입", "popover.hint.original": "원문", - "popover.hint.accessibility": "단축키: Enter 변환 또는 삽입, Command Enter 원문 삽입, Escape 뒤로 또는 닫기", + "popover.hint.accessibility": "단축키: Enter 변환 또는 삽입, Command Enter 원문 삽입, Command Shift C 결과 복사, Escape 뒤로 또는 닫기", "popover.busy.inserting": "삽입 중...", "popover.busy.transforming": "변환 중...", "popover.error.emptyOriginal": "삽입할 텍스트를 입력하세요.", @@ -1912,9 +1927,12 @@ enum L10n { "popover.action.regenerate": "Regenerar", "popover.action.insert": "Insertar", "popover.action.insertOriginal": "Insertar original", + "popover.action.copyResult": "Copiar resultado", + "popover.action.copyResultHelp": "Copiar resultado (⌘⇧C)", + "popover.hint.stop": "Detener", "popover.hint.transformInsert": "Transformar/Insertar", "popover.hint.original": "Original", - "popover.hint.accessibility": "Atajos: Enter transforma o inserta, Command Enter inserta el original, Escape vuelve o cierra", + "popover.hint.accessibility": "Atajos: Enter transforma o inserta, Command Enter inserta el original, Command Shift C copia el resultado, Escape vuelve o cierra", "popover.busy.inserting": "Insertando...", "popover.busy.transforming": "Transformando...", "popover.error.emptyOriginal": "Escribe texto para insertar.", @@ -2227,9 +2245,12 @@ enum L10n { "popover.action.regenerate": "Régénérer", "popover.action.insert": "Insérer", "popover.action.insertOriginal": "Insérer l’original", + "popover.action.copyResult": "Copier le résultat", + "popover.action.copyResultHelp": "Copier le résultat (⌘⇧C)", + "popover.hint.stop": "Arrêter", "popover.hint.transformInsert": "Transformer/Insérer", "popover.hint.original": "Original", - "popover.hint.accessibility": "Raccourcis : Enter transforme ou insère, Command Enter insère l’original, Escape revient ou ferme", + "popover.hint.accessibility": "Raccourcis : Enter transforme ou insère, Command Enter insère l’original, Command Shift C copie le résultat, Escape revient ou ferme", "popover.busy.inserting": "Insertion...", "popover.busy.transforming": "Transformation...", "popover.error.emptyOriginal": "Saisissez le texte à insérer.", @@ -2542,9 +2563,12 @@ enum L10n { "popover.action.regenerate": "Neu generieren", "popover.action.insert": "Einfügen", "popover.action.insertOriginal": "Original einfügen", + "popover.action.copyResult": "Ergebnis kopieren", + "popover.action.copyResultHelp": "Ergebnis kopieren (⌘⇧C)", + "popover.hint.stop": "Stoppen", "popover.hint.transformInsert": "Umwandeln/Einfügen", "popover.hint.original": "Original", - "popover.hint.accessibility": "Kürzel: Enter wandelt um oder fügt ein, Command Enter fügt Original ein, Escape zurück oder schließen", + "popover.hint.accessibility": "Kürzel: Enter wandelt um oder fügt ein, Command Enter fügt Original ein, Command Shift C kopiert das Ergebnis, Escape zurück oder schließen", "popover.busy.inserting": "Einfügen...", "popover.busy.transforming": "Umwandeln...", "popover.error.emptyOriginal": "Text zum Einfügen eingeben.", @@ -2857,9 +2881,12 @@ enum L10n { "popover.action.regenerate": "Gerar novamente", "popover.action.insert": "Inserir", "popover.action.insertOriginal": "Inserir original", + "popover.action.copyResult": "Copiar resultado", + "popover.action.copyResultHelp": "Copiar resultado (⌘⇧C)", + "popover.hint.stop": "Parar", "popover.hint.transformInsert": "Transformar/Inserir", "popover.hint.original": "Original", - "popover.hint.accessibility": "Atalhos: Enter transforma ou insere, Command Enter insere o original, Escape volta ou fecha", + "popover.hint.accessibility": "Atalhos: Enter transforma ou insere, Command Enter insere o original, Command Shift C copia o resultado, Escape volta ou fecha", "popover.busy.inserting": "Inserindo...", "popover.busy.transforming": "Transformando...", "popover.error.emptyOriginal": "Digite texto para inserir.", @@ -3172,9 +3199,12 @@ enum L10n { "popover.action.regenerate": "Rigenera", "popover.action.insert": "Inserisci", "popover.action.insertOriginal": "Inserisci originale", + "popover.action.copyResult": "Copia risultato", + "popover.action.copyResultHelp": "Copia risultato (⌘⇧C)", + "popover.hint.stop": "Interrompi", "popover.hint.transformInsert": "Trasforma/Inserisci", "popover.hint.original": "Originale", - "popover.hint.accessibility": "Scorciatoie: Enter trasforma o inserisce, Command Enter inserisce l’originale, Escape torna indietro o chiude", + "popover.hint.accessibility": "Scorciatoie: Enter trasforma o inserisce, Command Enter inserisce l’originale, Command Shift C copia il risultato, Escape torna indietro o chiude", "popover.busy.inserting": "Inserimento...", "popover.busy.transforming": "Trasformazione...", "popover.error.emptyOriginal": "Inserisci testo da inserire.", diff --git a/Sources/InkletApp/InkletPopoverView.swift b/Sources/InkletApp/InkletPopoverView.swift index 6e3c871..c072f9d 100644 --- a/Sources/InkletApp/InkletPopoverView.swift +++ b/Sources/InkletApp/InkletPopoverView.swift @@ -35,6 +35,8 @@ struct InkletPopoverView: View { private let actionBarHeight: CGFloat = 36 private let dividerHeight: CGFloat = 1 private let staleResultBannerHeight: CGFloat = 24 + private let resultCopyButtonInset: CGFloat = 30 + private let streamingResultEndID = "streamingResultEnd" private var isBusy: Bool { model.isBusy } @@ -56,6 +58,15 @@ struct InkletPopoverView: View { model.isInserting ? L10n.text("popover.busy.inserting") : L10n.text("popover.busy.transforming") } + private var isStreamingResult: Bool { + model.isTransforming && !model.streamingResultText.isEmpty + } + + /// The streamed partial output while generating, otherwise the editable result. + private var displayedResultText: String { + isStreamingResult ? model.streamingResultText : model.resultText + } + private var modeIconName: String { writingModeIconName(for: model.selectedModeID) } @@ -82,7 +93,7 @@ struct InkletPopoverView: View { headerHeight + dividerHeight + inputHeight - + (model.resultText.isEmpty ? 0 : dividerHeight + resultPanelHeight) + + (displayedResultText.isEmpty ? 0 : dividerHeight + resultPanelHeight) + (model.errorMessage == nil ? 0 : dividerHeight + min(statusMeasuredHeight, 120)) + dividerHeight + max(actionBarHeight, actionBarMeasuredHeight) @@ -98,7 +109,7 @@ struct InkletPopoverView: View { private var resultHeight: CGFloat { editorHeight( - for: model.resultText, + for: displayedResultText, measuredHeight: resultMeasuredHeight, maxRows: maxResultEditorRows ) @@ -109,7 +120,7 @@ struct InkletPopoverView: View { } private var showsStaleResultBanner: Bool { - model.isResultStale && model.resultModeDisplayName != nil + !isStreamingResult && model.isResultStale && model.resultModeDisplayName != nil } var body: some View { @@ -126,6 +137,7 @@ struct InkletPopoverView: View { route: model.route, onSubmit: { model.submit() }, onInsertOriginal: { model.insertOriginal() }, + onCopyResult: { model.copyResult() }, onEscape: { model.escape() }, onCycleMode: { model.cyclePromptMode(direction: $0) }, onMoveModeHighlight: { model.moveModeHighlight(by: $0) }, @@ -194,10 +206,10 @@ struct InkletPopoverView: View { @ViewBuilder private var resultPanel: some View { - if !model.resultText.isEmpty { + if !displayedResultText.isEmpty { Divider().opacity(0.45) VStack(spacing: 0) { - if model.isResultStale, let resultModeDisplayName = model.resultModeDisplayName { + if showsStaleResultBanner, let resultModeDisplayName = model.resultModeDisplayName { HStack(spacing: 6) { Image(systemName: "clock.arrow.circlepath") .font(.system(size: 10, weight: .medium)) @@ -215,25 +227,36 @@ struct InkletPopoverView: View { } ZStack(alignment: .topTrailing) { - InkletTextView( - text: Binding( - get: { model.resultText }, - set: { model.updateResultText($0) } - ), - isEditable: !isBusy, - onSubmit: { model.submit() }, - onInsertOriginal: { model.insertOriginal() }, - onEscape: { model.escape() }, - onTextViewAttachment: nil - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(InkletTheme.primary.opacity(0.08)) + if isStreamingResult { + streamingResultView + } else { + InkletTextView( + text: Binding( + get: { model.resultText }, + set: { model.updateResultText($0) } + ), + isEditable: !isBusy, + onSubmit: { model.submit() }, + onInsertOriginal: { model.insertOriginal() }, + onEscape: { model.escape() }, + onTextViewAttachment: nil + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.leading, 16) + .padding(.trailing, 16 + resultCopyButtonInset) + .padding(.vertical, 12) + + copyResultButton + } } + .background(InkletTheme.primary.opacity(0.08)) .frame(height: resultHeight) .background { - editorHeightReader(for: model.resultText, key: ResultEditorHeightPreferenceKey.self) + editorHeightReader( + for: displayedResultText, + trailingInset: resultCopyButtonInset, + key: ResultEditorHeightPreferenceKey.self + ) } .onPreferenceChange(ResultEditorHeightPreferenceKey.self) { height in resultMeasuredHeight = height @@ -249,6 +272,47 @@ struct InkletPopoverView: View { } } + private var streamingResultView: some View { + ScrollViewReader { proxy in + ScrollView(.vertical) { + VStack(spacing: 0) { + Text(model.streamingResultText) + .font(.system(size: 14)) + .foregroundStyle(InkletTheme.textPrimary) + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.leading, 16) + .padding(.trailing, 16 + resultCopyButtonInset) + .padding(.vertical, 12) + Color.clear + .frame(height: 0) + .id(streamingResultEndID) + } + } + .scrollIndicators(.never) + .onChange(of: model.streamingResultText) { + proxy.scrollTo(streamingResultEndID, anchor: .bottom) + } + } + } + + private var copyResultButton: some View { + Button { + model.copyResult() + } label: { + Image(systemName: model.isResultCopied ? "checkmark" : "doc.on.doc") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(InkletTheme.textSecondary) + .frame(width: 26, height: 26) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isBusy) + .padding(.top, 7) + .padding(.trailing, 10) + .help(L10n.text("popover.action.copyResultHelp")) + .accessibilityLabel(L10n.text("popover.action.copyResult")) + } + @ViewBuilder private var statusStrip: some View { if let errorMessage = model.errorMessage { @@ -424,6 +488,11 @@ struct InkletPopoverView: View { .font(.system(size: 11)) .foregroundStyle(InkletTheme.textSecondary) Spacer() + if model.isTransforming { + shortcutHint(keys: ["esc"], label: L10n.text("popover.hint.stop")) { + model.escape() + } + } } .padding(.horizontal, 4) } @@ -506,13 +575,15 @@ struct InkletPopoverView: View { private func editorHeightReader( for text: String, + trailingInset: CGFloat = 0, key: Key.Type ) -> some View where Key.Value == CGFloat { Text(text.isEmpty ? " \n " : text) .font(.system(size: 14)) .lineSpacing(3) .fixedSize(horizontal: false, vertical: true) - .padding(.horizontal, 16) + .padding(.leading, 16) + .padding(.trailing, 16 + trailingInset) .padding(.vertical, 12) .frame(maxWidth: .infinity, alignment: .topLeading) .hidden() diff --git a/Sources/InkletApp/InkletPopoverViewModel.swift b/Sources/InkletApp/InkletPopoverViewModel.swift index fc800c5..afd3166 100644 --- a/Sources/InkletApp/InkletPopoverViewModel.swift +++ b/Sources/InkletApp/InkletPopoverViewModel.swift @@ -10,6 +10,8 @@ final class InkletPopoverViewModel: ObservableObject { @Published var errorMessage: String? @Published var isTransforming = false @Published var isInserting = false + @Published private(set) var streamingResultText = "" + @Published private(set) var isResultCopied = false @Published private(set) var modePickerState: WritingModePickerState @Published private(set) var popoverSession: WritingPopoverSessionState @Published private(set) var modeSearchFocusRevision = 0 @@ -108,10 +110,13 @@ final class InkletPopoverViewModel: ObservableObject { private let transformationServiceFactory: (any LLMProvider) -> TransformationService private let historyStore: any HistoryStore private let writingModePreferenceStore: WritingModePreferenceStore + private let resultPasteboard: NSPasteboard private var config: AppConfig private var previousApplication: NSRunningApplication? private var transformationTask: Task? + private var transformationGeneration = 0 private var insertionTask: Task? + private var resultCopyFeedbackTask: Task? private var sessionID = 0 private var draftSourceText = "" private var hasTransformedInSession = false @@ -128,7 +133,8 @@ final class InkletPopoverViewModel: ObservableObject { transformationServiceFactory: @escaping (any LLMProvider) -> TransformationService = { TransformationService(provider: $0) }, insertionService: InsertionService = InsertionService(), historyStore: any HistoryStore = JSONLHistoryStore(), - writingModePreferenceStore: WritingModePreferenceStore = WritingModePreferenceStore() + writingModePreferenceStore: WritingModePreferenceStore = WritingModePreferenceStore(), + resultPasteboard: NSPasteboard = .general ) { self.stateMachine = stateMachine self.configStore = configStore @@ -137,6 +143,7 @@ final class InkletPopoverViewModel: ObservableObject { self.insertionService = insertionService self.historyStore = historyStore self.writingModePreferenceStore = writingModePreferenceStore + self.resultPasteboard = resultPasteboard let loadedConfig = (try? configStore.load()) ?? AppConfig.defaultConfig() let selectedModeID = Self.resolvedModeID( @@ -198,6 +205,8 @@ final class InkletPopoverViewModel: ObservableObject { refreshVoiceShortcutHint() sourceText = draftSourceText resultText = "" + streamingResultText = "" + resetResultCopyFeedback() errorMessage = nil isTransforming = false isInserting = false @@ -226,6 +235,7 @@ final class InkletPopoverViewModel: ObservableObject { insertionTask = nil isTransforming = false isInserting = false + streamingResultText = "" } private func refreshVoiceShortcutHint() { @@ -589,6 +599,10 @@ final class InkletPopoverViewModel: ObservableObject { } errorMessage = nil isTransforming = true + streamingResultText = "" + resetResultCopyFeedback() + transformationGeneration += 1 + let transformationGeneration = self.transformationGeneration let resolvedModeID = Self.resolvedModeID( preferredModeID: selectedModeID, @@ -612,6 +626,13 @@ final class InkletPopoverViewModel: ObservableObject { ).loadAPIKey() } let transformationService = transformationServiceFactory(provider) + let onPartialOutput: @Sendable (String) -> Void = { [weak self] partialOutput in + DispatchQueue.main.async { + MainActor.assumeIsolated { + self?.showStreamingResult(partialOutput, generation: transformationGeneration) + } + } + } transformationTask = Task { [weak self] in guard let self else { return } @@ -620,7 +641,8 @@ final class InkletPopoverViewModel: ObservableObject { sourceText: source, mode: mode, model: model, - timeoutSeconds: timeoutSeconds + timeoutSeconds: timeoutSeconds, + onPartialOutput: onPartialOutput ) guard !Task.isCancelled else { return } try? historyStore.append(HistoryItem( @@ -637,12 +659,14 @@ final class InkletPopoverViewModel: ObservableObject { )) transformationTask = nil isTransforming = false + streamingResultText = "" mutatePopoverSession { $0.recordResult(modeID: transformationModeID) } handle(actions: stateMachine.send(.transformationSucceeded(result: result.outputText))) } catch { guard !Task.isCancelled else { return } transformationTask = nil isTransforming = false + streamingResultText = "" if !preservesExistingResult { resultText = "" mutatePopoverSession { $0.clearResult() } @@ -659,6 +683,7 @@ final class InkletPopoverViewModel: ObservableObject { transformationTask?.cancel() transformationTask = nil isTransforming = false + streamingResultText = "" errorMessage = nil mutatePopoverSession { $0.enterEditor(modeID: selectedModeID) } synchronizeStateMachineWithVisibleContent() @@ -671,6 +696,36 @@ final class InkletPopoverViewModel: ObservableObject { stateMachine = PopoverStateMachine(state: visibleState) } + private func showStreamingResult(_ partialOutput: String, generation: Int) { + guard isTransforming, generation == transformationGeneration else { + return + } + streamingResultText = partialOutput + } + + func copyResult() { + guard !isBusy, !resultText.isEmpty else { + return + } + + resultPasteboard.clearContents() + resultPasteboard.setString(resultText, forType: .string) + isResultCopied = true + resultCopyFeedbackTask?.cancel() + resultCopyFeedbackTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(900)) + guard !Task.isCancelled else { return } + self?.isResultCopied = false + self?.resultCopyFeedbackTask = nil + } + } + + private func resetResultCopyFeedback() { + resultCopyFeedbackTask?.cancel() + resultCopyFeedbackTask = nil + isResultCopied = false + } + private func mutateModePickerState(_ mutation: (inout WritingModePickerState) -> Void) { var updatedState = modePickerState mutation(&updatedState) diff --git a/Sources/InkletApp/InkletTextView.swift b/Sources/InkletApp/InkletTextView.swift index d599c05..d3d7ef7 100644 --- a/Sources/InkletApp/InkletTextView.swift +++ b/Sources/InkletApp/InkletTextView.swift @@ -291,6 +291,7 @@ struct PopoverKeyEventHandler: NSViewRepresentable { let route: WritingPopoverSessionState.Route let onSubmit: () -> Void let onInsertOriginal: () -> Void + let onCopyResult: () -> Void let onEscape: () -> Void let onCycleMode: (Int) -> Void let onMoveModeHighlight: (Int) -> Void @@ -306,6 +307,7 @@ struct PopoverKeyEventHandler: NSViewRepresentable { context.coordinator.route = route context.coordinator.onSubmit = onSubmit context.coordinator.onInsertOriginal = onInsertOriginal + context.coordinator.onCopyResult = onCopyResult context.coordinator.onEscape = onEscape context.coordinator.onCycleMode = onCycleMode context.coordinator.onMoveModeHighlight = onMoveModeHighlight @@ -322,6 +324,7 @@ struct PopoverKeyEventHandler: NSViewRepresentable { route: route, onSubmit: onSubmit, onInsertOriginal: onInsertOriginal, + onCopyResult: onCopyResult, onEscape: onEscape, onCycleMode: onCycleMode, onMoveModeHighlight: onMoveModeHighlight, @@ -334,6 +337,7 @@ struct PopoverKeyEventHandler: NSViewRepresentable { var route: WritingPopoverSessionState.Route var onSubmit: () -> Void var onInsertOriginal: () -> Void + var onCopyResult: () -> Void var onEscape: () -> Void var onCycleMode: (Int) -> Void var onMoveModeHighlight: (Int) -> Void @@ -345,6 +349,7 @@ struct PopoverKeyEventHandler: NSViewRepresentable { route: WritingPopoverSessionState.Route, onSubmit: @escaping () -> Void, onInsertOriginal: @escaping () -> Void, + onCopyResult: @escaping () -> Void, onEscape: @escaping () -> Void, onCycleMode: @escaping (Int) -> Void, onMoveModeHighlight: @escaping (Int) -> Void, @@ -353,6 +358,7 @@ struct PopoverKeyEventHandler: NSViewRepresentable { self.route = route self.onSubmit = onSubmit self.onInsertOriginal = onInsertOriginal + self.onCopyResult = onCopyResult self.onEscape = onEscape self.onCycleMode = onCycleMode self.onMoveModeHighlight = onMoveModeHighlight @@ -387,7 +393,8 @@ struct PopoverKeyEventHandler: NSViewRepresentable { route: route, keyCode: event.keyCode, modifiers: keyboardModifiers(from: event.modifierFlags), - isComposingText: isComposingText + isComposingText: isComposingText, + charactersIgnoringModifiers: event.charactersIgnoringModifiers ) switch action { @@ -413,6 +420,9 @@ struct PopoverKeyEventHandler: NSViewRepresentable { case .insertOriginal: onInsertOriginal() return nil + case .copyResult: + onCopyResult() + return nil } } diff --git a/Sources/InkletCore/OpenAIProvider.swift b/Sources/InkletCore/OpenAIProvider.swift index 143258e..4911f2e 100644 --- a/Sources/InkletCore/OpenAIProvider.swift +++ b/Sources/InkletCore/OpenAIProvider.swift @@ -14,10 +14,81 @@ public struct OpenAIProvider: LLMProvider { public var model: String public var input: [InputMessage] + public var stream: Bool? - public init(model: String, input: [InputMessage]) { + public init(model: String, input: [InputMessage], stream: Bool? = nil) { self.model = model self.input = input + self.stream = stream + } + } + + /// Accumulates the output text from Responses API server-sent events. + public struct ResponseStreamParser: Sendable { + private struct Event: Decodable { + struct ErrorBody: Decodable { + var message: String? + } + + struct Response: Decodable { + var output: [ResponseBody.OutputItem]? + var error: ErrorBody? + } + + var type: String + var delta: String? + var message: String? + var response: Response? + } + + public private(set) var outputText = "" + + public init() {} + + /// Consumes one stream line and returns the full output so far when the line added text. + public mutating func consume(line: String) throws -> String? { + guard line.hasPrefix("data:") else { + return nil + } + + let payload = line.dropFirst("data:".count).trimmingCharacters(in: .whitespaces) + guard payload != "[DONE]", + let data = payload.data(using: .utf8), + let event = try? JSONDecoder().decode(Event.self, from: data) + else { + return nil + } + + switch event.type { + case "response.output_text.delta": + guard let delta = event.delta, !delta.isEmpty else { + return nil + } + outputText += delta + return outputText + case "response.completed": + let completedText = ResponseBody(output: event.response?.output ?? []).outputText + guard outputText.isEmpty, !completedText.isEmpty else { + return nil + } + outputText = completedText + return outputText + case "error": + throw TransformationError.provider("OpenAI 请求失败:\(event.message ?? "HTTP unknown")") + case "response.failed": + throw TransformationError.provider( + "OpenAI 请求失败:\(event.response?.error?.message ?? "HTTP unknown")" + ) + default: + return nil + } + } + + public func finish() throws -> String { + guard !outputText.isEmpty else { + throw TransformationError.emptyResponse + } + return outputText } } @@ -65,15 +136,7 @@ public struct OpenAIProvider: LLMProvider { } public func transform(_ request: TransformationRequest) async throws -> TransformationResult { - let apiKey = try apiKeyProvider() - let requestBody = Self.makeRequestBody(for: request) - let bodyData = try JSONEncoder().encode(requestBody) - - var urlRequest = URLRequest(url: endpoint, timeoutInterval: request.timeoutSeconds) - urlRequest.httpMethod = "POST" - urlRequest.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") - urlRequest.httpBody = bodyData + let urlRequest = try makeURLRequest(body: Self.makeRequestBody(for: request), timeoutSeconds: request.timeoutSeconds) let started = Date() let (data, response) = try await session.data(for: urlRequest) @@ -98,16 +161,70 @@ public struct OpenAIProvider: LLMProvider { ) } - public static func makeRequestBody(for request: TransformationRequest) -> RequestBody { + public func streamTransform( + _ request: TransformationRequest, + onPartialOutput: @escaping @Sendable (String) -> Void + ) async throws -> TransformationResult { + let urlRequest = try makeURLRequest( + body: Self.makeRequestBody(for: request, stream: true), + timeoutSeconds: request.timeoutSeconds + ) + + let started = Date() + let (bytes, response) = try await session.bytes(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + throw TransformationError.provider("OpenAI 请求失败:HTTP unknown") + } + + guard (200..<300).contains(httpResponse.statusCode) else { + var data = Data() + for try await byte in bytes { + data.append(byte) + } + throw TransformationError.provider(Self.providerErrorMessage(from: data, statusCode: httpResponse.statusCode)) + } + + var parser = ResponseStreamParser() + for try await line in bytes.lines { + if let outputText = try parser.consume(line: line) { + onPartialOutput(outputText) + } + } + + let outputText = try parser.finish() + let elapsedMilliseconds = Int(Date().timeIntervalSince(started) * 1_000) + return TransformationResult( + outputText: outputText, + providerMetadata: [ + "provider": "openai", + "model": request.model + ], + elapsedMilliseconds: elapsedMilliseconds + ) + } + + public static func makeRequestBody(for request: TransformationRequest, stream: Bool = false) -> RequestBody { RequestBody( model: request.model, input: [ RequestBody.InputMessage(role: "system", content: request.systemPrompt), RequestBody.InputMessage(role: "user", content: request.sourceText) - ] + ], + stream: stream ? true : nil ) } + private func makeURLRequest(body: RequestBody, timeoutSeconds: TimeInterval) throws -> URLRequest { + let apiKey = try apiKeyProvider() + var urlRequest = URLRequest(url: endpoint, timeoutInterval: timeoutSeconds) + urlRequest.httpMethod = "POST" + urlRequest.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = try JSONEncoder().encode(body) + return urlRequest + } + public static func parseOutputText(from data: Data) throws -> String { let outputText = try JSONDecoder().decode(ResponseBody.self, from: data).outputText guard !outputText.isEmpty else { diff --git a/Sources/InkletCore/TransformationService.swift b/Sources/InkletCore/TransformationService.swift index 8a0ddad..d17ab10 100644 --- a/Sources/InkletCore/TransformationService.swift +++ b/Sources/InkletCore/TransformationService.swift @@ -11,7 +11,8 @@ public struct TransformationService: Sendable { sourceText: String, mode: PromptMode, model: String, - timeoutSeconds: TimeInterval + timeoutSeconds: TimeInterval, + onPartialOutput: (@Sendable (String) -> Void)? = nil ) async throws -> TransformationResult { let trimmedSource = sourceText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedSource.isEmpty else { @@ -27,8 +28,9 @@ public struct TransformationService: Sendable { timeoutSeconds: timeoutSeconds ) + let trimmedPartialOutput = onPartialOutput.map { Self.trimmingPartialOutput($0) } let result = try await withTimeout(seconds: timeoutSeconds) { - try await transformWithNetworkConnectionLostRetry(request) + try await transformWithNetworkConnectionLostRetry(request, onPartialOutput: trimmedPartialOutput) } let trimmedOutput = result.outputText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedOutput.isEmpty else { @@ -43,10 +45,11 @@ public struct TransformationService: Sendable { } private func transformWithNetworkConnectionLostRetry( - _ request: TransformationRequest + _ request: TransformationRequest, + onPartialOutput: (@Sendable (String) -> Void)? ) async throws -> TransformationResult { do { - return try await provider.transform(request) + return try await transformOnce(request, onPartialOutput: onPartialOutput) } catch { guard Self.isNetworkConnectionLost(error) else { throw error } } @@ -54,13 +57,33 @@ public struct TransformationService: Sendable { try Task.checkCancellation() do { - return try await provider.transform(request) + return try await transformOnce(request, onPartialOutput: onPartialOutput) } catch { guard Self.isNetworkConnectionLost(error) else { throw error } throw TransformationError.networkConnectionLost } } + private static func trimmingPartialOutput( + _ onPartialOutput: @escaping @Sendable (String) -> Void + ) -> @Sendable (String) -> Void { + { partialOutput in + let trimmedPartialOutput = partialOutput.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPartialOutput.isEmpty else { return } + onPartialOutput(trimmedPartialOutput) + } + } + + private func transformOnce( + _ request: TransformationRequest, + onPartialOutput: (@Sendable (String) -> Void)? + ) async throws -> TransformationResult { + guard let onPartialOutput else { + return try await provider.transform(request) + } + return try await provider.streamTransform(request, onPartialOutput: onPartialOutput) + } + private static func isNetworkConnectionLost(_ error: Error) -> Bool { let nsError = error as NSError return nsError.domain == NSURLErrorDomain diff --git a/Sources/InkletCore/TransformationTypes.swift b/Sources/InkletCore/TransformationTypes.swift index c8985c4..1395423 100644 --- a/Sources/InkletCore/TransformationTypes.swift +++ b/Sources/InkletCore/TransformationTypes.swift @@ -43,6 +43,22 @@ public struct TransformationResult: Equatable, Sendable { public protocol LLMProvider: Sendable { func transform(_ request: TransformationRequest) async throws -> TransformationResult + + /// Reports the output received so far through `onPartialOutput` while the response is generated. + func streamTransform( + _ request: TransformationRequest, + onPartialOutput: @escaping @Sendable (String) -> Void + ) async throws -> TransformationResult +} + +extension LLMProvider { + /// Providers without streaming support return the complete result without partial updates. + public func streamTransform( + _ request: TransformationRequest, + onPartialOutput: @escaping @Sendable (String) -> Void + ) async throws -> TransformationResult { + try await transform(request) + } } public enum TransformationError: Error, Equatable, LocalizedError { diff --git a/Sources/InkletCore/WritingPopoverKeyboardPolicy.swift b/Sources/InkletCore/WritingPopoverKeyboardPolicy.swift index 69a726c..a6f1eb7 100644 --- a/Sources/InkletCore/WritingPopoverKeyboardPolicy.swift +++ b/Sources/InkletCore/WritingPopoverKeyboardPolicy.swift @@ -20,6 +20,7 @@ public enum WritingPopoverKeyboardAction: Equatable, Sendable { case cycleMode(Int) case submit case insertOriginal + case copyResult } public enum WritingPopoverKeyboardPolicy { @@ -27,7 +28,8 @@ public enum WritingPopoverKeyboardPolicy { route: WritingPopoverSessionState.Route, keyCode: UInt16, modifiers: WritingPopoverKeyboardModifiers, - isComposingText: Bool + isComposingText: Bool, + charactersIgnoringModifiers: String? = nil ) -> WritingPopoverKeyboardAction { let isReturnKey = keyCode == 36 || keyCode == 76 @@ -76,6 +78,10 @@ public enum WritingPopoverKeyboardPolicy { return .passThrough } + if modifiers == [.command, .shift], charactersIgnoringModifiers?.lowercased() == "c" { + return .copyResult + } + if modifiers.contains(.command), !modifiers.contains(.shift), !modifiers.contains(.option) diff --git a/Tests/InkletCoreTests/OpenAIProviderTests.swift b/Tests/InkletCoreTests/OpenAIProviderTests.swift index 6e88ada..66152f1 100644 --- a/Tests/InkletCoreTests/OpenAIProviderTests.swift +++ b/Tests/InkletCoreTests/OpenAIProviderTests.swift @@ -17,6 +17,7 @@ final class OpenAIProviderTests: XCTestCase { let input = try XCTUnwrap(json["input"] as? [[String: Any]]) XCTAssertNil(json["temperature"]) + XCTAssertNil(json["stream"]) XCTAssertEqual(json["model"] as? String, "gpt-4.1-mini") XCTAssertTrue(input.contains { $0["role"] as? String == "system" && $0["content"] as? String == "Rewrite in polished English." @@ -155,12 +156,163 @@ final class OpenAIProviderTests: XCTestCase { MockOpenAIURLProtocol.handler = nil } + func testStreamingRequestBodyAsksForServerSentEvents() throws { + let body = OpenAIProvider.makeRequestBody(for: request(), stream: true) + let json = try encodedJSONObject(body) + + XCTAssertEqual(json["stream"] as? Bool, true) + XCTAssertEqual(json["model"] as? String, "gpt-4.1-mini") + } + + func testStreamParserAccumulatesTextDeltasAndIgnoresOtherLines() throws { + var parser = OpenAIProvider.ResponseStreamParser() + + XCTAssertNil(try parser.consume(line: "event: response.output_text.delta")) + XCTAssertNil(try parser.consume(line: #"data: {"type":"response.created","response":{}}"#)) + XCTAssertEqual(try parser.consume(line: #"data: {"type":"response.output_text.delta","delta":"Hel"}"#), "Hel") + XCTAssertNil(try parser.consume(line: ": keep-alive")) + XCTAssertNil(try parser.consume(line: "data: not json")) + XCTAssertEqual(try parser.consume(line: #"data: {"type":"response.output_text.delta","delta":"lo."}"#), "Hello.") + XCTAssertNil(try parser.consume(line: #"data: {"type":"response.completed","response":{"output":[{"content":[{"type":"output_text","text":"Hello."}]}]}}"#)) + XCTAssertNil(try parser.consume(line: "data: [DONE]")) + + XCTAssertEqual(try parser.finish(), "Hello.") + } + + func testStreamParserFallsBackToCompletedResponseWithoutDeltas() throws { + var parser = OpenAIProvider.ResponseStreamParser() + + let output = try parser.consume( + line: #"data: {"type":"response.completed","response":{"output":[{"content":[{"type":"output_text","text":"Done."}]}]}}"# + ) + + XCTAssertEqual(output, "Done.") + XCTAssertEqual(try parser.finish(), "Done.") + } + + func testStreamParserThrowsEmptyResponseWithoutText() { + let parser = OpenAIProvider.ResponseStreamParser() + + XCTAssertThrowsError(try parser.finish()) { error in + XCTAssertEqual(error as? TransformationError, .emptyResponse) + } + } + + func testStreamParserMapsErrorAndFailedEvents() { + var parser = OpenAIProvider.ResponseStreamParser() + XCTAssertThrowsError(try parser.consume(line: #"data: {"type":"error","message":"Overloaded."}"#)) { error in + XCTAssertEqual(error as? TransformationError, .provider("OpenAI 请求失败:Overloaded.")) + } + + var failedParser = OpenAIProvider.ResponseStreamParser() + XCTAssertThrowsError( + try failedParser.consume(line: #"data: {"type":"response.failed","response":{"error":{"message":"Model failed."}}}"#) + ) { error in + XCTAssertEqual(error as? TransformationError, .provider("OpenAI 请求失败:Model failed.")) + } + } + + func testStreamTransformReportsPartialOutputAndReturnsFullText() async throws { + MockOpenAIURLProtocol.handler = { request in + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer test-api-key") + + let response = try XCTUnwrap(HTTPURLResponse( + url: try XCTUnwrap(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "text/event-stream"] + )) + let data = try XCTUnwrap(""" + event: response.output_text.delta + data: {"type":"response.output_text.delta","delta":"Hel"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","delta":"lo."} + + event: response.completed + data: {"type":"response.completed","response":{"output":[]}} + + """.data(using: .utf8)) + return (response, data) + } + defer { MockOpenAIURLProtocol.handler = nil } + let partialOutputs = PartialOutputRecorder() + + let result = try await makeMockedProvider().streamTransform(request()) { partialOutput in + partialOutputs.append(partialOutput) + } + + XCTAssertEqual(result.outputText, "Hello.") + XCTAssertEqual(result.providerMetadata["provider"], "openai") + XCTAssertEqual(partialOutputs.values, ["Hel", "Hello."]) + } + + func testStreamTransformMapsOpenAIErrorPayload() async throws { + MockOpenAIURLProtocol.handler = { request in + let response = try XCTUnwrap(HTTPURLResponse( + url: try XCTUnwrap(request.url), + statusCode: 401, + httpVersion: nil, + headerFields: nil + )) + let data = try XCTUnwrap(#"{"error":{"message":"Invalid API key."}}"#.data(using: .utf8)) + return (response, data) + } + defer { MockOpenAIURLProtocol.handler = nil } + + do { + _ = try await makeMockedProvider().streamTransform(request()) { _ in } + XCTFail("Expected streamTransform to throw") + } catch { + XCTAssertEqual(error as? TransformationError, .provider("OpenAI 请求失败:Invalid API key.")) + } + } + + private func request() -> TransformationRequest { + TransformationRequest( + sourceText: "Make this clearer.", + systemPrompt: "Rewrite in polished English.", + modeID: "polish", + modeName: "Polish", + model: "gpt-4.1-mini", + timeoutSeconds: 10 + ) + } + + private func makeMockedProvider() -> OpenAIProvider { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockOpenAIURLProtocol.self] + return OpenAIProvider( + apiKeyProvider: { "test-api-key" }, + endpoint: URL(string: "https://api.openai.test/v1/responses")!, + session: URLSession(configuration: configuration) + ) + } + private func encodedJSONObject(_ value: T) throws -> [String: Any] { let data = try JSONEncoder().encode(value) return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) } } +private final class PartialOutputRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String] = [] + + var values: [String] { + lock.lock() + defer { lock.unlock() } + return storage + } + + func append(_ value: String) { + lock.lock() + storage.append(value) + lock.unlock() + } +} + private final class MockOpenAIURLProtocol: URLProtocol { nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? diff --git a/Tests/InkletCoreTests/TransformationServiceTests.swift b/Tests/InkletCoreTests/TransformationServiceTests.swift index d980260..6712f3a 100644 --- a/Tests/InkletCoreTests/TransformationServiceTests.swift +++ b/Tests/InkletCoreTests/TransformationServiceTests.swift @@ -267,6 +267,57 @@ final class TransformationServiceTests: XCTestCase { } } + func testStreamingProviderForwardsTrimmedPartialOutput() async throws { + let service = TransformationService(provider: StreamingLLMProvider( + partialOutputs: ["\n", "\nHel", "\nHello. "], + outputText: "\nHello. " + )) + let partialOutputs = StreamedOutputRecorder() + + let result = try await service.transform( + sourceText: "hello", + mode: mode, + model: "test-model", + timeoutSeconds: 1, + onPartialOutput: { partialOutputs.append($0) } + ) + + XCTAssertEqual(result.outputText, "Hello.") + XCTAssertEqual(partialOutputs.values, ["Hel", "Hello."]) + } + + func testStreamingRequestFallsBackToCompleteResultForProvidersWithoutStreaming() async throws { + let service = TransformationService(provider: FakeLLMProvider(outputText: " Hello. ")) + let partialOutputs = StreamedOutputRecorder() + + let result = try await service.transform( + sourceText: "hello", + mode: mode, + model: "test-model", + timeoutSeconds: 1, + onPartialOutput: { partialOutputs.append($0) } + ) + + XCTAssertEqual(result.outputText, "Hello.") + XCTAssertEqual(partialOutputs.values, []) + } + + func testTransformWithoutPartialOutputDoesNotStream() async throws { + let service = TransformationService(provider: StreamingLLMProvider( + partialOutputs: ["Hel"], + outputText: "Hello." + )) + + let result = try await service.transform( + sourceText: "hello", + mode: mode, + model: "test-model", + timeoutSeconds: 1 + ) + + XCTAssertEqual(result.providerMetadata["path"], "transform") + } + private var mode: PromptMode { PromptMode( id: "polish", @@ -326,6 +377,42 @@ private struct FakeLLMProvider: LLMProvider { } } +private struct StreamingLLMProvider: LLMProvider { + var partialOutputs: [String] + var outputText: String + + func transform(_ request: TransformationRequest) async throws -> TransformationResult { + TransformationResult(outputText: outputText, providerMetadata: ["path": "transform"], elapsedMilliseconds: 1) + } + + func streamTransform( + _ request: TransformationRequest, + onPartialOutput: @escaping @Sendable (String) -> Void + ) async throws -> TransformationResult { + for partialOutput in partialOutputs { + onPartialOutput(partialOutput) + } + return TransformationResult(outputText: outputText, providerMetadata: ["path": "stream"], elapsedMilliseconds: 1) + } +} + +private final class StreamedOutputRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String] = [] + + var values: [String] { + lock.lock() + defer { lock.unlock() } + return storage + } + + func append(_ value: String) { + lock.lock() + storage.append(value) + lock.unlock() + } +} + private actor SequencedLLMProvider: LLMProvider { enum Outcome: Sendable { case success(String) diff --git a/Tests/InkletCoreTests/WritingModeLauncherSourceTests.swift b/Tests/InkletCoreTests/WritingModeLauncherSourceTests.swift index 2031ce9..f888d07 100644 --- a/Tests/InkletCoreTests/WritingModeLauncherSourceTests.swift +++ b/Tests/InkletCoreTests/WritingModeLauncherSourceTests.swift @@ -376,6 +376,7 @@ final class WritingModeLauncherSourceTests: XCTestCase { XCTAssertTrue(handleBlock.contains("case .cycleMode(let direction):")) XCTAssertTrue(handleBlock.contains("case .submit:")) XCTAssertTrue(handleBlock.contains("case .insertOriginal:")) + XCTAssertTrue(handleBlock.contains("case .copyResult:")) for modifier in ["command", "shift", "option", "control"] { XCTAssertTrue(modifierHelper.contains("modifiers.contains(.\(modifier))")) } diff --git a/Tests/InkletCoreTests/WritingPopoverKeyboardPolicyTests.swift b/Tests/InkletCoreTests/WritingPopoverKeyboardPolicyTests.swift index 9a679a0..e213c09 100644 --- a/Tests/InkletCoreTests/WritingPopoverKeyboardPolicyTests.swift +++ b/Tests/InkletCoreTests/WritingPopoverKeyboardPolicyTests.swift @@ -224,6 +224,29 @@ final class WritingPopoverKeyboardPolicyTests: XCTestCase { } } + func testEditorShiftCommandCCopiesResultOnLayoutCharacterC() { + XCTAssertEqual( + action(route: .editor, keyCode: 8, modifiers: [.command, .shift], characters: "C"), + .copyResult + ) + XCTAssertEqual( + action(route: .editor, keyCode: 34, modifiers: [.command, .shift], characters: "c"), + .copyResult + ) + XCTAssertEqual( + action(route: .editor, keyCode: 8, modifiers: [.command], characters: "c"), + .passThrough + ) + XCTAssertEqual( + action(route: .editor, keyCode: 8, modifiers: [.command, .shift, .option], characters: "c"), + .passThrough + ) + XCTAssertEqual( + action(route: .modePicker, keyCode: 8, modifiers: [.command, .shift], characters: "c"), + .passThrough + ) + } + func testEditorCycleRequiresCommandWithoutShiftOrOption() { for modifiers: WritingPopoverKeyboardModifiers in [ [.command, .shift], @@ -249,13 +272,15 @@ final class WritingPopoverKeyboardPolicyTests: XCTestCase { route: WritingPopoverSessionState.Route, keyCode: UInt16, modifiers: WritingPopoverKeyboardModifiers = [], - isComposingText: Bool = false + isComposingText: Bool = false, + characters: String? = nil ) -> WritingPopoverKeyboardAction { WritingPopoverKeyboardPolicy.action( route: route, keyCode: keyCode, modifiers: modifiers, - isComposingText: isComposingText + isComposingText: isComposingText, + charactersIgnoringModifiers: characters ) } diff --git a/Tests/InkletCoreTests/WritingPopoverStreamingResultTests.swift b/Tests/InkletCoreTests/WritingPopoverStreamingResultTests.swift new file mode 100644 index 0000000..6fef468 --- /dev/null +++ b/Tests/InkletCoreTests/WritingPopoverStreamingResultTests.swift @@ -0,0 +1,166 @@ +import AppKit +import XCTest +@testable import Inklet +@testable import InkletCore + +@MainActor +final class WritingPopoverStreamingResultTests: XCTestCase { + func testStreamedOutputIsVisibleWhileTransformingAndReplacedByFinalResult() async throws { + let gate = StreamingGate() + let harness = try makeHarness(provider: GatedStreamingProvider(gate: gate)) + let model = harness.model + model.commitMode(modeID: model.selectedModeID) + model.updateSourceText("hello") + + model.submit() + try await waitUntil { model.streamingResultText == "Hel" } + + XCTAssertTrue(model.isTransforming) + XCTAssertEqual(model.resultText, "") + + gate.open() + try await waitUntil { !model.isTransforming } + + XCTAssertEqual(model.resultText, "Hello.") + XCTAssertEqual(model.streamingResultText, "") + } + + func testEscapeWhileStreamingStopsAndDiscardsPartialOutput() async throws { + let gate = StreamingGate() + let harness = try makeHarness(provider: GatedStreamingProvider(gate: gate)) + let model = harness.model + model.commitMode(modeID: model.selectedModeID) + model.updateSourceText("hello") + + model.submit() + try await waitUntil { model.streamingResultText == "Hel" } + model.escape() + + XCTAssertFalse(model.isTransforming) + XCTAssertEqual(model.streamingResultText, "") + XCTAssertEqual(model.resultText, "") + XCTAssertEqual(model.route, .editor) + XCTAssertEqual(model.sourceText, "hello") + gate.open() + } + + func testCopyResultWritesTheVisibleResultAndShowsTemporaryFeedback() async throws { + let harness = try makeHarness(provider: GatedStreamingProvider(gate: StreamingGate(isOpen: true))) + let model = harness.model + model.commitMode(modeID: model.selectedModeID) + model.updateSourceText("hello") + model.submit() + try await waitUntil { !model.isTransforming } + + model.copyResult() + + XCTAssertEqual(harness.pasteboard.string(forType: .string), "Hello.") + XCTAssertTrue(model.isResultCopied) + try await waitUntil(timeout: .seconds(2)) { !model.isResultCopied } + } + + func testCopyResultIgnoresMissingResultAndBusyStates() async throws { + let gate = StreamingGate() + let harness = try makeHarness(provider: GatedStreamingProvider(gate: gate)) + let model = harness.model + model.commitMode(modeID: model.selectedModeID) + harness.pasteboard.clearContents() + + model.copyResult() + XCTAssertNil(harness.pasteboard.string(forType: .string)) + + model.updateSourceText("hello") + model.submit() + try await waitUntil { model.streamingResultText == "Hel" } + model.copyResult() + + XCTAssertNil(harness.pasteboard.string(forType: .string)) + XCTAssertFalse(model.isResultCopied) + gate.open() + try await waitUntil { !model.isTransforming } + } + + private func waitUntil( + timeout: Duration = .seconds(1), + _ condition: () -> Bool + ) async throws { + let deadline = ContinuousClock.now + timeout + while !condition() { + guard ContinuousClock.now < deadline else { + XCTFail("Timed out waiting for condition") + return + } + try await Task.sleep(for: .milliseconds(5)) + } + } + + private func makeHarness(provider: some LLMProvider) throws -> StreamingHarness { + let identifier = "WritingPopoverStreaming-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: identifier)) + defaults.removePersistentDomain(forName: identifier) + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(identifier, isDirectory: true) + let pasteboard = NSPasteboard(name: NSPasteboard.Name(identifier)) + addTeardownBlock { + defaults.removePersistentDomain(forName: identifier) + try? FileManager.default.removeItem(at: root) + } + let configStore = UserDefaultsConfigStore(userDefaults: defaults) + try configStore.save(AppConfig.defaultConfig()) + let model = InkletPopoverViewModel( + configStore: configStore, + transformationServiceFactory: { _ in TransformationService(provider: provider) }, + historyStore: JSONLHistoryStore(fileURL: root.appendingPathComponent("history.jsonl")), + writingModePreferenceStore: WritingModePreferenceStore(userDefaults: defaults), + resultPasteboard: pasteboard + ) + model.resetForOpen(previousApplication: nil) + return StreamingHarness(model: model, pasteboard: pasteboard) + } +} + +private struct StreamingHarness { + let model: InkletPopoverViewModel + let pasteboard: NSPasteboard +} + +private final class StreamingGate: @unchecked Sendable { + private let lock = NSLock() + private var isOpenStorage: Bool + + init(isOpen: Bool = false) { + isOpenStorage = isOpen + } + + var isOpen: Bool { + lock.lock() + defer { lock.unlock() } + return isOpenStorage + } + + func open() { + lock.lock() + isOpenStorage = true + lock.unlock() + } +} + +private struct GatedStreamingProvider: LLMProvider { + let gate: StreamingGate + + func transform(_ request: TransformationRequest) async throws -> TransformationResult { + TransformationResult(outputText: "Hello.", providerMetadata: [:], elapsedMilliseconds: 1) + } + + func streamTransform( + _ request: TransformationRequest, + onPartialOutput: @escaping @Sendable (String) -> Void + ) async throws -> TransformationResult { + onPartialOutput("Hel") + while !gate.isOpen { + try await Task.sleep(for: .milliseconds(5)) + } + onPartialOutput("Hello.") + return TransformationResult(outputText: "Hello.", providerMetadata: [:], elapsedMilliseconds: 1) + } +} diff --git a/docs/manual-test-checklist.md b/docs/manual-test-checklist.md index 75796a3..e5127b5 100644 --- a/docs/manual-test-checklist.md +++ b/docs/manual-test-checklist.md @@ -68,6 +68,9 @@ Record the tested Inklet version, artifact checksum, macOS version, hardware arc - Restore Insert behavior: reselect the mode that generated the existing result and confirm the stale-result label clears and `Enter` inserts the result instead of regenerating it. - Layered `Escape`: with a result visible, press `Escape` once to return to the source editor, once to return to the mode launcher, and once to close the popover. Confirm each press moves only one level. - Generation cancellation: press `Escape` while transforming and confirm generation cancels, remains in the editor, and performs no additional back navigation. +- Streaming result: transform a long draft and confirm the result area appears as soon as text starts arriving, grows with the text, keeps the newest line in view once it reaches its maximum height, and shows an `esc` Stop hint in the toolbar. Confirm the final result is editable and matches the streamed text. +- Streaming cancellation: press `Escape` or click Stop while text is streaming and confirm the partial text disappears, the source draft stays, and a prior result from a different mode reappears if one was visible. +- Copy result: with a result visible, click the copy icon at the top-right of the result and confirm the clipboard contains the result, the icon shows a checkmark briefly and then returns, the popover stays open, and long first lines never run under the icon. Confirm the control has a Copy result tooltip (with `⇧⌘C`) and VoiceOver label in English and Chinese. Press `Shift+Command+C` with the source or result editor focused and confirm it copies the result with the same checkmark feedback; confirm plain `Command+C` still copies only selected text, and the shortcut does nothing when there is no result or while generating. - Launcher capacity and copy: configure more than six visible modes with long English and Chinese names; at the actual 600-point popover width, confirm scrolling reaches every mode and text, icons, and hints do not overlap. - Pointer interaction: confirm a single click highlights without committing, a double-click commits, and hover and pressed states are visible without changing layout. - VoiceOver: confirm the search field and every mode have useful labels, the highlighted mode exposes its selected state, and each mode offers a named `Write` action that commits it.