fix(tui): localized voice status, distinct ASCII markers, NO_COLOR cursor

- voice: the recording status uses MessageId::VoiceRecording for both the
  initial and the interim line (interim transcript kept), dropping the
  hardcoded English "(⌥V to finish)" hint.
- glyphs: hollow ○/☐ now fall back to `o` while ●/•/· stay `.`, so
  CURRENT and AVAILABLE remain distinguishable on ASCII terminals; the
  charter test asserts CURRENT != AVAILABLE. The tideline ASCII test
  expectation moves with it.
- cursor_accent: the OSC 12 accent is suppressed when ColorDepth::detect()
  is Monochrome (NO_COLOR), via a no_color flag on the pure predicate.

Tests: cargo test -p codewhale-tui --lib filtered to session_picker,
glyphs, cursor_accent, live_transcript, pager, session_boot, voice:
123 passed, 0 failed. Glyph-consumer sweep (ascii, color_compat,
notifications, whales, infoline, markdown_render, phase_strip,
focus_texture, tideline, underwater): 301 passed, 2 failed before the
tideline expectation update; after it the tideline test passes and the
remaining failure, views::tests::focus_texture_modes_keep_inline_modal_usable
("approval prompt must survive the texture"), comes from in-flight
approval/widgets work and is untouched here.

Refs #5846

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01TvKGZz4LrKfrQiAXip48JS
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-22 21:06:28 -07:00
1 parent 105ad9d3e8
commit d7712814e9
4 files changed
+81 -12

No files matched your search

+34 -4
View File
@@ -578,6 +578,16 @@ fn resolve_asr_choice(_config: &Config) -> (String, String) {
}
}
/// Status line while recording: the localized recording label, followed by
/// the latest interim transcript once one exists.
fn recording_status(locale: codewhale_localization::Locale, interim: Option<&str>) -> String {
let label = tr(locale, MessageId::VoiceRecording);
match interim.map(str::trim).filter(|text| !text.is_empty()) {
Some(text) => format!("{label} \u{2014} \u{201c}{text}\u{201d}"),
None => label.to_string(),
}
}
pub async fn capture_and_transcribe(
app: &mut App,
config: &Config,
@@ -595,10 +605,10 @@ pub async fn capture_and_transcribe(
.openrouter_vendor()
.map_err(|error| error.to_string())?;
// Spark-style: show "● Recording (⌥V to finish)" + live interim in composer.
// Show the localized recording status plus the live interim in the composer.
let original_input = app.composer.input.clone();
let original_cursor = app.composer.cursor_position;
app.status_message = Some("● Recording (⌥V to finish) · speak naturally".to_string());
app.status_message = Some(recording_status(locale, None));
// Streaming interim: poll every 700ms and show partial transcript like Grok Build's
// VoiceEvent::Interim → VoiceState::Recording{interim}. We re-transcribe the
@@ -680,8 +690,7 @@ pub async fn capture_and_transcribe(
};
app.composer.input = display;
app.composer.cursor_position = original_cursor;
// Also keep status as Spark does
app.status_message = Some(format!("● Listening — “{trimmed}” (⌥V to finish)"));
app.status_message = Some(recording_status(locale, Some(trimmed)));
}
if ticks > 40 {
break; // safety: ~28s max interim polling
@@ -975,6 +984,27 @@ pub fn voice_control(app: &mut App) -> CommandResult {
mod tests {
use super::*;
#[test]
fn recording_status_is_localized_and_keeps_the_interim() {
use codewhale_localization::Locale;
for locale in [Locale::En, Locale::De, Locale::Ja] {
let label = tr(locale, MessageId::VoiceRecording).to_string();
assert_eq!(recording_status(locale, None), label);
assert_eq!(recording_status(locale, Some(" ")), label);
let with_interim = recording_status(locale, Some(" hello there "));
assert!(with_interim.starts_with(&label), "{with_interim}");
assert!(with_interim.contains("\u{201c}hello there\u{201d}"));
assert!(!with_interim.contains("\u{2325}V"), "no hardcoded key hint");
assert!(!with_interim.contains("to finish"), "no English hint");
}
assert_ne!(
recording_status(Locale::En, None),
recording_status(Locale::De, None)
);
}
#[tokio::test]
async fn voice_requests_preserve_openrouter_vendor_pin() {
use wiremock::matchers::{method, path};
+31 -4
View File
@@ -2,13 +2,14 @@
//!
//! OSC 12 changes the terminal cursor color and OSC 112 restores the terminal
//! default. The guard is deliberately conservative: an explicit supported
//! terminal marker is required, while `TERM=dumb` and reduced-motion policy
//! suppress the decorative escape entirely.
//! terminal marker is required, while `TERM=dumb`, `NO_COLOR` (monochrome
//! color depth), and reduced-motion policy suppress the decorative escape
//! entirely.
use std::io::{self, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use codewhale_palette::WHALE_ACTION_RGB;
use codewhale_palette::{ColorDepth, WHALE_ACTION_RGB};
use ratatui::style::Color;
const OSC12_RESET: &[u8] = b"\x1b]112\x07";
@@ -77,11 +78,13 @@ fn environment_allows_cursor_accent() -> bool {
let reduced_motion = std::env::var("NO_ANIMATIONS")
.ok()
.is_some_and(|value| env_truthy(&value));
let no_color = ColorDepth::detect() == ColorDepth::Monochrome;
cursor_accent_supported(
Some(&term_program),
Some(&term),
Some(&color_term),
reduced_motion,
no_color,
)
}
@@ -90,8 +93,9 @@ fn cursor_accent_supported(
term: Option<&str>,
color_term: Option<&str>,
reduced_motion: bool,
no_color: bool,
) -> bool {
if reduced_motion || term == Some("dumb") {
if reduced_motion || no_color || term == Some("dumb") {
return false;
}
@@ -130,18 +134,21 @@ mod tests {
Some("Ghostty"),
Some("xterm-256color"),
Some("truecolor"),
false,
false
));
assert!(cursor_accent_supported(
Some("kitty"),
Some("xterm-kitty"),
Some("truecolor"),
false,
false
));
assert!(!cursor_accent_supported(
Some("unknown-terminal"),
Some("xterm-256color"),
Some("truecolor"),
false,
false
));
}
@@ -152,12 +159,32 @@ mod tests {
Some("Ghostty"),
Some("dumb"),
Some("truecolor"),
false,
false
));
assert!(!cursor_accent_supported(
Some("Ghostty"),
Some("xterm-256color"),
Some("truecolor"),
true,
false
));
}
#[test]
fn no_color_suppresses_the_accent_on_supported_terminals() {
assert!(!cursor_accent_supported(
Some("Ghostty"),
Some("xterm-256color"),
Some("truecolor"),
false,
true
));
assert!(!cursor_accent_supported(
Some("kitty"),
Some("xterm-kitty"),
None,
false,
true
));
}
+14 -2
View File
@@ -76,8 +76,10 @@ pub fn ascii_fallback(symbol: &str) -> Option<&'static str> {
"▲" | "△" | "↑" => Some("^"),
"◆" | "◇" | "♦" | "✦" | "◍" | "◉" | "★" | "☆" => Some("*"),
"■" | "□" | "▪" | "▫" | "◼" | "◻" => Some("#"),
"●" | "○" | "∘" | "•" | "·" | "☐" => Some("."),
"◌" | "˚" | "°" | "◦" => Some("o"),
// Filled marks stay a dot; hollow ones become `o` so CURRENT and
// AVAILABLE stay distinguishable on ASCII terminals.
"●" | "∘" | "•" | "·" => Some("."),
"○" | "☐" | "◌" | "˚" | "°" | "◦" => Some("o"),
"✓" | "✔" | "☑" => Some("Y"),
"✕" | "×" | "⊘" | "✗" | "✘" | "☒" => Some("X"),
"⏸" => Some("="),
@@ -121,6 +123,11 @@ mod tests {
(SELECTION, ">"),
("▷", ">"),
(CURRENT, "."),
(AVAILABLE, "o"),
(READY, "o"),
("☐", "o"),
("•", "."),
(NEUTRAL, "."),
(USER, "|"),
(DONE, "Y"),
(FAILED, "X"),
@@ -142,6 +149,11 @@ mod tests {
] {
assert_eq!(ascii_fallback(rich), Some(safe));
}
assert_ne!(
ascii_fallback(CURRENT),
ascii_fallback(AVAILABLE),
"current and available must stay distinct in ASCII"
);
assert_eq!(braille_ascii_fallback('\u{2801}'), Some("."));
assert_eq!(braille_ascii_fallback('A'), None);
}
@@ -121,8 +121,8 @@ fn notifications_ascii_safe_projects_marks() {
let text = draw(80, 24, &inbox);
assert!(text.contains("* approval"), "gold ◆ projects to *: {text}");
assert!(
text.contains(". whale done"),
"read ○ projects to .: {text}"
text.contains("o whale done"),
"read ○ projects to o, distinct from the filled marker: {text}"
);
for ch in text.chars() {
if ch != '\n' {