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 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
- Hop’s mobile UI includes:
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
@@ -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 (!room) return false;
568
- const s = sessionsRef.current.find((x) => (x.internalName || x.name) === room);
569
- const proc = (s?.foregroundProcess || "").trim();
570
- return proc === "claude" || /^\d+\.\d+\.\d+$/.test(proc);
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
- // Browser SpeechRecognition (Chrome + Safari; where absent, space behaves
957
- // normally). The first press types its space instantly — no latency on the
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 speechRecognitionCtor = () =>
969
- (window as unknown as { SpeechRecognition?: unknown; webkitSpeechRecognition?: unknown }).SpeechRecognition
970
- || (window as unknown as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition;
971
- const finishVoiceInput = (send: boolean) => {
972
- if (!voiceActiveRef.current) return;
973
- voiceActiveRef.current = false;
974
- voiceRecRef.current = null;
975
- setVoiceOverlay(null);
976
- // Engines usually finalize pending speech on stop(); if only an interim
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 (Claude sessions, when the browser has
1776
- // SpeechRecognition). First press types its space with zero latency;
1777
- // holding past the threshold erases it and dictates until release.
1778
- if (event.code === 'Space' && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey
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 before switching: reuses the move endpoint, which is the
1844
- // one place membership is persisted durably.
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
- onSwitch({ name: data.name, displayName: data.name, active: false, starting: true, internalName: data.name });
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={createInFolder ? "new session in " + createInFolder.name : "session-name"}
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
+ };