From 5af3dd2ec6d730bad6ea55a20a1b7f2c00f0f5a6 Mon Sep 17 00:00:00 2001 From: yejunbo <692979649@qq.com> Date: Fri, 28 Aug 2026 22:21:21 +0800 Subject: [PATCH 1/3] fix(suggestions): fill ghost suggestion row --- extensions/setup/index.ts | 2 +- extensions/suggestions/src/ui.ts | 36 ++++++++++++++----------- tests/extensions/setup/index.test.ts | 3 ++- tests/extensions/suggestions/ui.test.ts | 35 +++++++++++++++++++----- 4 files changed, 52 insertions(+), 24 deletions(-) diff --git a/extensions/setup/index.ts b/extensions/setup/index.ts index b0f700c4..d004ee4f 100644 --- a/extensions/setup/index.ts +++ b/extensions/setup/index.ts @@ -128,7 +128,7 @@ export function buildInteractiveSetupPrompt(options: { "", "Before asking, briefly explain what can be configured and the practical impact:", "- Capability discovery: explicit is the safe default and keeps OpenPI model tools absent until the user asks for a capability. adaptive is opt-in and keeps only the small openpi_load_tools gateway visible, allowing the model to load Subagents, Workflows, background terminals, structured search, or Session tracking when it judges them useful. Loaded groups remain session-stable, and normal permission, concurrency, and workflow limits still apply.", - "- Next-action suggestions: disabled, or model-generated after a fully settled main-agent run. A suggestion appears as dim inline text on the first row of an empty editor; reserved cells at the row end keep CJK IME preedit from overwriting it. Right accepts it without submitting, and any other editor input dismisses it. Enabling requires an available provider/model and reasoning level and adds one small model call per settled run.", + "- Next-action suggestions: disabled, or model-generated after a fully settled main-agent run. A suggestion appears as dim inline text on the first row of an empty editor and uses the rest of that row except one cell reserved for the hidden hardware cursor. An active CJK IME preedit is drawn from that cell and can briefly cover the suggestion's last cell until the next editor input dismisses it. Right accepts it without submitting, and any other editor input dismisses it. Enabling requires an available provider/model and reasoning level and adds one small model call per settled run.", "- Workflow fan-out: concurrency controls simultaneous agents and resource pressure; max agent calls controls the total capacity of one workflow. Valid ranges are 1-64 and 1-1024.", "- UI: the large header costs vertical space; the custom footer is a declarative dashboard. Presets: powerline (one-line ANSI256 blocks), powerline-mono (one-line high-contrast gray powerline), and compact (one-line plain text); the default is plain with model/context on the left and git/pr/cwd on the right. Style can also be set independently: plain, powerline, powerline-mono. Custom lines are a 2D layout of cwd/model/thinking/context/cache/cost/throughput/git/pr plus at most one flex per line for left/right alignment. Footer metrics use Codicon outline glyphs for model, context, and directory; a Nerd Font renders them as designed while the text stays readable without it. Changes apply immediately in the active TUI session.", "- Operational activity for Subagents, Workflows, and background terminals is core status and always remains visible whenever the custom footer is enabled.", diff --git a/extensions/suggestions/src/ui.ts b/extensions/suggestions/src/ui.ts index 24f9234b..1c656d17 100644 --- a/extensions/suggestions/src/ui.ts +++ b/extensions/suggestions/src/ui.ts @@ -13,9 +13,15 @@ import { } from "../../shared/below-editor-navigation.ts"; const FAKE_CURSOR_PATTERN = /\u001b\[7m \u001b\[(?:0|27)m/; -const IME_PREEDIT_MIN_COLUMNS = 12; -const IME_PREEDIT_MAX_COLUMNS = 32; -const GHOST_MIN_COLUMNS = 8; +/** + * Cells kept after the ghost for the hidden hardware cursor. The terminal owns + * that column and CJK IME preedit is drawn there, so a composition can briefly + * cover the ghost's last cell. Reserving a wide fixed preedit band instead + * costs 16-31% of every idle row, and Pi's editor contract exposes only + * committed input, never an IME composition event, so a wider reservation + * cannot be limited to frames that actually need it. + */ +const HARDWARE_CURSOR_CELLS = 1; export interface SuggestionToken { readonly generation: number; @@ -99,14 +105,14 @@ function ghostGeometry(lines: readonly string[], width: number) { // One terminal cell must remain after the hidden hardware cursor. At // narrower widths, suppress the ghost rather than placing the cursor at the // terminal's out-of-range column width. - if (remaining <= GHOST_MIN_COLUMNS) return undefined; - - const desiredPreedit = Math.min( - IME_PREEDIT_MAX_COLUMNS, - Math.max(IME_PREEDIT_MIN_COLUMNS, Math.floor(width * 0.3)), - ); - const preedit = Math.min(desiredPreedit, remaining - GHOST_MIN_COLUMNS); - return { index, prefix, available: remaining - preedit, preedit }; + if (remaining <= HARDWARE_CURSOR_CELLS) return undefined; + + return { + index, + prefix, + available: remaining - HARDWARE_CURSOR_CELLS, + preedit: HARDWARE_CURSOR_CELLS, + }; } export function renderGhostSuggestion( @@ -124,10 +130,10 @@ export function renderGhostSuggestion( Math.max(0, width - preedit - visibleWidth(content)), ); const rendered = [...lines]; - // CJK IMEs draw uncommitted preedit at the terminal's hidden hardware - // cursor before the editor receives an input event. Keep the visible fake - // cursor and ghost inline, but move that hardware anchor to reserved cells - // at the row end so preedit cannot overwrite the suggestion. + // Keep the visible fake cursor and ghost inline, then park the hidden + // hardware cursor in the single reserved cell at the row end. Terminal-owned + // CJK IME preedit paints from that anchor, so an active composition can + // cover the ghost's final cell until the next editor input dismisses it. rendered[index] = `${content}${padding}${CURSOR_MARKER}${" ".repeat(preedit)}`; return rendered; diff --git a/tests/extensions/setup/index.test.ts b/tests/extensions/setup/index.test.ts index 8883a3c9..e560fbe0 100644 --- a/tests/extensions/setup/index.test.ts +++ b/tests/extensions/setup/index.test.ts @@ -682,7 +682,8 @@ test("builds a model-guided first-run setup prompt with impacts", () => { assert.match(message, /explicit.*adaptive/); assert.match(message, /adaptive.*opt-in/); assert.match(message, /dim inline text on the first row/); - assert.match(message, /reserved cells.*CJK IME preedit/); + assert.match(message, /one cell reserved for the hidden hardware cursor/); + assert.match(message, /CJK IME preedit.*briefly cover/); assert.match(message, /Right accepts it without submitting/); assert.match(message, /concurrency controls simultaneous agents/); assert.match(message, /large header costs vertical space/); diff --git a/tests/extensions/suggestions/ui.test.ts b/tests/extensions/suggestions/ui.test.ts index 612624f3..c893e770 100644 --- a/tests/extensions/suggestions/ui.test.ts +++ b/tests/extensions/suggestions/ui.test.ts @@ -86,7 +86,7 @@ test("latest-wins state rejects stale or non-empty-editor offers", () => { assert.equal(state.isActive(), false); }); -test("renders the ghost on the first row with reserved IME preedit cells", () => { +test("ghost text fills the row except the reserved hardware-cursor cell", () => { const width = 40; const lines = renderGhostSuggestion( ["top", `${CURSOR_MARKER}${FAKE_CURSOR}${" ".repeat(width - 1)}`, "bottom"], @@ -102,14 +102,35 @@ test("renders the ghost on the first row with reserved IME preedit cells", () => ); assert.equal(lines[2], "bottom"); - // The visible fake cursor and ghost stay on the first editor row. The hidden - // hardware cursor moves after them, leaving cells where terminal-owned CJK - // IME preedit can draw without overwriting the suggestion. + // The visible fake cursor and ghost stay on the first editor row. Only the + // single cell holding the hidden hardware cursor follows them, so an idle + // suggestion uses the rest of the row. const markerIndex = lines[1]!.indexOf(CURSOR_MARKER); assert.ok(markerIndex > lines[1]!.indexOf("run the full test suite")); assert.equal( visibleWidth(lines[1]!.slice(markerIndex + CURSOR_MARKER.length)), - 12, + 1, + ); + assert.equal(visibleWidth(lines[1]!), width); +}); + +test("a truncated ghost ends next to the reserved hardware-cursor cell", () => { + const width = 40; + const lines = renderGhostSuggestion( + ["top", `${CURSOR_MARKER}${FAKE_CURSOR}${" ".repeat(width - 1)}`, "bottom"], + width, + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", + (text) => text, + ); + + const markerIndex = lines[1]!.indexOf(CURSOR_MARKER); + const beforeMarker = lines[1]!.slice(0, markerIndex); + assert.ok(beforeMarker.includes("…")); + // No idle padding band survives between the ellipsis and the reserved cell. + assert.equal(visibleWidth(beforeMarker), width - 1); + assert.equal( + visibleWidth(lines[1]!.slice(markerIndex + CURSOR_MARKER.length)), + 1, ); assert.equal(visibleWidth(lines[1]!), width); }); @@ -142,11 +163,11 @@ test("an invisible suggestion never steals Right from a custom editor", () => { assert.equal(state.peek(), undefined); }); -test("an editor too narrow to reserve an IME cell does not steal Right", () => { +test("an editor too narrow to reserve the hardware-cursor cell does not steal Right", () => { const base = new FakeEditor(); const { state, editor } = suggestionEditor({ base }); state.offer(state.begin(), "run tests", true); - editor.render(9); + editor.render(2); editor.handleInput("\u001b[C"); From 4fded2a1b303d0b447c2a41fdd7c31325c06d2e1 Mon Sep 17 00:00:00 2001 From: yejunbo <692979649@qq.com> Date: Sun, 30 Aug 2026 01:07:46 +0800 Subject: [PATCH 2/3] fix(suggestions): restore conservative IME preedit reservation Keep a 12-32 cell band after the ghost so terminal-owned CJK IME preedit cannot cover or truncate the suggestion. Pi's editor contract still has no composition events, so reservation cannot shrink to live IME frames. Add compositor tests that follow Pi TUI hardware-cursor extraction and CJK cell painting, including a 1-cell pad contrast that overwrites the ghost. Live OS IME and host-terminal overlay drawing remain unrun. --- extensions/setup/index.ts | 2 +- extensions/suggestions/src/ui.ts | 46 +++--- tests/extensions/setup/index.test.ts | 3 +- tests/extensions/suggestions/ui.test.ts | 203 +++++++++++++++++++++--- 4 files changed, 207 insertions(+), 47 deletions(-) diff --git a/extensions/setup/index.ts b/extensions/setup/index.ts index d004ee4f..b0f700c4 100644 --- a/extensions/setup/index.ts +++ b/extensions/setup/index.ts @@ -128,7 +128,7 @@ export function buildInteractiveSetupPrompt(options: { "", "Before asking, briefly explain what can be configured and the practical impact:", "- Capability discovery: explicit is the safe default and keeps OpenPI model tools absent until the user asks for a capability. adaptive is opt-in and keeps only the small openpi_load_tools gateway visible, allowing the model to load Subagents, Workflows, background terminals, structured search, or Session tracking when it judges them useful. Loaded groups remain session-stable, and normal permission, concurrency, and workflow limits still apply.", - "- Next-action suggestions: disabled, or model-generated after a fully settled main-agent run. A suggestion appears as dim inline text on the first row of an empty editor and uses the rest of that row except one cell reserved for the hidden hardware cursor. An active CJK IME preedit is drawn from that cell and can briefly cover the suggestion's last cell until the next editor input dismisses it. Right accepts it without submitting, and any other editor input dismisses it. Enabling requires an available provider/model and reasoning level and adds one small model call per settled run.", + "- Next-action suggestions: disabled, or model-generated after a fully settled main-agent run. A suggestion appears as dim inline text on the first row of an empty editor; reserved cells at the row end keep CJK IME preedit from overwriting it. Right accepts it without submitting, and any other editor input dismisses it. Enabling requires an available provider/model and reasoning level and adds one small model call per settled run.", "- Workflow fan-out: concurrency controls simultaneous agents and resource pressure; max agent calls controls the total capacity of one workflow. Valid ranges are 1-64 and 1-1024.", "- UI: the large header costs vertical space; the custom footer is a declarative dashboard. Presets: powerline (one-line ANSI256 blocks), powerline-mono (one-line high-contrast gray powerline), and compact (one-line plain text); the default is plain with model/context on the left and git/pr/cwd on the right. Style can also be set independently: plain, powerline, powerline-mono. Custom lines are a 2D layout of cwd/model/thinking/context/cache/cost/throughput/git/pr plus at most one flex per line for left/right alignment. Footer metrics use Codicon outline glyphs for model, context, and directory; a Nerd Font renders them as designed while the text stays readable without it. Changes apply immediately in the active TUI session.", "- Operational activity for Subagents, Workflows, and background terminals is core status and always remains visible whenever the custom footer is enabled.", diff --git a/extensions/suggestions/src/ui.ts b/extensions/suggestions/src/ui.ts index 1c656d17..56a4d6e7 100644 --- a/extensions/suggestions/src/ui.ts +++ b/extensions/suggestions/src/ui.ts @@ -13,15 +13,13 @@ import { } from "../../shared/below-editor-navigation.ts"; const FAKE_CURSOR_PATTERN = /\u001b\[7m \u001b\[(?:0|27)m/; -/** - * Cells kept after the ghost for the hidden hardware cursor. The terminal owns - * that column and CJK IME preedit is drawn there, so a composition can briefly - * cover the ghost's last cell. Reserving a wide fixed preedit band instead - * costs 16-31% of every idle row, and Pi's editor contract exposes only - * committed input, never an IME composition event, so a wider reservation - * cannot be limited to frames that actually need it. - */ -const HARDWARE_CURSOR_CELLS = 1; +// Conservative idle-row band for terminal-owned CJK IME preedit. Pi's editor +// contract exposes only committed input, never a composition event, so the +// reservation cannot shrink to the frames that actually need it. A 1-cell +// hardware-cursor pad lets long preedit cover or truncate the ghost. +const IME_PREEDIT_MIN_COLUMNS = 12; +const IME_PREEDIT_MAX_COLUMNS = 32; +const GHOST_MIN_COLUMNS = 8; export interface SuggestionToken { readonly generation: number; @@ -102,17 +100,17 @@ function ghostGeometry(lines: readonly string[], width: number) { .replaceAll(CURSOR_MARKER, ""); const prefix = `${beforeCursor}${cursor[0]}`; const remaining = Math.max(0, width - visibleWidth(prefix)); - // One terminal cell must remain after the hidden hardware cursor. At - // narrower widths, suppress the ghost rather than placing the cursor at the - // terminal's out-of-range column width. - if (remaining <= HARDWARE_CURSOR_CELLS) return undefined; - - return { - index, - prefix, - available: remaining - HARDWARE_CURSOR_CELLS, - preedit: HARDWARE_CURSOR_CELLS, - }; + // Keep enough cells for a readable ghost and a conservative IME preedit + // band. At narrower widths, suppress the ghost rather than placing the + // hidden hardware cursor at the terminal's out-of-range column width. + if (remaining <= GHOST_MIN_COLUMNS) return undefined; + + const desiredPreedit = Math.min( + IME_PREEDIT_MAX_COLUMNS, + Math.max(IME_PREEDIT_MIN_COLUMNS, Math.floor(width * 0.3)), + ); + const preedit = Math.min(desiredPreedit, remaining - GHOST_MIN_COLUMNS); + return { index, prefix, available: remaining - preedit, preedit }; } export function renderGhostSuggestion( @@ -130,10 +128,10 @@ export function renderGhostSuggestion( Math.max(0, width - preedit - visibleWidth(content)), ); const rendered = [...lines]; - // Keep the visible fake cursor and ghost inline, then park the hidden - // hardware cursor in the single reserved cell at the row end. Terminal-owned - // CJK IME preedit paints from that anchor, so an active composition can - // cover the ghost's final cell until the next editor input dismisses it. + // CJK IMEs draw uncommitted preedit at the terminal's hidden hardware + // cursor before the editor receives an input event. Keep the visible fake + // cursor and ghost inline, but move that hardware anchor to reserved cells + // at the row end so preedit cannot overwrite the suggestion. rendered[index] = `${content}${padding}${CURSOR_MARKER}${" ".repeat(preedit)}`; return rendered; diff --git a/tests/extensions/setup/index.test.ts b/tests/extensions/setup/index.test.ts index e560fbe0..8883a3c9 100644 --- a/tests/extensions/setup/index.test.ts +++ b/tests/extensions/setup/index.test.ts @@ -682,8 +682,7 @@ test("builds a model-guided first-run setup prompt with impacts", () => { assert.match(message, /explicit.*adaptive/); assert.match(message, /adaptive.*opt-in/); assert.match(message, /dim inline text on the first row/); - assert.match(message, /one cell reserved for the hidden hardware cursor/); - assert.match(message, /CJK IME preedit.*briefly cover/); + assert.match(message, /reserved cells.*CJK IME preedit/); assert.match(message, /Right accepts it without submitting/); assert.match(message, /concurrency controls simultaneous agents/); assert.match(message, /large header costs vertical space/); diff --git a/tests/extensions/suggestions/ui.test.ts b/tests/extensions/suggestions/ui.test.ts index c893e770..7ff42e7f 100644 --- a/tests/extensions/suggestions/ui.test.ts +++ b/tests/extensions/suggestions/ui.test.ts @@ -2,7 +2,12 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { KeybindingsManager } from "@earendil-works/pi-coding-agent"; import type { EditorComponent } from "@earendil-works/pi-tui"; -import { CURSOR_MARKER, visibleWidth } from "@earendil-works/pi-tui"; +import { + CURSOR_MARKER, + stripTerminalSequences, + truncateToWidth, + visibleWidth, +} from "@earendil-works/pi-tui"; import { BelowEditorNavigationEditor, BelowEditorStripState, @@ -86,10 +91,99 @@ test("latest-wins state rejects stale or non-empty-editor offers", () => { assert.equal(state.isActive(), false); }); -test("ghost text fills the row except the reserved hardware-cursor cell", () => { +function editorRow(width: number) { + return `${CURSOR_MARKER}${FAKE_CURSOR}${" ".repeat(width - 1)}`; +} + +function reservedPreeditCells(row: string) { + const markerIndex = row.indexOf(CURSOR_MARKER); + assert.ok(markerIndex >= 0); + return visibleWidth(row.slice(markerIndex + CURSOR_MARKER.length)); +} + +const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +/** + * Same contract as Pi TUI `TuiBase.extractCursorPosition`: find CURSOR_MARKER + * in the visible viewport, take visibleWidth(before) as the hardware-cursor + * column, then strip the APC marker before the terminal paints. + */ +function extractHardwareCursor(lines: readonly string[], height: number) { + const viewportTop = Math.max(0, lines.length - height); + const next = [...lines]; + for (let row = next.length - 1; row >= viewportTop; row--) { + const line = next[row]!; + const markerIndex = line.indexOf(CURSOR_MARKER); + if (markerIndex === -1) continue; + const col = visibleWidth(line.slice(0, markerIndex)); + next[row] = + line.slice(0, markerIndex) + + line.slice(markerIndex + CURSOR_MARKER.length); + return { lines: next, row, col }; + } + return undefined; +} + +function paintVisibleCells(line: string, width: number) { + const cells = Array.from({ length: width }, () => " "); + let col = 0; + for (const { segment } of graphemes.segment(stripTerminalSequences(line))) { + const w = visibleWidth(segment); + if (w <= 0) continue; + if (col >= width) break; + if (col + w > width) { + // Wide glyphs that do not fit the last columns shift left, covering the + // preceding cell. That is how a 1-cell pad loses the ghost's last char. + col = Math.max(0, width - w); + } + cells[col] = segment; + for (let i = 1; i < w && col + i < width; i++) cells[col + i] = ""; + col += w; + } + return cells; +} + +/** + * Terminal compositor: park the hardware cursor where Pi TUI would, then paint + * IME preedit from that cell. CJK graphemes occupy two cells; a glyph that + * cannot fit at the row end clamps left and overwrites earlier cells. + */ +function composeImePreedit( + lines: readonly string[], + width: number, + preedit: string, +) { + const cursor = extractHardwareCursor(lines, lines.length); + assert.ok(cursor); + const row = paintVisibleCells(cursor.lines[cursor.row]!, width); + const ghostBefore = row.slice(0, cursor.col).map((cell) => cell); + let col = cursor.col; + let overwrittenGhostCells = 0; + for (const { segment } of graphemes.segment(preedit)) { + const w = visibleWidth(segment); + if (w <= 0) continue; + if (col + w > width) col = Math.max(0, width - w); + for (let i = 0; i < w && col + i < width; i++) { + if (col + i < cursor.col && ghostBefore[col + i] !== " ") { + overwrittenGhostCells += 1; + } + } + row[col] = segment; + for (let i = 1; i < w && col + i < width; i++) row[col + i] = ""; + col += w; + } + return { + hardwareCol: cursor.col, + ghost: ghostBefore.join(""), + overwrittenGhostCells, + cells: row, + }; +} + +test("renders the ghost on the first row with reserved IME preedit cells", () => { const width = 40; const lines = renderGhostSuggestion( - ["top", `${CURSOR_MARKER}${FAKE_CURSOR}${" ".repeat(width - 1)}`, "bottom"], + ["top", editorRow(width), "bottom"], width, "run the full test suite", (text) => `\u001b[2m${text}\u001b[22m`, @@ -102,22 +196,95 @@ test("ghost text fills the row except the reserved hardware-cursor cell", () => ); assert.equal(lines[2], "bottom"); - // The visible fake cursor and ghost stay on the first editor row. Only the - // single cell holding the hidden hardware cursor follows them, so an idle - // suggestion uses the rest of the row. + // The visible fake cursor and ghost stay on the first editor row. The hidden + // hardware cursor moves after them, leaving cells where terminal-owned CJK + // IME preedit can draw without overwriting the suggestion. const markerIndex = lines[1]!.indexOf(CURSOR_MARKER); assert.ok(markerIndex > lines[1]!.indexOf("run the full test suite")); - assert.equal( - visibleWidth(lines[1]!.slice(markerIndex + CURSOR_MARKER.length)), - 1, - ); + assert.equal(reservedPreeditCells(lines[1]!), 12); assert.equal(visibleWidth(lines[1]!), width); }); -test("a truncated ghost ends next to the reserved hardware-cursor cell", () => { +test("IME preedit reservation scales with terminal width up to 32 cells", () => { + const cases = [ + { width: 40, preedit: 12 }, + { width: 80, preedit: 24 }, + { width: 120, preedit: 32 }, + { width: 200, preedit: 32 }, + ]; + for (const { width, preedit } of cases) { + const lines = renderGhostSuggestion( + ["top", editorRow(width), "bottom"], + width, + "run the full test suite", + (text) => text, + ); + assert.equal(reservedPreeditCells(lines[1]!), preedit, `width ${width}`); + assert.equal(visibleWidth(lines[1]!), width); + } +}); + +test("Pi TUI parks the hardware cursor at the start of the reserved IME band", () => { + const width = 40; + const lines = renderGhostSuggestion( + ["top", editorRow(width), "bottom"], + width, + "run tests", + (text) => text, + ); + const cursor = extractHardwareCursor(lines, lines.length); + assert.ok(cursor); + assert.equal(cursor.row, 1); + assert.equal(cursor.col, width - 12); + assert.equal(cursor.lines[1]!.includes(CURSOR_MARKER), false); +}); + +test("a long CJK IME preedit stays inside the reserved band", () => { + const width = 80; + const lines = renderGhostSuggestion( + ["top", editorRow(width), "bottom"], + width, + "run the full test suite", + (text) => text, + ); + // 12 CJK syllables → 24 cells, matching width 80's 30% reservation. + const composed = composeImePreedit(lines, width, "にほんごにほんごにほんご"); + assert.equal(composed.hardwareCol, width - 24); + assert.equal(composed.overwrittenGhostCells, 0); + assert.match(composed.ghost, /run the full test suite/); + assert.equal(composed.ghost.includes("にほん"), false); +}); + +test("a 1-cell hardware pad lets a wide CJK preedit cover the ghost", () => { + const width = 40; + const ghost = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const conservative = renderGhostSuggestion( + ["top", editorRow(width), "bottom"], + width, + ghost, + (text) => text, + ); + // Rejected 1-cell layout: fill the row except one hardware-cursor cell. + const oneCellGhost = truncateToWidth(ghost, width - 2, "…"); + const oneCellPad = `${FAKE_CURSOR}${oneCellGhost}${CURSOR_MARKER} `; + assert.equal(visibleWidth(oneCellPad), width); + + const preedit = "漢字"; // 4 cells; a 1-cell pad must clamp left. + const safe = composeImePreedit(conservative, width, preedit); + const unsafe = composeImePreedit( + [conservative[0]!, oneCellPad, conservative[2]!], + width, + preedit, + ); + assert.equal(safe.overwrittenGhostCells, 0); + assert.ok(unsafe.overwrittenGhostCells > 0); + assert.ok(unsafe.ghost.includes("…")); +}); + +test("a truncated ghost still leaves the conservative IME preedit band", () => { const width = 40; const lines = renderGhostSuggestion( - ["top", `${CURSOR_MARKER}${FAKE_CURSOR}${" ".repeat(width - 1)}`, "bottom"], + ["top", editorRow(width), "bottom"], width, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", (text) => text, @@ -126,12 +293,8 @@ test("a truncated ghost ends next to the reserved hardware-cursor cell", () => { const markerIndex = lines[1]!.indexOf(CURSOR_MARKER); const beforeMarker = lines[1]!.slice(0, markerIndex); assert.ok(beforeMarker.includes("…")); - // No idle padding band survives between the ellipsis and the reserved cell. - assert.equal(visibleWidth(beforeMarker), width - 1); - assert.equal( - visibleWidth(lines[1]!.slice(markerIndex + CURSOR_MARKER.length)), - 1, - ); + assert.equal(visibleWidth(beforeMarker), width - 12); + assert.equal(reservedPreeditCells(lines[1]!), 12); assert.equal(visibleWidth(lines[1]!), width); }); @@ -163,11 +326,11 @@ test("an invisible suggestion never steals Right from a custom editor", () => { assert.equal(state.peek(), undefined); }); -test("an editor too narrow to reserve the hardware-cursor cell does not steal Right", () => { +test("an editor too narrow to reserve an IME cell does not steal Right", () => { const base = new FakeEditor(); const { state, editor } = suggestionEditor({ base }); state.offer(state.begin(), "run tests", true); - editor.render(2); + editor.render(9); editor.handleInput("\u001b[C"); From 24e323507291243df2951a023a199e3f03bc87b1 Mon Sep 17 00:00:00 2001 From: QuinnWan <144975606+somewan820@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:24:49 +0800 Subject: [PATCH 3/3] fix(suggestions): require minimum IME reservation --- extensions/suggestions/src/ui.ts | 4 +++- tests/extensions/suggestions/ui.test.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/extensions/suggestions/src/ui.ts b/extensions/suggestions/src/ui.ts index 56a4d6e7..06a28503 100644 --- a/extensions/suggestions/src/ui.ts +++ b/extensions/suggestions/src/ui.ts @@ -103,7 +103,9 @@ function ghostGeometry(lines: readonly string[], width: number) { // Keep enough cells for a readable ghost and a conservative IME preedit // band. At narrower widths, suppress the ghost rather than placing the // hidden hardware cursor at the terminal's out-of-range column width. - if (remaining <= GHOST_MIN_COLUMNS) return undefined; + if (remaining < GHOST_MIN_COLUMNS + IME_PREEDIT_MIN_COLUMNS) { + return undefined; + } const desiredPreedit = Math.min( IME_PREEDIT_MAX_COLUMNS, diff --git a/tests/extensions/suggestions/ui.test.ts b/tests/extensions/suggestions/ui.test.ts index 7ff42e7f..f2ce1bba 100644 --- a/tests/extensions/suggestions/ui.test.ts +++ b/tests/extensions/suggestions/ui.test.ts @@ -339,6 +339,16 @@ test("an editor too narrow to reserve an IME cell does not steal Right", () => { assert.equal(state.peek(), undefined); }); +test("suppresses ghost when the minimum IME band cannot fit", () => { + const base = new FakeEditor(); + const { state, editor } = suggestionEditor({ base }); + state.offer(state.begin(), "run tests", true); + + assert.equal(editor.render(20)[1], `${FAKE_CURSOR}${" ".repeat(19)}`); + assert.notEqual(editor.render(21)[1], base.render(21)[1]); + assert.match(editor.render(21)[1]!, /run tes/); +}); + test("supports a custom editor cursor with a selective reverse reset", () => { const selectiveCursor = "\u001b[7m \u001b[27m"; const lines = renderGhostSuggestion(