dsh-live-voice 0.0.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,12 +25,15 @@ export function createComponents(React) {
25
25
  };
26
26
  const paths = {
27
27
  mic: 'M9 5a3 3 0 0 1 6 0v7a3 3 0 0 1-6 0V5M6 10v2a6 6 0 0 0 12 0v-2M12 18v4M8 22h8',
28
+ micOff:
29
+ 'M9 9v3a3 3 0 0 0 5.12 2.12M15 9V5a3 3 0 0 0-5.64-1.42M6 10v2a6 6 0 0 0 9.5 4.88M18 10v2a6 6 0 0 1-.5 2.4M12 18v4M8 22h8M3 3l18 18',
28
30
  speaker: 'M3 9h4l6-5v16l-6-5H3V9M17 8a6 6 0 0 1 0 8M20 5a10 10 0 0 1 0 14',
29
31
  close: 'M6 6l12 12M18 6L6 18',
30
32
  stop: 'M6 6h12v12H6z',
31
33
  pause: 'M8 5v14M16 5v14',
32
34
  play: 'M7 4l13 8-13 8z',
33
35
  send: 'M3 11.5L21 3l-8.5 18-2-7.5L3 11.5zm7.5 2L21 3',
36
+ queue: 'M5 6h14M5 12h10M5 18h6M18 15v6M15 18h6',
34
37
  speakerOff: 'M3 9h4l6-5v16l-6-5H3V9M17 9l5 6M22 9l-5 6',
35
38
  };
36
39
  return h('svg', common, h('path', { d: paths[name] || paths.mic }));
@@ -189,7 +192,7 @@ export function createComponents(React) {
189
192
  }, [controller, enabled]);
190
193
  return h('canvas', { ref, className: 'dlv-wave', 'aria-hidden': true });
191
194
  }
192
- function RecordingBar({ controller }) {
195
+ function RecordingBar({ controller, questionOnly = false, overlay = false, overlayStyle }) {
193
196
  const state = useController(controller);
194
197
  const [now, setNow] = React.useState(Date.now());
195
198
  React.useEffect(() => {
@@ -200,6 +203,7 @@ export function createComponents(React) {
200
203
  }, [state.autoSendAt]);
201
204
  const [invoke, error, clearError] = useActions(controller);
202
205
  const capture = state.starting || state.listening || state.recognizing;
206
+ if (questionOnly && !state.answeringQuestion) return null;
203
207
  if (
204
208
  !state.conversation &&
205
209
  !capture &&
@@ -212,9 +216,13 @@ export function createComponents(React) {
212
216
  const remaining = state.autoSendAt
213
217
  ? Math.max(1, Math.ceil((state.autoSendAt - now) / 1000))
214
218
  : null;
215
- const status = remaining
216
- ? `Sending in ${remaining}…`
217
- : state.starting
219
+ const status = state.answeringQuestion && state.recognizing
220
+ ? 'Recognizing answer…'
221
+ : state.answeringQuestion && state.listening
222
+ ? 'Listening for your answer…'
223
+ : remaining
224
+ ? `Sending in ${remaining}…`
225
+ : state.starting
218
226
  ? 'Starting microphone…'
219
227
  : state.paused
220
228
  ? 'Speech paused'
@@ -229,7 +237,10 @@ export function createComponents(React) {
229
237
  : 'Voice ready';
230
238
  return h(
231
239
  'div',
232
- { className: 'dlv-bar-wrap' },
240
+ {
241
+ className: overlay ? 'dlv-bar-wrap dlv-question-overlay' : 'dlv-bar-wrap',
242
+ style: overlay ? overlayStyle : undefined,
243
+ },
233
244
  h(
234
245
  'div',
235
246
  { className: 'dlv-pill', role: 'group', 'aria-label': 'Voice controls' },
@@ -251,25 +262,46 @@ export function createComponents(React) {
251
262
  h('span', { className: 'dlv-status', role: 'status', 'aria-live': 'polite' }, status),
252
263
  h(Button, {
253
264
  className: 'dlv-live-toggle',
254
- label: 'Automatic sending',
255
- title: `Automatic sending: ${state.settings.sendingMode === 'automatic' ? 'on' : 'off'}`,
256
- icon: 'send',
257
- visibleLabel: state.settings.sendingMode === 'automatic' ? 'ON' : 'OFF',
258
- role: 'switch',
259
- 'aria-checked': state.settings.sendingMode === 'automatic',
265
+ label: 'Automatic delivery mode',
266
+ title: `Automatic delivery: ${
267
+ state.settings.sendingMode === 'steer'
268
+ ? 'send to the running agent'
269
+ : state.settings.sendingMode === 'queue'
270
+ ? 'queue'
271
+ : 'off'
272
+ }`,
273
+ icon: state.settings.sendingMode === 'queue' ? 'queue' : 'send',
274
+ visibleLabel:
275
+ state.settings.sendingMode === 'steer'
276
+ ? 'SEND'
277
+ : state.settings.sendingMode === 'queue'
278
+ ? 'QUEUE'
279
+ : 'OFF',
280
+ 'aria-label': `Automatic delivery: ${state.settings.sendingMode || 'manual'}`,
281
+ 'aria-pressed': ['queue', 'steer'].includes(state.settings.sendingMode),
282
+ 'data-mode': state.settings.sendingMode || 'manual',
260
283
  onClick: () =>
261
284
  invoke('updateSettings', {
262
- sendingMode: state.settings.sendingMode === 'automatic' ? 'manual' : 'automatic',
285
+ sendingMode: !['queue', 'steer'].includes(state.settings.sendingMode)
286
+ ? 'queue'
287
+ : state.settings.sendingMode === 'queue'
288
+ ? 'steer'
289
+ : 'manual',
263
290
  }),
264
291
  }),
265
292
  h(Button, {
266
- className: 'dlv-live-toggle',
293
+ className: `dlv-live-toggle${state.speechSegmentsRemaining > 0 ? ' dlv-live-toggle-expanded' : ''}`,
267
294
  label: 'Automatic assistant speech',
268
295
  title: `Automatic assistant speech: ${
269
296
  state.settings.announceAssistantMessages !== false ? 'on' : 'off'
270
- }`,
297
+ }${state.speechSegmentsRemaining > 0 ? ` — ${state.speechSegmentsRemaining} speech segment${state.speechSegmentsRemaining === 1 ? '' : 's'} remaining` : ''}`,
271
298
  icon: state.settings.announceAssistantMessages !== false ? 'speaker' : 'speakerOff',
272
- visibleLabel: state.settings.announceAssistantMessages !== false ? 'ON' : 'OFF',
299
+ visibleLabel:
300
+ state.settings.announceAssistantMessages === false
301
+ ? 'OFF'
302
+ : state.speechSegmentsRemaining > 0
303
+ ? String(state.speechSegmentsRemaining)
304
+ : 'ON',
273
305
  role: 'switch',
274
306
  'aria-checked': state.settings.announceAssistantMessages !== false,
275
307
  onClick: () =>
@@ -293,9 +325,14 @@ export function createComponents(React) {
293
325
  : null,
294
326
  capture
295
327
  ? h(Button, {
296
- label: 'Stop listening',
297
- icon: 'stop',
298
- onClick: () => invoke('stopListening'),
328
+ className: 'dlv-live-toggle dlv-mic-state',
329
+ label: state.muted ? 'Resume listening' : 'Ignore composer input',
330
+ title: `Microphone input: ${state.muted ? 'ignoring' : 'listening'}`,
331
+ icon: state.muted ? 'micOff' : 'mic',
332
+ visibleLabel: state.muted ? 'IGNORING' : 'LISTENING',
333
+ 'aria-pressed': Boolean(state.muted),
334
+ 'data-muted': state.muted ? 'true' : 'false',
335
+ onClick: () => invoke(state.muted ? 'resumeListeningInput' : 'muteListening'),
299
336
  })
300
337
  : null,
301
338
  state.speaking && !state.paused && state.capabilities[state.settings.engine]?.pause
@@ -344,6 +381,59 @@ export function createComponents(React) {
344
381
  const [invoke, error, clearError] = useActions(controller);
345
382
  const settings = state.settings || {};
346
383
  const capabilities = state.capabilities || {};
384
+ const tabsId = React.useId();
385
+ const tabs = [
386
+ { id: 'conversation', label: 'Conversation' },
387
+ { id: 'speech', label: 'Speech' },
388
+ { id: 'recognition', label: 'Speech recognition' },
389
+ ];
390
+ const tabRefs = React.useRef([]);
391
+ const [activeTab, setActiveTab] = React.useState('conversation');
392
+ const activateTab = (index) => {
393
+ const tab = tabs[index];
394
+ if (!tab) return;
395
+ setActiveTab(tab.id);
396
+ tabRefs.current[index]?.focus();
397
+ };
398
+ const handleTabKeyDown = (event, index) => {
399
+ let nextIndex;
400
+ if (event.key === 'ArrowRight') nextIndex = (index + 1) % tabs.length;
401
+ else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + tabs.length) % tabs.length;
402
+ else if (event.key === 'Home') nextIndex = 0;
403
+ else if (event.key === 'End') nextIndex = tabs.length - 1;
404
+ else return;
405
+ event.preventDefault();
406
+ activateTab(nextIndex);
407
+ };
408
+ const [audioDevices, setAudioDevices] = React.useState([]);
409
+ React.useEffect(() => {
410
+ let active = true;
411
+ const refresh = async () => {
412
+ try {
413
+ const devices = await globalThis.navigator?.mediaDevices?.enumerateDevices?.();
414
+ if (active) setAudioDevices(Array.from(devices || []));
415
+ } catch {
416
+ if (active) setAudioDevices([]);
417
+ }
418
+ };
419
+ void refresh();
420
+ globalThis.navigator?.mediaDevices?.addEventListener?.('devicechange', refresh);
421
+ return () => {
422
+ active = false;
423
+ globalThis.navigator?.mediaDevices?.removeEventListener?.('devicechange', refresh);
424
+ };
425
+ }, []);
426
+ const deviceOptions = (kind, fallback) => [
427
+ { value: '', label: 'System default' },
428
+ ...audioDevices
429
+ .filter(
430
+ (device) => device.kind === kind && device.deviceId && device.deviceId !== 'default',
431
+ )
432
+ .map((device, index) => ({
433
+ value: device.deviceId,
434
+ label: device.label || `${fallback} ${index + 1}`,
435
+ })),
436
+ ];
347
437
  const field = (label, key, options) =>
348
438
  h(
349
439
  'label',
@@ -370,12 +460,17 @@ export function createComponents(React) {
370
460
  ),
371
461
  ),
372
462
  );
373
- const card = (title, children, open = false) =>
463
+ const panel = (id, children) =>
374
464
  h(
375
- 'details',
376
- { className: 'dlv-settings-card', open },
377
- h('summary', null, title),
378
- h('div', { className: 'dlv-settings-card-body' }, ...children),
465
+ 'div',
466
+ {
467
+ id: `${tabsId}-panel-${id}`,
468
+ className: 'dlv-settings-panel',
469
+ role: 'tabpanel',
470
+ 'aria-labelledby': `${tabsId}-tab-${id}`,
471
+ hidden: activeTab !== id,
472
+ },
473
+ ...children,
379
474
  );
380
475
  const subcard = (title, children, open = false) =>
381
476
  h(
@@ -392,9 +487,41 @@ export function createComponents(React) {
392
487
  ? h(Button, { label: 'Close voice settings', icon: 'close', onClick: onClose })
393
488
  : null,
394
489
  h(
395
- 'details',
396
- { className: 'dlv-settings-card' },
397
- h('summary', null, 'Speech output'),
490
+ 'div',
491
+ { className: 'dlv-settings-tabs', role: 'tablist', 'aria-label': 'Live Voice settings' },
492
+ tabs.map((tab, index) => {
493
+ const selected = activeTab === tab.id;
494
+ return h(
495
+ 'button',
496
+ {
497
+ key: tab.id,
498
+ ref: (element) => {
499
+ tabRefs.current[index] = element;
500
+ },
501
+ id: `${tabsId}-tab-${tab.id}`,
502
+ type: 'button',
503
+ role: 'tab',
504
+ className: 'dlv-settings-tab',
505
+ 'aria-selected': selected,
506
+ 'aria-controls': `${tabsId}-panel-${tab.id}`,
507
+ 'data-active': selected ? 'true' : undefined,
508
+ tabIndex: selected ? 0 : -1,
509
+ onClick: () => setActiveTab(tab.id),
510
+ onKeyDown: (event) => handleTabKeyDown(event, index),
511
+ },
512
+ tab.label,
513
+ );
514
+ }),
515
+ ),
516
+ h(
517
+ 'div',
518
+ {
519
+ id: `${tabsId}-panel-speech`,
520
+ className: 'dlv-settings-panel',
521
+ role: 'tabpanel',
522
+ 'aria-labelledby': `${tabsId}-tab-speech`,
523
+ hidden: activeTab !== 'speech',
524
+ },
398
525
  h(
399
526
  'div',
400
527
  { className: 'dlv-settings-card-body' },
@@ -415,27 +542,27 @@ export function createComponents(React) {
415
542
  disabled: capabilities.browser?.supported === false,
416
543
  },
417
544
  ]),
545
+ settings.engine !== 'say'
546
+ ? field('Output device', 'outputDeviceId', deviceOptions('audiooutput', 'Audio output'))
547
+ : h('small', null, 'macOS say uses the output selected on the DSH host.'),
418
548
  settings.engine === 'browser'
419
- ? field('Local browser voice', 'voice', [
420
- { value: '', label: 'Automatic local voice' },
421
- ...(capabilities.browser?.voices || []).map((voice) => ({
422
- value: voice.voiceURI || voice.name,
423
- label: `${voice.name} — ${voice.lang || 'unknown language'}`,
424
- })),
425
- ])
426
- : settings.engine === 'say'
427
- ? h(
428
- 'label',
549
+ ? h(
550
+ React.Fragment,
551
+ null,
552
+ h(
553
+ 'small',
429
554
  null,
430
- 'macOS say voice (empty uses system default)',
431
- h('input', {
432
- type: 'text',
433
- value: settings.voice || '',
434
- onChange: (event) => invoke('updateSettings', { voice: event.target.value }),
435
- autoComplete: 'off',
436
- }),
437
- )
438
- : null,
555
+ 'Browser speech synthesis may ignore the selected output device; this browser API normally follows the system default.',
556
+ ),
557
+ field('Local browser voice', 'voice', [
558
+ { value: '', label: 'Automatic local voice' },
559
+ ...(capabilities.browser?.voices || []).map((voice) => ({
560
+ value: voice.voiceURI || voice.name,
561
+ label: `${voice.name} — ${voice.lang || 'unknown language'}`,
562
+ })),
563
+ ]),
564
+ )
565
+ : null,
439
566
  settings.engine === 'qwen-http'
440
567
  ? h(
441
568
  React.Fragment,
@@ -451,6 +578,49 @@ export function createComponents(React) {
451
578
  ]),
452
579
  )
453
580
  : null,
581
+ subcard('Filtering', [
582
+ h(
583
+ 'label',
584
+ { key: 'output-code-filter', className: 'dlv-check' },
585
+ h('input', {
586
+ type: 'checkbox',
587
+ checked: settings.outputCodeFilterEnabled !== false,
588
+ onChange: (event) =>
589
+ invoke('updateSettings', { outputCodeFilterEnabled: event.target.checked }),
590
+ }),
591
+ ' Filter Markdown code blocks before speaking',
592
+ ),
593
+ h(
594
+ 'label',
595
+ { key: 'output-code-lines' },
596
+ 'Read code blocks up to this many lines',
597
+ h('input', {
598
+ type: 'number',
599
+ min: 0,
600
+ max: 100,
601
+ value: settings.outputCodeMaxLines ?? 5,
602
+ disabled: settings.outputCodeFilterEnabled === false,
603
+ onChange: (event) => {
604
+ const value = Number(event.target.value);
605
+ if (Number.isInteger(value) && value >= 0 && value <= 100)
606
+ invoke('updateSettings', { outputCodeMaxLines: value });
607
+ },
608
+ }),
609
+ ),
610
+ h(
611
+ 'label',
612
+ { key: 'output-code-notice' },
613
+ 'Replacement phrase for larger code blocks',
614
+ h('input', {
615
+ type: 'text',
616
+ maxLength: 300,
617
+ value: settings.outputCodeNotice || 'Look the code on out conversation',
618
+ disabled: settings.outputCodeFilterEnabled === false,
619
+ onChange: (event) =>
620
+ invoke('updateSettings', { outputCodeNotice: event.target.value }),
621
+ }),
622
+ ),
623
+ ]),
454
624
  h(
455
625
  'label',
456
626
  null,
@@ -474,12 +644,52 @@ export function createComponents(React) {
474
644
  null,
475
645
  'Qwen synthesis runs on the DSH host and the generated WAV plays in this browser. macOS say plays on the host; Browser speech plays on this device.',
476
646
  ),
647
+ ...['qwen-http', 'say', 'browser']
648
+ .filter((id) => capabilities[id]?.supported === false)
649
+ .map((id) =>
650
+ h(
651
+ 'p',
652
+ { key: id, role: 'status' },
653
+ `${id === 'qwen-http' ? 'Qwen3 local' : id === 'say' ? 'macOS say' : 'Browser speech'}: ${capabilities[id].reason}`,
654
+ ),
655
+ ),
656
+ h(
657
+ 'div',
658
+ { className: 'dlv-settings-actions' },
659
+ h(
660
+ 'button',
661
+ {
662
+ type: 'button',
663
+ disabled: capabilities[settings.engine]?.supported !== true || state.speaking,
664
+ onClick: () =>
665
+ invoke('speak', 'DSH Live Voice. The selected speech output is working.'),
666
+ },
667
+ state.speaking ? 'Testing speech…' : 'Test selected speech output',
668
+ ),
669
+ state.speaking || state.paused
670
+ ? h(
671
+ 'button',
672
+ { type: 'button', onClick: () => invoke('stopSpeech') },
673
+ 'Stop speech test',
674
+ )
675
+ : null,
676
+ h(
677
+ 'button',
678
+ { type: 'button', onClick: () => invoke('refreshCapabilities') },
679
+ 'Refresh available engines',
680
+ ),
681
+ ),
477
682
  ),
478
683
  ),
479
684
  h(
480
- 'details',
481
- { className: 'dlv-settings-card' },
482
- h('summary', null, 'Speech recognition'),
685
+ 'div',
686
+ {
687
+ id: `${tabsId}-panel-recognition`,
688
+ className: 'dlv-settings-panel',
689
+ role: 'tabpanel',
690
+ 'aria-labelledby': `${tabsId}-tab-recognition`,
691
+ hidden: activeTab !== 'recognition',
692
+ },
483
693
  h(
484
694
  'div',
485
695
  { className: 'dlv-settings-card-body' },
@@ -504,6 +714,14 @@ export function createComponents(React) {
504
714
  h('option', { value: 'whisper-http' }, 'Whisper HTTP — DSH host'),
505
715
  ),
506
716
  ),
717
+ field('Input device', 'inputDeviceId', deviceOptions('audioinput', 'Microphone')),
718
+ settings.recognitionEngine === 'browser'
719
+ ? h(
720
+ 'small',
721
+ null,
722
+ 'Browser SpeechRecognition may use the browser or system default microphone instead of this selection.',
723
+ )
724
+ : null,
507
725
  field('Recognition language', 'recognitionLang', [
508
726
  ...(settings.recognitionEngine !== 'browser'
509
727
  ? [{ value: 'auto', label: 'Automatic — detect language' }]
@@ -562,6 +780,97 @@ export function createComponents(React) {
562
780
  ])
563
781
  : null,
564
782
  h('p', null, 'Provider settings change with the selected recognition engine.'),
783
+ subcard('Voice commands', [
784
+ h(
785
+ 'label',
786
+ { key: 'voice-commands-enabled', className: 'dlv-check' },
787
+ h('input', {
788
+ type: 'checkbox',
789
+ checked: settings.voiceCommandsEnabled !== false,
790
+ onChange: (event) =>
791
+ invoke('updateSettings', { voiceCommandsEnabled: event.target.checked }),
792
+ }),
793
+ ' Enable exact voice commands',
794
+ ),
795
+ h(
796
+ 'small',
797
+ { key: 'voice-command-help' },
798
+ 'Separate phrases with commas. Matching ignores capitalization, accents, punctuation, and extra spaces. The entire final chunk must match.',
799
+ ),
800
+ ...[
801
+ ['Send to running agent', 'voiceCommandSend', 'send, send message'],
802
+ ['Put in queue', 'voiceCommandQueue', 'queue, queue message'],
803
+ ['End conversation', 'voiceCommandEnd', 'end, end conversation'],
804
+ ['Mute composer input', 'voiceCommandMute', 'mute, stop listening'],
805
+ ['Resume composer input', 'voiceCommandResume', 'resume, start listening'],
806
+ [
807
+ 'Stop assistant speech',
808
+ 'voiceCommandStopSpeaking',
809
+ 'stop talking, stop speaking, shut up',
810
+ ],
811
+ ['Clear composer', 'voiceCommandClear', 'clear all, clear message'],
812
+ ].map(([label, key, fallback]) =>
813
+ h(
814
+ 'label',
815
+ { key },
816
+ label,
817
+ h('textarea', {
818
+ rows: 2,
819
+ maxLength: 1000,
820
+ defaultValue: settings[key] || fallback,
821
+ disabled: settings.voiceCommandsEnabled === false,
822
+ onBlur: (event) => invoke('updateSettings', { [key]: event.target.value }),
823
+ }),
824
+ ),
825
+ ),
826
+ ]),
827
+ subcard('Filtering', [
828
+ h(
829
+ 'label',
830
+ { key: 'recognition-filter', className: 'dlv-check' },
831
+ h('input', {
832
+ type: 'checkbox',
833
+ checked: settings.recognitionFilterEnabled !== false,
834
+ onChange: (event) =>
835
+ invoke('updateSettings', { recognitionFilterEnabled: event.target.checked }),
836
+ }),
837
+ ' Ignore short final transcription chunks',
838
+ ),
839
+ h(
840
+ 'label',
841
+ { key: 'recognition-minimum-words' },
842
+ 'Minimum words per final chunk',
843
+ h('input', {
844
+ type: 'number',
845
+ min: 1,
846
+ max: 20,
847
+ value: settings.recognitionMinimumWords ?? 2,
848
+ disabled: settings.recognitionFilterEnabled === false,
849
+ onChange: (event) => {
850
+ const value = Number(event.target.value);
851
+ if (Number.isInteger(value) && value >= 1 && value <= 20)
852
+ invoke('updateSettings', { recognitionMinimumWords: value });
853
+ },
854
+ }),
855
+ h(
856
+ 'small',
857
+ null,
858
+ 'Final chunks with fewer words are ignored before they reach the composer or automatic delivery.',
859
+ ),
860
+ ),
861
+ ]),
862
+ capabilities.capture?.supported === false
863
+ ? h('p', { role: 'status' }, `Microphone: ${capabilities.capture.reason}`)
864
+ : capabilities.capture?.permission === 'prompt'
865
+ ? h(
866
+ 'p',
867
+ { role: 'status' },
868
+ 'Microphone permission will be requested only when you start dictation or a voice conversation.',
869
+ )
870
+ : null,
871
+ capabilities.recognition?.supported === false
872
+ ? h('p', { role: 'status' }, capabilities.recognition.reason)
873
+ : null,
565
874
  usesPluginVoiceDetection(settings.recognitionEngine)
566
875
  ? h(
567
876
  'details',
@@ -615,156 +924,110 @@ export function createComponents(React) {
615
924
  : null,
616
925
  ),
617
926
  ),
618
- card(
619
- 'Conversation',
620
- [
621
- h(
622
- 'label',
623
- { key: 'announce', className: 'dlv-check' },
624
- h('input', {
625
- type: 'checkbox',
626
- checked: settings.announceAssistantMessages !== false,
627
- onChange: (event) =>
628
- invoke('updateSettings', { announceAssistantMessages: event.target.checked }),
629
- }),
630
- ' Automatically speak new assistant messages',
631
- ),
632
- h(
633
- 'p',
634
- { key: 'policy' },
635
- 'During a voice conversation, assistant phrases are announced automatically. Playback waits while you are speaking.',
636
- ),
927
+ panel('conversation', [
928
+ h(
929
+ 'label',
930
+ { key: 'announce', className: 'dlv-check' },
931
+ h('input', {
932
+ type: 'checkbox',
933
+ checked: settings.announceAssistantMessages !== false,
934
+ onChange: (event) =>
935
+ invoke('updateSettings', { announceAssistantMessages: event.target.checked }),
936
+ }),
937
+ ' Automatically speak new assistant messages',
938
+ ),
939
+ h(
940
+ 'p',
941
+ { key: 'policy' },
942
+ 'During a voice conversation, assistant phrases are announced automatically. Playback waits while you are speaking.',
943
+ ),
944
+ h(
945
+ 'label',
946
+ { key: 'interrupt-message', className: 'dlv-check' },
947
+ h('input', {
948
+ type: 'checkbox',
949
+ checked: settings.interruptSpeechOnUserMessage === true,
950
+ onChange: (event) =>
951
+ invoke('updateSettings', { interruptSpeechOnUserMessage: event.target.checked }),
952
+ }),
953
+ ' Stop assistant speech when I send a message',
954
+ ),
955
+ h(
956
+ 'p',
957
+ { key: 'interrupt-message-description', className: 'dlv-setting-description' },
958
+ settings.interruptSpeechOnUserMessage
959
+ ? 'Sending or steering a new user message stops current or paused assistant speech.'
960
+ : 'Sending another message does not stop the assistant audio you are already hearing.',
961
+ ),
962
+ field('Listening mode', 'mode', [
963
+ { value: 'speaker', label: 'Speakers — gated listening' },
964
+ { value: 'headphones', label: 'Headphones — open microphone' },
965
+ ]),
966
+ h(
967
+ 'p',
968
+ { key: 'mode-description', className: 'dlv-setting-description' },
969
+ settings.mode === 'headphones'
970
+ ? 'Open microphone keeps listening while responses play. When your speech is detected, playback pauses and resumes only when you choose.'
971
+ : 'Gated listening releases the microphone while responses play, preventing speaker audio from being recognized. Use Take microphone to interrupt.',
972
+ ),
973
+ h(
974
+ 'label',
975
+ { key: 'speech-delay' },
976
+ 'Assistant response delay',
637
977
  h(
638
- 'label',
639
- { key: 'interrupt-message', className: 'dlv-check' },
640
- h('input', {
641
- type: 'checkbox',
642
- checked: settings.interruptSpeechOnUserMessage === true,
978
+ 'select',
979
+ {
980
+ value: String(settings.assistantSpeechDelaySeconds || 3),
643
981
  onChange: (event) =>
644
- invoke('updateSettings', { interruptSpeechOnUserMessage: event.target.checked }),
645
- }),
646
- ' Stop assistant speech when I send a message',
647
- ),
648
- h(
649
- 'p',
650
- { key: 'interrupt-message-description', className: 'dlv-setting-description' },
651
- settings.interruptSpeechOnUserMessage
652
- ? 'Sending or steering a new user message stops current or paused assistant speech.'
653
- : 'Sending another message does not stop the assistant audio you are already hearing.',
654
- ),
655
- field('Listening mode', 'mode', [
656
- { value: 'speaker', label: 'Speakers — gated listening' },
657
- { value: 'headphones', label: 'Headphones — open microphone' },
658
- ]),
659
- h(
660
- 'p',
661
- { key: 'mode-description', className: 'dlv-setting-description' },
662
- settings.mode === 'headphones'
663
- ? 'Open microphone keeps listening while responses play. When your speech is detected, playback pauses and resumes only when you choose.'
664
- : 'Gated listening releases the microphone while responses play, preventing speaker audio from being recognized. Use Take microphone to interrupt.',
982
+ invoke('updateSettings', {
983
+ assistantSpeechDelaySeconds: Number(event.target.value),
984
+ }),
985
+ },
986
+ [1, 2, 3, 4, 5, 6, 8, 10].map((seconds) =>
987
+ h('option', { key: seconds, value: String(seconds) }, seconds + ' seconds'),
988
+ ),
665
989
  ),
666
990
  h(
667
- 'label',
668
- { key: 'speech-delay' },
669
- 'Assistant response delay',
670
- h(
671
- 'select',
672
- {
673
- value: String(settings.assistantSpeechDelaySeconds || 3),
674
- onChange: (event) =>
675
- invoke('updateSettings', {
676
- assistantSpeechDelaySeconds: Number(event.target.value),
677
- }),
678
- },
679
- [1, 2, 3, 4, 5, 6, 8, 10].map((seconds) =>
680
- h('option', { key: seconds, value: String(seconds) }, seconds + ' seconds'),
681
- ),
682
- ),
683
- h(
684
- 'small',
685
- null,
686
- 'After you stop speaking, automatic assistant playback waits for this much continuous silence. Speaking again restarts the wait.',
687
- ),
991
+ 'small',
992
+ null,
993
+ 'After you stop speaking, automatic assistant playback waits for this much continuous silence. Speaking again restarts the wait.',
688
994
  ),
689
- field('Sending mode', 'sendingMode', [
690
- { value: 'manual', label: 'Manual — review and send' },
691
- { value: 'automatic', label: 'Automatic — send after silence' },
692
- ]),
693
- settings.sendingMode === 'automatic'
694
- ? h(
695
- 'label',
696
- { key: 'delay' },
697
- 'Send after silence',
698
- h(
699
- 'select',
700
- {
701
- value: String(settings.autoSendDelaySeconds || 4),
702
- onChange: (event) =>
703
- invoke('updateSettings', {
704
- autoSendDelaySeconds: Number(event.target.value),
705
- }),
706
- },
707
- [2, 3, 4, 5, 6, 8, 10].map((seconds) =>
708
- h('option', { key: seconds, value: String(seconds) }, seconds + ' seconds'),
709
- ),
710
- ),
711
- h(
712
- 'small',
713
- null,
714
- 'Countdown starts after a final recognized phrase. New speech or edits cancel it.',
995
+ ),
996
+ field('Sending mode', 'sendingMode', [
997
+ { value: 'manual', label: 'Off — review and send manually' },
998
+ { value: 'queue', label: 'Queue — automatically add after silence' },
999
+ { value: 'steer', label: 'Steer — automatically send to the running agent' },
1000
+ ]),
1001
+ settings.sendingMode !== 'manual'
1002
+ ? h(
1003
+ 'label',
1004
+ { key: 'delay' },
1005
+ 'Send after silence',
1006
+ h(
1007
+ 'select',
1008
+ {
1009
+ value: String(settings.autoSendDelaySeconds || 4),
1010
+ onChange: (event) =>
1011
+ invoke('updateSettings', {
1012
+ autoSendDelaySeconds: Number(event.target.value),
1013
+ }),
1014
+ },
1015
+ [2, 3, 4, 5, 6, 8, 10].map((seconds) =>
1016
+ h('option', { key: seconds, value: String(seconds) }, seconds + ' seconds'),
715
1017
  ),
716
- )
717
- : h(
718
- 'p',
719
- { key: 'manual', className: 'dlv-setting-description' },
720
- 'Recognized text stays in the composer until you use the normal DSH Send control.',
721
1018
  ),
722
- ],
723
- false,
724
- ),
725
- capabilities.capture?.supported === false
726
- ? h('p', { role: 'status' }, `Microphone: ${capabilities.capture.reason}`)
727
- : capabilities.capture?.permission === 'prompt'
728
- ? h(
729
- 'p',
730
- { role: 'status' },
731
- 'Microphone permission will be requested only when you start dictation or a voice conversation.',
1019
+ h(
1020
+ 'small',
1021
+ null,
1022
+ 'Countdown starts after a final recognized phrase. New speech or edits cancel it.',
1023
+ ),
732
1024
  )
733
- : null,
734
- capabilities.recognition?.supported === false
735
- ? h('p', { role: 'status' }, capabilities.recognition.reason)
736
- : null,
737
- ...['qwen-http', 'say', 'browser']
738
- .filter((id) => capabilities[id]?.supported === false)
739
- .map((id) =>
740
- h(
741
- 'p',
742
- { key: id, role: 'status' },
743
- `${id === 'qwen-http' ? 'Qwen3 local' : id === 'say' ? 'macOS say' : 'Browser speech'}: ${capabilities[id].reason}`,
744
- ),
745
- ),
746
- h(
747
- 'div',
748
- { className: 'dlv-settings-actions' },
749
- h(
750
- 'button',
751
- {
752
- type: 'button',
753
- disabled: capabilities[settings.engine]?.supported !== true || state.speaking,
754
- onClick: () =>
755
- invoke('speak', 'DSH Live Voice. The selected speech output is working.'),
756
- },
757
- state.speaking ? 'Testing speech…' : 'Test selected speech output',
758
- ),
759
- state.speaking || state.paused
760
- ? h('button', { type: 'button', onClick: () => invoke('stopSpeech') }, 'Stop speech test')
761
- : null,
762
- h(
763
- 'button',
764
- { type: 'button', onClick: () => invoke('refreshCapabilities') },
765
- 'Refresh available engines',
766
- ),
767
- ),
1025
+ : h(
1026
+ 'p',
1027
+ { key: 'manual', className: 'dlv-setting-description' },
1028
+ 'Recognized text stays in the composer until you use the normal DSH Send control.',
1029
+ ),
1030
+ ]),
768
1031
  h(ErrorText, {
769
1032
  error: error || state.error,
770
1033
  onDismiss: () => {