Skip to content

Commit 4879163

Browse files
committed
Reveal streaming text by grapheme
1 parent 2865f1f commit 4879163

6 files changed

Lines changed: 41 additions & 8 deletions

File tree

tools/wta/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tools/wta/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ crossterm = { version = "0.29", features = ["event-stream"] }
2222
notify = "6"
2323
futures = "0.3"
2424
unicode-width = "0.2"
25+
unicode-segmentation = "1"
2526
textwrap = "0.16"
2627
tui-markdown = { version = "0.3.9", default-features = false }
2728
serde = { version = "1", features = ["derive"] }

tools/wta/src/app.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use std::io;
88
use std::sync::atomic::{AtomicBool, Ordering};
99
use std::sync::Arc;
1010
use tokio::sync::mpsc;
11+
use unicode_segmentation::UnicodeSegmentation;
1112

1213
struct DeferredAcpParams {
1314
agent_cmd: String,
@@ -4386,10 +4387,10 @@ impl App {
43864387
}
43874388
}
43884389

4389-
/// Number of user-visible characters in the active assistant segment.
4390+
/// Number of user-visible grapheme clusters in the active assistant segment.
43904391
fn tab_visible_stream_len(tab: &TabSession) -> Option<usize> {
43914392
crate::ui::chat::user_visible_stream_text(tab.streaming_agent_text()?)
4392-
.map(|text| text.chars().count())
4393+
.map(|text| text.graphemes(true).count())
43934394
}
43944395

43954396
/// True iff the current (visible) tab has streaming text that the reveal

tools/wta/src/app/tab_state.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -451,8 +451,8 @@ pub struct TabSession {
451451
// (see `doc/specs/turn-state-refactor.md`).
452452
pub turn: TurnState,
453453
pub activity_frame: usize,
454-
/// Typewriter reveal cursor for the final assistant-text item in the
455-
/// active transcript. Advanced toward its full length by `RevealTick`
454+
/// Typewriter reveal cursor, in extended grapheme clusters, for the final
455+
/// assistant-text item in the active transcript. Advanced toward its full length by `RevealTick`
456456
/// (`advance_reveal`), reset to 0 when a new turn starts streaming, and
457457
/// made irrelevant on finalize (the committed message renders in full).
458458
pub reveal_chars: usize,

tools/wta/src/app_tests.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8654,6 +8654,21 @@ fn first_message_chunk_transitions_to_streaming_with_transcript_text() {
86548654
);
86558655
}
86568656

8657+
#[test]
8658+
fn reveal_backlog_and_cursor_count_extended_grapheme_clusters() {
8659+
let mut app = test_app();
8660+
submit_test_prompt(&mut app, "hi");
8661+
app.turn_observe_chunk(DEFAULT_TAB_ID, ChunkKind::Message, "👩‍💻x");
8662+
8663+
assert_eq!(App::tab_visible_stream_len(app.current_tab()), Some(2));
8664+
app.current_tab_mut().reveal_chars = 1;
8665+
assert!(app.has_reveal_backlog());
8666+
8667+
app.advance_reveal();
8668+
assert_eq!(app.current_tab().reveal_chars, 2);
8669+
assert!(!app.has_reveal_backlog());
8670+
}
8671+
86578672
#[test]
86588673
fn thought_chunk_first_transitions_without_visible_text() {
86598674
let mut app = test_app();

tools/wta/src/ui/chat.rs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use std::rc::Rc;
88
use ratatui::prelude::*;
99
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
1010
use tui_markdown::{Options as MarkdownOptions, StyleSheet};
11+
use unicode_segmentation::UnicodeSegmentation;
1112
use unicode_width::UnicodeWidthChar;
1213
use unicode_width::UnicodeWidthStr;
1314

@@ -1357,19 +1358,19 @@ fn build_pending_stream_lines_for_tab_with_mode<'a>(
13571358
let Some(text) = pending_render_text(tab) else {
13581359
return Vec::new();
13591360
};
1360-
// Typewriter smoothing: only reveal the first `reveal_chars` characters of
1361-
// the streaming text. The reveal cursor is advanced toward the full length
1361+
// Typewriter smoothing: only reveal the first `reveal_chars` grapheme
1362+
// clusters of the streaming text. The reveal cursor advances toward the full length
13621363
// by the `RevealTick` animation (`App::advance_reveal`), turning the
13631364
// upstream ~90-char-every-~100ms bursts into a smooth character flow. The
13641365
// full text is always in the ordered transcript, and finalize moves that
13651366
// transcript to history unchanged.
13661367
let revealed: Cow<'_, str> = {
1367-
let total = text.chars().count();
1368+
let total = text.graphemes(true).count();
13681369
let shown = tab.reveal_chars.max(1).min(total);
13691370
if shown >= total {
13701371
text
13711372
} else {
1372-
Cow::Owned(text.chars().take(shown).collect())
1373+
Cow::Owned(text.graphemes(true).take(shown).collect())
13731374
}
13741375
};
13751376
agent_response_lines(
@@ -2467,6 +2468,20 @@ mod tests {
24672468
assert!(partial_text.contains("bo"));
24682469
}
24692470

2471+
#[test]
2472+
fn pending_stream_reveal_does_not_split_grapheme_clusters() {
2473+
let text = "👩‍💻 ready";
2474+
let tab = streaming_tab(text, 1);
2475+
2476+
let rendered = build_pending_stream_lines_for_tab(&tab, 80)
2477+
.iter()
2478+
.map(line_text)
2479+
.collect::<Vec<_>>()
2480+
.join("\n");
2481+
2482+
assert_eq!(rendered, "● 👩‍💻");
2483+
}
2484+
24702485
#[test]
24712486
fn thinking_activity_follows_turn_lifecycle() {
24722487
let mut tab = streaming_tab("", 0);

0 commit comments

Comments
 (0)