From 8f11df460a247dfcaafd84ce3046605724d11998 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Mon, 18 May 2026 17:20:11 +0800
Subject: [PATCH] feat: add hotkey input mode setting

---
 TODO.md                                                         |    7 +
 VoiceSnapGo/frontend/package-lock.json                          |    4 
 VoiceSnapGo/frontend/src/components/settings/AboutPage.svelte   |    2 
 VoiceSnapGo/internal/config/config.go                           |   17 +++
 VoiceSnapGo/build/darwin/Info.plist                             |    4 
 CHANGELOG.md                                                    |   16 +++
 VoiceSnapGo/build/config.yml                                    |    2 
 VoiceSnapGo/frontend/src/components/settings/GeneralPage.svelte |   74 ++++++++++++++
 VoiceSnapGo/services/config_service.go                          |   12 ++
 VoiceSnapGo/app.go                                              |  100 +++++++++++++++----
 VoiceSnapGo/frontend/package.json                               |    2 
 VoiceSnapGo/frontend/src/lib/stores/config.ts                   |    1 
 VoiceSnapGo/build/windows/wails.exe.manifest                    |    2 
 VoiceSnapGo/frontend/src/lib/i18n/zh.json                       |    9 +
 VoiceSnapGo/frontend/src/lib/i18n/en.json                       |    9 +
 15 files changed, 227 insertions(+), 34 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 886faf2..cd87d89 100755
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,21 @@
 # Changelog
 
+## v2.1.3 (2026-05-18)
+
+### 新功能
+
+- **语音输入方式可选**:设置页新增“点按 / 长按”模式。点按模式下,点一下快捷键开始说话,再点一下结束并输入;长按模式下,按住快捷键开始说话,松开后结束并输入。
+
+### 改进
+
+- 热键状态机拆分为清晰的点按与长按逻辑,避免短按/长按自动判断造成交互不确定。
+
+### 构建
+
+- build: `20260518.1651`
+
+---
+
 ## v2.1.2 (2026-05-18)
 
 ### Bug 修复
diff --git a/TODO.md b/TODO.md
index 3c44637..af44586 100644
--- a/TODO.md
+++ b/TODO.md
@@ -21,6 +21,13 @@
 
 ## Done
 
+- [2026-05-18] 增加语音输入交互方式设置。
+  - 新增配置 `HotkeyMode`,默认 `hold`。
+  - 设置页新增“语音输入方式”分段控件,支持“点按”和“长按”。
+  - 点按: 点一下快捷键开始说话,再点一下结束并识别输入。
+  - 长按: 长按快捷键开始录音,松开后结束并识别输入。
+  - 版本: `2.1.3`
+  - build: `20260518.1651`
 - [2026-05-18] 修复全屏窗口下语音输入悬浮指示器不显示。
   - 根因: macOS 全屏 Space 下普通 `NSWindow` + `NSFloatingWindowLevel` 不稳定,容易被全屏 App 压在下层。
   - 修复: 悬浮指示器改为非激活 `NSPanel`,窗口层级提升到 `kCGStatusWindowLevelKey`,并补充 `Transient`、`IgnoresCycle`、`FullScreenAuxiliary`、`CanJoinAllSpaces` 行为。
diff --git a/VoiceSnapGo/app.go b/VoiceSnapGo/app.go
index 2805702..595d31c 100755
--- a/VoiceSnapGo/app.go
+++ b/VoiceSnapGo/app.go
@@ -26,8 +26,8 @@
 )
 
 const (
-	appVersion        = "2.1.2"
-	appBuild          = "20260518.1623"
+	appVersion        = "2.1.3"
+	appBuild          = "20260518.1651"
 	appDisplayVersion = appVersion + " (build " + appBuild + ")"
 	appName           = "VoiceSnap"
 
@@ -249,9 +249,10 @@
 		a.hotkeyPollSeen = true
 		a.lastHotkeyDown = isDown
 		logger.Info(
-			"Hotkey poll state: key=%s vk=0x%X down=%t active=%t recording=%t freetalk=%t engineReady=%t recordingHotkey=%t",
+			"Hotkey poll state: key=%s vk=0x%X mode=%s down=%t active=%t recording=%t freetalk=%t engineReady=%t recordingHotkey=%t",
 			hotkey.GetKeyName(a.cfg.HotkeyVK),
 			a.cfg.HotkeyVK,
+			config.NormalizeHotkeyMode(a.cfg.HotkeyMode),
 			isDown,
 			a.hotkeyActive,
 			a.isRecording,
@@ -284,18 +285,27 @@
 		return
 	}
 
+	switch config.NormalizeHotkeyMode(a.cfg.HotkeyMode) {
+	case config.HotkeyModeTap:
+		a.pollTapHotkeyLocked(isDown)
+	default:
+		a.pollHoldHotkeyLocked(isDown)
+	}
+}
+
+func (a *App) pollHoldHotkeyLocked(isDown bool) {
 	if isDown {
 		if !a.hotkeyActive {
 			// Key just pressed
 			a.hotkeyActive = true
 			a.isCombination = false
 			a.hotkeyPressTime = time.Now()
-			logger.Info("Hotkey accepted: key=%s vk=0x%X freetalk=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.cfg.HotkeyVK, a.isFreetalking)
+			logger.Info("Hotkey accepted: key=%s vk=0x%X mode=%s freetalk=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.cfg.HotkeyVK, config.HotkeyModeHold, a.isFreetalking)
 
-			// If in free talk mode, stop on next press
+			// If the mode changed while a tap recording was active, let the next press stop it.
 			if a.isFreetalking {
-				logger.Info("Hotkey action: stop free talk")
-				a.stopFreetalkLocked()
+				logger.Info("Hotkey action: stop tap recording after mode switch")
+				a.stopTapRecordingLocked()
 				return
 			}
 		} else {
@@ -317,17 +327,54 @@
 	} else if a.hotkeyActive {
 		// Key released
 		pressDuration := time.Since(a.hotkeyPressTime)
-		logger.Info("Hotkey released: key=%s duration=%dms recording=%t combination=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), pressDuration.Milliseconds(), a.isRecording, a.isCombination)
+		logger.Info("Hotkey released: key=%s mode=%s duration=%dms recording=%t combination=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), config.HotkeyModeHold, pressDuration.Milliseconds(), a.isRecording, a.isCombination)
 		a.hotkeyActive = false
 
 		if a.isRecording {
 			// Hold-to-talk: release stops recording
 			a.stopRecordingLocked(a.isCombination)
-		} else if !a.isCombination && pressDuration < 300*time.Millisecond && time.Since(a.lastStopTime) > 500*time.Millisecond {
-			// Short tap (<300ms): start free talk mode
-			logger.Info("Hotkey action: start free talk after short tap")
-			a.startFreetalkLocked()
 		}
+	}
+}
+
+func (a *App) pollTapHotkeyLocked(isDown bool) {
+	if isDown {
+		if !a.hotkeyActive {
+			a.hotkeyActive = true
+			a.isCombination = false
+			a.hotkeyPressTime = time.Now()
+			logger.Info("Hotkey accepted: key=%s vk=0x%X mode=%s recording=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.cfg.HotkeyVK, config.HotkeyModeTap, a.isFreetalking)
+			return
+		}
+
+		if !a.isCombination && a.hk.IsAnyOtherKeyPressedSince(a.cfg.HotkeyVK, a.hotkeyPressTime) {
+			a.isCombination = true
+			logger.Info("Hotkey marked as combination: key=%s mode=%s", hotkey.GetKeyName(a.cfg.HotkeyVK), config.HotkeyModeTap)
+		}
+		return
+	}
+
+	if !a.hotkeyActive {
+		return
+	}
+
+	pressDuration := time.Since(a.hotkeyPressTime)
+	logger.Info("Hotkey released: key=%s mode=%s duration=%dms recording=%t combination=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), config.HotkeyModeTap, pressDuration.Milliseconds(), a.isRecording, a.isCombination)
+	a.hotkeyActive = false
+
+	if a.isCombination {
+		return
+	}
+
+	if a.isFreetalking {
+		logger.Info("Hotkey action: stop tap recording")
+		a.stopTapRecordingLocked()
+		return
+	}
+
+	if time.Since(a.lastStopTime) > 500*time.Millisecond {
+		logger.Info("Hotkey action: start tap recording")
+		a.startTapRecordingLocked()
 	}
 }
 
@@ -379,7 +426,7 @@
 	go a.recognizeAndPaste(hasVoice, samples)
 }
 
-func (a *App) startFreetalkLocked() {
+func (a *App) startTapRecordingLocked() {
 	if a.isRecording || a.isFreetalking {
 		return
 	}
@@ -389,7 +436,7 @@
 	a.silenceSince = time.Time{}
 	a.hideGen.Add(1)
 
-	logger.Info("Free talk started")
+	logger.Info("Tap recording started")
 	a.positionIndicator()
 	a.indicator.SetStatus(overlay.StatusFreetalking, "0:00")
 	a.indicator.Show()
@@ -398,7 +445,7 @@
 	}
 
 	if err := a.recorder.Start(); err != nil {
-		logger.Error("Failed to start free talk recording: %v", err)
+		logger.Error("Failed to start tap recording: %v", err)
 		a.isFreetalking = false
 		a.isRecording = false
 		return
@@ -431,7 +478,7 @@
 	}()
 }
 
-func (a *App) stopFreetalkLocked() {
+func (a *App) stopTapRecordingLocked() {
 	if !a.isFreetalking {
 		return
 	}
@@ -442,19 +489,20 @@
 	hasVoice := a.recorder.HasVoiceActivity()
 	samples := a.recorder.StopAndGetSamples()
 
-	logger.Info("Free talk stopped")
+	logger.Info("Tap recording stopped")
 	a.indicator.SetStatus(overlay.StatusProcessing, "识别中")
 	go a.recognizeAndPaste(hasVoice, samples)
 }
 
-// checkSilenceTimeout monitors volume during free-talk mode and auto-stops
-// recording if silence persists for silenceTimeoutDuration.
+// checkSilenceTimeout monitors legacy free-talk recordings. The explicit tap
+// mode is user-stopped by pressing the hotkey again, so it does not auto-stop
+// on silence.
 // Called from the audio volume callback (~every 50ms).
 func (a *App) checkSilenceTimeout(vol float64) {
 	a.mu.Lock()
 	defer a.mu.Unlock()
 
-	if !a.isFreetalking {
+	if !a.isFreetalking || config.NormalizeHotkeyMode(a.cfg.HotkeyMode) == config.HotkeyModeTap {
 		a.silenceSince = time.Time{}
 		return
 	}
@@ -502,7 +550,7 @@
 }
 
 // recognizeAndPaste runs ASR on the recorded samples and pastes the result.
-// Shared by both hold-to-talk and free-talk modes.
+// Shared by both hold and tap modes.
 func (a *App) recognizeAndPaste(hasVoice bool, samples []float32) {
 	// Snapshot config values under lock to avoid data races
 	a.mu.Lock()
@@ -631,6 +679,7 @@
 	// Show indicator briefly — snapshot config under lock
 	a.mu.Lock()
 	hotkeyVK := a.cfg.HotkeyVK
+	hotkeyMode := config.NormalizeHotkeyMode(a.cfg.HotkeyMode)
 	autoHide := a.cfg.AutoHide
 	indX := a.cfg.IndicatorX
 	indY := a.cfg.IndicatorY
@@ -638,11 +687,18 @@
 
 	keyName := hotkey.GetKeyName(hotkeyVK)
 	a.positionIndicatorAt(indX, indY)
-	a.indicator.SetStatus(overlay.StatusReady, "按住"+keyName+"说话")
+	a.indicator.SetStatus(overlay.StatusReady, hotkeyReadyText(keyName, hotkeyMode))
 	a.indicator.Show()
 	a.delayedHideIf(autoHide, 2000)
 }
 
+func hotkeyReadyText(keyName, mode string) string {
+	if mode == config.HotkeyModeTap {
+		return "点按" + keyName + "说话"
+	}
+	return "按住" + keyName + "说话"
+}
+
 // positionIndicator places the indicator at the saved position, or bottom-center if not saved.
 // Caller must hold a.mu (reads a.cfg).
 func (a *App) positionIndicator() {
diff --git a/VoiceSnapGo/build/config.yml b/VoiceSnapGo/build/config.yml
index 4e1cbbe..e29467f 100755
--- a/VoiceSnapGo/build/config.yml
+++ b/VoiceSnapGo/build/config.yml
@@ -7,7 +7,7 @@
   description: "Offline voice to text tool"
   copyright: "(c) 2025, VoiceSnap"
   comments: "Press and hold hotkey to record, release to transcribe"
-  version: "2.1.2"
+  version: "2.1.3"
 
 dev_mode:
   root_path: .
diff --git a/VoiceSnapGo/build/darwin/Info.plist b/VoiceSnapGo/build/darwin/Info.plist
index 926b27b..2eaf40f 100755
--- a/VoiceSnapGo/build/darwin/Info.plist
+++ b/VoiceSnapGo/build/darwin/Info.plist
@@ -15,9 +15,9 @@
     <key>CFBundlePackageType</key>
     <string>APPL</string>
     <key>CFBundleShortVersionString</key>
-    <string>2.1.2</string>
+    <string>2.1.3</string>
     <key>CFBundleVersion</key>
-    <string>20260518.1623</string>
+    <string>20260518.1651</string>
     <key>LSMinimumSystemVersion</key>
     <string>11.0</string>
     <key>NSMicrophoneUsageDescription</key>
diff --git a/VoiceSnapGo/build/windows/wails.exe.manifest b/VoiceSnapGo/build/windows/wails.exe.manifest
index b4dec20..9843877 100755
--- a/VoiceSnapGo/build/windows/wails.exe.manifest
+++ b/VoiceSnapGo/build/windows/wails.exe.manifest
@@ -3,7 +3,7 @@
   <assemblyIdentity
     type="win32"
     name="VoiceSnap"
-    version="2.1.2.0"
+    version="2.1.3.0"
     processorArchitecture="*"/>
   <dependency>
     <dependentAssembly>
diff --git a/VoiceSnapGo/frontend/package-lock.json b/VoiceSnapGo/frontend/package-lock.json
index c3b8e4a..1b4944a 100755
--- a/VoiceSnapGo/frontend/package-lock.json
+++ b/VoiceSnapGo/frontend/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "voicesnap-frontend",
-  "version": "2.1.2",
+  "version": "2.1.3",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "voicesnap-frontend",
-      "version": "2.1.2",
+      "version": "2.1.3",
       "dependencies": {
         "@wailsio/runtime": "latest"
       },
diff --git a/VoiceSnapGo/frontend/package.json b/VoiceSnapGo/frontend/package.json
index 824edf4..51ad42c 100755
--- a/VoiceSnapGo/frontend/package.json
+++ b/VoiceSnapGo/frontend/package.json
@@ -1,7 +1,7 @@
 {
   "name": "voicesnap-frontend",
   "private": true,
-  "version": "2.1.2",
+  "version": "2.1.3",
   "type": "module",
   "scripts": {
     "dev": "vite dev",
diff --git a/VoiceSnapGo/frontend/src/components/settings/AboutPage.svelte b/VoiceSnapGo/frontend/src/components/settings/AboutPage.svelte
index c4ecfb7..418e1b0 100755
--- a/VoiceSnapGo/frontend/src/components/settings/AboutPage.svelte
+++ b/VoiceSnapGo/frontend/src/components/settings/AboutPage.svelte
@@ -2,7 +2,7 @@
   import { Call } from '@wailsio/runtime'
   import { t } from '../../lib/i18n'
 
-  let version = $state('2.1.2')
+  let version = $state('2.1.3')
   let updateStatus = $state('')
   let updateColor = $state('')
   let checking = $state(false)
diff --git a/VoiceSnapGo/frontend/src/components/settings/GeneralPage.svelte b/VoiceSnapGo/frontend/src/components/settings/GeneralPage.svelte
index 091a855..d54ca49 100755
--- a/VoiceSnapGo/frontend/src/components/settings/GeneralPage.svelte
+++ b/VoiceSnapGo/frontend/src/components/settings/GeneralPage.svelte
@@ -2,7 +2,7 @@
   import { Call } from '@wailsio/runtime'
   import ToggleSwitch from '../shared/ToggleSwitch.svelte'
   import { t } from '../../lib/i18n'
-  import { autoHide, soundFeedback, copyToClipboard, startAtLogin, hotkeyVK } from '../../lib/stores/config'
+  import { autoHide, soundFeedback, copyToClipboard, startAtLogin, hotkeyVK, hotkeyMode } from '../../lib/stores/config'
   import { deviceName, engineStatus, engineHardwareInfo } from '../../lib/stores/app'
   import { hotkeyName } from '../../lib/stores/indicator'
 
@@ -21,6 +21,7 @@
   let status = $state('loading')
   let hwInfo = $state('')
   let currentKeyName = $state('Ctrl')
+  let hotkeyModeVal: 'tap' | 'hold' = $state('hold')
   let isRecording = $state(false)
   let hintText = $state('')
   let devices = $state<InputDevice[]>([])
@@ -34,6 +35,7 @@
   const unsub6 = hotkeyName.subscribe(k => { currentKeyName = k })
   const unsub7 = soundFeedback.subscribe(v => { soundFeedbackVal = v })
   const unsub8 = copyToClipboard.subscribe(v => { copyToClipboardVal = v })
+  const unsub9 = hotkeyMode.subscribe(v => { hotkeyModeVal = v })
 
   // Load actual device name from backend on mount
   async function loadDeviceName() {
@@ -69,6 +71,12 @@
       copyToClipboardVal = copy
     } catch {}
     try {
+      const mode: any = await Call.ByName('voicesnap/services.ConfigService.GetHotkeyMode')
+      const normalized = mode === 'tap' ? 'tap' : 'hold'
+      hotkeyMode.set(normalized)
+      hotkeyModeVal = normalized
+    } catch {}
+    try {
       const hideDock: any = await Call.ByName('voicesnap/services.ConfigService.GetHideDockIcon')
       hideDockIconVal = !!hideDock
     } catch {}
@@ -94,6 +102,14 @@
     copyToClipboard.set(checked)
     try {
       await Call.ByName('voicesnap/services.ConfigService.SetCopyToClipboard', checked)
+    } catch {}
+  }
+
+  async function onHotkeyModeChange(mode: 'tap' | 'hold') {
+    hotkeyMode.set(mode)
+    hotkeyModeVal = mode
+    try {
+      await Call.ByName('voicesnap/services.ConfigService.SetHotkeyMode', mode)
     } catch {}
   }
 
@@ -212,7 +228,7 @@
   <!-- Header -->
   <div class="header">
     <h1 class="tagline">{t('home.tagline')}</h1>
-    <p class="app-desc">{t('home.modeHint')}</p>
+    <p class="app-desc">{hotkeyModeVal === 'tap' ? t('home.modeHintTap') : t('home.modeHintHold')}</p>
   </div>
 
   <!-- Hotkey -->
@@ -232,6 +248,31 @@
     {#if hintText}
       <p class="hint" class:recording={isRecording}>{hintText}</p>
     {/if}
+
+    <div class="divider"></div>
+
+    <div class="setting-row">
+      <div class="setting-info">
+        <span class="setting-label">{t('settings.hotkeyMode')}</span>
+        <span class="setting-desc">
+          {hotkeyModeVal === 'tap' ? t('settings.hotkeyModeTapDesc') : t('settings.hotkeyModeHoldDesc')}
+        </span>
+      </div>
+      <div class="segmented" role="group" aria-label={t('settings.hotkeyMode')}>
+        <button
+          class:active={hotkeyModeVal === 'tap'}
+          onclick={() => onHotkeyModeChange('tap')}
+        >
+          {t('settings.hotkeyModeTap')}
+        </button>
+        <button
+          class:active={hotkeyModeVal === 'hold'}
+          onclick={() => onHotkeyModeChange('hold')}
+        >
+          {t('settings.hotkeyModeHold')}
+        </button>
+      </div>
+    </div>
 
   </div>
 
@@ -476,6 +517,35 @@
     color: var(--color-tertiary-label);
   }
 
+  .segmented {
+    display: inline-grid;
+    grid-template-columns: 1fr 1fr;
+    min-width: 148px;
+    padding: 2px;
+    border-radius: var(--radius-sm);
+    background: var(--color-bg-secondary);
+    flex-shrink: 0;
+  }
+
+  .segmented button {
+    min-width: 66px;
+    height: 30px;
+    padding: 0 12px;
+    border: none;
+    border-radius: calc(var(--radius-sm) - 2px);
+    background: transparent;
+    color: var(--color-secondary-label);
+    font-size: var(--font-size-sm);
+    font-weight: 500;
+    cursor: pointer;
+    transition: background var(--transition-fast), color var(--transition-fast);
+  }
+
+  .segmented button.active {
+    background: var(--color-blue);
+    color: white;
+  }
+
   .divider {
     height: 1px;
     background: var(--color-separator);
diff --git a/VoiceSnapGo/frontend/src/lib/i18n/en.json b/VoiceSnapGo/frontend/src/lib/i18n/en.json
index 4527cd3..5dacfa2 100755
--- a/VoiceSnapGo/frontend/src/lib/i18n/en.json
+++ b/VoiceSnapGo/frontend/src/lib/i18n/en.json
@@ -32,10 +32,17 @@
     "tagline": "Snap · Voice to Text",
     "description": "Hold {key} to speak, release to recognize and type",
     "hotkeyLabel": "Trigger Hotkey",
-    "modeHint": "Hold to record, release to type · Tap to start free talk, tap again to stop"
+    "modeHint": "Hold to record, release to type · Tap to start free talk, tap again to stop",
+    "modeHintTap": "Tap to start speaking, tap again to stop and type",
+    "modeHintHold": "Hold to speak, release to stop and type"
   },
   "settings": {
     "inputDevice": "Input Device",
+    "hotkeyMode": "Voice Input Mode",
+    "hotkeyModeTap": "Tap",
+    "hotkeyModeHold": "Hold",
+    "hotkeyModeTapDesc": "Tap the hotkey to start speaking, then tap again to stop and type",
+    "hotkeyModeHoldDesc": "Hold the hotkey to speak, release to stop and type",
     "autoHide": "Auto-hide Indicator",
     "autoHideDesc": "Automatically hide the floating indicator after completion",
     "soundFeedback": "Sound Feedback",
diff --git a/VoiceSnapGo/frontend/src/lib/i18n/zh.json b/VoiceSnapGo/frontend/src/lib/i18n/zh.json
index 8a52d67..38041b8 100755
--- a/VoiceSnapGo/frontend/src/lib/i18n/zh.json
+++ b/VoiceSnapGo/frontend/src/lib/i18n/zh.json
@@ -32,10 +32,17 @@
     "tagline": "Snap · 语音即输入",
     "description": "按住 {key} 说话,松开即识别并输入文字",
     "hotkeyLabel": "触发热键",
-    "modeHint": "长按录音,松开输入 · 短按开始自由说话,再按结束"
+    "modeHint": "长按录音,松开输入 · 短按开始自由说话,再按结束",
+    "modeHintTap": "点按开始说话,再点按结束并输入",
+    "modeHintHold": "长按开始说话,松开结束并输入"
   },
   "settings": {
     "inputDevice": "输入设备",
+    "hotkeyMode": "语音输入方式",
+    "hotkeyModeTap": "点按",
+    "hotkeyModeHold": "长按",
+    "hotkeyModeTapDesc": "点一下快捷键开始说话,再点一下结束并输入",
+    "hotkeyModeHoldDesc": "按住快捷键开始说话,松开后结束并输入",
     "autoHide": "自动隐藏指示器",
     "autoHideDesc": "操作完成后自动隐藏浮动指示器",
     "soundFeedback": "提示音",
diff --git a/VoiceSnapGo/frontend/src/lib/stores/config.ts b/VoiceSnapGo/frontend/src/lib/stores/config.ts
index 412c6f5..1c78f57 100755
--- a/VoiceSnapGo/frontend/src/lib/stores/config.ts
+++ b/VoiceSnapGo/frontend/src/lib/stores/config.ts
@@ -5,3 +5,4 @@
 export const copyToClipboard = writable<boolean>(true);
 export const startAtLogin = writable<boolean>(false);
 export const hotkeyVK = writable<number>(0xa5);
+export const hotkeyMode = writable<"tap" | "hold">("hold");
diff --git a/VoiceSnapGo/internal/config/config.go b/VoiceSnapGo/internal/config/config.go
index a0b131d..04b54c6 100755
--- a/VoiceSnapGo/internal/config/config.go
+++ b/VoiceSnapGo/internal/config/config.go
@@ -7,9 +7,15 @@
 	"voicesnap/internal/paths"
 )
 
+const (
+	HotkeyModeTap  = "tap"
+	HotkeyModeHold = "hold"
+)
+
 // Config holds the application configuration, compatible with the WPF version's config.json.
 type Config struct {
 	HotkeyVK                 int    `json:"HotkeyVK"`
+	HotkeyMode               string `json:"HotkeyMode"`
 	AutoHide                 bool   `json:"AutoHide"`
 	SoundFeedback            bool   `json:"SoundFeedback"`
 	HideDockIcon             bool   `json:"HideDockIcon"`
@@ -25,6 +31,7 @@
 func Default() *Config {
 	return &Config{
 		HotkeyVK:                 0xA5, // Right Alt
+		HotkeyMode:               HotkeyModeHold,
 		AutoHide:                 true,
 		SoundFeedback:            true,
 		HideDockIcon:             true,
@@ -55,9 +62,19 @@
 	if err := json.Unmarshal(data, cfg); err != nil {
 		return nil, err
 	}
+	cfg.HotkeyMode = NormalizeHotkeyMode(cfg.HotkeyMode)
 	return cfg, nil
 }
 
+func NormalizeHotkeyMode(mode string) string {
+	switch mode {
+	case HotkeyModeTap, HotkeyModeHold:
+		return mode
+	default:
+		return HotkeyModeHold
+	}
+}
+
 // Save writes the config to disk.
 func Save(cfg *Config) {
 	if err := paths.Ensure(); err != nil {
diff --git a/VoiceSnapGo/services/config_service.go b/VoiceSnapGo/services/config_service.go
index 55fe812..6bffa6f 100755
--- a/VoiceSnapGo/services/config_service.go
+++ b/VoiceSnapGo/services/config_service.go
@@ -42,6 +42,18 @@
 	return s.cfg.SoundFeedback
 }
 
+// SetHotkeyMode sets the voice input interaction mode.
+func (s *ConfigService) SetHotkeyMode(mode string) {
+	s.cfg.HotkeyMode = config.NormalizeHotkeyMode(mode)
+	config.Save(s.cfg)
+}
+
+// GetHotkeyMode returns the voice input interaction mode.
+func (s *ConfigService) GetHotkeyMode() string {
+	s.cfg.HotkeyMode = config.NormalizeHotkeyMode(s.cfg.HotkeyMode)
+	return s.cfg.HotkeyMode
+}
+
 // SetCopyToClipboard sets whether recognized text remains in the clipboard after input.
 func (s *ConfigService) SetCopyToClipboard(enabled bool) {
 	s.cfg.CopyToClipboard = enabled

--
Gitblit v1.9.3