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();