diff --git a/.changeset/draft-note-cell-wrap.md b/.changeset/draft-note-cell-wrap.md new file mode 100644 index 00000000..cb45618c --- /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 b2ab0a80..6f4c6da8 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 00000000..761abdab --- /dev/null +++ b/src/ui/components/panes/AgentInlineNote.test.tsx @@ -0,0 +1,351 @@ +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], + ["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], + ]; + + 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), + "HEAD-" + "🇺🇸\u0301".repeat(10) + "-TAIL", + "❤".repeat(13), + "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, layout: "split" | "stack" = "split") { + return measureAgentInlineNoteHeight({ + annotation: draftAnnotation(body), + anchorSide: "new", + layout, + 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("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 }); + + 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 df0ba151..9a30221b 100644 --- a/src/ui/components/panes/AgentInlineNote.tsx +++ b/src/ui/components/panes/AgentInlineNote.tsx @@ -1,6 +1,10 @@ -import { createTextAttributes, type TextareaRenderable } from "@opentui/core"; -import { flushSync } from "@opentui/react"; -import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +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"; import { annotationRangeLabel, reviewNoteSource } from "../../lib/agentAnnotations"; @@ -48,30 +52,30 @@ export function agentInlineNoteMarkupLines( return lines.length > 0 ? lines : null; } -function draftLineCount(text: string) { - return Math.max(1, text.split("\n").length); -} +let draftMeasureView: { buffer: EditBuffer; view: EditorView } | null = null; -/** Estimate the textarea's wrapped visual row count for a given content width. */ -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), - ); -} +/** + * Count the composer's visual rows for one body at one content width. + * + * 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) { + if (!draftMeasureView) { + const buffer = EditBuffer.create("unicode"); + const view = EditorView.create(buffer, 1, 1); + view.setWrapMode("char"); + draftMeasureView = { buffer, view }; + } -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") - ); + 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. */ @@ -158,13 +162,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 +205,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 +228,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 +343,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 dc4e0ecb..6fe53809 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({