@phenx-inc/ctlsurf 0.6.0 → 0.7.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.
Files changed (26) hide show
  1. package/out/headless/index.mjs +1 -1
  2. package/out/headless/index.mjs.map +1 -1
  3. package/out/renderer/assets/{cssMode-DbMmcl1h.js → cssMode-eTXVdAkZ.js} +3 -3
  4. package/out/renderer/assets/{freemarker2-CvaHiy92.js → freemarker2-B5BKaiK4.js} +1 -1
  5. package/out/renderer/assets/{handlebars-D58lUIOu.js → handlebars-BIdLd2wU.js} +1 -1
  6. package/out/renderer/assets/{html-D1h1aJbM.js → html-BXL4cnLS.js} +1 -1
  7. package/out/renderer/assets/{htmlMode-BdkAp9qr.js → htmlMode-46N3XG2c.js} +3 -3
  8. package/out/renderer/assets/{index-DJFYmHjz.css → index-Cf-RsxoC.css} +74 -0
  9. package/out/renderer/assets/{index-B60JU1yI.js → index-dRvutfbl.js} +180 -27
  10. package/out/renderer/assets/{javascript-CXqZcnvb.js → javascript-n_iZZzDX.js} +2 -2
  11. package/out/renderer/assets/{jsonMode-BuVr-eSl.js → jsonMode-DXDczSNu.js} +3 -3
  12. package/out/renderer/assets/{liquid-LKu0Wd0B.js → liquid-B1QweUh7.js} +1 -1
  13. package/out/renderer/assets/{lspLanguageFeatures-Cjr_4HGs.js → lspLanguageFeatures-DqzMqkRk.js} +1 -1
  14. package/out/renderer/assets/{mdx-Bl84ILla.js → mdx-BCv8lm5e.js} +1 -1
  15. package/out/renderer/assets/{python-0sFd9G1k.js → python-BLNzYwDv.js} +1 -1
  16. package/out/renderer/assets/{razor-Cqcu1rLJ.js → razor-CvAww8bG.js} +1 -1
  17. package/out/renderer/assets/{tsMode-CYd3NUkW.js → tsMode-C7m6Kr5E.js} +1 -1
  18. package/out/renderer/assets/{typescript-rkc9lhpi.js → typescript-DhPw4VVg.js} +1 -1
  19. package/out/renderer/assets/{xml-EsHEUps1.js → xml-B0WLFJ2U.js} +1 -1
  20. package/out/renderer/assets/{yaml-B9-nQ_s2.js → yaml-BWyn9Wd7.js} +1 -1
  21. package/out/renderer/index.html +2 -2
  22. package/package.json +1 -1
  23. package/src/renderer/App.tsx +34 -6
  24. package/src/renderer/components/FloatingMic.tsx +128 -0
  25. package/src/renderer/components/VoiceInput.tsx +11 -3
  26. package/src/renderer/styles.css +74 -0
@@ -1,6 +1,6 @@
1
1
  import { useState, useEffect, useCallback, useRef } from 'react'
2
2
  import { TerminalPanel, destroyTerminal, focusTerminal } from './components/TerminalPanel'
3
- import { VoiceInput } from './components/VoiceInput'
3
+ import { FloatingMic } from './components/FloatingMic'
4
4
  import { CtlsurfPanel } from './components/CtlsurfPanel'
5
5
  import { EditorPanel } from './components/EditorPanel'
6
6
  import { AgentPicker } from './components/AgentPicker'
@@ -133,6 +133,14 @@ export default function App() {
133
133
  const [activeTabId, setActiveTabId] = useState<string>(tabs[0].id)
134
134
  const [trackingActive, setTrackingActive] = useState(false)
135
135
  const [showTicketPanel, setShowTicketPanel] = useState(false)
136
+ // Draggable on-canvas push-to-talk mic; visibility persists across launches.
137
+ const [showFloatingMic, setShowFloatingMic] = useState<boolean>(() => {
138
+ try { return localStorage.getItem('ctlsurf.floatingMicVisible') !== 'false' } catch { return true }
139
+ })
140
+ const setFloatingMicVisible = useCallback((v: boolean) => {
141
+ setShowFloatingMic(v)
142
+ try { localStorage.setItem('ctlsurf.floatingMicVisible', String(v)) } catch { /* ignore */ }
143
+ }, [])
136
144
 
137
145
  // Agent picker state: which tab is being configured (null = initial picker for first tab)
138
146
  const [pickerTargetTabId, setPickerTargetTabId] = useState<string | null>(tabs[0].id)
@@ -207,13 +215,13 @@ export default function App() {
207
215
  }
208
216
  }, [trackingActive])
209
217
 
210
- // Voice typing: inject the transcribed text into the active terminal exactly
211
- // as if it were typed (no auto-submit), then refocus so the user can press
212
- // Enter to send it.
218
+ // Voice typing: inject the transcribed text into the active terminal as if it
219
+ // were typed, then send a carriage return to submit it (same as pressing Enter
220
+ // after typing), and refocus the terminal.
213
221
  const handleVoiceTranscript = useCallback((text: string) => {
214
222
  const trimmed = text.trim()
215
223
  if (!trimmed) return
216
- window.worker.writePty(activeTabId, trimmed)
224
+ window.worker.writePty(activeTabId, trimmed + '\r')
217
225
  focusTerminal(activeTabId)
218
226
  }, [activeTabId])
219
227
 
@@ -482,7 +490,20 @@ export default function App() {
482
490
  </svg>
483
491
  <span>Tickets</span>
484
492
  </button>
485
- <VoiceInput onTranscript={handleVoiceTranscript} />
493
+ <button
494
+ className={`titlebar-btn titlebar-icon-btn ${showFloatingMic ? 'active' : ''}`}
495
+ onClick={() => setFloatingMicVisible(!showFloatingMic)}
496
+ title={showFloatingMic ? 'Hide floating mic' : 'Show floating mic'}
497
+ aria-label="Toggle floating mic"
498
+ >
499
+ <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor"
500
+ strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
501
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
502
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
503
+ <line x1="12" y1="19" x2="12" y2="23" />
504
+ <line x1="8" y1="23" x2="16" y2="23" />
505
+ </svg>
506
+ </button>
486
507
  <span className="titlebar-separator" />
487
508
  {agents.map(a => {
488
509
  const activeTab = tabs.find(t => t.id === activeTabId)
@@ -544,6 +565,13 @@ export default function App() {
544
565
  }}
545
566
  />
546
567
  )}
568
+
569
+ {showFloatingMic && (
570
+ <FloatingMic
571
+ onTranscript={handleVoiceTranscript}
572
+ onHide={() => setFloatingMicVisible(false)}
573
+ />
574
+ )}
547
575
  </div>
548
576
  )
549
577
  }
@@ -0,0 +1,128 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { VoiceInput } from './VoiceInput'
3
+
4
+ // A draggable, dismissable push-to-talk mic that floats over the panes. It wraps
5
+ // the same <VoiceInput> push-to-talk logic used in the titlebar; only the chrome
6
+ // (drag handle + hide button) and positioning live here.
7
+
8
+ const POS_KEY = 'ctlsurf.floatingMicPos'
9
+
10
+ interface Pos { x: number; y: number }
11
+
12
+ interface FloatingMicProps {
13
+ onTranscript: (text: string) => void
14
+ onHide: () => void
15
+ }
16
+
17
+ // Keep the button clear of the 38px titlebar and 24px status bar.
18
+ const EDGE = 20
19
+ const TOP_MIN = 46
20
+ const BOTTOM_GAP = 36
21
+
22
+ function loadPos(): Pos | null {
23
+ try {
24
+ const raw = localStorage.getItem(POS_KEY)
25
+ if (raw) {
26
+ const p = JSON.parse(raw) as Partial<Pos>
27
+ if (typeof p.x === 'number' && typeof p.y === 'number') return { x: p.x, y: p.y }
28
+ }
29
+ } catch { /* ignore */ }
30
+ return null
31
+ }
32
+
33
+ export function FloatingMic({ onTranscript, onHide }: FloatingMicProps) {
34
+ const [pos, setPos] = useState<Pos | null>(loadPos)
35
+ const elRef = useRef<HTMLDivElement>(null)
36
+ // Pointer-to-element offset captured at drag start; null when not dragging.
37
+ const dragRef = useRef<{ dx: number; dy: number } | null>(null)
38
+
39
+ // Keep the button fully inside the viewport (used on drag, mount, and resize).
40
+ const clamp = useCallback((x: number, y: number): Pos => {
41
+ const el = elRef.current
42
+ const w = el?.offsetWidth ?? 64
43
+ const h = el?.offsetHeight ?? 90
44
+ return {
45
+ x: Math.max(EDGE, Math.min(x, window.innerWidth - w - EDGE)),
46
+ y: Math.max(TOP_MIN, Math.min(y, window.innerHeight - h - BOTTOM_GAP)),
47
+ }
48
+ }, [])
49
+
50
+ // First mount with no saved position: default to bottom-right.
51
+ useEffect(() => {
52
+ if (pos) return
53
+ const el = elRef.current
54
+ const w = el?.offsetWidth ?? 64
55
+ const h = el?.offsetHeight ?? 90
56
+ setPos({
57
+ x: window.innerWidth - w - EDGE,
58
+ y: window.innerHeight - h - BOTTOM_GAP,
59
+ })
60
+ }, [pos])
61
+
62
+ // Keep it reachable if the window shrinks.
63
+ useEffect(() => {
64
+ const onResize = () => setPos((p) => (p ? clamp(p.x, p.y) : p))
65
+ window.addEventListener('resize', onResize)
66
+ return () => window.removeEventListener('resize', onResize)
67
+ }, [clamp])
68
+
69
+ const onHandleDown = useCallback((e: React.PointerEvent) => {
70
+ const el = elRef.current
71
+ if (!el) return
72
+ e.preventDefault()
73
+ const rect = el.getBoundingClientRect()
74
+ dragRef.current = { dx: e.clientX - rect.left, dy: e.clientY - rect.top }
75
+ e.currentTarget.setPointerCapture?.(e.pointerId)
76
+ }, [])
77
+
78
+ const onHandleMove = useCallback((e: React.PointerEvent) => {
79
+ const d = dragRef.current
80
+ if (!d) return
81
+ setPos(clamp(e.clientX - d.dx, e.clientY - d.dy))
82
+ }, [clamp])
83
+
84
+ const onHandleUp = useCallback((e: React.PointerEvent) => {
85
+ if (!dragRef.current) return
86
+ dragRef.current = null
87
+ e.currentTarget.releasePointerCapture?.(e.pointerId)
88
+ setPos((p) => {
89
+ if (p) {
90
+ try { localStorage.setItem(POS_KEY, JSON.stringify(p)) } catch { /* ignore */ }
91
+ }
92
+ return p
93
+ })
94
+ }, [])
95
+
96
+ // Render off-screen+hidden until the first position is computed (no flash).
97
+ const style: React.CSSProperties = pos
98
+ ? { left: pos.x, top: pos.y }
99
+ : { left: -9999, top: -9999, visibility: 'hidden' }
100
+
101
+ return (
102
+ <div ref={elRef} className="floating-mic" style={style}>
103
+ <div
104
+ className="floating-mic-handle"
105
+ onPointerDown={onHandleDown}
106
+ onPointerMove={onHandleMove}
107
+ onPointerUp={onHandleUp}
108
+ onPointerCancel={onHandleUp}
109
+ title="Drag to move"
110
+ aria-label="Drag floating mic"
111
+ >
112
+ <span className="floating-mic-grip" aria-hidden="true">⠿</span>
113
+ <button
114
+ type="button"
115
+ className="floating-mic-hide"
116
+ // Don't let a click on the hide button start a drag.
117
+ onPointerDown={(e) => e.stopPropagation()}
118
+ onClick={onHide}
119
+ title="Hide floating mic"
120
+ aria-label="Hide floating mic"
121
+ >
122
+ ×
123
+ </button>
124
+ </div>
125
+ <VoiceInput variant="floating" onTranscript={onTranscript} />
126
+ </div>
127
+ )
128
+ }
@@ -76,9 +76,12 @@ function describeMicError(err: unknown): string {
76
76
  interface VoiceInputProps {
77
77
  // Called once per push-to-talk session with the final transcribed text.
78
78
  onTranscript: (text: string) => void
79
+ // 'titlebar' (default) renders the compact titlebar pill; 'floating' renders
80
+ // a round FAB used by the draggable on-canvas mic (see FloatingMic).
81
+ variant?: 'titlebar' | 'floating'
79
82
  }
80
83
 
81
- export function VoiceInput({ onTranscript }: VoiceInputProps) {
84
+ export function VoiceInput({ onTranscript, variant = 'titlebar' }: VoiceInputProps) {
82
85
  const [engine, setEngine] = useState<Engine>(loadInitialEngine)
83
86
  const [phase, setPhase] = useState<Phase>('idle')
84
87
  const [interim, setInterim] = useState('')
@@ -291,11 +294,16 @@ export function VoiceInput({ onTranscript }: VoiceInputProps) {
291
294
  else if (listening) chip = { kind: 'listening', text: interim || (engine === 'local' ? 'Recording…' : 'Listening…') }
292
295
  else if (busy) chip = { kind: 'busy', text: modelPct !== null ? `Downloading voice model… ${modelPct}%` : 'Transcribing…' }
293
296
 
297
+ const floating = variant === 'floating'
298
+ const btnClass = floating
299
+ ? `voice-btn voice-btn-floating ${listening ? 'listening' : ''} ${busy ? 'busy' : ''}`
300
+ : `titlebar-btn titlebar-icon-btn voice-btn ${listening ? 'listening' : ''} ${busy ? 'busy' : ''}`
301
+
294
302
  return (
295
303
  <div className="voice-input-wrap">
296
304
  <button
297
305
  type="button"
298
- className={`titlebar-btn titlebar-icon-btn voice-btn ${listening ? 'listening' : ''} ${busy ? 'busy' : ''}`}
306
+ className={btnClass}
299
307
  disabled={!ANY_SUPPORTED}
300
308
  onPointerDown={handlePointerDown}
301
309
  onPointerUp={handlePointerUp}
@@ -307,7 +315,7 @@ export function VoiceInput({ onTranscript }: VoiceInputProps) {
307
315
  <span className="voice-icon" aria-hidden="true">🎤</span>
308
316
  <span className={`voice-dot ${listening ? 'on' : busy ? 'busy' : 'off'}`} />
309
317
  </button>
310
- {chip && <div className={`voice-chip ${chip.kind}`}>{chip.text}</div>}
318
+ {chip && <div className={`voice-chip ${chip.kind} ${floating ? 'voice-chip-floating' : ''}`}>{chip.text}</div>}
311
319
  </div>
312
320
  )
313
321
  }
@@ -659,6 +659,80 @@ html, body, #root {
659
659
  white-space: normal;
660
660
  }
661
661
 
662
+ /* Floating push-to-talk mic (draggable, dismissable FAB) */
663
+ .floating-mic {
664
+ position: fixed;
665
+ z-index: 200;
666
+ display: flex;
667
+ flex-direction: column;
668
+ align-items: center;
669
+ gap: 4px;
670
+ padding: 4px 4px 6px;
671
+ background: #16161e;
672
+ border: 1px solid #3b3d57;
673
+ border-radius: 12px;
674
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
675
+ user-select: none;
676
+ -webkit-user-select: none;
677
+ }
678
+ .floating-mic-handle {
679
+ display: flex;
680
+ align-items: center;
681
+ justify-content: space-between;
682
+ width: 100%;
683
+ cursor: grab;
684
+ touch-action: none;
685
+ }
686
+ .floating-mic-handle:active { cursor: grabbing; }
687
+ .floating-mic-grip {
688
+ color: #565f89;
689
+ font-size: 12px;
690
+ line-height: 1;
691
+ padding: 0 2px;
692
+ }
693
+ .floating-mic-hide {
694
+ background: transparent;
695
+ border: none;
696
+ color: #565f89;
697
+ font-size: 15px;
698
+ line-height: 1;
699
+ cursor: pointer;
700
+ padding: 0 2px;
701
+ }
702
+ .floating-mic-hide:hover { color: #f7768e; }
703
+
704
+ .voice-btn-floating {
705
+ width: 48px;
706
+ height: 48px;
707
+ border-radius: 50%;
708
+ display: inline-flex;
709
+ align-items: center;
710
+ justify-content: center;
711
+ background: #2a2b3d;
712
+ border: 1px solid #3b3d57;
713
+ color: #a9b1d6;
714
+ cursor: pointer;
715
+ position: relative;
716
+ transition: all 0.15s;
717
+ }
718
+ .voice-btn-floating:hover { border-color: #565f89; }
719
+ .voice-btn-floating .voice-icon { font-size: 22px; }
720
+ .voice-btn-floating .voice-dot {
721
+ position: absolute;
722
+ top: 5px;
723
+ right: 5px;
724
+ }
725
+ /* Floating chip sits above the round button rather than below it. */
726
+ .voice-chip-floating {
727
+ top: auto;
728
+ bottom: 100%;
729
+ right: auto;
730
+ left: 50%;
731
+ transform: translateX(-50%);
732
+ margin-top: 0;
733
+ margin-bottom: 8px;
734
+ }
735
+
662
736
  /* Editor panel */
663
737
  .editor-panel {
664
738
  display: flex;