Skip to content

TUI: Locale.truncate splits emoji surrogates and under-truncates CJK — UTF-16 units instead of display width #44338

Closed as not planned

Description

@elaina-agi

Description

Description

packages/tui/src/util/locale.ts:61-79 — truncate, truncateLeft and truncateMiddle measure str.length (UTF-16 code units) and slice on code-unit indices:

export function truncate(str: string, len: number): string {
  if (str.length <= len) return str
  return str.slice(0, len - 1) + "…"
}

Two user-visible consequences (both reproduced manually on 1.17.9):

1. Split surrogate pairs → mojibake. The slice can cut an emoji in half, leaving a lone surrogate that renders as a broken glyph. Also affects the terminal title at app.tsx:468 (session.title.slice(0, 37)).

2. Column budgets ignored for wide characters. Callers pass display-column budgets, but UTF-16 length counts a CJK character as 1 while it renders as 2 columns. A 40-character Chinese session title is 80 columns on screen yet length === 40 <= 61, so Locale.truncate(title, 61) (ui/dialog-select.tsx:778) never truncates it and the dialog row overflows. Same for truncateMiddle in component/prompt/autocomplete.tsx:347.

Same width-semantics family as #39462. The codebase already has grapheme-based width helpers in packages/tui/src/prompt/display.ts (promptOffsetWidth, displaySlice).

Suggested fix

Single-file change in util/locale.ts (plus routing the terminal title through Locale.truncate), reusing the existing prompt/display.ts helpers:

const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })

// Longest prefix that fits the column budget; never splits a grapheme (emoji, CJK).
function fitWidth(str: string, budget: number) {
  let width = 0
  for (const part of graphemes.segment(str)) {
    const next = width + (part.segment === "\n" ? 1 : Bun.stringWidth(part.segment))
    if (next > budget) return str.slice(0, part.index)
    width = next
  }
  return str
}

export function truncate(str: string, len: number): string {
  if (promptOffsetWidth(str) <= len) return str
  return fitWidth(str, len - 1) + "…"
}

export function truncateLeft(str: string, len: number): string {
  const width = promptOffsetWidth(str)
  if (width <= len) return str
  return "…" + displaySlice(str, width - (len - 1))
}

export function truncateMiddle(str: string, maxLength: number = 35): string {
  const width = promptOffsetWidth(str)
  if (width <= maxLength) return str

  const keepStart = Math.ceil((maxLength - 1) / 2)
  const keepEnd = Math.floor((maxLength - 1) / 2)

  return displaySlice(str, 0, keepStart) + "…" + displaySlice(str, width - keepEnd)
}

Verified locally on dev source: after this change the emoji title truncates to a whole 🚀 + … (no split surrogate), a 40-CJK-char title truncates to 30 chars + … (61 cols), and ASCII behavior is byte-identical to before. Known minor edge: truncateLeft/truncateMiddle can exceed the budget by 1 column when a wide character straddles the boundary (ASCII stays exact).

Happy to send a PR with tests if the column-width semantics are the intended ones.

Plugins

none

OpenCode version

1.17.9 (reproduced); fix verified on dev source

Steps to reproduce

  1. Broken emoji in terminal title (verified): open a session, rename it (ctrl+r) to exactly 36 ASCII chars + 3 emoji, e.g. abcdefghijklmnopqrstuvwxyz0123456789🚀🚀🚀. The terminal tab title shows the first rocket cut in half before ... — slice(0, 37) lands between the high and low surrogate.
  2. Broken emoji in dialog: rename a session to 59 ASCII chars + 2 emoji → the session list (<leader>l) shows a mangled half-emoji at the truncation point.
  3. CJK overflow: narrow the terminal to ~80 columns, rename a session to 40 Chinese characters → the session-list row renders 80 columns wide and overflows the dialog (40 ≤ 61 → never truncated).

Operating System

Windows 11

Terminal

Windows Terminal

Plugins

No response

OpenCode version

No response

Steps to reproduce

No response

Screenshot and/or share link

No response

Operating System

No response

Terminal

No response

Activity

  1. Tarquinen commented on Oct 20, 2026

    @Tarquinen
    Member

    Thanks for the report! OpenCode 2.0 is now out and development is focused on V2, so we're closing issues filed against OpenCode 1. Please upgrade (opencode upgrade --major, details: https://anoma.ly/notes/opencode-2-0), and if this is still relevant on 2.0, reopen this issue or open a new one with your 2.0 version.

    — from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions