@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.
- package/out/headless/index.mjs +1 -1
- package/out/headless/index.mjs.map +1 -1
- package/out/renderer/assets/{cssMode-DbMmcl1h.js → cssMode-eTXVdAkZ.js} +3 -3
- package/out/renderer/assets/{freemarker2-CvaHiy92.js → freemarker2-B5BKaiK4.js} +1 -1
- package/out/renderer/assets/{handlebars-D58lUIOu.js → handlebars-BIdLd2wU.js} +1 -1
- package/out/renderer/assets/{html-D1h1aJbM.js → html-BXL4cnLS.js} +1 -1
- package/out/renderer/assets/{htmlMode-BdkAp9qr.js → htmlMode-46N3XG2c.js} +3 -3
- package/out/renderer/assets/{index-DJFYmHjz.css → index-Cf-RsxoC.css} +74 -0
- package/out/renderer/assets/{index-B60JU1yI.js → index-dRvutfbl.js} +180 -27
- package/out/renderer/assets/{javascript-CXqZcnvb.js → javascript-n_iZZzDX.js} +2 -2
- package/out/renderer/assets/{jsonMode-BuVr-eSl.js → jsonMode-DXDczSNu.js} +3 -3
- package/out/renderer/assets/{liquid-LKu0Wd0B.js → liquid-B1QweUh7.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-Cjr_4HGs.js → lspLanguageFeatures-DqzMqkRk.js} +1 -1
- package/out/renderer/assets/{mdx-Bl84ILla.js → mdx-BCv8lm5e.js} +1 -1
- package/out/renderer/assets/{python-0sFd9G1k.js → python-BLNzYwDv.js} +1 -1
- package/out/renderer/assets/{razor-Cqcu1rLJ.js → razor-CvAww8bG.js} +1 -1
- package/out/renderer/assets/{tsMode-CYd3NUkW.js → tsMode-C7m6Kr5E.js} +1 -1
- package/out/renderer/assets/{typescript-rkc9lhpi.js → typescript-DhPw4VVg.js} +1 -1
- package/out/renderer/assets/{xml-EsHEUps1.js → xml-B0WLFJ2U.js} +1 -1
- package/out/renderer/assets/{yaml-B9-nQ_s2.js → yaml-BWyn9Wd7.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/renderer/App.tsx +34 -6
- package/src/renderer/components/FloatingMic.tsx +128 -0
- package/src/renderer/components/VoiceInput.tsx +11 -3
- package/src/renderer/styles.css +74 -0
package/src/renderer/App.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
2
2
|
import { TerminalPanel, destroyTerminal, focusTerminal } from './components/TerminalPanel'
|
|
3
|
-
import {
|
|
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
|
|
211
|
-
//
|
|
212
|
-
//
|
|
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
|
-
<
|
|
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={
|
|
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
|
}
|
package/src/renderer/styles.css
CHANGED
|
@@ -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;
|