jeo-code 0.8.37 → 0.8.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.8.39] - 2026-07-21
10
+ _User report (paraphrased): "when I copy and paste content from elsewhere, it doesn't need to show every line — like gjc — update it so the composer stays a reasonable size and shows a clear indicator when width/length isn't enough." Investigated live before assuming intent: pasting a large multi-line block into the composer already caps the visible box height and scrolls to the caret, but the scrolled-off content above/below was flagged with a bare `…` — no indication of HOW MUCH text was hidden. (Checked gjc's own actual paste-handling source directly: it does not have a general text-paste-collapse feature either — only image-path detection — so this fix targets the concrete, reproducible UX gap found live, not a specific gjc feature to port.)_
11
+
12
+ ### Changed
13
+ - **The input box's scrolled-content indicator now shows a count, not a bare ellipsis** (`src/tui/components/input-box.ts`'s `renderInputFrame`) — pasting a 20-line block previously showed `…line sixteen` at the top of the box with zero indication that 15 more lines existed above; it now shows `+15⋯line sixteen`. Same treatment for content scrolled below the visible window (`text⋯+N`). Purely a rendering-layer change — the bracketed-paste parsing state machine (chunk carrying, CRLF splits, escape stripping) is untouched.
14
+
15
+ ### Verified
16
+ - Live (unmocked): booted a real `jeo --tmux` session and pasted a real 20-line bracketed-paste block via `tmux send-keys` with real `\x1b[200~`/`\x1b[201~` markers — the box now shows `+15⋯line sixteen` instead of the previous bare `…line sixteen`.
17
+ - `test/input-box.test.ts` — 13 pass (was 11): a new test reproduces the exact 20-line scenario end to end (asserts the count-bearing marker appears and no bare `…` remains anywhere), plus a negative test confirming no marker at all appears when content fits without scrolling.
18
+ - Full `bun test` — 3125 pass / 0 fail across 307 files.
19
+ - `bun run typecheck` — no errors.
20
+
21
+ ## [0.8.38] - 2026-07-21
22
+ _Independent architect-subagent review of this whole session's TUI/workflow changes (v0.8.29-v0.8.37), requested explicitly to verify stability before calling the work done. Verdict: WATCH/COMMENT, no P0/P1 — the resize fix, the idle↔mid-turn transition, the new `calc` tool wiring, and the web-search credential gating all held up under independent scrutiny. Found and fixed 3 real, previously-unknown bugs in `width.ts`; 1 architectural observation recorded as tech debt, not fixed._
23
+
24
+ ### Fixed
25
+ - **`wrapTextWithAnsi` could hang the whole TUI forever at `cols<=1` with a leading wide grapheme cluster** (`src/tui/components/width.ts`) — reproduced live (a 5-second `timeout` kill was required to stop it) before fixing: when the very first cluster of the remaining text is wider than the available column budget (e.g. any CJK/emoji character at `cols=1`), `truncateToWidth` returned `""`, so `rest` never shrank and the wrap loop spun forever. This session's own floor-removal fixes (v0.8.33) made `cols=1` a real, reachable value in several call sites, directly increasing exposure to this latent defect. Fixed by forcing forward progress: when nothing fits, the one cluster that doesn't is emitted on its own (deliberately over-width) line instead of hanging — an unavoidable overflow beats a hung process.
26
+ - **Non-Latin combining marks (Hebrew, Arabic, Devanagari, Thai) were not zero-width**, over-counting `visibleWidth` and misaligning box borders/wraps for those scripts (`src/tui/components/width.ts`'s `charWidth`/`isAttachableCombining`) — e.g. Arabic "بَّ" (base + shadda + fatha) counted 3 instead of the 1 a terminal renders. Fixed by extending the zero-width tables with the Hebrew/Arabic/Devanagari/Thai combining-mark ranges, verified against the current `jquast/wcwidth` reference table (github.com/termux/wcwidth, Unicode-aligned as of 2022-12-16) rather than guessed from memory; the Devanagari fix was additionally cross-checked against the `unicode-width` crate's own documented example (`"क्ष".width() == 2`).
27
+ - **Paired regional-indicator flag emoji (e.g. 🇰🇷) were not one atomic cluster** (`nextGraphemeCluster`) — the total width was already coincidentally correct (1+1=2), but a wrap/truncate boundary could land between the two indicators, rendering a lone indicator-letter square instead of a flag. Fixed per UAX #29's RI-pairing rule: a Regional Indicator immediately followed by another Regional Indicator now absorbs into one width-2 cluster; a lone, unpaired indicator is left alone (not force-paired).
28
+
29
+ ### Investigated, not changed (tech debt, not a bug)
30
+ - **Idle-prompt (`launch.ts`) and mid-turn (`tui/app.ts`+`tui/renderer.ts`) run two independent differential-render + resize systems.** The architect traced the actual handoff (`previewArmed` gate, `disarmPreview()`/`armPreview()`, independent `onResize` registration/teardown) and found a clean, correctly-gated transition with no shared row-accounting state to desync — confirmed live too (multiple `--tmux` resize-during-transition reproductions, including an extreme 8-column terminal, all rendered and typed correctly with no corruption). No concrete bug found; flagged only as a maintainability risk (a future renderer-level fix has to be reasoned about twice) worth a future unification, not an urgent fix.
31
+
32
+ ### Verified
33
+ - `test/width.test.ts` — 14 pass (was 11): new tests for the combining-mark fix (Hebrew/Arabic/Devanagari/Thai, cross-checked against the unicode-width crate's own documented example), the flag-emoji atomicity fix (including the "lone indicator is not force-paired" case), and the hang fix (reproducing the exact previously-hanging input, plus a mixed narrow/wide case and a lone emoji case).
34
+ - Live (unmocked) verification beyond the subagent's read-only review: real `jeo --tmux` sessions resized exactly at the idle↔mid-turn transition boundary (including across a real turn error and a real successful reply) showed clean transitions with no corruption; a real 8-column-wide terminal degraded gracefully (no crash, no hang) end to end.
35
+ - Full `bun test` — 3123 pass / 0 fail across 307 files.
36
+ - `bun run typecheck` — no errors.
37
+
9
38
  ## [0.8.37] - 2026-07-21
10
39
  _Long-term goal opened to triage every remaining PR in gjc v0.11.5's ~65-entry list, one batch at a time. This entry covers the first batch (10 PRs read in full)._
11
40
 
package/README.ja.md CHANGED
@@ -283,11 +283,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
283
283
  ## 変更履歴 (Changelog)
284
284
 
285
285
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
286
+ - **[0.8.39]** (2026-07-21) — User report (paraphrased): "when I copy and paste content from elsewhere, it doesn't need to show every line — like gjc — update it so the composer stays a reasonable size and shows a clear indicator when width/length isn't enough." Investigated live before assuming intent: pasting a large multi-line block into the composer already caps the visible box height and scrolls to the caret, but the scrolled-off content above/below was flagged with a bare `…` — no indication of HOW MUCH text was hidden. (Checked gjc's own actual paste-handling source directly: it does not have a general text-paste-collapse feature either — only image-path detection — so this fix targets the concrete, reproducible UX gap found live, not a specific gjc feature to port.)
287
+ - **[0.8.38]** (2026-07-21) — Independent architect-subagent review of this whole session's TUI/workflow changes (v0.8.29-v0.8.37), requested explicitly to verify stability before calling the work done. Verdict: WATCH/COMMENT, no P0/P1 — the resize fix, the idle↔mid-turn transition, the new `calc` tool wiring, and the web-search credential gating all held up under independent scrutiny. Found and fixed 3 real, previously-unknown bugs in `width.ts`; 1 architectural observation recorded as tech debt, not fixed.
286
288
  - **[0.8.37]** (2026-07-21) — Long-term goal opened to triage every remaining PR in gjc v0.11.5's ~65-entry list, one batch at a time. This entry covers the first batch (10 PRs read in full).
287
289
  - **[0.8.36]** (2026-07-21) — First full audit of gjc v0.11.5's actual ~65-PR "What's Changed" list (prior passes had used release-note prose or direct-source structural diffs — this is the first time every individual PR was read and triaged against jeo's own codebase).
288
290
  - **[0.8.35]** (2026-07-21) — Deeper gjc-parity pass: re-cloned the real, public `Yeachan-Heo/gajae-code` source and, this time, structurally compared its actual tool implementations (`packages/coding-agent/src/tools/`) against jeo's own tool surface, not just the provider catalog.
289
- - **[0.8.34]** (2026-07-21) — Provider/model-catalog sync against gjc's actual current source (a shallow clone of the real, public `Yeachan-Heo/gajae-code` repo at v0.11.5 — direct `packages/ai/src/models.json` comparison, not inference from release notes). The system prompt's philosophy, the `/route` command, and the `tencent` provider were explicitly out of scope and are unchanged.
290
- - **[0.8.33]** (2026-07-20) — Real terminal-resize testing (repeated tmux resize-window + keystroke reproductions, byte-for-byte ANSI replay outside jeo/tmux to isolate root causes deterministically) uncovered and closed a progressive screen-corruption bug: resizing down to a narrow terminal (e.g. a ~20-column tmux pane) produced a growing stack of duplicate status-bar lines on every subsequent keystroke, and a separate, independently-reproducible scroll bug in the mid-turn live renderer.
291
291
 
292
292
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
293
293
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -284,11 +284,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
284
284
  ## 변경 이력 (Changelog)
285
285
 
286
286
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
287
+ - **[0.8.39]** (2026-07-21) — User report (paraphrased): "when I copy and paste content from elsewhere, it doesn't need to show every line — like gjc — update it so the composer stays a reasonable size and shows a clear indicator when width/length isn't enough." Investigated live before assuming intent: pasting a large multi-line block into the composer already caps the visible box height and scrolls to the caret, but the scrolled-off content above/below was flagged with a bare `…` — no indication of HOW MUCH text was hidden. (Checked gjc's own actual paste-handling source directly: it does not have a general text-paste-collapse feature either — only image-path detection — so this fix targets the concrete, reproducible UX gap found live, not a specific gjc feature to port.)
288
+ - **[0.8.38]** (2026-07-21) — Independent architect-subagent review of this whole session's TUI/workflow changes (v0.8.29-v0.8.37), requested explicitly to verify stability before calling the work done. Verdict: WATCH/COMMENT, no P0/P1 — the resize fix, the idle↔mid-turn transition, the new `calc` tool wiring, and the web-search credential gating all held up under independent scrutiny. Found and fixed 3 real, previously-unknown bugs in `width.ts`; 1 architectural observation recorded as tech debt, not fixed.
287
289
  - **[0.8.37]** (2026-07-21) — Long-term goal opened to triage every remaining PR in gjc v0.11.5's ~65-entry list, one batch at a time. This entry covers the first batch (10 PRs read in full).
288
290
  - **[0.8.36]** (2026-07-21) — First full audit of gjc v0.11.5's actual ~65-PR "What's Changed" list (prior passes had used release-note prose or direct-source structural diffs — this is the first time every individual PR was read and triaged against jeo's own codebase).
289
291
  - **[0.8.35]** (2026-07-21) — Deeper gjc-parity pass: re-cloned the real, public `Yeachan-Heo/gajae-code` source and, this time, structurally compared its actual tool implementations (`packages/coding-agent/src/tools/`) against jeo's own tool surface, not just the provider catalog.
290
- - **[0.8.34]** (2026-07-21) — Provider/model-catalog sync against gjc's actual current source (a shallow clone of the real, public `Yeachan-Heo/gajae-code` repo at v0.11.5 — direct `packages/ai/src/models.json` comparison, not inference from release notes). The system prompt's philosophy, the `/route` command, and the `tencent` provider were explicitly out of scope and are unchanged.
291
- - **[0.8.33]** (2026-07-20) — Real terminal-resize testing (repeated tmux resize-window + keystroke reproductions, byte-for-byte ANSI replay outside jeo/tmux to isolate root causes deterministically) uncovered and closed a progressive screen-corruption bug: resizing down to a narrow terminal (e.g. a ~20-column tmux pane) produced a growing stack of duplicate status-bar lines on every subsequent keystroke, and a separate, independently-reproducible scroll bug in the mid-turn live renderer.
292
292
 
293
293
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
294
294
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -291,11 +291,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
291
291
  ## Changelog
292
292
 
293
293
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
294
+ - **[0.8.39]** (2026-07-21) — User report (paraphrased): "when I copy and paste content from elsewhere, it doesn't need to show every line — like gjc — update it so the composer stays a reasonable size and shows a clear indicator when width/length isn't enough." Investigated live before assuming intent: pasting a large multi-line block into the composer already caps the visible box height and scrolls to the caret, but the scrolled-off content above/below was flagged with a bare `…` — no indication of HOW MUCH text was hidden. (Checked gjc's own actual paste-handling source directly: it does not have a general text-paste-collapse feature either — only image-path detection — so this fix targets the concrete, reproducible UX gap found live, not a specific gjc feature to port.)
295
+ - **[0.8.38]** (2026-07-21) — Independent architect-subagent review of this whole session's TUI/workflow changes (v0.8.29-v0.8.37), requested explicitly to verify stability before calling the work done. Verdict: WATCH/COMMENT, no P0/P1 — the resize fix, the idle↔mid-turn transition, the new `calc` tool wiring, and the web-search credential gating all held up under independent scrutiny. Found and fixed 3 real, previously-unknown bugs in `width.ts`; 1 architectural observation recorded as tech debt, not fixed.
294
296
  - **[0.8.37]** (2026-07-21) — Long-term goal opened to triage every remaining PR in gjc v0.11.5's ~65-entry list, one batch at a time. This entry covers the first batch (10 PRs read in full).
295
297
  - **[0.8.36]** (2026-07-21) — First full audit of gjc v0.11.5's actual ~65-PR "What's Changed" list (prior passes had used release-note prose or direct-source structural diffs — this is the first time every individual PR was read and triaged against jeo's own codebase).
296
298
  - **[0.8.35]** (2026-07-21) — Deeper gjc-parity pass: re-cloned the real, public `Yeachan-Heo/gajae-code` source and, this time, structurally compared its actual tool implementations (`packages/coding-agent/src/tools/`) against jeo's own tool surface, not just the provider catalog.
297
- - **[0.8.34]** (2026-07-21) — Provider/model-catalog sync against gjc's actual current source (a shallow clone of the real, public `Yeachan-Heo/gajae-code` repo at v0.11.5 — direct `packages/ai/src/models.json` comparison, not inference from release notes). The system prompt's philosophy, the `/route` command, and the `tencent` provider were explicitly out of scope and are unchanged.
298
- - **[0.8.33]** (2026-07-20) — Real terminal-resize testing (repeated tmux resize-window + keystroke reproductions, byte-for-byte ANSI replay outside jeo/tmux to isolate root causes deterministically) uncovered and closed a progressive screen-corruption bug: resizing down to a narrow terminal (e.g. a ~20-column tmux pane) produced a growing stack of duplicate status-bar lines on every subsequent keystroke, and a separate, independently-reproducible scroll bug in the mid-turn live renderer.
299
299
 
300
300
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
301
301
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -283,11 +283,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
283
283
  ## 更新日志 (Changelog)
284
284
 
285
285
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
286
+ - **[0.8.39]** (2026-07-21) — User report (paraphrased): "when I copy and paste content from elsewhere, it doesn't need to show every line — like gjc — update it so the composer stays a reasonable size and shows a clear indicator when width/length isn't enough." Investigated live before assuming intent: pasting a large multi-line block into the composer already caps the visible box height and scrolls to the caret, but the scrolled-off content above/below was flagged with a bare `…` — no indication of HOW MUCH text was hidden. (Checked gjc's own actual paste-handling source directly: it does not have a general text-paste-collapse feature either — only image-path detection — so this fix targets the concrete, reproducible UX gap found live, not a specific gjc feature to port.)
287
+ - **[0.8.38]** (2026-07-21) — Independent architect-subagent review of this whole session's TUI/workflow changes (v0.8.29-v0.8.37), requested explicitly to verify stability before calling the work done. Verdict: WATCH/COMMENT, no P0/P1 — the resize fix, the idle↔mid-turn transition, the new `calc` tool wiring, and the web-search credential gating all held up under independent scrutiny. Found and fixed 3 real, previously-unknown bugs in `width.ts`; 1 architectural observation recorded as tech debt, not fixed.
286
288
  - **[0.8.37]** (2026-07-21) — Long-term goal opened to triage every remaining PR in gjc v0.11.5's ~65-entry list, one batch at a time. This entry covers the first batch (10 PRs read in full).
287
289
  - **[0.8.36]** (2026-07-21) — First full audit of gjc v0.11.5's actual ~65-PR "What's Changed" list (prior passes had used release-note prose or direct-source structural diffs — this is the first time every individual PR was read and triaged against jeo's own codebase).
288
290
  - **[0.8.35]** (2026-07-21) — Deeper gjc-parity pass: re-cloned the real, public `Yeachan-Heo/gajae-code` source and, this time, structurally compared its actual tool implementations (`packages/coding-agent/src/tools/`) against jeo's own tool surface, not just the provider catalog.
289
- - **[0.8.34]** (2026-07-21) — Provider/model-catalog sync against gjc's actual current source (a shallow clone of the real, public `Yeachan-Heo/gajae-code` repo at v0.11.5 — direct `packages/ai/src/models.json` comparison, not inference from release notes). The system prompt's philosophy, the `/route` command, and the `tencent` provider were explicitly out of scope and are unchanged.
290
- - **[0.8.33]** (2026-07-20) — Real terminal-resize testing (repeated tmux resize-window + keystroke reproductions, byte-for-byte ANSI replay outside jeo/tmux to isolate root causes deterministically) uncovered and closed a progressive screen-corruption bug: resizing down to a narrow terminal (e.g. a ~20-column tmux pane) produced a growing stack of duplicate status-bar lines on every subsequent keystroke, and a separate, independently-reproducible scroll bug in the mid-turn live renderer.
291
291
 
292
292
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
293
293
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.8.37",
3
+ "version": "0.8.39",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -135,22 +135,31 @@ export function renderInputFrame(line: string, opts: InputBoxOptions = {}): Inpu
135
135
  }
136
136
 
137
137
  // Scroll the visible window so the caret row stays in view — cursor movement reveals
138
- // every line (no content is unreachable). `…` markers flag rows hidden above/below.
138
+ // every line (no content is unreachable). A count-bearing marker (not a bare `…`) flags
139
+ // rows hidden above/below so a large paste's user isn't left guessing how much text is
140
+ // off-screen — e.g. pasting a 20-line block previously showed a bare "…line sixteen"
141
+ // with no indication 15 more lines existed above it.
139
142
  const maxBodyRows = Math.max(1, Math.trunc(opts.maxBodyRows ?? rows.length));
140
143
  const totalRows = rows.length;
141
144
  let hidden = 0;
145
+ let topPrefixWidth = 0;
142
146
  if (totalRows > maxBodyRows) {
143
147
  hidden = Math.min(Math.max(0, crow - maxBodyRows + 1), totalRows - maxBodyRows);
144
148
  if (crow < hidden) hidden = crow; // caret above the window → scroll up to it
145
149
  rows = rows.slice(hidden, hidden + maxBodyRows);
146
- if (hidden > 0) rows[0] = truncateToWidth(`…${rows[0] ?? ""}`, textWidth);
147
- if (hidden + maxBodyRows < totalRows) {
150
+ if (hidden > 0) {
151
+ const topPrefix = `+${hidden}⋯`;
152
+ topPrefixWidth = visibleWidth(topPrefix);
153
+ rows[0] = truncateToWidth(`${topPrefix}${rows[0] ?? ""}`, textWidth);
154
+ }
155
+ const hiddenBelow = totalRows - hidden - maxBodyRows;
156
+ if (hiddenBelow > 0) {
148
157
  const last = rows.length - 1;
149
- rows[last] = truncateToWidth(`${rows[last] ?? ""}…`, textWidth);
158
+ rows[last] = truncateToWidth(`${rows[last] ?? ""}⋯+${hiddenBelow}`, textWidth);
150
159
  }
151
160
  }
152
161
  let visRow = Math.max(0, Math.min(crow - hidden, rows.length - 1));
153
- if (hidden > 0 && crow - hidden === 0) ccol += 1; // shifted by the leading `…`
162
+ if (hidden > 0 && crow - hidden === 0) ccol += topPrefixWidth; // shifted by the leading "+N⋯" marker
154
163
  if (crow - hidden < 0) { visRow = 0; ccol = 0; }
155
164
 
156
165
  const promptMark = "> ";
@@ -26,6 +26,11 @@ export function charWidth(cp: number): number {
26
26
  // C0/C1 control characters have no width here (callers strip/By handle them).
27
27
  if (cp < 32 || (cp >= 0x7f && cp < 0xa0)) return 0;
28
28
  // Zero-width: combining marks, ZWSP/ZWNJ/ZWJ, variation selectors, BOM.
29
+ // Non-Latin combining-mark ranges (Hebrew points/cantillation, Arabic vowel signs,
30
+ // Devanagari matras/virama, Thai vowels/tone marks) verified against the current
31
+ // jquast/wcwidth zero-width table (github.com/termux/wcwidth, table_zero.py,
32
+ // Unicode-aligned as of 2022-12-16) — a real, reproduced gap: e.g. Arabic "بّّ"َ
33
+ // (base + shadda + fatha) previously counted visibleWidth 3 instead of 1.
29
34
  if (
30
35
  (cp >= 0x0300 && cp <= 0x036f) || // combining diacritical marks
31
36
  (cp >= 0x200b && cp <= 0x200f) || // zero-width space..RLM
@@ -33,6 +38,14 @@ export function charWidth(cp: number): number {
33
38
  (cp >= 0x1ab0 && cp <= 0x1aff) || // combining diacritical marks extended
34
39
  (cp >= 0x1dc0 && cp <= 0x1dff) || // combining diacritical marks supplement
35
40
  (cp >= 0x20d0 && cp <= 0x20ff) || // combining marks for symbols
41
+ (cp >= 0x0591 && cp <= 0x05bd) || // Hebrew accents/points (Etnahta..Meteg)
42
+ cp === 0x05bf || (cp >= 0x05c1 && cp <= 0x05c2) || (cp >= 0x05c4 && cp <= 0x05c5) || cp === 0x05c7 || // Hebrew points (Rafe, Shin/Sin Dot, upper/lower dot, Qamats Qatan)
43
+ (cp >= 0x0610 && cp <= 0x061a) || // Arabic honorific/small signs
44
+ (cp >= 0x064b && cp <= 0x065f) || cp === 0x0670 || // Arabic vowel signs/tanwin, superscript alef
45
+ (cp >= 0x06d6 && cp <= 0x06dc) || (cp >= 0x06df && cp <= 0x06e4) || (cp >= 0x06e7 && cp <= 0x06e8) || (cp >= 0x06ea && cp <= 0x06ed) || // Arabic Quranic annotation signs
46
+ (cp >= 0x0900 && cp <= 0x0902) || cp === 0x093a || cp === 0x093c || // Devanagari candrabindu/anusvara/vowel-sign-oe/nukta
47
+ (cp >= 0x0941 && cp <= 0x0948) || cp === 0x094d || (cp >= 0x0951 && cp <= 0x0957) || (cp >= 0x0962 && cp <= 0x0963) || // Devanagari vowel signs/virama/stress/vocalic
48
+ cp === 0x0e31 || (cp >= 0x0e34 && cp <= 0x0e3a) || (cp >= 0x0e47 && cp <= 0x0e4e) || // Thai vowels/tone marks
36
49
  cp === 0xfeff
37
50
  ) {
38
51
  return 0;
@@ -64,6 +77,12 @@ const VS16 = 0xfe0f; // variation selector: force EMOJI (wide) presentation
64
77
  const FITZPATRICK_MIN = 0x1f3fb; // Emoji Modifier Fitzpatrick Type-1-2 .. Type-6
65
78
  const FITZPATRICK_MAX = 0x1f3ff;
66
79
  const KEYCAP_COMBINING = 0x20e3; // combining enclosing keycap (1️⃣, #️⃣, *️⃣)
80
+ const RI_MIN = 0x1f1e6; // Regional Indicator Symbol Letter A
81
+ const RI_MAX = 0x1f1ff; // Regional Indicator Symbol Letter Z
82
+
83
+ function isRegionalIndicator(cp: number): boolean {
84
+ return cp >= RI_MIN && cp <= RI_MAX;
85
+ }
67
86
 
68
87
  function isFitzpatrick(cp: number): boolean {
69
88
  return cp >= FITZPATRICK_MIN && cp <= FITZPATRICK_MAX;
@@ -78,6 +97,10 @@ function isAttachableCombining(cp: number): boolean {
78
97
  || (cp >= 0x1ab0 && cp <= 0x1aff)
79
98
  || (cp >= 0x1dc0 && cp <= 0x1dff)
80
99
  || (cp >= 0x20d0 && cp <= 0x20ff)
100
+ || (cp >= 0x0591 && cp <= 0x05bd) || cp === 0x05bf || (cp >= 0x05c1 && cp <= 0x05c2) || (cp >= 0x05c4 && cp <= 0x05c5) || cp === 0x05c7 // Hebrew
101
+ || (cp >= 0x0610 && cp <= 0x061a) || (cp >= 0x064b && cp <= 0x065f) || cp === 0x0670 || (cp >= 0x06d6 && cp <= 0x06dc) || (cp >= 0x06df && cp <= 0x06e4) || (cp >= 0x06e7 && cp <= 0x06e8) || (cp >= 0x06ea && cp <= 0x06ed) // Arabic
102
+ || (cp >= 0x0900 && cp <= 0x0902) || cp === 0x093a || cp === 0x093c || (cp >= 0x0941 && cp <= 0x0948) || cp === 0x094d || (cp >= 0x0951 && cp <= 0x0957) || (cp >= 0x0962 && cp <= 0x0963) // Devanagari
103
+ || cp === 0x0e31 || (cp >= 0x0e34 && cp <= 0x0e3a) || (cp >= 0x0e47 && cp <= 0x0e4e) // Thai
81
104
  || cp === 0xfeff;
82
105
  }
83
106
 
@@ -110,6 +133,17 @@ export function nextGraphemeCluster(s: string, i: number): { length: number; wid
110
133
  let len = base.length;
111
134
  let j = i + base.length;
112
135
  const peek = (): number => s.codePointAt(j) ?? -1;
136
+ // A pair of adjacent Regional Indicators forms ONE flag-emoji cluster (UAX #29's
137
+ // RI-pairing rule) — e.g. 🇰🇷 = REGIONAL INDICATOR K + REGIONAL INDICATOR R. Each
138
+ // RI alone is already width 1 (no dedicated charWidth range; falls through to the
139
+ // default), so an unpaired scan already SUMS to the right total (2) even without
140
+ // this — but without pairing, a wrap/truncate boundary can land BETWEEN the two,
141
+ // rendering a lone indicator-letter square instead of a flag. No further
142
+ // modifiers (VS16/ZWJ/Fitzpatrick) are defined for a flag pair, so return early.
143
+ if (isRegionalIndicator(base.cp) && isRegionalIndicator(peek())) {
144
+ const second = codePointAndLength(s, j);
145
+ return { length: len + second.length, width: 2 };
146
+ }
113
147
 
114
148
  if (peek() === VS15) { width = 1; len += 1; j += 1; }
115
149
  else if (peek() === VS16) { width = 2; len += 1; j += 1; }
@@ -322,6 +356,28 @@ export function wrapTextWithAnsi(text: string, cols: number): string[] {
322
356
  : head.endsWith(RESET) && rest.startsWith(head.slice(0, -RESET.length))
323
357
  ? head.slice(0, -RESET.length)
324
358
  : head;
359
+ if (consumed.length === 0) {
360
+ // `truncateToWidth` couldn't fit even ONE whole cluster in `width` — a wide
361
+ // grapheme cluster (CJK/emoji/ZWJ sequence) wider than the available column
362
+ // budget, e.g. a 2-wide cluster at cols=1. Left alone, `rest` never shrinks
363
+ // and this loop spins forever — a REAL, reproduced infinite loop (hangs the
364
+ // whole TUI; confirmed live via a 5s timeout kill before this fix). Force
365
+ // forward progress: emit that one cluster on its own, deliberately
366
+ // over-width, line — an unavoidable overflow beats a hung process. `rest[0]`
367
+ // is guaranteed to be real content here, never an escape sequence (those are
368
+ // always consumed into `head` regardless of the width budget, so an empty
369
+ // `head` can only mean the first REAL cluster itself didn't fit).
370
+ const forced = nextGraphemeCluster(rest, 0);
371
+ const forcedText = rest.slice(0, forced.length);
372
+ let forcedLine = active + forcedText;
373
+ const forcedActive = sgrStateAfter(active, forcedText);
374
+ if (forcedActive && !forcedLine.endsWith(RESET)) forcedLine += RESET;
375
+ out.push(forcedLine);
376
+ active = forcedActive;
377
+ rest = rest.slice(forced.length);
378
+ if (rest.length === 0) break;
379
+ continue;
380
+ }
325
381
  if (consumed.length === rest.length) {
326
382
  let line = active + rest;
327
383
  if (active && !line.endsWith(RESET)) line += RESET;