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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@
- Offer Utterform in "Open with" on Linux, Windows and macOS without becoming the default player for any audio type.

See [0.13.0 release notes](docs/releases/v0.13.0.md).

## 0.12.0 · The right action in every app (on `main`, ships with 1.0)

- Add **Settings → Context → Per app**: give an application its own action — Email in Thunderbird, Plain in the terminal. Pressing the dictation key there takes that action for this one dictation; everywhere else the action chosen in the window applies. The list starts empty, so nothing changes until you add an app.
- Add an app with **+ App**, which lists the apps you dictated into lately, or type its name. Each app has its action menu and × to remove it.
- Say when an app's action takes over: a short notification "Utterform · Email — for Thunderbird", and the window shows the action with the app's name while it records. A recording started from Utterform's own window, and live dictation, never follow an app's action.
- Know the app in front on Linux under Hyprland and Sway (and X11 through `xprop`), on Windows and on macOS. GNOME and KDE on Wayland do not tell applications which window is focused; there the row says so.
- The app names are kept in `settings.json` on this device. The diagnostic log records only that an app was found and which action ran, never the app.

See [0.12.0 release notes](docs/releases/v0.12.0.md).

## 0.11.0 · Say what to do with the selected text (on `main`, ships with 1.0)

- Add the **Context** action. Select an email, start dictating with Context, say "reply that Monday at ten works", and the reply arrives at the cursor. What you say is the instruction; the selected text is the material. The material is treated as data, so an instruction hidden in it is not followed.
Expand Down
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ and the matching rows from every category appear, ready to change right there.
| Recording | Microphone, shortcut, sounds |
| Transcription | Engine (Cloud, Live, Local), OpenAI key, language, vocabulary, topic, local model |
| AI | Actions (built-in and your own prompts), provider (OpenAI key or ChatGPT), text model and thinking effort |
| Context | What the Context action may read: selection, clipboard |
| Context | What the Context action may read: selection, clipboard; an action per app |
| Output | Typing method, keystroke delay, file folder, history |
| App | Theme, start at login, diagnostics |

Expand Down Expand Up @@ -283,6 +283,19 @@ Only the Context action reads the selection or the clipboard, never plain dictat
another action. On Linux the primary selection is whatever you selected last, even in
another window, so check what is selected before you use Context for something private.

## An action per app

Under **Settings → Context → Per app** an app can have its own action: *Email* in
Thunderbird, *Plain* in the terminal. The dictation key then takes that action in that
app; everywhere else the action chosen in the window applies. **+ App** lists the apps
you dictated into lately, or type a name. When an app's action takes over, a short
notification says which, and the window shows it while recording. A recording started
from Utterform's own window, and live dictation, never follow an app's action.

Utterform knows the app in front on Linux under Hyprland, Sway and X11 (`xprop`), on
Windows and on macOS. GNOME and KDE on Wayland do not say which window is focused, so
there the feature stays off.

## Vocabulary

Names, products and spellings a model would otherwise guess at go under **Settings → Transcription → Vocabulary**, one word at a time: type it and press Enter, remove it with ×. A pasted list keeps its lines. Write *Careum* there and it stops coming back as *Kareum*.
Expand All @@ -306,6 +319,7 @@ Push/PR CI runs Linux validation without release compilation. For a test binary
- Offline transcription through `whisper.cpp`, built for every supported desktop rather than for the machine that compiled it
- One-click, SHA-256-verified downloads for Tiny, Base, and Small multilingual models
- Plain, Clean, Polish, Summarize, Prompt, Email, and user-defined actions — every shipped prompt can be rewritten
- An action per app for the dictation key: Email in the mail client, Plain in the terminal
- A vocabulary of your own terms, and a reasoning effort for the text step
- Clipboard, TXT, Markdown, or combined output
- Selectable microphone with a system-default fallback
Expand Down Expand Up @@ -349,6 +363,7 @@ Global dictation is separate from these: see [Dictate from anywhere](#dictate-fr
- **Local Whisper + Plain:** audio and text stay on the device.
- **Local Whisper + transformed action:** audio stays local; transcribed text is sent to OpenAI.
- **Context action:** the selected or copied text (up to 20,000 characters) is sent to the text model with your instruction. No other action reads the selection or the clipboard.
- **Action per app:** the names of up to eight apps you dictated into are kept in `settings.json` on this device, to choose from in Settings. They are not logged.
- Temporary recordings are deleted after processing or cancellation.
- The OpenAI API key is never written to `settings.json`; it is stored through the native OS keyring.
- The last 100 completed texts are stored unencrypted on this device in `history.json`, including clipboard-only output. Titles are generated locally, without an AI call. Disable future storage or clear existing history in Settings; exported files and clipboard contents are not cleared.
Expand Down
42 changes: 42 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,48 @@ as Plain with a note: it has no spoken instruction and nothing was read at its s
**Diagnostics.** `context.captured` (source, characters, truncated) or `context.none`
(which sources were on). The text itself is never logged, like a transcript.

## Action per application (0.12.0)

**Rule.** `settings.app_actions` holds `{ app: { id, name }, action }` pairs, empty by
default. `action` is a built-in id or `custom:<id>`. When the dictation key starts a
recording (`applyIntent` → `startRecording(true)`), the interface asks
`foreground_app` for the application in front, adds it to `settings.recent_apps` (newest
first, eight at most, written by the settings save the start already does) and looks for a
rule (`lib/app-actions.ts`: matched on the id or the name, case-insensitive, so a name
typed by hand matches as well; a rule whose action was removed is ignored). A match whose
action differs from the window's sets `appAction` for this one recording:
`effectiveAction` follows it for `start_recording`, `finish_recording` and a custom
action's prompt, the action menu shows it disabled, its label names the app, and
`announce_app_action` shows a notification. `showResult`, `setError` and
`cancelRecording` clear it. A start from the window's button or Space never asks, so the
window's choice holds there; live dictation stays Plain and only remembers the app.
Removing a custom action removes the rules that pointed at it.

**Timing.** The query runs after `phase` is `starting`, so the existing guards against
double starts still hold, and before the microphone opens. On Hyprland it is one request
on the command socket (a few milliseconds); `spawn_blocking` keeps it off the main thread.

**Which application** (`foreground.rs`). Utterform itself is never the answer: a window
whose process id is Utterform's own yields none.

| Platform | Source | `id` |
| --- | --- | --- |
| Hyprland | command socket `j/activewindow` | `class`, else `initialClass` |
| Sway | `swaymsg -t get_tree -r`, the focused leaf | `app_id`, else X11 `class` |
| X11 | `xprop -root _NET_ACTIVE_WINDOW`, then `WM_CLASS` and `_NET_WM_PID` | class (second `WM_CLASS` string) |
| Windows | `GetForegroundWindow` → process → `QueryFullProcessImageNameW` | file name without `.exe` |
| macOS | `NSWorkspace.frontmostApplication` | bundle identifier |

The compositor is asked before X11: on Wayland, `xprop` only sees XWayland windows. GNOME
and KDE on Wayland offer no such query; `foreground_support` says so and Settings disables
**+ App**. Child processes are killed after 400 ms. `display_name` turns an id into what
Settings shows (`org.mozilla.Thunderbird` → Thunderbird, `com.mitchellh.ghostty` →
Ghostty); macOS uses the localized name.

**Diagnostics.** `foreground.read` (source, found, elapsed_ms) for every query and
`recording.app_action` (action) when a rule takes over. The application is never logged:
which program someone dictates into says as much as a window title.

## What the local engine is compiled for

whisper.cpp is built from source by `whisper-rs-sys`, and ggml's CMake targets the machine doing the compiling unless told otherwise. For a release built on someone else's server that is a trap rather than an optimization: the 0.7.2 Linux binary carried the runner's AVX-512 and AMX instructions and was stopped with SIGILL on an Intel N300 while loading a model — inside `ggml_backend_cpu_device_get_extra_buffers_type`, plain C++ frame code, which is before ggml's own runtime dispatch can choose a kernel and therefore beyond its help. The bug had been there since the first release; only the pairing of build machine and user machine decided whether it fired.
Expand Down
15 changes: 14 additions & 1 deletion docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,20 @@ proposals are in [plans/v1.0-roadmap.md](plans/v1.0-roadmap.md). The plan of 202
(reliability, assistant, finish) is superseded; 0.7.13 shipped, 0.7.14
(`feat/simple-settings`) was rejected and will not be merged.

## Current handoff — 0.11.0 Context: selected text and clipboard
## Current handoff — 0.12.0 Action per application

Stage 0.12.0 of [the roadmap](plans/v1.0-roadmap.md), branch `work/utterform-0.12.0-per-app`.
Jonas decided to build it small and accepted the mock-up: **Settings → Context → Per app**,
empty by default; **+ App** offers the apps dictated into lately (`settings.recent_apps`) or
a typed name; a rule applies only when the dictation key starts the recording; a
notification names the action and app only when a rule changes the action. `foreground.rs`
names the app in front (Hyprland, Sway, X11, Windows, macOS; none on GNOME/KDE Wayland),
`lib/app-actions.ts` matches the rule. Design in
[ARCHITECTURE.md → Action per application](ARCHITECTURE.md#action-per-application-0120),
validation in [TESTING.md](TESTING.md). Merged into `main` without tag or release; ships
with 1.0. Next stage: 0.14.0 first-run assistant.

## Previous handoff — 0.11.0 Context: selected text and clipboard

Stage 0.11.0 of [the roadmap](plans/v1.0-roadmap.md), branch `work/utterform-0.11.0-context`.
A new built-in action **Context**: the transcript is the instruction, the selected text
Expand Down
22 changes: 22 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,28 @@
`PLAYWRIGHT_CHROMIUM_EXECUTABLE=/usr/bin/chromium npm run test:e2e`.
- `cmake` is needed for whisper.cpp. Without root, `mise exec cmake@latest -- cargo test` works.

## 0.12.0 Action per application

Unit tests in `foreground.rs`: display names from classes and program names, Utterform's
own process is never the answer, the compositor before X11 (none on GNOME/KDE Wayland),
the Hyprland answer (class, `initialClass`, an empty object), the focused Sway leaf also
among floating windows, and `xprop` output. `settings.rs`: a file without the new fields
gets none, rules and recent apps survive a reload. `lib/app-actions.test.ts`: matching by
id or typed name, a removed action ignored, recent apps newest first and capped, one rule
per app. `App.test.ts`: the key in a ruled app starts and finishes with the rule's custom
action and prompt, announces it and remembers the app; the window's own button asks
nothing and keeps Plain. Playwright: the rule taking over and handing back the window's
action, the notification only when it differs, recent apps saved; adding from the recent
list, choosing the action, typing a name, removing, all saved at once, at 920×720.

Live on `asus16` (Hyprland), through
`dbus-run-session -- cargo test print_the_application_in_front -- --ignored --nocapture`:
the focused Herdr window was named `herdr-app`, supported, in a few milliseconds.

Not exercised, to check at 1.0: the installed app with a rule (Thunderbird → Email via
Alt+Space, the notification), Sway and X11, the foreground program on Windows and the
frontmost app on macOS.

## 0.13.0 Audio files

Unit tests in `audio_file.rs`: the resampler (48 kHz averaged by three, odd ratios keep the
Expand Down
5 changes: 3 additions & 2 deletions docs/plans/v1.0-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ and without its subscription:
| 2026-11-07 | Jonas tests only once, at 1.0, everything together; until then no preview builds for him. Stages go through on their own: merge into `main` when tests and CI are green, no tag or release before 1.0. Style samples (mock-ups, images) are still welcome, and so are occasional questions. The two parallel sessions coordinate with each other. |
| 2026-11-07 | 0.13.0: audio files open from a folder button next to Settings (and `O`) or by dropping on the window, which shows "Drop to transcribe" with action and engine; text to history and clipboard, never the cursor. Mock-up accepted. |
| 2026-11-07 | Settings mock-up accepted: search field on top, categories as tabs below, one row per setting. The "Context" category appears only once 0.11.0 gives it rows. |
| 2026-11-08 | 0.12.0 is built, small: Settings → Context → "Per app", empty by default; "+ App" offers the apps dictated into lately or a typed name; a rule applies only to the dictation key, everywhere else the window's action; a notification "Utterform · Email — for Thunderbird" only when a rule changes the action. Mock-up accepted. |
| 2026-11-07 | 0.10.0: ChatGPT is marked "Experimental" in Settings; **no fallback** to the OpenAI key — a ChatGPT failure is shown and the plain transcript delivered. Mock-up (AI → Provider: OpenAI key \| ChatGPT) accepted. |
| 2026-11-07 | 0.11.0: context is its **own action "Context"**, no per-action switch; other actions never read context. **Linux first**: Linux reads the selection, Windows/macOS use the clipboard only — no simulated Ctrl+C, which would interrupt a terminal. |

Expand All @@ -71,7 +72,7 @@ Proposals not yet confirmed by Jonas are marked **Proposal**.
| 0.9.0 | Settings from scratch | merged, ships with 1.0 |
| 0.10.0 | Codex login for text actions | merged, ships with 1.0 |
| 0.11.0 | Context: selected text and clipboard | merged, ships with 1.0 |
| 0.12.0 | Action per application | to discuss first |
| 0.12.0 | Action per application | merged, ships with 1.0 |
| 0.13.0 | Audio files | merged, ships with 1.0 |
| 0.14.0 | First-run assistant | planned (was 0.8.0) |
| 1.0.0 | Acceptance on Linux, Windows, macOS; release; WinGet | planned |
Expand Down Expand Up @@ -154,7 +155,7 @@ select an email, say "reply that I have time on Monday", get the reply at the cu
plain dictation and the other actions never read context.
- Delivery replaces the selection where the application allows it.

### 0.12.0 — Action per application (discuss first)
### 0.12.0 — Action per application (decided 2026-11-08, built)

Example: email action in the mail client, plain text in the terminal. Utterform already
knows the active window class on Linux (`typing/linux.rs`).
Expand Down
30 changes: 30 additions & 0 deletions docs/releases/v0.12.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Utterform 0.12.0 — The right action in every app

*Stage of the road to 1.0, merged into `main`; not released on its own. It ships with 1.0.*

Until now Utterform used one action everywhere, the one chosen in its window. Dictating a
mail meant switching to *Email* first, and back to *Plain* before the next command in the
terminal. Now an app can have an action of its own.

**Per app.** Under **Settings → Context → Per app**, give Thunderbird *Email* and the
terminal *Plain*. Press the dictation key in Thunderbird and the text arrives as an email;
press it in the terminal and it arrives word for word. In every other app the action in
the window applies, as before. The list starts empty, so nothing changes until you add an
app.

**Adding an app** takes one click: **+ App** lists the apps you dictated into lately — so
dictate into an app once, and it is there. An app that has not come up yet can be typed by
name. Each app has its action menu next to it and × to remove it.

**Never a surprise.** When an app's action takes over, a short notification says so
("Utterform · Email — for Thunderbird"), and the window shows the action and the app while
it records. A recording started from Utterform's own window follows the window, and live
dictation stays word for word.

**Where it works.** Linux under Hyprland (Omarchy) and Sway, X11 with `xprop`, Windows and
macOS. GNOME and KDE on Wayland do not tell other applications which window is focused;
there **Per app** says so and stays off.

**Privacy.** The names of the apps you dictated into are kept in `settings.json` on this
device, at most eight. The diagnostic log notes that an app was found and which action
ran, never which app.
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -74,5 +74,5 @@ winreg = "0.55"
core-foundation = "0.10"
core-graphics = "0.25"
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSRunningApplication"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "libc", "NSWorkspace", "NSRunningApplication"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString"] }
45 changes: 45 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,51 @@ pub fn context_support() -> context::Support {
context::support()
}

#[tauri::command]
pub fn foreground_support() -> crate::foreground::Support {
crate::foreground::support()
}

/// The application in front as the dictation key is pressed. Off the main
/// thread: the compositor is asked, and a slow answer must not freeze the
/// window.
#[tauri::command]
pub async fn foreground_app() -> Option<crate::foreground::ForegroundApp> {
let began = Instant::now();
let app = tauri::async_runtime::spawn_blocking(crate::foreground::current)
.await
.ok()
.flatten();
// Whether an app was named and how long it took — never which one.
diagnostics::info!(
"foreground.read",
source = crate::foreground::source(),
found = app.is_some(),
elapsed_ms = began.elapsed().as_millis() as u64
);
app
}

/// Say that an application's own action is taking this dictation, so an
/// email where plain text was expected never comes as a surprise. Only the
/// action is logged, never the application.
#[tauri::command]
pub fn announce_app_action(app: AppHandle, action: String, action_name: String, app_name: String) {
diagnostics::info!(
"recording.app_action",
action = actions::diagnostic_id(&action)
);
let shown = app
.notification()
.builder()
.title(format!("Utterform · {}", action_name.trim()))
.body(format!("for {}", app_name.trim()))
.show();
if shown.is_err() {
diagnostics::warning!("recording.app_action_notification_failed");
}
}

#[tauri::command]
pub fn set_recording_paused(
state: State<'_, AudioCaptureState>,
Expand Down
Loading
Loading