From a5a8a1362204044df3bcd78a65e04a1abe1493d4 Mon Sep 17 00:00:00 2001 From: Omkar Tripathi <38633467+triggeredcode@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:42:14 +0530 Subject: [PATCH 1/2] feat(desktop): choose how long a paste waits before it is sent Settings has a "Dictation" group: a paste (how dictation apps deliver words) is sent by itself after 0.5 s as before, or after 1 s or 2 s, which suits slower speech, or never, so the words wait in the box until Enter and can be read first. Kept in settings.json (a file from before has 0.5 s). Unit tests cover the setting; an end-to-end test checks that nothing is sent after one second with 2 s, that nothing is sent by itself with Never, and that Enter sends it, still as a dictation (mutation-checked). Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + apps/desktop/e2e/dictation.spec.ts | 49 ++++++++++++++++++++ apps/desktop/electron/ipc/index.ts | 1 + apps/desktop/electron/ipc/types.ts | 6 +++ apps/desktop/electron/settings.test.ts | 17 +++++++ apps/desktop/electron/settings.ts | 12 +++++ apps/desktop/src/dictation/DictationBar.tsx | 2 + apps/desktop/src/pages/Workspace.tsx | 4 +- apps/desktop/src/settings/SettingsDialog.tsx | 26 ++++++++++- apps/desktop/src/settings/settingsStore.ts | 26 ++++++++++- docs/guide.html | 5 +- 11 files changed, 144 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b2e5d8..a487382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and Lucid uses [semantic versioning](https://semver.org). ### Added - **The look of the PDF is yours to set** (Settings, "PDF look"): paper (Auto picks Letter or A4 by where the computer is), text size, typeface (Latin Modern, Palatino, Times or Helvetica), margins, an accent colour for the title and headings, numbered or plain headings, and the date under the title. The defaults are the look PDFs always had. **Edit template…** opens `lucid.sty`, the file that sets the house style, so everything else can be changed too; if it has a mistake the PDF is made with the built-in one and says so. +- **Dictation timing** (Settings, "Dictation"): how long a paste waits before it is sent by itself: 0.5 s as before, 1 s, 2 s, or never (the words wait in the box until Enter). ### Changed diff --git a/apps/desktop/e2e/dictation.spec.ts b/apps/desktop/e2e/dictation.spec.ts index 75bf207..59c2e9b 100644 --- a/apps/desktop/e2e/dictation.spec.ts +++ b/apps/desktop/e2e/dictation.spec.ts @@ -120,6 +120,55 @@ test("a paste (how Wispr Flow delivers dictation) is sent on its own after a sho } }); +/** Settings, Dictation: how long a paste waits before it is sent by itself. */ +async function chooseSendDelay(page: Page, label: string): Promise { + await page.keyboard.press("Meta+,"); + const dialog = page.getByRole("dialog", { name: "Settings" }); + await dialog + .locator(".dictation-settings") + .getByRole("radio", { name: label, exact: true }) + .click(); + await dialog.getByRole("button", { name: "Save" }).click(); + await expect(dialog).toBeHidden(); +} + +test("Settings can make a paste wait longer before it is sent, or never send it by itself", async () => { + test.setTimeout(120_000); + const { app, page, home } = await launch({ + script: [ + { kind: "reply", content: notes }, + { kind: "reply", content: notes }, + ], + }); + try { + // Two seconds: nothing is sent after one. + await chooseSendDelay(page, "2 s"); + const stored = JSON.parse(readFileSync(join(home, ".lucid", "settings.json"), "utf8")) as { + dictation: unknown; + }; + expect(stored.dictation).toEqual({ autoSendMs: 2000 }); + await wisprPaste(app, page, `${said} `); + await page.waitForTimeout(1000); + expect(requestsSent()).toBe(0); + await expect.poll(requestsSent, { timeout: 30_000 }).toBe(1); + + // Never: the words wait in the box until Enter. + await chooseSendDelay(page, "Never"); + await bar(page).focus(); + await wisprPaste(app, page, `${said} `); + await page.waitForTimeout(2500); + expect(requestsSent()).toBe(1); + await expect(bar(page)).toHaveValue(/^Load balancer\. Multiple application servers/); + await bar(page).press("Enter"); + await expect.poll(requestsSent, { timeout: 30_000 }).toBe(2); + await expect(bar(page)).toHaveValue(""); + // Said by a paste, so it is kept as a dictation and not as typing. + expect(JSON.stringify(mock?.requests[1]?.body)).toContain("Multiple application servers"); + } finally { + await app.close(); + } +}); + test("typing after a paste cancels the automatic send", async () => { test.setTimeout(60_000); const { app, page } = await launch({ script: [{ kind: "reply", content: notes }] }); diff --git a/apps/desktop/electron/ipc/index.ts b/apps/desktop/electron/ipc/index.ts index d21bea4..c326c87 100644 --- a/apps/desktop/electron/ipc/index.ts +++ b/apps/desktop/electron/ipc/index.ts @@ -154,6 +154,7 @@ export function registerIpc(): void { author: s.author, footerRight: s.footerRight, pdf: s.pdf, + dictation: s.dictation, vocabulary: s.vocabulary, llm: { baseUrl: process.env["LUCID_LLM_URL"] ?? s.llm.baseUrl, diff --git a/apps/desktop/electron/ipc/types.ts b/apps/desktop/electron/ipc/types.ts index dfd65e2..9fc3122 100644 --- a/apps/desktop/electron/ipc/types.ts +++ b/apps/desktop/electron/ipc/types.ts @@ -99,11 +99,17 @@ export type AgentReply = { id: number; ok: true; result: unknown } | { id: number; ok: false; error: string }; /** What the renderer may know about the settings, and change (no program paths). */ +/** How long a paste waits for more before it is sent by itself, in ms (0: never, press Enter). */ +export const AUTO_SEND_MS = [450, 1000, 2000, 0] as const; +export type AutoSendMs = (typeof AUTO_SEND_MS)[number]; + export interface PublicSettings { author: string; footerRight: string; /** How PDFs look (paper, text size, typeface, margins, accent, numbering, date). */ pdf: PdfStyle; + /** What dictation does with a paste. */ + dictation: { autoSendMs: AutoSendMs }; vocabulary: string[]; llm: { baseUrl: string; model: string; contextLength: number }; /** The address comes from the LUCID_LLM_URL environment variable (tests, development) and is not changed here. */ diff --git a/apps/desktop/electron/settings.test.ts b/apps/desktop/electron/settings.test.ts index 70b7514..0ebb901 100644 --- a/apps/desktop/electron/settings.test.ts +++ b/apps/desktop/electron/settings.test.ts @@ -25,6 +25,19 @@ describe("loadSettings", () => { expect(await loadSettings(dir)).toMatchObject({ author: "Asha", footerRight: "" }); }); + it("sends a paste after 0.5 s unless the settings say otherwise (a file from before has no such setting)", async () => { + const dir = await mkdtemp(join(tmpdir(), "lucid-set-")); + await mkdir(join(dir, ".lucid")); + const file = join(dir, ".lucid", "settings.json"); + await writeFile(file, JSON.stringify({ author: "Asha" }), "utf8"); + expect((await loadSettings(dir)).dictation).toEqual({ autoSendMs: 450 }); + await writeFile(file, JSON.stringify({ dictation: { autoSendMs: 0 } }), "utf8"); + expect((await loadSettings(dir)).dictation).toEqual({ autoSendMs: 0 }); + // A value that is not offered falls back to the defaults, like any damaged setting. + await writeFile(file, JSON.stringify({ dictation: { autoSendMs: 7 } }), "utf8"); + expect((await loadSettings(dir)).dictation).toEqual({ autoSendMs: 450 }); + }); + it("gives the house style to a settings file from before there were PDF choices, and keeps partial ones", async () => { const dir = await mkdtemp(join(tmpdir(), "lucid-set-")); await mkdir(join(dir, ".lucid")); @@ -53,6 +66,7 @@ describe("saveSettings", () => { author: "Asha", footerRight: "Notes", pdf: { ...DEFAULT_PDF_STYLE, paper: "a4", fontSize: 11, accent: "navy", showDate: false }, + dictation: { autoSendMs: 1000 }, vocabulary: ["vLLM", "Kafka", "vLLM"], llm: { baseUrl: "http://localhost:1234/", model: "other/model-7b", contextLength: 16384 }, }; @@ -70,6 +84,7 @@ describe("saveSettings", () => { author: "Asha", footerRight: "Notes", pdf: { paper: "a4", fontSize: 11, accent: "navy", showDate: false }, + dictation: { autoSendMs: 1000 }, vocabulary: ["vLLM", "Kafka"], tectonicPath: "/custom/tectonic", llm: { @@ -118,6 +133,8 @@ describe("saveSettings", () => { SettingsError, ); expect(await bad({ pdf: undefined })).toBeInstanceOf(SettingsError); + expect(await bad({ dictation: { autoSendMs: 300 } })).toBeInstanceOf(SettingsError); + expect(await bad({ dictation: undefined })).toBeInstanceOf(SettingsError); expect(await loadSettings(dir)).toEqual(before); }); }); diff --git a/apps/desktop/electron/settings.ts b/apps/desktop/electron/settings.ts index 034cedb..b160170 100644 --- a/apps/desktop/electron/settings.ts +++ b/apps/desktop/electron/settings.ts @@ -1,8 +1,16 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { DEFAULT_PDF_STYLE, PdfStyleSchema } from "@lucid/kit"; +import { AUTO_SEND_MS } from "./ipc/types"; import { z } from "zod"; +const AutoSendMsSchema = z.union([ + z.literal(AUTO_SEND_MS[0]), + z.literal(AUTO_SEND_MS[1]), + z.literal(AUTO_SEND_MS[2]), + z.literal(AUTO_SEND_MS[3]), +]); + /** User settings in `/.lucid/settings.json`. Missing file or fields fall back to the defaults. */ export const SettingsSchema = z.object({ /** Your name under the title of exported PDFs (optional). */ @@ -11,6 +19,8 @@ export const SettingsSchema = z.object({ footerRight: z.string().default(""), /** How PDFs look: paper, text size, typeface, margins, accent, numbering and date (the house style by default). */ pdf: PdfStyleSchema.default(DEFAULT_PDF_STYLE), + /** Dictation: how long a paste waits for more before it is sent by itself (0: never, press Enter). */ + dictation: z.object({ autoSendMs: AutoSendMsSchema.default(450) }).default({ autoSendMs: 450 }), /** Words speech recognition tends to get wrong (names, tools); kept as spoken when the notes are written. */ vocabulary: z.array(z.string()).default([]), /** The Tectonic to run; empty means the one that comes with Lucid, or an installed one. */ @@ -87,6 +97,7 @@ export const EditableSettingsSchema = z.strictObject({ author: oneLine(80), footerRight: oneLine(40), pdf: PdfStyleSchema, + dictation: z.strictObject({ autoSendMs: AutoSendMsSchema }), vocabulary: z.array(oneLine(60).min(1)).max(200, "at most 200 words"), llm: z.strictObject({ baseUrl: LocalAddress, @@ -120,6 +131,7 @@ export async function saveSettings(libraryDir: string, input: unknown): Promise< author: parsed.data.author, footerRight: parsed.data.footerRight, pdf: parsed.data.pdf, + dictation: parsed.data.dictation, vocabulary: [...new Set(parsed.data.vocabulary)], llm: { ...current.llm, ...parsed.data.llm }, }; diff --git a/apps/desktop/src/dictation/DictationBar.tsx b/apps/desktop/src/dictation/DictationBar.tsx index b14f0b7..6ea9250 100644 --- a/apps/desktop/src/dictation/DictationBar.tsx +++ b/apps/desktop/src/dictation/DictationBar.tsx @@ -114,6 +114,8 @@ export function DictationBar({ const sendWhenQuiet = () => { pasted.current = true; clearTimeout(timer.current); + // Never: the words wait in the box until Enter. + if (autoSubmitDelayMs <= 0) return; timer.current = setTimeout(() => { sendRef.current("wispr"); }, autoSubmitDelayMs); diff --git a/apps/desktop/src/pages/Workspace.tsx b/apps/desktop/src/pages/Workspace.tsx index 6c2af4a..17cab1f 100644 --- a/apps/desktop/src/pages/Workspace.tsx +++ b/apps/desktop/src/pages/Workspace.tsx @@ -23,7 +23,7 @@ import { LmStatusPill } from "./LmStatusPill"; import { ZoomChip } from "./ZoomChip"; import { zoomPageByWheel } from "../theme/pageZoom"; import { agentName, setAgentHandler } from "../agent/bridge"; -import { peekSettings } from "../settings/settingsStore"; +import { peekSettings, useAutoSendMs } from "../settings/settingsStore"; import { HistoryPanel } from "./HistoryPanel"; import { useAutosave } from "./useAutosave"; @@ -137,6 +137,7 @@ export function Workspace({ file, onSaved, sidebarOpen, onToggleSidebar }: Props retry, snapshot, } = useAutosave(file, { title, doc, transcripts, changeLog, format, info }, onSaved); + const autoSendMs = useAutoSendMs(); const jobs = useJobs({ editor: () => editorApi.current, title: () => titleRef.current, @@ -488,6 +489,7 @@ export function Workspace({ file, onSaved, sidebarOpen, onToggleSidebar }: Props
= { + 450: "0.5 s", + 1000: "1 s", + 2000: "2 s", + 0: "Never", +}; +const SEND_OPTIONS = AUTO_SEND_MS.map((ms) => [ms, SEND_LABELS[ms]] as const); const HEADING_OPTIONS = [ [true, "Numbered"], [false, "Plain"], @@ -91,6 +98,7 @@ export function SettingsDialog({ settings, onClose }: Props) { const [author, setAuthor] = useState(settings.author); const [footer, setFooter] = useState(settings.footerRight); const [pdf, setPdf] = useState(settings.pdf); + const [autoSend, setAutoSend] = useState(settings.dictation.autoSendMs); // The PDF template: whether the person has their own, and whether its window is open. const [ownTemplate, setOwnTemplate] = useState(false); const [editingTemplate, setEditingTemplate] = useState(false); @@ -132,6 +140,7 @@ export function SettingsDialog({ settings, onClose }: Props) { author, footerRight: footer, pdf, + dictation: { autoSendMs: autoSend }, vocabulary: words .split(/\r?\n/) .map((w) => w.trim()) @@ -413,6 +422,21 @@ export function SettingsDialog({ settings, onClose }: Props) { +
+ Dictation + + + Dictation apps paste what they heard; Lucid sends it by itself once nothing more has + arrived for this long. A longer pause suits slower speech, and Never leaves the words + in the box until you press Enter, so you can read them first. + +
+
Words to expect