hop2 0.11.2 → 0.11.3
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/README.md +7 -1
- package/hay/apps/web/src/App.tsx +57 -101
- package/hay/apps/web/src/components/SessionSwitcher.tsx +87 -8
- package/hay/apps/web/src/styles.css +13 -0
- package/hay/apps/web/src/utils/voiceHold.ts +170 -0
- package/hay-web/assets/{index-BohAWpcL.css → index-BRpBf8QE.css} +1 -1
- package/hay-web/assets/index-BrLbmVOB.js +132 -0
- package/hay-web/assets/index-BrLbmVOB.js.map +1 -0
- package/hay-web/index.html +2 -2
- package/hop +152 -8
- package/package.json +1 -1
- package/scripts/claude-session-hook.js +24 -3
- package/scripts/hay-host.js +11 -0
- package/tests/terminal-api.integration.test.cjs +26 -0
- package/hay-web/assets/index-DfGT3DuO.js +0 -130
- package/hay-web/assets/index-DfGT3DuO.js.map +0 -1
package/README.md
CHANGED
|
@@ -199,7 +199,13 @@ The attached-terminal UI adapts light/dark themes to your terminal and puts the
|
|
|
199
199
|
|
|
200
200
|
### Use Hop on Mobile
|
|
201
201
|
|
|
202
|
-
|
|
202
|
+
**Native iOS app**: [hop-ios](https://github.com/jzthree/hop-ios) — a SwiftUI
|
|
203
|
+
client for iPhone with a real terminal (SwiftTerm), optimistic-echo typing,
|
|
204
|
+
bell notifications with lock-screen reply, Siri/Shortcuts/Spotlight/Handoff
|
|
205
|
+
integration, and a fixed-height terminal keyboard. It consumes hop's public
|
|
206
|
+
HTTP + WebSocket API and needs no changes to hop.
|
|
207
|
+
|
|
208
|
+
Hop’s mobile **web** UI includes:
|
|
203
209
|
|
|
204
210
|
- a terminal-oriented accessory row with `Esc`, `Tab`, `Ctrl`, `Alt`, and arrows
|
|
205
211
|
- a native keyboard button for dictation, spellcheck, and autocomplete
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import { activityLabel, sortPresence } from "./utils/presence";
|
|
|
13
13
|
import { createOptimisticEcho } from "./utils/optimisticEcho";
|
|
14
14
|
import { attachScrollFlywheel } from "./utils/scrollFlywheel";
|
|
15
15
|
import { collectTerminalMatches, selectTerminalMatch } from "./utils/terminalSearch";
|
|
16
|
+
import { createVoiceHold, speechRecognitionCtor } from "./utils/voiceHold";
|
|
16
17
|
import { scanKeyboardProtocol } from "./utils/keyboardProtocol";
|
|
17
18
|
import { originalPathHint } from "./utils/fileDrop";
|
|
18
19
|
import { MobileKeyboard } from "./components/MobileKeyboard";
|
|
@@ -518,6 +519,10 @@ const App = () => {
|
|
|
518
519
|
const shouldReconnectRef = useRef(true);
|
|
519
520
|
const connectNonceRef = useRef(0);
|
|
520
521
|
const userScrolledUpRef = useRef(false);
|
|
522
|
+
// Late-bound hooks for the voice controller (created before these exist).
|
|
523
|
+
const handleUserInputRef = useRef<((data: string) => void) | null>(null);
|
|
524
|
+
const pushNoticeRef = useRef<((m: string) => void) | null>(null);
|
|
525
|
+
const foregroundIsClaudeRef = useRef<(() => boolean) | null>(null);
|
|
521
526
|
// Custom overlay scrollbar (native overlay bars can't be styled, and styled
|
|
522
527
|
// webkit bars reserve layout width): a floating semi-transparent thumb.
|
|
523
528
|
const termScrollbarRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -562,13 +567,44 @@ const App = () => {
|
|
|
562
567
|
// Claude Code titles its process with a bare version number; hop's session
|
|
563
568
|
// poll surfaces it as foregroundProcess. Used to unlock claude-specific key
|
|
564
569
|
// encodings (Shift+Enter) without protocol negotiation.
|
|
570
|
+
// Is Claude Code the thing on screen? The foreground PROCESS cannot answer
|
|
571
|
+
// alone: a session relaunched by `hop restore` runs
|
|
572
|
+
// `shell -lc "claude ...; exec shell -l"`, so the room reports the WRAPPER
|
|
573
|
+
// SHELL for claude's whole life. Everything gated on this — Shift+Enter as
|
|
574
|
+
// a newline, hold-space voice — silently stopped working in exactly the
|
|
575
|
+
// sessions a restore brought back, which is why voice "does not always
|
|
576
|
+
// work". The screen is the honest witness, so fall back to claude's own
|
|
577
|
+
// chrome in the buffer (cached ~1s, since key handlers ask per keystroke).
|
|
578
|
+
const claudeScreenRef = useRef<{ at: number; value: boolean }>({ at: 0, value: false });
|
|
579
|
+
const screenLooksLikeClaude = () => {
|
|
580
|
+
const now = performance.now();
|
|
581
|
+
if (now - claudeScreenRef.current.at < 1000) return claudeScreenRef.current.value;
|
|
582
|
+
let value = false;
|
|
583
|
+
const t = termRef.current;
|
|
584
|
+
if (t) {
|
|
585
|
+
try {
|
|
586
|
+
const buf = t.buffer.active;
|
|
587
|
+
const end = buf.baseY + t.rows;
|
|
588
|
+
let text = "";
|
|
589
|
+
for (let i = Math.max(0, end - 30); i < end; i++) {
|
|
590
|
+
text += (buf.getLine(i)?.translateToString(true) ?? "") + "\n";
|
|
591
|
+
}
|
|
592
|
+
value = /bypass permissions on|shift\+tab to cycle|esc to interrupt|\? for shortcuts/i.test(text);
|
|
593
|
+
} catch { /* disposed mid-read */ }
|
|
594
|
+
}
|
|
595
|
+
claudeScreenRef.current = { at: now, value };
|
|
596
|
+
return value;
|
|
597
|
+
};
|
|
565
598
|
const foregroundIsClaude = () => {
|
|
566
599
|
const room = activeSessionRoomRef.current;
|
|
567
|
-
if (
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
600
|
+
if (room) {
|
|
601
|
+
const s = sessionsRef.current.find((x) => (x.internalName || x.name) === room);
|
|
602
|
+
const proc = (s?.foregroundProcess || "").trim();
|
|
603
|
+
if (proc === "claude" || /^\d+\.\d+\.\d+$/.test(proc)) return true;
|
|
604
|
+
}
|
|
605
|
+
return screenLooksLikeClaude();
|
|
571
606
|
};
|
|
607
|
+
foregroundIsClaudeRef.current = foregroundIsClaude;
|
|
572
608
|
const drawerOpenRef = useRef(drawerOpen);
|
|
573
609
|
drawerOpenRef.current = drawerOpen;
|
|
574
610
|
const shortcutHelpRef = useRef(shortcutHelpOpen);
|
|
@@ -723,6 +759,7 @@ const App = () => {
|
|
|
723
759
|
}, 3000);
|
|
724
760
|
};
|
|
725
761
|
|
|
762
|
+
pushNoticeRef.current = pushNotice;
|
|
726
763
|
const showToast = (message: string, durationMs = 2000) => {
|
|
727
764
|
setToast(message);
|
|
728
765
|
if (toastTimeout.current) {
|
|
@@ -953,78 +990,18 @@ const App = () => {
|
|
|
953
990
|
};
|
|
954
991
|
|
|
955
992
|
// ── Voice input: hold SPACE in a Claude session to dictate. ──
|
|
956
|
-
//
|
|
957
|
-
//
|
|
958
|
-
// most common key; crossing the hold threshold erases that space and
|
|
959
|
-
// listens until release. The transcript is TYPED, not submitted: you can
|
|
960
|
-
// edit before Enter. Auto-repeat spaces are swallowed while held, which in
|
|
961
|
-
// a Claude composer is noise nobody wants anyway.
|
|
962
|
-
const voiceTimerRef = useRef(0);
|
|
963
|
-
const voiceActiveRef = useRef(false);
|
|
964
|
-
const voiceRecRef = useRef<{ stop: () => void } | null>(null);
|
|
965
|
-
const voiceFinalRef = useRef("");
|
|
966
|
-
const voiceInterimRef = useRef("");
|
|
993
|
+
// Shared controller (utils/voiceHold) — the live wall tiles run the same
|
|
994
|
+
// machine, so dictation exists on every surface a Claude composer does.
|
|
967
995
|
const [voiceOverlay, setVoiceOverlay] = useState<{ interim: string } | null>(null);
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
// result ever arrived, it is still what the user said — use it.
|
|
978
|
-
const text = (voiceFinalRef.current || voiceInterimRef.current).trim();
|
|
979
|
-
voiceFinalRef.current = "";
|
|
980
|
-
voiceInterimRef.current = "";
|
|
981
|
-
if (send && text) handleUserInput(text);
|
|
982
|
-
};
|
|
983
|
-
const startVoiceInput = () => {
|
|
984
|
-
if (voiceActiveRef.current) return;
|
|
985
|
-
const Ctor = speechRecognitionCtor() as (new () => never) | undefined;
|
|
986
|
-
if (!Ctor) return;
|
|
987
|
-
let rec: never;
|
|
988
|
-
try { rec = new Ctor(); } catch { return; }
|
|
989
|
-
const r = rec as {
|
|
990
|
-
lang: string; continuous: boolean; interimResults: boolean;
|
|
991
|
-
onresult: (ev: { resultIndex: number; results: ArrayLike<{ isFinal: boolean; 0: { transcript: string } }> }) => void;
|
|
992
|
-
onerror: (ev: { error?: string }) => void; onend: () => void;
|
|
993
|
-
start: () => void; stop: () => void;
|
|
994
|
-
};
|
|
995
|
-
voiceActiveRef.current = true;
|
|
996
|
-
voiceFinalRef.current = "";
|
|
997
|
-
voiceInterimRef.current = "";
|
|
998
|
-
handleUserInput(""); // erase the space the initial press typed
|
|
999
|
-
r.lang = navigator.language || "en-US";
|
|
1000
|
-
r.continuous = true;
|
|
1001
|
-
r.interimResults = true;
|
|
1002
|
-
r.onresult = (ev) => {
|
|
1003
|
-
let interim = "";
|
|
1004
|
-
for (let i = ev.resultIndex; i < ev.results.length; i++) {
|
|
1005
|
-
const res = ev.results[i];
|
|
1006
|
-
if (res.isFinal) voiceFinalRef.current += res[0].transcript;
|
|
1007
|
-
else interim += res[0].transcript;
|
|
1008
|
-
}
|
|
1009
|
-
voiceInterimRef.current = interim;
|
|
1010
|
-
setVoiceOverlay({ interim: (voiceFinalRef.current + interim).trim() });
|
|
1011
|
-
};
|
|
1012
|
-
r.onerror = (ev) => {
|
|
1013
|
-
if (ev?.error === "not-allowed" || ev?.error === "service-not-allowed") {
|
|
1014
|
-
pushNotice("Microphone permission needed for voice input");
|
|
1015
|
-
}
|
|
1016
|
-
finishVoiceInput(false);
|
|
1017
|
-
};
|
|
1018
|
-
r.onend = () => finishVoiceInput(true);
|
|
1019
|
-
voiceRecRef.current = r;
|
|
1020
|
-
setVoiceOverlay({ interim: "" });
|
|
1021
|
-
try { r.start(); } catch { finishVoiceInput(false); }
|
|
1022
|
-
};
|
|
1023
|
-
const stopVoiceInput = () => {
|
|
1024
|
-
const rec = voiceRecRef.current;
|
|
1025
|
-
if (rec) { try { rec.stop(); } catch { finishVoiceInput(true); } }
|
|
1026
|
-
else finishVoiceInput(true);
|
|
1027
|
-
};
|
|
996
|
+
const voiceHoldRef = useRef<ReturnType<typeof createVoiceHold> | null>(null);
|
|
997
|
+
if (!voiceHoldRef.current) {
|
|
998
|
+
voiceHoldRef.current = createVoiceHold({
|
|
999
|
+
send: (data) => handleUserInputRef.current?.(data),
|
|
1000
|
+
notify: (m) => pushNoticeRef.current?.(m),
|
|
1001
|
+
setOverlay: (interim) => setVoiceOverlay(interim === null ? null : { interim }),
|
|
1002
|
+
eligible: () => foregroundIsClaudeRef.current?.() ?? false
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1028
1005
|
|
|
1029
1006
|
const handleUserInput = (data: string) => {
|
|
1030
1007
|
// Typing is "I am at the prompt": any earlier scroll-up turned
|
|
@@ -1105,6 +1082,7 @@ const App = () => {
|
|
|
1105
1082
|
|
|
1106
1083
|
// One-shot: armed when a snapshot loads (fresh session open/switch), consumed
|
|
1107
1084
|
// by the next resize so it goes out as an attach claim.
|
|
1085
|
+
handleUserInputRef.current = handleUserInput;
|
|
1108
1086
|
const attachClaimPendingRef = useRef(false);
|
|
1109
1087
|
// Throttle gate for fit-on-type (one fit per typing burst, not per key).
|
|
1110
1088
|
const lastTypeFitAtRef = useRef(0);
|
|
@@ -1772,32 +1750,10 @@ const App = () => {
|
|
|
1772
1750
|
}
|
|
1773
1751
|
return false;
|
|
1774
1752
|
}
|
|
1775
|
-
// Long-press SPACE = voice input
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
&& foregroundIsClaude() && !!speechRecognitionCtor()) {
|
|
1780
|
-
if (event.type === 'keydown') {
|
|
1781
|
-
if (!event.repeat) {
|
|
1782
|
-
window.clearTimeout(voiceTimerRef.current);
|
|
1783
|
-
voiceTimerRef.current = window.setTimeout(startVoiceInput, 550);
|
|
1784
|
-
return true;
|
|
1785
|
-
}
|
|
1786
|
-
return false; // swallow auto-repeat while held
|
|
1787
|
-
}
|
|
1788
|
-
if (event.type === 'keyup') {
|
|
1789
|
-
window.clearTimeout(voiceTimerRef.current);
|
|
1790
|
-
if (voiceActiveRef.current) {
|
|
1791
|
-
stopVoiceInput();
|
|
1792
|
-
return false;
|
|
1793
|
-
}
|
|
1794
|
-
return true;
|
|
1795
|
-
}
|
|
1796
|
-
}
|
|
1797
|
-
// Any other keydown cancels a pending (not yet active) voice hold —
|
|
1798
|
-
// normal typing must never trip the mic.
|
|
1799
|
-
if (event.type === 'keydown' && event.code !== 'Space') {
|
|
1800
|
-
window.clearTimeout(voiceTimerRef.current);
|
|
1753
|
+
// Long-press SPACE = voice input — shared controller; see voiceHold.ts.
|
|
1754
|
+
{
|
|
1755
|
+
const verdict = voiceHoldRef.current?.handleKey(event);
|
|
1756
|
+
if (verdict !== undefined) return verdict;
|
|
1801
1757
|
}
|
|
1802
1758
|
// Shift+Enter: xterm.js would emit a plain \r (indistinguishable from
|
|
1803
1759
|
// Enter), which apps like Claude Code treat as "submit". Synthesize the
|
|
@@ -15,6 +15,7 @@ import { WebglAddon } from "@xterm/addon-webgl";
|
|
|
15
15
|
import { attachScrollFlywheel } from "../utils/scrollFlywheel";
|
|
16
16
|
import { ContextMenu, type MenuRequest } from "./ContextMenu";
|
|
17
17
|
import { collectTerminalMatches, selectTerminalMatch } from "../utils/terminalSearch";
|
|
18
|
+
import { bufferLooksLikeClaude, createVoiceHold } from "../utils/voiceHold";
|
|
18
19
|
import {
|
|
19
20
|
buildSwitcherModel,
|
|
20
21
|
filterSessionsByOrigin,
|
|
@@ -270,10 +271,10 @@ const runningApp = (s: SwitcherSession) => {
|
|
|
270
271
|
// responsiveness and whether keystrokes are accepted. The old design — an
|
|
271
272
|
// HTML preview swapped for a fresh xterm + socket behind a veil — is gone.
|
|
272
273
|
const LIVETILE_POLL_MS = 5000;
|
|
273
|
-
const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, activeCols, activeRows, onFullscreen, onUnfocus }: {
|
|
274
|
+
const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, activeCols, activeRows, onFullscreen, onUnfocus, onNotice }: {
|
|
274
275
|
wsBase: string; room: string; userName: string; theme: object | undefined;
|
|
275
276
|
live: boolean; claudeApp: boolean; claimSize: boolean; activeCols?: number; activeRows?: number;
|
|
276
|
-
onFullscreen: () => void; onUnfocus: () => void;
|
|
277
|
+
onFullscreen: () => void; onUnfocus: () => void; onNotice: (m: string) => void;
|
|
277
278
|
}) => {
|
|
278
279
|
const boxRef = useRef<HTMLDivElement | null>(null);
|
|
279
280
|
const termRef = useRef<Terminal | null>(null);
|
|
@@ -284,6 +285,24 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
284
285
|
const kbdEnhancedRef = useRef(false);
|
|
285
286
|
// null = watching; "live"/"down" = connected state for the chrome.
|
|
286
287
|
const [conn, setConn] = useState<"live" | "down" | null>(null);
|
|
288
|
+
// Hold-space voice, same controller as the full screen — the tile is where
|
|
289
|
+
// typing mostly happens now, and dictation not existing here was most of
|
|
290
|
+
// "voice doesn't always work".
|
|
291
|
+
const [voiceInterim, setVoiceInterim] = useState<string | null>(null);
|
|
292
|
+
const voiceHoldRef = useRef<ReturnType<typeof createVoiceHold> | null>(null);
|
|
293
|
+
if (!voiceHoldRef.current) {
|
|
294
|
+
voiceHoldRef.current = createVoiceHold({
|
|
295
|
+
send: (data) => sendInputRef.current?.(data),
|
|
296
|
+
notify: (m) => onNoticeRef.current?.(m),
|
|
297
|
+
setOverlay: setVoiceInterim,
|
|
298
|
+
eligible: () => {
|
|
299
|
+
if (claudeAppRef.current) return true;
|
|
300
|
+
const t = termRef.current;
|
|
301
|
+
return !!t && bufferLooksLikeClaude(t as never);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
287
306
|
// In-tile find (⌘F while the tile is live): same engine as the full-screen
|
|
288
307
|
// find bar. Scope follows focus — ⌘F in a live tile searches THIS session;
|
|
289
308
|
// ⌘F anywhere else is the wall filter, which finds sessions.
|
|
@@ -319,6 +338,8 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
319
338
|
const myIdRef = useRef<string | null>(null);
|
|
320
339
|
const [viewers, setViewers] = useState<Array<{ name: string }>>([]);
|
|
321
340
|
const [driver, setDriver] = useState<{ name: string; color: string } | null>(null);
|
|
341
|
+
const onNoticeRef = useRef(onNotice);
|
|
342
|
+
onNoticeRef.current = onNotice;
|
|
322
343
|
const claudeAppRef = useRef(claudeApp);
|
|
323
344
|
claudeAppRef.current = claudeApp;
|
|
324
345
|
const liveRef = useRef(live);
|
|
@@ -407,6 +428,10 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
407
428
|
// Keys route through refs so the handler survives mode flips untouched.
|
|
408
429
|
term.attachCustomKeyEventHandler((ev) => {
|
|
409
430
|
if (!liveRef.current) return false; // watching: the terminal takes no keys
|
|
431
|
+
{
|
|
432
|
+
const verdict = voiceHoldRef.current?.handleKey(ev);
|
|
433
|
+
if (verdict !== undefined) return verdict;
|
|
434
|
+
}
|
|
410
435
|
if ((ev.metaKey || ev.ctrlKey) && !ev.altKey && !ev.shiftKey && ev.key.toLowerCase() === "f") {
|
|
411
436
|
// Scope follows focus: find in THIS terminal. (The wall filter stays
|
|
412
437
|
// one ⌘K away; ⌘. still unfocuses the tile.)
|
|
@@ -752,6 +777,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
752
777
|
setConn(null);
|
|
753
778
|
setViewers([]);
|
|
754
779
|
setDriver(null);
|
|
780
|
+
voiceHoldRef.current?.dispose();
|
|
755
781
|
setFind(null);
|
|
756
782
|
findIndexRef.current = -1;
|
|
757
783
|
term.blur();
|
|
@@ -770,6 +796,14 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
770
796
|
onPointerDown={live ? (e) => e.stopPropagation() : undefined}
|
|
771
797
|
>
|
|
772
798
|
<div className="switcher-focus-term" ref={boxRef} />
|
|
799
|
+
{voiceInterim !== null && (
|
|
800
|
+
<div className="voice-overlay" aria-hidden="true">
|
|
801
|
+
<span className="voice-dot" />
|
|
802
|
+
<span className={voiceInterim ? "voice-text" : "voice-text muted"}>
|
|
803
|
+
{voiceInterim || "Listening… release Space to insert"}
|
|
804
|
+
</span>
|
|
805
|
+
</div>
|
|
806
|
+
)}
|
|
773
807
|
{live && (
|
|
774
808
|
<div className="switcher-live-chrome" onClick={(e) => e.stopPropagation()}>
|
|
775
809
|
<span className={"switcher-focus-label" + (conn === "down" ? " down" : "")}>
|
|
@@ -1495,6 +1529,30 @@ export const SessionSwitcher = ({
|
|
|
1495
1529
|
body: JSON.stringify({ internalName: sessionKey(session), parked: false })
|
|
1496
1530
|
}).catch(() => { /* daemon will still show it parked; harmless */ });
|
|
1497
1531
|
}
|
|
1532
|
+
// A DEAD terminal session starts IN PLACE instead of falling through to
|
|
1533
|
+
// full screen — the last accidental door into the mode. Attaching is what
|
|
1534
|
+
// starts a known session, so a claim-free throwaway socket kicks it
|
|
1535
|
+
// alive; the tile is pre-focused and goes live the moment the session
|
|
1536
|
+
// reports active. Ports still open full screen (they are web pages), and
|
|
1537
|
+
// without live tiles (no wsBase / small zoom) the old switch stands.
|
|
1538
|
+
if (session.type !== "port" && !session.active && interactiveTiles && tileWsBase) {
|
|
1539
|
+
const key = sessionKey(session);
|
|
1540
|
+
try {
|
|
1541
|
+
const sep = tileWsBase.includes("?") ? "&" : "?";
|
|
1542
|
+
const kick = new WebSocket(
|
|
1543
|
+
tileWsBase + sep + "room=" + encodeURIComponent(key)
|
|
1544
|
+
+ "&name=" + encodeURIComponent((userName || "user") + " (start)")
|
|
1545
|
+
+ "&replay=0&nudge=0&cols=80&rows=24"
|
|
1546
|
+
);
|
|
1547
|
+
kick.onopen = () => { window.setTimeout(() => { try { kick.close(); } catch { /* done */ } }, 400); };
|
|
1548
|
+
kick.onerror = () => { try { kick.close(); } catch { /* refused */ } };
|
|
1549
|
+
} catch { /* daemon will surface it as starting on the next poll */ }
|
|
1550
|
+
onNotice("Starting " + (session.displayName || session.name) + "…");
|
|
1551
|
+
setFocusedKey(key);
|
|
1552
|
+
onFocusSession?.(session);
|
|
1553
|
+
onRefresh();
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1498
1556
|
onSwitch(session);
|
|
1499
1557
|
};
|
|
1500
1558
|
|
|
@@ -1840,8 +1898,8 @@ export const SessionSwitcher = ({
|
|
|
1840
1898
|
setCreating(false);
|
|
1841
1899
|
setCreateDraft("");
|
|
1842
1900
|
if (createInFolder) {
|
|
1843
|
-
// File it
|
|
1844
|
-
//
|
|
1901
|
+
// File it first: the move endpoint is the one place membership is
|
|
1902
|
+
// persisted durably.
|
|
1845
1903
|
await fetch("/api/sessions/move", {
|
|
1846
1904
|
method: "POST",
|
|
1847
1905
|
headers: { "Content-Type": "application/json" },
|
|
@@ -1849,7 +1907,13 @@ export const SessionSwitcher = ({
|
|
|
1849
1907
|
}).catch(() => { /* filing is best-effort; the session exists */ });
|
|
1850
1908
|
setCreateInFolder(null);
|
|
1851
1909
|
}
|
|
1852
|
-
|
|
1910
|
+
// Stay on the wall. Creating used to jump straight to full screen —
|
|
1911
|
+
// the exact accidental mode-entry the hotkey inversion removed from
|
|
1912
|
+
// clicks. The new tile becomes current and goes live in place as soon
|
|
1913
|
+
// as the session reports active.
|
|
1914
|
+
onFocusSession?.({ name: data.name, displayName: data.name, active: false, starting: true, internalName: data.name });
|
|
1915
|
+
setFocusedKey(data.name);
|
|
1916
|
+
onRefresh();
|
|
1853
1917
|
} catch {
|
|
1854
1918
|
onNotice("Failed to create session");
|
|
1855
1919
|
}
|
|
@@ -2016,6 +2080,7 @@ export const SessionSwitcher = ({
|
|
|
2016
2080
|
activeRows={preview?.rows}
|
|
2017
2081
|
onFullscreen={() => onSwitch(s)}
|
|
2018
2082
|
onUnfocus={() => setFocusedKey(null)}
|
|
2083
|
+
onNotice={onNotice}
|
|
2019
2084
|
/>
|
|
2020
2085
|
</div>
|
|
2021
2086
|
) : (
|
|
@@ -2235,10 +2300,10 @@ export const SessionSwitcher = ({
|
|
|
2235
2300
|
</div>
|
|
2236
2301
|
{/* Create form lives in the top bar so the header + works from EVERY
|
|
2237
2302
|
mode — the dashed grid card only exists in recent mode. */}
|
|
2238
|
-
{creating && (
|
|
2303
|
+
{creating && !createInFolder && (
|
|
2239
2304
|
<form className="switcher-create-row inline-edit" onSubmit={submitCreate}>
|
|
2240
2305
|
<input
|
|
2241
|
-
placeholder=
|
|
2306
|
+
placeholder="session-name"
|
|
2242
2307
|
value={createDraft}
|
|
2243
2308
|
onChange={(e) => setCreateDraft(e.target.value)}
|
|
2244
2309
|
maxLength={64}
|
|
@@ -2331,8 +2396,22 @@ export const SessionSwitcher = ({
|
|
|
2331
2396
|
<button type="button" onClick={() => renameFolder(folder)} title="Rename folder">✎</button>
|
|
2332
2397
|
<button type="button" onClick={() => deleteFolder(folder)} title="Delete folder">🗑</button>
|
|
2333
2398
|
</h3>
|
|
2399
|
+
{creating && createInFolder?.id === folder.id && (
|
|
2400
|
+
<form className="switcher-create-row inline-edit switcher-create-infolder" onSubmit={submitCreate}>
|
|
2401
|
+
<input
|
|
2402
|
+
placeholder={"new session in " + folder.name}
|
|
2403
|
+
value={createDraft}
|
|
2404
|
+
onChange={(e) => setCreateDraft(e.target.value)}
|
|
2405
|
+
maxLength={64}
|
|
2406
|
+
autoFocus
|
|
2407
|
+
aria-label={"New session name in " + folder.name}
|
|
2408
|
+
/>
|
|
2409
|
+
<button type="submit">Create</button>
|
|
2410
|
+
<button type="button" onClick={() => { setCreating(false); setCreateDraft(""); setCreateInFolder(null); }}>✕</button>
|
|
2411
|
+
</form>
|
|
2412
|
+
)}
|
|
2334
2413
|
{rows.length === 0 ? (
|
|
2335
|
-
<p className="switcher-folder-empty">Drag a session here</p>
|
|
2414
|
+
creating && createInFolder?.id === folder.id ? null : <p className="switcher-folder-empty">Drag a session here, or + to create one</p>
|
|
2336
2415
|
) : (
|
|
2337
2416
|
<div className="switcher-grid">{rows.map((s) => renderCard(s))}</div>
|
|
2338
2417
|
)}
|
|
@@ -3892,3 +3892,16 @@ button.primary {
|
|
|
3892
3892
|
opacity: 1;
|
|
3893
3893
|
}
|
|
3894
3894
|
}
|
|
3895
|
+
|
|
3896
|
+
/* Create-in-folder: the input appears INSIDE the folder, where the session
|
|
3897
|
+
will land — not up in the header bar two scrolls away from the + that
|
|
3898
|
+
asked for it. */
|
|
3899
|
+
.switcher-folder .switcher-create-infolder {
|
|
3900
|
+
margin: 4px 0 8px;
|
|
3901
|
+
}
|
|
3902
|
+
|
|
3903
|
+
/* Voice overlay inside a live tile: same pill, tile-scaled. */
|
|
3904
|
+
.switcher-live-tile .voice-overlay {
|
|
3905
|
+
bottom: 30px;
|
|
3906
|
+
max-width: 86%;
|
|
3907
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Hold-space voice dictation — ONE controller for every terminal surface.
|
|
2
|
+
//
|
|
3
|
+
// It lived only in the full-screen terminal, but the wall's live tiles are
|
|
4
|
+
// where typing mostly happens now, so "voice doesn't always work" was often
|
|
5
|
+
// simply "voice does not exist on this surface". Both surfaces now share
|
|
6
|
+
// this state machine; each provides its own send/overlay/eligibility.
|
|
7
|
+
//
|
|
8
|
+
// Contract (unchanged from the original):
|
|
9
|
+
// - the first Space keydown types its space with zero latency
|
|
10
|
+
// - holding past the threshold erases the typed space(s) and listens
|
|
11
|
+
// - releasing stops; the transcript is TYPED, never submitted
|
|
12
|
+
// - auto-repeat spaces are swallowed; any other key cancels a pending hold
|
|
13
|
+
// - while listening, every space is eaten
|
|
14
|
+
|
|
15
|
+
type RecognitionLike = {
|
|
16
|
+
lang: string;
|
|
17
|
+
continuous: boolean;
|
|
18
|
+
interimResults: boolean;
|
|
19
|
+
onresult: ((ev: { resultIndex: number; results: ArrayLike<{ isFinal: boolean; 0: { transcript: string } }> }) => void) | null;
|
|
20
|
+
onerror: ((ev: { error?: string }) => void) | null;
|
|
21
|
+
onend: (() => void) | null;
|
|
22
|
+
start: () => void;
|
|
23
|
+
stop: () => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const speechRecognitionCtor = (): (new () => RecognitionLike) | undefined =>
|
|
27
|
+
(window as unknown as { SpeechRecognition?: new () => RecognitionLike }).SpeechRecognition
|
|
28
|
+
|| (window as unknown as { webkitSpeechRecognition?: new () => RecognitionLike }).webkitSpeechRecognition;
|
|
29
|
+
|
|
30
|
+
export type VoiceHoldOptions = {
|
|
31
|
+
send: (data: string) => void;
|
|
32
|
+
notify: (message: string) => void;
|
|
33
|
+
/** null = hidden; string = the interim transcript ("" while warming up). */
|
|
34
|
+
setOverlay: (interim: string | null) => void;
|
|
35
|
+
/** Is the surface a Claude-ish composer right now? Checked at keydown. */
|
|
36
|
+
eligible: () => boolean;
|
|
37
|
+
thresholdMs?: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const createVoiceHold = (opts: VoiceHoldOptions) => {
|
|
41
|
+
const threshold = opts.thresholdMs ?? 550;
|
|
42
|
+
let timer = 0;
|
|
43
|
+
let active = false;
|
|
44
|
+
let spacesTyped = 0;
|
|
45
|
+
let finalText = "";
|
|
46
|
+
let interimText = "";
|
|
47
|
+
let rec: RecognitionLike | null = null;
|
|
48
|
+
|
|
49
|
+
const finish = (sendText: boolean) => {
|
|
50
|
+
if (!active) return;
|
|
51
|
+
active = false;
|
|
52
|
+
rec = null;
|
|
53
|
+
opts.setOverlay(null);
|
|
54
|
+
const text = (finalText || interimText).trim();
|
|
55
|
+
finalText = "";
|
|
56
|
+
interimText = "";
|
|
57
|
+
if (sendText && text) opts.send(text);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const start = () => {
|
|
61
|
+
if (active) return;
|
|
62
|
+
const Ctor = speechRecognitionCtor();
|
|
63
|
+
if (!Ctor) return;
|
|
64
|
+
let r: RecognitionLike;
|
|
65
|
+
try { r = new Ctor(); } catch { return; }
|
|
66
|
+
active = true;
|
|
67
|
+
finalText = "";
|
|
68
|
+
interimText = "";
|
|
69
|
+
// Erase every space that reached the app during this hold.
|
|
70
|
+
for (let i = 0; i < Math.max(1, spacesTyped); i++) opts.send("\x7f");
|
|
71
|
+
spacesTyped = 0;
|
|
72
|
+
r.lang = navigator.language || "en-US";
|
|
73
|
+
r.continuous = true;
|
|
74
|
+
r.interimResults = true;
|
|
75
|
+
r.onresult = (ev) => {
|
|
76
|
+
let interim = "";
|
|
77
|
+
for (let i = ev.resultIndex; i < ev.results.length; i++) {
|
|
78
|
+
const res = ev.results[i];
|
|
79
|
+
if (res.isFinal) finalText += res[0].transcript;
|
|
80
|
+
else interim += res[0].transcript;
|
|
81
|
+
}
|
|
82
|
+
interimText = interim;
|
|
83
|
+
opts.setOverlay((finalText + interim).trim());
|
|
84
|
+
};
|
|
85
|
+
r.onerror = (ev) => {
|
|
86
|
+
// EVERY failure explains itself. Only mic-denial was surfaced before;
|
|
87
|
+
// Chrome's cloud recognizer routinely fails with "network", and that
|
|
88
|
+
// silent death read as "voice randomly doesn't work".
|
|
89
|
+
const code = ev?.error || "unknown";
|
|
90
|
+
if (code === "not-allowed" || code === "service-not-allowed") {
|
|
91
|
+
opts.notify("Microphone permission needed for voice input");
|
|
92
|
+
} else if (code === "network") {
|
|
93
|
+
opts.notify("Voice recognition unreachable (browser speech service) — try again");
|
|
94
|
+
} else if (code === "no-speech") {
|
|
95
|
+
opts.notify("No speech detected");
|
|
96
|
+
} else if (code !== "aborted") {
|
|
97
|
+
opts.notify(`Voice input failed: ${code}`);
|
|
98
|
+
}
|
|
99
|
+
finish(false);
|
|
100
|
+
};
|
|
101
|
+
r.onend = () => finish(true);
|
|
102
|
+
rec = r;
|
|
103
|
+
opts.setOverlay("");
|
|
104
|
+
try { r.start(); } catch { finish(false); }
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const stop = () => {
|
|
108
|
+
const r = rec;
|
|
109
|
+
if (r) { try { r.stop(); } catch { finish(true); } }
|
|
110
|
+
else finish(true);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Feed every key event here first. Returns:
|
|
115
|
+
* false — consumed (swallow), true — let the terminal process it,
|
|
116
|
+
* undefined — not a voice-relevant event; continue with other branches.
|
|
117
|
+
*/
|
|
118
|
+
const handleKey = (ev: { type: string; code: string; key: string; repeat?: boolean; metaKey?: boolean; ctrlKey?: boolean; altKey?: boolean; shiftKey?: boolean }): boolean | undefined => {
|
|
119
|
+
if (ev.code === "Space" && !ev.metaKey && !ev.ctrlKey && !ev.altKey && !ev.shiftKey
|
|
120
|
+
&& !!speechRecognitionCtor() && (active || opts.eligible())) {
|
|
121
|
+
if (ev.type === "keydown") {
|
|
122
|
+
if (active) return false; // listening: eat every space
|
|
123
|
+
if (!ev.repeat) {
|
|
124
|
+
window.clearTimeout(timer);
|
|
125
|
+
spacesTyped = 1; // this one goes through, for zero latency
|
|
126
|
+
timer = window.setTimeout(start, threshold);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false; // swallow auto-repeat while held
|
|
130
|
+
}
|
|
131
|
+
if (ev.type === "keyup") {
|
|
132
|
+
window.clearTimeout(timer);
|
|
133
|
+
spacesTyped = 0;
|
|
134
|
+
if (active) { stop(); return false; }
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
// Any other keydown cancels a pending (not yet active) hold.
|
|
140
|
+
if (ev.type === "keydown" && ev.code !== "Space") window.clearTimeout(timer);
|
|
141
|
+
return undefined;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const dispose = () => {
|
|
145
|
+
window.clearTimeout(timer);
|
|
146
|
+
if (active) { try { rec?.stop(); } catch { /* gone */ } }
|
|
147
|
+
active = false;
|
|
148
|
+
rec = null;
|
|
149
|
+
opts.setOverlay(null);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
return { handleKey, dispose, isActive: () => active };
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** Claude chrome scan over a terminal's visible rows — shared eligibility. */
|
|
156
|
+
export const bufferLooksLikeClaude = (t: {
|
|
157
|
+
rows: number;
|
|
158
|
+
buffer: { active: { baseY: number; getLine: (row: number) => { translateToString: (trim: boolean) => string } | undefined } };
|
|
159
|
+
}): boolean => {
|
|
160
|
+
try {
|
|
161
|
+
const end = t.buffer.active.baseY + t.rows;
|
|
162
|
+
let text = "";
|
|
163
|
+
for (let i = Math.max(0, end - 30); i < end; i++) {
|
|
164
|
+
text += (t.buffer.active.getLine(i)?.translateToString(true) ?? "") + "\n";
|
|
165
|
+
}
|
|
166
|
+
return /bypass permissions on|shift\+tab to cycle|esc to interrupt|\? for shortcuts/i.test(text);
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
};
|