Skip to content

Commit 7cbb22d

Browse files
authored
Make TUI voice input optional on Linux (#14611)
1 parent 49e9e2f commit 7cbb22d

17 files changed

Lines changed: 277 additions & 83 deletions

File tree

app/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -467,7 +467,7 @@ embed-resource = "3.0"
467467

468468
# Note that we support channel-specific enables for these features
469469
[features]
470-
tui = ["warpui_core/tui", "voice_input"]
470+
tui = ["warpui_core/tui"]
471471
ai_resume_button = []
472472
autoupdate = []
473473
figma_detection = []

crates/warp_tui/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ warp = { workspace = true, features = [
7575
"nld_classifier_v3",
7676
"nld_heuristic_v2",
7777
"tui",
78-
"voice_input",
7978
] }
8079
warp_channel_config.workspace = true
8180
warp_completer.workspace = true
@@ -129,6 +128,9 @@ harness = false
129128
required-features = ["test-util"]
130129

131130
[features]
131+
# Enables native microphone capture and transcription support. Keep this
132+
# opt-in so portable Linux builds do not require the host ALSA runtime.
133+
voice_input = ["warp/voice_input"]
132134
# Exposes deterministic, production-shaped transcript fixtures to benchmarks.
133135
test-util = ["warp/test-util", "warp_core/test-util"]
134136
# Declared so the `warp_channel_config::load_config!` macro's

crates/warp_tui/src/input/view.rs

Lines changed: 61 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,14 @@ use warp::tui_export::{
3434
use warp_editor::model::CoreEditorModel;
3535
use warpui::SingletonEntity as _;
3636
use warpui_core::elements::MouseStateHandle;
37+
#[cfg(feature = "voice_input")]
3738
use warpui_core::elements::animation::AnimationClock;
3839
use warpui_core::elements::tui::{TuiContainer, TuiElement, TuiFlex, TuiHoverable, TuiText};
40+
#[cfg(feature = "voice_input")]
3941
use warpui_core::event::KeyState;
4042
use warpui_core::keymap::macros::*;
4143
use warpui_core::keymap::{self, EditableBinding, FixedBinding, Keystroke};
44+
#[cfg(feature = "voice_input")]
4245
use warpui_core::platform::keyboard::KeyCode;
4346
use warpui_core::text::{byte_offset_for_char_offset, count_chars_up_to_byte};
4447
use warpui_core::{
@@ -64,6 +67,7 @@ use crate::mcp_install_flow::TuiMcpInstallFlowAction;
6467
use crate::read_only_menu::TuiReadOnlyMenuKind;
6568
use crate::terminal_session_view::state::TuiTerminalSessionStateModel;
6669
use crate::tui_builder::TuiUiBuilder;
70+
#[cfg(feature = "voice_input")]
6771
use crate::voice_input::{
6872
TuiVoiceInputEvent, TuiVoiceInputModel, TuiVoiceInputState, VoiceInputStartSource,
6973
};
@@ -248,6 +252,7 @@ pub struct TuiInputView {
248252
/// Consults the owner live before an inline-menu Enter can accept an item.
249253
can_accept_inline_menu: Rc<dyn Fn(&AppContext) -> bool>,
250254
/// TUI voice state used for Escape routing and shell-gutter suppression.
255+
#[cfg(feature = "voice_input")]
251256
voice_input: ModelHandle<TuiVoiceInputModel>,
252257
/// Vim model (shared FSA + event dispatch layer). Always present but only
253258
/// active when `AppEditorSettings::vim_mode_enabled()` returns `true`.
@@ -332,6 +337,7 @@ impl TuiInputView {
332337
session_state: ModelHandle<TuiTerminalSessionStateModel>,
333338
ctx: &mut ViewContext<Self>,
334339
) -> Self {
340+
#[cfg(feature = "voice_input")]
335341
let voice_input = ctx.add_model(|ctx| TuiVoiceInputModel::new(input_mode.clone(), ctx));
336342
let vim_model = ctx.add_model(|_| VimModel::new());
337343
// Subscribe to vim events: VimSubscriber blanket impl (TuiInputView: VimHandler)
@@ -346,15 +352,18 @@ impl TuiInputView {
346352
// on the config (shell-mode gutter/border), so every event re-renders.
347353
ctx.subscribe_to_model(&input_mode, |_, _, _, ctx| ctx.notify());
348354
ctx.subscribe_to_model(&suggestions_mode, |_, _, _, ctx| ctx.notify());
349-
// Only the voice lifecycle state reaches this view's render (the
350-
// suppressed shell gutter and the Escape keymap flag). Transcribed text
351-
// arrives through `insert_text`, and failure or cancellation notices
352-
// render in the session footer, so neither repaints the input.
353-
ctx.subscribe_to_model(&voice_input, |_, _, event, ctx| {
354-
if matches!(event, TuiVoiceInputEvent::StateChanged(_)) {
355-
ctx.notify();
356-
}
357-
});
355+
#[cfg(feature = "voice_input")]
356+
{
357+
// Only the voice lifecycle state reaches this view's render (the
358+
// suppressed shell gutter and the Escape keymap flag). Transcribed text
359+
// arrives through `insert_text`, and failure or cancellation notices
360+
// render in the session footer, so neither repaints the input.
361+
ctx.subscribe_to_model(&voice_input, |_, _, event, ctx| {
362+
if matches!(event, TuiVoiceInputEvent::StateChanged(_)) {
363+
ctx.notify();
364+
}
365+
});
366+
}
358367

359368
Self {
360369
model,
@@ -368,6 +377,7 @@ impl TuiInputView {
368377
session_state,
369378
keyboard_enhancement_supported: false,
370379
can_accept_inline_menu: Rc::new(|_| true),
380+
#[cfg(feature = "voice_input")]
371381
voice_input,
372382
vim_model,
373383
yank_buffer: String::new(),
@@ -397,6 +407,7 @@ impl TuiInputView {
397407
.resolve(ctx)
398408
.is_ok_and(|state| state.plan_available())
399409
}
410+
400411
/// Whether vim mode is enabled in settings.
401412
///
402413
/// Returns `false` when [`AppEditorSettings`] has not been registered in
@@ -432,28 +443,32 @@ impl TuiInputView {
432443
input_mode_policy::is_shell_mode(self.input_mode.as_ref(ctx))
433444
}
434445

446+
#[cfg(feature = "voice_input")]
435447
pub(crate) fn voice_is_active(&self, ctx: &AppContext) -> bool {
436448
self.voice_input.as_ref(ctx).is_active()
437449
}
438-
450+
#[cfg(feature = "voice_input")]
439451
pub(crate) fn voice_input_model(&self) -> &ModelHandle<TuiVoiceInputModel> {
440452
&self.voice_input
441453
}
442-
454+
#[cfg(feature = "voice_input")]
443455
pub(crate) fn voice_state(&self, ctx: &AppContext) -> TuiVoiceInputState {
444456
self.voice_input.as_ref(ctx).state()
445457
}
446458

459+
#[cfg(feature = "voice_input")]
447460
pub(crate) fn voice_animation_clock(&self, ctx: &AppContext) -> AnimationClock {
448461
self.voice_input.as_ref(ctx).animation_clock()
449462
}
450463

451464
/// The physical modifier holding the current recording open, set only while
452465
/// a hold-to-talk press started it.
466+
#[cfg(feature = "voice_input")]
453467
pub(crate) fn voice_hold_key(&self, ctx: &AppContext) -> Option<KeyCode> {
454468
self.voice_input.as_ref(ctx).hold_key()
455469
}
456470

471+
#[cfg(feature = "voice_input")]
457472
pub(crate) fn start_voice_input(
458473
&mut self,
459474
available: bool,
@@ -465,16 +480,19 @@ impl TuiInputView {
465480
})
466481
}
467482

483+
#[cfg(feature = "voice_input")]
468484
pub(crate) fn stop_voice_input(&mut self, ctx: &mut ViewContext<Self>) {
469485
self.voice_input
470486
.update(ctx, |voice_input, ctx| voice_input.stop(ctx));
471487
}
472488

489+
#[cfg(feature = "voice_input")]
473490
pub(crate) fn stop_active_voice_hold(&mut self, ctx: &mut ViewContext<Self>) {
474491
self.voice_input
475492
.update(ctx, |voice_input, ctx| voice_input.stop_hold(ctx));
476493
}
477494

495+
#[cfg(feature = "voice_input")]
478496
pub(crate) fn handle_voice_hold_key(
479497
&mut self,
480498
key: KeyCode,
@@ -512,6 +530,7 @@ impl TuiInputView {
512530
}
513531

514532
/// Inserts normalized text at the current cursor without submitting it.
533+
#[cfg(feature = "voice_input")]
515534
pub(crate) fn insert_text(&mut self, text: &str, ctx: &mut ViewContext<Self>) {
516535
let text = self.editor_behavior.normalize_text(text);
517536
if !text.is_empty() {
@@ -651,6 +670,7 @@ impl TuiView for TuiInputView {
651670
let inline_menu_owns_input = self
652671
.active_inline_menu_input_ownership(ctx)
653672
.inline_menu_owns_input();
673+
#[cfg(feature = "voice_input")]
654674
if self.voice_is_active(ctx) && !inline_menu_owns_input {
655675
return self.render_input(ctx);
656676
}
@@ -693,7 +713,16 @@ impl TuiView for TuiInputView {
693713
input_handles_escape: self.active_inline_menu(ctx).is_some()
694714
|| suggestions_mode.read_only_menu().is_some()
695715
|| self.is_shell_mode(ctx)
696-
|| self.voice_is_active(ctx)
716+
|| {
717+
#[cfg(feature = "voice_input")]
718+
{
719+
self.voice_is_active(ctx)
720+
}
721+
#[cfg(not(feature = "voice_input"))]
722+
{
723+
false
724+
}
725+
}
697726
|| (vim_mode_enabled
698727
&& (!matches!(vim_state.mode, VimMode::Normal)
699728
|| !vim_state.showcmd.is_empty())),
@@ -833,7 +862,11 @@ impl TypedActionView for TuiInputView {
833862
self.close_read_only_menu(ctx);
834863
// In vim normal/visual/replace mode, Enter still submits so the
835864
// prompt behaves like a command line (same as bash/zsh vi-mode).
836-
if !self.handle_voice_submit(ctx) {
865+
#[cfg(feature = "voice_input")]
866+
if self.handle_voice_submit(ctx) {
867+
return;
868+
}
869+
{
837870
self.submit(ctx);
838871
}
839872
TuiEditorInteractionOutcome::FollowCursor
@@ -1292,6 +1325,7 @@ impl TuiInputView {
12921325
ctx.emit(TuiInputViewEvent::Submitted(text));
12931326
}
12941327

1328+
#[cfg(feature = "voice_input")]
12951329
fn handle_voice_submit(&mut self, ctx: &mut ViewContext<Self>) -> bool {
12961330
match self.voice_input.as_ref(ctx).state() {
12971331
TuiVoiceInputState::Listening => {
@@ -1406,18 +1440,21 @@ impl TuiInputView {
14061440
return true;
14071441
}
14081442

1409-
match self.voice_input.as_ref(ctx).state() {
1410-
TuiVoiceInputState::Listening => {
1411-
self.voice_input
1412-
.update(ctx, |voice_input, ctx| voice_input.stop(ctx));
1413-
return true;
1414-
}
1415-
TuiVoiceInputState::Transcribing => {
1416-
self.voice_input
1417-
.update(ctx, |voice_input, ctx| voice_input.cancel(ctx));
1418-
return true;
1443+
#[cfg(feature = "voice_input")]
1444+
{
1445+
match self.voice_input.as_ref(ctx).state() {
1446+
TuiVoiceInputState::Listening => {
1447+
self.voice_input
1448+
.update(ctx, |voice_input, ctx| voice_input.stop(ctx));
1449+
return true;
1450+
}
1451+
TuiVoiceInputState::Transcribing => {
1452+
self.voice_input
1453+
.update(ctx, |voice_input, ctx| voice_input.cancel(ctx));
1454+
return true;
1455+
}
1456+
TuiVoiceInputState::Idle => {}
14191457
}
1420-
TuiVoiceInputState::Idle => {}
14211458
}
14221459

14231460
// In vim mode, Escape transitions between modes (Insert→Normal,

crates/warp_tui/src/input/view_tests.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ use vim::vim::VimMode;
1313
use warp::appearance::Appearance;
1414
use warp::editor::CodeEditorModel;
1515
use warp::settings::AISettingsChangedEvent;
16+
#[cfg(feature = "voice_input")]
17+
use warp::tui_export::VoiceInput;
1618
use warp::tui_export::{
1719
AcceptSlashCommandOrSavedPrompt, BlocklistAIHistoryModel, BlocklistAIInputModel,
1820
ConversationSelectionEvent, InputConfig, InputModePolicy, InputType, LLMId, PolicyConfigUpdate,
1921
SlashCommandId, SlashCommandMixer, TuiMcpAction, TuiMcpServerId, TuiUpArrowHistoryItemKind,
20-
VoiceInput, add_tui_history_test_models, blocklist_ai_history_model_with_queries,
22+
add_tui_history_test_models, blocklist_ai_history_model_with_queries,
2123
register_tui_input_mode_test_settings, register_tui_session_view_test_singletons,
2224
};
2325
use warp_core::features::FeatureFlag;
@@ -57,6 +59,7 @@ use crate::read_only_menu::TuiReadOnlyMenuKind;
5759
use crate::slash_commands::{TuiSlashCommandModel, TuiSlashCommandRow};
5860
use crate::test_fixtures::{add_test_conversation_selection, add_test_semantic_selection};
5961
use crate::tui_builder::TuiUiBuilder;
62+
#[cfg(feature = "voice_input")]
6063
use crate::voice_input::{TuiVoiceInputModel, TuiVoiceInputState};
6164

6265
const W: u16 = 80;
@@ -1331,6 +1334,7 @@ fn slash_command_argument_hint_renders_after_menu_closes() {
13311334
}
13321335

13331336
#[test]
1337+
#[cfg(feature = "voice_input")]
13341338
fn enter_and_escape_stop_listening_while_escape_cancels_transcribing() {
13351339
App::test((), |mut app| async move {
13361340
let (view, voice_input, submissions) = app.update(|ctx| {
@@ -1822,6 +1826,7 @@ fn typeahead_overwrites_incremental_prefix_and_moves_cursor_to_end() {
18221826
});
18231827
});
18241828
}
1829+
#[cfg(feature = "voice_input")]
18251830
fn build_view_with_voice(
18261831
ctx: &mut AppContext,
18271832
) -> (ViewHandle<TuiInputView>, ModelHandle<TuiVoiceInputModel>) {
@@ -1852,6 +1857,7 @@ fn build_view_with_voice(
18521857
}
18531858

18541859
#[test]
1860+
#[cfg(feature = "voice_input")]
18551861
fn listening_voice_input_suppresses_shell_gutter() {
18561862
App::test((), |mut app| async move {
18571863
app.update(|ctx| {

crates/warp_tui/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ mod tui_plan_view;
8484
mod tui_review_comments;
8585
mod tui_shell_command_view;
8686
mod usage;
87+
#[cfg(feature = "voice_input")]
8788
mod voice_input;
8889
mod warping_indicator;
8990
mod zero_state;

crates/warp_tui/src/session.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ use anyhow::{Context, Result, anyhow};
1313
use clap::Parser;
1414
use clap::error::ErrorKind;
1515
use inquire::{InquireError, Password, PasswordDisplayMode};
16-
use warp::settings::{TuiThemeSettings, TuiVoiceSettings, TuiVoiceSettingsChangedEvent};
16+
use warp::settings::TuiThemeSettings;
17+
#[cfg(feature = "voice_input")]
18+
use warp::settings::{TuiVoiceSettings, TuiVoiceSettingsChangedEvent};
1719
use warp::tui_export::{AIConversationAutoexecuteMode, Appearance, ServerConversationToken};
1820
use warp::{TuiLoginEvent, TuiLoginModel, TuiLoginPhase};
1921
use warp_core::channel::ChannelState;
@@ -34,6 +36,7 @@ use crate::terminal_background::TuiHostTerminalBackground;
3436
use crate::terminal_session_view::{
3537
TuiConversationRestoreOrigin, TuiConversationRestoreTarget, tui_resume_shell_command,
3638
};
39+
#[cfg(feature = "voice_input")]
3740
use crate::voice_input::requires_modifier_key_reporting;
3841

3942
/// Version string printed by `--version`. Release builds get `GIT_RELEASE_TAG`;
@@ -250,18 +253,24 @@ fn init(
250253
},
251254
|_| RootTuiView::new(),
252255
);
256+
#[cfg(feature = "voice_input")]
257+
let modifier_key_lifecycle_enabled = requires_modifier_key_reporting(ctx);
258+
#[cfg(not(feature = "voice_input"))]
259+
let modifier_key_lifecycle_enabled = false;
253260
match spawn_tui_driver(
254261
ctx,
255262
window_id,
256263
root.clone(),
257-
requires_modifier_key_reporting(ctx),
264+
modifier_key_lifecycle_enabled,
258265
Some(probe),
259266
) {
260267
Ok(driver) => {
261268
let sessions = ctx.add_singleton_model(|_| {
262269
TuiSessions::new(driver, exit_summary, resume_token, default_autoexecute_mode)
263270
});
271+
#[cfg(feature = "voice_input")]
264272
let sessions_for_voice_settings = sessions.clone();
273+
#[cfg(feature = "voice_input")]
265274
ctx.subscribe_to_model(&TuiVoiceSettings::handle(ctx), move |_, event, ctx| {
266275
let TuiVoiceSettingsChangedEvent::TuiVoiceInputHoldKeySetting { .. } = event;
267276
let enabled = requires_modifier_key_reporting(ctx);

crates/warp_tui/src/session_registry.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ pub(crate) enum TuiSessionsEvent {
130130
pub(crate) struct TuiSessions {
131131
/// TUI-specific process driver. Its handle restores terminal mode on
132132
/// drop, so the app-lifetime session singleton must retain it.
133+
#[cfg_attr(not(feature = "voice_input"), allow(dead_code))]
133134
driver: Option<TuiDriverHandle>,
134135
keyboard_enhancement_supported: bool,
135136
exit_summary: TuiExitSummaryHandle,
@@ -602,6 +603,7 @@ impl TuiSessions {
602603
}
603604
}
604605

606+
#[cfg(feature = "voice_input")]
605607
pub(crate) fn set_modifier_key_lifecycle_enabled(
606608
&mut self,
607609
enabled: bool,

0 commit comments

Comments
 (0)