Skip to content

fix(render): stream text on word boundaries so the glasses don't split words - #37

Merged
pawaca merged 2 commits into
mainfrom
fix/stream-word-boundaries
Jul 13, 2026
Merged

pawaca merged 2 commits into
mainfrom
fix/stream-word-boundaries

Conversation

@pawaca

@pawaca pawaca commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Symptom (user-reported, live test)

Streaming prose on the glasses sometimes rendered a single English word split across two lines — the halves landing on separate lines when a chunk boundary fell mid-word near the line edge.

Cause

OutputStream paced a fixed code-point count per frame (slice(pos, pos+n)), so a text_delta could end mid-word ("hel" then "lo"). The glasses wrap at spaces, so a word split across frames could land its halves on different lines. And the small-backlog step is 3 — shorter than most words — so short answers split words routinely.

Fix

frameEnd(chars, pos, step) takes ~step code points, then extends past the rest of the current word and its trailing whitespace, so every frame ends on a word boundary and no word spans two frames. Extending (rather than backing up to the last space) is what keeps whole a word longer than step. A token longer than the remaining text (a bare URL) just becomes one frame.

  • Not SELF_HOOK-related — it's the shared output-pacing path.
  • Lossless + order-preserving: existing OutputStream tests (reconstruct, event order, emoji-intact, clear, flush) still pass.

Tests

pnpm check clean; pnpm test → 190 pass (+ frameEnd unit tests: word longer than step, last-frame remainder, long-token-whole, and a full-sentence stream that never splits a word).

🤖 Generated with Claude Code

…t words

The paced OutputStream sliced a fixed code-point count per frame, so a text_delta could
end mid-word (e.g. "hel" then "lo"). The glasses wrap at spaces, so when such a boundary
landed near the line edge a word's halves rendered on separate lines. Worse, the
small-backlog step (3) is shorter than most words, so short answers split words routinely.
frameEnd now takes ~step code points then EXTENDS past the rest of the current word and its
trailing whitespace, so every frame ends on a word boundary and a word is never split
across frames; a token longer than the remaining text (a bare URL) becomes one frame. Lossless
and order-preserving (existing tests still pass); pure + unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELxUVZ1WcJWfYbAdY5uMs4

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0968b2a6fd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/output-stream.ts Outdated
* than the whole remaining text (a bare URL) just becomes one frame. Pure + unit-tested. */
export function frameEnd(chars: string[], pos: number, step: number): number {
let end = Math.min(pos + step, chars.length);
while (end < chars.length && !isSpace(chars[end])) end++; // finish the current word

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound word-boundary scans for unspaced runs

When a queued assistant block contains a long span with no \s characters (for example CJK prose, minified JSON, or a long generated token), this loop advances end all the way to chars.length, so OutputStream emits the entire block as one text_delta instead of pacing it. That regresses the documented streaming behavior for normal no-space text and can make long answers arrive on the glasses in one burst; consider capping the extension or segmenting such text without relying solely on whitespace.

Useful? React with 👍 / 👎.

…l paces

The first cut extended a frame to the next whitespace unconditionally, so a long run with
no spaces — CJK prose, minified JSON, a bare URL — extended to the block end and emitted the
whole block as one text_delta (no pacing / a burst on the glasses). Cap the reach at
MAX_WORD_EXTEND (24) past pos+step: extend only when a space is that close (a normal
space-delimited word, kept whole), otherwise return the raw step boundary so a spaceless run
keeps normal step pacing. Notably fixes CJK, which has no inter-word spaces. Added tests for
a CJK run and a long URL both pacing rather than bursting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELxUVZ1WcJWfYbAdY5uMs4
@pawaca

pawaca commented Jul 13, 2026

Copy link
Copy Markdown
Owner Author

Fixed in 81053d8 — important catch, especially since CJK (the maintainer's language) has no inter-word spaces, so the unconditional extend would have burst whole Chinese paragraphs. Bounded the reach at MAX_WORD_EXTEND (24) past pos+step: extend only when a space is that close (a normal word, kept whole); otherwise return the raw step boundary so a spaceless run — CJK, minified JSON, a bare URL — keeps normal step pacing. Added tests for a CJK run and a long URL both pacing rather than bursting. pnpm check + 191 tests green. 👍

@pawaca
pawaca merged commit 5c483f7 into main Jul 13, 2026
1 check passed
@pawaca
pawaca deleted the fix/stream-word-boundaries branch July 13, 2026 02:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant