diff --git a/PRIVACY.md b/PRIVACY.md index 3bf8b1e5..32b5e3df 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -71,6 +71,23 @@ into the Claude session where you asked for the report, which, like anything in session, goes to Anthropic. It sends nothing anywhere else, and writes only its own cursor (`catch-up.json`) beside the list. It runs only when you ask for it. +## Dictating from any app + +With dictation on and Settings > Dictation > "Dictate from any app" on (the default when dictation +is on), a small helper watches the keyboard so the dictation key works with another app in front. +It reads only the dictation key's own keys, notes only THAT another key was pressed while they are +held (never which), and, only while the widget's list of sessions is open, the digits 1 to 9 and +Esc (which it keeps from the app in front). It writes nothing anywhere and sends nothing. What you +say is handled as all dictation is: transcribed on your PC, then sent to the Claude session you +pick, or to the orchestrator tab. + +With more than one copy of Prism Terminal open (for example a stable copy beside the installed +app), one copy runs the dictation for all of them. The copies talk over a local named pipe that +only your Windows account uses: the running copy asks the others for their tab list (the tab's +name, folder and whether Claude runs there) so the list of sessions shows every copy's, and hands a +copy the words to type into one of its tabs. Nothing on the pipe leaves your PC, and nothing is +written to disk. + ## What programs in a tab can do A program running in a tab (Claude Code's /copy, or anything you reach over ssh) can put text ON diff --git a/core/dictation-host/parakeetHost.ps1 b/core/dictation-host/parakeetHost.ps1 new file mode 100644 index 00000000..5713a1a6 --- /dev/null +++ b/core/dictation-host/parakeetHost.ps1 @@ -0,0 +1,129 @@ +# THE RESIDENT PARAKEET HOST (owner, 2026-10-17: "It needs to be ready at all +# times"; the startup is "the important bit"). parakeet-cli loads the model for +# every pass: MEASURED on the owner's RTX 5090, 0.72 to 0.86 s a pass whatever +# the clip's length, nearly all of it start-up. This keeps ONE model loaded in +# a long-lived Windows PowerShell (the app's helper pattern, no new binary) and +# calls the engine's own parakeet.dll for each pass: 24 ms (1 s clip) to 170 ms +# (60 s clip) on the GPU, the text identical to parakeet-cli's, MEASURED. +# Report: research/prism-terminal/2026-10-17-dictation-performance.md. +# +# args: [] +# stdin: "pass " + LF, then n bytes of a 16 kHz mono PCM16 WAV; "quit" ends it. +# stdout: "ready" once the model is loaded; per pass "ok " or "err ". Nothing else is ever written there. +# +# The C# is compiled ONCE into (keyed by its own hash) and +# loaded from there afterwards, so a start pays no compiler. +param([string]$dir, [string]$model, [int]$gpu = 1, [int]$threads = 4, [string]$cache = '') +$ErrorActionPreference = 'Stop' +$src = @' +using System; +using System.IO; +using System.Text; +using System.Runtime.InteropServices; +public static class PTParakeetHost { + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] static extern IntPtr LoadLibraryW(string path); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] static extern bool SetDllDirectoryW(string path); + [DllImport("ggml.dll", CallingConvention = CallingConvention.Cdecl)] static extern void ggml_backend_load_all(); + [StructLayout(LayoutKind.Sequential)] public struct CtxParams { [MarshalAs(UnmanagedType.I1)] public bool use_gpu; public int gpu_device; } + // parakeet.h's parakeet_full_params, field for field (passed BY VALUE). + [StructLayout(LayoutKind.Sequential)] public struct FullParams { + public int strategy; public int n_threads; public int offset_ms; public int duration_ms; + [MarshalAs(UnmanagedType.I1)] public bool no_context; public int audio_ctx; + public IntPtr seg_cb, seg_ud, tok_cb, tok_ud, prog_cb, prog_ud, enc_cb, enc_ud, abort_cb, abort_ud; + } + [DllImport("parakeet.dll", CallingConvention = CallingConvention.Cdecl)] static extern IntPtr parakeet_init_from_file_with_params([MarshalAs(UnmanagedType.LPStr)] string path, CtxParams p); + [DllImport("parakeet.dll", CallingConvention = CallingConvention.Cdecl)] static extern IntPtr parakeet_full_default_params_by_ref(int strategy); + [DllImport("parakeet.dll", CallingConvention = CallingConvention.Cdecl)] static extern void parakeet_free_params(IntPtr p); + [DllImport("parakeet.dll", CallingConvention = CallingConvention.Cdecl)] static extern int parakeet_full(IntPtr ctx, FullParams p, float[] samples, int n); + [DllImport("parakeet.dll", CallingConvention = CallingConvention.Cdecl)] static extern int parakeet_full_n_segments(IntPtr ctx); + [DllImport("parakeet.dll", CallingConvention = CallingConvention.Cdecl)] static extern IntPtr parakeet_full_get_segment_text(IntPtr ctx, int i); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] delegate void LogCb(int level, IntPtr text, IntPtr ud); + [DllImport("parakeet.dll", CallingConvention = CallingConvention.Cdecl)] static extern void parakeet_log_set(LogCb cb, IntPtr ud); + static readonly LogCb quiet = (l, t, u) => { }; + + static string Utf8(IntPtr p) { + if (p == IntPtr.Zero) return ""; + int n = 0; while (Marshal.ReadByte(p, n) != 0) n++; + var b = new byte[n]; Marshal.Copy(p, b, 0, n); return Encoding.UTF8.GetString(b); + } + // The app's own WAV (16-bit mono, 16 kHz): the samples of its "data" chunk. + static float[] Pcm(byte[] wav) { + int i = 12; + while (i + 8 <= wav.Length) { + int len = BitConverter.ToInt32(wav, i + 4); + if (len < 0) break; + if (wav[i] == 'd' && wav[i + 1] == 'a' && wav[i + 2] == 't' && wav[i + 3] == 'a') { + int n = Math.Min(len, wav.Length - i - 8) / 2; + var f = new float[n]; + for (int k = 0; k < n; k++) f[k] = BitConverter.ToInt16(wav, i + 8 + k * 2) / 32768f; + return f; + } + i += 8 + len + (len & 1); + } + return new float[0]; + } + static string ReadLine(Stream s) { + var b = new StringBuilder(); + while (true) { + int c = s.ReadByte(); + if (c < 0) return null; + if (c == '\n') return b.ToString().TrimEnd('\r'); + if (b.Length > 64) return ""; + b.Append((char)c); + } + } + public static void Run(string dir, string model, bool gpu, int threads) { + var output = Console.Out; + SetDllDirectoryW(dir); + LoadLibraryW(Path.Combine(dir, "ggml.dll")); + LoadLibraryW(Path.Combine(dir, "parakeet.dll")); + parakeet_log_set(quiet, IntPtr.Zero); + ggml_backend_load_all(); + IntPtr ctx = parakeet_init_from_file_with_params(model, new CtxParams { use_gpu = gpu, gpu_device = 0 }); + if (ctx == IntPtr.Zero) { output.WriteLine("err the model did not load"); output.Flush(); return; } + IntPtr dp = parakeet_full_default_params_by_ref(0); + var fp = (FullParams)Marshal.PtrToStructure(dp, typeof(FullParams)); + parakeet_free_params(dp); + fp.n_threads = threads; + output.WriteLine("ready"); output.Flush(); + var input = Console.OpenStandardInput(); + while (true) { + string head = ReadLine(input); + if (head == null || head == "quit") return; + int n; + if (!head.StartsWith("pass ") || !int.TryParse(head.Substring(5), out n) || n < 0 || n > 64 * 1024 * 1024) { output.WriteLine("err a bad request"); output.Flush(); return; } + var wav = new byte[n]; int got = 0; + while (got < n) { int r = input.Read(wav, got, n - got); if (r <= 0) return; got += r; } + var pcm = Pcm(wav); + if (pcm.Length == 0) { output.WriteLine("ok "); output.Flush(); continue; } + int rc = parakeet_full(ctx, fp, pcm, pcm.Length); + if (rc != 0) { output.WriteLine("err the pass failed (" + rc + ")"); output.Flush(); continue; } + var sb = new StringBuilder(); + int segs = parakeet_full_n_segments(ctx); + for (int s = 0; s < segs; s++) sb.Append(Utf8(parakeet_full_get_segment_text(ctx, s))); + output.WriteLine("ok " + Convert.ToBase64String(Encoding.UTF8.GetBytes(sb.ToString().Trim()))); + output.Flush(); + } + } +} +'@ +$loaded = $false +if ($cache) { + try { + $sha = [Security.Cryptography.SHA256]::Create() + $hash = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($src))) -replace '-', '').Substring(0, 16) + $dll = Join-Path $cache "pt-parakeet-host-$hash.dll" + if (-not (Test-Path -LiteralPath $dll)) { + New-Item -ItemType Directory -Force -Path $cache | Out-Null + $tmp = Join-Path $cache "pt-parakeet-host-$hash-$PID.dll" + Add-Type -TypeDefinition $src -OutputAssembly $tmp -OutputType Library + Move-Item -LiteralPath $tmp -Destination $dll -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } + } + Add-Type -LiteralPath $dll + $loaded = $true + } catch { $loaded = $false } +} +if (-not $loaded) { Add-Type -TypeDefinition $src } +[PTParakeetHost]::Run($dir, $model, $gpu -eq 1, $threads) diff --git a/core/main/__fixtures__/fakeParakeetHost.mjs b/core/main/__fixtures__/fakeParakeetHost.mjs new file mode 100644 index 00000000..9b5a9864 --- /dev/null +++ b/core/main/__fixtures__/fakeParakeetHost.mjs @@ -0,0 +1,37 @@ +// A stand-in for core/dictation-host/parakeetHost.ps1: the same protocol on +// stdin and stdout, run by Node. argv: +// []. FAKE_HOST=never: it never says "ready". +// Each answer's text is JSON of what it saw: the pass number, the bytes, its pid. +// The globals by name, as fakeParakeetCli.mjs does (the eslint config). +import { Buffer } from 'node:buffer' +import process from 'node:process' +import { setInterval } from 'node:timers' +const [dir, model, gpu] = process.argv.slice(2) +if (process.env.FAKE_HOST === 'never') { + setInterval(() => {}, 1000) +} else { + process.stdout.write('ready\n') + let buf = Buffer.alloc(0) + let need = -1 + let n = 0 + process.stdin.on('data', (d) => { + buf = Buffer.concat([buf, d]) + for (;;) { + if (need < 0) { + const i = buf.indexOf(10) + if (i < 0) return + const head = buf.subarray(0, i).toString() + buf = buf.subarray(i + 1) + if (head === 'quit') process.exit(0) + need = Number(head.slice(5)) + } + if (buf.length < need) return + const bytes = need + buf = buf.subarray(need) + need = -1 + n += 1 + const text = JSON.stringify({ n, bytes, pid: process.pid, gpu: gpu === '1', model, dir }) + process.stdout.write(`ok ${Buffer.from(text, 'utf8').toString('base64')}\n`) + } + }) +} diff --git a/core/main/dictationEngine.test.ts b/core/main/dictationEngine.test.ts index 01a926ec..5354580f 100644 --- a/core/main/dictationEngine.test.ts +++ b/core/main/dictationEngine.test.ts @@ -406,6 +406,17 @@ describe('standing down', () => { expect(w.launched).toHaveLength(2) }) + // Prism Terminal's dictation service (owner, 2026-10-17: "It needs to be + // ready at all times"): Infinity never stands down; stop() still ends it. + it('never stands down with an idleMs of Infinity', async () => { + const w = world({}, { idleMs: Infinity }) + await w.engine.transcribe(req()) + await sleep(300) + expect(w.engine.info().running).toBe(true) + w.engine.stop() + await until(() => gone(w.children[0])) + }) + it('counts the idle time from the LAST request, not the first', async () => { const w = world({}, { idleMs: 500 }) await w.engine.transcribe(req()) @@ -631,3 +642,85 @@ describe('a Parakeet model', () => { await until(() => gone(w.children[0])) }) }) + +// THE RESIDENT PARAKEET HOST (2026-10-17): the model kept loaded in one +// process (core/dictation-host/parakeetHost.ps1), parakeet-cli only when it +// cannot start. The stand-in speaks its protocol, run by this Node. +const HOST_FIXTURE = join(dirname(fileURLToPath(import.meta.url)), '__fixtures__', 'fakeParakeetHost.mjs') +function hostWorld(env: Record = {}, knobs: Partial = {}): World & { hosts: ChildProcess[] } { + const hosts: ChildProcess[] = [] + const w = world(knobs, { + powershell: 'FAKE-POWERSHELL', + parakeetHost: () => ({ script: 'parakeetHost.ps1', cache: null }), + spawnImpl: ((file: string, args: string[], options: { env?: NodeJS.ProcessEnv }) => { + let child: ChildProcess + if (file === 'FAKE-POWERSHELL') { + // The host's own argv after `-File + + diff --git a/src/renderer/overlay/overlay.css b/src/renderer/overlay/overlay.css new file mode 100644 index 00000000..d3bca452 --- /dev/null +++ b/src/renderer/overlay/overlay.css @@ -0,0 +1,238 @@ +/* THE DICTATION WIDGET (2026-10-16). The app's own inks, handed over by main + from the theme in front (rule 15); these are only the first frame's. Sized + for the owner at 225% zoom: 17 px words, 44 px rows. */ +:root { + --ground: #16161a; + --text: #f2f2f4; + --dim: #b4b4bd; + --accent: #e8925c; + --edge: #3a3a44; + color-scheme: dark; +} +* { + box-sizing: border-box; +} +html, +body { + margin: 0; + height: 100%; + background: transparent; + overflow: hidden; + font: 15px/1.4 'Segoe UI Variable Text', 'Segoe UI', system-ui, sans-serif; + color: var(--text); + user-select: none; + -webkit-font-smoothing: antialiased; +} +body { + display: flex; + align-items: flex-end; + justify-content: center; + padding: 10px 14px 14px; +} +#card { + width: 100%; + border-radius: 14px; + background: color-mix(in srgb, var(--ground) 96%, transparent); + border: 1px solid color-mix(in srgb, var(--edge) 80%, var(--text) 20%); + box-shadow: 0 10px 28px -6px rgb(0 0 0 / 0.55), 0 2px 6px rgb(0 0 0 / 0.35); + padding: 14px 16px 12px; + animation: rise 160ms cubic-bezier(0.16, 1, 0.3, 1); +} +#card[data-phase='idle'] { + display: none; +} +@keyframes rise { + from { + transform: translateY(10px); + opacity: 0; + } +} +.head { + display: flex; + align-items: center; + gap: 12px; +} +.badge { + position: relative; + display: grid; + place-items: center; + width: 34px; + height: 34px; + flex: none; + border-radius: 50%; + background: color-mix(in srgb, var(--accent) 22%, transparent); + color: var(--accent); +} +.badge.ring::after { + content: ''; + position: absolute; + inset: -4px; + border-radius: 50%; + border: 2px solid var(--accent); + opacity: 0.55; + animation: breathe 1.4s ease-in-out infinite; +} +@keyframes breathe { + 50% { + transform: scale(1.12); + opacity: 0.15; + } +} +.icon { + width: 19px; + height: 19px; + fill: none; + stroke: currentColor; + stroke-width: 1.9; + stroke-linecap: round; + stroke-linejoin: round; +} +.icon.check { + stroke-width: 2.6; +} +.icon.warn { + color: #f0b45a; +} +.title { + font-size: 17px; + font-weight: 600; + letter-spacing: -0.005em; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.meter { + height: 4px; + margin: 12px 0 10px; + border-radius: 2px; + background: color-mix(in srgb, var(--text) 10%, transparent); + overflow: hidden; +} +.level { + height: 100%; + width: 100%; + transform-origin: left center; + transform: scaleX(0.04); + background: var(--accent); + border-radius: 2px; + transition: transform 90ms linear; +} +.live, +.said { + font-size: 17px; + line-height: 1.45; + color: var(--text); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: anywhere; +} +.said { + margin: 10px 0 10px; + padding-left: 12px; + border-left: 1px solid color-mix(in srgb, var(--accent) 70%, transparent); + color: var(--dim); + -webkit-line-clamp: 2; +} +.hint { + margin-top: 8px; + font-size: 13px; + color: var(--dim); +} +.dots { + margin: 12px 0 4px 46px; + width: 42px; + height: 6px; + background: radial-gradient(circle, var(--accent) 3px, transparent 3.5px) 0 0 / 14px 6px repeat-x; + animation: dots 900ms steps(3) infinite; +} +@keyframes dots { + to { + background-position: 42px 0; + } +} +.rows { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 340px; + overflow: auto; +} +.row { + display: flex; + align-items: center; + gap: 12px; + min-height: 44px; + padding: 6px 10px; + border: 1px solid transparent; + border-radius: 9px; + background: color-mix(in srgb, var(--text) 5%, transparent); + color: var(--text); + font: inherit; + text-align: left; + cursor: pointer; +} +.row:hover { + background: color-mix(in srgb, var(--accent) 16%, transparent); + border-color: color-mix(in srgb, var(--accent) 45%, transparent); +} +.row:disabled { + cursor: default; + opacity: 0.55; +} +.row:disabled:hover { + background: color-mix(in srgb, var(--text) 5%, transparent); + border-color: transparent; +} +.num { + display: grid; + place-items: center; + width: 26px; + height: 26px; + flex: none; + border-radius: 7px; + background: color-mix(in srgb, var(--accent) 24%, transparent); + color: var(--text); + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.name { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; +} +.label { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.folder { + font-size: 12.5px; + color: var(--dim); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.chip { + flex: none; + font-size: 12px; + padding: 2px 8px; + border-radius: 999px; + color: var(--dim); + border: 1px solid color-mix(in srgb, var(--text) 18%, transparent); +} +.chip.orchestrator { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 55%, transparent); +} +@media (prefers-reduced-motion: reduce) { + #card, + .badge.ring::after, + .dots { + animation: none; + } +} diff --git a/src/renderer/overlay/overlay.ts b/src/renderer/overlay/overlay.ts new file mode 100644 index 00000000..af48371e --- /dev/null +++ b/src/renderer/overlay/overlay.ts @@ -0,0 +1,119 @@ +import './overlay.css' +import type { OverlayLook, OverlayRow, OverlayState } from '../../shared/dictOverlay' + +/** + * THE DICTATION WIDGET (owner, 2026-10-16): above every window, never taking + * focus, sized for 225% zoom. Listening (the level and the live words), then + * the outcome: sent with a check mark, or the numbered sessions to pick from. + * Plain DOM: it opens on a key press and must draw at once. + */ + +declare global { + interface Window { + overlay: { + onState(cb: (s: OverlayState) => void): void + onLook(cb: (l: OverlayLook) => void): void + pick(id: string): void + dismiss(): void + } + } +} + +const card = document.getElementById('card') as HTMLDivElement + +const el = (tag: K, cls?: string, text?: string): HTMLElementTagNameMap[K] => { + const e = document.createElement(tag) + if (cls) e.className = cls + if (text !== undefined) e.textContent = text + return e +} + +const svg = (paths: string, cls: string): SVGSVGElement => { + const ns = 'http://www.w3.org/2000/svg' + const s = document.createElementNS(ns, 'svg') + s.setAttribute('viewBox', '0 0 24 24') + s.setAttribute('class', cls) + s.setAttribute('aria-hidden', 'true') + for (const d of paths.split('|')) { + const p = document.createElementNS(ns, 'path') + p.setAttribute('d', d) + s.appendChild(p) + } + return s +} +const MIC = 'M12 3.5a3 3 0 0 0-3 3v5a3 3 0 0 0 6 0v-5a3 3 0 0 0-3-3z|M6 11.5a6 6 0 0 0 12 0|M12 17.5v3' +const CHECK = 'M6 12.5l4 4 8-9' +const WARN = 'M12 4l9 16H3z|M12 10v4|M12 17.2v.1' + +function head(icon: SVGSVGElement, title: string, ring = false): HTMLDivElement { + const h = el('div', 'head') + const badge = el('div', ring ? 'badge ring' : 'badge') + badge.appendChild(icon) + h.append(badge, el('div', 'title', title)) + return h +} + +function rowEl(r: OverlayRow): HTMLButtonElement { + const b = el('button', 'row') + b.type = 'button' + b.dataset.pick = r.id + if (r.disabled) { + b.disabled = true + b.title = r.disabled + } + b.append(el('span', 'num', String(r.n))) + const name = el('span', 'name') + name.append(el('span', 'label', r.label), el('span', 'folder', r.copy ? `${r.copy} \u00b7 ${r.folder}` : r.folder)) + b.append(name) + const chip = r.lead ? 'orchestrator' : r.disabled ? 'asking' : r.state === 'working' ? 'working' : null + if (chip) b.append(el('span', `chip ${chip}`, chip)) + b.addEventListener('click', () => window.overlay.pick(r.id)) + return b +} + +let levelBar: HTMLDivElement | null = null + +function draw(s: OverlayState): void { + card.dataset.phase = s.phase + if (s.phase === 'listening' && levelBar && card.querySelector('.live')) { + // A level or words update: no rebuild, so nothing flickers. + levelBar.style.transform = `scaleX(${Math.max(0.04, Math.min(1, s.level * 3))})` + ;(card.querySelector('.live') as HTMLElement).textContent = s.text || (s.starting ? 'Starting the speech engine' : 'Speak now') + return + } + card.replaceChildren() + levelBar = null + if (s.phase === 'listening') { + card.append(head(svg(MIC, 'icon'), 'Listening', true)) + const meter = el('div', 'meter') + levelBar = el('div', 'level') + meter.append(levelBar) + card.append(meter, el('div', 'live', s.text || (s.starting ? 'Starting the speech engine' : 'Speak now'))) + card.append(el('div', 'hint', 'Let go to finish. Esc cancels.')) + } else if (s.phase === 'transcribing') { + card.append(head(svg(MIC, 'icon'), 'Transcribing')) + card.append(el('div', 'dots')) + } else if (s.phase === 'pick') { + card.append(head(svg(MIC, 'icon'), 'Send to')) + card.append(el('div', 'said', s.text)) + const list = el('div', 'rows') + s.rows.forEach((r) => list.append(rowEl(r))) + card.append(list, el('div', 'hint', 'Press a number or click. Esc keeps it unsent.')) + } else if (s.phase === 'sending') { + card.append(head(svg(MIC, 'icon'), `Sending to ${s.to}`)) + } else if (s.phase === 'sent') { + card.append(head(svg(CHECK, 'icon check'), `Sent to ${s.to}`)) + } else if (s.phase === 'note') { + card.append(head(svg(s.tone === 'warn' ? WARN : MIC, s.tone === 'warn' ? 'icon warn' : 'icon'), s.message)) + } +} + +window.overlay.onState(draw) +window.overlay.onLook((l) => { + const r = document.documentElement.style + r.setProperty('--ground', l.ground) + r.setProperty('--text', l.text) + r.setProperty('--dim', l.dim) + r.setProperty('--accent', l.accent) + r.setProperty('--edge', l.edge) +}) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 347a1263..e9d06d5c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -88,6 +88,7 @@ import { holdsWindowClose } from '@core/renderer/lib/agentClose' import { pluginChoice, termFontStack, useOrchestratorOn } from '@core/renderer/lib/termLook' +import { useDictateAnywhereBridge } from './lib/dictateAnywhere' import { claudeResume, ledgerState, useLedgerReport } from './lib/ledgerReport' import { useTaskbarBadge } from './lib/taskbarBadge' import { usePaintedChrome } from './lib/paintChrome' @@ -559,6 +560,8 @@ export default function App(): JSX.Element { useTaskbarBadge({ doneIds, questionIds, workingIds, failedIds }) const orchestrating = useOrchestratorOn() + // DICTATE FROM ANY APP (2026-10-16, lib/dictateAnywhere). + useDictateAnywhereBridge() // THE CATCH-UP REPORT'S LEDGER (2026-10-16, lib/ledgerReport): main keeps // which Claude session is in which tab and when the owner really looked. const allLabels = tabLabels(state.tabs) diff --git a/src/renderer/src/components/settings/DictateAnywherePage.tsx b/src/renderer/src/components/settings/DictateAnywherePage.tsx new file mode 100644 index 00000000..6ff26f3b --- /dev/null +++ b/src/renderer/src/components/settings/DictateAnywherePage.tsx @@ -0,0 +1,30 @@ +import type { JSX } from 'react' +import { Switch } from '@core/renderer/settings/fields' +import { SettingRow } from '@core/renderer/settings/layout/SettingRow' +import { SettingsSection } from '@core/renderer/settings/layout/SettingsSection' +import { useDictationEnabled } from '@core/renderer/lib/dictationPrefs' +import { setDictateAnywhere, useDictateAnywhere } from '../../lib/dictateAnywhere' +import { APP_SECTIONS, appOpt } from './appOptions' + +/** + * DICTATE FROM ANY APP (owner, 2026-10-16), this app's own row at the foot of + * the Dictation page: the key with another app in front, the widget, and the + * words sent to the orchestrator or to the session picked from the widget. + */ +export function DictateAnywhereRow(): JSX.Element { + const o = appOpt('dictation-anywhere') + const on = useDictateAnywhere() + const enabled = useDictationEnabled() + return ( + + + + + + + + ) +} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index c36e395f..5c76c347 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type JSX } from 'react' import { dictationHost } from '@core/renderer/host' import { SettingsFrame } from '@core/renderer/settings/layout/SettingsFrame' import { DiagnosticsPage } from '@core/renderer/settings/sections/DiagnosticsPage' +import { DictateAnywhereRow } from './DictateAnywherePage' import { DictationPage } from '@core/renderer/settings/sections/DictationPage' import { AboutPage } from './AboutPage' import { AgentsPage } from './AgentsPage' @@ -63,7 +64,7 @@ export default function Settings({ ) : page === 'agents' ? ( ) : page === 'dictation' ? ( - + } /> ) : page === 'diagnostics' ? ( ) : ( diff --git a/src/renderer/src/components/settings/appOptions.test.ts b/src/renderer/src/components/settings/appOptions.test.ts index 2e954e0a..2428244e 100644 --- a/src/renderer/src/components/settings/appOptions.test.ts +++ b/src/renderer/src/components/settings/appOptions.test.ts @@ -43,6 +43,7 @@ describe("this app's own settings rows", () => { "newtab-mode=prism.newtab.mode+prism.newtab.folder", "explorer-verb=registry", "orchestrator=prism.term.orchestrator", + "dictation-anywhere=prism.dictation.anywhere", "taskbar-badge=prism.window.taskbarBadge", "app-version=null", ] diff --git a/src/renderer/src/components/settings/appOptions.ts b/src/renderer/src/components/settings/appOptions.ts index fe7edaa0..1280e3f2 100644 --- a/src/renderer/src/components/settings/appOptions.ts +++ b/src/renderer/src/components/settings/appOptions.ts @@ -18,7 +18,7 @@ export interface AppOption { id: string label: string sub: string - section: 'window' | 'opening' | 'keys' | 'theme' | 'marks' | 'orchestrator' | 'about' + section: 'window' | 'opening' | 'keys' | 'theme' | 'marks' | 'orchestrator' | 'anywhere' | 'about' page: AppPageId icon: string keywords?: string @@ -37,12 +37,13 @@ export const APP_OPTIONS: readonly AppOption[] = [ { id: 'newtab-mode', label: 'Folder for new tabs', sub: 'Opens in your user folder.', section: 'opening', page: 'terminal', icon: 'newtab', keywords: 'folder home directory start new tab ask', store: ['prism.newtab.mode', 'prism.newtab.folder'] }, { id: 'explorer-verb', label: 'Add to the Explorer menu', sub: 'Open terminal here, on every folder.', section: 'opening', page: 'terminal', icon: 'menu', keywords: 'context menu right click open here', store: 'registry' }, { id: 'orchestrator', label: 'Orchestrator', sub: 'Reports on other tabs, and a lead tab.', section: 'orchestrator', page: 'agents', icon: 'lead', keywords: 'orchestrator lead catch up report summary summarise away sessions tabs claude skill plugin promote', store: ['prism.term.orchestrator'] }, + { id: 'dictation-anywhere', label: 'Dictate from any app', sub: 'The key works with another app in front.', section: 'anywhere', page: 'dictation', icon: 'mic', keywords: 'global anywhere outside background other app widget overlay send session orchestrator hotkey ctrl win', store: ['prism.dictation.anywhere'] }, { id: 'taskbar-badge', label: 'Count marked tabs on the taskbar', sub: 'A number on the taskbar button.', section: 'marks', page: 'agents', icon: 'taskbar', keywords: 'count number badge overlay', store: ['prism.window.taskbarBadge'] }, { id: 'app-version', label: 'Version', sub: 'The version you are running.', section: 'about', page: 'about', icon: 'version', keywords: 'update release number', store: null } ] /** This app's own section headings (the About section has none). */ -export const APP_SECTIONS = { window: 'Window', opening: 'Opening terminals', keys: 'Keys', orchestrator: 'Orchestrator (beta)', about: '' } as const +export const APP_SECTIONS = { window: 'Window', opening: 'Opening terminals', keys: 'Keys', orchestrator: 'Orchestrator (beta)', anywhere: 'From any app', about: '' } as const /** One of this app's rows by id. Throws on a typo, which the tests find. */ export function appOpt(id: string): AppOption { diff --git a/src/renderer/src/components/settings/settingsIndex.ts b/src/renderer/src/components/settings/settingsIndex.ts index b241c8cd..635840e1 100644 --- a/src/renderer/src/components/settings/settingsIndex.ts +++ b/src/renderer/src/components/settings/settingsIndex.ts @@ -43,7 +43,7 @@ export const ROW_ORDER = [ 'agent-indicator', 'agent-done-on', 'agent-rainbow', 'agent-question-on', 'agent-failed-on', 'taskbar-badge', 'agent-hooks', 'orchestrator', 'agent-color', 'agent-done-color', 'agent-question-color', 'dictation-enabled', 'dictation-mode', 'dictation-hotkey', 'dictation-mic', 'dictation-language', - 'dictation-pause-media', 'dictation-sounds', 'dictation-model', 'dictation-gpu', + 'dictation-pause-media', 'dictation-sounds', 'dictation-model', 'dictation-gpu', 'dictation-anywhere', 'diag-verbose', 'diag-folder', 'diag-mark', 'app-version' ] as const diff --git a/src/renderer/src/lib/dictateAnywhere.ts b/src/renderer/src/lib/dictateAnywhere.ts new file mode 100644 index 00000000..00ad78c1 --- /dev/null +++ b/src/renderer/src/lib/dictateAnywhere.ts @@ -0,0 +1,104 @@ +import { useEffect } from 'react' +import { boolPref } from '@core/renderer/lib/prefStore' +import { + dictationOutside, + dictationView, + keepDictationWarm, + onDictationLevel, + onDictationView, + OUTSIDE, + pasteIntoFront, + setDictationAutoWarm, + setDictationPageKeys, + setDictationRoute +} from '@core/renderer/lib/dictation' +import { dictationEnabled, useDictationEnabled, useDictationHotkey, useDictationMode } from '@core/renderer/lib/dictationPrefs' + +/** + * DICTATE FROM ANY APP (owner, 2026-10-16; spec: + * docs/superpowers/specs/2026-10-16-dictate-anywhere.md). THIS APP'S OWN + * setting, on by default (whenever dictation is on): main runs the key from + * any app (globalDictKey); the page records (core dictation, `OUTSIDE`) and + * hands what it heard to main, which routes it (the orchestrator, or a pick). + */ +const pref = boolPref('prism.dictation.anywhere', true) +export const dictateAnywhere = pref.get +export const setDictateAnywhere = pref.set +export const useDictateAnywhere = pref.use + +/** The theme's inks as the page wears them, for the widget (rule 15). */ +function look(): { ground: string; text: string; dim: string; accent: string; edge: string } { + const cs = getComputedStyle(document.documentElement) + const v = (name: string, fallback: string): string => cs.getPropertyValue(name).trim() || fallback + return { + ground: v('--p-tabs', '#16161a'), + text: v('--p-text', '#f2f2f4'), + dim: v('--p-dim', '#b4b4bd'), + accent: v('--p-accent-hi', '#e8925c'), + edge: v('--p-divider', '#3a3a44') + } +} + +const LEVEL_EVERY_MS = 66 + +export function useDictateAnywhereBridge(): void { + const enabled = useDictationEnabled() + const anywhere = useDictateAnywhere() + const hotkey = useDictationHotkey() + const mode = useDictationMode() + // Main runs the key only while both are on. + useEffect(() => { + window.prism.dictAnywhere({ on: enabled && anywhere, hotkey, mode }) + }, [enabled, anywhere, hotkey, mode]) + + useEffect(() => { + // What the outside recording heard goes to main, never into a tab here. + setDictationRoute((r) => window.prism.dictRouted(r)) + let level = 0 + let lastSent = 0 + const live = (): void => { + const v = dictationView() + if (v.sessionId !== OUTSIDE) return + window.prism.dictLive({ + phase: v.phase === 'idle' ? 'idle' : v.phase, + text: v.liveText, + level, + starting: v.starting + }) + } + const offView = onDictationView(live) + const offLevel = onDictationLevel((l) => { + level = l + const now = performance.now() + if (now - lastSent < LEVEL_EVERY_MS) return + lastSent = now + if (dictationView().sessionId === OUTSIDE) live() + }) + const offKey = window.prism.onDictOutside((action) => { + if (action === 'start') window.prism.dictLook(look()) + dictationOutside(action) + }) + // ONE DICTATION (owner, 2026-10-17): with a service in reach (this copy or + // another) the hook reads the key, so this window does not; only the + // service's copy keeps an engine warm. + const offRole = window.prism.onDictRole((role) => { + setDictationPageKeys(role === 'none') + setDictationAutoWarm(role !== 'client') + if (role === 'client') window.prism.dictationStop() + else if (role === 'service' && dictationEnabled()) keepDictationWarm() + }) + const offPaste = window.prism.onDictPaste((text) => { + pasteIntoFront(text) + }) + return () => { + setDictationRoute(null) + setDictationPageKeys(true) + setDictationAutoWarm(true) + offView() + offLevel() + offKey() + offRole() + offPaste() + } + }, []) +} diff --git a/src/shared/channels.ts b/src/shared/channels.ts index 01f72892..40825817 100644 --- a/src/shared/channels.ts +++ b/src/shared/channels.ts @@ -16,6 +16,13 @@ export const APP_CH = { closeRequest: 'close:request', openFolder: 'open:folder', dialogPickFolder: 'dialog:pick-folder', + dictAnywhere: 'dict:anywhere', + dictLive: 'dict:live', + dictLook: 'dict:look', + dictOutside: 'dict:outside', + dictPaste: 'dict:paste', + dictRole: 'dict:role', + dictRouted: 'dict:routed', ledgerEnable: 'ledger:enable', ledgerEngage: 'ledger:engage', ledgerFront: 'ledger:front', diff --git a/src/shared/dictOverlay.ts b/src/shared/dictOverlay.ts new file mode 100644 index 00000000..96a50f59 --- /dev/null +++ b/src/shared/dictOverlay.ts @@ -0,0 +1,32 @@ +/** + * THE DICTATION WIDGET'S STATES (owner, 2026-10-16: "when you start the + * dictation outside the app you should still see like a sort of widget ... in + * whatever window you're in"). Main sends one of these to the widget's page; + * the page draws it and says back only a pick or a dismissal. + */ +/** `copy`: which copy of the app the tab is in ("Stable"), when more than one is open. */ +export type OverlayRow = { id: string; n: number; label: string; folder: string; state: string | null; disabled: string | null; lead: boolean; copy: string | null } + +export type OverlayState = + | { phase: 'listening'; text: string; level: number; starting: boolean } + | { phase: 'transcribing' } + | { phase: 'pick'; text: string; rows: OverlayRow[] } + | { phase: 'sending'; to: string } + | { phase: 'sent'; to: string } + | { phase: 'note'; message: string; tone: 'quiet' | 'warn' } + +/** The app's look, so the widget wears the theme (rule 15's inks). */ +export interface OverlayLook { + ground: string + text: string + dim: string + accent: string + edge: string +} + +export const OVERLAY_CH = { + state: 'overlay:state', + look: 'overlay:look', + pick: 'overlay:pick', + dismiss: 'overlay:dismiss' +} as const diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 6e08735d..7342bb2f 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -15,7 +15,7 @@ // that "closed" its window has left a process holding the single-instance lock. import { _electron as electron } from 'playwright-core' import { execFileSync, spawn } from 'child_process' -import { createHash } from 'crypto' +import { createHash, randomBytes } from 'crypto' import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { createServer } from 'http' @@ -172,7 +172,7 @@ const PREF_PAGE = { 'term-shell': 'terminal', 'newtab-mode': 'terminal', 'explorer-verb': 'terminal', 'term-font-family': 'terminal', 'term-font': 'terminal', 'help-enabled': 'terminal', 'agent-indicator': 'agents', 'agent-done-on': 'agents', 'agent-rainbow': 'agents', 'agent-question-on': 'agents', 'agent-failed-on': 'agents', - 'taskbar-badge': 'agents', 'agent-hooks': 'agents', 'orchestrator': 'agents', 'agent-color': 'agents', 'agent-done-color': 'agents', + 'taskbar-badge': 'agents', 'agent-hooks': 'agents', 'orchestrator': 'agents', 'dictation-anywhere': 'dictation', 'agent-color': 'agents', 'agent-done-color': 'agents', 'agent-question-color': 'agents', 'dictation-enabled': 'dictation', 'dictation-mode': 'dictation', 'dictation-hotkey': 'dictation', 'dictation-mic': 'dictation', 'dictation-language': 'dictation', 'dictation-pause-media': 'dictation', 'dictation-sounds': 'dictation', @@ -3158,6 +3158,174 @@ const scenarios = { ok(!!list()?.sessions?.[SID], 'the list written while it was on stays on disk') }, + /** + * DICTATE FROM ANY APP (owner, 2026-10-16; spec: + * docs/superpowers/specs/2026-10-16-dictate-anywhere.md). The suite never + * runs the real keyboard hook: helper lines arrive through a seam, and what + * was heard is handed to main as the page would. A stand-in Claude (a copy of + * PowerShell named claude.exe, so Windows' process check passes) takes the + * words, and runs them, which proves they were typed and entered. + * - No orchestrator: the widget lists the sessions, numbered; "1" sends. + * - An ACTIVE orchestrator: it is sent there with no list. + * - The key with the app not in front opens the widget. + */ + async dictateAnywhere(ok) { + const w = world() + const { app, page } = await launch(w, { args: [w.alpha] }) + const SID = 'f1f2f3f4-a5a6-4b70-8c9d-0e1f2a3b4c5d' + const dir = await app.evaluate(({ app: a }) => a.getPath('userData')) + const shells = () => page.locator('[data-tab]').filter({ hasNotText: /^Settings$/ }) + const shown = () => + page.evaluate(() => [...document.querySelectorAll('.xterm')].filter((x) => x.offsetParent !== null).map((x) => x.querySelector('.xterm-rows')?.textContent ?? '').join('\n')) + const widget = async () => app.windows().find((p) => p.url().includes('overlay.html')) ?? null + const widgetText = async () => { + const p = await widget() + return p ? await p.evaluate(() => document.getElementById('card')?.textContent ?? '').catch(() => '') : '' + } + try { + await until(async () => (await tabLabels(page)).length === 1) + await polled(page) + // The stand-in Claude. + await page.locator('.xterm').first().click({ force: true }) + await typeLine(page, hookSay('done', `;session=${SID}`)) + const fake = dir + '\\stand-in\\claude.exe' + mkdirSync(dir + '\\stand-in', { recursive: true }) + copyFileSync('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', fake) + await typeLine(page, `& '${fake}' -NoLogo -NoProfile`) + await sleep(2500) + + // 1. No orchestrator: the list, then "1". + await page.evaluate(() => window.prism.dictRouted({ text: "Write-Output ('DICT-' + 'OK')" })) + ok(!!(await until(async () => (await widgetText()).includes('Send to'), 10000, 100)), 'with no orchestrator the widget lists the sessions') + const w1 = await widgetText() + ok(w1.includes('1') && w1.includes('alpha'), `numbered, by tab (${w1.slice(0, 120)})`) + const unfocusable = await app.evaluate(({ BrowserWindow }) => { + const o = BrowserWindow.getAllWindows().find((x) => x.webContents.getURL().includes('overlay.html')) + return o ? !o.isFocusable() && o.isAlwaysOnTop() : null + }) + ok(unfocusable === true, 'the widget never takes focus and stays on top') + const wp = await widget() + await wp?.screenshot({ path: resolve(process.cwd(), '.e2e-shots/dictation-widget-pick.png') }).catch(() => {}) + await app.evaluate(() => globalThis.__e2eDictLine('p 1')) + ok(!!(await until(async () => (await widgetText()).includes('Sent to alpha'), 15000, 100)), 'the number sends it, and the widget says sent') + await wp?.screenshot({ path: resolve(process.cwd(), '.e2e-shots/dictation-widget-sent.png') }).catch(() => {}) + ok(!!(await until(async () => (await shown()).includes('DICT-OK'), 8000, 100)), 'and it was typed and entered in that session') + + // 2. An active orchestrator: no list. + const sw = (await gotoPref(page, 'orchestrator')).locator('[role="switch"]') + await sw.click() + await page.keyboard.press('Control+w') + await until(async () => (await page.locator('[data-tab]').count()) === 1, 4000, 50) + await shells().nth(0).click({ button: 'right' }) + await page.locator('[role="menu"] [role="menuitem"]', { hasText: 'Make orchestrator' }).click() + await sleep(800) + await page.evaluate(() => window.prism.dictRouted({ text: "Write-Output ('LEAD-' + 'OK')" })) + ok(!!(await until(async () => (await widgetText()).includes('Sent to alpha'), 15000, 100)), 'with an active orchestrator it is sent straight there') + ok(!(await widgetText()).includes('Send to'), 'with no list') + ok(!!(await until(async () => (await shown()).includes('LEAD-OK'), 8000, 100)), 'and it arrived') + + // 3. The key, with the app not in front (the suite's window is parked + // and unfocused): Ctrl then Win, held past the solo wait. + await page.evaluate(() => localStorage.setItem('prism.dictation.enabled', '1')) + await gotoPref(page, 'dictation-anywhere') + await page.keyboard.press('Control+w') + await sleep(600) + await app.evaluate(() => { + globalThis.__e2eDictLine('d A2') + globalThis.__e2eDictLine('d 5B') + }) + await sleep(500) + const keyed = await widgetText() + ok(/Listening|speech model|Speak now/.test(keyed), `Ctrl+Win from another app opens the widget (${keyed.slice(0, 80)})`) + await app.evaluate(() => { + globalThis.__e2eDictLine('u 5B') + globalThis.__e2eDictLine('u A2') + }) + } finally { + await closeApp(app) + } + }, + + /** + * ONE DICTATION FOR EVERY COPY (owner, 2026-10-17: "there should be one + * dictation engine for both ... it should show all sessions open in one ui + * from both instances"; spec: docs/superpowers/specs/2026-10-17-one-dictation.md). + * Two copies on a pipe of the scenario's own; the first is made the service + * through the seam (the suite runs no real hook). + * - The service's list holds the OTHER copy's Claude, named by its copy, and + * a pick is typed and entered there by that copy. + * - With that copy's window in front at the press, the words are pasted at + * its front tab's cursor, no Enter. + * - The service closes: the other copy takes over and lists its own tab. + */ + async dictateTwoCopies(ok) { + const pipe = `\\\\.\\pipe\\pt-e2e-dict-${process.pid}-${Date.now()}` + const wa = world() + const wb = world() + // The scenario's own link key: both copies prove they hold it (dictationLink). + const env = { PT_E2E_DICT_PIPE: pipe, PT_E2E_DICT_KEY: randomBytes(32).toString('hex') } + const A = await launch(wa, { args: [wa.alpha], env }) + const B = await launch(wb, { args: [wb.beta], env }) + const SID = 'b1b2b3b4-a5a6-4b70-8c9d-0e1f2a3b4c5d' + const shown = (page) => + page.evaluate(() => [...document.querySelectorAll('.xterm')].filter((x) => x.offsetParent !== null).map((x) => x.querySelector('.xterm-rows')?.textContent ?? '').join('\n')) + const widgetText = async (app) => { + const p = app.windows().find((x) => x.url().includes('overlay.html')) + return p ? await p.evaluate(() => document.getElementById('card')?.textContent ?? '').catch(() => '') : '' + } + let aOpen = true + try { + await until(async () => (await tabLabels(B.page)).length === 1) + await polled(B.page) + // B's stand-in Claude (a copy of PowerShell named claude.exe). + const dir = await B.app.evaluate(({ app: a }) => a.getPath('userData')) + await B.page.locator('.xterm').first().click({ force: true }) + await typeLine(B.page, hookSay('done', `;session=${SID}`)) + const fake = dir + '\\stand-in\\claude.exe' + mkdirSync(dir + '\\stand-in', { recursive: true }) + copyFileSync('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', fake) + await typeLine(B.page, `& '${fake}' -NoLogo -NoProfile`) + await sleep(2500) + + // A takes the key: the service. B finds it on the pipe. + await A.app.evaluate(() => globalThis.__e2eDictLine('ready')) + await sleep(1800) + + // 1. A's list holds B's Claude, named by its copy; "1" sends it there. + await A.app.evaluate(() => globalThis.__e2eDictRoute("Write-Output ('TWO-' + 'OK')", null)) + ok(!!(await until(async () => (await widgetText(A.app)).includes('Send to'), 10000, 100)), 'the service lists the sessions') + const listed = await widgetText(A.app) + ok(listed.includes('beta') && listed.includes('\u00b7'), `the other copy's Claude is in the list, named by its copy (${listed.slice(0, 160)})`) + await A.app.evaluate(() => globalThis.__e2eDictLine('p 1')) + ok(!!(await until(async () => (await widgetText(A.app)).includes('Sent to beta'), 15000, 100)), 'the pick is sent to the other copy') + ok(!!(await until(async () => (await shown(B.page)).includes('TWO-OK'), 8000, 100)), 'and that copy typed and entered it') + + // 2. B's window in front at the press: pasted at its cursor, no Enter. + const pidB = await B.app.evaluate(() => process.pid) + await A.app.evaluate((_e, pid) => globalThis.__e2eDictRoute('echo PASTEDB', pid), pidB) + ok(!!(await until(async () => (await shown(B.page)).includes('echo PASTEDB'), 8000, 100)), 'with that copy in front the words are typed at its cursor') + await sleep(800) + const after = await shown(B.page) + ok(!/^PASTEDB\s*$/m.test(after), 'and not entered') + ok((await widgetText(A.app)).includes('Typed into the tab in front'), 'the widget says where they went') + await B.page.keyboard.press('Control+c') + + // 3. The service closes: B takes over and lists its own tab. + await closeApp(A.app) + aOpen = false + await sleep(1500) + await B.app.evaluate(() => globalThis.__e2eDictLine('ready')) + await B.app.evaluate(() => globalThis.__e2eDictRoute('Write-Output hi', null)) + ok(!!(await until(async () => (await widgetText(B.app)).includes('Send to'), 10000, 100)), 'the other copy takes over the dictation') + const own = await widgetText(B.app) + ok(own.includes('beta') && !own.includes('\u00b7'), `with one copy left, no copy names (${own.slice(0, 120)})`) + await B.app.evaluate(() => globalThis.__e2eDictLine('x')) + } finally { + if (aOpen) await closeApp(A.app) + await closeApp(B.app) + } + }, + /** * NO WINDOWS SYSTEM MENU ON A RIGHT-CLICK (owner, 2026-10-13: "stop chromium * right click menus like this from appearing in the terminal"). Windows @@ -3479,6 +3647,17 @@ const scenarios = { const early = await look() await page.screenshot({ path: resolve(process.cwd(), '.e2e-shots/launch-skeleton.png') }).catch(() => {}) ok(early.skeleton, 'the tab in front wears the skeleton while its conversation comes back') + // Nothing of the terminal on top of it (owner, 2026-10-17: thin blue + // lines over the skeleton). They were the link decorations + // (termLinkPaint, xterm's z-index 6) of the paths Claude prints as it + // resumes, above the skeleton's z-index 1: the terminal's region must be + // a stacking context of its own, so no layer of xterm's leaves it. + const covered = await page.evaluate(() => { + const sk = document.querySelector('[data-resume-skeleton="shown"]') + const region = sk?.parentElement?.querySelector('[data-term-region]') + return region ? getComputedStyle(region).isolation : null + }) + ok(covered === 'isolate', `no layer of the terminal can paint over the skeleton (isolation: ${covered})`) ok(early.rings === 3, `every tab shows the loading ring (${early.rings})`) ok(early.ringsAtEnd === 3, `each at the tab's end, where the Ring goes (${early.ringsAtEnd})`) let bare = false @@ -7924,6 +8103,9 @@ const scenarios = { await page.evaluate(() => { localStorage.setItem('prism.dictation.model', 'tiny') localStorage.setItem('prism.dictation.sounds', '0') + // These scenarios hold Right Alt, the default until 2026-10-16 (now Ctrl+Win): + // they set it, so the AltGr-era rules keep their real-key proof. + localStorage.setItem('prism.dictation.hotkey', JSON.stringify({ code: 'AltRight', ctrl: false, alt: false, shift: false, meta: false })) }) await page.locator('[data-title-settings]').click() await page.locator('[data-settings-tab="dictation"]').click() @@ -8174,6 +8356,9 @@ const scenarios = { // so the English sentence must still come back in English. await page.evaluate(() => { localStorage.setItem('prism.dictation.model', 'parakeet-v3-q4') + // These scenarios hold Right Alt, the default until 2026-10-16 (now Ctrl+Win): + // they set it, so the AltGr-era rules keep their real-key proof. + localStorage.setItem('prism.dictation.hotkey', JSON.stringify({ code: 'AltRight', ctrl: false, alt: false, shift: false, meta: false })) localStorage.setItem('prism.dictation.language', 'no') localStorage.setItem('prism.dictation.sounds', '0') }) diff --git a/tools/install-stable.ps1 b/tools/install-stable.ps1 index 426a0276..384b9d4b 100644 --- a/tools/install-stable.ps1 +++ b/tools/install-stable.ps1 @@ -52,8 +52,14 @@ if ($running) { Write-Host 'Closing the stable copy to refresh it (its tabs come back when it starts).' $running | ForEach-Object { $_.CloseMainWindow() | Out-Null } if (-not (Wait-StableGone 20)) { - Get-Process PrismTerminalStable -ErrorAction SilentlyContinue | Stop-Process -Force - if (-not (Wait-StableGone 15)) { throw 'the stable copy did not close; nothing was changed' } + # Forced, AGAIN AND AGAIN (2026-10-17): a closing copy started a fresh + # network service child after the first force, which then outlived its + # parent for good, the wait ran out and nothing was refreshed. + for ($i = 0; $i -lt 10 -and (Get-Process PrismTerminalStable -ErrorAction SilentlyContinue); $i++) { + Get-Process PrismTerminalStable -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + [void](Wait-StableGone 3) + } + if (-not (Wait-StableGone 5)) { throw 'the stable copy did not close; nothing was changed' } } }