From 487ade8bac1cb224875e260aa307e1428edc5df8 Mon Sep 17 00:00:00 2001 From: "kylin1020@outlook.com" Date: Wed, 23 Sep 2026 11:36:28 +0800 Subject: [PATCH] fix(cli): hide the composer hint while an IME composes The composer hint is drawn whenever the editor's buffer is empty. An IME does not put its preedit in that buffer: the terminal holds it and paints it at the cursor itself. So while pinyin is being composed the buffer is still empty, the editor keeps rewriting the hint into the cells the terminal is also drawing the composing text into, and the two layers alternate on the same cells. Visually the hint does not hide and the row flickers. No terminal reports composition start or end to the application, so the suppression has to be inferred. A printable keystroke that reaches the editor and leaves the buffer empty is exactly that: text the editor did not consume is still with the terminal. Such a keystroke hides the hint for 500ms and re-arms on the next one, so a whole composition stays covered; committing into the buffer, or abandoning the composition and letting the timer expire, brings the hint back. Keystrokes that type nothing (arrows, Escape, Ctrl chords) do not arm it, and the bash-mode hints follow a typed `!` prefix, so they are unaffected. Covered by four cases in step-editor.test.ts: a composition stays hidden across keystrokes and the hint returns on the deadline, committed text and programmatic fills clear the state, non-composing keystrokes leave the hint alone, and the bash-mode hint survives the idle suppression. Co-Authored-By: Claude --- apps/cli/src/ui/view/editor/step-editor.ts | 92 ++++++++++++++++++++++ apps/cli/test/step-editor.test.ts | 79 +++++++++++++++++++ 2 files changed, 171 insertions(+) diff --git a/apps/cli/src/ui/view/editor/step-editor.ts b/apps/cli/src/ui/view/editor/step-editor.ts index 592341ad..dd4c0697 100644 --- a/apps/cli/src/ui/view/editor/step-editor.ts +++ b/apps/cli/src/ui/view/editor/step-editor.ts @@ -35,6 +35,37 @@ const STEP_EDITOR_CONTENT_INSET = " "; */ const STEP_EDITOR_PROMPT_MARKER = "❯ "; +/** + * How long a keystroke that leaves the buffer empty suppresses the hint. + * + * The IME (pinyin, kana, ...) keeps the text it is composing in the terminal, + * not in the editor, so the buffer really is empty while a composition is in + * progress. The terminal paints that preedit over the cells the editor just + * wrote, and every keystroke makes the editor rewrite them with the hint. Both + * layers draw the same cell, so the hint and the composing text alternate: + * the flicker. Hiding the hint for the shortest window that covers the gaps + * between keystrokes keeps the terminal's cells to itself until the composition + * either commits into the buffer or is abandoned. + */ +const STEP_EDITOR_COMPOSING_HINT_SUPPRESS_MS = 500; + +/** + * Whether a keystroke is one the editor would insert text for. + * + * A printable keystroke that leaves the buffer empty was not consumed here, so + * the text it carried is still with the terminal. Anything that is not + * printable (arrows, Ctrl chords, Escape, function keys, paste frames) types + * nothing and must not open a suppression window. + */ +function isComposableKeystroke(data: string): boolean { + if (data.length === 0) return false; + for (const char of data) { + const code = char.codePointAt(0) ?? 0; + if (code < 32 || code === 0x7f) return false; + } + return true; +} + /** A frame this narrow is less useful than pi-tui's compact native editor. */ const STEP_EDITOR_MIN_FRAME_WIDTH = 8; @@ -96,6 +127,13 @@ export class StepEditor extends CustomEditor { private keywordCount = 0; private shimmerFrame = -1; private shimmerTimer?: ReturnType; + /** + * Whether an in-progress composition is keeping the hint off the screen. + * Only meaningful while the buffer is empty: with text in it the hint is + * hidden anyway. See {@link STEP_EDITOR_COMPOSING_HINT_SUPPRESS_MS}. + */ + private hintSuppressed = false; + private hintRestoreTimer?: ReturnType; constructor( tui: TUI, @@ -147,12 +185,16 @@ export class StepEditor extends CustomEditor { } override handleInput(data: string): void { + this.noteCompositionKeystroke(data); super.handleInput(data); this.updateHighlights(); } override setText(text: string): void { super.setText(text); + // Only text disproves a composition. A programmatic clear can land + // between two keystrokes of one, so it leaves the window running. + if (text.length !== 0) this.clearHintSuppression(); this.updateHighlights(); } @@ -162,6 +204,51 @@ export class StepEditor extends CustomEditor { this.shimmerFrame = -1; } + /** + * Record a keystroke that arrived while the buffer was empty and carried + * text the editor has not committed. That is the only composition evidence + * available here: no terminal reports composition start/end to the + * application, and the preedit an IME draws never reaches stdin, so the + * buffer stays empty until the composition commits. Plain typing fills the + * buffer on the first printable key, which ends the suppression by itself. + */ + private noteCompositionKeystroke(data: string): void { + if (this.getText().length !== 0) { + this.clearHintSuppression(); + return; + } + if (!isComposableKeystroke(data)) return; + this.hintSuppressed = true; + // The window is the timer, not a clock comparison. Re-arming slides the + // deadline forward across the gaps between one composition's keystrokes. + this.scheduleHintRestore(); + } + + /** + * Whether the empty composer should keep its hint out of the terminal's + * way. A blurred editor never composes and cannot be repainted by the + * restore timer, so it always shows its hint. + */ + private isHintSuppressed(): boolean { + return this.focused && this.hintSuppressed; + } + + private clearHintSuppression(): void { + if (this.hintRestoreTimer) clearTimeout(this.hintRestoreTimer); + this.hintRestoreTimer = undefined; + this.hintSuppressed = false; + } + + private scheduleHintRestore(): void { + if (this.hintRestoreTimer) clearTimeout(this.hintRestoreTimer); + this.hintRestoreTimer = setTimeout(() => { + this.hintRestoreTimer = undefined; + this.hintSuppressed = false; + if (this.focused) this.tui.requestRender(); + }, STEP_EDITOR_COMPOSING_HINT_SUPPRESS_MS); + this.hintRestoreTimer.unref?.(); + } + private updateHighlights(): void { const text = this.getText(); if (!this.focused) this.dispose(); @@ -259,6 +346,11 @@ export class StepEditor extends CustomEditor { if ((text.length !== 0 && !bashHint) || lines.length < 2) { return lines; } + // An empty composer is the only row an in-progress composition can + // collide with; `!`/`!!` already holds typed text, so its hint stays. + if (!bashHint && this.isHintSuppressed()) { + return lines; + } const contentRow = lines[1] ?? ""; const trailingSpaces = /( *)$/.exec(contentRow)?.[1]?.length ?? 0; diff --git a/apps/cli/test/step-editor.test.ts b/apps/cli/test/step-editor.test.ts index 8da85a02..0227c0a5 100644 --- a/apps/cli/test/step-editor.test.ts +++ b/apps/cli/test/step-editor.test.ts @@ -128,6 +128,85 @@ describe("StepEditor", () => { for (const line of lines) expect(visibleWidth(line)).toBe(80); }); + it("hides the hint while an IME is composing into an empty composer", () => { + vi.useFakeTimers(); + initTheme("step-blue"); + const editor = createEditor(); + editor.focused = true; + expect(editor.render(80)[1]).toContain(STEP_EDITOR_PLACEHOLDER); + + // Pinyin: the terminal holds the preedit, so the buffer the editor sees + // stays empty while each keystroke repaints the region the hint was just + // written into. Re-arming on every keystroke keeps a whole composition + // covered rather than only its first stroke. + for (let index = 0; index < 6; index += 1) { + // Every keystroke of the composition reaches the editor with the + // buffer still empty: the preedit is the terminal's, so nothing the + // editor can see has been typed yet. + expect(editor.getText()).toBe(""); + editor.handleInput("z"); + editor.setText(""); + vi.advanceTimersByTime(80); + expect(editor.render(80)[1]).not.toContain(STEP_EDITOR_PLACEHOLDER); + } + + // Abandoning the composition restores the hint on a deadline, without + // waiting for unrelated input to trigger the next frame. + expect(editor.getText()).toBe(""); + vi.advanceTimersByTime(500); + const restored = editor.render(80); + expect(restored[1]).toContain(STEP_EDITOR_PLACEHOLDER); + for (const line of restored) expect(visibleWidth(line)).toBe(80); + }); + + it("stops hiding the hint once composed text reaches the buffer", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + initTheme("step-blue"); + const editor = createEditor(); + editor.focused = true; + + editor.handleInput("z"); + expect(editor.render(80)[1]).not.toContain(STEP_EDITOR_PLACEHOLDER); + expect(editor.getText()).toBe("z"); + // A commit or a programmatic fill is not a composition any more, so no + // suppressed state may outlive it. + editor.setText("这是拼音字符"); + editor.setText(""); + expect(editor.render(80)[1]).toContain(STEP_EDITOR_PLACEHOLDER); + expect(vi.getTimerCount()).toBe(0); + }); + + it("keeps the hint for keystrokes that compose nothing", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + initTheme("step-blue"); + const editor = createEditor(); + editor.focused = true; + + // Arrows, Escape, and Ctrl chords type nothing, so they must not look + // like a composition starting. + for (const key of ["\x1b[D", "\x1b[A", "\x1b", "\x01"]) { + editor.handleInput(key); + vi.advanceTimersByTime(20); + expect(editor.render(80)[1]).toContain(STEP_EDITOR_PLACEHOLDER); + } + }); + + it("keeps the bash-mode hint while the prefix suppresses the idle hint", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + initTheme("step-blue"); + const editor = createEditor(); + editor.focused = true; + editor.handleInput("z"); + editor.setText("!"); + + const lines = editor.render(80); + expect(lines[1]).toContain("run a shell command (Esc to exit)"); + expect(lines[1]).not.toContain(STEP_EDITOR_PLACEHOLDER); + }); + it("marks the first row and aligns wrapped rows to the same content column", () => { initTheme("step-blue"); const editor = createEditor();