1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
//! Pick-mode logic for the dictate command.
//!
//! When `--pick` is passed, the user selects among multiple transcription
//! providers/models via a GTK picker window. Cached results are reused
//! when available.
mod backend;
mod ui;
use crate::config::{Config, Provider};
use crate::error::TalkError;
use crate::paste::{paste_with_root, PasteNode, PasteTiming};
use crate::recording_cache;
use crate::transcription::{self, RealtimeTranscriber};
use crate::x11::x11_centre_and_raise;
use std::path::PathBuf;
use super::models::{build_retry_candidates, resolve_model, resolve_provider};
use ui::{pick_with_streaming_gtk, PICKER_TITLE};
/// Parameters for the pick-mode path.
pub(crate) struct PickParams {
pub input_audio_file: Option<PathBuf>,
pub cached_brief: Option<recording_cache::RecordingMetadataBrief>,
pub replace_char_count: Option<usize>,
pub replace_last_paste: bool,
pub provider: Option<Provider>,
pub model: Option<String>,
pub target_window: Option<String>,
/// Pre-built paste-node tree shared with the dictate one-shot
/// path. `Arc` rather than `Box` so the same root can be cloned
/// into both the one-shot and pick paths.
pub paste_root: std::sync::Arc<dyn PasteNode>,
pub paste_timing: PasteTiming,
}
/// Run pick mode: show a GTK picker with cached and live transcriptions.
///
/// Returns `Ok(())` when the user selects a result (or cancels).
pub(crate) async fn run_pick(config: Config, params: PickParams) -> Result<(), TalkError> {
// Single-instance: if a picker window is already open, just
// raise and focus it instead of opening a second one.
if x11_centre_and_raise(PICKER_TITLE) {
log::info!("picker already open — raised existing window");
return Ok(());
}
let audio_path = params.input_audio_file.clone().ok_or_else(|| {
TalkError::Config("--pick requires --input-audio-file or --retry-last".to_string())
})?;
if !audio_path.exists() {
return Err(TalkError::Config(format!(
"input audio file not found: {}",
audio_path.display()
)));
}
// Read the authoritative pick (user-confirmed selection + edited text).
// This is the ONLY cross-provider source of truth for the picker's
// selection state. Sidecars are per-model internals probed below
// via `transcribe_audio(allow_api=false)`.
let pick = recording_cache::read_pick(&audio_path);
let selected_key: Option<(Provider, String, bool)> = pick
.as_ref()
.map(|(p, m, s, _)| (*p, m.clone(), *s))
.or_else(|| {
params.cached_brief.as_ref().and_then(|b| {
Some((
b.provider.as_deref()?.parse().ok()?,
b.model.as_deref()?.to_string(),
false,
))
})
});
let mut all_entries: Vec<(Provider, String, String, bool)> = Vec::new();
// Seed entry for the pick itself: the user's authoritative choice
// goes first so it can be pre-selected.
if let Some((p, m, s, t)) = pick.as_ref() {
all_entries.push((*p, m.clone(), t.clone(), *s));
}
let config = std::sync::Arc::new(config);
let candidates =
build_retry_candidates(config.as_ref(), params.provider, params.model.as_deref());
log::debug!("picker candidates: {} total", candidates.len());
for (p, m, s) in &candidates {
log::debug!(" candidate: {}:{} (streaming={})", p, m, s);
}
// Probe the per-model sidecar cache for each one-shot candidate via
// Layer 3 with `allow_api=false`. Hits populate `all_entries`
// (no spinner needed). Misses return `CacheOnly` — those models
// remain uncached and will be shown as deferred buttons or the
// default model's auto-firing row.
for (p, m, streaming) in &candidates {
if *streaming {
continue; // realtime models have no sidecar cache
}
// Skip if we already have it (e.g. from the pick file).
if all_entries
.iter()
.any(|(ep, em, _, es)| ep == p && em == m && !*es)
{
continue;
}
let sink: std::sync::Arc<dyn crate::telemetry::TelemetrySink> =
std::sync::Arc::new(crate::telemetry::NoOpSink);
// `allow_api=false` short-circuits before any HTTP call, so
// the `policy` here is purely a typing requirement — the
// wall-clock branch in `send_once` is never reached. Pass
// `Proportional` (the function-default flavour) so this
// call site does not look like it is asking for picker
// semantics it cannot use.
match transcription::transcribe_audio(
&audio_path,
config.as_ref(),
*p,
Some(m),
false,
transcription::TranscribeOptions {
allow_api: false,
policy: transcription::RequestTimeoutPolicy::Proportional,
cancel_token: None,
skip_legacy_lock: false,
},
&sink,
)
.await
{
Ok(result) => {
all_entries.push((*p, m.clone(), result.text, false));
}
Err(TalkError::CacheOnly) => {
log::debug!(" sidecar cache miss: {}:{}", p, m);
}
Err(e) => {
log::debug!(" sidecar probe failed: {}:{} — {}", p, m, e);
}
}
}
// Build cached_entries with is_primary flag. The selected
// (already-pasted) entry goes first so it is pre-selected
// in the GTK list.
// Tuple: (provider, model, text, is_primary, streaming)
let mut cached_entries: Vec<(Provider, String, String, bool, bool)> = Vec::new();
if let Some((ref sp, ref sm, ss)) = selected_key {
if let Some(idx) = all_entries
.iter()
.position(|(p, m, _, s)| p == sp && m == sm && *s == ss)
{
let (p, m, t, s) = all_entries.remove(idx);
cached_entries.push((p, m, t, true, s));
}
}
for (p, m, t, s) in all_entries {
cached_entries.push((p, m, t, false, s));
}
log::debug!(
"picker cache: {} cached entries (primary={})",
cached_entries.len(),
cached_entries.iter().filter(|(_, _, _, p, _)| *p).count(),
);
for (p, m, _, is_primary, streaming) in &cached_entries {
log::debug!(
" cached: {}:{} (primary={}, streaming={})",
p,
m,
is_primary,
streaming,
);
}
// Filter out every (provider, model, streaming) triple that
// already has a cached result — no need to re-transcribe.
let filtered: Vec<(Provider, String, bool)> = candidates
.into_iter()
.filter(|(p, m, s)| {
let dominated = cached_entries
.iter()
.any(|(cp, cm, _, _, cs)| cp == p && cm == m && cs == s);
if dominated {
log::debug!(" filtered out (cached): {}:{} (streaming={})", p, m, s);
}
!dominated
})
.collect();
log::debug!(
"picker: {} transcribers needed (after filtering)",
filtered.len(),
);
for (p, m, s) in &filtered {
log::debug!(" needs API call: {}:{} (streaming={})", p, m, s);
}
// Resolve the default model so only it is transcribed immediately.
// All other candidates are deferred — shown in the UI with a
// "transcribe" button that the user can click on demand.
let default_provider = resolve_provider(params.provider, config.as_ref());
let default_model = resolve_model(
params.model.as_deref(),
config.as_ref(),
default_provider,
false,
);
log::debug!(
"picker default model: {}:{} (one-shot)",
default_provider,
default_model,
);
let is_default = |p: &Provider, m: &str| *p == default_provider && m == default_model;
// Split filtered candidates into default (immediate) and deferred.
let mut default_filtered: Vec<(Provider, String, bool)> = Vec::new();
let mut deferred: Vec<(Provider, String, bool)> = Vec::new();
for (p, m, s) in filtered {
if is_default(&p, &m) {
default_filtered.push((p, m, s));
} else {
deferred.push((p, m, s));
}
}
// Parakeet immediate-default fallback. When the default model
// is Parakeet and its files are not on disk, demote the
// candidate from the immediate set to the deferred list — so
// the picker opens with a clickable "T" row instead of
// auto-firing a transcription that would just error out (the
// pipeline NEVER downloads silently). The user then clicks T
// and the picker's GTK click handler shows the consent
// AlertDialog before the async retry listener fetches the model
// and runs the transcription. This keeps "open the picker" a
// non-intrusive action (no auto-dialog at open) while still
// surfacing the model as a first-class candidate.
#[cfg(feature = "parakeet")]
{
let mut i = 0;
while i < default_filtered.len() {
if default_filtered[i].0 == Provider::Parakeet {
let present = crate::transcription::parakeet::consent::resolve(config.as_ref())
.map(|s| s.present)
.unwrap_or(true); // resolve failed → let the
// transcribe path surface a
// clean error instead of
// silently deferring.
if !present {
let cand = default_filtered.remove(i);
log::debug!(
"picker: default Parakeet model {} absent — deferring until user clicks T",
cand.1,
);
deferred.push(cand);
continue;
}
}
i += 1;
}
}
log::debug!(
"picker: {} immediate, {} deferred",
default_filtered.len(),
deferred.len(),
);
// Split default candidates into one-shot and realtime groups.
let oneshot_filtered: Vec<(Provider, String)> = default_filtered
.iter()
.filter(|(_, _, s)| !s)
.map(|(p, m, _)| (*p, m.clone()))
.collect();
let realtime_filtered: Vec<(Provider, String)> = default_filtered
.iter()
.filter(|(_, _, s)| *s)
.map(|(p, m, _)| (*p, m.clone()))
.collect();
// Create realtime transcribers for the default model only.
let mut rt_transcribers: Vec<(Provider, String, Box<dyn RealtimeTranscriber>)> = Vec::new();
for (provider, model) in realtime_filtered {
match transcription::create_realtime_transcriber(config.as_ref(), provider, Some(&model)) {
Ok(t) => rt_transcribers.push((provider, model, t)),
Err(e) => log::warn!("skipping realtime {}:{}: {}", provider, model, e),
}
}
if oneshot_filtered.is_empty()
&& cached_entries.is_empty()
&& rt_transcribers.is_empty()
&& deferred.is_empty()
{
return Err(TalkError::Transcription(
"no transcription providers available".to_string(),
));
}
let selected = pick_with_streaming_gtk(
oneshot_filtered,
audio_path,
cached_entries,
config.clone(),
rt_transcribers,
deferred,
)
.await?;
let selection = match selected {
Some(s) => s,
None => return Ok(()),
};
// Selection is auto-saved by the picker UI (on first result,
// debounced on row change, and on close).
// If the user selected the cached entry, nothing to do — the
// text is already in the target window.
if selection.is_cached {
log::info!("cached entry selected — no paste needed");
return Ok(());
}
let delete_chars = if params.replace_last_paste {
// Prefer the paste-state file (written after every paste,
// including picker selections) over recording metadata so
// that successive picker replacements delete the correct
// number of characters.
recording_cache::read_last_paste_state()?
.map(|s| s.char_count)
.or(params.replace_char_count)
.unwrap_or(0)
} else {
0
};
paste_with_root(
params.paste_root.as_ref(),
params.target_window.as_ref(),
&selection.text,
delete_chars,
None,
&crate::telemetry::NoOpSink,
params.paste_timing,
// Picker paste path has no sound player wired.
None,
)
.await?;
let _ =
recording_cache::write_last_paste_state(params.target_window.as_deref(), &selection.text);
println!("{}", selection.text);
Ok(())
}