Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
49 changes: 49 additions & 0 deletions apps/desktop/e2e/dictation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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 }] });
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/electron/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/electron/ipc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/electron/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -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 },
};
Expand All @@ -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: {
Expand Down Expand Up @@ -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);
});
});
12 changes: 12 additions & 0 deletions apps/desktop/electron/settings.ts
Original file line number Diff line number Diff line change
@@ -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 `<library>/.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). */
Expand All @@ -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. */
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
};
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/dictation/DictationBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/pages/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -488,6 +489,7 @@ export function Workspace({ file, onSaved, sidebarOpen, onToggleSidebar }: Props
<div className="dock">
<div className="page-column">
<DictationBar
autoSubmitDelayMs={autoSendMs}
mode={jobs.shownMode}
onMode={jobs.setMode}
status={jobs.status}
Expand Down
26 changes: 25 additions & 1 deletion apps/desktop/src/settings/SettingsDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useId, useRef, useState } from "react";
import { ACCENT_COLORS, FONT_NAMES, type PdfStyle } from "@lucid/kit";
import type { PublicSettings } from "../../electron/ipc/types";
import { AUTO_SEND_MS, type AutoSendMs, type PublicSettings } from "../../electron/ipc/types";
import {
setFont,
setTheme,
Expand Down Expand Up @@ -72,6 +72,13 @@ const MARGIN_OPTIONS = [
["normal", "Normal"],
["wide", "Wide"],
] as const;
const SEND_LABELS: Record<AutoSendMs, string> = {
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"],
Expand All @@ -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<PdfStyle>(settings.pdf);
const [autoSend, setAutoSend] = useState<AutoSendMs>(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);
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -413,6 +422,21 @@ export function SettingsDialog({ settings, onClose }: Props) {
</small>
</fieldset>

<fieldset className="dictation-settings">
<legend>Dictation</legend>
<Choice
label="Send a paste after a pause of"
value={autoSend}
options={SEND_OPTIONS}
onChange={setAutoSend}
/>
<small>
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.
</small>
</fieldset>

<fieldset>
<legend>Words to expect</legend>
<label>
Expand Down
26 changes: 25 additions & 1 deletion apps/desktop/src/settings/settingsStore.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { PublicSettings, SettingsInput, SettingsSaveResult } from "../../electron/ipc/types";
import { useEffect, useState } from "react";
import type {
AutoSendMs,
PublicSettings,
SettingsInput,
SettingsSaveResult,
} from "../../electron/ipc/types";
import { resetModel } from "../llm/model";

let current: PublicSettings | undefined;
Expand Down Expand Up @@ -27,3 +33,21 @@ export async function saveSettings(input: SettingsInput): Promise<SettingsSaveRe
}
return result;
}

/** How long a paste waits for more before it is sent by itself (0: never); follows the Settings window. */
export function useAutoSendMs(): AutoSendMs {
const [ms, setMs] = useState<AutoSendMs>(peekSettings()?.dictation.autoSendMs ?? 450);
useEffect(() => {
const read = () => {
void loadSettings().then((s) => {
setMs(s.dictation.autoSendMs);
});
};
read();
window.addEventListener("lucid:settings-saved", read);
return () => {
window.removeEventListener("lucid:settings-saved", read);
};
}, []);
return ms;
}
2 changes: 2 additions & 0 deletions docs/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Lucid does no speech recognition itself: a dictation app turns your voice into t
[dictation](dictation.md) for free and open-source choices). From there:

- **Notes without pressing Enter.** A pasted dictation is sent by itself about half a second after the last piece arrives.
Settings → **Dictation** makes the pause 1 s or 2 s (slower speech), or turns it off so the words wait in the box until you
press Enter and you can read them first.
- **Commands by voice.** In Command mode, say what to change: *"cross out Memcached, we are choosing the local LRU"*, *"add a
CDN in front of the web server"*, *"undo"*. Say *"command mode"*, *"input mode"* or *"edit mode"* to change mode.
- **One key for one command.** Hold the **right ⌥ key** (right Alt on Windows and Linux) and everything you dictate while it is
Expand Down
4 changes: 2 additions & 2 deletions docs/dictation.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ sees the text that arrives.

Two things to know:

- A **paste** is sent about half a second after the last piece arrives, with no Enter. Text that is typed key by key is not
sent on its own: press Enter.
- A **paste** is sent about half a second after the last piece arrives, with no Enter (Settings → Dictation makes that 1 s or
2 s, or never). Text that is typed key by key is not sent on its own: press Enter.
- Lucid's own command key is the **right ⌥ key** (right Alt on Windows and Linux). Choose another key for your dictation app's
push-to-talk (VoiceNative's is right Shift) so they do not meet.

Expand Down
3 changes: 2 additions & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ changes to other blocks alone.

- **PDF**, typeset with Tectonic in a fixed house style (numbered sections, real tables and figures, formulas). Settings →
**PDF look** changes the paper (A4 or Letter), the text size, the typeface, the margins, an accent colour, whether headings
are numbered and whether the date shows.
are numbered and whether the date shows. **Edit template…** opens `lucid.sty`, the file behind the house style, to change
anything else; if it has a mistake, the PDF is made with the built-in one and says so.
- **Markdown** (diagrams as Mermaid blocks).
- **A `.lucid` file**, the whole note with its history, to back up, move to another computer or share.

Expand Down
5 changes: 3 additions & 2 deletions docs/guide.html

Large diffs are not rendered by default.

Loading