mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
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:
1 parent
105ad9d3e8
commit
d7712814e9
4 files changed
+81
-12
No files matched your search
@@ -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};
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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' {
|
||||
|
||||
Reference in new issue
Block a user