From 24f551a39eae849d891da26cc91f90d354e3a2db Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sun, 12 Jul 2026 02:44:05 +0800
Subject: [PATCH] chore(mas): refresh codebase memory after split

---
 C1.source/privatevoice.src/app.go |  421 ++++++++++++++++++++++++++++++++++++++++++++++++---
 1 files changed, 390 insertions(+), 31 deletions(-)

diff --git a/C1.source/privatevoice.src/app.go b/C1.source/privatevoice.src/app.go
index 908337b..7f22693 100755
--- a/C1.source/privatevoice.src/app.go
+++ b/C1.source/privatevoice.src/app.go
@@ -2,6 +2,7 @@
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"runtime"
 	"strings"
@@ -22,6 +23,7 @@
 	"voicesnap/internal/modelselection"
 	"voicesnap/internal/overlay"
 	"voicesnap/internal/sound"
+	"voicesnap/internal/textoutput"
 	"voicesnap/internal/textproc"
 	"voicesnap/internal/userdict"
 	"voicesnap/services"
@@ -30,11 +32,14 @@
 	"github.com/wailsapp/wails/v3/pkg/events"
 )
 
+var (
+	appVersion = "2.2.2"
+	appBuild   = "20260703.0335"
+)
+
 const (
-	appVersion        = "2.2.2"
-	appBuild          = "20260703.0335"
-	appDisplayVersion = appVersion + " (build " + appBuild + ")"
 	appName           = "PrivateVoice Dictation"
+	textOutputUpdated = "text-output:updated"
 
 	silenceThreshold         = 0.05            // RMS below this = silence (matches HasVoiceActivity)
 	silenceTimeoutDuration   = 3 * time.Second // auto-stop after this much silence in free-talk
@@ -47,6 +52,15 @@
 	liveCaptionMaxBytes      = 108
 )
 
+func appDisplayVersion() string {
+	return appVersion + " (build " + appBuild + ")"
+}
+
+var (
+	holdStartupSampleWaitTimeout  = 2 * time.Second
+	holdStartupSamplePollInterval = 25 * time.Millisecond
+)
+
 // App holds all application state and orchestration logic.
 type App struct {
 	ctx    context.Context
@@ -54,7 +68,7 @@
 	wg     sync.WaitGroup
 
 	cfg           *config.Config
-	recorder      *audio.Recorder
+	recorder      appRecorder
 	eng           engine.Engine
 	engineMu      sync.Mutex
 	engineInitMu  sync.Mutex
@@ -63,6 +77,7 @@
 	engineFactory engineFactoryFunc
 	hk            hotkey.Listener
 	paster        input.Paster
+	outputRouter  *textoutput.Router
 	history       *history.Store
 	userdict      *userdict.Store
 
@@ -76,6 +91,8 @@
 	isRecording             bool
 	isStoppingRecording     bool
 	isHoldRecordingPending  bool
+	isHoldRecorderStarting  bool
+	isHoldStopPending       bool
 	isFreetalking           bool
 	hotkeyActive            bool
 	hotkeyPressTime         time.Time
@@ -93,9 +110,22 @@
 	liveCaptionSeq          uint64
 	releaseTailCaptureNanos atomic.Int64
 	holdPreCaptureEnabled   atomic.Bool
+	holdActivationTimer     *time.Timer
+	holdActivationSeq       uint64
 }
 
 type engineFactoryFunc func(initID uint64) (engine.Engine, engineMetadata, bool, error)
+
+type appRecorder interface {
+	OnVolume(func(float64))
+	OnDeviceChange(func(string))
+	Start() error
+	Stop()
+	StopAndGetSamples() []float32
+	HasVoiceActivity() bool
+	ReadSamplesSince(offset int) ([]float32, int)
+	Close()
+}
 
 type engineMetadata struct {
 	InitID                uint64
@@ -131,13 +161,15 @@
 	app.cfg = cfg
 
 	// Initialize audio recorder
-	app.recorder = audio.NewRecorder()
+	recorder := audio.NewRecorder()
+	app.recorder = recorder
 
 	// Initialize platform-specific hotkey listener
 	app.hk = hotkey.New()
 
 	// Initialize platform-specific paster
 	app.paster = input.NewPaster()
+	app.outputRouter = textoutput.NewRouter(app.paster)
 
 	// Initialize history store
 	app.history = history.New()
@@ -146,18 +178,20 @@
 	app.userdict = userdict.New()
 
 	// Create services for Wails bindings
-	appService := services.NewAppService(app.cfg, appDisplayVersion)
+	displayVersion := appDisplayVersion()
+	appService := services.NewAppService(app.cfg, displayVersion)
 	configService := services.NewConfigService(app.cfg)
 	engineService := services.NewEngineService(app.cfg)
 	app.engineService = engineService
 	hotkeyService := services.NewHotkeyService(app.cfg)
 	permissionService := services.NewPermissionService()
 	updaterService := services.NewUpdaterService(appVersion)
-	audioService := services.NewAudioService(app.recorder, app.cfg)
+	audioService := services.NewAudioService(recorder, app.cfg)
 	historyService := services.NewHistoryService(app.history)
 	userDictService := services.NewUserDictService(app.userdict)
 	correctionCSVService := services.NewCorrectionCSVService(app.history, app.userdict)
-	diagnosticsService := services.NewDiagnosticsService(app.cfg, appDisplayVersion)
+	diagnosticsService := services.NewDiagnosticsService(app.cfg, displayVersion)
+	textOutputService := services.NewTextOutputService(app.outputRouter)
 
 	// Create Wails application
 	wailsApp := application.New(application.Options{
@@ -175,6 +209,7 @@
 			application.NewService(userDictService),
 			application.NewService(correctionCSVService),
 			application.NewService(diagnosticsService),
+			application.NewService(textOutputService),
 		},
 		Assets: application.AssetOptions{
 			Handler: application.AssetFileServerFS(assets),
@@ -294,6 +329,23 @@
 	a.mu.Lock()
 	defer a.mu.Unlock()
 
+	if config.IsHotkeyUnset(a.cfg.HotkeyVK) {
+		if !a.hotkeyPollSeen || a.lastHotkeyDown {
+			a.hotkeyPollSeen = true
+			a.lastHotkeyDown = false
+			logger.Info(
+				"Hotkey poll state: key=unset vk=0x0 mode=%s down=false active=%t recording=%t freetalk=%t engineReady=%t recordingHotkey=%t",
+				config.NormalizeHotkeyMode(a.cfg.HotkeyMode),
+				a.hotkeyActive,
+				a.isRecording,
+				a.isFreetalking,
+				a.eng != nil,
+				a.isRecordingHotkey,
+			)
+		}
+		return
+	}
+
 	isDown := a.hk.IsKeyDown(a.cfg.HotkeyVK)
 	if !a.hotkeyPollSeen || a.lastHotkeyDown != isDown {
 		a.hotkeyPollSeen = true
@@ -323,10 +375,40 @@
 
 	// Escape cancels any active recording
 	if a.cfg.HotkeyVK != 0x1B && (a.isRecording || a.isFreetalking) && a.hk.IsKeyDown(0x1B) {
+		if a.isHoldRecordingPending {
+			a.cancelHoldPreCaptureLocked("Escape")
+			a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
+			if a.cfg.SoundFeedback {
+				sound.PlayCancel()
+			}
+			a.delayedHide(1000)
+			return
+		}
+		if a.isHoldRecorderStarting {
+			a.isCombination = true
+			a.isFreetalking = false
+			a.isRecording = false
+			a.isHoldRecordingPending = false
+			a.isHoldStopPending = true
+			a.cancelHoldActivationTimerLocked()
+			a.lastStopTime = time.Now()
+			a.stopLiveCaptionLocked()
+			logger.Info("Recording cancellation deferred until recorder start completes (Escape)")
+			a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
+			if a.cfg.SoundFeedback {
+				sound.PlayCancel()
+			}
+			a.delayedHide(1000)
+			a.recorder.Stop()
+			return
+		}
 		a.isCombination = true
 		a.isFreetalking = false
 		a.isRecording = false
 		a.isHoldRecordingPending = false
+		a.isHoldRecorderStarting = false
+		a.isHoldStopPending = false
+		a.cancelHoldActivationTimerLocked()
 		a.lastStopTime = time.Now()
 		a.stopLiveCaptionLocked()
 		a.recorder.Stop()
@@ -417,6 +499,7 @@
 	return a.holdPreCaptureEnabled.Load() &&
 		!a.isRecording &&
 		!a.isStoppingRecording &&
+		!a.isHoldRecorderStarting &&
 		!a.isFreetalking &&
 		time.Since(a.lastStopTime) > 500*time.Millisecond
 }
@@ -425,21 +508,133 @@
 	if a.isRecording || a.isStoppingRecording || a.isFreetalking {
 		return
 	}
+	a.isRecording = true
+	a.isHoldRecordingPending = true
+	a.isHoldRecorderStarting = true
+	a.isHoldStopPending = false
+	a.scheduleHoldActivationTimerLocked()
+	logger.Info("Hold pre-capture start requested")
+	go a.startHoldPreCaptureRecorder()
+}
+
+func (a *App) startHoldPreCaptureRecorder() {
 	if err := a.recorder.Start(); err != nil {
+		a.mu.Lock()
+		releasePipelineActive := a.isHoldRecorderStarting && !a.isRecording && !a.isHoldRecordingPending && !a.isHoldStopPending
+		cancelPending := a.isHoldRecorderStarting && !a.isRecording && a.isHoldStopPending
+		wasRecording := a.isRecording || a.isHoldRecordingPending || a.isHoldRecorderStarting
+		a.isRecording = false
+		a.isHoldRecordingPending = false
+		a.isHoldRecorderStarting = false
+		a.isHoldStopPending = false
+		a.cancelHoldActivationTimerLocked()
+		if releasePipelineActive {
+			a.mu.Unlock()
+			logger.Error("Hold pre-capture recorder start failed after release pipeline started: %v", err)
+			return
+		}
+		if cancelPending {
+			a.mu.Unlock()
+			logger.Error("Hold pre-capture recorder start failed after cancellation: %v", err)
+			return
+		}
+		if wasRecording {
+			a.stopLiveCaptionLocked()
+			a.setRecordingStartErrorLocked(err)
+			a.delayedHide(2000)
+		}
+		a.mu.Unlock()
 		logger.Error("Failed to start hold pre-capture: %v", err)
 		return
 	}
-	a.isRecording = true
-	a.isHoldRecordingPending = true
+
+	a.mu.Lock()
+	a.isHoldRecorderStarting = false
+	if !a.isRecording && !a.isHoldStopPending {
+		a.mu.Unlock()
+		logger.Info("Hold pre-capture recorder start completed after release pipeline started")
+		return
+	}
+	shouldStop := !a.isRecording || a.isHoldStopPending
+	if shouldStop {
+		a.isRecording = false
+		a.isHoldRecordingPending = false
+		stopPending := a.isHoldStopPending
+		a.isHoldStopPending = false
+		a.lastStopTime = time.Now()
+		a.stopLiveCaptionLocked()
+		a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
+		a.delayedHide(1000)
+		a.mu.Unlock()
+		if !stopPending {
+			a.recorder.Stop()
+		}
+		return
+	}
+	a.mu.Unlock()
 	logger.Info("Hold pre-capture started")
+}
+
+func (a *App) scheduleHoldActivationTimerLocked() {
+	a.cancelHoldActivationTimerLocked()
+	a.holdActivationSeq++
+	seq := a.holdActivationSeq
+	a.holdActivationTimer = time.AfterFunc(holdActivationDelay, func() {
+		a.mu.Lock()
+		defer a.mu.Unlock()
+
+		if a.holdActivationSeq != seq {
+			return
+		}
+		a.holdActivationTimer = nil
+		if a.ctx != nil {
+			select {
+			case <-a.ctx.Done():
+				return
+			default:
+			}
+		}
+		if !a.hotkeyActive || !a.isHoldRecordingPending || !a.isRecording {
+			return
+		}
+		if a.hk == nil || !a.hk.IsKeyDown(a.cfg.HotkeyVK) {
+			a.cancelHoldPreCaptureLocked("released before activation")
+			return
+		}
+		if a.eng == nil {
+			a.cancelHoldPreCaptureLocked("engine not ready")
+			return
+		}
+		if !a.isCombination && a.hk != nil && a.hk.IsAnyOtherKeyPressedSince(a.cfg.HotkeyVK, a.hotkeyPressTime) {
+			a.isCombination = true
+			logger.Info("Hotkey marked as combination: key=%s", hotkey.GetKeyName(a.cfg.HotkeyVK))
+			a.cancelHoldPreCaptureLocked("combination key")
+			return
+		}
+		if a.isCombination {
+			return
+		}
+		logger.Info("Hotkey action: confirm hold pre-capture after %dms", time.Since(a.hotkeyPressTime).Milliseconds())
+		a.confirmHoldPreCaptureLocked()
+	})
+}
+
+func (a *App) cancelHoldActivationTimerLocked() {
+	a.holdActivationSeq++
+	if a.holdActivationTimer != nil {
+		a.holdActivationTimer.Stop()
+		a.holdActivationTimer = nil
+	}
 }
 
 func (a *App) confirmHoldPreCaptureLocked() {
 	if !a.isHoldRecordingPending || !a.isRecording {
 		a.isHoldRecordingPending = false
+		a.cancelHoldActivationTimerLocked()
 		return
 	}
 	a.isHoldRecordingPending = false
+	a.cancelHoldActivationTimerLocked()
 	a.hideGen.Add(1)
 
 	logger.Info("Hold pre-capture confirmed")
@@ -458,8 +653,10 @@
 		return
 	}
 	a.isHoldRecordingPending = false
+	a.cancelHoldActivationTimerLocked()
 	if a.isRecording {
 		a.isRecording = false
+		a.isHoldStopPending = a.isHoldRecorderStarting
 		a.lastStopTime = time.Now()
 		a.recorder.Stop()
 	}
@@ -537,6 +734,8 @@
 	if err := a.recorder.Start(); err != nil {
 		logger.Error("Failed to start recording: %v", err)
 		a.isRecording = false
+		a.setRecordingStartErrorLocked(err)
+		a.delayedHide(2000)
 		return
 	}
 	a.startLiveCaptionLocked(overlay.StatusRecording)
@@ -550,6 +749,39 @@
 	a.isRecording = false
 	a.isHoldRecordingPending = false
 	a.lastStopTime = time.Now()
+
+	if a.isHoldRecorderStarting {
+		a.stopLiveCaptionLocked()
+		if cancel {
+			a.isHoldStopPending = true
+			logger.Info("Hold recording stop deferred until recorder start completes")
+			logger.Info("Recording cancelled (combination key)")
+			a.indicator.SetStatus(overlay.StatusCancelled, "已取消")
+			if a.cfg.SoundFeedback {
+				sound.PlayCancel()
+			}
+			a.delayedHide(1000)
+			a.recorder.Stop()
+			return
+		}
+
+		logger.Info("Hold recording stop proceeds while recorder start is pending")
+		a.isStoppingRecording = true
+		a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
+		go func() {
+			perfID := newPerfTraceID()
+			pipelineStart := time.Now()
+			logger.Info("PERF pipeline_start id=%d mode=hold_starting_release", perfID)
+			samples, hasVoice, ok := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart, true)
+			if !ok {
+				a.indicator.SetStatus(overlay.StatusError, "录音未就绪")
+				a.delayedHide(2000)
+				return
+			}
+			a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart)
+		}()
+		return
+	}
 
 	if cancel {
 		logger.Info("Recording cancelled (combination key)")
@@ -570,7 +802,12 @@
 		perfID := newPerfTraceID()
 		pipelineStart := time.Now()
 		logger.Info("PERF pipeline_start id=%d mode=hold", perfID)
-		samples, hasVoice := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart)
+		samples, hasVoice, ok := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart, false)
+		if !ok {
+			a.indicator.SetStatus(overlay.StatusError, "录音未就绪")
+			a.delayedHide(2000)
+			return
+		}
 		a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart)
 	}()
 }
@@ -597,10 +834,20 @@
 		logger.Error("Failed to start tap recording: %v", err)
 		a.isFreetalking = false
 		a.isRecording = false
+		a.setRecordingStartErrorLocked(err)
+		a.delayedHide(2000)
 		return
 	}
 	a.startLiveCaptionLocked(overlay.StatusFreetalking)
 	a.startRecordingTimer(overlay.StatusFreetalking)
+}
+
+func (a *App) setRecordingStartErrorLocked(err error) {
+	if errors.Is(err, audio.ErrMicrophonePermission) {
+		a.indicator.SetStatus(overlay.StatusError, "麦克风权限")
+		return
+	}
+	a.indicator.SetStatus(overlay.StatusError, "录音启动失败")
 }
 
 // startRecordingTimer updates the indicator text with elapsed time every second.
@@ -759,7 +1006,12 @@
 		perfID := newPerfTraceID()
 		pipelineStart := time.Now()
 		logger.Info("PERF pipeline_start id=%d mode=tap", perfID)
-		samples, hasVoice := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart)
+		samples, hasVoice, ok := a.stopRecorderAfterReleaseTailCapture(perfID, pipelineStart, false)
+		if !ok {
+			a.indicator.SetStatus(overlay.StatusError, "录音未就绪")
+			a.delayedHide(2000)
+			return
+		}
 		a.recognizeAndPaste(hasVoice, samples, perfID, pipelineStart)
 	}()
 }
@@ -859,8 +1111,61 @@
 	return time.Duration(nanos)
 }
 
-func (a *App) stopRecorderAfterReleaseTailCapture(perfID int64, pipelineStart time.Time) ([]float32, bool) {
+func (a *App) waitForHoldStartupSamples(perfID int64, pipelineStart time.Time) bool {
+	deadline := time.NewTimer(holdStartupSampleWaitTimeout)
+	defer deadline.Stop()
+	ticker := time.NewTicker(holdStartupSamplePollInterval)
+	defer ticker.Stop()
+
+	for {
+		samples, _ := a.recorder.ReadSamplesSince(0)
+		hasVoice := a.recorder.HasVoiceActivity()
+
+		a.mu.Lock()
+		starting := a.isHoldRecorderStarting
+		a.mu.Unlock()
+
+		if len(samples) > 0 {
+			logger.Info(
+				"PERF hold_startup_sample_wait_done id=%d result=samples samples=%d has_voice=%t since_start_ms=%d",
+				perfID,
+				len(samples),
+				hasVoice,
+				perfSinceMS(pipelineStart),
+			)
+			return true
+		}
+		if !starting {
+			logger.Info("PERF hold_startup_sample_wait_done id=%d result=start_complete_without_samples since_start_ms=%d", perfID, perfSinceMS(pipelineStart))
+			return true
+		}
+
+		select {
+		case <-a.ctx.Done():
+			logger.Info("PERF hold_startup_sample_wait_done id=%d result=app_stopping since_start_ms=%d", perfID, perfSinceMS(pipelineStart))
+			return false
+		case <-deadline.C:
+			logger.Error("Hold release reached audio startup sample wait timeout without PCM")
+			logger.Info("PERF hold_startup_sample_wait_done id=%d result=timeout timeout_ms=%d since_start_ms=%d", perfID, holdStartupSampleWaitTimeout.Milliseconds(), perfSinceMS(pipelineStart))
+			return false
+		case <-ticker.C:
+		}
+	}
+}
+
+func (a *App) stopRecorderAfterReleaseTailCapture(perfID int64, pipelineStart time.Time, waitForStartupSamples bool) ([]float32, bool, bool) {
 	a.waitForReleaseTailCapture(perfID)
+
+	if waitForStartupSamples && !a.waitForHoldStartupSamples(perfID, pipelineStart) {
+		stopStart := time.Now()
+		a.recorder.Stop()
+		stopElapsed := time.Since(stopStart)
+		a.mu.Lock()
+		a.isStoppingRecording = false
+		a.mu.Unlock()
+		logger.Info("PERF audio_stop_done id=%d result=audio_not_ready stop_ms=%d samples=0 audio_ms=0 has_voice=false since_start_ms=%d", perfID, perfDurationMS(stopElapsed), perfSinceMS(pipelineStart))
+		return nil, false, false
+	}
 
 	stopStart := time.Now()
 	samples := a.recorder.StopAndGetSamples()
@@ -885,10 +1190,10 @@
 		perfSinceMS(pipelineStart),
 	)
 
-	return samples, hasVoice
+	return samples, hasVoice, true
 }
 
-// recognizeAndPaste runs ASR on the recorded samples and pastes the result.
+// recognizeAndPaste runs ASR on the recorded samples and routes the result to text output.
 // Shared by both hold and tap modes.
 func (a *App) recognizeAndPaste(hasVoice bool, samples []float32, perfID int64, pipelineStart time.Time) {
 	// Snapshot config values under lock to avoid data races
@@ -897,17 +1202,32 @@
 	hotkeyVK := a.cfg.HotkeyVK
 	autoHide := a.cfg.AutoHide
 	copyToClipboard := a.cfg.CopyToClipboard
+	autoPasteExperiment := a.cfg.AutoPasteExperiment
 	a.mu.Unlock()
 
 	logger.Info(
-		"PERF recognize_pipeline_input id=%d has_voice=%t samples=%d audio_ms=%d copy_to_clipboard=%t since_start_ms=%d",
+		"PERF recognize_pipeline_input id=%d has_voice=%t samples=%d audio_ms=%d copy_to_clipboard=%t auto_paste_experiment=%t since_start_ms=%d",
 		perfID,
 		hasVoice,
 		len(samples),
 		perfAudioDurationMS(samples),
 		copyToClipboard,
+		autoPasteExperiment,
 		perfSinceMS(pipelineStart),
 	)
+
+	outputOptions := textoutput.OutputOptions{
+		KeepClipboard:       copyToClipboard,
+		AutoPasteExperiment: autoPasteExperiment,
+	}
+	if autoPasteExperiment {
+		outputOptions.AutoPasteGuard = a.outputRouter.CaptureAutoPasteGuard()
+		logger.Info(
+			"Text output auto-paste experiment guard captured ok=%t identity_present=%t",
+			outputOptions.AutoPasteGuard.OK,
+			outputOptions.AutoPasteGuard.Identity != "",
+		)
+	}
 
 	if !hasVoice {
 		logger.Info("No voice activity detected")
@@ -978,7 +1298,8 @@
 		return
 	}
 
-	logger.Info("Recognized: %s", text)
+	textRunes := len([]rune(text))
+	logger.Info("Recognized text produced: text_runes=%d", textRunes)
 	historyStart := time.Now()
 	a.history.Add(text)
 	logger.Info("PERF history_done id=%d history_ms=%d since_start_ms=%d", perfID, perfSinceMS(historyStart), perfSinceMS(pipelineStart))
@@ -994,23 +1315,41 @@
 		perfSinceMS(pipelineStart),
 	)
 
-	pasteStart := time.Now()
-	if err := a.paster.Paste(text, copyToClipboard); err != nil {
-		pasteElapsed := time.Since(pasteStart)
-		logger.Info("PERF paste_done id=%d result=error paste_ms=%d since_start_ms=%d", perfID, perfDurationMS(pasteElapsed), perfSinceMS(pipelineStart))
-		logger.Error("Paste failed, trying fallback: %v", err)
-		typeStart := time.Now()
-		if err := a.paster.TypeText(text); err != nil {
-			logger.Info("PERF fallback_type_done id=%d result=error type_ms=%d total_ms=%d", perfID, perfSinceMS(typeStart), perfSinceMS(pipelineStart))
-			logger.Error("Fallback type also failed: %v", err)
+	outputStart := time.Now()
+	output := a.outputRouter.OutputWithOptions(text, outputOptions)
+	a.emitTextOutputUpdated(output)
+	if !output.OK {
+		logger.Info(
+			"PERF text_output_done id=%d result=error output_ms=%d text_runes=%d fallback_available=%t since_start_ms=%d",
+			perfID,
+			perfDurationMS(time.Since(outputStart)),
+			textRunes,
+			output.FallbackAvailable,
+			perfSinceMS(pipelineStart),
+		)
+		logger.Error(
+			"Text output failed: needs_permission=%t fallback_available=%t direct_inserted=%t text_runes=%d",
+			output.NeedsPermission,
+			output.FallbackAvailable,
+			output.DirectInserted,
+			textRunes,
+		)
+		if output.FallbackAvailable {
+			a.indicator.SetStatus(overlay.StatusError, "文本已保留")
+		} else {
 			a.indicator.SetStatus(overlay.StatusError, "需辅助权限")
-			a.delayedHideIf(autoHide, 2500)
-			return
 		}
-		logger.Info("PERF fallback_type_done id=%d result=success type_ms=%d total_ms=%d", perfID, perfSinceMS(typeStart), perfSinceMS(pipelineStart))
-	} else {
-		logger.Info("PERF paste_done id=%d result=success paste_ms=%d since_start_ms=%d", perfID, perfSinceMS(pasteStart), perfSinceMS(pipelineStart))
+		a.delayedHideIf(autoHide, 2500)
+		return
 	}
+	logger.Info(
+		"PERF text_output_done id=%d result=success direct_inserted=%t output_ms=%d text_runes=%d since_start_ms=%d",
+		perfID,
+		output.DirectInserted,
+		perfDurationMS(time.Since(outputStart)),
+		textRunes,
+		perfSinceMS(pipelineStart),
+	)
 
 	a.indicator.SetStatus(overlay.StatusDone, "完成")
 	if soundFeedback {
@@ -1111,6 +1450,23 @@
 		return "total"
 	default:
 		return ""
+	}
+}
+
+func (a *App) emitTextOutputUpdated(result textoutput.Result) {
+	if a == nil || a.wailsApp == nil {
+		return
+	}
+	a.wailsApp.Event.Emit(textOutputUpdated, textOutputUpdatePayload(result))
+}
+
+func textOutputUpdatePayload(result textoutput.Result) map[string]interface{} {
+	return map[string]interface{}{
+		"ok":                result.OK,
+		"needsPermission":   result.NeedsPermission,
+		"directInserted":    result.DirectInserted,
+		"fallbackAvailable": result.FallbackAvailable,
+		"hasText":           result.Text != "",
 	}
 }
 
@@ -1357,6 +1713,9 @@
 }
 
 func hotkeyReadyText(keyName, mode string) string {
+	if keyName == "" {
+		return "触发键未设置"
+	}
 	if mode == config.HotkeyModeTap {
 		return "点按" + keyName + "说话"
 	}

--
Gitblit v1.9.3