google-gemini / google-gemini/gemini-cli
sanitizeForDisplay can drop an emoji when its truncation limit lands inside a surrogate pair
- Dominant language
- TypeScript
- Stars
- 107k
- Forks
- 14.6k
- Avg merge
- 2d 3h
- Merged PRs (30d)
- 45
Description
### What happened?
`sanitizeForDisplay` truncates by UTF-16 code units, so when its limit lands inside a surrogate pair it emits an unpaired surrogate and the rendered emoji is silently dropped. This is the same failure class as #29296 (which covers `ExpandableText`'s own slicing), but on the sibling code path used for suggestion descriptions, help text, command-loader descriptions, and terminal notifications.
In `packages/cli/src/ui/utils/textUtils.ts` (lines 158-160 at revision `9c1b0a610`):
```ts
if (maxLength && sanitized.length > maxLength) {
sanitized = sanitized.substring(0, maxLength - 3) + '...';
}
```
`length` and `substring` are UTF-16 based. Pure-function repro:
```js
const s = 'a'.repeat(96) + '😀' + 'b'.repeat(50);
const cut = s.substring(0, 97); // what maxLength 100 produces
cut.charCodeAt(96).toString(16); // 'd83d' — an unpaired high surrogate
```
Component-level confirmation (rendering `SuggestionsDisplay` in slash mode with a suggestion whose `description` is `'a'.repeat(96) + '😀' + 'b'.repeat(50)`): the rendered frame contains the 96 `a`s and the `...` marker, but no `😀` at all — the TUI silently omits the emoji, exactly the symptom described in #29296. A short description like `hello 😀 world` (no truncation) renders the emoji fine, so the loss is caused by the truncation cut, not by the sanitizer's character stripping.
Affected call sites that pass a `maxLength`:
- `packages/cli/src/ui/components/SuggestionsDisplay.tsx` — description column, `maxLength` 100
- `packages/cli/src/ui/components/Help.tsx` — two call sites, `maxLength` 100
- `packages/cli/src/services/FileCommandLoader.ts` — command descriptions, `maxLength` 100
- `packages/cli/src/utils/terminalNotifications.ts` — notification title/subtitle/body limits
Call sites without `maxLength` are unaffected.
### What did you expect to happen?
Truncation should stop at a code-point boundary: the cut should back off so a surrogate pair is never split, and the function should never emit an unpaired surrogate to Ink. Ideally the complete emoji is retained before the `...` marker, as requested for `ExpandableText` in #29296.
### Client information
Component-level reproduction; no Gemini API request, login session, or account is involved. Verified against a working tree based on main revision `9c1b0a610534d6f8120964cf2672c07807d8fc90`; the files cited above are unmodified from that revision. Package version reports `0.61.0-nightly.20260908.gc647533d6`.
### Login information
Not applicable. No provider, API key, Google account, or network request is used by the reproducer.
### Anything else we need to know?
The same module already exports code-point helpers (`cpLen`, `cpSlice`), so a minimal fix is:
```ts
if (maxLength && cpLen(sanitized) > maxLength) {
sanitized = cpSlice(sanitized, 0, maxLength - 3) + '...';
}
```
This is separate from #29296, which is fixed in the `ExpandableText` label path only; `sanitizeForDisplay` is untouched by that change.
Contributor guide
Research direction
Start in packages/cli/src/ui/utils/textUtils.ts at sanitizeForDisplay and review the existing cpLen and cpSlice helpers. Check the affected call sites in SuggestionsDisplay.tsx, Help.tsx, FileCommandLoader.ts, and terminalNotifications.ts; done means truncation never splits a surrogate pair and the reproduced emoji remains before the ellipsis.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- cli
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 88/100