From 2e84b84126359c1517b65e5ede2a67beab348ff8 Mon Sep 17 00:00:00 2001 From: IAMLEIzZ Date: Sat, 8 Aug 2026 00:15:35 +0800 Subject: [PATCH 1/2] fix(ui): wrap draft notes by terminal cells so long CJK input stays visible The draft composer estimated its row count with String#length (UTF-16 code units) while the textarea wraps by terminal cells. For CJK text (two cells per code unit) the estimate stayed at one row, and the editor clamps its wrap count to the viewport height, so the one-row composer never started wrapping and scrolled everything before the cursor out of view. Give the composer wrapMode="char" so the wrap count is computable exactly, count rows by packing grapheme clusters into terminal cells, drop the stale line-count hint state, and resize the composer in the same frame as each edit. Add editor-parity, component, stream, and PTY coverage. --- .changeset/draft-note-cell-wrap.md | 5 + src/ui/AppHost.interactions.test.tsx | 66 ++++ .../components/panes/AgentInlineNote.test.tsx | 313 ++++++++++++++++++ src/ui/components/panes/AgentInlineNote.tsx | 115 +++---- src/ui/components/ui-components.test.tsx | 4 +- test/pty/notes.test.ts | 36 ++ 6 files changed, 475 insertions(+), 64 deletions(-) create mode 100644 .changeset/draft-note-cell-wrap.md create mode 100644 src/ui/components/panes/AgentInlineNote.test.tsx diff --git a/.changeset/draft-note-cell-wrap.md b/.changeset/draft-note-cell-wrap.md new file mode 100644 index 000000000..cb45618cc --- /dev/null +++ b/.changeset/draft-note-cell-wrap.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Wrap draft review notes by terminal cells instead of scrolling horizontally, so long CJK notes stay fully visible while typing; previously the composer stayed one row high and hid everything before the cursor. diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index b2ab0a802..6f4c6da86 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -2822,6 +2822,72 @@ describe("App interactions", () => { } }); + test("draft note wraps long CJK input instead of scrolling it out of view", async () => { + const setup = await testRender(, { + width: 160, + height: 40, + }); + + try { + await flush(setup); + + await act(async () => { + await setup.mockInput.typeText("c"); + }); + await flush(setup); + + const body = + "这个包主要是为了在普通的chatmodel外面包一层,在外层把toolcallid统一转换,方便后续处理"; + for (const chunk of body.match(/.{1,12}/g) ?? []) { + await act(async () => { + await setup.mockInput.typeText(chunk); + }); + await flush(setup); + } + + const frame = setup.captureCharFrame(); + expect(frame).toContain("Draft note"); + expect(frame).toContain(body.slice(0, 10)); + expect(frame).toContain(body.slice(-4)); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("draft note survives a large burst of input in one chunk", async () => { + const setup = await testRender(, { + width: 160, + height: 40, + }); + + try { + await flush(setup); + + await act(async () => { + await setup.mockInput.typeText("c"); + }); + await flush(setup); + + // One synchronous burst, the shape chunked pastes and key repeats take. + const text = "the quick brown fox jumps over the lazy dog 0123456789".repeat(3); + await act(async () => { + await setup.mockInput.typeText(text); + }); + await flush(setup); + + const frame = setup.captureCharFrame(); + expect(frame).toContain("Draft note"); + expect(frame).toContain(text.slice(0, 10)); + expect(frame).toContain(text.slice(-6)); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("draft note saves Ctrl-S when tmux sends CSI-u input", async () => { const setup = await testRender(, { width: 240, diff --git a/src/ui/components/panes/AgentInlineNote.test.tsx b/src/ui/components/panes/AgentInlineNote.test.tsx new file mode 100644 index 000000000..f3ed3e043 --- /dev/null +++ b/src/ui/components/panes/AgentInlineNote.test.tsx @@ -0,0 +1,313 @@ +import { describe, expect, test } from "bun:test"; +import { TextareaRenderable } from "@opentui/core"; +import { createTestRenderer } from "@opentui/core/testing"; +import { testRender } from "@opentui/react/test-utils"; +import { act, useState } from "react"; +import { resolveTheme } from "../../themes"; +import { + AgentInlineNote, + draftVisualLineCount, + measureAgentInlineNoteHeight, +} from "./AgentInlineNote"; + +const theme = resolveTheme("github-dark-default", null); + +describe("draftVisualLineCount", () => { + const cases: Array<[string, string, number, number]> = [ + // [label, text, width, expected rows] + ["empty text", "", 10, 1], + ["short ASCII fits", "hello", 10, 1], + ["ASCII exact fit", "aaaaaaaaaa", 10, 1], + ["ASCII one cell over", "aaaaaaaaaaa", 10, 2], + ["long unbroken ASCII", "a".repeat(50), 10, 5], + ["word slack packs by cells", "aaaaaa aaaaaa aaaaaa", 10, 2], + ["trailing space counts", "aaaaaaaaaa ", 10, 2], + ["spaces only", " ", 10, 1], + ["CJK exact fit", "阿斯蒂芬加", 10, 1], + ["CJK one cluster over", "阿斯蒂芬加快", 10, 2], + ["long unbroken CJK", "阿".repeat(25), 10, 5], + ["wide clusters cannot straddle an odd width", "阿".repeat(25), 25, 3], + ["CJK punctuation", "你好,世界。你好!", 10, 2], + ["combining marks stay attached", "e\u0301".repeat(8), 10, 1], + ["combining mark at the boundary", "a".repeat(10) + "e\u0301", 10, 2], + ["ZWJ emoji cluster", "👨‍👩‍👧".repeat(6), 10, 2], + ["mixed ASCII and CJK", "ab阿cd", 10, 1], + ["emoji run", "🎉".repeat(8), 10, 2], + ["mixed emoji", "ab🎉cd🎉ef", 6, 2], + ["realistic CJK prose", "这个包主要是为了在普通的chatmodel外面包一层?", 20, 3], + ["hard newline", "aaa\nbbb", 10, 2], + ["trailing newline", "aaa\n", 10, 2], + ["empty middle line", "aaa\n\nbbb", 10, 3], + ["newline plus wrap", "aaaaaaaaaaa\nbbb", 10, 3], + ["tab is two cells", "a\tb", 3, 2], + ["tab fits wider box", "a\tb", 4, 1], + ["tab after content", "aaaaaaaa\taa", 10, 2], + ["width clamps to one", "ab", 0, 2], + ]; + + for (const [label, text, width, expected] of cases) { + test(label, () => { + expect(draftVisualLineCount(text, width)).toBe(expected); + }); + } +}); + +describe("draftVisualLineCount editor parity", () => { + const parityTexts = [ + "", + "hello world", + "a".repeat(10), + "a".repeat(50), + "hello world this is a longer line with spaces to wrap properly ok", + "aaaaaa aaaaaa aaaaaa", + "阿斯蒂芬加", + "阿斯蒂芬加快", + "阿".repeat(25), + "你好,世界。你好!", + "这个包主要是为了在普通的chatmodel外面包一层?", + "ab阿cd🎉ef", + "🎉".repeat(8), + "e\u0301".repeat(12), + "a".repeat(10) + "e\u0301", + "👨‍👩‍👧".repeat(6), + "a b c", + "aaaaaaaaaa ", + "aaa\nbbb", + "aaa\n", + "aaa\n\nbbb", + "a\tb", + "aaaaaaaa\taa", + ]; + + for (const width of [24, 25, 40, 72]) { + test(`matches the real editor wrap count at width ${width}`, async () => { + const { renderer, renderOnce } = await createTestRenderer({ width: 120, height: 60 }); + const textarea = new TextareaRenderable(renderer, { width, height: 40, wrapMode: "char" }); + renderer.root.add(textarea); + await renderOnce(); + + try { + for (const text of parityTexts) { + textarea.setText(text); + await renderOnce(); + expect(textarea.virtualLineCount).toBe(draftVisualLineCount(text, width)); + } + } finally { + await renderer.destroy(); + } + }); + } +}); + +function draftAnnotation(body: string) { + return { + id: "draft:1", + source: "user-draft" as const, + summary: body || " ", + newRange: [1, 1] as [number, number], + editable: true, + }; +} + +function DraftHarness({ width }: { width: number }) { + const [body, setBody] = useState(""); + return ( + {}, + onSave: () => {}, + }} + /> + ); +} + +async function flush(setup: Awaited>) { + await act(async () => { + await setup.renderOnce(); + await Bun.sleep(0); + await setup.renderOnce(); + }); +} + +/** Count the rendered card rows from its top border to the footer bottom border. */ +function renderedCardRowCount(frame: string) { + const lines = frame.split("\n"); + const top = lines.findIndex((line) => line.includes("╭─")); + const bottom = lines.reduce((last, line, index) => (line.includes("┴") ? index : last), -1); + expect(top).toBeGreaterThanOrEqual(0); + expect(bottom).toBeGreaterThan(top); + return bottom - top + 1; +} + +function plannedCardHeight(body: string, width: number) { + return measureAgentInlineNoteHeight({ + annotation: draftAnnotation(body), + anchorSide: "new", + layout: "split", + width, + }); +} + +describe("AgentInlineNote draft composer", () => { + test("renders long CJK drafts fully wrapped with nothing scrolled away", async () => { + // 60 distinct wide characters, 120 cells. + const body = + "天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往秋收冬藏闰余成岁律吕调阳云腾致雨露结为霜金生丽水玉出昆冈"; + const setup = await testRender( + {}, + onCancel: () => {}, + onSave: () => {}, + }} + />, + { width: 120, height: 40 }, + ); + + try { + await flush(setup); + const frame = setup.captureCharFrame(); + expect(frame).toContain(body.slice(0, 10)); + expect(frame).toContain(body.slice(-4)); + expect(renderedCardRowCount(frame)).toBe(plannedCardHeight(body, 96)); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("keeps every typed CJK character visible while the text wraps", async () => { + const setup = await testRender(, { width: 120, height: 40 }); + + try { + await flush(setup); + // 31 distinct wide characters, 62 cells: wraps well before the last keystroke. + const chars = [..."一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯辰巳午未申酉戌亥完"]; + + let typed = ""; + for (const char of chars) { + typed += char; + await act(async () => { + await setup.mockInput.typeText(char); + }); + await flush(setup); + } + + const frame = setup.captureCharFrame(); + expect(frame).toContain(typed.slice(0, 10)); + expect(frame).toContain(typed.slice(-10)); + expect(renderedCardRowCount(frame)).toBe(plannedCardHeight(typed, 96)); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("grows across hard newlines with wide characters before and after", async () => { + const setup = await testRender(, { width: 120, height: 40 }); + + try { + await flush(setup); + await act(async () => { + await setup.mockInput.typeText("第一行内容"); + }); + await flush(setup); + await act(async () => { + setup.mockInput.pressEnter(); + }); + await flush(setup); + await act(async () => { + await setup.mockInput.typeText("第二行"); + }); + await flush(setup); + + const frame = setup.captureCharFrame(); + expect(frame).toContain("第一行内容"); + expect(frame).toContain("第二行"); + expect(renderedCardRowCount(frame)).toBe(plannedCardHeight("第一行内容\n第二行", 96)); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("grows to fit a large bracketed paste", async () => { + const setup = await testRender(, { width: 120, height: 40 }); + + try { + await flush(setup); + const blob = "pasted 文本内容 mixed english words ".repeat(6) + "END标记"; + await act(async () => { + await setup.mockInput.pasteBracketedText(blob); + }); + await flush(setup); + + const frame = setup.captureCharFrame(); + expect(frame).toContain(blob.slice(0, 10)); + expect(frame).toContain("标记"); + expect(renderedCardRowCount(frame)).toBe(plannedCardHeight(blob, 96)); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("rendered card height matches the planned height for wide bodies", async () => { + const bodies = [ + "阿".repeat(60), + "🎉".repeat(30) + "tail", + " tabs\tinside\t text ".repeat(4), + "short\n阿斯蒂芬加快速度发卡号\nend", + "plain English text that keeps wrapping past one full row of the box", + ]; + + for (const body of bodies) { + const setup = await testRender( + {}, + onCancel: () => {}, + onSave: () => {}, + }} + />, + { width: 120, height: 40 }, + ); + + try { + await flush(setup); + const frame = setup.captureCharFrame(); + expect(renderedCardRowCount(frame)).toBe(plannedCardHeight(body, 96)); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + } + }); +}); diff --git a/src/ui/components/panes/AgentInlineNote.tsx b/src/ui/components/panes/AgentInlineNote.tsx index df0ba1515..6a799c3a7 100644 --- a/src/ui/components/panes/AgentInlineNote.tsx +++ b/src/ui/components/panes/AgentInlineNote.tsx @@ -1,13 +1,19 @@ import { createTextAttributes, type TextareaRenderable } from "@opentui/core"; -import { flushSync } from "@opentui/react"; -import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { useLayoutEffect, useRef, type ReactNode } from "react"; import type { AgentAnnotation, DiffFile, LayoutMode } from "../../../core/types"; import { agentNoteBoxLayout } from "../../lib/agentNoteGeometry"; import { annotationRangeLabel, reviewNoteSource } from "../../lib/agentAnnotations"; import { wrapText } from "../../lib/agentPopover"; import { sanitizeTerminalLine } from "../../../lib/terminalText"; -import { fitText, measureTextWidth, padText } from "../../lib/text"; +import { + fitText, + isPrintableAsciiText, + measureClusterWidth, + measureTextWidth, + padText, + textClusters, +} from "../../lib/text"; import { resolveStmlColor } from "../../lib/stml/colors"; import { layoutStmlCached, type StmlLine, type StmlSpan } from "../../lib/stml/layout"; import type { AppTheme } from "../../themes"; @@ -48,30 +54,46 @@ export function agentInlineNoteMarkupLines( return lines.length > 0 ? lines : null; } -function draftLineCount(text: string) { - return Math.max(1, text.split("\n").length); +/** Measure one grapheme cluster the way the composer renders it: a tab takes two cells. */ +function draftClusterCells(cluster: string) { + return cluster === "\t" ? 2 : measureClusterWidth(cluster); } -/** Estimate the textarea's wrapped visual row count for a given content width. */ -function draftVisualLineCount(text: string, width: number) { +/** + * Count the composer's visual rows for one body at one content width. + * + * The textarea wraps by character without splitting a grapheme cluster: a + * cluster that would cross the row boundary moves to the next row whole, so + * at an odd width a wide CJK character leaves one cell unused. This count + * must match the editor exactly: the row-windowed stream plans note heights + * from it before the card mounts, and the editor clamps its wrap count to + * its viewport height, so an undercount would hide rows instead of + * revealing them. + */ +export function draftVisualLineCount(text: string, width: number) { const usableWidth = Math.max(1, width); - return Math.max( - 1, - text - .split("\n") - .reduce((total, line) => total + Math.max(1, Math.ceil(line.length / usableWidth)), 0), - ); -} + let rows = 0; -function isNewlineKey(key: { ctrl?: boolean; name?: string; sequence?: string }) { - return ( - key.name === "return" || - key.name === "enter" || - key.name === "linefeed" || - key.sequence === "\r" || - key.sequence === "\n" || - (key.ctrl && key.name === "j") - ); + for (const line of text.split("\n")) { + if (isPrintableAsciiText(line)) { + rows += Math.max(1, Math.ceil(line.length / usableWidth)); + continue; + } + + let used = 0; + let lineRows = 1; + for (const cluster of textClusters(line)) { + const cells = draftClusterCells(cluster); + if (used > 0 && used + cells > usableWidth) { + lineRows++; + used = 0; + } + used += cells; + } + rows += lineRows; + } + + return rows; } /** Wrap text while preserving author-entered line breaks in review notes. */ @@ -158,13 +180,6 @@ export function AgentInlineNote({ width: number; }) { const textareaRef = useRef(null); - const [draftLineCountHint, setDraftLineCountHint] = useState(() => - draftLineCount(draft?.body ?? ""), - ); - - useEffect(() => { - setDraftLineCountHint(draftLineCount(draft?.body ?? "")); - }, [draft?.body]); useLayoutEffect(() => { if (!draft) { @@ -208,9 +223,7 @@ export function AgentInlineNote({ const closeWidth = closeText.length; const draftInnerWidth = Math.max(1, boxWidth - 2); const draftContentWidth = Math.max(1, draftInnerWidth - 2); - const draftVisibleRows = draft - ? Math.max(draftLineCountHint, draftVisualLineCount(draft.body, draftContentWidth)) - : 0; + const draftVisibleRows = draft ? draftVisualLineCount(draft.body, draftContentWidth) : 0; useLayoutEffect(() => { if (!draft || draftVisibleRows <= 0) { @@ -233,12 +246,6 @@ export function AgentInlineNote({ textarea.requestRender(); }, [draft, draftVisibleRows]); - const updateDraftLineCountHint = (nextLineCount: number) => { - flushSync(() => { - setDraftLineCountHint(nextLineCount); - }); - }; - const lines = agentInlineNoteBodyLines(annotation, contentWidth); const savedTitleText = fitText( ` ${titleText} `, @@ -354,37 +361,21 @@ export function AgentInlineNote({ initialValue={draft.body} placeholder="Write a note…" focused={draft.focused} + wrapMode="char" backgroundColor={theme.panel} textColor={theme.text} focusedBackgroundColor={theme.panel} focusedTextColor={theme.text} keyBindings={[{ name: "j", ctrl: true, action: "newline" }]} onContentChange={() => { - const textarea = textareaRef.current; - const nextBody = textarea?.plainText ?? ""; - updateDraftLineCountHint( - Math.max( - draftVisualLineCount(nextBody, draftContentWidth), - textarea?.virtualLineCount ?? 0, - ), - ); + const nextBody = textareaRef.current?.plainText ?? ""; + // Deliberately not flushSync: burst input (chunked paste, key + // repeat) emits many content changes in one stack, and forcing a + // synchronous render per change nests renders until React hits + // its nested-update limit. Batched propagation commits before + // the next frame, so the resize still lands with the edit. draft.onInput(nextBody); }} - onKeyDown={(key) => { - // Escape (cancel) and Ctrl-S (save) never reach this textarea: - // the global key chain owns and consumes them while the draft is - // focused (`useAppKeyboardShortcuts`, focus area "note"). Only - // sizing bookkeeping for keys the editor itself handles lives - // here. - if (isNewlineKey(key)) { - updateDraftLineCountHint( - draftVisualLineCount( - textareaRef.current?.plainText ?? draft.body, - draftContentWidth, - ) + 1, - ); - } - }} /> { const saveLineIndex = lines.findIndex( (line) => line.includes("Save (^S)") && line.includes("Cancel (Esc)"), ); - expect(lines.some((line) => line.includes("soft"))).toBe(true); - expect(lines.some((line) => line.includes("wrap inside"))).toBe(true); + expect(lines.some((line) => line.includes(body.slice(0, 10)))).toBe(true); + expect(lines.some((line) => line.includes(body.slice(-10)))).toBe(true); expect(saveLineIndex).toBeGreaterThan(5); }); diff --git a/test/pty/notes.test.ts b/test/pty/notes.test.ts index dc4e0ecb2..6fe538097 100644 --- a/test/pty/notes.test.ts +++ b/test/pty/notes.test.ts @@ -145,6 +145,42 @@ describe("PTY notes", () => { } }); + test("CJK draft notes wrap instead of scrolling out of view in a real PTY", async () => { + const fixture = harness.createLongWrapFilePair(); + const session = await harness.launchHunk({ + args: ["diff", fixture.before, fixture.after, "--mode", "split"], + cols: 120, + rows: 24, + }); + + try { + await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { + timeout: 15_000, + }); + + await session.press("c"); + await session.waitForText(/Draft note/, { timeout: 5_000 }); + + // 48 characters, 86 cells: past the wrap point of any reasonable + // composer width, and long enough that a code-unit row estimate would + // keep the composer at one row. + const body = + "这个包主要是为了在普通的chatmodel外面包一层,把工具调用的编号统一转换后再返回给调用方使用"; + await session.type(body); + + const draft = await session.waitForText(/这个包主要是为了/, { timeout: 5_000 }); + expect(draft).toContain(body.slice(0, 10)); + expect(draft).toContain(body.slice(-6)); + + await session.type("\x13"); + const savedNote = await session.waitForText(/Your note/, { timeout: 5_000 }); + expect(savedNote).toContain(body.slice(0, 10)); + expect(savedNote).toContain(body.slice(-6)); + } finally { + session.close(); + } + }); + test("rapid Ctrl+S presses save a draft note exactly once", async () => { const fixture = harness.createLongWrapFilePair(); const session = await harness.launchHunk({ From d67a927d578b84d2705ce2b82e5223b28c6b9a72 Mon Sep 17 00:00:00 2001 From: IAMLEIzZ Date: Sun, 9 Aug 2026 02:44:33 +0800 Subject: [PATCH 2/2] fix(ui): measure draft note rows through the editor's native buffer --- .../components/panes/AgentInlineNote.test.tsx | 42 +++++++++++- src/ui/components/panes/AgentInlineNote.tsx | 64 +++++++------------ 2 files changed, 63 insertions(+), 43 deletions(-) diff --git a/src/ui/components/panes/AgentInlineNote.test.tsx b/src/ui/components/panes/AgentInlineNote.test.tsx index f3ed3e043..761abdabd 100644 --- a/src/ui/components/panes/AgentInlineNote.test.tsx +++ b/src/ui/components/panes/AgentInlineNote.test.tsx @@ -42,6 +42,8 @@ describe("draftVisualLineCount", () => { ["tab is two cells", "a\tb", 3, 2], ["tab fits wider box", "a\tb", 4, 1], ["tab after content", "aaaaaaaa\taa", 10, 2], + ["emoji flag with combining mark", "HEAD-" + "🇺🇸\u0301".repeat(10) + "-TAIL", 24, 2], + ["bare heart emoji", "❤".repeat(13), 24, 2], ["width clamps to one", "ab", 0, 2], ]; @@ -70,6 +72,8 @@ describe("draftVisualLineCount editor parity", () => { "e\u0301".repeat(12), "a".repeat(10) + "e\u0301", "👨‍👩‍👧".repeat(6), + "HEAD-" + "🇺🇸\u0301".repeat(10) + "-TAIL", + "❤".repeat(13), "a b c", "aaaaaaaaaa ", "aaa\nbbb", @@ -147,11 +151,11 @@ function renderedCardRowCount(frame: string) { return bottom - top + 1; } -function plannedCardHeight(body: string, width: number) { +function plannedCardHeight(body: string, width: number, layout: "split" | "stack" = "split") { return measureAgentInlineNoteHeight({ annotation: draftAnnotation(body), anchorSide: "new", - layout: "split", + layout, width, }); } @@ -220,6 +224,40 @@ describe("AgentInlineNote draft composer", () => { } }); + test("renders drafts with clusters that JS width tables mismeasure", async () => { + // 30 cells by the editor's native width tables; JS tables count 20. + const body = "HEAD-" + "🇺🇸\u0301".repeat(10) + "-TAIL"; + const setup = await testRender( + {}, + onCancel: () => {}, + onSave: () => {}, + }} + />, + { width: 60, height: 30 }, + ); + + try { + await flush(setup); + const frame = setup.captureCharFrame(); + expect(frame).toContain("HEAD-"); + expect(frame).toContain("TAIL"); + expect(renderedCardRowCount(frame)).toBe(plannedCardHeight(body, 34, "stack")); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("grows across hard newlines with wide characters before and after", async () => { const setup = await testRender(, { width: 120, height: 40 }); diff --git a/src/ui/components/panes/AgentInlineNote.tsx b/src/ui/components/panes/AgentInlineNote.tsx index 6a799c3a7..9a30221ba 100644 --- a/src/ui/components/panes/AgentInlineNote.tsx +++ b/src/ui/components/panes/AgentInlineNote.tsx @@ -1,4 +1,9 @@ -import { createTextAttributes, type TextareaRenderable } from "@opentui/core"; +import { + createTextAttributes, + EditBuffer, + EditorView, + type TextareaRenderable, +} from "@opentui/core"; import { useLayoutEffect, useRef, type ReactNode } from "react"; import type { AgentAnnotation, DiffFile, LayoutMode } from "../../../core/types"; import { agentNoteBoxLayout } from "../../lib/agentNoteGeometry"; @@ -6,14 +11,7 @@ import { annotationRangeLabel, reviewNoteSource } from "../../lib/agentAnnotatio import { wrapText } from "../../lib/agentPopover"; import { sanitizeTerminalLine } from "../../../lib/terminalText"; -import { - fitText, - isPrintableAsciiText, - measureClusterWidth, - measureTextWidth, - padText, - textClusters, -} from "../../lib/text"; +import { fitText, measureTextWidth, padText } from "../../lib/text"; import { resolveStmlColor } from "../../lib/stml/colors"; import { layoutStmlCached, type StmlLine, type StmlSpan } from "../../lib/stml/layout"; import type { AppTheme } from "../../themes"; @@ -54,46 +52,30 @@ export function agentInlineNoteMarkupLines( return lines.length > 0 ? lines : null; } -/** Measure one grapheme cluster the way the composer renders it: a tab takes two cells. */ -function draftClusterCells(cluster: string) { - return cluster === "\t" ? 2 : measureClusterWidth(cluster); -} +let draftMeasureView: { buffer: EditBuffer; view: EditorView } | null = null; /** * Count the composer's visual rows for one body at one content width. * - * The textarea wraps by character without splitting a grapheme cluster: a - * cluster that would cross the row boundary moves to the next row whole, so - * at an odd width a wide CJK character leaves one cell unused. This count - * must match the editor exactly: the row-windowed stream plans note heights - * from it before the card mounts, and the editor clamps its wrap count to - * its viewport height, so an undercount would hide rows instead of - * revealing them. + * Measured through the editor's own native buffer because JS width tables + * disagree with it on some clusters (e.g. an emoji flag followed by a + * combining mark). This count must match the editor exactly: the + * row-windowed stream plans note heights from it before the card mounts, + * and the editor clamps its wrap count to its viewport height, so an + * undercount would hide rows instead of revealing them. */ export function draftVisualLineCount(text: string, width: number) { - const usableWidth = Math.max(1, width); - let rows = 0; - - for (const line of text.split("\n")) { - if (isPrintableAsciiText(line)) { - rows += Math.max(1, Math.ceil(line.length / usableWidth)); - continue; - } - - let used = 0; - let lineRows = 1; - for (const cluster of textClusters(line)) { - const cells = draftClusterCells(cluster); - if (used > 0 && used + cells > usableWidth) { - lineRows++; - used = 0; - } - used += cells; - } - rows += lineRows; + if (!draftMeasureView) { + const buffer = EditBuffer.create("unicode"); + const view = EditorView.create(buffer, 1, 1); + view.setWrapMode("char"); + draftMeasureView = { buffer, view }; } - return rows; + const { buffer, view } = draftMeasureView; + view.setViewport(0, 0, Math.max(1, width), 1); + buffer.setText(text); + return view.getTotalVirtualLineCount(); } /** Wrap text while preserving author-entered line breaks in review notes. */